Merge pull request #193 from zaidshurbaji/feature/table-slicers
Feature/table slicers
This commit is contained in:
commit
dc85b2ab07
|
|
@ -1031,6 +1031,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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)
|
||||
|
|
@ -1047,6 +1048,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod pivot;
|
|||
pub mod print;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
pub mod solver;
|
||||
pub mod spelling;
|
||||
pub mod view;
|
||||
|
|
@ -138,6 +139,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(slicer::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
v.extend(analysis::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
//! 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<CommandSpec> {
|
||||
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<String> {
|
||||
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<Json> {
|
||||
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(s, p, "at")?
|
||||
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
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<Json> {
|
||||
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<Vec<String>> = if multi {
|
||||
let mut set: Vec<String> = 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<Json> {
|
||||
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<Json> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -979,6 +979,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)> {
|
||||
|
|
|
|||
|
|
@ -499,6 +499,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<Vec<String>>,
|
||||
/// 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]
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ pub struct Sheet {
|
|||
pub images: Vec<Image>,
|
||||
pub shapes: Vec<Shape>,
|
||||
pub sparklines: Vec<Sparkline>,
|
||||
/// On-sheet slicer widgets (filter a table column by clicking value tiles).
|
||||
#[serde(default)]
|
||||
pub slicers: Vec<Slicer>,
|
||||
/// PivotTables whose output lives on this sheet.
|
||||
#[serde(default)]
|
||||
pub pivots: Vec<PivotTable>,
|
||||
|
|
@ -146,6 +149,7 @@ impl Sheet {
|
|||
images: vec![],
|
||||
shapes: vec![],
|
||||
sparklines: vec![],
|
||||
slicers: vec![],
|
||||
pivots: vec![],
|
||||
protection: None,
|
||||
edit_ranges: vec![],
|
||||
|
|
|
|||
|
|
@ -220,6 +220,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,9 +345,115 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
|||
}
|
||||
}
|
||||
}
|
||||
paint_slicers(app, p, geo, wb, si, sh);
|
||||
painted
|
||||
}
|
||||
|
||||
/// 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<Rect>,
|
||||
}
|
||||
|
||||
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) {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside);
|
||||
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
|
||||
|
|
@ -360,6 +466,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) {
|
||||
|
|
|
|||
|
|
@ -1525,7 +1525,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 {
|
||||
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
|
||||
let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos };
|
||||
|
|
@ -2096,6 +2114,7 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::mo
|
|||
match kind {
|
||||
"chart" => 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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1264,6 +1264,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")] {
|
||||
|
|
@ -1877,6 +1881,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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue