Merge PR #1612: Grid: a scrolled Table's header names and filter buttons in the column headings (#1423)

docs/ui-parity.md: kept HEAD header line, both change rows, newest first
This commit is contained in:
aiongg
2026-10-22 02:36:10 +00:00
3 changed files with 252 additions and 6 deletions
+85 -6
View File
@@ -747,7 +747,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);
@@ -1408,10 +1409,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);
}
}
@@ -1423,6 +1421,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<Rect>,
/// 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<TableHeading> {
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`).
@@ -1718,6 +1777,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.
@@ -1761,7 +1821,21 @@ pub(crate) fn paint_headers(
}
}
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())));
@@ -2072,6 +2146,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;
}
+165
View File
@@ -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::<Vec<_>>()];
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");
}