diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3f95fa9b8..3a5430377 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)", + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)", has_doc, auto_fill ), @@ -1403,6 +1403,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "alternate" => crate::fill::FillMode::Alternate, "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, + "extend" => crate::fill::FillMode::Extend, _ => crate::fill::FillMode::Series, }; if target.count() > 5_000_000 || src.count() > 5_000_000 { diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..29a6f3f9f 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -19,7 +19,17 @@ pub enum FillMode { Alternate, Copy, Formats, + /// Fill Without Formatting: extends like a plain drag, the targets keep their formats. ValuesOnly, + /// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up. + Extend, +} + +impl FillMode { + /// The modes that continue the source's series rather than copy it. + fn extends_series(self) -> bool { + matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly) + } } /// Built-in custom lists. @@ -67,15 +77,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F cols.into_iter().map(|c| CellRef::new(lane, c)).collect() }; let fmt = |c: &(CellRef, Option)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str(); - let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) { + let pattern = if mode.extends_series() || mode == FillMode::Alternate { let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default()); - if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected } + 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 = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 { + let per_cell: Vec = 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() @@ -93,7 +106,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F writes.push((*t, Some(existing))); continue; } - FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => { 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() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..842555bbb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -783,6 +783,43 @@ fn ctrl_drag_fill_swaps_copy_and_series() { assert_eq!(v(&s, "G3"), Value::Empty); } +#[test] +fn fill_series_from_the_right_drag_menu_counts_a_lone_number() { + // Excel's right-drag Fill Series: a lone number counts up, everything else extends as a plain drag. + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A3", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "A2"), v(&s, "A3")], [Value::Number(8.0), Value::Number(9.0)]); + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [3]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::Number(5.0), Value::Number(7.0)]); + s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2", "mode": "extend"})).unwrap(); + assert_eq!(v(&s, "C2"), Value::from("Item 10")); + // A source with no common pattern continues each cell's own series, as a plain drag does. + s.execute("range.setValues", json!({"range": "D1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "D3"), v(&s, "D4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + +#[test] +fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1:A2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "A4", "on": true})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "A3"), v(&s, "A4")], [Value::Number(3.0), Value::Number(4.0)]); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let a4 = d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())); + assert!(a4.font.italic && !a4.font.bold, "A4 keeps its own formats"); + assert!(!d.wb.styles.get(sh.style_id(CellRef::parse("A3").unwrap())).font.bold); + s.execute("range.setValues", json!({"range": "B1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 45854b6af..e3daf1642 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -336,7 +336,7 @@ impl Dialog { Number { key: "step", label: "Step value:" }, Text { key: "stop", label: "Stop value:" }, ], - json!({"direction": "columns", "type": "linear", "dateUnit": "day", "step": 1}), + json!({"direction": if p("direction") == "rows" { "rows" } else { "columns" }, "type": "linear", "dateUnit": "day", "step": 1}), ), "comment" => { let threaded = p("threaded").as_bool().unwrap_or(true); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5dffb3fce..7fe6c30bc 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -78,8 +78,10 @@ pub enum Drag { start: f32, orig: f32, }, + /// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release). Fill { target: Option, + menu: bool, }, /// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze. Freeze { @@ -116,6 +118,8 @@ pub struct GridState { pub context_menu: Option, /// The frame a right-click opened `context_menu` in: that click's own press doesn't close it. pub context_menu_frame: u64, + /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. + pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, @@ -760,7 +764,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); } // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { app.grid.touch_pan = false; @@ -782,6 +786,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { @@ -1731,7 +1736,7 @@ pub(crate) fn paint_selection( p.rect_filled(h, 0.0, t.sel_border); } // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag && range_in_pane(*tr, rows, cols) { let rr = geo.range_rect(sh, *tr); @@ -2225,7 +2230,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } if on_handle(p) { - app.grid.drag = Drag::Fill { target: None }; + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; return; } if on_cur_border(p) && resp.drag_started() { @@ -2360,10 +2365,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & Drag::Freeze { cols, .. } => { app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; } - Drag::Fill { .. } => { + Drag::Fill { menu, .. } => { let src = sel.current(); let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target) }; + app.grid.drag = Drag::Fill { target: Some(target), menu }; scroll_by(app, auto); } Drag::Move { grab, .. } => { @@ -2405,7 +2410,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Whole rows or columns clicked or dragged over by their headers. Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), - Drag::Fill { target: Some(t) } => { + // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is + // spent on the drag so the grid's Esc doesn't also drop the copy. + Drag::Fill { target: Some(t), menu: true } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { let src = sel.current(); if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. @@ -2760,7 +2775,16 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() { + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from). + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + { return; } if ctx.memory(|m| m.focused()).is_none() { @@ -3130,6 +3154,45 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The menu a right-drag of the fill handle opens on release, as in Excel. +fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((src, target, at)) = app.grid.fill_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let items = [ + ("Copy Cells", "copy"), + ("Fill Series", "extend"), + ("Fill Formatting Only", "formats"), + ("Fill Without Formatting", "values"), + ("-", ""), + ("Series…", "ui:series"), + ]; + let lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + /// Used by `EditState` users to keep the active cell visible while pointing. pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { let Some(d) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d8f83da8b..f797d0f37 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1250,6 +1250,7 @@ impl SheetApp { && !ctx.text_edit_focused() && self.grid.context_menu.is_none() && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() && self.grid.filter_menu.is_none() && self.grid.list_picker.is_none() && self.grid.renaming_tab.is_none() diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/fill_handle_right_drag.rs new file mode 100644 index 000000000..9562c1d63 --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_right_drag.rs @@ -0,0 +1,268 @@ +//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill +//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Drag, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0) +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn fills(h: &Harness) -> usize { + h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count() +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn right_drag_opens_the_fill_menu_and_fills_nothing() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + + let menu = h.state().grid.fill_menu.map(|(src, target, _)| (src, target)); + assert_eq!(menu, Some((RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("B1:B6").unwrap()))); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert_eq!(fills(&h), 0); + assert!(h.state().grid.context_menu.is_none(), "not the cell context menu"); + assert_eq!(selection(&h), "B1:B2"); +} + +#[test] +fn left_drag_still_fills_the_series() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(3.0), n(4.0), n(5.0), n(6.0)]); + assert!(h.state().grid.fill_menu.is_none()); +} + +#[test] +fn copy_cells_repeats_the_source() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(selection(&h), "B1:B6"); +} + +#[test] +fn fill_series_counts_up_from_a_lone_number() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Secondary); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +/// B1:B2 = 1, 2 in bold, B4 = 9 in italic. +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_formatting_only_leaves_the_values() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Formatting Only").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [Value::Empty, n(9.0)]); + assert!(bold(&h, "B3") && bold(&h, "B4")); +} + +#[test] +fn fill_without_formatting_extends_the_series_under_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4"), "the source's bold stays behind"); + assert!(italic(&h, "B4")); +} + +#[test] +fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "E1", PointerButton::Secondary); + h.get_by_label("Series…").click(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:E1"); + let dialog = h.state().dialog.as_ref().map(|d| (d.name().to_string(), d.values.get("direction").cloned())); + assert_eq!(dialog, Some(("series".into(), Some(json!("rows"))))); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]); +} + +#[test] +fn escape_mid_right_drag_cancels_it() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + assert!(h.state().session.clipboard.is_some()); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + escape(&mut h); + assert_eq!(h.state().grid.drag, Drag::None, "the dashed outline goes"); + assert!(h.query_by_label("Copy Cells").is_none(), "the menu doesn't flash for the Esc frame"); + release(&mut h, end, PointerButton::Secondary); + + assert!(h.state().grid.fill_menu.is_none()); + assert!(h.state().grid.context_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc cancels the drag, not the copy"); +} + +#[test] +fn arrow_keys_mid_right_drag_leave_the_source_alone() { + let mut h = harness(one_two); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + h.input_mut().events.push(Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + release(&mut h, end, PointerButton::Secondary); + + assert_eq!(selection(&h), "B1:B2"); + assert_eq!(h.state().grid.fill_menu.map(|(src, ..)| src), Some(RangeRef::parse("B1:B2").unwrap())); +} + +#[test] +fn escape_closes_the_menu_without_filling() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.state().grid.fill_menu.is_some()); + escape(&mut h); + h.run_steps(2); + + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc closes the menu, not the copy"); +} + +#[test] +fn right_click_on_the_handle_opens_the_context_menu() { + let mut h = harness(one_two); + let at = corner(&h, "B2"); + press(&mut h, at, PointerButton::Secondary); + release(&mut h, at, PointerButton::Secondary); + + assert!(h.state().grid.context_menu.is_some()); + assert!(h.state().grid.fill_menu.is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2158a1388..25c540361 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -45,7 +45,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) | | 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); right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, 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 | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | @@ -165,6 +165,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. |---|---|---| | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | +| 2026-10-16 | minor | Right-drag on the fill handle opens the fill menu on release: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Esc cancels the drag or closes the menu without filling | | 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) | | 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | | 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |