From 38a7067ca26aaf884e54bb79cae90b708b974e18 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:59:41 +0100 Subject: [PATCH] Options: highlight the active cell's row, as a global preference On a wide table the eye loses its place following a row from the active cell. File > Options > General gets "Highlight the active cell's row", off by default: a tint across the whole row of the active cell, over the fills and under the text, in Normal and Page Layout views. It is a preference for every sheet and workbook, so nothing goes into the file, the workbook does not become dirty and there is no undo step. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 4 + crates/ui-egui/src/backstage/options.rs | 4 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/grid.rs | 2 + crates/ui-egui/src/grid/active_row.rs | 28 ++++ crates/ui-egui/src/grid/quadrant.rs | 7 + crates/ui-egui/src/grid/show.rs | 14 +- crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/paper_view.rs | 14 +- crates/ui-egui/src/theme.rs | 3 + .../ui-egui/tests/it/active_row_highlight.rs | 124 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 4 +- 14 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/grid/active_row.rs create mode 100644 crates/ui-egui/tests/it/active_row_highlight.rs diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..db657f567 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -191,6 +191,10 @@ impl SheetApp { self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); Ok(json!({"on": self.ui.autocomplete})) } + "options.highlightActiveRow" => { + self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row); + Ok(json!({"on": self.ui.highlight_active_row})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..8d62ae41b 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -14,6 +14,10 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } + let mut highlight_row = app.ui.highlight_active_row; + if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() { + app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row})); + } ui.add_space(18.0); section(ui, lang, "Options|Editing"); let mut autocomplete = app.ui.autocomplete; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..4cf1a77df 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -269,6 +269,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), + ("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..1b92ed0c0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -22,6 +22,8 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>); /// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot). type DetailsCache = ((u64, u64, usize, usize), Option>); +mod active_row; +pub use active_row::active_row_band; mod cell_paint; mod cursor; mod editor; diff --git a/crates/ui-egui/src/grid/active_row.rs b/crates/ui-egui/src/grid/active_row.rs new file mode 100644 index 000000000..89c33dce2 --- /dev/null +++ b/crates/ui-egui/src/grid/active_row.rs @@ -0,0 +1,28 @@ +//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of +//! the active cell, so the eye keeps its place in a wide table. + +use super::geo::Geo; +use super::quadrant::QuadrantPaint; +use super::{Rect, Sheet, pos2}; + +/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside +/// the quadrant or hidden. +pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option { + let height = sh.row_height(row) * geo.z; + if row < rows.0 || row > rows.1 || height <= 0.0 { + return None; + } + let top = geo.y(sh, row); + let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z; + Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height))) +} + +/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it +/// inside that area (text running past its cell has just cleared it). +pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option) { + let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return }; + let part = within.map_or(band, |area| band.intersect(area)); + if part.is_positive() { + q.painter.rect_filled(part, 0.0, q.tokens.active_row); + } +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..84155bf26 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -1,3 +1,4 @@ +use super::active_row::paint_active_row; use super::cell_paint::{ Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, rotated_text_pos, shrink_font, wrapped_cell_text_y, @@ -102,6 +103,8 @@ pub(crate) struct QuadrantPaint<'a> { pub(crate) cols: (u32, u32), pub(crate) cf: &'a mut CfCache, pub(crate) tokens: &'a Tokens, + /// The row to tint, when "Highlight the active cell's row" is on. + pub(crate) active_row: Option, } /// Paint a worksheet quadrant, including backgrounds, borders, and text. @@ -118,6 +121,8 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) { let cells = visible_cells(&quadrant); let looks = paint_backgrounds(&mut quadrant, &cells, &merges); let outside = outside_text_cells(&mut quadrant, &merges); + // Over the fills and under the text and borders. + paint_active_row(&quadrant, None); paint_cells(&mut quadrant, &looks, &outside, &merges); paint_borders(&mut quadrant, &looks, &merges); paint_sparklines(&quadrant, range); @@ -478,9 +483,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option 0.0 { p.rect_filled(extra_l, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_l)); } if extra_r.width() > 0.0 { p.rect_filled(extra_r, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_r)); } } } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..bcf128c17 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -404,7 +404,19 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t }); + let active_row = app.ui.highlight_active_row.then_some(sel.active.row); + paint_quadrant(QuadrantPaint { + painter: &p, + geo, + workbook: wb, + sheet_index: si, + sheet: sh, + rows: *rows, + cols: *cols, + cf, + tokens: t, + active_row, + }); paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols }); } // Freeze lines. diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f72a4cdf1..a0ca6bd16 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -410,6 +410,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|Save" => "Salvataggio", "Options|Editing" => "Modifica", "Show formula bar" => "Mostra barra della formula", + "Highlight the active cell's row" => "Evidenzia la riga della cella attiva", "Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle", "Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica", "Workbook calculation" => "Calcolo della cartella di lavoro", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..78a949d0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,9 @@ pub struct UiState { /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed /// from the one matching entry in the same column. pub autocomplete: bool, + /// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so + /// the eye keeps its place across a wide table. A preference: nothing goes into the file. + pub highlight_active_row: bool, /// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every /// change. A preference like the rest of this struct — it stays on across restarts (#1901). pub autosave: bool, @@ -140,6 +143,7 @@ impl Default for UiState { status_bar: true, animations: true, autocomplete: true, + highlight_active_row: false, autosave: false, recent: vec![], keytip_invoke: None, diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f444b6ac2..114d1e8ca 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -162,7 +162,19 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec egui_kittest::Harness<'static, SheetApp> { + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + crate::common::app(session), + ); + h.run_steps(4); + h +} + +fn book(active: &str) -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": active})).unwrap(); + session +} + +/// The rectangles painted with the highlight's own colour. +fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec { + let tint = Tokens::get(&h.ctx).active_row; + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + Shape::Rect(r) if r.fill == tint => Some(r.rect), + _ => None, + }) + .collect() +} + +#[test] +fn it_is_off_by_default_and_paints_nothing() { + assert!(!UiState::default().highlight_active_row); + let h = harness(book("C5")); + assert!(tinted(&h).is_empty()); +} + +#[test] +fn turned_on_it_tints_the_active_row_across_the_columns() { + let mut h = harness(book("C5")); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true}))); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "{bands:?}"); + 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); + let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap()); + assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom())); + assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]); + assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]); +} + +#[test] +fn the_band_follows_the_active_cell_and_only_the_row() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap(); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}"); + 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_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top()); +} + +#[test] +fn it_applies_to_every_sheet_and_workbook() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another sheet"); + h.state_mut().session.new_workbook(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another workbook"); +} + +#[test] +fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() { + let mut h = harness(book("C5")); + let dirty = h.state().session.active().unwrap().is_dirty(); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles"); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false}))); + h.run_steps(2); + assert!(tinted(&h).is_empty()); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); +} + +#[test] +fn the_preference_survives_a_restart_and_older_settings_read_as_off() { + let state = UiState { highlight_active_row: true, ..UiState::default() }; + let saved = serde_json::to_string(&state).unwrap(); + assert!(serde_json::from_str::(&saved).unwrap().highlight_active_row); + assert!(!serde_json::from_str::("{}").unwrap().highlight_active_row); +} + +#[test] +fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() { + let mut session = book("A1"); + session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap(); + let h = harness(session); + 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_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden"); + assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside"); + assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..df6d47842 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -7,6 +7,7 @@ mod common; mod accounting_fill; +mod active_row_highlight; mod advanced_filter; mod argument_names; mod autosave_persist; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..ca917a571 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-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Highlight the active cell's row, a global preference in File › Options › General; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -54,6 +54,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 | +| Highlight the active cell's row (no Excel equivalent; Focus Cell tints the row and the column) | done (11-03): File › Options › General, off by default; a preference for every sheet and workbook, nothing saved in the file. Tints over the fills and under the text, in Normal and Page Layout views (`options.highlightActiveRow`) | | 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 | | 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 | @@ -237,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |