diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 4f47cf085..2c28bfba3 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -202,6 +202,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 afe976733..faef32a5b 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -15,6 +15,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 38e86f106..602d5ccdf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -273,6 +273,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 5dd7246b9..4ad692dd2 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 9c5712da7..0469c345f 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); @@ -475,9 +480,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 287bd0bcd..c1c9011e7 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -410,7 +410,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 b14fd2e16..470855d7d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,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, @@ -148,6 +151,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..9db376fbf 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -162,10 +162,26 @@ 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()); +} + +#[test] +fn in_page_layout_the_band_shows_on_a_click_to_add_data_page_too() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("view.pageLayout", json!({})).unwrap(); + h.run_steps(3); + let bands = tinted(&h); + // Each page paints its own part of the row; the faded page paints it again over the fade. + let repeated = bands.iter().filter(|b| bands.iter().filter(|o| o == b).count() == 2).count(); + assert_eq!(repeated, 2, "{bands:?}"); + assert!(bands.iter().all(|b| b.top() == bands[0].top()), "one row: {bands:?}"); +} + +#[test] +fn with_frozen_panes_the_band_crosses_the_frozen_columns() { + let mut h = harness(book("C5")); + h.state_mut().session.execute("selection.set", json!({"range": "C3"})).unwrap(); + h.state_mut().session.execute("view.freezePanes", json!({})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"range": "E7"})).unwrap(); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 2, "the frozen columns' pane and the scrolling one: {bands:?}"); + assert_eq!(bands[0].top(), bands[1].top()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4f0627ba3..efc4fdc23 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 e6a339c9f..4f56e81d7 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-03 · **Change:** minor (a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; 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 +> **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: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; 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 @@ -56,6 +56,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) | border drag moves (M3) and asks "There's already data here. Do you want to replace it?" before replacing cells that hold data (`range.move`, #3136); Ctrl/Option to copy and Shift to insert are not wired to the border drag yet (reorder whole columns by their headers instead) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -240,6 +241,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-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | | 2026-11-03 | minor | Cell context menu on a cell with a link: Edit Hyperlink… (the link dialog, titled Edit Hyperlink and filled with the link), Open Hyperlink and Remove Hyperlink, as in Excel, instead of Link… (#2120) | | 2026-11-03 | minor | File › Options › Display options for this workbook: Show horizontal scroll bar, Show vertical scroll bar and Show sheet tabs (`view.showHorizontalScrollBar`, `view.showVerticalScrollBar`, `view.showSheetTabs`), saved with the workbook; with the sheet tabs off the scroll bar takes the bar, with the scroll bar off the tabs do, with both off the bar goes, and without the vertical scroll bar the grid takes its width (#3101) |