T14c Tables: DataGrid, Spreadsheet with formulas, merges, frozen panes and fill, TreeTable, PivotTable

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 20:41:47 -04:00
1 parent 0bfde95888
commit ee7ee44f46
14 files changed
+1914 -10

No files matched your search

+1
View File
@@ -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 |
+214
View File
@@ -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>| {
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<Vec<Cell>> = [
("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."))
}
+13
View File
@@ -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()
+287
View File
@@ -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<Regex> = 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<Regex> =
LazyLock::new(|| Regex::new(r"\b([A-Za-z]+)\(").expect("a call pattern"));
static REFERENCE: LazyLock<Regex> =
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::<usize>().ok()?.checked_sub(1)?;
Some((row, col))
}
/// Every cell name in a rectangle, row by row.
fn span(from: &str, to: &str) -> Result<Vec<String>, 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<String, String> {
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<Option<f64>, String> {
let raw = cells(row, col);
match raw.strip_prefix('=') {
Some(source) => formula(cells, source, depth).map(Some),
None => Ok(raw.trim().parse::<f64>().ok()),
}
}
fn formula(
cells: &dyn Fn(usize, usize) -> SharedString,
source: &str,
depth: usize,
) -> Result<f64, String> {
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<SharedString> {
let numbers: Option<Vec<f64>> = run
.iter()
.map(|text| text.trim().parse::<f64>().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<Vec<SharedString>> = 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::<Vec<_>>()
};
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");
}
}
+478
View File
@@ -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<dyn Fn(&[(usize, usize, SharedString)], &mut Window, &mut App)>;
pub(crate) type Text = Rc<dyn Fn(usize, usize) -> 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<Pixels>,
pub bounds: Bounds<Pixels>,
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<dyn Fn(usize) -> SharedString>,
pub numbered: bool,
pub raw: Text,
pub shown: Text,
pub merges: Vec<Merge>,
pub frozen: (usize, usize),
pub fills: bool,
pub on_change: Option<OnCells>,
}
/// 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<Pixels>,
}
impl Metrics {
/// The cell under a point within the grid, if any.
pub(crate) fn cell_at(
&self,
at: Point<Pixels>,
offset: Point<Pixels>,
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<Sheet> =
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<AnyElement> {
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::<f64>().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<usize>, cols: std::ops::Range<usize>| {
rows.flat_map(move |row| cols.clone().map(move |col| (row, col)))
.filter_map(|(row, col)| cell(row, col))
.collect::<Vec<_>>()
};
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::<Vec<_>>();
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::<Vec<_>>()
});
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<FillDrag>, _, 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(),
)
}
+255
View File
@@ -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<Sheet>,
editor: &Entity<Editing>,
on_change: Option<OnCells>,
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::<Vec<_>>()
.join("\t")
})
.collect::<Vec<_>>()
.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<Sheet>,
editor: Entity<Editing>,
) -> 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<Sheet>,
raw: &Text,
on_change: &Option<OnCells>,
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<SharedString> = (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<SharedString> = (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())]);
}
}
+9
View File
@@ -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};
+11 -6
View File
@@ -17,12 +17,8 @@ pub(crate) fn filtered(rows: &[Row], query: &str) -> Vec<usize> {
.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<f64> {
let values: Vec<f64> = 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<f64> {
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<f64> {
let values: Vec<f64> = 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()
+238
View File
@@ -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<SharedString>,
pub cols: Vec<SharedString>,
pub cells: Vec<Vec<Option<f64>>>,
pub row_totals: Vec<Option<f64>>,
pub col_totals: Vec<Option<f64>>,
pub grand: Option<f64>,
}
/// 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<Cell>],
down: usize,
across: usize,
value: usize,
how: Aggregate,
) -> Pivot {
let mut rows: Vec<SharedString> = Vec::new();
let mut cols: Vec<SharedString> = 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<Cell>) -> bool| -> Vec<f64> {
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<SharedString>,
records: Vec<Vec<Cell>>,
by: (SharedString, SharedString, SharedString),
how: Aggregate,
decimals: usize,
}
impl PivotTable {
/// `fields` name each record's cells in order.
pub fn new(
fields: impl IntoIterator<Item = impl Into<SharedString>>,
records: impl IntoIterator<Item = Vec<Cell>>,
) -> Self {
let fields: Vec<SharedString> = fields.into_iter().map(Into::into).collect();
let records: Vec<Vec<Cell>> = 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<SharedString>,
across: impl Into<SharedString>,
value: impl Into<SharedString>,
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<f64>| {
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<Vec<Cell>> = [
("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));
}
}
+166
View File
@@ -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<SharedString>,
rows: Rc<Vec<Vec<SharedString>>>,
on_change: Option<OnCells>,
}
impl DataGrid {
pub fn new(
id: impl Into<ElementId>,
titles: impl IntoIterator<Item = impl Into<SharedString>>,
rows: impl Into<Rc<Vec<Vec<SharedString>>>>,
) -> 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<HashMap<(usize, usize), SharedString>>,
merges: Vec<Merge>,
frozen: (usize, usize),
on_change: Option<OnCells>,
}
impl Spreadsheet {
pub fn new(id: impl Into<ElementId>, 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<Rc<HashMap<(usize, usize), SharedString>>>) -> 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)
}
}
+48
View File
@@ -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<RefCell<Vec<(usize, usize, String)>>>);
impl Render for Grid {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.0.clone();
let rows: Vec<Vec<SharedString>> = (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"
);
}
+185
View File
@@ -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<Cell>,
children: Vec<TreeRow>,
}
impl TreeRow {
pub fn new(key: impl Into<SharedString>, cells: impl IntoIterator<Item = Cell>) -> Self {
Self {
key: key.into(),
cells: cells.into_iter().collect(),
children: Vec::new(),
}
}
pub fn children(mut self, rows: impl IntoIterator<Item = TreeRow>) -> 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<Column>,
rows: Vec<TreeRow>,
open: Vec<SharedString>,
}
impl TreeTable {
pub fn new(
id: impl Into<ElementId>,
columns: impl IntoIterator<Item = Column>,
rows: impl IntoIterator<Item = TreeRow>,
) -> 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<Item = impl Into<SharedString>>) -> 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)
}
}
+5
View File
@@ -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 {
+4 -4
View File
@@ -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`