T14a Tables core: DataTable, Table, HeatmapTable, cells, Sparkline (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 20:15:45 -04:00
1 parent 9eddfeb48b
commit dcd988e4b8
18 files changed
+1690 -19

No files matched your search

+4 -1
View File
@@ -49,7 +49,9 @@ Per-component lines live in `tasks/`. Tags there:
- [ ] T13a Lists & Trees · Lists
- [ ] T13b Lists & Trees · Trees
- [ ] T13c Lists & Trees · Files
- [ ] T14 Tables
- [ ] T14a Tables · Core
- [ ] T14b Tables · Columns & rows
- [ ] T14c Tables · Grids
- [ ] T15 Charts
- [ ] T16a Finance · Market charts
- [ ] T16b Finance · Technical analysis
@@ -191,3 +193,4 @@ Per-component lines live in `tasks/`. Tags there:
| T13a | 1 | — | pending: codex still logged out |
| T13b | 1 | — | pending: codex still logged out |
| T13c | 1 | — | pending: codex still logged out |
| T14a | 1 | — | pending: codex still logged out |
+2
View File
@@ -10,6 +10,7 @@ mod navigation;
mod overlays;
mod primitives;
mod shell;
mod tables;
mod theme;
mod typography;
@@ -41,6 +42,7 @@ pub const ALL: &[Page] = &[
motion::PAGE,
data::PAGE,
lists::PAGE,
tables::PAGE,
theme::PAGE,
];
+247
View File
@@ -0,0 +1,247 @@
use std::rc::Rc;
use ely_gpui_component::{
buttons::SegmentedControl,
data_display::Tone,
forms::{SearchInput, TextInput},
tables::{Aggregate, Cell, Column, DataTable, HeatmapTable, Row, Table},
theme::{ActiveTheme, ControlSize, Density, Radius},
typography::Caption,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems};
use crate::{
probe::probe,
ui::{keep, section, set},
};
pub fn plain(cx: &mut App) -> impl IntoElement + use<> {
section(
"Table",
"A header and rows, hairlines between. Nothing more.",
cx,
)
.child(
div().w(px(560.)).child(
Table::new(["Name", "Role", "City"])
.row(["Ada Lovelace", "Analyst", "London"])
.row(["Grace Hopper", "Engineer", "Arlington"])
.row(["Katherine Johnson", "Mathematician", "Hampton"]),
),
)
}
const PEOPLE: [&str; 10] = [
"Ada Lovelace",
"Alan Turing",
"Grace Hopper",
"Katherine Johnson",
"Tim Berners-Lee",
"Radia Perlman",
"Sophie Wilson",
"Barbara Liskov",
"Ken Thompson",
"Margaret Hamilton",
];
const REGIONS: [&str; 4] = ["Europe", "Americas", "Asia", "Oceania"];
/// A steady pseudo-random series, so captures repeat.
fn noise(seed: u64) -> impl FnMut() -> f64 {
let mut state = seed;
move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as f64 / (1u64 << 31) as f64
}
}
fn order_rows() -> Vec<Row> {
let mut next = noise(7);
(0..24)
.map(|ix| {
let (status, tone) = match (next() * 3.0) as u32 {
0 => ("Live", Tone::Success),
1 => ("Draft", Tone::Neutral),
_ => ("Review", Tone::Warning),
};
let spark: Vec<f32> = (0..8).map(|_| next() as f32).collect();
Row::new(
format!("order-{ix}"),
[
Cell::Person(PEOPLE[ix % PEOPLE.len()].into()),
Cell::Tag(status.into(), tone),
REGIONS[(next() * 4.0) as usize].into(),
(next() * 90_000.0 + 8_000.0).round().into(),
((next() - 0.35) * 40.0).into(),
Cell::Spark(spark),
Cell::Progress(next() as f32),
],
)
})
.collect()
}
fn order_columns() -> [Column; 7] {
[
Column::new("owner", "Owner"),
Column::new("status", "Status").width(rems(6.)),
Column::new("region", "Region").width(rems(6.)),
Column::new("revenue", "Revenue")
.end()
.prefix("$")
.aggregate(Aggregate::Sum)
.width(rems(8.)),
Column::new("growth", "Growth")
.end()
.decimals(1)
.suffix("%")
.scale()
.aggregate(Aggregate::Average)
.width(rems(6.)),
Column::new("trend", "Trend").unsorted().width(rems(6.)),
Column::new("quota", "Quota")
.width(rems(9.))
.aggregate(Aggregate::Average),
]
}
pub fn orders(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let search = window.use_keyed_state("orders-search", cx, TextInput::new);
let query = search.read(cx).text().to_string();
let picked = keep("orders-picked", Vec::<SharedString>::new, window, cx);
let density = keep(
"orders-density",
|| SharedString::from("standard"),
window,
cx,
);
let (chosen, store, dense, pick) = (
picked.read(cx).clone(),
picked.clone(),
density.read(cx).clone(),
density.clone(),
);
let spacing = match dense.as_ref() {
"compact" => Density::Compact,
"comfortable" => Density::Comfortable,
_ => Density::Standard,
};
section(
"DataTable / CellRenderer / RowSelection / TableDensity / AggregationFooter / ConditionalFormatting",
"Press a header to sort, rising then falling. Search keeps rows that hold the text; boxes select, Shift takes a range. Cells draw people, tags, sparklines and progress; growth is tinted by where it sits; the footer sums and averages what the search keeps.",
cx,
)
.child(
div()
.flex()
.items_center()
.justify_between()
.w(px(900.))
.pb_3()
.child(div().w(px(260.)).child(SearchInput::new("orders-find", &search).size(ControlSize::Sm)))
.child(
SegmentedControl::new("orders-density", dense)
.segment("compact", "Compact", None)
.segment("standard", "Standard", None)
.segment("comfortable", "Comfortable", None)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| set(&pick, value.clone(), cx)),
),
)
.child(probe(
"orders",
div().w(px(900.)).child(
DataTable::new("orders", order_columns())
.rows(order_rows())
.query(query)
.paged(8)
.density(spacing)
.selected(chosen.clone())
.on_select(move |keys, _, cx| set(&store, keys.to_vec(), cx)),
),
))
.child(Caption::new(format!("{} selected", chosen.len())))
}
pub fn long(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let rows = keep(
"long-rows",
|| {
let mut next = noise(11);
Rc::new(
(0..100_000)
.map(|ix| {
Row::new(
format!("long-{ix}"),
[
format!("Event {}", ix + 1).into(),
REGIONS[ix % 4].into(),
(next() * 1000.0).round().into(),
],
)
})
.collect::<Vec<_>>(),
)
},
window,
cx,
)
.read(cx)
.clone();
let theme = cx.theme();
section(
"VirtualTable",
"A hundred thousand rows; only the ones in view are drawn, so it scrolls as lightly as ten.",
cx,
)
.child(
div()
.w(px(560.))
.h(px(300.))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.child(
DataTable::new(
"long",
[
Column::new("event", "Event"),
Column::new("region", "Region"),
Column::new("value", "Value").end().aggregate(Aggregate::Sum),
],
)
.rows(rows)
.virtualized()
.density(Density::Compact)
.size_full(),
),
)
}
pub fn heatmap(cx: &mut App) -> impl IntoElement + use<> {
let mut next = noise(3);
let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let table = days.iter().enumerate().fold(
HeatmapTable::new(["6", "9", "12", "15", "18", "21"]),
|table, (day, name)| {
let busy = if day >= 5 { 0.5 } else { 1.0 };
table.row(
*name,
(0..6).map(|slot| {
(busy
* (40.0 + 60.0 * next())
* if (1..=4).contains(&slot) { 1.6 } else { 0.7 })
.round()
}),
)
},
);
section(
"HeatmapTable",
"Visits by weekday and hour; each cell is shaded by where it sits between the quietest and the busiest.",
cx,
)
.child(div().w(px(560.)).child(table))
.child(Caption::new("ComparisonTable is Comparison, in Data Display."))
}
+39
View File
@@ -0,0 +1,39 @@
mod core;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 14,
slug: "tables",
title: "Tables",
summary: "Rows and columns that sort, filter, page and select, and stay quick at a hundred thousand rows.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::DownAt("orders", 440.0, 16.0),
Step::UpAt("orders", 440.0, 16.0),
Step::Wait(200),
Step::Shot("sorted"),
Step::DownAt("orders", 18.0, 54.0),
Step::UpAt("orders", 18.0, 54.0),
Step::DownAt("orders", 18.0, 126.0),
Step::UpAt("orders", 18.0, 126.0),
Step::Wait(200),
Step::Shot("selected"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(core::plain(cx))
.child(core::orders(window, cx))
.child(core::long(window, cx))
.child(core::heatmap(cx))
.into_any_element()
}
+2
View File
@@ -9,6 +9,7 @@ mod feed;
mod gallery;
mod measures;
mod records;
mod spark;
mod stars;
mod stat;
#[cfg(test)]
@@ -27,6 +28,7 @@ pub use feed::{Activity, ActivityFeed};
pub use gallery::Gallery;
pub use measures::{Gauge, Meter, UsageBar};
pub use records::{DescriptionList, PropertyGrid, PropertyGroup};
pub use spark::Sparkline;
pub use stars::Stars;
pub(crate) use stars::star;
pub use stat::{KpiCard, Statistic, TrendIndicator};
+163
View File
@@ -0,0 +1,163 @@
use gpui::{
App, Bounds, Div, Hsla, IntoElement, ParentElement, PathBuilder, Pixels, Refineable,
RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, point, size,
};
use crate::theme::ActiveTheme;
/// How a sparkline draws its series.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Spark {
Line,
Area,
Bars,
}
/// Each value's height as a share of the series' own range; a flat series sits in the middle.
fn shares(values: &[f32]) -> Vec<f32> {
let (low, high) = values.iter().fold((f32::MAX, f32::MIN), |(low, high), v| {
(low.min(*v), high.max(*v))
});
let span = high - low;
values
.iter()
.map(|value| {
if span > 0.0 {
(value - low) / span
} else {
0.5
}
})
.collect()
}
/// A word-sized chart of a series, scaled to its own range: a line, a filled area, or bars.
#[derive(IntoElement)]
pub struct Sparkline {
base: Div,
values: Vec<f32>,
spark: Spark,
color: Option<Hsla>,
}
impl Sparkline {
pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
let values: Vec<f32> = values.into_iter().collect();
assert!(values.len() >= 2, "a sparkline needs two values");
assert!(
values.iter().all(|value| value.is_finite()),
"a sparkline needs finite values"
);
Self {
base: div(),
values,
spark: Spark::Line,
color: None,
}
}
/// The line with the area under it lightly filled.
pub fn area(mut self) -> Self {
self.spark = Spark::Area;
self
}
pub fn bars(mut self) -> Self {
self.spark = Spark::Bars;
self
}
/// Its ink; the foreground unless set.
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl Styled for Sparkline {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Sparkline {
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let ink = self.color.unwrap_or(theme.colors.fg_muted);
let (heights, spark) = (shares(&self.values), self.spark);
let mut frame = div()
.flex_none()
.w(theme.spark_size().width)
.h(theme.spark_size().height);
frame.style().refine(self.base.style());
frame.child(
canvas(
|_, _, _| {},
move |bounds: Bounds<Pixels>, _, window, _| {
let stroke = bounds.size.height / 12.0;
let inner = bounds.size.height - stroke * 2.0;
let count = heights.len() as f32;
if spark == Spark::Bars {
let slot = bounds.size.width / count;
for (ix, share) in heights.iter().enumerate() {
let tall = (inner * *share).max(stroke);
let corner = point(
bounds.left() + slot * ix as f32 + slot * 0.15,
bounds.bottom() - tall,
);
window
.paint_quad(fill(Bounds::new(corner, size(slot * 0.7, tall)), ink));
}
return;
}
let at = |ix: usize, share: f32| {
point(
bounds.left() + bounds.size.width * ix as f32 / (count - 1.0),
bounds.top() + stroke + inner * (1.0 - share),
)
};
if spark == Spark::Area {
let mut area = PathBuilder::fill();
area.move_to(point(bounds.left(), bounds.bottom()));
for (ix, share) in heights.iter().enumerate() {
area.line_to(at(ix, *share));
}
area.line_to(point(bounds.right(), bounds.bottom()));
area.close();
if let Ok(area) = area.build() {
window.paint_path(area, ink.opacity(0.16));
}
}
let mut line = PathBuilder::stroke(stroke);
for (ix, share) in heights.iter().enumerate() {
if ix == 0 {
line.move_to(at(ix, *share));
} else {
line.line_to(at(ix, *share));
}
}
match line.build() {
Ok(line) => window.paint_path(line, ink),
Err(error) => log::error!("sparkline: the line failed to build: {error:#}"),
}
},
)
.size_full(),
)
}
}
#[cfg(test)]
mod tests {
use super::shares;
#[test]
fn values_scale_to_their_own_range() {
assert_eq!(shares(&[2.0, 4.0, 3.0]), [0.0, 1.0, 0.5]);
assert_eq!(
shares(&[7.0, 7.0]),
[0.5, 0.5],
"a flat series sits in the middle"
);
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod navigation;
pub mod overlays;
pub mod primitives;
pub mod shell;
pub mod tables;
pub mod theme;
pub mod typography;
+1
View File
@@ -18,6 +18,7 @@ pub use item::{List, ListItem};
pub use long::{InfiniteList, VirtualList};
pub use outline::Outline;
pub use select::SelectableList;
pub(crate) use select::{Pick, picked};
pub use sortable::SortableList;
pub use swipe::{SwipeAction, SwipeableListItem};
pub use tree::{DropAt, Tree, TreeNode, TreeSelect};
+2 -2
View File
@@ -10,7 +10,7 @@ use crate::primitives::tab_stop;
/// How a press or a key picks rows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Pick {
pub(crate) enum Pick {
One,
Toggle,
Range,
@@ -18,7 +18,7 @@ pub(super) enum Pick {
}
/// The selection after picking row `at`, a range reaching back to `anchor`; in row order.
pub(super) fn picked(
pub(crate) fn picked(
keys: &[SharedString],
selected: &[SharedString],
anchor: usize,
+117
View File
@@ -0,0 +1,117 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Rems,
SharedString, StatefulInteractiveElement, Styled, div, prelude::*,
};
use super::{Align, Column, Row, cell::draw, model::tint};
use crate::{
forms::{CheckState, check_mark},
lists::Pick,
theme::{ActiveTheme, TextSize},
};
pub(super) type Select = Rc<dyn Fn(usize, Pick, &mut gpui::Window, &mut App)>;
/// Gives a cell its column's width: fixed, or a share of what is left.
pub(super) fn sized<E: Styled>(cell: E, column: &Column) -> E {
let cell = match column.width {
Some(width) => cell.w(width).flex_none(),
None => cell.flex_1().min_w_0(),
};
if column.align == Align::End {
cell.justify_end()
} else {
cell
}
}
/// What drawing a row needs, shared by pages and by a long table's list.
pub(super) struct Body {
pub id: ElementId,
pub columns: Rc<Vec<Column>>,
pub rows: Rc<Vec<Row>>,
pub keys: Rc<Vec<SharedString>>,
pub shown: Rc<Vec<usize>>,
pub offset: usize,
pub selected: Option<Rc<Vec<SharedString>>>,
pub select: Option<Select>,
pub ranges: Rc<Vec<Option<(f64, f64)>>>,
pub height: Rems,
pub check: Rems,
}
impl Body {
pub(super) fn row(&self, at: usize, cx: &App) -> AnyElement {
let ix = self.shown[at];
let key = self.keys[ix].clone();
let on = self
.selected
.as_ref()
.is_some_and(|selected| selected.contains(&key));
let theme = cx.theme();
let colors = &theme.colors;
let position = self.offset + at;
let press = self.select.clone();
div()
.id((self.id.clone(), format!("row-{key}")))
.flex()
.items_center()
.w_full()
.h(self.height)
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.when(on, |row| row.bg(colors.active))
.when(!on, |row| row.hover(|style| style.bg(colors.hover)))
.when_some(self.select.clone(), |row, select| {
row.cursor_pointer()
.child(
div()
.flex_none()
.w(self.check)
.flex()
.justify_center()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
select(position, Pick::Toggle, window, cx);
})
.child(check_mark(
if on { CheckState::On } else { CheckState::Off },
false,
false,
0,
cx,
)),
)
.on_click(move |event, window, cx| {
let held = event.modifiers();
let how = match (held.shift, held.platform) {
(true, _) => Pick::Range,
(false, true) => Pick::Toggle,
_ => Pick::One,
};
if let Some(press) = &press {
press(position, how, window, cx);
}
})
})
.children(self.columns.iter().enumerate().map(|(col, column)| {
let cell = &self.rows[ix].cells[col];
let tinted = self.ranges[col]
.zip(cell.number())
.map(|(range, value)| tint(value, range, colors));
sized(div().h_full().flex().items_center().px_3(), column)
.when_some(tinted, |cell, bg| cell.bg(bg))
.child(draw(
cell,
column,
(self.id.clone(), format!("cell-{key}-{col}")).into(),
cx,
))
}))
.into_any_element()
}
}
+228
View File
@@ -0,0 +1,228 @@
use gpui::{
AnyElement, App, ElementId, IntoElement, ParentElement, Rems, SharedString, Styled, div,
};
use crate::{
data_display::{Avatar, Badge, Sparkline, Tone},
motion::ProgressBar,
theme::{ActiveTheme, AvatarSize},
typography::{ExternalLink, format, tabular},
};
/// What a cell holds; its column says how numbers read.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell {
Empty,
Text(SharedString),
Number(f64),
Tag(SharedString, Tone),
/// A share from 0 to 1, drawn as a bar with its percent.
Progress(f32),
Spark(Vec<f32>),
/// A person's name beside their initials.
Person(SharedString),
/// A label, and the address it opens.
Link(SharedString, SharedString),
}
impl Cell {
/// The words a filter looks through and a sort compares.
pub(crate) fn words(&self) -> SharedString {
match self {
Self::Text(text) | Self::Tag(text, _) | Self::Person(text) | Self::Link(text, _) => {
text.clone()
}
Self::Number(value) => value.to_string().into(),
Self::Progress(share) => share.to_string().into(),
Self::Empty | Self::Spark(_) => SharedString::default(),
}
}
/// The value a sort and a figure use, for cells that count.
pub(crate) fn number(&self) -> Option<f64> {
match self {
Self::Number(value) => Some(*value),
Self::Progress(share) => Some(f64::from(*share)),
_ => None,
}
}
}
impl From<&'static str> for Cell {
fn from(text: &'static str) -> Self {
Self::Text(text.into())
}
}
impl From<String> for Cell {
fn from(text: String) -> Self {
Self::Text(text.into())
}
}
impl From<f64> for Cell {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Align {
#[default]
Start,
End,
}
/// A figure the footer shows for a column.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Aggregate {
Sum,
Average,
Count,
Min,
Max,
}
impl Aggregate {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Sum => "Sum",
Self::Average => "Avg",
Self::Count => "Count",
Self::Min => "Min",
Self::Max => "Max",
}
}
}
/// A column: its key and title, its width, and how its cells read.
#[derive(Clone, Debug)]
pub struct Column {
pub(crate) key: SharedString,
pub(crate) title: SharedString,
pub(crate) width: Option<Rems>,
pub(crate) align: Align,
pub(crate) sortable: bool,
pub(crate) decimals: usize,
pub(crate) prefix: Option<SharedString>,
pub(crate) suffix: Option<SharedString>,
pub(crate) aggregate: Option<Aggregate>,
pub(crate) scale: bool,
}
impl Column {
pub fn new(key: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
title: title.into(),
width: None,
align: Align::Start,
sortable: true,
decimals: 0,
prefix: None,
suffix: None,
aggregate: None,
scale: false,
}
}
/// A fixed width; columns without one share what is left.
pub fn width(mut self, width: Rems) -> Self {
self.width = Some(width);
self
}
/// Sets its cells to the end, as numbers read.
pub fn end(mut self) -> Self {
self.align = Align::End;
self
}
/// Its header does not sort.
pub fn unsorted(mut self) -> Self {
self.sortable = false;
self
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
self
}
pub fn prefix(mut self, text: impl Into<SharedString>) -> Self {
self.prefix = Some(text.into());
self
}
pub fn suffix(mut self, text: impl Into<SharedString>) -> Self {
self.suffix = Some(text.into());
self
}
/// A figure for the footer, over the rows the filter keeps.
pub fn aggregate(mut self, aggregate: Aggregate) -> Self {
self.aggregate = Some(aggregate);
self
}
/// Tints each number by where it sits in the column: red low, green high.
pub fn scale(mut self) -> Self {
self.scale = true;
self
}
/// A number as this column reads it.
pub(crate) fn reads(&self, value: f64) -> String {
format!(
"{}{}{}",
self.prefix.as_ref().map_or("", |text| text.as_ref()),
format::number(value, self.decimals, format::Separators::EN),
self.suffix.as_ref().map_or("", |text| text.as_ref())
)
}
}
/// A cell drawn as its kind asks.
pub(crate) fn draw(cell: &Cell, column: &Column, id: ElementId, cx: &App) -> AnyElement {
let colors = &cx.theme().colors;
match cell {
Cell::Empty => div().into_any_element(),
Cell::Text(text) => div()
.min_w_0()
.truncate()
.child(text.clone())
.into_any_element(),
Cell::Number(value) => tabular(div())
.child(column.reads(*value))
.into_any_element(),
Cell::Tag(text, tone) => Badge::new(text.clone()).tone(*tone).into_any_element(),
Cell::Progress(share) => div()
.flex()
.items_center()
.gap_2()
.w_full()
.child(
div()
.flex_1()
.child(ProgressBar::new(id, share.clamp(0.0, 1.0))),
)
.child(
tabular(div())
.text_color(colors.fg_muted)
.child(format::percent(f64::from(*share), 0, false)),
)
.into_any_element(),
Cell::Spark(values) => Sparkline::new(values.iter().copied()).into_any_element(),
Cell::Person(name) => div()
.flex()
.items_center()
.gap_2()
.min_w_0()
.child(Avatar::new(id, name.clone()).size(AvatarSize::Xs))
.child(div().min_w_0().truncate().child(name.clone()))
.into_any_element(),
Cell::Link(label, url) => {
ExternalLink::new(id, label.clone(), url.clone()).into_any_element()
}
}
}
+11
View File
@@ -0,0 +1,11 @@
mod body;
mod cell;
mod model;
mod simple;
mod table;
#[cfg(test)]
mod tests;
pub use cell::{Aggregate, Align, Cell, Column};
pub use simple::{HeatmapTable, Table};
pub use table::{DataTable, Row};
+159
View File
@@ -0,0 +1,159 @@
use std::cmp::Ordering;
use gpui::Hsla;
use super::{Aggregate, Row};
use crate::theme::{Mix, Palette};
/// Rows holding `query` in any cell, ignoring case; every row when it is empty.
pub(crate) fn filtered(rows: &[Row], query: &str) -> Vec<usize> {
let query = query.trim().to_lowercase();
(0..rows.len())
.filter(|ix| {
query.is_empty()
|| rows[*ix]
.cells
.iter()
.any(|cell| cell.words().to_lowercase().contains(&query))
})
.collect()
}
/// `order` sorted by column `col`, rising or falling: numbers by value, the rest by their words; ties keep their order.
pub(crate) fn sorted(rows: &[Row], mut order: Vec<usize>, col: usize, rising: bool) -> Vec<usize> {
order.sort_by(|a, b| {
let (a, b) = (&rows[*a].cells[col], &rows[*b].cells[col]);
let by = match (a.number(), b.number()) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
_ => a.words().to_lowercase().cmp(&b.words().to_lowercase()),
};
if rising { by } else { by.reverse() }
});
order
}
/// 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();
if how == Aggregate::Count {
return Some(values.len() as f64);
}
if values.is_empty() {
return None;
}
let sum: f64 = values.iter().sum();
Some(match how {
Aggregate::Sum => sum,
Aggregate::Average => sum / values.len() as f64,
Aggregate::Min => values.iter().copied().fold(f64::MAX, f64::min),
Aggregate::Max => values.iter().copied().fold(f64::MIN, f64::max),
Aggregate::Count => unreachable!("counted above"),
})
}
/// The lowest and highest numbers in column `col`, across every row.
pub(crate) fn range(rows: &[Row], col: usize) -> Option<(f64, f64)> {
rows.iter()
.filter_map(|row| row.cells[col].number())
.fold(None, |range, value| match range {
None => Some((value, value)),
Some((low, high)) => Some((low.min(value), high.max(value))),
})
}
/// A value's tint in a range: red at the bottom, clear in the middle, green at the top.
pub(crate) fn tint(value: f64, (low, high): (f64, f64), colors: &Palette) -> Hsla {
let share = if high > low {
((value - low) / (high - low)) as f32
} else {
0.5
};
if share < 0.5 {
colors
.danger_subtle
.mix(&colors.danger_subtle.opacity(0.0), share * 2.0)
} else {
colors
.success_subtle
.opacity(0.0)
.mix(&colors.success_subtle, share * 2.0 - 1.0)
}
}
/// The part of `order` on page `page`, counted from zero, `size` rows a page.
pub(crate) fn page(order: &[usize], page: usize, size: usize) -> &[usize] {
let start = (page * size).min(order.len());
&order[start..(start + size).min(order.len())]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{data_display::Tone, tables::Cell};
fn rows() -> Vec<Row> {
[
[
"Grace".into(),
42.0.into(),
Cell::Tag("Live".into(), Tone::Success),
],
[
"alan".into(),
7.0.into(),
Cell::Tag("Draft".into(), Tone::Neutral),
],
["Ada".into(), 7.0.into(), Cell::Empty],
[
"Katherine".into(),
105.5.into(),
Cell::Tag("Live".into(), Tone::Success),
],
]
.into_iter()
.enumerate()
.map(|(ix, cells)| Row::new(ix.to_string(), cells))
.collect()
}
#[test]
fn a_query_keeps_rows_holding_it_anywhere() {
assert_eq!(filtered(&rows(), "live"), [0, 3]);
assert_eq!(filtered(&rows(), " "), [0, 1, 2, 3]);
}
#[test]
fn numbers_sort_by_value_and_words_ignore_case() {
let rows = rows();
assert_eq!(
sorted(&rows, vec![0, 1, 2, 3], 1, true),
[1, 2, 0, 3],
"ties keep their order"
);
assert_eq!(sorted(&rows, vec![0, 1, 2, 3], 1, false), [3, 0, 1, 2]);
assert_eq!(sorted(&rows, vec![0, 1, 2, 3], 0, true), [2, 1, 0, 3]);
}
#[test]
fn figures_count_only_what_counts() {
let rows = rows();
let all = [0, 1, 2, 3];
assert_eq!(figure(&rows, &all, 1, Aggregate::Sum), Some(161.5));
assert_eq!(figure(&rows, &all, 1, Aggregate::Average), Some(40.375));
assert_eq!(figure(&rows, &[1, 2], 1, Aggregate::Max), Some(7.0));
assert_eq!(figure(&rows, &all, 0, Aggregate::Sum), None);
assert_eq!(figure(&rows, &all, 0, Aggregate::Count), Some(0.0));
assert_eq!(range(&rows, 1), Some((7.0, 105.5)));
}
#[test]
fn a_page_is_a_slice_that_stops_at_the_end() {
let order: Vec<usize> = (0..7).collect();
assert_eq!(page(&order, 1, 3), [3, 4, 5]);
assert_eq!(page(&order, 2, 3), [6]);
assert!(page(&order, 5, 3).is_empty());
}
}
+192
View File
@@ -0,0 +1,192 @@
use gpui::{
AnyElement, App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use crate::{
theme::{ActiveTheme, Density, Radius, TextSize},
typography::{format, tabular},
};
/// A plain table: a header and rows of cells with hairlines between. To sort, page or select, use `DataTable`.
#[derive(IntoElement)]
pub struct Table {
headers: Vec<SharedString>,
rows: Vec<Vec<AnyElement>>,
}
impl Table {
pub fn new(headers: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
Self {
headers: headers.into_iter().map(Into::into).collect(),
rows: Vec::new(),
}
}
pub fn row(mut self, cells: impl IntoIterator<Item = impl IntoElement>) -> Self {
let cells: Vec<AnyElement> = cells
.into_iter()
.map(IntoElement::into_any_element)
.collect();
assert_eq!(
cells.len(),
self.headers.len(),
"a row needs a cell per header"
);
self.rows.push(cells);
self
}
}
impl RenderOnce for Table {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let last = self.rows.len().saturating_sub(1);
let cell = || div().flex_1().min_w_0().px_3().py_2();
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.children(self.headers.into_iter().map(|header| cell().child(header))),
)
.children(self.rows.into_iter().enumerate().map(|(ix, row)| {
div()
.flex()
.text_color(colors.fg)
.when(ix < last, |row| {
row.border_b_1().border_color(colors.border)
})
.children(row.into_iter().map(|content| cell().child(content)))
}))
}
}
/// Numbers in a grid, each cell shaded by where it sits between the grid's lowest and highest, its value on top.
#[derive(IntoElement)]
pub struct HeatmapTable {
columns: Vec<SharedString>,
rows: Vec<(SharedString, Vec<f64>)>,
decimals: usize,
}
impl HeatmapTable {
pub fn new(columns: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
Self {
columns: columns.into_iter().map(Into::into).collect(),
rows: Vec::new(),
decimals: 0,
}
}
pub fn row(
mut self,
label: impl Into<SharedString>,
values: impl IntoIterator<Item = f64>,
) -> Self {
let values: Vec<f64> = values.into_iter().collect();
assert_eq!(
values.len(),
self.columns.len(),
"a row needs a value per column"
);
assert!(
values.iter().all(|value| value.is_finite()),
"a heatmap needs finite values"
);
self.rows.push((label.into(), values));
self
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
self
}
}
impl RenderOnce for HeatmapTable {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let (low, high) = self
.rows
.iter()
.flat_map(|(_, values)| values.iter().copied())
.fold((f64::MAX, f64::MIN), |(low, high), value| {
(low.min(value), high.max(value))
});
let share = |value: f64| {
if high > low {
((value - low) / (high - low)) as f32
} else {
0.5
}
};
let (hue, height, decimals) = (
colors.chart[0],
theme.table_row(Density::Standard),
self.decimals,
);
let label = |text: SharedString| {
div()
.flex_none()
.w(theme.label_width() * 0.6)
.flex()
.items_center()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(text)
};
div()
.flex()
.flex_col()
.gap_0p5()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.gap_0p5()
.child(label(SharedString::default()))
.children(self.columns.into_iter().map(|column| {
div()
.flex_1()
.flex()
.justify_center()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(column)
})),
)
.children(self.rows.into_iter().map(|(name, values)| {
div()
.flex()
.gap_0p5()
.child(label(name))
.children(values.into_iter().map(|value| {
let share = share(value);
tabular(div())
.flex_1()
.h(height)
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Sm))
.bg(hue.opacity(0.08 + 0.84 * share))
.text_color(if share > 0.55 {
colors.on_media
} else {
colors.fg
})
.child(format::number(value, decimals, format::Separators::EN))
}))
}))
}
}
+417
View File
@@ -0,0 +1,417 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
UniformListScrollHandle, Window, div, prelude::*, uniform_list,
};
use super::{
Aggregate, Cell, Column,
body::{Body, Select, sized},
model::{figure, filtered, page, range, sorted},
};
use crate::{
forms::{CheckState, check_mark},
lists::{Pick, picked},
navigation::Pagination,
primitives::{Icon, IconName},
theme::{ActiveTheme, Density, IconSize, TextSize},
typography::{Caption, format},
};
/// One row: its key, which a selection names it by, and a cell for each column.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub(crate) key: SharedString,
pub(crate) cells: Vec<Cell>,
}
impl Row {
pub fn new(key: impl Into<SharedString>, cells: impl IntoIterator<Item = Cell>) -> Self {
Self {
key: key.into(),
cells: cells.into_iter().collect(),
}
}
}
/// The sort, the page, where a Shift range starts, and the scroll of a long table.
#[derive(Default)]
struct View {
sort: Option<(usize, bool)>,
page: usize,
anchor: usize,
scroll: UniformListScrollHandle,
}
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Rows under headers that sort, rising then falling then as given. A query keeps rows holding it; pages split long results, or a long table draws only the rows in view. With a selection, a box leads each row: a press picks one, Cmd adds, Shift takes a range, the header box takes all. Footers show figures; tinted columns show where each number sits.
#[derive(IntoElement)]
pub struct DataTable {
id: ElementId,
base: Div,
columns: Vec<Column>,
rows: Rc<Vec<Row>>,
query: SharedString,
page_size: Option<usize>,
virtualized: bool,
selected: Option<Vec<SharedString>>,
on_select: Option<OnSelect>,
density: Density,
}
impl DataTable {
pub fn new(id: impl Into<ElementId>, columns: impl IntoIterator<Item = Column>) -> Self {
Self {
id: id.into(),
base: div(),
columns: columns.into_iter().collect(),
rows: Rc::default(),
query: SharedString::default(),
page_size: None,
virtualized: false,
selected: None,
on_select: None,
density: Density::Standard,
}
}
/// The rows, owned or shared; a shared list is not copied.
pub fn rows(mut self, rows: impl Into<Rc<Vec<Row>>>) -> Self {
self.rows = rows.into();
self
}
/// Keeps the rows holding this text in any cell.
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
/// Splits the rows into pages of `size`.
pub fn paged(mut self, size: usize) -> Self {
assert!(size > 0, "a page holds at least a row");
self.page_size = Some(size);
self
}
/// Draws only the rows in view, for long tables. Give it a height.
pub fn virtualized(mut self) -> Self {
self.virtualized = true;
self
}
/// Shows a box on each row; `keys` are the rows selected.
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = Some(keys.into_iter().map(Into::into).collect());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn density(mut self, density: Density) -> Self {
self.density = density;
self
}
}
impl Styled for DataTable {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
/// The next sort after a press on column `col`: rising, then falling, then none.
fn next_sort(sort: Option<(usize, bool)>, col: usize) -> Option<(usize, bool)> {
match sort {
Some((was, true)) if was == col => Some((col, false)),
Some((was, false)) if was == col => None,
_ => Some((col, true)),
}
}
impl RenderOnce for DataTable {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let count = self.columns.len();
for row in self.rows.iter() {
assert_eq!(
row.cells.len(),
count,
"row {} needs a cell per column",
row.key
);
}
let view: Entity<View> =
window.use_keyed_state((id.clone(), "view"), cx, |_, _| View::default());
let (sort, page_at, scroll) = {
let view = view.read(cx);
(view.sort, view.page, view.scroll.clone())
};
let rows = self.rows;
let keys: Rc<Vec<SharedString>> = Rc::new(rows.iter().map(|row| row.key.clone()).collect());
let mut order = filtered(&rows, &self.query);
if let Some((col, rising)) = sort {
order = sorted(&rows, order, col, rising);
}
let total = order.len();
let pages = self.page_size.map(|size| total.div_ceil(size).max(1));
let page_at = pages.map_or(0, |pages| page_at.min(pages - 1));
let (shown, offset) = match self.page_size {
Some(size) => (page(&order, page_at, size).to_vec(), page_at * size),
None => (order.clone(), 0),
};
let columns = Rc::new(self.columns);
let ranges: Rc<Vec<_>> = Rc::new(
columns
.iter()
.enumerate()
.map(|(col, column)| column.scale.then(|| range(&rows, col)).flatten())
.collect(),
);
let ordered: Rc<Vec<SharedString>> =
Rc::new(order.iter().map(|ix| keys[*ix].clone()).collect());
let selected = self.selected.map(Rc::new);
let select: Option<Select> = selected.clone().map(|selected| {
let (view, ordered, on_select, id) = (
view.clone(),
ordered.clone(),
self.on_select.clone(),
id.clone(),
);
Rc::new(
move |position: usize, how: Pick, window: &mut Window, cx: &mut App| {
let next = picked(&ordered, &selected, view.read(cx).anchor, position, how);
view.update(cx, |view, _| {
if how != Pick::Range {
view.anchor = position;
}
});
log::info!("data table {id:?}: {} selected", next.len());
if let Some(on_select) = &on_select {
on_select(&next, window, cx);
}
},
) as Select
});
let theme = cx.theme();
let colors = &theme.colors;
let height = theme.table_row(self.density);
let check = theme.control_height(crate::theme::ControlSize::Md);
let all = selected.as_ref().map(|selected| {
let on = ordered.iter().filter(|key| selected.contains(key)).count();
match on {
0 => CheckState::Off,
on if on == ordered.len() => CheckState::On,
_ => CheckState::Mixed,
}
});
let header = 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)
.when_some(all.zip(selected.clone()), |header, (state, selected)| {
let (ordered, on_select) = (ordered.clone(), self.on_select.clone());
header.child(
div()
.id((id.clone(), "all"))
.flex_none()
.w(check)
.flex()
.justify_center()
.cursor_pointer()
.on_click(move |_, window, cx| {
let next: Vec<SharedString> = if state == CheckState::On {
selected
.iter()
.filter(|key| !ordered.contains(key))
.cloned()
.collect()
} else {
let mut next = (*selected).clone();
next.extend(
ordered
.iter()
.filter(|key| !selected.contains(key))
.cloned(),
);
next
};
if let Some(on_select) = &on_select {
on_select(&next, window, cx);
}
})
.child(check_mark(state, false, false, 0, cx)),
)
})
.children(columns.iter().enumerate().map(|(col, column)| {
let here = sort
.filter(|(sorted, _)| *sorted == col)
.map(|(_, rising)| rising);
let (view, id) = (view.clone(), id.clone());
sized(
div()
.id((self.id.clone(), format!("head-{}", column.key)))
.h_full()
.flex()
.items_center()
.gap_1()
.px_3(),
column,
)
.when(column.sortable, |head| {
head.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_click(move |_, _, cx| {
view.update(cx, |view, cx| {
view.sort = next_sort(view.sort, col);
view.page = 0;
log::info!("data table {id:?}: sort {:?}", view.sort);
cx.notify();
})
})
})
.child(column.title.clone())
.children(here.map(|rising| {
Icon::new(if rising {
IconName::ArrowUp
} else {
IconName::ArrowDown
})
.size(IconSize::Xs)
.color(colors.fg_muted)
}))
}));
let figures = columns
.iter()
.any(|column| column.aggregate.is_some())
.then(|| {
div()
.flex()
.items_center()
.h(height)
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.when(selected.is_some(), |footer| {
footer.child(div().flex_none().w(check))
})
.children(columns.iter().enumerate().map(|(col, column)| {
let shown = column.aggregate.map(|how| {
let shares = rows
.first()
.is_some_and(|row| matches!(row.cells[col], Cell::Progress(_)));
let value =
figure(&rows, &order, col, how).map_or("—".to_string(), |value| {
match how {
Aggregate::Count => format!("{}", value as usize),
_ if shares => format::percent(value, 0, false),
_ => column.reads(value),
}
});
div()
.flex()
.items_baseline()
.gap_1()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(how.label()),
)
.child(value)
});
sized(div().px_3().flex().items_center(), column).children(shown)
}))
});
let body = Body {
id: id.clone(),
columns: columns.clone(),
rows,
keys,
shown: Rc::new(shown),
offset,
selected,
select,
ranges,
height,
check,
};
let rows: AnyElement = if total == 0 {
div()
.flex()
.justify_center()
.py_6()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child("Nothing matches.")
.into_any_element()
} else if self.virtualized {
let count = body.shown.len();
uniform_list((id.clone(), "rows"), count, move |range, _, cx| {
range.map(|at| body.row(at, cx)).collect()
})
.track_scroll(scroll)
.flex_1()
.min_h_0()
.into_any_element()
} else {
div()
.children((0..body.shown.len()).map(|at| body.row(at, cx)))
.into_any_element()
};
let pager = pages.filter(|pages| *pages > 1).map(|pages| {
let size = self.page_size.expect("pages come from a page size");
let (first, last) = (page_at * size + 1, ((page_at + 1) * size).min(total));
let (view, id) = (view.clone(), id.clone());
div()
.flex()
.items_center()
.justify_between()
.pt_3()
.child(Caption::new(format!("{first}–{last} of {total}")))
.child(
Pagination::new((id.clone(), "pages"), page_at + 1, pages).on_change(
move |page, _, cx| {
view.update(cx, |view, cx| {
view.page = page - 1;
log::info!("data table {id:?}: page {page}");
cx.notify();
})
},
),
)
});
self.base
.flex()
.flex_col()
.child(header)
.child(rows)
.children(figures)
.children(pager)
}
}
#[cfg(test)]
mod tests {
use super::next_sort;
#[test]
fn a_header_sorts_rising_then_falling_then_not() {
assert_eq!(next_sort(None, 2), Some((2, true)));
assert_eq!(next_sort(Some((2, true)), 2), Some((2, false)));
assert_eq!(next_sort(Some((2, false)), 2), None);
assert_eq!(next_sort(Some((1, false)), 2), Some((2, true)));
}
}
+69
View File
@@ -0,0 +1,69 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, Modifiers, Render, SharedString, Styled, TestAppContext,
VisualTestContext, Window, point, px,
};
use super::{Column, DataTable, Row};
use crate::theme::Theme;
/// Five selectable rows, 36 tall under a 36 tall header; it keeps what the table reports.
struct Picks(Rc<RefCell<Vec<SharedString>>>);
impl Render for Picks {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let (now, store) = (self.0.borrow().clone(), self.0.clone());
DataTable::new("picks", [Column::new("name", "Name")])
.rows(
(0..5)
.map(|ix| Row::new(format!("r{ix}"), [format!("Row {ix}").into()]))
.collect::<Vec<_>>(),
)
.selected(now)
.on_select(move |keys, _, _| *store.borrow_mut() = keys.to_vec())
.w(px(400.0))
}
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn picks(seen: &Rc<RefCell<Vec<SharedString>>>) -> Vec<String> {
seen.borrow().iter().map(|key| key.to_string()).collect()
}
#[gpui::test]
fn boxes_toggle_shift_takes_a_range_and_the_header_takes_all(cx: &mut TestAppContext) {
cx.update(Theme::init);
let seen = Rc::new(RefCell::new(Vec::new()));
let store = seen.clone();
let (_, cx) = cx.add_window_view(|_, _| Picks(store));
settle(cx);
let row = |ix: usize, x: f32| point(px(x), px(54.0 + 36.0 * ix as f32));
cx.simulate_click(row(0, 14.0), Modifiers::none());
settle(cx);
assert_eq!(picks(&seen), ["r0"]);
cx.simulate_click(
row(3, 200.0),
Modifiers {
shift: true,
..Modifiers::none()
},
);
settle(cx);
assert_eq!(picks(&seen), ["r0", "r1", "r2", "r3"]);
cx.simulate_click(point(px(14.0), px(18.0)), Modifiers::none());
settle(cx);
assert_eq!(
picks(&seen),
["r0", "r1", "r2", "r3", "r4"],
"a mixed header box takes all"
);
cx.simulate_click(point(px(14.0), px(18.0)), Modifiers::none());
settle(cx);
assert!(picks(&seen).is_empty(), "a full one clears");
}
+14
View File
@@ -284,6 +284,20 @@ impl Theme {
px_to_rems(128.0)
}
/// A table row's height at each density.
pub fn table_row(&self, density: Density) -> Rems {
px_to_rems(match density {
Density::Compact => 28.0,
Density::Standard => 36.0,
Density::Comfortable => 44.0,
})
}
/// A sparkline beside text.
pub fn spark_size(&self) -> Size<Rems> {
size(px_to_rems(64.0), px_to_rems(18.0))
}
/// How far each level of a tree steps in.
pub fn tree_indent(&self) -> Rems {
px_to_rems(16.0)
+22 -16
View File
@@ -116,32 +116,38 @@
- [x] Menu List → `menus::Menu`
- [x] Transfer List → `forms::TransferList`
## T14 Tables — `src/tables`
## T14a Tables · Core — `src/tables`
- [x] Table → `tables::Table`
- [x] DataTable — sort, filter, page, select → `tables::DataTable`
- [x] VirtualTable → `tables::DataTable`, `virtualized`
- [x] CellRenderer → `tables::Cell`: text, number, tag, progress, sparkline, person, link
- [x] RowSelection → `tables::DataTable`, `selected`
- [x] TableDensity → `tables::DataTable`, `density`
- [x] AggregationFooter → `tables::Column`, `aggregate`
- [x] ConditionalFormatting → `tables::Column`, `scale`
- [x] HeatmapTable → `tables::HeatmapTable`
- [x] ComparisonTable → `data_display::Comparison`
## T14b Tables · Columns & rows
- [ ] Table
- [ ] DataTable — sort, filter, page, select
- [ ] VirtualTable → `tables::DataTable`
- [ ] DataGrid
- [ ] Spreadsheet — formula subset, merge, freeze, fill handle
- [ ] TreeTable
- [ ] PivotTable
- [ ] ColumnResizer
- [ ] ColumnReorder
- [ ] ColumnVisibility
- [ ] ColumnPinning
- [ ] RowSelection
- [ ] RowExpansion
- [ ] RowGrouping
- [ ] InlineRowEdit
- [ ] CellRenderer
- [ ] TableToolbar
- [ ] FilterBuilder
- [ ] SortBuilder
- [ ] AggregationFooter
- [ ] TableDensity
- [ ] ConditionalFormatting
- [ ] HeatmapTable
- [ ] ComparisonTable
## T14c Tables · Grids
- [ ] DataGrid
- [ ] Spreadsheet — formula subset, merge, freeze, fill handle
- [ ] TreeTable
- [ ] PivotTable
## T15 Charts — `src/charts`
@@ -166,7 +172,7 @@
- [ ] NetworkGraph / ForceGraph
- [ ] ChordDiagram
- [ ] ParallelCoordinates
- [ ] Sparkline / SparkBar / SparkArea
- [x] Sparkline / SparkBar / SparkArea → `data_display::Sparkline`, landed with T14a for table cells
- [ ] Bullet Chart
- [ ] ProgressChart
- [ ] RealtimeChart