From 4e6e0d6c31d8cf7595159ff0d51fdc54a2ddb965 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:49:46 +0200 Subject: [PATCH] AutoFilter: a selected header row filters the data below it; search box in the filter menu (#201) Turning the filter on with a multi-cell selection used the selection as the filter range. With only the header row selected, the filter had no data rows: the dropdown list was empty and filtering hid nothing. A one-row selection now extends down over the current region below it. An explicit `range` parameter is still taken as given. The filter menu gets a search box, as asked for in the issue. It narrows the value list; Select All then reads "(Select All Search Results)" and covers only the matches, and Apply keeps only the checked matches. The box takes focus when the menu opens, so typing no longer lands in the active cell. generated with Claude Code --- crates/engine/src/cmd/data.rs | 11 ++++++++++- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/grid.rs | 29 +++++++++++++++++++++++------ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index fd50afb..700c2b1 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { Ok(json!({"filter": false})) }); } - let r = data_range(s, p)?; + let mut r = data_range(s, p)?; + // A selected header row filters the data below it (#201). + if p.get("range").is_none() && r.height() == 1 { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let below = current_region(sh, r.start).end.row; + if below > r.end.row { + r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); + } + } edit(s, |cx| { cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); Ok(json!({"filter": true, "range": r.a1()})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..79281e9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -512,6 +512,26 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// Turning the filter on with only the header row selected filters the data below it (#201). +#[test] +fn filter_on_a_selected_header_row_covers_the_data_below() { + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}), + ) + .unwrap(); + s.execute("selection.set", json!({"range": "A1:B1"})).unwrap(); + let r = s.execute("data.filter", json!({})).unwrap(); + assert_eq!(r["range"], "A1:B6"); + let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap(); + assert_eq!(r["hiddenRows"], 3); + // An explicit range is taken as given. + s.execute("data.filter", json!({})).unwrap(); + let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap(); + assert_eq!(r["range"], "A1:B1"); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..b838a7b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1803,6 +1803,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); let key = egui::Id::new(("filter_sel", col)); + let search_key = egui::Id::new(("filter_search", col)); let mut close = false; let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); let response = area.show(ui.ctx(), |ui| { @@ -1819,19 +1820,32 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) ui.separator(); ui.label(egui::RichText::new("Filter").strong()); let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); - let all = checks.iter().all(|(_, b)| *b); + // A search narrows the list; Select All and Apply then only cover the matches. + let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); + if !was_open { + field.request_focus(); + } + let needle = search.trim().to_lowercase(); + let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); + let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); let mut all_new = all; - if ui.checkbox(&mut all_new, "(Select All)").changed() { - for c in checks.iter_mut() { + let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; + if ui.checkbox(&mut all_new, label).changed() { + for c in checks.iter_mut().filter(|(v, _)| matches(v)) { c.1 = all_new; } } egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - for (v, on) in checks.iter_mut() { + for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { ui.checkbox(on, v.as_str()); } }); - ui.ctx().data_mut(|d| d.insert_temp(key, checks.clone())); + ui.ctx().data_mut(|d| { + d.insert_temp(key, checks.clone()); + d.insert_temp(search_key, search.clone()); + }); + let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); ui.separator(); ui.horizontal(|ui| { if ui.button("Clear Filter").clicked() { @@ -1853,7 +1867,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) }); }); if close || popup_dismissed(&response.response, was_open) { - ui.ctx().data_mut(|d| d.remove::>(key)); + ui.ctx().data_mut(|d| { + d.remove::>(key); + d.remove::(search_key); + }); app.grid.filter_menu = None; } }