This commit is contained in:
Brandon Thomas committed 2026-11-03 09:55:36 -05:00
commit 0670337908
4 files changed
+90 -6

No files matched your search

+44 -4
View File
@@ -261,16 +261,55 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
// quote but turned out not to be quoted.
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
// Text is replayed at most as much as the input is long; past that, quotes are text, so
// hostile input full of fields that only look quoted reads in linear time.
let mut replay_budget = text.len();
let mut quoted = false;
let mut at_start = true;
while let Some(c) = chars.next() {
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
let mut raw = String::new();
loop {
let c = match replay.pop_front().or_else(|| chars.next()) {
Some(c) => c,
// A quote that never closes: the field is its text as written.
None if quoted => {
quoted = false;
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
replay.extend(raw.chars().skip(1));
raw.clear();
continue;
}
None => break,
};
if quoted {
raw.push(c);
if c == '"' {
if chars.peek() == Some(&'"') {
let next = replay.front().copied().or_else(|| chars.peek().copied());
if next == Some('"') {
field.push('"');
chars.next();
raw.push('"');
if replay.pop_front().is_none() {
chars.next();
}
} else {
quoted = false;
// A field is quoted only when its closing quote ends it, as Excel reads it:
// `"hi" there` is that text, quotes included, and a tab or line break inside
// it still ends the cell, as if the leading quote weren't there.
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
for ch in rest.into_iter().rev() {
replay.push_front(ch);
}
}
}
} else {
field.push(c);
@@ -278,9 +317,10 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
continue;
}
match c {
'"' if at_start => {
'"' if at_start && replay_budget > 0 => {
quoted = true;
at_start = false;
raw = String::from('"');
}
'\t' => {
row.push(std::mem::take(&mut field));
+41
View File
@@ -307,6 +307,9 @@ fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
}
#[test]
@@ -511,3 +514,41 @@ fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
}
#[test]
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
// tab or line break, and pastes the result back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
let rows = parse_tsv(&text);
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
}
#[test]
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
// never closes), the text is read as written, and the tabs and line breaks inside it still
// separate cells, as they would have without the leading quote.
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
// input's length), and every one is still its own cell.
let hostile = "\"x\t".repeat(50_000);
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
}
+3 -1
View File
@@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
}
first = false;
let t = cell_text(wb, sh, CellRef::new(row, col));
if t.contains(['\t', '\n', '"']) {
// Like Excel, only text with a tab or line break is quoted (its quotes doubled);
// `say "hi"` is copied as it is (#2225).
if t.contains(['\t', '\n', '\r']) {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
out.push('"');
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -238,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) |
| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) |
| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet |
| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into |