T14c Tables: DataGrid, Spreadsheet with formulas, merges, frozen panes and fill, TreeTable, PivotTable
This commit is contained in:
14 files changed
+1914
-10
No files matched your search
@@ -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 |
|
||||
@@ -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."))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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())]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user