T14c Tables: DataGrid, Spreadsheet with formulas, merges, frozen panes and fill, TreeTable, PivotTable
This commit is contained in:
14 files changed
+1914
-10
No files matched your search
@@ -0,0 +1,478 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Div, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle,
|
||||
InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, Pixels, Point,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, point, prelude::*,
|
||||
};
|
||||
|
||||
use super::gridkeys::{begin, fill, keys};
|
||||
use crate::{
|
||||
forms::{Editing, Input},
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
pub(crate) type OnCells = Rc<dyn Fn(&[(usize, usize, SharedString)], &mut Window, &mut App)>;
|
||||
pub(crate) type Text = Rc<dyn Fn(usize, usize) -> SharedString>;
|
||||
|
||||
/// A rectangle of cells shown as one, from its first corner to its last.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Merge {
|
||||
pub from: (usize, usize),
|
||||
pub to: (usize, usize),
|
||||
}
|
||||
|
||||
/// A grid's own state: the cursor and the corner its range reaches from, whether it edits, how far it has scrolled, its bounds, and where a fill would reach.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Sheet {
|
||||
pub cursor: (usize, usize),
|
||||
pub anchor: (usize, usize),
|
||||
pub editing: bool,
|
||||
pub offset: Point<Pixels>,
|
||||
pub bounds: Bounds<Pixels>,
|
||||
pub fill: Option<(usize, usize)>,
|
||||
pub rows: usize,
|
||||
}
|
||||
|
||||
/// What a grid face supplies: its size, its labels, the text a cell holds and shows, and who hears edits.
|
||||
pub(crate) struct Face {
|
||||
pub id: ElementId,
|
||||
pub rows: usize,
|
||||
pub cols: usize,
|
||||
pub titles: Rc<dyn Fn(usize) -> SharedString>,
|
||||
pub numbered: bool,
|
||||
pub raw: Text,
|
||||
pub shown: Text,
|
||||
pub merges: Vec<Merge>,
|
||||
pub frozen: (usize, usize),
|
||||
pub fills: bool,
|
||||
pub on_change: Option<OnCells>,
|
||||
}
|
||||
|
||||
/// Sizes in pixels: a cell, a header, the row labels, and the frozen stretch.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Metrics {
|
||||
pub wide: Pixels,
|
||||
pub tall: Pixels,
|
||||
pub label: Pixels,
|
||||
pub frozen: Point<Pixels>,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
/// The cell under a point within the grid, if any.
|
||||
pub(crate) fn cell_at(
|
||||
&self,
|
||||
at: Point<Pixels>,
|
||||
offset: Point<Pixels>,
|
||||
face: (usize, usize),
|
||||
) -> Option<(usize, usize)> {
|
||||
let (x, y) = (at.x - self.label, at.y - self.tall);
|
||||
if x < Pixels::ZERO || y < Pixels::ZERO {
|
||||
return None;
|
||||
}
|
||||
let x = if x < self.frozen.x { x } else { x + offset.x };
|
||||
let y = if y < self.frozen.y { y } else { y + offset.y };
|
||||
let (col, row) = ((x / self.wide) as usize, (y / self.tall) as usize);
|
||||
(row < face.0 && col < face.1).then_some((row, col))
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag of a grid's fill handle, named by its owner.
|
||||
pub(crate) struct FillDrag {
|
||||
owner: EntityId,
|
||||
}
|
||||
|
||||
/// The range the cursor and its anchor span, as top-left and bottom-right.
|
||||
pub(crate) fn range(sheet: &Sheet) -> ((usize, usize), (usize, usize)) {
|
||||
let ((r0, c0), (r1, c1)) = (sheet.anchor, sheet.cursor);
|
||||
((r0.min(r1), c0.min(c1)), (r0.max(r1), c0.max(c1)))
|
||||
}
|
||||
|
||||
/// Draws a grid face: headers, labels, visible cells, the range, the cursor and its fill handle.
|
||||
pub(crate) fn grid(face: Face, base: Div, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = face.id.clone();
|
||||
let focus: FocusHandle =
|
||||
crate::primitives::tab_stop((id.clone(), "focus").into(), true, window, cx);
|
||||
let sheet: Entity<Sheet> =
|
||||
window.use_keyed_state((id.clone(), "sheet"), cx, |_, _| Sheet::default());
|
||||
let editor = window.use_keyed_state((id.clone(), "editor"), cx, |_, _| Editing::default());
|
||||
sheet.update(cx, |sheet, _| sheet.rows = face.rows);
|
||||
if sheet.read(cx).editing && editor.read(cx).field().is_none() {
|
||||
sheet.update(cx, |sheet, _| sheet.editing = false);
|
||||
window.focus(&focus);
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let rem = window.rem_size();
|
||||
let metrics = Metrics {
|
||||
wide: theme.grid_column().to_pixels(rem),
|
||||
tall: theme
|
||||
.table_row(crate::theme::Density::Compact)
|
||||
.to_pixels(rem),
|
||||
label: if face.numbered {
|
||||
theme.control_height(ControlSize::Lg).to_pixels(rem) * 1.25
|
||||
} else {
|
||||
Pixels::ZERO
|
||||
},
|
||||
frozen: Point::default(),
|
||||
};
|
||||
let metrics = Metrics {
|
||||
frozen: point(
|
||||
metrics.wide * face.frozen.1 as f32,
|
||||
metrics.tall * face.frozen.0 as f32,
|
||||
),
|
||||
..metrics
|
||||
};
|
||||
let (rows, cols) = (face.rows, face.cols);
|
||||
let (offset, bounds, editing, fill_to) = {
|
||||
let sheet = sheet.read(cx);
|
||||
(sheet.offset, sheet.bounds, sheet.editing, sheet.fill)
|
||||
};
|
||||
let ((top, left), (bottom, right)) = range(sheet.read(cx));
|
||||
let cursor = sheet.read(cx).cursor;
|
||||
let focused = focus.contains_focused(window, cx);
|
||||
let span = |from: Pixels, cell: Pixels, count: usize, size: Pixels| {
|
||||
let first = (from / cell).floor() as usize;
|
||||
let last = ((from + size) / cell).ceil() as usize;
|
||||
first.min(count)..last.min(count)
|
||||
};
|
||||
let view_rows = span(
|
||||
offset.y + metrics.frozen.y,
|
||||
metrics.tall,
|
||||
rows,
|
||||
bounds.size.height,
|
||||
);
|
||||
let view_cols = span(
|
||||
offset.x + metrics.frozen.x,
|
||||
metrics.wide,
|
||||
cols,
|
||||
bounds.size.width,
|
||||
);
|
||||
let x_of = |col: usize| {
|
||||
let x = metrics.wide * col as f32;
|
||||
metrics.label + if col < face.frozen.1 { x } else { x - offset.x }
|
||||
};
|
||||
let y_of = |row: usize| {
|
||||
let y = metrics.tall * row as f32;
|
||||
metrics.tall + if row < face.frozen.0 { y } else { y - offset.y }
|
||||
};
|
||||
let merged = |row: usize, col: usize| {
|
||||
face.merges.iter().find(|merge| {
|
||||
(merge.from.0..=merge.to.0).contains(&row) && (merge.from.1..=merge.to.1).contains(&col)
|
||||
})
|
||||
};
|
||||
let field = editing.then(|| editor.read(cx).field()).flatten();
|
||||
let cell = |row: usize, col: usize| -> Option<AnyElement> {
|
||||
let merge = merged(row, col);
|
||||
if merge.is_some_and(|merge| merge.from != (row, col)) {
|
||||
return None;
|
||||
}
|
||||
let (wide, tall) = match merge {
|
||||
Some(merge) => (
|
||||
metrics.wide * (merge.to.1 - col + 1) as f32,
|
||||
metrics.tall * (merge.to.0 - row + 1) as f32,
|
||||
),
|
||||
None => (metrics.wide, metrics.tall),
|
||||
};
|
||||
let text = (face.shown)(row, col);
|
||||
let number = text.trim().replace(',', "").parse::<f64>().is_ok();
|
||||
let content: AnyElement = match (&field, (row, col) == cursor) {
|
||||
(Some(field), true) => {
|
||||
let editor = editor.clone();
|
||||
div()
|
||||
.size_full()
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
editor.update(cx, |editing, cx| editing.finish(true, window, cx));
|
||||
}
|
||||
})
|
||||
.child(Input::new(field).size(ControlSize::Sm))
|
||||
.into_any_element()
|
||||
}
|
||||
_ => div().min_w_0().truncate().child(text).into_any_element(),
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left(x_of(col))
|
||||
.top(y_of(row))
|
||||
.w(wide)
|
||||
.h(tall)
|
||||
.flex()
|
||||
.items_center()
|
||||
.when(number, |cell| cell.justify_end())
|
||||
.px_2()
|
||||
.border_r_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.bg)
|
||||
.child(content)
|
||||
.into_any_element(),
|
||||
)
|
||||
};
|
||||
let frozen_rows = 0..face.frozen.0.min(rows);
|
||||
let frozen_cols = 0..face.frozen.1.min(cols);
|
||||
let layer = |rows: std::ops::Range<usize>, cols: std::ops::Range<usize>| {
|
||||
rows.flat_map(move |row| cols.clone().map(move |col| (row, col)))
|
||||
.filter_map(|(row, col)| cell(row, col))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let main = layer(view_rows.clone(), view_cols.clone());
|
||||
let top_band = layer(frozen_rows.clone(), view_cols.clone());
|
||||
let left_band = layer(view_rows.clone(), frozen_cols.clone());
|
||||
let corner_band = layer(frozen_rows, frozen_cols);
|
||||
let headers = view_cols
|
||||
.clone()
|
||||
.chain(0..face.frozen.1.min(cols))
|
||||
.map(|col| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(x_of(col))
|
||||
.top_0()
|
||||
.w(metrics.wide)
|
||||
.h(metrics.tall)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.border_r_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.bg(if (left..=right).contains(&col) && focused {
|
||||
colors.active
|
||||
} else {
|
||||
colors.sunken
|
||||
})
|
||||
.text_color(colors.fg_muted)
|
||||
.child((face.titles)(col))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let labels = face.numbered.then(|| {
|
||||
view_rows
|
||||
.clone()
|
||||
.chain(0..face.frozen.0.min(rows))
|
||||
.map(|row| {
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top(y_of(row))
|
||||
.w(metrics.label)
|
||||
.h(metrics.tall)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.border_r_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.bg(if (top..=bottom).contains(&row) && focused {
|
||||
colors.active
|
||||
} else {
|
||||
colors.sunken
|
||||
})
|
||||
.text_color(colors.fg_muted)
|
||||
.child((row + 1).to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let area = Bounds::new(
|
||||
point(x_of(left), y_of(top)),
|
||||
gpui::size(
|
||||
metrics.wide * (right - left + 1) as f32,
|
||||
metrics.tall * (bottom - top + 1) as f32,
|
||||
),
|
||||
);
|
||||
let owner = sheet.entity_id();
|
||||
let marks = [
|
||||
div()
|
||||
.absolute()
|
||||
.left(area.origin.x)
|
||||
.top(area.origin.y)
|
||||
.w(area.size.width)
|
||||
.h(area.size.height)
|
||||
.bg(colors.selection)
|
||||
.border_1()
|
||||
.border_color(colors.accent.opacity(0.6))
|
||||
.into_any_element(),
|
||||
div()
|
||||
.absolute()
|
||||
.left(x_of(cursor.1))
|
||||
.top(y_of(cursor.0))
|
||||
.w(metrics.wide)
|
||||
.h(metrics.tall)
|
||||
.border_2()
|
||||
.border_color(colors.accent)
|
||||
.into_any_element(),
|
||||
];
|
||||
let preview = fill_to.map(|(row, col)| {
|
||||
let down = row > bottom;
|
||||
let (to_row, to_col) = if down {
|
||||
(row, right)
|
||||
} else {
|
||||
(bottom, col.max(right))
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.left(area.origin.x)
|
||||
.top(area.origin.y)
|
||||
.w(metrics.wide * (to_col - left + 1) as f32)
|
||||
.h(metrics.tall * (to_row - top + 1) as f32)
|
||||
.border_1()
|
||||
.border_color(colors.fg_muted)
|
||||
});
|
||||
let handle = (face.fills && !editing).then(|| {
|
||||
let reach = theme.status_dot();
|
||||
div()
|
||||
.id((id.clone(), "fill"))
|
||||
.absolute()
|
||||
.left(area.origin.x + area.size.width - reach.to_pixels(rem) * 0.5)
|
||||
.top(area.origin.y + area.size.height - reach.to_pixels(rem) * 0.5)
|
||||
.size(reach)
|
||||
.bg(colors.accent)
|
||||
.border_1()
|
||||
.border_color(colors.bg)
|
||||
.cursor_crosshair()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.on_drag(FillDrag { owner }, |_, _, _, cx| {
|
||||
cx.new(|_| gpui::EmptyView)
|
||||
})
|
||||
});
|
||||
let (face_rows, face_cols) = (rows, cols);
|
||||
let (pressed, dragged, moved, measured, scrolled, dropped) = (
|
||||
sheet.clone(),
|
||||
sheet.clone(),
|
||||
sheet.clone(),
|
||||
sheet.clone(),
|
||||
sheet.clone(),
|
||||
sheet.clone(),
|
||||
);
|
||||
let (raw, on_change, begin_with) = (face.raw.clone(), face.on_change.clone(), editor.clone());
|
||||
let (fill_raw, fill_hears) = (face.raw.clone(), face.on_change.clone());
|
||||
let numbered = face.numbered;
|
||||
let keyed = keys(face, metrics, sheet.clone(), editor.clone());
|
||||
let focus_on_press = focus.clone();
|
||||
let text_size = theme.text_size(TextSize::Sm);
|
||||
base.id(id.clone())
|
||||
.track_focus(&focus)
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.text_size(text_size)
|
||||
.text_color(colors.fg)
|
||||
.bg(colors.bg)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.on_key_down(keyed)
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
let at = event.position - pressed.read(cx).bounds.origin;
|
||||
let Some(cell) = metrics.cell_at(at, pressed.read(cx).offset, (face_rows, face_cols))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
window.focus(&focus_on_press);
|
||||
pressed.update(cx, |sheet, cx| {
|
||||
sheet.cursor = cell;
|
||||
if !event.modifiers.shift {
|
||||
sheet.anchor = cell;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
if event.click_count == 2 {
|
||||
let text = raw(cell.0, cell.1).to_string();
|
||||
begin(
|
||||
&pressed,
|
||||
&begin_with,
|
||||
on_change.clone(),
|
||||
text,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
.on_mouse_move(move |event: &MouseMoveEvent, _, cx| {
|
||||
if event.pressed_button != Some(MouseButton::Left) || cx.has_active_drag() {
|
||||
return;
|
||||
}
|
||||
let at = event.position - moved.read(cx).bounds.origin;
|
||||
if let Some(cell) = metrics.cell_at(at, moved.read(cx).offset, (face_rows, face_cols)) {
|
||||
moved.update(cx, |sheet, cx| {
|
||||
if sheet.cursor != cell && !sheet.editing {
|
||||
sheet.cursor = cell;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.on_scroll_wheel(move |event, _, cx| {
|
||||
let delta = event.delta.pixel_delta(metrics.tall);
|
||||
scrolled.update(cx, |sheet, cx| {
|
||||
let most = point(
|
||||
(metrics.wide * face_cols as f32 + metrics.label - sheet.bounds.size.width)
|
||||
.max(Pixels::ZERO),
|
||||
(metrics.tall * (face_rows + 1) as f32 - sheet.bounds.size.height)
|
||||
.max(Pixels::ZERO),
|
||||
);
|
||||
sheet.offset = point(
|
||||
(sheet.offset.x - delta.x).clamp(Pixels::ZERO, most.x),
|
||||
(sheet.offset.y - delta.y).clamp(Pixels::ZERO, most.y),
|
||||
);
|
||||
cx.notify();
|
||||
});
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_drag_move(move |event: &DragMoveEvent<FillDrag>, _, cx| {
|
||||
if event.drag(cx).owner != owner {
|
||||
return;
|
||||
}
|
||||
let at = event.event.position - dragged.read(cx).bounds.origin;
|
||||
let cell = metrics.cell_at(at, dragged.read(cx).offset, (face_rows, face_cols));
|
||||
dragged.update(cx, |sheet, cx| {
|
||||
if sheet.fill != cell {
|
||||
sheet.fill = cell;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.on_drop(move |drag: &FillDrag, window, cx| {
|
||||
if drag.owner == owner {
|
||||
fill(&dropped, &fill_raw, &fill_hears, window, cx);
|
||||
}
|
||||
})
|
||||
.children(main)
|
||||
.children(top_band)
|
||||
.children(left_band)
|
||||
.children(corner_band)
|
||||
.children(marks)
|
||||
.children(preview)
|
||||
.children(handle)
|
||||
.children(headers)
|
||||
.children(labels.into_iter().flatten())
|
||||
.when(numbered, |grid| {
|
||||
grid.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(metrics.label)
|
||||
.h(metrics.tall)
|
||||
.bg(colors.sunken)
|
||||
.border_r_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
if measured.read(cx).bounds != bounds {
|
||||
measured.update(cx, |sheet, cx| {
|
||||
sheet.bounds = bounds;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user