From ee7ee44f46a269f1a2e53e91c2f897f5ac2ee05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 20:41:47 -0400 Subject: [PATCH] T14c Tables: DataGrid, Spreadsheet with formulas, merges, frozen panes and fill, TreeTable, PivotTable --- TASKS.md | 1 + examples/gallery/pages/tables/grids.rs | 214 +++++++++++ examples/gallery/pages/tables/mod.rs | 13 + src/tables/formula.rs | 287 +++++++++++++++ src/tables/grid.rs | 478 +++++++++++++++++++++++++ src/tables/gridkeys.rs | 255 +++++++++++++ src/tables/mod.rs | 9 + src/tables/model.rs | 17 +- src/tables/pivot.rs | 238 ++++++++++++ src/tables/sheets.rs | 166 +++++++++ src/tables/tests.rs | 48 +++ src/tables/tree_table.rs | 185 ++++++++++ src/theme/tokens.rs | 5 + tasks/ch11-20.md | 8 +- 14 files changed, 1914 insertions(+), 10 deletions(-) create mode 100644 examples/gallery/pages/tables/grids.rs create mode 100644 src/tables/formula.rs create mode 100644 src/tables/grid.rs create mode 100644 src/tables/gridkeys.rs create mode 100644 src/tables/pivot.rs create mode 100644 src/tables/sheets.rs create mode 100644 src/tables/tree_table.rs diff --git a/TASKS.md b/TASKS.md index 2e0c888..0880090 100644 --- a/TASKS.md +++ b/TASKS.md @@ -195,3 +195,4 @@ Per-component lines live in `tasks/`. Tags there: | T13c | 1 | — | pending: codex still logged out | | T14a | 1 | — | pending: codex still logged out | | T14b | 1 | — | pending: codex still logged out | +| T14c | 1 | — | pending review | diff --git a/examples/gallery/pages/tables/grids.rs b/examples/gallery/pages/tables/grids.rs new file mode 100644 index 0000000..06a2924 --- /dev/null +++ b/examples/gallery/pages/tables/grids.rs @@ -0,0 +1,214 @@ +use std::{collections::HashMap, rc::Rc}; + +use ely_gpui_component::{ + tables::{Aggregate, Cell, Column, DataGrid, PivotTable, Spreadsheet, TreeRow, TreeTable}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +type Cells = HashMap<(usize, usize), SharedString>; + +pub fn data_grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep( + "grid-rows", + || { + [ + ["Rent", "2400", "2400", "2400"], + ["Salaries", "18000", "18500", "19000"], + ["Software", "1200", "1250", "1300"], + ["Travel", "900", "400", "1600"], + ] + .map(|row| row.map(SharedString::from).to_vec()) + .to_vec() + }, + window, + cx, + ); + let (rows, store) = (kept.read(cx).clone(), kept.clone()); + section( + "DataGrid", + "Cells to edit like a spreadsheet's. Arrows walk, Shift stretches a range, typing or a double press edits, Delete clears, and Cmd-C and Cmd-V copy and paste.", + cx, + ) + .child(probe( + "grid", + div().child( + DataGrid::new("grid", ["Item", "Q1", "Q2", "Q3"], rows) + .on_change(move |edits, _, cx| { + let mut next = store.read(cx).clone(); + for (row, col, text) in edits { + next[*row][*col] = text.clone(); + } + set(&store, next, cx) + }) + .w(px(386.)) + .h(px(150.)), + ), + )) +} + +fn budget() -> Cells { + let mut cells = Cells::new(); + let mut put = |row: usize, col: usize, text: &str| { + cells.insert((row, col), text.to_string().into()); + }; + put(0, 0, "Quarterly budget"); + for (col, title) in ["Item", "Q1", "Q2", "Q3", "Q4", "Total"].iter().enumerate() { + put(1, col, title); + } + let items = [ + ("Rent", 2400.0), + ("Salaries", 18000.0), + ("Software", 1200.0), + ("Travel", 900.0), + ("Marketing", 3000.0), + ]; + for (ix, (item, base)) in items.iter().enumerate() { + let row = ix + 2; + put(row, 0, item); + for quarter in 0..4 { + put( + row, + quarter + 1, + &format!("{:.0}", base * (1.0 + 0.04 * quarter as f64)), + ); + } + put(row, 5, &format!("=SUM(B{0}:E{0})", row + 1)); + } + put(7, 0, "Total"); + for col in 1..6 { + let letter = (b'A' + col as u8) as char; + put(7, col, &format!("=SUM({letter}3:{letter}7)")); + } + put(8, 0, "Share of rent"); + put(8, 5, "=F3/F8*100"); + cells +} + +pub fn spreadsheet(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep("sheet-cells", || Rc::new(budget()), window, cx); + let (cells, store) = (kept.read(cx).clone(), kept.clone()); + section( + "Spreadsheet", + "Lettered columns and numbered rows. Formulas such as =SUM(B3:B7) recalculate as cells change; the title spans six merged cells; the first two rows and the first column stay put. Drag the dot at the range's corner to fill a series or carry a formula on.", + cx, + ) + .child(probe( + "sheet", + div().child( + Spreadsheet::new("sheet", 40, 12) + .cells(cells) + .merge((0, 0), (0, 5)) + .freeze(2, 1) + .on_change(move |edits, _, cx| { + let mut next = (**store.read(cx)).clone(); + for (row, col, text) in edits { + if text.is_empty() { + next.remove(&(*row, *col)); + } else { + next.insert((*row, *col), text.clone()); + } + } + set(&store, Rc::new(next), cx) + }) + .w(px(640.)) + .h(px(300.)), + ), + )) +} + +pub fn tree_table(cx: &mut App) -> impl IntoElement + use<> { + let folder = |key: &str, name: &str, size: f64, files: f64, children: Vec| { + TreeRow::new( + key.to_string(), + [name.to_string().into(), size.into(), files.into()], + ) + .children(children) + }; + let leaf = |key: &str, name: &str, size: f64| { + TreeRow::new( + key.to_string(), + [name.to_string().into(), size.into(), 1.0.into()], + ) + }; + let rows = [ + folder( + "src", + "src", + 842.0, + 64.0, + vec![ + folder( + "ui", + "ui", + 512.0, + 38.0, + vec![ + leaf("button", "button.rs", 18.0), + leaf("table", "table.rs", 41.0), + ], + ), + leaf("main", "main.rs", 6.0), + ], + ), + folder( + "assets", + "assets", + 2480.0, + 12.0, + vec![ + leaf("inter", "Inter.ttf", 804.0), + leaf("mono", "JetBrains Mono.ttf", 268.0), + ], + ), + leaf("cargo", "Cargo.toml", 2.0), + ]; + section( + "TreeTable", + "Rows that open and close under columns; the first column steps in by depth.", + cx, + ) + .child( + div().w(px(560.)).child( + TreeTable::new( + "sizes", + [ + Column::new("name", "Name"), + Column::new("size", "Size") + .end() + .suffix(" KB") + .width(rems(7.)), + Column::new("files", "Files").end().width(rems(5.)), + ], + rows, + ) + .open(["src"]), + ), + ) +} + +pub fn pivot(cx: &mut App) -> impl IntoElement + use<> { + let records: Vec> = [ + ("Europe", "Q1", 120.0), + ("Europe", "Q2", 132.0), + ("Europe", "Q3", 128.0), + ("Americas", "Q1", 180.0), + ("Americas", "Q2", 171.0), + ("Americas", "Q3", 199.0), + ("Asia", "Q1", 90.0), + ("Asia", "Q2", 118.0), + ("Asia", "Q3", 141.0), + ("Europe", "Q3", 22.0), + ] + .into_iter() + .map(|(region, quarter, sales)| vec![region.into(), quarter.into(), sales.into()]) + .collect(); + section("PivotTable", "Sales cross-tabbed: regions down the side, quarters across, summed where they meet, with totals.", cx) + .child(div().w(px(560.)).child(PivotTable::new(["Region", "Quarter", "Sales"], records).pivot("Region", "Quarter", "Sales", Aggregate::Sum))) + .child(Caption::new("Sales in thousands.")) +} diff --git a/examples/gallery/pages/tables/mod.rs b/examples/gallery/pages/tables/mod.rs index 6c039db..498082c 100644 --- a/examples/gallery/pages/tables/mod.rs +++ b/examples/gallery/pages/tables/mod.rs @@ -1,5 +1,6 @@ mod core; mod features; +mod grids; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -43,6 +44,14 @@ const SCRIPT: &[Step] = &[ Step::UpAt("grouped", 60.0, 54.0), Step::Wait(300), Step::Shot("folded"), + Step::Hover("sheet"), + Step::Wait(300), + Step::DownAt("sheet", 184.0, 294.0), + Step::UpAt("sheet", 184.0, 294.0), + Step::Type("=F8/4"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("sheet-formula"), Step::Rest, ]; @@ -53,6 +62,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(features::wide(cx)) .child(features::rows(window, cx)) .child(features::grouped(cx)) + .child(grids::data_grid(window, cx)) + .child(grids::spreadsheet(window, cx)) + .child(grids::tree_table(cx)) + .child(grids::pivot(cx)) .child(core::long(window, cx)) .child(core::heatmap(cx)) .into_any_element() diff --git a/src/tables/formula.rs b/src/tables/formula.rs new file mode 100644 index 0000000..9e33da1 --- /dev/null +++ b/src/tables/formula.rs @@ -0,0 +1,287 @@ +use std::sync::LazyLock; + +use gpui::SharedString; +use regex::{Captures, Regex}; + +use crate::{ + forms::evaluate, + typography::format::{self, Separators}, +}; + +static RANGE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b([A-Z]+)\(\s*([A-Z]+[0-9]+)\s*:\s*([A-Z]+[0-9]+)\s*\)") + .expect("a range pattern") +}); +static CALL: LazyLock = + LazyLock::new(|| Regex::new(r"\b([A-Za-z]+)\(").expect("a call pattern")); +static REFERENCE: LazyLock = + LazyLock::new(|| Regex::new(r"\b([A-Z]+)([0-9]+)\b").expect("a reference pattern")); + +/// How deep formulas may lean on each other before a loop is assumed. +const DEPTH: usize = 64; + +/// A column's letters: A to Z, then AA. +pub(crate) fn letters(mut col: usize) -> String { + let mut out = Vec::new(); + loop { + out.push(b'A' + (col % 26) as u8); + if col < 26 { + break; + } + col = col / 26 - 1; + } + out.reverse(); + String::from_utf8(out).expect("letters are ASCII") +} + +/// A cell's name, as B3 for row 2 and column 1, counted from zero. +pub(crate) fn name(row: usize, col: usize) -> String { + format!("{}{}", letters(col), row + 1) +} + +/// The row and column an A1 name points to, counted from zero. +pub(crate) fn place(text: &str) -> Option<(usize, usize)> { + let caps = REFERENCE + .captures(text) + .filter(|caps| caps.get(0).is_some_and(|all| all.as_str() == text))?; + let col = caps[1].bytes().fold(0usize, |col, letter| { + col * 26 + usize::from(letter - b'A' + 1) + }) - 1; + let row = caps[2].parse::().ok()?.checked_sub(1)?; + Some((row, col)) +} + +/// Every cell name in a rectangle, row by row. +fn span(from: &str, to: &str) -> Result, String> { + let ((r0, c0), (r1, c1)) = ( + place(from).ok_or("a bad cell")?, + place(to).ok_or("a bad cell")?, + ); + let (rows, cols) = (r0.min(r1)..=r0.max(r1), c0.min(c1)..=c0.max(c1)); + Ok(rows + .flat_map(|row| cols.clone().map(move |col| name(row, col))) + .collect()) +} + +/// A formula with its ranges written out and its functions in the evaluator's words. +fn expand(source: &str) -> Result { + let mut failure = None; + let written = RANGE.replace_all(source, |caps: &Captures| { + let cells = match span(&caps[2], &caps[3]) { + Ok(cells) => cells, + Err(error) => { + failure = Some(error); + return String::new(); + } + }; + let (sum, count) = (cells.join("+"), cells.len()); + match caps[1].to_uppercase().as_str() { + "SUM" => format!("({sum})"), + "AVERAGE" | "AVG" => format!("(({sum})/{count})"), + "MIN" => format!("min({})", cells.join(",")), + "MAX" => format!("max({})", cells.join(",")), + "COUNT" => count.to_string(), + other => { + failure = Some(format!("{other} takes no range")); + String::new() + } + } + }); + if let Some(error) = failure { + return Err(error); + } + Ok(CALL + .replace_all(&written, |caps: &Captures| { + format!("{}(", caps[1].to_lowercase()) + }) + .into_owned()) +} + +/// A cell's number: its own, or its formula's result; text and blanks have none. +pub(crate) fn value( + cells: &dyn Fn(usize, usize) -> SharedString, + row: usize, + col: usize, + depth: usize, +) -> Result, String> { + let raw = cells(row, col); + match raw.strip_prefix('=') { + Some(source) => formula(cells, source, depth).map(Some), + None => Ok(raw.trim().parse::().ok()), + } +} + +fn formula( + cells: &dyn Fn(usize, usize) -> SharedString, + source: &str, + depth: usize, +) -> Result { + if depth > DEPTH { + return Err("a loop".into()); + } + let expanded = expand(source)?; + let mut variables: Vec<(SharedString, f64)> = Vec::new(); + for caps in REFERENCE.captures_iter(&expanded) { + let reference = caps[0].to_string(); + if variables + .iter() + .any(|(known, _)| known.as_ref() == reference) + { + continue; + } + let (row, col) = place(&reference).ok_or_else(|| format!("{reference} is not a cell"))?; + let number = value(cells, row, col, depth + 1)?.unwrap_or(0.0); + variables.push((reference.into(), number)); + } + evaluate(&expanded, &variables) +} + +/// What a cell shows: a formula's result, or #ERR when it fails; other text as written. +pub(crate) fn shown( + cells: &dyn Fn(usize, usize) -> SharedString, + row: usize, + col: usize, +) -> SharedString { + let raw = cells(row, col); + if !raw.starts_with('=') { + return raw; + } + match value(cells, row, col, 0) { + Ok(Some(number)) => format::number( + number, + if number.fract() == 0.0 { 0 } else { 2 }, + Separators::EN, + ) + .into(), + Ok(None) => SharedString::default(), + Err(error) => { + log::debug!("spreadsheet: {} failed: {error}", name(row, col)); + "#ERR".into() + } + } +} + +/// A formula with each reference moved by `rows` and `cols`, as a fill carries it. +pub(crate) fn shifted(source: &str, rows: isize, cols: isize) -> String { + REFERENCE + .replace_all(source, |caps: &Captures| match place(&caps[0]) { + Some((row, col)) => { + let (row, col) = (row as isize + rows, col as isize + cols); + if row < 0 || col < 0 { + "#REF".to_string() + } else { + name(row as usize, col as usize) + } + } + None => caps[0].to_string(), + }) + .into_owned() +} + +/// What a fill writes into `count` cells past a run, down or `across`: numbers that step evenly keep stepping; a formula shifts; anything else repeats in turn. +pub(crate) fn filled(run: &[SharedString], count: usize, across: bool) -> Vec { + let numbers: Option> = run + .iter() + .map(|text| text.trim().parse::().ok()) + .collect(); + if let Some(numbers) = numbers.filter(|numbers| numbers.len() >= 2) { + let step = numbers[1] - numbers[0]; + let even = numbers + .windows(2) + .all(|pair| (pair[1] - pair[0] - step).abs() < 1e-9); + if even { + let last = numbers[numbers.len() - 1]; + return (1..=count) + .map(|ix| format!("{}", last + step * ix as f64).into()) + .collect(); + } + } + (0..count) + .map(|ix| { + let source = &run[ix % run.len()]; + let distance = (run.len() + ix - ix % run.len()) as isize; + match source.strip_prefix('=') { + Some(formula) if across => format!("={}", shifted(formula, 0, distance)).into(), + Some(formula) => format!("={}", shifted(formula, distance, 0)).into(), + None => source.clone(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sheet(rows: &[&[&'static str]]) -> impl Fn(usize, usize) -> SharedString { + let rows: Vec> = rows + .iter() + .map(|row| row.iter().map(|cell| SharedString::from(*cell)).collect()) + .collect(); + move |row, col| { + rows.get(row) + .and_then(|cells| cells.get(col)) + .cloned() + .unwrap_or_default() + } + } + + #[test] + fn names_and_places_meet() { + assert_eq!(letters(0), "A"); + assert_eq!(letters(25), "Z"); + assert_eq!(letters(26), "AA"); + assert_eq!(name(2, 1), "B3"); + assert_eq!(place("B3"), Some((2, 1))); + assert_eq!(place("AA10"), Some((9, 26))); + assert_eq!(place("B0"), None); + } + + #[test] + fn formulas_add_ranges_and_lean_on_each_other() { + let cells = sheet(&[ + &["4", "=A1*2", "=SUM(A1:B1)"], + &["6", "=average(A1:A2)", "=MAX(A1:B2)"], + &["x", "=A3+1", "=C3"], + ]); + assert_eq!(shown(&cells, 0, 1), "8"); + assert_eq!(shown(&cells, 0, 2), "12"); + assert_eq!(shown(&cells, 1, 1), "5"); + assert_eq!(shown(&cells, 1, 2), "8"); + assert_eq!(shown(&cells, 2, 1), "1", "text counts as nothing"); + assert_eq!( + shown(&cells, 2, 2), + "#ERR", + "a cell leaning on itself is a loop" + ); + let grouped = sheet(&[&["4", "6", "=SUM(A1:B1)*2"]]); + assert_eq!( + shown(&grouped, 0, 2), + "20", + "a range sums before it multiplies" + ); + } + + #[test] + fn fills_step_numbers_shift_formulas_and_repeat_text() { + let texts = |list: &[&'static str]| { + list.iter() + .map(|text| SharedString::from(*text)) + .collect::>() + }; + assert_eq!( + filled(&texts(&["2", "4"]), 3, false), + texts(&["6", "8", "10"]) + ); + assert_eq!( + filled(&texts(&["=A1*2"]), 2, false), + texts(&["=A2*2", "=A3*2"]) + ); + assert_eq!(filled(&texts(&["=A1*2"]), 1, true), texts(&["=B1*2"])); + assert_eq!( + filled(&texts(&["Mon", "Tue"]), 3, false), + texts(&["Mon", "Tue", "Mon"]) + ); + assert_eq!(shifted("B2-A1", -1, 0), "B1-#REF"); + } +} diff --git a/src/tables/grid.rs b/src/tables/grid.rs new file mode 100644 index 0000000..3f10488 --- /dev/null +++ b/src/tables/grid.rs @@ -0,0 +1,478 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Bounds, Div, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, + InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, Pixels, Point, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, point, prelude::*, +}; + +use super::gridkeys::{begin, fill, keys}; +use crate::{ + forms::{Editing, Input}, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +pub(crate) type OnCells = Rc; +pub(crate) type Text = Rc SharedString>; + +/// A rectangle of cells shown as one, from its first corner to its last. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Merge { + pub from: (usize, usize), + pub to: (usize, usize), +} + +/// A grid's own state: the cursor and the corner its range reaches from, whether it edits, how far it has scrolled, its bounds, and where a fill would reach. +#[derive(Default)] +pub(crate) struct Sheet { + pub cursor: (usize, usize), + pub anchor: (usize, usize), + pub editing: bool, + pub offset: Point, + pub bounds: Bounds, + pub fill: Option<(usize, usize)>, + pub rows: usize, +} + +/// What a grid face supplies: its size, its labels, the text a cell holds and shows, and who hears edits. +pub(crate) struct Face { + pub id: ElementId, + pub rows: usize, + pub cols: usize, + pub titles: Rc SharedString>, + pub numbered: bool, + pub raw: Text, + pub shown: Text, + pub merges: Vec, + pub frozen: (usize, usize), + pub fills: bool, + pub on_change: Option, +} + +/// Sizes in pixels: a cell, a header, the row labels, and the frozen stretch. +#[derive(Clone, Copy)] +pub(crate) struct Metrics { + pub wide: Pixels, + pub tall: Pixels, + pub label: Pixels, + pub frozen: Point, +} + +impl Metrics { + /// The cell under a point within the grid, if any. + pub(crate) fn cell_at( + &self, + at: Point, + offset: Point, + face: (usize, usize), + ) -> Option<(usize, usize)> { + let (x, y) = (at.x - self.label, at.y - self.tall); + if x < Pixels::ZERO || y < Pixels::ZERO { + return None; + } + let x = if x < self.frozen.x { x } else { x + offset.x }; + let y = if y < self.frozen.y { y } else { y + offset.y }; + let (col, row) = ((x / self.wide) as usize, (y / self.tall) as usize); + (row < face.0 && col < face.1).then_some((row, col)) + } +} + +/// A drag of a grid's fill handle, named by its owner. +pub(crate) struct FillDrag { + owner: EntityId, +} + +/// The range the cursor and its anchor span, as top-left and bottom-right. +pub(crate) fn range(sheet: &Sheet) -> ((usize, usize), (usize, usize)) { + let ((r0, c0), (r1, c1)) = (sheet.anchor, sheet.cursor); + ((r0.min(r1), c0.min(c1)), (r0.max(r1), c0.max(c1))) +} + +/// Draws a grid face: headers, labels, visible cells, the range, the cursor and its fill handle. +pub(crate) fn grid(face: Face, base: Div, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = face.id.clone(); + let focus: FocusHandle = + crate::primitives::tab_stop((id.clone(), "focus").into(), true, window, cx); + let sheet: Entity = + window.use_keyed_state((id.clone(), "sheet"), cx, |_, _| Sheet::default()); + let editor = window.use_keyed_state((id.clone(), "editor"), cx, |_, _| Editing::default()); + sheet.update(cx, |sheet, _| sheet.rows = face.rows); + if sheet.read(cx).editing && editor.read(cx).field().is_none() { + sheet.update(cx, |sheet, _| sheet.editing = false); + window.focus(&focus); + } + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rem = window.rem_size(); + let metrics = Metrics { + wide: theme.grid_column().to_pixels(rem), + tall: theme + .table_row(crate::theme::Density::Compact) + .to_pixels(rem), + label: if face.numbered { + theme.control_height(ControlSize::Lg).to_pixels(rem) * 1.25 + } else { + Pixels::ZERO + }, + frozen: Point::default(), + }; + let metrics = Metrics { + frozen: point( + metrics.wide * face.frozen.1 as f32, + metrics.tall * face.frozen.0 as f32, + ), + ..metrics + }; + let (rows, cols) = (face.rows, face.cols); + let (offset, bounds, editing, fill_to) = { + let sheet = sheet.read(cx); + (sheet.offset, sheet.bounds, sheet.editing, sheet.fill) + }; + let ((top, left), (bottom, right)) = range(sheet.read(cx)); + let cursor = sheet.read(cx).cursor; + let focused = focus.contains_focused(window, cx); + let span = |from: Pixels, cell: Pixels, count: usize, size: Pixels| { + let first = (from / cell).floor() as usize; + let last = ((from + size) / cell).ceil() as usize; + first.min(count)..last.min(count) + }; + let view_rows = span( + offset.y + metrics.frozen.y, + metrics.tall, + rows, + bounds.size.height, + ); + let view_cols = span( + offset.x + metrics.frozen.x, + metrics.wide, + cols, + bounds.size.width, + ); + let x_of = |col: usize| { + let x = metrics.wide * col as f32; + metrics.label + if col < face.frozen.1 { x } else { x - offset.x } + }; + let y_of = |row: usize| { + let y = metrics.tall * row as f32; + metrics.tall + if row < face.frozen.0 { y } else { y - offset.y } + }; + let merged = |row: usize, col: usize| { + face.merges.iter().find(|merge| { + (merge.from.0..=merge.to.0).contains(&row) && (merge.from.1..=merge.to.1).contains(&col) + }) + }; + let field = editing.then(|| editor.read(cx).field()).flatten(); + let cell = |row: usize, col: usize| -> Option { + let merge = merged(row, col); + if merge.is_some_and(|merge| merge.from != (row, col)) { + return None; + } + let (wide, tall) = match merge { + Some(merge) => ( + metrics.wide * (merge.to.1 - col + 1) as f32, + metrics.tall * (merge.to.0 - row + 1) as f32, + ), + None => (metrics.wide, metrics.tall), + }; + let text = (face.shown)(row, col); + let number = text.trim().replace(',', "").parse::().is_ok(); + let content: AnyElement = match (&field, (row, col) == cursor) { + (Some(field), true) => { + let editor = editor.clone(); + div() + .size_full() + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + editor.update(cx, |editing, cx| editing.finish(true, window, cx)); + } + }) + .child(Input::new(field).size(ControlSize::Sm)) + .into_any_element() + } + _ => div().min_w_0().truncate().child(text).into_any_element(), + }; + Some( + div() + .absolute() + .left(x_of(col)) + .top(y_of(row)) + .w(wide) + .h(tall) + .flex() + .items_center() + .when(number, |cell| cell.justify_end()) + .px_2() + .border_r_1() + .border_b_1() + .border_color(colors.border) + .bg(colors.bg) + .child(content) + .into_any_element(), + ) + }; + let frozen_rows = 0..face.frozen.0.min(rows); + let frozen_cols = 0..face.frozen.1.min(cols); + let layer = |rows: std::ops::Range, cols: std::ops::Range| { + rows.flat_map(move |row| cols.clone().map(move |col| (row, col))) + .filter_map(|(row, col)| cell(row, col)) + .collect::>() + }; + let main = layer(view_rows.clone(), view_cols.clone()); + let top_band = layer(frozen_rows.clone(), view_cols.clone()); + let left_band = layer(view_rows.clone(), frozen_cols.clone()); + let corner_band = layer(frozen_rows, frozen_cols); + let headers = view_cols + .clone() + .chain(0..face.frozen.1.min(cols)) + .map(|col| { + div() + .absolute() + .left(x_of(col)) + .top_0() + .w(metrics.wide) + .h(metrics.tall) + .flex() + .items_center() + .justify_center() + .border_r_1() + .border_b_1() + .border_color(colors.border) + .bg(if (left..=right).contains(&col) && focused { + colors.active + } else { + colors.sunken + }) + .text_color(colors.fg_muted) + .child((face.titles)(col)) + }) + .collect::>(); + let labels = face.numbered.then(|| { + view_rows + .clone() + .chain(0..face.frozen.0.min(rows)) + .map(|row| { + div() + .absolute() + .left_0() + .top(y_of(row)) + .w(metrics.label) + .h(metrics.tall) + .flex() + .items_center() + .justify_center() + .border_r_1() + .border_b_1() + .border_color(colors.border) + .bg(if (top..=bottom).contains(&row) && focused { + colors.active + } else { + colors.sunken + }) + .text_color(colors.fg_muted) + .child((row + 1).to_string()) + }) + .collect::>() + }); + let area = Bounds::new( + point(x_of(left), y_of(top)), + gpui::size( + metrics.wide * (right - left + 1) as f32, + metrics.tall * (bottom - top + 1) as f32, + ), + ); + let owner = sheet.entity_id(); + let marks = [ + div() + .absolute() + .left(area.origin.x) + .top(area.origin.y) + .w(area.size.width) + .h(area.size.height) + .bg(colors.selection) + .border_1() + .border_color(colors.accent.opacity(0.6)) + .into_any_element(), + div() + .absolute() + .left(x_of(cursor.1)) + .top(y_of(cursor.0)) + .w(metrics.wide) + .h(metrics.tall) + .border_2() + .border_color(colors.accent) + .into_any_element(), + ]; + let preview = fill_to.map(|(row, col)| { + let down = row > bottom; + let (to_row, to_col) = if down { + (row, right) + } else { + (bottom, col.max(right)) + }; + div() + .absolute() + .left(area.origin.x) + .top(area.origin.y) + .w(metrics.wide * (to_col - left + 1) as f32) + .h(metrics.tall * (to_row - top + 1) as f32) + .border_1() + .border_color(colors.fg_muted) + }); + let handle = (face.fills && !editing).then(|| { + let reach = theme.status_dot(); + div() + .id((id.clone(), "fill")) + .absolute() + .left(area.origin.x + area.size.width - reach.to_pixels(rem) * 0.5) + .top(area.origin.y + area.size.height - reach.to_pixels(rem) * 0.5) + .size(reach) + .bg(colors.accent) + .border_1() + .border_color(colors.bg) + .cursor_crosshair() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_drag(FillDrag { owner }, |_, _, _, cx| { + cx.new(|_| gpui::EmptyView) + }) + }); + let (face_rows, face_cols) = (rows, cols); + let (pressed, dragged, moved, measured, scrolled, dropped) = ( + sheet.clone(), + sheet.clone(), + sheet.clone(), + sheet.clone(), + sheet.clone(), + sheet.clone(), + ); + let (raw, on_change, begin_with) = (face.raw.clone(), face.on_change.clone(), editor.clone()); + let (fill_raw, fill_hears) = (face.raw.clone(), face.on_change.clone()); + let numbered = face.numbered; + let keyed = keys(face, metrics, sheet.clone(), editor.clone()); + let focus_on_press = focus.clone(); + let text_size = theme.text_size(TextSize::Sm); + base.id(id.clone()) + .track_focus(&focus) + .relative() + .overflow_hidden() + .text_size(text_size) + .text_color(colors.fg) + .bg(colors.bg) + .border_1() + .border_color(colors.border) + .on_key_down(keyed) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + let at = event.position - pressed.read(cx).bounds.origin; + let Some(cell) = metrics.cell_at(at, pressed.read(cx).offset, (face_rows, face_cols)) + else { + return; + }; + window.focus(&focus_on_press); + pressed.update(cx, |sheet, cx| { + sheet.cursor = cell; + if !event.modifiers.shift { + sheet.anchor = cell; + } + cx.notify(); + }); + if event.click_count == 2 { + let text = raw(cell.0, cell.1).to_string(); + begin( + &pressed, + &begin_with, + on_change.clone(), + text, + false, + window, + cx, + ); + } + }) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + if event.pressed_button != Some(MouseButton::Left) || cx.has_active_drag() { + return; + } + let at = event.position - moved.read(cx).bounds.origin; + if let Some(cell) = metrics.cell_at(at, moved.read(cx).offset, (face_rows, face_cols)) { + moved.update(cx, |sheet, cx| { + if sheet.cursor != cell && !sheet.editing { + sheet.cursor = cell; + cx.notify(); + } + }); + } + }) + .on_scroll_wheel(move |event, _, cx| { + let delta = event.delta.pixel_delta(metrics.tall); + scrolled.update(cx, |sheet, cx| { + let most = point( + (metrics.wide * face_cols as f32 + metrics.label - sheet.bounds.size.width) + .max(Pixels::ZERO), + (metrics.tall * (face_rows + 1) as f32 - sheet.bounds.size.height) + .max(Pixels::ZERO), + ); + sheet.offset = point( + (sheet.offset.x - delta.x).clamp(Pixels::ZERO, most.x), + (sheet.offset.y - delta.y).clamp(Pixels::ZERO, most.y), + ); + cx.notify(); + }); + cx.stop_propagation(); + }) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + if event.drag(cx).owner != owner { + return; + } + let at = event.event.position - dragged.read(cx).bounds.origin; + let cell = metrics.cell_at(at, dragged.read(cx).offset, (face_rows, face_cols)); + dragged.update(cx, |sheet, cx| { + if sheet.fill != cell { + sheet.fill = cell; + cx.notify(); + } + }); + }) + .on_drop(move |drag: &FillDrag, window, cx| { + if drag.owner == owner { + fill(&dropped, &fill_raw, &fill_hears, window, cx); + } + }) + .children(main) + .children(top_band) + .children(left_band) + .children(corner_band) + .children(marks) + .children(preview) + .children(handle) + .children(headers) + .children(labels.into_iter().flatten()) + .when(numbered, |grid| { + grid.child( + div() + .absolute() + .left_0() + .top_0() + .w(metrics.label) + .h(metrics.tall) + .bg(colors.sunken) + .border_r_1() + .border_b_1() + .border_color(colors.border), + ) + }) + .child( + canvas( + move |bounds, _, cx| { + if measured.read(cx).bounds != bounds { + measured.update(cx, |sheet, cx| { + sheet.bounds = bounds; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) +} diff --git a/src/tables/gridkeys.rs b/src/tables/gridkeys.rs new file mode 100644 index 0000000..fc3d044 --- /dev/null +++ b/src/tables/gridkeys.rs @@ -0,0 +1,255 @@ +use std::rc::Rc; + +use gpui::{App, ClipboardItem, Entity, KeyDownEvent, Pixels, SharedString, Window}; + +use super::{ + formula::filled, + grid::{Face, Metrics, OnCells, Sheet, Text, range}, +}; +use crate::forms::Editing; + +/// Scrolls just enough that the cursor shows, past the frozen rows and columns. +pub(crate) fn reveal(sheet: &mut Sheet, metrics: Metrics, frozen: (usize, usize)) { + let (row, col) = sheet.cursor; + let room_x = sheet.bounds.size.width - metrics.label - metrics.frozen.x; + let room_y = sheet.bounds.size.height - metrics.tall - metrics.frozen.y; + if col >= frozen.1 { + let x = metrics.wide * col as f32 - metrics.frozen.x; + sheet.offset.x = sheet + .offset + .x + .min(x) + .max(x + metrics.wide - room_x) + .max(Pixels::ZERO); + } + if row >= frozen.0 { + let y = metrics.tall * row as f32 - metrics.frozen.y; + sheet.offset.y = sheet + .offset + .y + .min(y) + .max(y + metrics.tall - room_y) + .max(Pixels::ZERO); + } +} + +/// Opens the cursor's cell with `text`; a kept edit reports and moves down. +pub(crate) fn begin( + sheet: &Entity, + editor: &Entity, + on_change: Option, + text: String, + at_end: bool, + window: &mut Window, + cx: &mut App, +) { + let (cell, rows) = (sheet.read(cx).cursor, sheet.read(cx).rows); + let moved = sheet.clone(); + editor.update(cx, |editing, _| { + editing.on_commit = Some(Rc::new(move |text, window, cx| { + log::info!("grid: {cell:?} is now {text}"); + if let Some(on_change) = &on_change { + on_change(&[(cell.0, cell.1, text.clone())], window, cx); + } + moved.update(cx, |sheet, _| { + sheet.cursor.0 = (cell.0 + 1).min(rows.saturating_sub(1)); + sheet.anchor = sheet.cursor; + }); + })) + }); + sheet.update(cx, |sheet, _| sheet.editing = true); + let end = text.len(); + Editing::begin(editor, text, window, cx); + if at_end && let Some(field) = editor.read(cx).field() { + field.update(cx, |field, cx| field.select(end..end, cx)); + } +} + +/// Text for the clipboard: the range's cells as they show, tabs between, lines below. +fn copied(shown: &Text, sheet: &Sheet) -> String { + let ((top, left), (bottom, right)) = range(sheet); + (top..=bottom) + .map(|row| { + (left..=right) + .map(|col| shown(row, col).to_string()) + .collect::>() + .join("\t") + }) + .collect::>() + .join("\n") +} + +/// Pasted text as cell edits from the cursor on, clipped to the grid. +pub(crate) fn pasted( + text: &str, + from: (usize, usize), + size: (usize, usize), +) -> Vec<(usize, usize, SharedString)> { + text.lines() + .enumerate() + .flat_map(|(dr, line)| { + line.split('\t').enumerate().map(move |(dc, cell)| { + ( + from.0 + dr, + from.1 + dc, + SharedString::from(cell.to_string()), + ) + }) + }) + .filter(|(row, col, _)| *row < size.0 && *col < size.1) + .collect() +} + +/// The key handler: arrows walk and Shift stretches the range, Tab and Enter step, typing or F2 edits, Delete clears, Cmd-C and Cmd-V copy and paste. +pub(crate) fn keys( + face: Face, + metrics: Metrics, + sheet: Entity, + editor: Entity, +) -> impl Fn(&KeyDownEvent, &mut Window, &mut App) + 'static { + move |event, window, cx| { + if sheet.read(cx).editing { + return; + } + let (rows, cols) = (face.rows, face.cols); + if rows == 0 || cols == 0 { + return; + } + let stroke = &event.keystroke; + let held = &stroke.modifiers; + let (row, col) = sheet.read(cx).cursor; + let to = match stroke.key.as_str() { + "up" => Some((row.saturating_sub(1), col)), + "down" | "enter" => Some(((row + 1).min(rows - 1), col)), + "left" => Some((row, col.saturating_sub(1))), + "right" => Some((row, (col + 1).min(cols - 1))), + "tab" if held.shift => Some((row, col.saturating_sub(1))), + "tab" => Some((row, (col + 1).min(cols - 1))), + "home" => Some((row, 0)), + "end" => Some((row, cols - 1)), + _ => None, + }; + if let Some(to) = to { + cx.stop_propagation(); + let stretch = held.shift && !matches!(stroke.key.as_str(), "tab" | "enter"); + sheet.update(cx, |sheet, cx| { + sheet.cursor = to; + if !stretch { + sheet.anchor = to; + } + reveal(sheet, metrics, face.frozen); + cx.notify(); + }); + return; + } + match (stroke.key.as_str(), held.platform) { + ("escape", _) => sheet.update(cx, |sheet, cx| { + sheet.anchor = sheet.cursor; + cx.notify(); + }), + ("f2", _) => begin( + &sheet, + &editor, + face.on_change.clone(), + (face.raw)(row, col).to_string(), + true, + window, + cx, + ), + ("backspace" | "delete", false) => { + let ((top, left), (bottom, right)) = range(sheet.read(cx)); + let cleared: Vec<_> = (top..=bottom) + .flat_map(|row| { + (left..=right).map(move |col| (row, col, SharedString::default())) + }) + .collect(); + if let Some(on_change) = &face.on_change { + on_change(&cleared, window, cx); + } + } + ("c", true) => cx.write_to_clipboard(ClipboardItem::new_string(copied( + &face.shown, + sheet.read(cx), + ))), + ("v", true) => { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + let edits = pasted(&text, (row, col), (rows, cols)); + log::info!("grid: pasted {} cells", edits.len()); + if let Some(on_change) = &face.on_change { + on_change(&edits, window, cx); + } + } + _ => { + let typed = stroke.key_char.clone().filter(|typed| { + !held.platform && !held.control && !typed.chars().any(char::is_control) + }); + let Some(typed) = typed else { + return; + }; + begin( + &sheet, + &editor, + face.on_change.clone(), + typed, + true, + window, + cx, + ); + } + } + cx.stop_propagation(); + } +} + +/// Applies a fill: the range's runs carry on down, or across, to where the handle was dropped. +pub(crate) fn fill( + sheet: &Entity, + raw: &Text, + on_change: &Option, + window: &mut Window, + cx: &mut App, +) { + let Some((row, col)) = sheet.update(cx, |sheet, cx| { + cx.notify(); + sheet.fill.take() + }) else { + return; + }; + let ((top, left), (bottom, right)) = range(sheet.read(cx)); + let mut edits = Vec::new(); + if row > bottom { + for c in left..=right { + let run: Vec = (top..=bottom).map(|r| raw(r, c)).collect(); + for (ix, text) in filled(&run, row - bottom, false).into_iter().enumerate() { + edits.push((bottom + 1 + ix, c, text)); + } + } + } else if col > right { + for r in top..=bottom { + let run: Vec = (left..=right).map(|c| raw(r, c)).collect(); + for (ix, text) in filled(&run, col - right, true).into_iter().enumerate() { + edits.push((r, right + 1 + ix, text)); + } + } + } + log::info!("grid: filled {} cells", edits.len()); + if let Some(on_change) = on_change.as_ref().filter(|_| !edits.is_empty()) { + on_change(&edits, window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::pasted; + + #[test] + fn pasted_text_lands_from_the_cursor_and_stops_at_the_edge() { + let edits: Vec<(usize, usize, String)> = pasted("a\tb\nc\td", (1, 1), (3, 2)) + .into_iter() + .map(|(row, col, text)| (row, col, text.to_string())) + .collect(); + assert_eq!(edits, [(1, 1, "a".into()), (2, 1, "c".into())]); + } +} diff --git a/src/tables/mod.rs b/src/tables/mod.rs index 4344b7c..e625521 100644 --- a/src/tables/mod.rs +++ b/src/tables/mod.rs @@ -1,19 +1,28 @@ mod body; mod builders; mod cell; +mod formula; +mod grid; +mod gridkeys; mod header; mod model; mod options; +mod pivot; mod rules; +mod sheets; mod simple; mod table; #[cfg(test)] mod tests; mod toolbar; +mod tree_table; pub use builders::{FilterBuilder, SortBuilder}; pub use cell::{Aggregate, Align, Cell, Column}; +pub use pivot::PivotTable; pub use rules::{FilterRule, SortKey, Test, to_csv}; +pub use sheets::{DataGrid, Spreadsheet}; pub use simple::{HeatmapTable, Table}; pub use table::{DataTable, Row}; pub use toolbar::TableToolbar; +pub use tree_table::{TreeRow, TreeTable}; diff --git a/src/tables/model.rs b/src/tables/model.rs index f03a4f8..ce4c486 100644 --- a/src/tables/model.rs +++ b/src/tables/model.rs @@ -17,12 +17,8 @@ pub(crate) fn filtered(rows: &[Row], query: &str) -> Vec { .collect() } -/// Column `col`'s figure over `order`; none when no cell counts. -pub(crate) fn figure(rows: &[Row], order: &[usize], col: usize, how: Aggregate) -> Option { - let values: Vec = order - .iter() - .filter_map(|ix| rows[*ix].cells[col].number()) - .collect(); +/// Values combined as a figure; none when there are none, save a count. +pub(crate) fn combine(values: &[f64], how: Aggregate) -> Option { if how == Aggregate::Count { return Some(values.len() as f64); } @@ -39,6 +35,15 @@ pub(crate) fn figure(rows: &[Row], order: &[usize], col: usize, how: Aggregate) }) } +/// Column `col`'s figure over `order`; none when no cell counts. +pub(crate) fn figure(rows: &[Row], order: &[usize], col: usize, how: Aggregate) -> Option { + let values: Vec = order + .iter() + .filter_map(|ix| rows[*ix].cells[col].number()) + .collect(); + combine(&values, how) +} + /// The lowest and highest numbers in column `col`, across every row. pub(crate) fn range(rows: &[Row], col: usize) -> Option<(f64, f64)> { rows.iter() diff --git a/src/tables/pivot.rs b/src/tables/pivot.rs new file mode 100644 index 0000000..8bf7d3c --- /dev/null +++ b/src/tables/pivot.rs @@ -0,0 +1,238 @@ +use gpui::{ + App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{Aggregate, Cell, model::combine}; +use crate::{ + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// A cross-tab: labels down the side and across the top, a figure where they meet, and totals. +#[derive(Debug, PartialEq)] +pub(crate) struct Pivot { + pub rows: Vec, + pub cols: Vec, + pub cells: Vec>>, + pub row_totals: Vec>, + pub col_totals: Vec>, + pub grand: Option, +} + +/// Records cross-tabbed by fields `down` and `across`, field `value` combined where they meet; labels keep the order they first come in. +pub(crate) fn pivot( + records: &[Vec], + down: usize, + across: usize, + value: usize, + how: Aggregate, +) -> Pivot { + let mut rows: Vec = Vec::new(); + let mut cols: Vec = Vec::new(); + for record in records { + let (row, col) = (record[down].words(), record[across].words()); + if !rows.contains(&row) { + rows.push(row); + } + if !cols.contains(&col) { + cols.push(col); + } + } + let values = |keep: &dyn Fn(&Vec) -> bool| -> Vec { + records + .iter() + .filter(|record| keep(record)) + .filter_map(|record| record[value].number()) + .collect() + }; + let cells = rows + .iter() + .map(|row| { + cols.iter() + .map(|col| { + combine( + &values(&|r| r[down].words() == *row && r[across].words() == *col), + how, + ) + }) + .collect() + }) + .collect(); + let row_totals = rows + .iter() + .map(|row| combine(&values(&|r| r[down].words() == *row), how)) + .collect(); + let col_totals = cols + .iter() + .map(|col| combine(&values(&|r| r[across].words() == *col), how)) + .collect(); + let grand = combine(&values(&|_| true), how); + Pivot { + rows, + cols, + cells, + row_totals, + col_totals, + grand, + } +} + +/// Records cross-tabbed: one field's values down the side, another's across the top, and a third combined where they meet, with totals for each row, each column, and all. +#[derive(IntoElement)] +pub struct PivotTable { + fields: Vec, + records: Vec>, + by: (SharedString, SharedString, SharedString), + how: Aggregate, + decimals: usize, +} + +impl PivotTable { + /// `fields` name each record's cells in order. + pub fn new( + fields: impl IntoIterator>, + records: impl IntoIterator>, + ) -> Self { + let fields: Vec = fields.into_iter().map(Into::into).collect(); + let records: Vec> = records.into_iter().collect(); + assert!( + records.iter().all(|record| record.len() == fields.len()), + "a record needs a cell per field" + ); + Self { + fields, + records, + by: Default::default(), + how: Aggregate::Sum, + decimals: 0, + } + } + + /// Field `down` down the side, `across` over the top, `value` combined by `how`. + pub fn pivot( + mut self, + down: impl Into, + across: impl Into, + value: impl Into, + how: Aggregate, + ) -> Self { + self.by = (down.into(), across.into(), value.into()); + self.how = how; + self + } + + pub fn decimals(mut self, decimals: usize) -> Self { + self.decimals = decimals; + self + } +} + +impl RenderOnce for PivotTable { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let field = |name: &SharedString| { + self.fields + .iter() + .position(|known| known == name) + .unwrap_or_else(|| panic!("no field {name} to pivot by")) + }; + let (down, across, value) = (field(&self.by.0), field(&self.by.1), field(&self.by.2)); + let table = pivot(&self.records, down, across, value, self.how); + let theme = cx.theme(); + let colors = &theme.colors; + let decimals = self.decimals; + let reads = |figure: Option| { + figure.map_or("—".to_string(), |figure| { + format::number(figure, decimals, format::Separators::EN) + }) + }; + let label = |text: SharedString| { + div() + .w(theme.label_width() * 0.8) + .flex_none() + .px_3() + .truncate() + .child(text) + }; + let number = |text: String, strong: bool| { + tabular(div()) + .flex_1() + .min_w_0() + .px_3() + .flex() + .justify_end() + .when(strong, |cell| cell.font_weight(FontWeight::MEDIUM)) + .child(text) + }; + let line = || { + div() + .flex() + .items_center() + .h(theme.table_row(crate::theme::Density::Standard)) + .border_b_1() + .border_color(colors.border) + }; + let head = line() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(label(format!("{} · {}", self.by.0, self.by.1).into())) + .children(table.cols.iter().map(|col| number(col.to_string(), false))) + .child(number("Total".into(), false)); + let body = table.rows.iter().enumerate().map(|(ix, row)| { + line() + .child(label(row.clone()).text_color(colors.fg_muted)) + .children( + table.cells[ix] + .iter() + .map(|figure| number(reads(*figure), false)), + ) + .child(number(reads(table.row_totals[ix]), true)) + }); + let totals = line() + .bg(colors.sunken) + .child(label("Total".into()).font_weight(FontWeight::MEDIUM)) + .children( + table + .col_totals + .iter() + .map(|figure| number(reads(*figure), true)), + ) + .child(number(reads(table.grand), true)); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .child(head) + .children(body) + .child(totals) + } +} + +#[cfg(test)] +mod tests { + use super::{Aggregate, Cell, pivot}; + + #[test] + fn records_cross_tab_with_totals() { + let records: Vec> = [ + ("Europe", "Q1", 10.0), + ("Asia", "Q1", 4.0), + ("Europe", "Q2", 6.0), + ("Europe", "Q1", 5.0), + ] + .into_iter() + .map(|(region, quarter, sales)| vec![region.into(), quarter.into(), sales.into()]) + .collect(); + let table = pivot(&records, 0, 1, 2, Aggregate::Sum); + assert_eq!(table.rows, ["Europe", "Asia"]); + assert_eq!(table.cols, ["Q1", "Q2"]); + assert_eq!( + table.cells, + [vec![Some(15.0), Some(6.0)], vec![Some(4.0), None]] + ); + assert_eq!(table.row_totals, [Some(21.0), Some(4.0)]); + assert_eq!(table.col_totals, [Some(19.0), Some(6.0)]); + assert_eq!(table.grand, Some(25.0)); + } +} diff --git a/src/tables/sheets.rs b/src/tables/sheets.rs new file mode 100644 index 0000000..58cb5ea --- /dev/null +++ b/src/tables/sheets.rs @@ -0,0 +1,166 @@ +use std::{collections::HashMap, rc::Rc}; + +use gpui::{ + App, Div, ElementId, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, +}; + +use super::{ + formula::{letters, shown}, + grid::{Face, Merge, OnCells, Text, grid}, +}; + +/// Cells to edit as a spreadsheet's are, under column titles. Arrows walk and Shift stretches a range; typing or a double press edits; Delete clears; Cmd-C and Cmd-V copy and paste. `on_change` gets the edits and the owner keeps the text. Give it a height. +#[derive(IntoElement)] +pub struct DataGrid { + id: ElementId, + base: Div, + titles: Vec, + rows: Rc>>, + on_change: Option, +} + +impl DataGrid { + pub fn new( + id: impl Into, + titles: impl IntoIterator>, + rows: impl Into>>>, + ) -> Self { + Self { + id: id.into(), + base: div(), + titles: titles.into_iter().map(Into::into).collect(), + rows: rows.into(), + on_change: None, + } + } + + /// Gets each edit as a row, a column and the new text. + pub fn on_change( + mut self, + handler: impl Fn(&[(usize, usize, SharedString)], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl Styled for DataGrid { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for DataGrid { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let cols = self.titles.len(); + for (ix, row) in self.rows.iter().enumerate() { + assert_eq!(row.len(), cols, "row {ix} needs a cell per title"); + } + let (rows, titles) = (self.rows.clone(), Rc::new(self.titles)); + let text: Text = Rc::new(move |row, col| rows[row][col].clone()); + let face = Face { + id: self.id, + rows: self.rows.len(), + cols, + titles: Rc::new(move |col| titles[col].clone()), + numbered: false, + raw: text.clone(), + shown: text, + merges: Vec::new(), + frozen: (0, 0), + fills: false, + on_change: self.on_change, + }; + grid(face, self.base, window, cx) + } +} + +/// A spreadsheet: lettered columns, numbered rows, formulas such as =SUM(A1:A5) or =B2*1.2, merged cells, frozen rows and columns, and a fill handle that carries a run on. `on_change` gets the edits and the owner keeps the cells. Give it a height. +#[derive(IntoElement)] +pub struct Spreadsheet { + id: ElementId, + base: Div, + size: (usize, usize), + cells: Rc>, + merges: Vec, + frozen: (usize, usize), + on_change: Option, +} + +impl Spreadsheet { + pub fn new(id: impl Into, rows: usize, cols: usize) -> Self { + Self { + id: id.into(), + base: div(), + size: (rows, cols), + cells: Rc::default(), + merges: Vec::new(), + frozen: (0, 0), + on_change: None, + } + } + + /// The cells by row and column from zero; text, numbers, or formulas that start with =. + pub fn cells(mut self, cells: impl Into>>) -> Self { + self.cells = cells.into(); + self + } + + /// Shows a rectangle of cells as one, holding its first cell's text. + pub fn merge(mut self, from: (usize, usize), to: (usize, usize)) -> Self { + assert!( + from.0 <= to.0 && from.1 <= to.1, + "a merge runs from its top-left to its bottom-right" + ); + assert!( + to.0 < self.size.0 && to.1 < self.size.1, + "a merge stays inside the sheet" + ); + self.merges.push(Merge { from, to }); + self + } + + /// Holds the first `rows` and `cols` still while the rest scrolls. + pub fn freeze(mut self, rows: usize, cols: usize) -> Self { + self.frozen = (rows, cols); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[(usize, usize, SharedString)], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl Styled for Spreadsheet { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Spreadsheet { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let cells = self.cells.clone(); + let raw: Text = + Rc::new(move |row, col| cells.get(&(row, col)).cloned().unwrap_or_default()); + let lookup = raw.clone(); + let face = Face { + id: self.id, + rows: self.size.0, + cols: self.size.1, + titles: Rc::new(|col| letters(col).into()), + numbered: true, + raw, + shown: Rc::new(move |row, col| shown(&*lookup, row, col)), + merges: self.merges, + frozen: self.frozen, + fills: true, + on_change: self.on_change, + }; + grid(face, self.base, window, cx) + } +} diff --git a/src/tables/tests.rs b/src/tables/tests.rs index 1063d6e..6bca722 100644 --- a/src/tables/tests.rs +++ b/src/tables/tests.rs @@ -182,3 +182,51 @@ fn a_row_opens_its_detail_and_a_double_press_edits_a_cell(cx: &mut TestAppContex ["edit b name Alan Kay"] ); } + +/// A two-column grid of three rows that keeps every edit it hears. +struct Grid(Rc>>); + +impl Render for Grid { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let heard = self.0.clone(); + let rows: Vec> = (0..3) + .map(|row| vec![format!("a{row}").into(), format!("b{row}").into()]) + .collect(); + super::DataGrid::new("grid", ["A", "B"], rows) + .on_change(move |edits, _, _| { + heard.borrow_mut().extend( + edits + .iter() + .map(|(row, col, text)| (*row, *col, text.to_string())), + ) + }) + .w(px(300.0)) + .h(px(200.0)) + } +} + +#[gpui::test] +fn typing_edits_enter_keeps_and_backspace_clears_a_range(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); + let heard = Rc::new(RefCell::new(Vec::new())); + let seen = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Grid(seen)); + settle(cx); + cx.simulate_click(point(px(48.0), px(42.0)), Modifiers::none()); + settle(cx); + cx.simulate_keystrokes("7"); + settle(cx); + cx.simulate_keystrokes("enter"); + settle(cx); + assert_eq!(*heard.borrow(), [(0, 0, "7".to_string())]); + cx.simulate_keystrokes("shift-down backspace"); + settle(cx); + assert_eq!( + heard.borrow()[1..], + [(1, 0, String::new()), (2, 0, String::new())], + "enter moved down; the range clears" + ); +} diff --git a/src/tables/tree_table.rs b/src/tables/tree_table.rs new file mode 100644 index 0000000..5bc30fb --- /dev/null +++ b/src/tables/tree_table.rs @@ -0,0 +1,185 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; + +use super::{Cell, Column, body::sized, cell::draw}; +use crate::{ + layout::seeded::use_seeded, + primitives::Disclosure, + theme::{ActiveTheme, Density, TextSize}, +}; + +/// One row of a tree table: its key, a cell for each column, and the rows under it. +#[derive(Clone, Debug, PartialEq)] +pub struct TreeRow { + key: SharedString, + cells: Vec, + children: Vec, +} + +impl TreeRow { + pub fn new(key: impl Into, cells: impl IntoIterator) -> Self { + Self { + key: key.into(), + cells: cells.into_iter().collect(), + children: Vec::new(), + } + } + + pub fn children(mut self, rows: impl IntoIterator) -> Self { + self.children.extend(rows); + self + } +} + +/// Rows in view, depth first through the open ones, each with its depth. +fn flat<'a>( + rows: &'a [TreeRow], + open: &[SharedString], + depth: usize, + out: &mut Vec<(&'a TreeRow, usize)>, +) { + for row in rows { + out.push((row, depth)); + if open.contains(&row.key) { + flat(&row.children, open, depth + 1, out); + } + } +} + +/// Rows that open and close under table columns; the first column steps in by depth and carries each row's disclosure. +#[derive(IntoElement)] +pub struct TreeTable { + id: ElementId, + columns: Vec, + rows: Vec, + open: Vec, +} + +impl TreeTable { + pub fn new( + id: impl Into, + columns: impl IntoIterator, + rows: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + columns: columns.into_iter().collect(), + rows: rows.into_iter().collect(), + open: Vec::new(), + } + } + + /// The rows open at first; a new list from the owner replaces what was opened since. + pub fn open(mut self, keys: impl IntoIterator>) -> Self { + self.open = keys.into_iter().map(Into::into).collect(); + self + } +} + +impl RenderOnce for TreeTable { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let opened = use_seeded((self.id.clone(), "open"), self.open, window, cx); + let open = opened.read(cx).value.clone(); + let mut shown = Vec::new(); + flat(&self.rows, &open, 0, &mut shown); + let theme = cx.theme(); + let colors = &theme.colors; + let (height, narrowest, indent) = ( + theme.table_row(Density::Standard), + theme.label_width() * 0.5, + theme.tree_indent(), + ); + let columns = Rc::new(self.columns); + let head = + div() + .flex() + .items_center() + .h(height) + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .children(columns.iter().map(|column| { + sized(div().px_3(), column, narrowest).child(column.title.clone()) + })); + let rows = shown.into_iter().map(|(row, depth)| { + assert_eq!( + row.cells.len(), + columns.len(), + "row {} needs a cell per column", + row.key + ); + let (toggle, key, is_open) = (opened.clone(), row.key.clone(), open.contains(&row.key)); + let first = div() + .flex() + .items_center() + .gap_1() + .min_w_0() + .child(div().flex_none().w(indent * depth as f32)) + .child( + div() + .id((self.id.clone(), format!("open-{}", row.key))) + .flex_none() + .w(indent) + .flex() + .justify_center() + .when(!row.children.is_empty(), |slot| { + slot.cursor_pointer() + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + toggle.update(cx, |seeded, cx| { + if is_open { + seeded.value.retain(|known| *known != key); + } else { + seeded.value.push(key.clone()); + } + cx.notify(); + }) + }) + .child(Disclosure::new( + (self.id.clone(), format!("chevron-{}", row.key)), + is_open, + )) + }), + ) + .child(draw( + &row.cells[0], + &columns[0], + (self.id.clone(), format!("cell-{}-0", row.key)).into(), + cx, + )); + let mut first = Some(first); + div() + .id((self.id.clone(), format!("row-{}", row.key))) + .flex() + .items_center() + .h(height) + .border_b_1() + .border_color(colors.border) + .hover(|style| style.bg(colors.hover)) + .text_size(theme.text_size(TextSize::Sm)) + .children(columns.iter().enumerate().map(|(col, column)| { + let cell = sized( + div().h_full().flex().items_center().px_3(), + column, + narrowest, + ); + if col == 0 { + cell.children(first.take()) + } else { + cell.child(draw( + &row.cells[col], + column, + (self.id.clone(), format!("cell-{}-{col}", row.key)).into(), + cx, + )) + } + })) + }); + div().flex().flex_col().child(head).children(rows) + } +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 3fbf26c..1a1cc28 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -284,6 +284,11 @@ impl Theme { px_to_rems(128.0) } + /// A grid's column, before any resize. + pub fn grid_column(&self) -> Rems { + px_to_rems(96.0) + } + /// A table row's height at each density. pub fn table_row(&self, density: Density) -> Rems { px_to_rems(match density { diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index bf4eac6..3c896bf 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -144,10 +144,10 @@ ## T14c Tables · Grids -- [ ] DataGrid -- [ ] Spreadsheet — formula subset, merge, freeze, fill handle -- [ ] TreeTable -- [ ] PivotTable +- [x] DataGrid → `tables::DataGrid` +- [x] Spreadsheet — formula subset, merge, freeze, fill handle → `tables::Spreadsheet`; SUM, AVERAGE, MIN, MAX, COUNT over ranges +- [x] TreeTable → `tables::TreeTable`, `TreeRow` +- [x] PivotTable → `tables::PivotTable` ## T15 Charts — `src/charts`