mod state;
pub use state::{SelectionMode, SortDir};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::ops::Range;
use std::rc::Rc;
use gpui::prelude::*;
use gpui::{
div, px, uniform_list, AnyElement, App, Bounds, Context, Div, DragMoveEvent, Empty, EntityId,
EventEmitter, FocusHandle, FontWeight, KeyDownEvent, MouseButton, MouseDownEvent, Pixels,
ScrollStrategy, SharedString, Subscription, UniformListScrollHandle, WeakEntity, Window,
};
use self::state::{cycle_sort, identity_order, sorted_order, SelectionState};
use super::Content;
use crate::layout::Align;
use crate::reactive::Signal;
use crate::style::FlexExt;
use crate::theme::{theme, ColorName, Size};
#[derive(Debug, Clone)]
pub enum TableViewEvent {
SelectionChanged(Vec<usize>),
Activated(usize),
Sorted(Option<(usize, SortDir)>),
}
type Comparator<T> = Rc<dyn Fn(&T, &T) -> Ordering>;
type CellBuilder<T> = Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>;
enum CellContent<T> {
Text(Rc<dyn Fn(&T) -> SharedString>),
Element(CellBuilder<T>),
}
pub struct Column<T> {
title: SharedString,
width: Option<f32>,
flex: f32,
min_width: f32,
align: Align,
sort: Option<Comparator<T>>,
content: Option<CellContent<T>>,
}
impl<T> Column<T> {
pub fn new(title: impl Into<SharedString>) -> Self {
Column {
title: title.into(),
width: None,
flex: 1.0,
min_width: 60.0,
align: Align::Start,
sort: None,
content: None,
}
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn flex(mut self, flex: f32) -> Self {
self.flex = flex;
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.min_width = min_width;
self
}
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
pub fn sortable_by(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self {
self.sort = Some(Rc::new(cmp));
self
}
pub fn cell<E>(mut self, cell: impl Fn(&T, &mut Window, &mut App) -> E + 'static) -> Self
where
E: IntoElement,
{
self.content = Some(CellContent::Element(Rc::new(move |row, window, cx| {
cell(row, window, cx).into_any_element()
})));
self
}
pub fn text(mut self, text: impl Fn(&T) -> SharedString + 'static) -> Self {
self.content = Some(CellContent::Text(Rc::new(text)));
self
}
}
enum Rows<T> {
Owned(Rc<Vec<T>>),
Bound(Signal<Vec<T>>),
}
impl<T> Clone for Rows<T> {
fn clone(&self) -> Self {
match self {
Rows::Owned(rows) => Rows::Owned(rows.clone()),
Rows::Bound(signal) => Rows::Bound(signal.clone()),
}
}
}
struct ResizeDrag {
owner: EntityId,
column: usize,
}
#[derive(Clone, Copy)]
enum ColWidth {
Fixed(f32),
Flex(f32, f32), }
pub struct TableView<T: 'static> {
columns: Vec<Column<T>>,
rows: Rows<T>,
focus: FocusHandle,
mode: SelectionMode,
selection: SelectionState,
sort: Option<(usize, SortDir)>,
display_order: Vec<usize>,
resized: HashMap<usize, f32>,
header_bounds: Vec<Bounds<Pixels>>,
rows_sub: Option<Subscription>,
striped: bool,
highlight_on_hover: bool,
with_border: bool,
height: Option<f32>,
empty: Option<Content>,
scroll: UniformListScrollHandle,
}
impl<T: 'static> EventEmitter<TableViewEvent> for TableView<T> {}
impl<T: 'static> TableView<T> {
pub fn new(cx: &mut Context<Self>) -> Self {
TableView {
columns: Vec::new(),
rows: Rows::Owned(Rc::new(Vec::new())),
focus: cx.focus_handle(),
mode: SelectionMode::None,
selection: SelectionState::default(),
sort: None,
display_order: Vec::new(),
resized: HashMap::new(),
header_bounds: Vec::new(),
rows_sub: None,
striped: false,
highlight_on_hover: false,
with_border: false,
height: None,
empty: None,
scroll: UniformListScrollHandle::new(),
}
}
pub fn columns(mut self, columns: Vec<Column<T>>) -> Self {
self.columns = columns;
self
}
pub fn rows(mut self, rows: Vec<T>) -> Self {
self.rows = Rows::Owned(Rc::new(rows));
self
}
pub fn bind_rows(mut self, signal: &Signal<Vec<T>>, cx: &mut Context<Self>) -> Self {
self.rows = Rows::Bound(signal.clone());
self.rows_sub = Some(cx.observe(signal.entity(), |this, rows, cx| {
let len = rows.read(cx).len();
this.prune_selection(len, cx);
cx.notify();
}));
self
}
pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
self.mode = mode;
self
}
pub fn striped(mut self, striped: bool) -> Self {
self.striped = striped;
self
}
pub fn highlight_on_hover(mut self, highlight: bool) -> Self {
self.highlight_on_hover = highlight;
self
}
pub fn with_border(mut self, with_border: bool) -> Self {
self.with_border = with_border;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub fn empty<E>(mut self, builder: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
where
E: IntoElement,
{
self.empty = Some(Box::new(move |window, cx| {
builder(window, cx).into_any_element()
}));
self
}
pub fn set_rows(&mut self, rows: Vec<T>, cx: &mut Context<Self>) {
let len = rows.len();
self.rows = Rows::Owned(Rc::new(rows));
self.rows_sub = None;
self.prune_selection(len, cx);
cx.notify();
}
pub fn selected(&self) -> Vec<usize> {
self.selection.selected()
}
pub fn sort_state(&self) -> Option<(usize, SortDir)> {
self.sort
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus.clone()
}
fn prune_selection(&mut self, len: usize, cx: &mut Context<Self>) {
if self.selection.retain_below(len) {
cx.emit(TableViewEvent::SelectionChanged(self.selection.selected()));
}
}
fn compute_order(&self, cx: &App) -> Vec<usize> {
let sort = self.sort.and_then(|(col, dir)| {
let cmp = self.columns.get(col)?.sort.clone()?;
Some((dir, cmp))
});
match &self.rows {
Rows::Owned(rows) => order_of(rows, sort),
Rows::Bound(signal) => order_of(signal.read(cx), sort),
}
}
fn col_width(&self, ix: usize) -> ColWidth {
let col = &self.columns[ix];
if let Some(&w) = self.resized.get(&ix) {
ColWidth::Fixed(w.max(col.min_width))
} else if let Some(w) = col.width {
ColWidth::Fixed(w.max(col.min_width))
} else {
ColWidth::Flex(col.flex, col.min_width)
}
}
fn toggle_sort(&mut self, column: usize, cx: &mut Context<Self>) {
self.sort = cycle_sort(self.sort, column);
cx.emit(TableViewEvent::Sorted(self.sort));
cx.notify();
}
fn on_resize_drag(
&mut self,
ev: &DragMoveEvent<ResizeDrag>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let (owner, column) = {
let drag = ev.drag(cx);
(drag.owner, drag.column)
};
if owner != cx.entity_id() {
return;
}
let Some(bounds) = self.header_bounds.get(column) else {
return;
};
let min = self.columns.get(column).map(|c| c.min_width).unwrap_or(0.0);
let width = f32::from(ev.event.position.x - bounds.left()).max(min);
self.resized.insert(column, width);
cx.notify();
}
fn row_mouse_down(
&mut self,
display: usize,
toggle: bool,
range: bool,
click_count: usize,
cx: &mut Context<Self>,
) {
if click_count == 2 {
if let Some(&source) = self.display_order.get(display) {
cx.emit(TableViewEvent::Activated(source));
}
return;
}
if matches!(self.mode, SelectionMode::None) {
return;
}
let before = self.selection.selected();
self.selection
.click(self.mode, &self.display_order, display, toggle, range);
let after = self.selection.selected();
if before != after {
cx.emit(TableViewEvent::SelectionChanged(after));
}
cx.notify();
}
fn step(&mut self, delta: isize, extend: bool, cx: &mut Context<Self>) {
let before = self.selection.selected();
let Some(display) = self
.selection
.step(self.mode, &self.display_order, delta, extend)
else {
return;
};
if self.height.is_some() {
self.scroll.scroll_to_item(display, ScrollStrategy::Center);
}
let after = self.selection.selected();
if before != after {
cx.emit(TableViewEvent::SelectionChanged(after));
}
cx.notify();
cx.stop_propagation();
}
fn on_key(&mut self, ev: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
let shift = ev.keystroke.modifiers.shift;
match ev.keystroke.key.as_str() {
"up" => self.step(-1, shift, cx),
"down" => self.step(1, shift, cx),
"enter" => {
let target = self.selection.cursor().or_else(|| {
let selected = self.selection.selected();
(selected.len() == 1).then(|| selected[0])
});
if let Some(source) = target {
cx.emit(TableViewEvent::Activated(source));
cx.stop_propagation();
}
}
"escape" => self.clear_selection(cx),
_ => {}
}
}
fn clear_selection(&mut self, cx: &mut Context<Self>) {
if self.selection.clear() {
cx.emit(TableViewEvent::SelectionChanged(Vec::new()));
cx.notify();
cx.stop_propagation();
}
}
fn render_header(&self, cx: &mut Context<Self>) -> Div {
let t = theme(cx);
let font = t.font_size(Size::Sm);
let dimmed = t.dimmed().hsla();
let text = t.text().hsla();
let accent = t.primary().hsla();
let grip_hover = t.primary().alpha(0.6);
let line = t.border().hsla();
let owner = cx.entity_id();
let view = cx.weak_entity();
let mut row = div()
.flex()
.w_full()
.border_b_1()
.border_color(line)
.on_children_prepainted(move |bounds, _window, app| {
view.update(app, |this, _| this.header_bounds = bounds).ok();
});
for ix in 0..self.columns.len() {
let col = &self.columns[ix];
let sortable = col.sort.is_some();
let sort_dir = self.sort.filter(|&(c, _)| c == ix).map(|(_, d)| d);
let grip = div()
.id(("guise-tableview-grip", ix))
.absolute()
.top(px(0.0))
.bottom(px(0.0))
.right(px(-3.0))
.w(px(6.0))
.cursor_col_resize()
.hover(move |s| s.bg(grip_hover))
.on_drag(ResizeDrag { owner, column: ix }, |_, _, _, cx| {
cx.new(|_| Empty)
})
.on_click(|_ev, _window, cx| cx.stop_propagation());
let mut cell = div()
.relative()
.flex()
.items_center()
.gap(px(6.0))
.px(px(12.0))
.py(px(8.0))
.text_size(px(font))
.text_color(dimmed)
.font_weight(FontWeight::SEMIBOLD);
cell = sized(cell, self.col_width(ix));
cell = aligned(cell, col.align);
cell = cell.child(div().min_w(px(0.0)).truncate().child(col.title.clone()));
if let Some(dir) = sort_dir {
cell = cell.child(div().text_size(px(font * 0.65)).text_color(accent).child(
SharedString::new_static(match dir {
SortDir::Asc => "\u{25b2}",
SortDir::Desc => "\u{25bc}",
}),
));
}
cell = cell.child(grip);
let cell: AnyElement = if sortable {
cell.id(("guise-tableview-head", ix))
.cursor_pointer()
.hover(move |s| s.text_color(text))
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.toggle_sort(ix, cx);
}))
.into_any_element()
} else {
cell.into_any_element()
};
row = row.child(cell);
}
row
}
fn render_rows(
&self,
range: Range<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let view = cx.weak_entity();
match self.rows.clone() {
Rows::Owned(rows) => range
.filter_map(|display| {
let source = *self.display_order.get(display)?;
let row = rows.get(source)?;
Some(self.render_row(&view, display, source, row, window, cx))
})
.collect(),
Rows::Bound(signal) => signal.entity().update(cx, |rows, cx| {
range
.filter_map(|display| {
let source = *self.display_order.get(display)?;
let row = rows.get(source)?;
Some(self.render_row(&view, display, source, row, window, cx))
})
.collect()
}),
}
}
fn render_row(
&self,
view: &WeakEntity<Self>,
display: usize,
source: usize,
row: &T,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let t = theme(cx);
let font = t.font_size(Size::Sm);
let text = t.text().hsla();
let line = t.border().hsla();
let stripe = t.surface_hover().hsla();
let hover = t
.color(ColorName::Gray, if t.scheme.is_dark() { 6 } else { 1 })
.hsla();
let selected_bg = t.primary().alpha(0.12);
let is_selected = self.selection.is_selected(source);
let mut tr = div()
.id(("guise-tableview-row", display))
.flex()
.w_full()
.border_b_1()
.border_color(line)
.text_size(px(font))
.text_color(text);
if is_selected {
tr = tr.bg(selected_bg);
} else if self.striped && display % 2 == 1 {
tr = tr.bg(stripe);
}
if self.highlight_on_hover && !is_selected {
tr = tr.hover(move |s| s.bg(hover));
}
for (ix, col) in self.columns.iter().enumerate() {
let mut cell = div()
.flex()
.items_center()
.px(px(12.0))
.py(px(8.0))
.overflow_hidden();
cell = sized(cell, self.col_width(ix));
cell = aligned(cell, col.align);
cell = match &col.content {
Some(CellContent::Text(to_text)) => {
cell.child(div().min_w(px(0.0)).truncate().child(to_text(row)))
}
Some(CellContent::Element(build)) => cell.child(build(row, window, cx)),
None => cell,
};
tr = tr.child(cell);
}
let view = view.clone();
tr = tr.on_mouse_down(
MouseButton::Left,
move |ev: &MouseDownEvent, window, app| {
let toggle = ev.modifiers.platform;
let range = ev.modifiers.shift;
let count = ev.click_count;
view.update(app, |this, cx| {
window.focus(&this.focus);
this.row_mouse_down(display, toggle, range, count, cx);
})
.ok();
},
);
tr.into_any_element()
}
}
impl<T: 'static> Render for TableView<T> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.display_order = self.compute_order(cx);
let count = self.display_order.len();
let t = theme(cx);
let line = t.border().hsla();
let dimmed = t.dimmed().hsla();
let font = t.font_size(Size::Sm);
let radius = t.radius(t.default_radius);
let header = self.render_header(cx);
let body: AnyElement = if count == 0 {
match &self.empty {
Some(builder) => builder(window, cx),
None => div()
.flex()
.items_center()
.justify_center()
.py(px(24.0))
.text_size(px(font))
.text_color(dimmed)
.child(SharedString::new_static("No data"))
.into_any_element(),
}
} else if let Some(height) = self.height {
uniform_list(
"guise-tableview-body",
count,
cx.processor(|this, range: Range<usize>, window, cx| {
this.render_rows(range, window, cx)
}),
)
.h(px(height))
.w_full()
.track_scroll(self.scroll.clone())
.into_any_element()
} else {
div()
.flex()
.flex_col()
.w_full()
.children(self.render_rows(0..count, window, cx))
.into_any_element()
};
let mut table = div()
.id("guise-tableview")
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::on_key))
.on_drag_move(cx.listener(Self::on_resize_drag))
.flex()
.flex_col()
.w_full()
.child(header)
.child(body);
if self.with_border {
table = table
.border_1()
.border_color(line)
.rounded(px(radius))
.overflow_hidden();
}
table
}
}
fn order_of<T>(rows: &[T], sort: Option<(SortDir, Comparator<T>)>) -> Vec<usize> {
match sort {
Some((dir, cmp)) => sorted_order(rows, dir, &*cmp),
None => identity_order(rows.len()),
}
}
fn sized(cell: Div, width: ColWidth) -> Div {
match width {
ColWidth::Fixed(w) => cell.w(px(w)).flex_none(),
ColWidth::Flex(factor, min) => cell
.grow(factor)
.shrink(1.0)
.flex_basis(px(0.0))
.min_w(px(min)),
}
}
fn aligned(cell: Div, align: Align) -> Div {
match align {
Align::Start | Align::Stretch => cell.justify_start(),
Align::Center => cell.justify_center(),
Align::End => cell.justify_end(),
}
}