diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8e08ea6..6f9d369 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -869,6 +869,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { match kind.as_str() { "chart" => sh.charts.retain(|c| c.id != id), "image" => sh.images.retain(|c| c.id != id), + "slicer" => sh.slicers.retain(|c| c.id != id), _ => sh.shapes.retain(|c| c.id != id), } Ok(Json::Null) @@ -884,6 +885,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result { let anchor = match kind.as_str() { "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), _ => None, }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d238ef7..7172bcb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod pivot; pub mod print; pub mod review; pub mod sheet; +pub mod slicer; pub mod spelling; pub mod view; pub mod whatif; @@ -119,6 +120,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw::specs()); v.extend(spelling::specs()); v.extend(pivot::specs()); + v.extend(slicer::specs()); v.extend(whatif::specs()); v.extend(print::specs()); v diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs new file mode 100644 index 0000000..907ac7e --- /dev/null +++ b/crates/engine/src/cmd/slicer.rs @@ -0,0 +1,229 @@ +//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! +//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a +//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and +//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / +//! `table.insertSlicer`) selects everything, i.e. applies no filter. + +use gridcraft_core::CellRef; +use gridcraft_model::*; +use serde_json::{Value as Json, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.slicer", + "Slicer", + ["Insert", "Filters"], + None, + "{table?, column?: \"Region\", columns?: 1, at?: \"H2\", width?, height?}", + has_doc, + insert_slicer + ), + cmd!( + "table.insertSlicer", + "Insert Slicer", + ["Table", "Tools"], + None, + "{table?, column?: \"Region\", columns?: 1, at?, width?, height?}", + has_doc, + insert_slicer + ), + cmd!("slicer.toggle", "Toggle Slicer Value", [], None, "{slicer?: id, value: \"East\", multi?: false}", has_doc, slicer_toggle), + cmd!("slicer.selectAll", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.clear", "Clear Slicer Filter", [], None, "{slicer?: id}", has_doc, slicer_select_all), + cmd!("slicer.delete", "Delete Slicer", [], None, "{slicer?: id}", has_doc, slicer_delete), + ] +} + +/// Distinct, non-blank display values of a table column, in first-seen order. Used to populate a +/// slicer's tiles and to detect "everything selected". +pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> Vec { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table)) else { return vec![] }; + let Some(ci) = t.column_index(column) else { return vec![] }; + let Some(dr) = t.data_range() else { return vec![] }; + let col = dr.start.col + ci as u32; + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for row in dr.start.row..=dr.end.row { + let text = crate::display::cell_text(wb, sh, CellRef::new(row, col)); + if text.is_empty() { + continue; + } + if seen.insert(text.to_lowercase()) { + out.push(text); + } + } + out +} + +/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the +/// slicer has no filter (`selected == None`). +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { + column_values(wb, sheet, &sl.table, &sl.column) + .into_iter() + .map(|v| { + let on = match &sl.selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + (v, on) + }) + .collect() +} + +/// Writes the slicer's current selection into the sheet filter and re-applies it. +fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { + let info = wb.sheet(sheet).and_then(|sh| { + let sl = sh.slicers.iter().find(|s| s.id == id)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + }); + let Some((table_range, abs_col, selected)) = info else { return }; + { + let Some(sh) = wb.sheet_mut(sheet) else { return }; + let af = sh.autofilter.get_or_insert_with(|| AutoFilter { range: table_range, criteria: vec![] }); + let off = abs_col.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + if let Some(values) = selected { + af.criteria.push((off, FilterCriterion::Values { values, blanks: false })); + } + } + super::data::apply_filter(wb, sheet); +} + +fn insert_slicer(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let t = match str_param(p, "table") { + Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)), + None => sh.table_at(d.selection.active), + } + .ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?; + let column = match str_param(p, "column") { + Some(c) => t + .columns + .iter() + .find(|tc| tc.name.eq_ignore_ascii_case(c)) + .map(|tc| tc.name.clone()) + .ok_or_else(|| bad("insert.slicer", format!("table has no column `{c}`")))?, + None => { + // Default to the active cell's column within the table, else the first column. + let idx = d.selection.active.col.checked_sub(t.range.start.col).map(|o| o as usize).filter(|o| *o < t.columns.len()).unwrap_or(0); + t.columns.get(idx).map(|tc| tc.name.clone()).ok_or_else(|| bad("insert.slicer", "the table has no columns"))? + } + }; + let table = t.name.clone(); + let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2))); + let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10); + let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32; + let height = f64_param(p, "height").unwrap_or(200.0).clamp(60.0, 10000.0) as f32; + let id = d.wb.next_object_id(); + let slicer = Slicer { + id, + table, + column: column.clone(), + caption: column, + anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, + selected: None, + columns, + }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.slicers.push(slicer); + Ok(json!({"slicer": id})) + }) +} + +/// The target slicer: by `slicer` id, else the last one on the active sheet. +fn find_slicer(s: &Session, p: &Json) -> Result<(usize, u32)> { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "slicer") { + Some(id) => sh.slicers.iter().position(|x| x.id == id), + None => sh.slicers.len().checked_sub(1), + }; + idx.and_then(|i| sh.slicers.get(i).map(|x| (i, x.id))).ok_or_else(|| bad("slicer", "no slicer on this sheet")) +} + +fn slicer_toggle(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); + let multi = bool_param(p, "multi").unwrap_or(false); + let sheet = s.doc()?.wb.active_sheet; + let (table, column, current) = { + let d = s.doc()?; + let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; + (sl.table.clone(), sl.column.clone(), sl.selected.clone()) + }; + let all = column_values(&s.doc()?.wb, sheet, &table, &column); + if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return Err(bad("slicer.toggle", "value is not in the column")); + } + let new_selected: Option> = if multi { + let mut set: Vec = current.unwrap_or_else(|| all.clone()); + match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { + Some(pos) => { + set.remove(pos); + } + None => set.push(value.clone()), + } + // Everything selected is the same as no filter. + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + // Single click: show only this value; clicking the sole selected value clears the filter. + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, + _ => Some(vec![value.clone()]), + } + }; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_select_all(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + edit(s, |cx| { + if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { + sl.selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + cx.structural = true; + Ok(Json::Null) + }) +} + +fn slicer_delete(s: &mut Session, p: &Json) -> Result { + let (idx, id) = find_slicer(s, p)?; + let sheet = s.doc()?.wb.active_sheet; + // Absolute column of the slicer, so its filter criterion can be removed too. + let abs_col = s.doc().ok().and_then(|d| { + let sh = d.wb.active()?; + let sl = sh.slicers.get(idx)?; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; + let ci = t.column_index(&sl.column)?; + Some(t.range.start.col + ci as u32) + }); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + sh.slicers.retain(|x| x.id != id); + if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9121a06..ad30855 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -647,6 +647,59 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn slicer_filters_table() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5], ["East", 3], ["North", 1]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + + // A new slicer on the Region column filters nothing (all values selected). + let r = s.execute("insert.slicer", json!({})).unwrap(); + let id = r["slicer"].as_u64().unwrap() as u32; + let hidden = |s: &Session| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + (1..=4).filter(|row| sh.is_row_hidden(*row)).count() // data rows are sheet rows 1..=4 (0-based header at 0) + }; + assert_eq!(hidden(&s), 0); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(sh.slicers.len(), 1); + assert_eq!(sh.slicers[0].column, "Region"); + } + + // Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden). + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(sh.is_row_hidden(2) && sh.is_row_hidden(4)); + assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(3)); + } + + // Ctrl-click "West" adds it: East + West visible, only North hidden. + s.execute("slicer.toggle", json!({"slicer": id, "value": "West", "multi": true})).unwrap(); + assert_eq!(hidden(&s), 1); + + // Clear restores everything. + s.execute("slicer.clear", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none()); + + // Deleting the slicer drops it and clears its filter. + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + assert_eq!(hidden(&s), 2); + s.execute("slicer.delete", json!({"slicer": id})).unwrap(); + assert_eq!(hidden(&s), 0); + assert!(s.doc().unwrap().wb.active().unwrap().slicers.is_empty()); + + // A bad value is rejected, not panicked. + assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err()); +} + #[test] fn create_names_from_selection() { let names = |s: &Session| -> Vec<(String, String)> { diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b9fe2ce..be10c91 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -479,6 +479,26 @@ pub struct AutoFilter { pub criteria: Vec<(u32, FilterCriterion)>, } +/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles +/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, +/// so a slicer is just a visual front-end over the existing filter machinery. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slicer { + pub id: u32, + /// Name of the table this slicer filters. + pub table: String, + /// Header name of the filtered column within the table. + pub column: String, + /// Caption shown in the slicer header (defaults to the column name). + pub caption: String, + pub anchor: Anchor, + /// Selected display values. `None` means every value (no filter from this slicer); an empty + /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". + pub selected: Option>, + /// Number of button columns in the layout (Excel default 1). + pub columns: u32, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Orientation { #[default] diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 4b4200d..5e84bb4 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -54,6 +54,9 @@ pub struct Sheet { pub images: Vec, pub shapes: Vec, pub sparklines: Vec, + /// On-sheet slicer widgets (filter a table column by clicking value tiles). + #[serde(default)] + pub slicers: Vec, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, @@ -99,6 +102,7 @@ impl Sheet { images: vec![], shapes: vec![], sparklines: vec![], + slicers: vec![], pivots: vec![], protection: None, print: PrintSettings::default(), diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 6ccef9e..b1587ff 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -216,6 +216,9 @@ impl Workbook { for c in &s.tables { m = m.max(c.id); } + for c in &s.slicers { + m = m.max(c.id); + } } m + 1 } diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index adeeddb..188fbad 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -308,6 +308,112 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } + paint_slicers(app, p, geo, wb, si, sh); +} + +/// Geometry of a slicer's parts within its screen rect. +pub struct SlicerLayout { + pub header: Rect, + /// Clear-filter button in the header. + pub clear: Rect, + /// One rect per value tile, in order. + pub tiles: Vec, +} + +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { + let header_h = 26.0 * z; + let pad = 6.0 * z; + let gap = 3.0 * z; + let tile_h = 22.0 * z; + let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); + let cb = 16.0 * z; + let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); + let cols = columns.max(1) as f32; + let inner_left = rect.left() + pad; + let inner_top = header.bottom() + pad; + let inner_w = (rect.width() - 2.0 * pad).max(1.0); + let tile_w = ((inner_w - (cols - 1.0) * gap) / cols).max(1.0); + let mut tiles = Vec::with_capacity(n); + for i in 0..n { + let col = (i as f32) % cols; + let row = (i as f32 / cols).floor(); + let x = inner_left + col * (tile_w + gap); + let y = inner_top + row * (tile_h + gap); + tiles.push(Rect::from_min_size(pos2(x, y), vec2(tile_w, tile_h))); + } + SlicerLayout { header, clear, tiles } +} + +fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { + let tk = theme::Tokens::get(p.ctx()); + for sl in &sh.slicers { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.intersects(p.clip_rect()) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let radius = 4.0 * geo.z; + // Card + soft shadow. + p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); + p.rect_filled(r, radius, tk.window); + p.rect_stroke(r, radius, Stroke::new(1.0, tk.separator), StrokeKind::Inside); + // Header. + p.rect_filled(lay.header, radius, tk.accent_soft); + p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); + let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); + // Clear-filter button (tinted when a filter is active). + let filtered = sl.selected.is_some(); + if let Some(icon) = crate::icons::from_name("filter") { + crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); + } + // Value tiles. + for ((val, on), tr) in items.iter().zip(&lay.tiles) { + if !r.contains(tr.left_top()) { + continue; // overflowed the card; skip (no scroll yet) + } + let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + p.rect_filled(*tr, 3.0 * geo.z, fill); + let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + } + if app.selected_chart == Some(sl.id) { + selection_frame(p, r); + } + } +} + +/// What a click inside a slicer hit. +pub enum SlicerAction { + Toggle(String), + Clear, +} + +/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns +/// `None` so the caller can treat it as a select/drag on the object. +pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> { + if !geo.cells.contains(p) { + return None; + } + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if !r.contains(p) { + continue; + } + let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + if lay.clear.contains(p) { + return Some((sl.id, SlicerAction::Clear)); + } + for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + if r.contains(tr.left_top()) && tr.contains(p) { + return Some((sl.id, SlicerAction::Toggle(val.clone()))); + } + } + return None; // inside this (topmost) slicer, but on its header/background → select/drag + } + None } fn selection_frame(p: &Painter, r: Rect) { @@ -322,6 +428,12 @@ fn selection_frame(p: &Painter, r: Rect) { /// The topmost object under `p`: (kind, id, rect). pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> { let _ = app; + for sl in sh.slicers.iter().rev() { + let r = anchor_rect(geo, sh, &sl.anchor); + if r.contains(p) && geo.cells.contains(p) { + return Some(("slicer", sl.id, r)); + } + } for ch in sh.charts.iter().rev() { let r = anchor_rect(geo, sh, &ch.anchor); if r.expand(4.0).contains(p) && geo.cells.contains(p) { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4bd50dd..b989d25 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1126,7 +1126,25 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } } - // Objects (charts, pictures, shapes) take clicks first. + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. if resp.drag_started() || primary_clicked { if let Some(p) = pos && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) @@ -1597,6 +1615,7 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 28d025c..17c7633 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1213,6 +1213,10 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { }); }); sep(ui); + if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { + act(app, "insert.slicer", json!({})); + } + sep(ui); let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); egui::Popup::menu(&sp).show(|ui| { for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { @@ -1738,6 +1742,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { act(app, "table.convertToRange", json!({"table": tname})); } sep(ui); + if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { + act(app, "table.insertSlicer", json!({"table": tname})); + } + sep(ui); ui.vertical(|ui| { for (label, cmd, v) in [ ("Header Row", "table.headerRow", header && filter),