diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 50a55bda1..173f1bc3a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -102,6 +102,19 @@ pub enum Drag { }, } +/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo +/// another way. It only applies while nothing else changed the workbook (`revision`) and the +/// filled cells are still selected. +#[derive(Clone, Debug, PartialEq)] +pub struct FillTag { + pub src: RangeRef, + pub target: RangeRef, + pub uid: u64, + pub sheet: usize, + pub revision: u64, + pub undo_len: usize, +} + #[derive(Default)] pub struct GridState { pub drag: Drag, @@ -120,6 +133,8 @@ pub struct GridState { 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)>, + /// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells. + pub fill_tag: Option, /// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, @@ -792,6 +807,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { 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); + fill_options_tag(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()) }; @@ -2546,8 +2562,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. let mode = if mods.command { "alternate" } else { "series" }; - if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), } } } @@ -3330,6 +3347,85 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + if !geo.cells.contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in FILL_MODES { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + /// 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 }; @@ -3337,14 +3433,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { 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 items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs new file mode 100644 index 000000000..2cb209fa9 --- /dev/null +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -0,0 +1,211 @@ +//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells, +//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403). + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, 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.mirror_pos(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.screen_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); +} + +/// 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 selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn left_drag_shows_the_auto_fill_options_button() { + let mut h = harness(one_two); + assert!(h.query_by_label("Auto Fill Options").is_none()); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B6"]), [n(3.0), n(6.0)]); + assert!(h.query_by_label("Auto Fill Options").is_some()); +} + +#[test] +fn copy_cells_replaces_the_series_the_drag_made() { + let mut h = harness(one_two); + let steps_before = h.state().session.active().unwrap().undo.len(); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + 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_eq!(selection(&h), "B1:B6"); + assert_eq!(h.state().session.active().unwrap().undo.len(), steps_before + 1, "one fill step, not two"); +} + +#[test] +fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() { + 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::Primary); + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(5.0), n(5.0), n(5.0)]); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + 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)]); +} + +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_without_formatting_keeps_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + 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")); + assert!(italic(&h, "B4")); +} + +#[test] +fn the_button_goes_away_once_the_selection_moves() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + let away = center(&h, "E9"); + h.input_mut().events.push(Event::PointerMoved(away)); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn right_drag_makes_no_button() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn the_button_goes_away_once_the_workbook_changes() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + h.state_mut().session.execute("cell.set", json!({"cell": "D1", "input": "x"})).unwrap(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:B6", "the selection did not move"); + assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); +} + +#[test] +fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { + let mut h = harness(|s| { + one_two(s); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let button = h.get_by_label("Auto Fill Options").rect(); + 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); + assert!(geo.cells.contains_rect(button), "{button:?} outside {:?}", geo.cells); + let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); + assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 129a5fc70..1ac539718 100644 --- 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-22 · **Change:** minor (Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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 @@ -50,8 +50,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); 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 | -| 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 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); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); 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 | | 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. | @@ -176,6 +175,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel |