collective

Browse / github.com/storytold/gridcraft / patch

Fix cascading AutoFilter dropdown values

Automatic filters and how they work - Řádky tabulky filtruje podle podmínek ve více sloupcích současně. - Nabídku hodnot pod šipkou ale sestavuje ze všech řádků původní oblasti, včetně těch, které první filtr už skryl. Například když v prvním sloupci vybereš jednu firmu, v nabídce pátého sloupce stále uvidíš i zakázky ostatních firem. Když tam vybereš konkrétní zakázku, výsledné řádky už musejí splnit obě podmínky současně. LibreOffice se v tomto skutečně chová jinak: jeho dokumentace výslovně uvádí, že nabídky dalších filtrů obsahují pouze údaje odpovídající již použitým filtrům. [help.libreoffice.org](https://help.libreoffice

Status
open
Score
0.00
Adopters
0 other operators
Author
TheRenegadeDev (imported from pull request #2672, open)
Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86
Size
+396 −59 in 7 files
Review
pending
Shared
2026-10-28
Id
3e93d55b1c880e2c8448739a

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/data.rs crates/engine/tests/it/cascading_filter_values.rs crates/engine/tests/it/main.rs crates/ui-egui/src/grid.rs crates/ui-egui/tests/it/cascading_filter_values.rs crates/ui-egui/tests/it/main.rs docs/ui-parity.md

diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs--- a/crates/engine/src/cmd/data.rs+++ b/crates/engine/src/cmd/data.rs@@ -679,17 +679,17 @@ pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {     } } -/// Hides rows of the autofilter range that fail any criterion.-pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {-    let Some(sh) = wb.sheet(sheet) else { return };-    let Some(af) = sh.autofilter.clone() else { return };+/// Statistics use the original range, regardless of the other active criteria (as in Excel).+fn filter_stats(sh: &Sheet, af: &AutoFilter, excluded_offset: Option<u32>) -> std::collections::HashMap<usize, (Option<f64>, f64)> {     let r = af.range;-    let mut hide = Vec::new();     // Precompute per-criterion top10 threshold and average once, not per row.     let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();     for (i, (off, c)) in af.criteria.iter().enumerate() {+        if Some(*off) == excluded_offset {+            continue;+        }         if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {-            let col = r.start.col + off;+            let Some(col) = r.start.col.checked_add(*off).filter(|col| *col <= r.end.col) else { continue };             let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();             let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };             let thr = match c {@@ -704,59 +704,84 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {             col_stats.insert(i, (thr, avg));         }     }-    for row in r.start.row + 1..=r.end.row {-        let mut visible = true;-        for (i, (off, crit)) in af.criteria.iter().enumerate() {-            let c = CellRef::new(row, r.start.col + off);-            let v = sh.value(c);-            let text = crate::display::cell_text(wb, sh, c);-            let ok = match crit {-                FilterCriterion::Values { values, blanks } => {-                    if filter_blank(&v) {-                        *blanks-                    } else {-                        values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))-                    }+    col_stats+}++/// Evaluate criteria directly, without conflating manual visibility with filter results.+fn filter_row_matches(+    wb: &Workbook,+    sh: &Sheet,+    af: &AutoFilter,+    row: u32,+    col_stats: &std::collections::HashMap<usize, (Option<f64>, f64)>,+    excluded_offset: Option<u32>,+) -> bool {+    for (i, (off, crit)) in af.criteria.iter().enumerate() {+        if Some(*off) == excluded_offset {+            continue;+        }+        let Some(col) = af.range.start.col.checked_add(*off).filter(|col| *col <= af.range.end.col) else { continue };+        let c = CellRef::new(row, col);+        let v = sh.value(c);+        let text = crate::display::cell_text(wb, sh, c);+        let ok = match crit {+            FilterCriterion::Values { values, blanks } => {+                if filter_blank(&v) {+                    *blanks+                } else {+                    values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))                 }-                FilterCriterion::Custom { a, b, and } => {-                    let x = custom_ok(&v, &text, &a.0, &a.1);-                    match b {-                        Some(b) => {-                            let y = custom_ok(&v, &text, &b.0, &b.1);-                            if *and { x && y } else { x || y }-                        }-                        None => x,+            }+            FilterCriterion::Custom { a, b, and } => {+                let x = custom_ok(&v, &text, &a.0, &a.1);+                match b {+                    Some(b) => {+                        let y = custom_ok(&v, &text, &b.0, &b.1);+                        if *and { x && y } else { x || y }                     }+                    None => x,                 }-                FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {-                    (Some(n), Some((thr, _))) => {-                        let thr = thr.unwrap_or(n);-                        if *bottom { n <= thr } else { n >= thr }-                    }-                    _ => false,-                },-                FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {-                    (Some(n), Some((_, avg))) => {-                        if *above {-                            n > *avg-                        } else {-                            n < *avg-                        }-                    }-                    _ => false,-                },-                FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,-                FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,-                // Icons are not evaluated here: the row keeps its current visibility.-                FilterCriterion::Icon { .. } => !sh.is_row_hidden(row),-            };-            if !ok {-                visible = false;-                break;             }+            FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {+                (Some(n), Some((thr, _))) => {+                    let thr = thr.unwrap_or(n);+                    if *bottom { n <= thr } else { n >= thr }+                }+                _ => false,+            },+            FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {+                (Some(n), Some((_, avg))) => {+                    if *above {+                        n > *avg+                    } else {+                        n < *avg+                    }+                }+                _ => false,+            },+            FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,+            FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,+            // Icon criteria are retained from files but cannot be evaluated yet.+            FilterCriterion::Icon { .. } => true,+        };+        if !ok {+            return false;         }-        hide.push((row, !visible));     }+    true+}++/// Hides rows of the autofilter range that fail any criterion.+pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {+    let Some(sh) = wb.sheet(sheet) else { return };+    let Some(af) = sh.autofilter.clone() else { return };+    let r = af.range;+    let col_stats = filter_stats(sh, &af, None);+    // Reapplying a retained icon filter preserves visibility until icons can be evaluated.+    let has_icon = af.criteria.iter().any(|(_, c)| matches!(c, FilterCriterion::Icon { .. }));+    let hide: Vec<_> = (r.start.row + 1..=r.end.row)+        .map(|row| (row, !filter_row_matches(wb, sh, &af, row, &col_stats, None) || (has_icon && sh.is_row_hidden(row))))+        .collect();     let Some(shm) = wb.sheet_mut(sheet) else { return };     for (row, h) in hide {         if h {@@ -789,14 +814,22 @@ fn reapply(s: &mut Session, _: &Json) -> Result<Json> {     }) } -/// Distinct display values of a filter column (for the dropdown list).+/// Distinct display values from rows matching the other columns' criteria.+/// The current criterion only determines checkboxes, so its unchecked alternatives stay available. pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> {     let Some(sh) = wb.sheet(sheet) else { return vec![] };     let Some(af) = &sh.autofilter else { return vec![] };-    let off = col.saturating_sub(af.range.start.col);+    if col < af.range.start.col || col > af.range.end.col {+        return vec![];+    }+    let off = col - af.range.start.col;     let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c);+    let col_stats = filter_stats(sh, af, Some(off));     let mut seen = std::collections::BTreeMap::new();-    for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {+    for row in af.range.start.row.saturating_add(1)..=af.range.end.row.min(af.range.start.row.saturating_add(1_000_000)) {+        if !filter_row_matches(wb, sh, af, row, &col_stats, Some(off)) {+            continue;+        }         let c = CellRef::new(row, col);         let v = sh.value(c);         let blank = filter_blank(&v);diff --git a/crates/engine/tests/it/cascading_filter_values.rs b/crates/engine/tests/it/cascading_filter_values.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/cascading_filter_values.rs@@ -0,0 +1,214 @@+use std::sync::Arc;++use gridcraft_engine::{+    Session,+    cmd::data::filter_values,+    model::{Color, FilterCriterion},+};+use serde_json::json;++#[test]+fn choices_use_the_same_custom_and_statistical_criteria_as_filtering() {+    for (criterion, expected) in [+        (json!({"custom": {"op": ">=", "value": 20, "op2": "<", "value2": 100}}), vec!["mid", "upper"]),+        (json!({"custom": {"op": "=", "value": 10, "op2": "=", "value2": 100, "and": false}}), vec!["high", "low"]),+        (json!({"custom": {"op": "=", "value": "n?*"}}), vec!["text"]),+        (json!({"top": {"count": 2}}), vec!["high", "upper"]),+        (json!({"top": {"count": 50, "percent": true, "bottom": true}}), vec!["low", "mid"]),+        (json!({"aboveAverage": true}), vec!["high"]),+        (json!({"aboveAverage": false}), vec!["low", "mid", "upper"]),+    ] {+        let mut s = Session::new();+        s.new_workbook();+        s.execute(+            "range.setValues",+            json!({"range": "A1", "values": [+                ["Name", "Amount"], ["low", 10], ["mid", 20], ["upper", 30], ["high", 100], ["blank", null], ["text", "n/a"]+            ]}),+        )+        .unwrap();+        s.execute("data.filter", json!({"range": "A1:B7"})).unwrap();+        let mut params = criterion.clone();+        params["column"] = json!("B");+        s.execute("data.filterBy", params).unwrap();+        let actual: Vec<_> = filter_values(&s.doc().unwrap().wb, 0, 0).into_iter().map(|(v, _)| v).collect();+        assert_eq!(actual, expected, "{criterion}");+        // The current column's own criterion must not narrow its alternatives.+        s.execute("data.filterBy", json!({"column": "A", "values": ["mid"]})).unwrap();+        let actual: Vec<_> = filter_values(&s.doc().unwrap().wb, 0, 0).into_iter().map(|(v, _)| v).collect();+        assert_eq!(actual, expected, "{criterion}, with the current column filtered");+        assert_eq!(+            filter_values(&s.doc().unwrap().wb, 0, 1),+            vec![("20".into(), true)],+            "the numeric column's own criterion must be excluded: {criterion}"+        );+    }+}++#[test]+fn statistical_thresholds_use_the_original_range_not_other_columns_subset() {+    for (criterion, names, expected) in [+        (json!({"top": {"count": 2}}), vec!["low", "mid", "upper"], vec!["upper"]),+        (json!({"top": {"count": 50, "percent": true}}), vec!["low", "mid", "upper"], vec!["upper"]),+        (json!({"top": {"count": 2, "bottom": true}}), vec!["mid", "upper", "high"], vec!["mid"]),+        (json!({"top": {"count": 50, "percent": true, "bottom": true}}), vec!["mid", "upper", "high"], vec!["mid"]),+        (json!({"aboveAverage": true}), vec!["low", "mid", "upper"], vec![]),+        (json!({"aboveAverage": false}), vec!["low", "mid", "upper"], vec!["low", "mid", "upper"]),+    ] {+        let mut s = Session::new();+        s.new_workbook();+        s.execute(+            "range.setValues",+            json!({"range": "A1", "values": [+                ["Name", "Amount", "Job"], ["low", 10, "low"], ["mid", 20, "mid"], ["upper", 30, "upper"], ["high", 100, "high"]+            ]}),+        )+        .unwrap();+        s.execute("data.filter", json!({"range": "A1:C5"})).unwrap();+        s.execute("data.filterBy", json!({"column": "A", "values": names})).unwrap();+        let mut params = criterion.clone();+        params["column"] = json!("B");+        s.execute("data.filterBy", params).unwrap();+        // A and B both remain active when enumerating C. Subset statistics produce different jobs.+        let actual: Vec<_> = filter_values(&s.doc().unwrap().wb, 0, 2).into_iter().map(|(v, _)| v).collect();+        assert_eq!(actual, expected, "{criterion}");+    }+}++#[test]+fn choices_match_other_columns_but_keep_the_current_columns_unchecked_values() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute(+        "range.setValues",+        json!({"range": "B3", "values": [+            ["Company", "Job", "Status"],+            ["East", "Alpha", "Open"],+            ["East", "Beta", "Open"],+            ["East", "Closed job", "Closed"],+            ["West", "Other company", "Open"],+            ["East", null, "Open"],+            ["East", "=\"\"", "Open"],+            ["East", "Outside range", "Open"]+        ]}),+    )+    .unwrap();+    s.execute("data.filter", json!({"range": "B3:D9"})).unwrap();+    s.execute("data.filterBy", json!({"column": "B", "values": ["East"]})).unwrap();+    s.execute("data.filterBy", json!({"column": "D", "values": ["Open"]})).unwrap();+    s.execute("data.filterBy", json!({"column": "C", "values": ["Alpha"]})).unwrap();++    let choices = |s: &Session| filter_values(&s.doc().unwrap().wb, 0, 2);+    let expected =+        |beta: bool, blanks: bool| -> Vec<(String, bool)> { vec![("Alpha".into(), !beta), ("Beta".into(), beta), ("(Blanks)".into(), blanks)] };+    assert_eq!(choices(&s), expected(false, false));+    s.execute("data.filterBy", json!({"column": "C", "values": ["Beta"], "blanks": true})).unwrap();+    assert_eq!(choices(&s), expected(true, true));+    s.execute("data.filterBy", json!({"column": "C", "clear": true})).unwrap();+    assert_eq!(choices(&s), vec![("Alpha".into(), true), ("Beta".into(), true), ("(Blanks)".into(), true)]);+    // Manual visibility is not a filter criterion, even when every eligible row is hidden.+    s.execute("home.hideRows", json!({"rows": "4:10"})).unwrap();+    assert_eq!(choices(&s).len(), 3);+    assert!(filter_values(&s.doc().unwrap().wb, 0, 0).is_empty());+    assert!(filter_values(&s.doc().unwrap().wb, 0, 4).is_empty());+}++#[test]+fn choices_respect_blank_and_formatted_value_criteria_on_other_columns() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute(+        "range.setValues",+        json!({"range": "A1", "values": [+            ["Name", "Value"], ["empty", null], ["formula", "=\"\""], ["spaces", " "], ["number", 12], ["other", 30]+        ]}),+    )+    .unwrap();+    s.execute("home.numberFormat", json!({"range": "B5:B6", "code": "0.00"})).unwrap();+    s.execute("data.filter", json!({"range": "A1:B6"})).unwrap();+    s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();+    assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 0), vec![("empty".into(), true), ("formula".into(), true)]);+    for value in ["12", "12.00"] {+        s.execute("data.filterBy", json!({"column": "B", "values": [value]})).unwrap();+        assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 0), vec![("number".into(), true)]);+    }+    s.execute("data.filterBy", json!({"column": "B", "values": ["absent"]})).unwrap();+    assert!(filter_values(&s.doc().unwrap().wb, 0, 0).is_empty());+}++#[test]+fn choices_match_color_criteria_and_do_not_infer_icon_matches_from_hidden_rows() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Color"], ["red", 1], ["plain", 2]]})).unwrap();+    s.execute("home.fillColor", json!({"range": "B2", "color": "#FF0000"})).unwrap();+    s.execute("home.fontColor", json!({"range": "B2", "color": "#FF0000"})).unwrap();+    s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();+    for criterion in [FilterCriterion::FillColor(Color::Rgb(0xFF0000)), FilterCriterion::FontColor(Color::Rgb(0xFF0000))] {+        let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();+        sh.autofilter.as_mut().unwrap().criteria = vec![(1, criterion)];+        s.execute("data.reapply", json!({})).unwrap();+        assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 0), vec![("red".into(), true)]);+    }+    let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();+    sh.autofilter.as_mut().unwrap().criteria = vec![(1, FilterCriterion::Icon { set: "3Arrows".into(), id: Some(0) })];+    s.execute("data.reapply", json!({})).unwrap();+    assert!(s.doc().unwrap().wb.active().unwrap().is_row_hidden(2), "reapply preserves imported icon visibility");+    assert_eq!(+        filter_values(&s.doc().unwrap().wb, 0, 0),+        vec![("plain".into(), true), ("red".into(), true)],+        "unsupported icon criteria cannot distinguish manual and current-column hiding"+    );+}++#[test]+fn querying_a_table_backed_sheet_filter_is_read_only_and_preserves_xlsx_criteria() {+    for table in [false, true] {+        let mut s = Session::new();+        s.new_workbook();+        s.execute("range.setValues", json!({"range": "B3", "values": [["Company", "Job"], ["East", "Alpha"], ["East", "Beta"], ["West", "Other"]]}))+            .unwrap();+        if table {+            s.execute("insert.table", json!({"range": "B3:C6", "header": true})).unwrap();+        }+        s.execute("selection.set", json!({"cell": "B3"})).unwrap();+        s.execute("data.filterBy", json!({"column": "B", "values": ["East"]})).unwrap();+        let before = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap();+        assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 2), vec![("Alpha".into(), true), ("Beta".into(), true)]);+        assert_eq!(before, gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap());+        let (reopened, _) = gridcraft_xlsx::read_xlsx(&before).unwrap();+        let sh = reopened.active().unwrap();+        let criteria = if table { &sh.tables[0].filter } else { &sh.autofilter.as_ref().unwrap().criteria };+        assert_eq!(criteria, &vec![(0, FilterCriterion::Values { values: vec!["East".into()], blanks: false })]);+        assert!(!sh.is_row_hidden(3) && !sh.is_row_hidden(4) && sh.is_row_hidden(5));+    }+}++#[test]+fn an_unrelated_tables_criteria_do_not_restrict_the_sheet_filter_choices() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("range.setValues", json!({"range": "B3", "values": [["Company", "Job"], ["East", "Alpha"], ["East", "Beta"], ["West", "Other"]]}))+        .unwrap();+    s.execute("range.setValues", json!({"range": "F3", "values": [["Company", "Job"], ["West", "Unrelated"], ["East", "Separate"]]})).unwrap();+    s.execute("insert.table", json!({"range": "F3:G5", "header": true})).unwrap();+    let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();+    sh.tables[0].filter = vec![(0, FilterCriterion::Values { values: vec!["West".into()], blanks: false })];+    s.execute("selection.set", json!({"cell": "B3"})).unwrap();+    s.execute("data.filter", json!({"range": "B3:C6"})).unwrap();+    s.execute("data.filterBy", json!({"column": "B", "values": ["East"]})).unwrap();+    assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 2), vec![("Alpha".into(), true), ("Beta".into(), true)]);+}++#[test]+fn choices_are_bounded_at_the_last_sheet_row_and_empty_for_missing_data() {+    let mut s = Session::new();+    s.new_workbook();+    assert!(filter_values(&s.doc().unwrap().wb, 0, 0).is_empty());+    assert!(filter_values(&s.doc().unwrap().wb, 99, 0).is_empty());+    s.execute("range.setValues", json!({"range": "B1048574", "values": [["Company", "Job"], ["West", "Before last"], ["East", "Last East"]]}))+        .unwrap();+    s.execute("data.filter", json!({"range": "B1048574:C1048576"})).unwrap();+    s.execute("data.filterBy", json!({"column": "B", "values": ["East"]})).unwrap();+    assert_eq!(filter_values(&s.doc().unwrap().wb, 0, 2), vec![("Last East".into(), true)]);+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -1,6 +1,7 @@ //! The crate's integration tests, one module per file, built as one test binary.  mod autofit_filter_buttons;+mod cascading_filter_values; mod cf_formula_validate; mod chart_area_stacked100; mod chart_workbook_names;diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -3326,7 +3326,6 @@ 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::unique(("filter_sel", col));     let search_key = egui::Id::unique(("filter_search", col));     let buttons_key = egui::Id::unique(("filter_buttons", col));@@ -3352,7 +3351,10 @@ 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::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());+            let mut checks: Vec<(String, bool)> = ui+                .ctx()+                .data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key))+                .unwrap_or_else(|| gridcraft_engine::cmd::data::filter_values(&wb, si, col));             // 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));diff --git a/crates/ui-egui/tests/it/cascading_filter_values.rs b/crates/ui-egui/tests/it/cascading_filter_values.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/cascading_filter_values.rs@@ -0,0 +1,68 @@+use crate::common;++use egui::{Event, Key, Modifiers};+use egui_kittest::kittest::Queryable;+use gridcraft_engine::Session;+use serde_json::json;++fn key(h: &mut common::Harness, key: Key, modifiers: Modifiers) {+    for pressed in [true, false] {+        h.input_mut().events.push(Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers });+    }+    h.run_steps(3);+}++#[test]+fn dropdown_can_change_and_clear_a_column_while_another_filter_stays_active() {+    for table in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        session+            .execute(+                "range.setValues",+                json!({"range": "A1", "values": [+                    ["Company", "Job"], ["East", "Alpha"], ["East", "Beta"], ["West", "Other"], ["East", null]+                ]}),+            )+            .unwrap();+        if table {+            session.execute("insert.table", json!({"range": "A1:B5", "header": true})).unwrap();+        }+        session.execute("data.filterBy", json!({"column": "A", "values": ["East"]})).unwrap();+        session.execute("data.filterBy", json!({"column": "B", "values": ["Alpha"]})).unwrap();+        session.execute("selection.set", json!({"cell": "B1"})).unwrap();+        let screenshot = std::env::var("GRIDCRAFT_CASCADING_SNAPSHOT").ok().filter(|_| !table);+        let mut h = if screenshot.is_some() {+            common::harness_with(+                common::builder().with_os(egui::os::OperatingSystem::Windows).with_size(egui::vec2(1200.0, 800.0)).wgpu(),+                common::app(session),+                4,+            )+        } else {+            common::os_harness(session, egui::os::OperatingSystem::Windows)+        };+        key(&mut h, Key::ArrowDown, Modifiers::ALT);+        assert!(h.query_by_label("Alpha").is_some());+        assert!(h.query_by_label("Beta").is_some(), "current-column filtering must not remove alternatives");+        assert!(h.query_by_label("(Blanks)").is_some());+        assert!(h.query_by_label("Other").is_none(), "the other company's job must not be listed");+        if let Some(path) = screenshot {+            h.render().unwrap().save(path).unwrap();+        }+        h.get_by_label("Alpha").click();+        h.run_steps(2);+        h.get_by_label("Beta").click();+        h.run_steps(2);+        h.get_by_label("Apply").click();+        h.run_steps(3);+        let hidden = |h: &common::Harness| {+            let sh = h.state().session.active().unwrap().wb.active().unwrap();+            (1..=4).map(|row| sh.is_row_hidden(row)).collect::<Vec<_>>()+        };+        assert_eq!(hidden(&h), [true, false, true, true]);+        key(&mut h, Key::ArrowDown, Modifiers::ALT);+        h.get_by_label("Clear Filter").click();+        h.run_steps(3);+        assert_eq!(hidden(&h), [false, false, true, false], "clearing Job keeps Company filtered");+    }+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -11,6 +11,7 @@ mod advanced_filter; mod argument_names; mod autosave_persist; mod borders;+mod cascading_filter_values; mod cell_editor_caret; mod cell_pictures; mod cf_rule_pick;diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (cascading AutoFilter choices) · **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@@ -60,6 +60,7 @@ this logic, with one undo step. | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) |+| AutoFilter value dropdowns | Choices come from rows satisfying the other columns' active criteria; the current column only determines checkbox state, including (Blanks), so its filter can be changed or cleared (#2641). Custom comparisons, Top 10, average and color criteria use the same evaluator as row filtering. Top 10 / average thresholds still use the original range. Manual hidden rows do not restrict choices; imported icon criteria remain unevaluated. | | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | | 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; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) |@@ -91,6 +92,22 @@ this logic, with one undo step. | Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | +### AutoFilter population and pending integrations++[Microsoft's filtering guide](https://support.microsoft.com/en-us/excel/get-started/filter-data-in-a-range-or-table-in-excel)+describes additive column filters and restricting other dropdowns to the filtered range. The+current column is excluded from enumeration so its unchecked alternatives remain available.+The existing one-million-data-row enumeration limit and value sorting remain unchanged.++This changes the population behind the pending row-count work (#1737, and its stacked Only+button #1741): a future count beside a choice should count eligible rows satisfying the other+columns, including rows rejected by the current column. It must not count the whole original+range or just currently visible rows. Search narrows the displayed choices, not that population.+These PRs are not incorporated here. Date grouping (#1509) should group this same eligible+population. Table filter activation / roundtrip ownership changes remain in #2506; enumeration+reads the active sheet AutoFilter used by the current table dropdown without changing criteria,+row visibility or the XLSX representation.+ ### Shortcut audit (#110)  Checked against Microsoft's published "Keyboard shortcuts in Excel" (Windows and Mac tables,@@ -187,6 +204,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | AutoFilter choices cascade across other columns while retaining current-column alternatives and blanks (#2641); document pending counts, date grouping and table roundtrip integration | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |