T14a Tables core: DataTable, Table, HeatmapTable, cells, Sparkline (review pending: codex 401)
This commit is contained in:
18 files changed
+1690
-19
No files matched your search
@@ -49,7 +49,9 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [ ] T13a Lists & Trees · Lists
|
- [ ] T13a Lists & Trees · Lists
|
||||||
- [ ] T13b Lists & Trees · Trees
|
- [ ] T13b Lists & Trees · Trees
|
||||||
- [ ] T13c Lists & Trees · Files
|
- [ ] T13c Lists & Trees · Files
|
||||||
- [ ] T14 Tables
|
- [ ] T14a Tables · Core
|
||||||
|
- [ ] T14b Tables · Columns & rows
|
||||||
|
- [ ] T14c Tables · Grids
|
||||||
- [ ] T15 Charts
|
- [ ] T15 Charts
|
||||||
- [ ] T16a Finance · Market charts
|
- [ ] T16a Finance · Market charts
|
||||||
- [ ] T16b Finance · Technical analysis
|
- [ ] T16b Finance · Technical analysis
|
||||||
@@ -191,3 +193,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T13a | 1 | — | pending: codex still logged out |
|
| T13a | 1 | — | pending: codex still logged out |
|
||||||
| T13b | 1 | — | pending: codex still logged out |
|
| T13b | 1 | — | pending: codex still logged out |
|
||||||
| T13c | 1 | — | pending: codex still logged out |
|
| T13c | 1 | — | pending: codex still logged out |
|
||||||
|
| T14a | 1 | — | pending: codex still logged out |
|
||||||
@@ -10,6 +10,7 @@ mod navigation;
|
|||||||
mod overlays;
|
mod overlays;
|
||||||
mod primitives;
|
mod primitives;
|
||||||
mod shell;
|
mod shell;
|
||||||
|
mod tables;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod typography;
|
mod typography;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ pub const ALL: &[Page] = &[
|
|||||||
motion::PAGE,
|
motion::PAGE,
|
||||||
data::PAGE,
|
data::PAGE,
|
||||||
lists::PAGE,
|
lists::PAGE,
|
||||||
|
tables::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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."))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ mod feed;
|
|||||||
mod gallery;
|
mod gallery;
|
||||||
mod measures;
|
mod measures;
|
||||||
mod records;
|
mod records;
|
||||||
|
mod spark;
|
||||||
mod stars;
|
mod stars;
|
||||||
mod stat;
|
mod stat;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -27,6 +28,7 @@ pub use feed::{Activity, ActivityFeed};
|
|||||||
pub use gallery::Gallery;
|
pub use gallery::Gallery;
|
||||||
pub use measures::{Gauge, Meter, UsageBar};
|
pub use measures::{Gauge, Meter, UsageBar};
|
||||||
pub use records::{DescriptionList, PropertyGrid, PropertyGroup};
|
pub use records::{DescriptionList, PropertyGrid, PropertyGroup};
|
||||||
|
pub use spark::Sparkline;
|
||||||
pub use stars::Stars;
|
pub use stars::Stars;
|
||||||
pub(crate) use stars::star;
|
pub(crate) use stars::star;
|
||||||
pub use stat::{KpiCard, Statistic, TrendIndicator};
|
pub use stat::{KpiCard, Statistic, TrendIndicator};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod navigation;
|
|||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod primitives;
|
pub mod primitives;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod tables;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod typography;
|
pub mod typography;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub use item::{List, ListItem};
|
|||||||
pub use long::{InfiniteList, VirtualList};
|
pub use long::{InfiniteList, VirtualList};
|
||||||
pub use outline::Outline;
|
pub use outline::Outline;
|
||||||
pub use select::SelectableList;
|
pub use select::SelectableList;
|
||||||
|
pub(crate) use select::{Pick, picked};
|
||||||
pub use sortable::SortableList;
|
pub use sortable::SortableList;
|
||||||
pub use swipe::{SwipeAction, SwipeableListItem};
|
pub use swipe::{SwipeAction, SwipeableListItem};
|
||||||
pub use tree::{DropAt, Tree, TreeNode, TreeSelect};
|
pub use tree::{DropAt, Tree, TreeNode, TreeSelect};
|
||||||
+2
-2
@@ -10,7 +10,7 @@ use crate::primitives::tab_stop;
|
|||||||
|
|
||||||
/// How a press or a key picks rows.
|
/// How a press or a key picks rows.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) enum Pick {
|
pub(crate) enum Pick {
|
||||||
One,
|
One,
|
||||||
Toggle,
|
Toggle,
|
||||||
Range,
|
Range,
|
||||||
@@ -18,7 +18,7 @@ pub(super) enum Pick {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The selection after picking row `at`, a range reaching back to `anchor`; in row order.
|
/// 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],
|
keys: &[SharedString],
|
||||||
selected: &[SharedString],
|
selected: &[SharedString],
|
||||||
anchor: usize,
|
anchor: usize,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -284,6 +284,20 @@ impl Theme {
|
|||||||
px_to_rems(128.0)
|
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.
|
/// How far each level of a tree steps in.
|
||||||
pub fn tree_indent(&self) -> Rems {
|
pub fn tree_indent(&self) -> Rems {
|
||||||
px_to_rems(16.0)
|
px_to_rems(16.0)
|
||||||
|
|||||||
+22
-16
@@ -116,32 +116,38 @@
|
|||||||
- [x] Menu List → `menus::Menu`
|
- [x] Menu List → `menus::Menu`
|
||||||
- [x] Transfer List → `forms::TransferList`
|
- [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
|
- [ ] ColumnResizer
|
||||||
- [ ] ColumnReorder
|
- [ ] ColumnReorder
|
||||||
- [ ] ColumnVisibility
|
- [ ] ColumnVisibility
|
||||||
- [ ] ColumnPinning
|
- [ ] ColumnPinning
|
||||||
- [ ] RowSelection
|
|
||||||
- [ ] RowExpansion
|
- [ ] RowExpansion
|
||||||
- [ ] RowGrouping
|
- [ ] RowGrouping
|
||||||
- [ ] InlineRowEdit
|
- [ ] InlineRowEdit
|
||||||
- [ ] CellRenderer
|
|
||||||
- [ ] TableToolbar
|
- [ ] TableToolbar
|
||||||
- [ ] FilterBuilder
|
- [ ] FilterBuilder
|
||||||
- [ ] SortBuilder
|
- [ ] SortBuilder
|
||||||
- [ ] AggregationFooter
|
|
||||||
- [ ] TableDensity
|
## T14c Tables · Grids
|
||||||
- [ ] ConditionalFormatting
|
|
||||||
- [ ] HeatmapTable
|
- [ ] DataGrid
|
||||||
- [ ] ComparisonTable
|
- [ ] Spreadsheet — formula subset, merge, freeze, fill handle
|
||||||
|
- [ ] TreeTable
|
||||||
|
- [ ] PivotTable
|
||||||
|
|
||||||
## T15 Charts — `src/charts`
|
## T15 Charts — `src/charts`
|
||||||
|
|
||||||
@@ -166,7 +172,7 @@
|
|||||||
- [ ] NetworkGraph / ForceGraph
|
- [ ] NetworkGraph / ForceGraph
|
||||||
- [ ] ChordDiagram
|
- [ ] ChordDiagram
|
||||||
- [ ] ParallelCoordinates
|
- [ ] ParallelCoordinates
|
||||||
- [ ] Sparkline / SparkBar / SparkArea
|
- [x] Sparkline / SparkBar / SparkArea → `data_display::Sparkline`, landed with T14a for table cells
|
||||||
- [ ] Bullet Chart
|
- [ ] Bullet Chart
|
||||||
- [ ] ProgressChart
|
- [ ] ProgressChart
|
||||||
- [ ] RealtimeChart
|
- [ ] RealtimeChart
|
||||||
|
|||||||
Reference in new issue
Block a user