diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..edd108ecf 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -737,7 +737,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } // Headers. if sh.show_headings { - paint_headers(&painter, &geo, sh, &sel, &t, &quads); + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } crate::outline::paint(&painter, &geo, sh, &t); @@ -1387,10 +1388,7 @@ pub(crate) fn paint_quadrant( if !range.contains(c) { continue; } - let r = filter_button_rect(geo, sh, c); - p.rect_filled(r, 2.0, Color32::from_gray(250)); - p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); - crate::icons::paint(p, r.shrink(1.5), if active { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); + paint_filter_button(p, filter_button_rect(geo, sh, c), active); } } @@ -1402,6 +1400,67 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) } +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + p.rect_filled(r, 2.0, Color32::from_gray(250)); + p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); + crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); +} + /// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the /// first row's and first column's cells, the bottom and right edges from the last row's and last /// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). @@ -1696,6 +1755,7 @@ pub(crate) fn paint_headers( sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)], + headings: &[TableHeading], ) { let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); // The headers' own area: inside the outline bands. @@ -1731,7 +1791,21 @@ pub(crate) fn paint_headers( } pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } } } let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); @@ -2016,6 +2090,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); + return; + } if point_header(app, wb, sh, RangeRef::cols(c, c)) { return; } diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/table_headings.rs new file mode 100644 index 000000000..5a2685e01 --- /dev/null +++ b/crates/ui-egui/tests/table_headings.rs @@ -0,0 +1,165 @@ +//! A Table's header names and filter buttons stand in for the column letters while its header row +//! is scrolled out of view and the active cell is inside it, as in Excel (#1423). + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_engine::model::Sheet; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Geo, heading_table, table_headings}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A Table on `range` (header row 3) with these header names and numbers below. +fn table(s: &mut Session, range: &str, names: &[&str]) { + let r = RangeRef::parse(range).unwrap(); + let mut rows = vec![names.iter().map(|n| json!(n)).collect::>()]; + for i in r.start.row + 1..=r.end.row { + rows.push(names.iter().map(|_| json!(i)).collect()); + } + s.execute("range.setValues", json!({"range": r.start.a1(), "values": rows})).unwrap(); + s.execute("insert.table", json!({"range": range, "header": true})).unwrap(); +} + +fn session(active: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:E40", &["Bank", "Date", "Type", "Amount", "Ref"]); + s.execute("selection.set", json!({"cell": active})).unwrap(); + s +} + +fn sheet(s: &Session) -> Sheet { + s.active().unwrap().wb.active().unwrap().clone() +} + +/// The grid scrolled so that `row` is the first one under the frozen rows. +fn geo_at(sh: &Sheet, row: u32) -> Geo { + let fr = sh.freeze.map_or(0, |f| f.0); + let scroll = vec2(0.0, (sh.row_top(row) - sh.row_top(fr)) as f32); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)), scroll) +} + +fn names(geo: &Geo, sh: &Sheet, active: &str) -> Vec<(u32, String)> { + table_headings(geo, sh, CellRef::parse(active).unwrap()).into_iter().map(|h| (h.col, h.name)).collect() +} + +fn owned(v: &[(u32, &str)]) -> Vec<(u32, String)> { + v.iter().map(|(c, n)| (*c, n.to_string())).collect() +} + +#[test] +fn header_names_replace_the_letters_only_while_the_header_row_is_out_of_view() { + let s = session("C20"); + let sh = sheet(&s); + let c20 = CellRef::parse("C20").unwrap(); + assert!(heading_table(&geo_at(&sh, 0), &sh, c20).is_none(), "header row 3 on screen: letters"); + assert!(heading_table(&geo_at(&sh, 2), &sh, c20).is_none(), "header row 3 at the top: letters"); + assert_eq!(heading_table(&geo_at(&sh, 3), &sh, c20).map(|t| t.range.a1()).as_deref(), Some("A3:E40")); + assert_eq!(names(&geo_at(&sh, 10), &sh, "C20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type"), (3, "Amount"), (4, "Ref")])); + // Scrolled past the table's last row, the letters come back. + assert!(heading_table(&geo_at(&sh, 40), &sh, c20).is_none()); +} + +#[test] +fn an_active_cell_outside_the_table_keeps_the_letters() { + let s = session("G20"); + let sh = sheet(&s); + // The table's rows fill the screen, so only where the active cell is tells the two apart. + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("G20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("E20").unwrap()).is_some()); +} + +#[test] +fn a_header_row_in_the_frozen_pane_keeps_the_letters() { + let mut s = session("C20"); + s.execute("view.freezePanes", json!({"cell": "A4"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((3, 0))); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("C20").unwrap()).is_none()); + + // Frozen above the header row, the header row scrolls under the frozen rows. + s.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((1, 0))); + assert!(heading_table(&geo_at(&sh, 2), &sh, CellRef::parse("C20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 3), &sh, CellRef::parse("C20").unwrap()).is_some()); +} + +#[test] +fn with_two_tables_the_names_come_from_the_active_cells_table() { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:C40", &["Bank", "Date", "Type"]); + table(&mut s, "E3:G40", &["Region", "Units", "Price"]); + let sh = sheet(&s); + let geo = geo_at(&sh, 10); + assert_eq!(names(&geo, &sh, "F20"), owned(&[(4, "Region"), (5, "Units"), (6, "Price")])); + assert_eq!(names(&geo, &sh, "B20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type")])); + assert_eq!(names(&geo, &sh, "D20"), owned(&[])); +} + +#[test] +fn hidden_columns_leave_the_other_names_over_their_own_columns() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.col_width(1), 0.0); + let geo = geo_at(&sh, 10); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let got: Vec<(u32, &str)> = headings.iter().map(|h| (h.col, h.name.as_str())).collect(); + assert_eq!(got, [(0, "Bank"), (2, "Type"), (3, "Amount"), (4, "Ref")]); + for h in &headings { + let cell = geo.cell_rect(&sh, CellRef::new(10, h.col)); + assert_eq!((h.rect.left(), h.rect.right()), (cell.left(), cell.right()), "{} sits over column {}", h.name, h.col); + let b = h.button.unwrap(); + assert!(b.left() > cell.left() && b.right() < cell.right() && b.top() >= geo.head_top() && b.bottom() <= geo.cells.top()); + } +} + +fn click(h: &mut Harness, at: Pos2) { + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(at), press(true)]); + h.step(); + h.input_mut().events.push(press(false)); + h.run_steps(3); +} + +#[test] +fn the_button_in_a_replaced_heading_opens_that_columns_filter_menu() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + s.execute("selection.set", json!({"cell": "C20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + let sh = sheet(&h.state().session); + let scroll = sh.row_top(10) as f32; + h.state_mut().view_mut().unwrap().scroll.y = scroll; + h.run_steps(2); + if let Ok(path) = std::env::var("GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + let app = h.state(); + let geo = Geo::new(&sh, app.grid.rect.unwrap(), app.view().scroll); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let amount = headings.iter().find(|x| x.name == "Amount").unwrap(); + assert_eq!(amount.col, 3); + + click(&mut h, amount.button.unwrap().center()); + let menu = h.state().grid.filter_menu.map(|(c, r, _)| (c, r)); + assert_eq!(menu, Some((3, 2)), "the menu of column D, keyed by the table's header row"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C20", "the button doesn't select"); + + // Beside the button the heading still selects the whole column. + h.state_mut().grid.filter_menu = None; + click(&mut h, pos2(amount.rect.left() + 6.0, amount.rect.center().y)); + assert_eq!(h.state().grid.filter_menu, None); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "D:D"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..6c6383c4b 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-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (a scrolled Table's header names in the column headings, #1423) · **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. | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | +| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |