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
@@ -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()));new file mode 100644@@ -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());+ }+}@@ -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;@@ -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;@@ -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);@@ -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 |