collective

Browse / github.com/storytold/gridcraft / patch

fix(ui): preview autofill values while dragging the fill handle

[UI Parity] Drag down should show value as tooltip Create a column of values (1, 2, 3). Select the values. Drag down from the right hand corner. More values should appear as a tooltip (4, 5, 6...). At present does not show any new values until you let go.

Status
open
Score
0.00
Adopters
0 other operators
Author
TheRenegadeDev (imported from pull request #2669, open)
Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86
Size
+434 −70 in 6 files
Review
pending
Shared
2026-10-28
Id
378e043c982468279a737050

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/fill.rs crates/engine/tests/it/autofill_preview.rs crates/engine/tests/it/main.rs crates/ui-egui/src/grid.rs crates/ui-egui/tests/it/fill_options_button.rs docs/ui-parity.md

diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs--- a/crates/engine/src/fill.rs+++ b/crates/engine/src/fill.rs@@ -85,26 +85,148 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {         && (target.start.col == src.start.col || target.end.col == src.end.col) } +/// The same axis, direction and write count for a committed fill and its preview.+struct FillShape {+    vertical: bool,+    forward: bool,+    lane_count: u32,+    steps: u32,+}++impl FillShape {+    fn new(src: RangeRef, target: RangeRef) -> Self {+        // Direction: down/up if target extends rows, else right/left.+        let vertical = target.height() != src.height() || target.width() == src.width();+        let forward = if vertical {+            target.end.row > src.end.row || target.start.row == src.start.row+        } else {+            target.end.col > src.end.col || target.start.col == src.start.col+        };+        // Every source lane writes one cell per step past the source: bound that product, not the+        // source and target separately (whole rows into whole columns was ~17 billion writes).+        let (lane_count, steps) = if vertical {+            let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };+            (src.width(), steps)+        } else {+            let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };+            (src.height(), steps)+        };+        Self { vertical, forward, lane_count, steps }+    }+}++/// One source lane and its detected patterns, shared by filling and read-only prediction.+struct FillLane {+    source: Vec<(CellRef, Option<Cell>)>,+    pattern: Pattern,+    per_cell: Vec<Pattern>,+}++impl FillLane {+    fn new(wb: &Workbook, src_cells: Vec<(CellRef, Option<Cell>)>, mode: FillMode) -> Self {+        let fmt = |c: &(CellRef, Option<Cell>)| wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();+        let pattern = if mode.extends_series() || mode == FillMode::Alternate {+            let first_fmt = src_cells.first().map(fmt).unwrap_or_default();+            let dates = match mode {+                FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, wb.date_system, first_fmt),+                _ => None,+            };+            let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &wb.custom_lists, wb.date_system, first_fmt));+            match (mode, detected) {+                (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),+                (_, p) => p,+            }+        } else {+            Pattern::Copy+        };+        // A source with no common pattern (`Product 1`, `On backorder`): each cell continues its+        // own series, one step per repeat of the source.+        let per_cell: Vec<Pattern> = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {+            src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &wb.custom_lists, wb.date_system, fmt(c))).collect()+        } else {+            Vec::new()+        };+        Self { source: src_cells, pattern, per_cell }+    }++    fn cell(&self, sh: &gridcraft_model::Sheet, t: CellRef, k: usize, forward: bool, mode: FillMode) -> (CellRef, Cell) {+        let step = k as i64 + 1;+        let idx = k % self.source.len().max(1);+        let src_i = if forward { idx } else { self.source.len() - 1 - idx };+        let (sc, scell) = self.source.get(src_i).cloned().unwrap_or((t, None));+        let mut cell = scell.clone().unwrap_or_default();+        match mode {+            FillMode::Formats => {+                let mut existing = sh.cell(t).cloned().unwrap_or_default();+                existing.style = cell.style;+                return (sc, existing);+            }+            FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {+                let style = if mode == FillMode::ValuesOnly { sh.cell(t).map(|c| c.style).unwrap_or_default() } else { cell.style };+                if let Some(f) = &cell.formula {+                    if let Some(e) = f.expr() {+                        let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);+                        cell.formula = Some(Arc::new(Formula::from_expr(shifted)));+                        cell.value = Value::Empty;+                    }+                } else if let Some(v) = match self.per_cell.get(src_i) {+                    Some(p) => p.value(k as i64 / self.source.len() as i64 + 1, forward, 1),+                    None => self.pattern.value(step, forward, self.source.len()),+                } {+                    cell.value = v;+                }+                cell.style = style;+            }+        }+        (sc, cell)+    }+}++/// Predict one destination cell using the committed fill's patterns and formula adjustment.+/// No workbook, calculation, revision or history state is changed. Work depends only on one+/// source lane (at most 4,096 cells), never on the distance dragged or number of target cells.+/// Oversized sources and ranges beyond the fill command's size limit have no preview.+pub fn preview_cell(wb: &Workbook, sheet: usize, src: RangeRef, target: RangeRef, at: CellRef, mode: FillMode) -> Option<Cell> {+    if src.count() > MAX_FILL_CELLS+        || target.count() > MAX_FILL_CELLS+        || !holds_source_in_corner(src, target)+        || !target.contains(at)+        || src.contains(at)+    {+        return None;+    }+    let FillShape { vertical, forward, lane_count, steps } = FillShape::new(src, target);+    let src_len = if vertical { src.height() } else { src.width() };+    if src_len > 4096 || u64::from(lane_count) * u64::from(steps) > MAX_FILL_CELLS {+        return None;+    }+    // A diagonal target is filled on the engine's selected axis, in the source's lanes only.+    let (lane, offset) = if vertical {+        if at.col < src.start.col || at.col > src.end.col {+            return None;+        }+        (at.col, if forward { at.row.checked_sub(src.end.row)? } else { src.start.row.checked_sub(at.row)? })+    } else {+        if at.row < src.start.row || at.row > src.end.row {+            return None;+        }+        (at.row, if forward { at.col.checked_sub(src.end.col)? } else { src.start.col.checked_sub(at.col)? })+    };+    let k = offset.checked_sub(1)? as usize;+    let sh = wb.sheet(sheet)?;+    let source = (0..src_len)+        .map(|i| {+            let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };+            (c, sh.cell(c).cloned())+        })+        .collect();+    Some(FillLane::new(wb, source, mode).cell(sh, at, k, forward, mode).1)+}+ /// Fills `target` (which contains `src` at one edge) from the pattern in `src`. pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {-    let custom = cx.wb.custom_lists.clone();     let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };-    // Direction: down/up if target extends rows, else right/left.-    let vertical = target.height() != src.height() || target.width() == src.width();-    let forward = if vertical {-        target.end.row > src.end.row || target.start.row == src.start.row-    } else {-        target.end.col > src.end.col || target.start.col == src.start.col-    };-    // Every source lane writes one cell per step past the source: bound that product, not the-    // source and target separately (whole rows into whole columns was ~17 billion writes).-    let (lane_count, steps) = if vertical {-        let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };-        (src.width(), steps)-    } else {-        let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };-        (src.height(), steps)-    };+    let FillShape { vertical, forward, lane_count, steps } = FillShape::new(src, target);     if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {         return Err(crate::EngineError::Other("The fill is too large.".into()));     }@@ -128,57 +250,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F                 if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };             cols.into_iter().map(|c| CellRef::new(lane, c)).collect()         };-        let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();-        let pattern = if mode.extends_series() || mode == FillMode::Alternate {-            let first_fmt = src_cells.first().map(fmt).unwrap_or_default();-            let dates = match mode {-                FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),-                _ => None,-            };-            let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));-            match (mode, detected) {-                (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),-                (_, p) => p,-            }-        } else {-            Pattern::Copy-        };-        // A source with no common pattern (`Product 1`, `On backorder`): each cell continues its-        // own series, one step per repeat of the source.-        let per_cell: Vec<Pattern> = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {-            src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()-        } else {-            Vec::new()-        };+        let lane = FillLane::new(&cx.wb, src_cells, mode);         for (k, t) in targets.iter().enumerate() {-            let step = k as i64 + 1;-            let idx = k % src_cells.len().max(1);-            let src_i = if forward { idx } else { src_cells.len() - 1 - idx };-            let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));-            let mut cell = scell.clone().unwrap_or_default();-            match mode {-                FillMode::Formats => {-                    let mut existing = sh.cell(*t).cloned().unwrap_or_default();-                    existing.style = cell.style;-                    writes.push((*t, Some(existing)));-                    continue;-                }-                FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {-                    let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };-                    if let Some(f) = &cell.formula {-                        if let Some(e) = f.expr() {-                            let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);-                            cell.formula = Some(Arc::new(Formula::from_expr(shifted)));-                            cell.value = Value::Empty;-                        }-                    } else if let Some(v) = match per_cell.get(src_i) {-                        Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),-                        None => pattern.value(step, forward, src_cells.len()),-                    } {-                        cell.value = v;-                    }-                    cell.style = style;-                }+            let (sc, cell) = lane.cell(sh, *t, k, forward, mode);+            if mode == FillMode::Formats {+                writes.push((*t, Some(cell)));+                continue;             }             if let Some(picture) = sh.cell_pictures.get(&sc) {                 pictures.push((*t, picture.clone()));diff --git a/crates/engine/tests/it/autofill_preview.rs b/crates/engine/tests/it/autofill_preview.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/autofill_preview.rs@@ -0,0 +1,83 @@+//! Fill-handle prediction agrees with the committed engine fill without changing state.++use gridcraft_engine::{+    Session,+    core::{CellRef, RangeRef, Value},+    fill::{FillMode, preview_cell},+};++#[test]+fn preview_matches_committed_fill_in_both_directions_and_modes() {+    for (values, source, target, at, mode, expected) in [+        (serde_json::json!([[1], [2], [3]]), "B1:B3", "B1:B6", "B6", "series", "6"),+        (serde_json::json!([[1], [4], [4]]), "B1:B3", "B1:B6", "B6", "series", "9"),+        (serde_json::json!([[1], [2], [3]]), "B4:B6", "B1:B6", "B1", "series", "-2"),+        (serde_json::json!([[1, 2, 3]]), "B2:D2", "B2:G2", "G2", "series", "6"),+        (serde_json::json!([[1, 2, 3]]), "E2:G2", "B2:G2", "B2", "series", "-2"),+        (serde_json::json!([[1], [2], [3]]), "B1:B3", "B1:B5", "B5", "alternate", "2"),+        (serde_json::json!([[5]]), "B1", "B1:B4", "B4", "series", "5"),+        (serde_json::json!([[5]]), "B1", "B1:B4", "B4", "alternate", "8"),+        (serde_json::json!([["Item 007"]]), "B1", "B1:B4", "B4", "series", "Item 010"),+        (serde_json::json!([["Monday"]]), "B1", "B1:B4", "B4", "series", "Thursday"),+        (serde_json::json!([["Product 1"], ["On backorder"]]), "B1:B2", "B1:B5", "B5", "series", "Product 3"),+        (serde_json::json!([["1/31/2026"]]), "B1", "B1:B4", "B4", "series", "2/3/2026"),+        (serde_json::json!([["1/31/2026"], ["3/31/2026"]]), "B1:B2", "B1:B4", "B4", "series", "7/31/2026"),+        (serde_json::json!([["=A1+$A$1"]]), "B1", "B1:B4", "B4", "series", "=A4+$A$1"),+    ] {+        let mut s = Session::new();+        s.new_workbook();+        let src = RangeRef::parse(source).unwrap();+        let target_range = RangeRef::parse(target).unwrap();+        let at = CellRef::parse(at).unwrap();+        s.execute("range.setValues", serde_json::json!({"range": src.start.a1(), "values": values})).unwrap();+        let d = s.doc().unwrap();+        let revision = d.revision;+        let undo = d.undo.len();+        let fill_mode = if mode == "alternate" { FillMode::Alternate } else { FillMode::Series };+        let preview = preview_cell(&d.wb, 0, src, target_range, at, fill_mode).unwrap();+        let text = if preview.formula.is_some() {+            preview.input_text()+        } else {+            gridcraft_engine::display::value_text_as(&d.wb, &preview.value, d.wb.styles.get(preview.style).num_fmt.as_str())+        };+        assert_eq!(text, expected, "{source} -> {target} ({mode})");+        assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().undo.len()), (revision, undo));+        s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target, "mode": mode})).unwrap();+        let committed = s.doc().unwrap().wb.active().unwrap().cell(at).unwrap();+        assert_eq!(preview.formula, committed.formula);+        if preview.formula.is_none() {+            assert_eq!(preview.value, committed.value);+        }+        assert_eq!(preview.style, committed.style);+    }+}++#[test]+fn preview_bounds_work_by_source_lane_instead_of_drag_distance() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("range.setValues", serde_json::json!({"range": "A1", "values": [[1, 10], [2, 20]]})).unwrap();+    let wb = &s.doc().unwrap().wb;+    let src = RangeRef::parse("A1:B2").unwrap();+    let target = RangeRef::parse("A1:B1048576").unwrap();+    let at = CellRef::parse("B1048576").unwrap();+    assert_eq!(preview_cell(wb, 0, src, target, at, FillMode::Series).unwrap().value, Value::number(10_485_760.0));+    let huge_source = RangeRef::parse("A1:A1048575").unwrap();+    assert!(+        preview_cell(wb, 0, huge_source, RangeRef::parse("A1:A1048576").unwrap(), CellRef::parse("A1048576").unwrap(), FillMode::Series).is_none()+    );+    assert!(preview_cell(wb, 0, src, src, src.start, FillMode::Series).is_none());+    assert!(preview_cell(wb, 0, RangeRef::parse("A1:F2").unwrap(), RangeRef::parse("A1:F1048576").unwrap(), at, FillMode::Series).is_none());+}++#[test]+fn preview_rejects_rectangles_that_the_fill_command_refuses() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();+    for (source, target, at) in [("A1:E1", "A1:E1000001", "A1000001"), ("A1:XFD400", "A1:XFD401", "A401")] {+        let (src, target_range, at) = (RangeRef::parse(source).unwrap(), RangeRef::parse(target).unwrap(), CellRef::parse(at).unwrap());+        assert!(preview_cell(&s.doc().unwrap().wb, 0, src, target_range, at, FillMode::Series).is_none(), "{source} -> {target}");+        assert!(s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).is_err());+    }+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -1,5 +1,6 @@ //! The crate's integration tests, one module per file, built as one test binary. +mod autofill_preview; mod autofit_filter_buttons; mod cf_formula_validate; mod chart_area_stacked100;diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -863,6 +863,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {     context_menu(app, ui);     fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu);     fill_options_tag(app, ui, &geo, sh);+    fill_value_preview(app, ui, &geo, sh);     object_menu(app, ui);     error_check_tag(app, ui, &geo, sh);     let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };@@ -2670,7 +2671,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &             }             Drag::Fill { target: Some(t), menu: false } => {                 let src = sel.current();-                if t != src {+                if t != src && !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Escape)) {                     // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.                     let mode = if mods.command { "alternate" } else { "series" };                     match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {@@ -2844,6 +2845,46 @@ fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {     } } +/// The current drag endpoint, predicted by the engine without executing an edit.+fn fill_value_preview(app: &SheetApp, ui: &egui::Ui, geo: &Geo, sh: &Sheet) {+    let Drag::Fill { target: Some(target), menu: false } = app.grid.drag else { return };+    let Some(doc) = app.session.active() else { return };+    let Some(pointer) = ui.ctx().pointer_hover_pos() else { return };+    let pc = geo.cell_at(sh, geo.mirror_pos(pointer));+    let at = CellRef::new(pc.row.clamp(target.start.row, target.end.row), pc.col.clamp(target.start.col, target.end.col));+    let mode = if ui.input(|i| i.modifiers.command) { gridcraft_engine::fill::FillMode::Alternate } else { gridcraft_engine::fill::FillMode::Series };+    let Some(cell) = gridcraft_engine::fill::preview_cell(&doc.wb, doc.wb.active_sheet(), doc.selection.current(), target, at, mode) else { return };+    let style = doc.wb.styles.get(cell.style);+    // Recalculation is deferred until release; show the shifted formula itself.+    let text = if cell.formula.is_some() {+        if style.protection.hidden && sh.is_protected() {+            return;+        }+        crate::formula_locale::display(&cell.input_text(), app.ui.language.formula_locale(), &doc.wb, doc.wb.active_sheet())+    } else {+        gridcraft_engine::display::value_text_as(&doc.wb, &cell.value, style.num_fmt.as_str())+    };+    if text.is_empty() {+        return;+    }+    // Keep long cell contents from covering the grid while dragging.+    let mut chars = text.chars();+    let mut label: String = chars.by_ref().take(128).collect();+    if chars.next().is_some() {+        label.push('…');+    }+    egui::Area::new(egui::Id::unique("fill_value_preview"))+        .order(egui::Order::Tooltip)+        .interactable(false)+        .fixed_pos(pointer + vec2(14.0, 18.0))+        .show(ui.ctx(), |ui| {+            ui.set_max_width(240.0);+            egui::Frame::popup(ui.style()).show(ui, |ui| {+                ui.add(egui::Label::new(label).wrap());+            });+        });+}+ fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef {     let below = pc.row as i64 - src.end.row as i64;     let above = src.start.row as i64 - pc.row as i64;diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs--- a/crates/ui-egui/tests/it/fill_options_button.rs+++ b/crates/ui-egui/tests/it/fill_options_button.rs@@ -91,6 +91,167 @@ fn n(x: f64) -> Value {     Value::Number(x) } +#[test]+fn fill_drag_previews_endpoint_without_changing_the_workbook() {+    let mut h = harness(|s| {+        s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();+        s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();+    });+    let before = h.state().session.active().unwrap();+    let (undo_before, revision_before, dirty_before) = (before.undo.len(), before.revision, before.is_dirty());+    let workbook_before = before.wb.clone();+    let (start, end) = (corner(&h, "B3"), center(&h, "B6"));+    press(&mut h, start, PointerButton::Primary);+    move_to(&mut h, start, end);+    h.run_steps(3);++    assert_eq!(values(&h, &["B4", "B5", "B6"]), [Value::Empty, Value::Empty, Value::Empty]);+    assert_eq!(h.state().session.active().unwrap().undo.len(), undo_before);+    let during = h.state().session.active().unwrap();+    assert_eq!((during.revision, during.is_dirty()), (revision_before, dirty_before));+    assert!(std::sync::Arc::ptr_eq(&workbook_before, &during.wb));+    if let Ok(path) = std::env::var("GRIDCRAFT_FILL_PREVIEW_SCREENSHOT") {+        h.render().unwrap().save(path).unwrap();+    }+    assert!(h.query_by_label("6").is_some(), "the tooltip predicts the value at the drag endpoint");++    release(&mut h, end, PointerButton::Primary);+    assert_eq!(value(&h, "B6"), n(6.0));+    assert!(h.query_by_label("6").is_none(), "the tooltip disappears on release");+    assert!(h.query_by_label("Auto Fill Options").is_some());+    assert_eq!(h.state().session.active().unwrap().undo.len(), undo_before + 1);+}++#[test]+fn escape_cancels_the_fill_preview_without_committing() {+    let mut h = harness(one_two);+    let before = h.state().session.active().unwrap().wb.clone();+    let (start, end) = (corner(&h, "B2"), center(&h, "B6"));+    press(&mut h, start, PointerButton::Primary);+    move_to(&mut h, start, end);+    h.run_steps(3);+    assert!(h.query_by_label("6").is_some());+    h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+    h.run_steps(3);+    release(&mut h, end, PointerButton::Primary);+    assert!(h.query_by_label("6").is_none());+    assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().wb));+    assert!(h.query_by_label("Auto Fill Options").is_none());+}++#[test]+fn escape_also_cancels_a_copy_modifier_drag() {+    let mut h = harness(one_two);+    let before = h.state().session.active().unwrap().wb.clone();+    let (start, end) = (corner(&h, "B2"), center(&h, "B6"));+    press(&mut h, start, PointerButton::Primary);+    move_to(&mut h, start, end);+    let modifiers = Modifiers::COMMAND;+    h.input_mut().events.push(Event::ModifiersChanged(modifiers));+    h.run_steps(3);+    assert!(h.query_by_label("2").is_some());+    h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers });+    h.run_steps(3);+    release(&mut h, end, PointerButton::Primary);+    assert!(h.query_by_label("2").is_none());+    assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().wb));+}++#[test]+fn fill_preview_follows_direction_format_and_copy_modifier() {+    for (source, input, from, to, expected, alternate, rtl) in [+        ("B4:B6", json!([[1], [2], [3]]), "B6", "B1", "-2", false, false),+        ("B2:D2", json!([[1, 2, 3]]), "D2", "G2", "6", false, false),+        ("E2:G2", json!([[1, 2, 3]]), "G2", "B2", "-2", false, false),+        ("B1:B3", json!([[1], [2], [3]]), "B3", "B5", "2", true, false),+        ("B1", json!([[5]]), "B1", "B4", "8", true, false),+        ("B1", json!([["1/31/2026"]]), "B1", "B4", "2/3/2026", false, false),+        ("B1", json!([["Item 007"]]), "B1", "B4", "Item 010", false, false),+        ("B1", json!([["=A1+1"]]), "B1", "B4", "=A4+1", false, false),+        ("B1:B3", json!([[1], [2], [3]]), "B3", "B6", "6", false, true),+    ] {+        let mut h = harness(|s| {+            let start = source.split(':').next().unwrap();+            s.execute("range.setValues", json!({"range": start, "values": input})).unwrap();+            s.execute("selection.set", json!({"range": source})).unwrap();+            if rtl {+                s.execute("view.rightToLeft", json!({"on": true})).unwrap();+            }+        });+        let (start, end) = (corner(&h, from), center(&h, to));+        press(&mut h, start, PointerButton::Primary);+        move_to(&mut h, start, end);+        // Change the modifier during the drag; the tooltip must use the release mode.+        let modifiers = Modifiers { command: alternate, ctrl: alternate, ..Modifiers::NONE };+        h.input_mut().events.push(Event::ModifiersChanged(modifiers));+        h.run_steps(3);+        assert!(h.query_by_label(expected).is_some(), "{source} -> {to}, alternate={alternate}, rtl={rtl}");+        assert_eq!(value(&h, to), Value::Empty);+        h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers });+        h.run_steps(3);+        assert!(h.query_by_label(expected).is_none(), "preview gone after release");+        let d = h.state().session.active().unwrap();+        let sh = d.wb.active().unwrap();+        let cell = CellRef::parse(to).unwrap();+        let actual = if sh.cell(cell).is_some_and(|c| c.formula.is_some()) {+            sh.input_text(cell)+        } else {+            gridcraft_engine::display::cell_text(&d.wb, sh, cell)+        };+        assert_eq!(actual, expected);+    }+}++#[test]+fn fill_preview_keeps_protected_formulas_hidden() {+    for (protected, hidden, target_unlocked) in [(true, true, true), (true, true, false), (false, true, true), (true, false, true)] {+        let mut h = harness(|s| {+            s.execute("cell.set", json!({"cell": "B1", "input": "=A1*1.17"})).unwrap();+            s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"hidden": hidden}}})).unwrap();+            if target_unlocked {+                s.execute("home.formatCells", json!({"range": "B2:B4", "style": {"protection": {"locked": false}}})).unwrap();+            }+            s.execute("selection.set", json!({"cell": "B1"})).unwrap();+            if protected {+                s.execute("review.protectSheet", json!({})).unwrap();+            }+        });+        let (start, end) = (corner(&h, "B1"), center(&h, "B4"));+        press(&mut h, start, PointerButton::Primary);+        move_to(&mut h, start, end);+        h.run_steps(3);+        assert_eq!(+            h.query_by_label("=A4*1.17").is_some(),+            !(protected && hidden),+            "protected={protected}, hidden={hidden}, target_unlocked={target_unlocked}"+        );+        if !protected || target_unlocked {+            release(&mut h, end, PointerButton::Primary);+            let d = h.state().session.active().unwrap();+            let sh = d.wb.active().unwrap();+            assert_eq!(sh.input_text(CellRef::parse("B4").unwrap()), "=A4*1.17");+            assert_eq!(gridcraft_engine::display::formula_hidden(&d.wb, sh, CellRef::parse("B4").unwrap()), protected && hidden);+        }+    }+}++#[test]+fn formula_fill_preview_uses_the_selected_formula_language() {+    let mut h = harness(|s| {+        s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1,1.5)"})).unwrap();+        s.execute("selection.set", json!({"cell": "B1"})).unwrap();+    });+    h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();+    h.run_steps(3);+    let (start, end) = (corner(&h, "B1"), center(&h, "B4"));+    press(&mut h, start, PointerButton::Primary);+    move_to(&mut h, start, end);+    h.run_steps(3);+    assert!(h.query_by_label("=SUMA(A4;1,5)").is_some());+    release(&mut h, end, PointerButton::Primary);+    assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().input_text(CellRef::parse("B4").unwrap()), "=SUM(A4,1.5)");+}+ #[test] fn left_drag_shows_the_auto_fill_options_button() {     let mut h = harness(one_two);diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (fill-handle value preview) · **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@@ -52,7 +52,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |-| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing |+| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); left-drag previews the endpoint's formatted value beside the pointer, using the engine's fill patterns (shifted formula text for formulas), without editing the workbook; previews omit source lanes over 4,096 cells and fills over the command's size limit; right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. |@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | A left-drag of the fill handle previews the endpoint value beside the pointer before release (#2598). Prediction shares the engine's fill patterns, formats dates and numbers, shows shifted formulas in the selected formula language (respecting Hidden on protected sheets), and follows Ctrl/Cmd copy mode. It disappears on release or Escape without changing workbook revision or history; the existing fill menus are unchanged. Preview work is limited to one source lane of at most 4,096 cells, independent of drag distance | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |