410 lines
16 KiB
Rust
410 lines
16 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
AnyElement, App, AppContext as _, ElementId, EmptyView, EntityId, InteractiveElement,
|
|
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
|
|
StatefulInteractiveElement, Styled, Window, canvas, div,
|
|
};
|
|
|
|
use super::tiles::{Tile, arranged, stepped};
|
|
use crate::{
|
|
primitives::{Icon, IconName, tab_stop},
|
|
theme::{ActiveTheme, IconSize, Radius},
|
|
};
|
|
|
|
type OnTiles = Rc<dyn Fn(Vec<Tile>, &mut Window, &mut App)>;
|
|
|
|
/// What a drag on a tile does.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Grip {
|
|
Move,
|
|
Size,
|
|
}
|
|
|
|
/// A drag on a grid's tile: whose grid, which tile, what it does, and where the pointer was pressed.
|
|
struct Pull {
|
|
owner: EntityId,
|
|
}
|
|
|
|
/// The grid's own state: its width, and a drag under way with the tile as it would land.
|
|
#[derive(Default)]
|
|
struct Board {
|
|
width: f32,
|
|
pull: Option<(SharedString, Grip, Point<Pixels>, Tile)>,
|
|
landing: Option<Tile>,
|
|
}
|
|
|
|
/// Widgets on a grid of columns. A tile's top strip drags it and its corner resizes it, a cell at a time; the tiles in the way move down and every tile rises to fill a gap, while the drag shows where it will land. A focused tile moves a column with Left and Right, below the next tile under it with Down or into the place of the one above with Up, and grows or shrinks a cell with Shift. The owner keeps the tiles. Under the theme's `stack_below` width the tiles stand in one column in reading order, each its own height, and moving waits for room.
|
|
#[derive(IntoElement)]
|
|
pub struct DashboardGrid {
|
|
id: ElementId,
|
|
columns: u32,
|
|
tiles: Vec<Tile>,
|
|
cards: Vec<(SharedString, AnyElement)>,
|
|
on_change: OnTiles,
|
|
}
|
|
|
|
impl DashboardGrid {
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
columns: u32,
|
|
tiles: impl IntoIterator<Item = Tile>,
|
|
on_change: impl Fn(Vec<Tile>, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
assert!(columns > 0, "a grid of no columns");
|
|
Self {
|
|
id: id.into(),
|
|
columns,
|
|
tiles: tiles.into_iter().collect(),
|
|
cards: Vec::new(),
|
|
on_change: Rc::new(on_change),
|
|
}
|
|
}
|
|
|
|
/// What the tile of `key` shows.
|
|
pub fn card(mut self, key: impl Into<SharedString>, card: impl IntoElement) -> Self {
|
|
self.cards.push((key.into(), card.into_any_element()));
|
|
self
|
|
}
|
|
}
|
|
|
|
/// The tile a drag of `by` pixels makes of `start`, a cell at a time.
|
|
fn pulled(start: &Tile, grip: Grip, by: (f32, f32), cell: (f32, f32)) -> Tile {
|
|
let step = |pixels: f32, size: f32| (pixels / size).round() as i64;
|
|
let (dx, dy) = (step(by.0, cell.0), step(by.1, cell.1));
|
|
let moved = |value: u32, by: i64, least: i64| (value as i64 + by).max(least) as u32;
|
|
match grip {
|
|
Grip::Move => Tile {
|
|
x: moved(start.x, dx, 0),
|
|
y: moved(start.y, dy, 0),
|
|
..start.clone()
|
|
},
|
|
Grip::Size => Tile {
|
|
w: moved(start.w, dx, 1),
|
|
h: moved(start.h, dy, 1),
|
|
..start.clone()
|
|
},
|
|
}
|
|
}
|
|
|
|
/// A canvas that keeps the grid's width in its board.
|
|
fn measure(board: gpui::Entity<Board>) -> impl IntoElement {
|
|
canvas(
|
|
move |bounds, window, cx| {
|
|
let now = f32::from(bounds.size.width);
|
|
if board.read(cx).width != now {
|
|
board.update(cx, |board, _| board.width = now);
|
|
window.request_animation_frame();
|
|
}
|
|
},
|
|
|_, _, _, _| {},
|
|
)
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.size_full()
|
|
}
|
|
|
|
/// The tiles in one column in reading order, each as tall as its rows: a grid too narrow for its columns.
|
|
fn stacked(
|
|
id: ElementId,
|
|
mut tiles: Vec<Tile>,
|
|
mut cards: Vec<(SharedString, AnyElement)>,
|
|
board: gpui::Entity<Board>,
|
|
cx: &App,
|
|
) -> AnyElement {
|
|
let sizes = cx.theme().dashboard();
|
|
tiles.sort_by_key(|tile| (tile.y, tile.x));
|
|
let column: Vec<_> = tiles
|
|
.into_iter()
|
|
.filter_map(|tile| {
|
|
let Some(at) = cards.iter().position(|(key, _)| *key == tile.key) else {
|
|
log::error!(
|
|
"dashboard grid {id:?}: no card for tile {}; left out",
|
|
tile.key
|
|
);
|
|
return None;
|
|
};
|
|
let card = cards.remove(at).1;
|
|
let key = tile.key.clone();
|
|
Some(
|
|
div()
|
|
.debug_selector(move || format!("stacked-{key}"))
|
|
.h((sizes.row + sizes.gap) * tile.h as f32 - sizes.gap)
|
|
.child(card),
|
|
)
|
|
})
|
|
.collect();
|
|
if !cards.is_empty() {
|
|
let keys: Vec<_> = cards.iter().map(|(key, _)| key).collect();
|
|
log::error!("dashboard grid: cards with no tile {keys:?}; left out");
|
|
}
|
|
div()
|
|
.id(id)
|
|
.relative()
|
|
.flex()
|
|
.flex_col()
|
|
.gap(sizes.gap)
|
|
.child(measure(board))
|
|
.children(column)
|
|
.into_any_element()
|
|
}
|
|
|
|
impl RenderOnce for DashboardGrid {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let id = self.id;
|
|
let board = window.use_keyed_state((id.clone(), "board"), cx, |_, _| Board::default());
|
|
let owner = board.entity_id();
|
|
let theme = cx.theme();
|
|
let rem = window.rem_size();
|
|
let (row, gap) = (
|
|
f32::from(theme.dashboard().row.to_pixels(rem)),
|
|
f32::from(theme.dashboard().gap.to_pixels(rem)),
|
|
);
|
|
let columns = self.columns;
|
|
let width = board.read(cx).width;
|
|
if width > 0.0 && width < f32::from(theme.dashboard().stack_below.to_pixels(rem)) {
|
|
return stacked(id, self.tiles, self.cards, board, cx);
|
|
}
|
|
let cell = ((width + gap) / columns as f32, row + gap);
|
|
let landing = board.read(cx).landing.clone();
|
|
let shown = match &landing {
|
|
Some(tile) => {
|
|
arranged(&self.tiles, tile.clone(), columns).unwrap_or_else(|| self.tiles.clone())
|
|
}
|
|
None => self.tiles.clone(),
|
|
};
|
|
let height = shown.iter().map(|tile| tile.y + tile.h).max().unwrap_or(0) as f32 * cell.1;
|
|
let tiles = Rc::new(self.tiles);
|
|
let on_change = self.on_change;
|
|
let (colors, radius) = (theme.colors.clone(), theme.radius(Radius::Lg));
|
|
let grip = theme.control_height(crate::theme::ControlSize::Sm);
|
|
let mut cards = self.cards;
|
|
let placed: Vec<AnyElement> = shown
|
|
.iter()
|
|
.filter_map(|tile| {
|
|
let Some(at) = cards.iter().position(|(key, _)| *key == tile.key) else {
|
|
log::error!(
|
|
"dashboard grid {id:?}: no card for tile {}; left out",
|
|
tile.key
|
|
);
|
|
return None;
|
|
};
|
|
let card = cards.remove(at).1;
|
|
let focus = tab_stop(
|
|
(id.clone(), format!("tile-{}", tile.key)).into(),
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
let focused = focus.is_focused(window);
|
|
let key = tile.key.clone();
|
|
let start = |grip: Grip| {
|
|
let (board, key, tiles) = (board.clone(), key.clone(), tiles.clone());
|
|
move |event: &gpui::MouseDownEvent, _: &mut Window, cx: &mut App| {
|
|
cx.stop_propagation();
|
|
let tile = tiles
|
|
.iter()
|
|
.find(|tile| tile.key == key)
|
|
.expect("a tile of the grid")
|
|
.clone();
|
|
board.update(cx, |board, _| {
|
|
board.pull = Some((key.clone(), grip, event.position, tile))
|
|
});
|
|
}
|
|
};
|
|
let (keys, keyed_tiles, keyed_change, own) =
|
|
(key.clone(), tiles.clone(), on_change.clone(), focus.clone());
|
|
Some(
|
|
div()
|
|
.id((id.clone(), format!("tile-{}", tile.key)))
|
|
.track_focus(&focus)
|
|
.absolute()
|
|
.left(Pixels::from(tile.x as f32 * cell.0))
|
|
.top(Pixels::from(tile.y as f32 * cell.1))
|
|
.w(Pixels::from(tile.w as f32 * cell.0 - gap))
|
|
.h(Pixels::from(tile.h as f32 * cell.1 - gap))
|
|
.rounded(radius)
|
|
.border_1()
|
|
.border_color(if focused {
|
|
colors.focus
|
|
} else {
|
|
gpui::transparent_black()
|
|
})
|
|
.on_key_down(move |event, window, cx| {
|
|
if !own.is_focused(window) {
|
|
return;
|
|
}
|
|
let grow = event.keystroke.modifiers.shift;
|
|
let key = event.keystroke.key.as_str();
|
|
let (dx, dy): (i64, i64) = match key {
|
|
"left" => (-1, 0),
|
|
"right" => (1, 0),
|
|
"up" => (0, -1),
|
|
"down" => (0, 1),
|
|
_ => return,
|
|
};
|
|
cx.stop_propagation();
|
|
let tile = keyed_tiles
|
|
.iter()
|
|
.find(|tile| tile.key == keys)
|
|
.expect("a tile of the grid");
|
|
let to = match (grow, dy) {
|
|
(false, 0) => {
|
|
pulled(tile, Grip::Move, (dx as f32, 0.0), (1.0, 1.0))
|
|
}
|
|
(false, _) => match stepped(&keyed_tiles, &keys, dy > 0) {
|
|
Some(to) => to,
|
|
None => return,
|
|
},
|
|
(true, _) => {
|
|
pulled(tile, Grip::Size, (dx as f32, dy as f32), (1.0, 1.0))
|
|
}
|
|
};
|
|
log::info!("dashboard grid: {} to {to:?}", keys);
|
|
if let Some(tiles) = arranged(&keyed_tiles, to, columns) {
|
|
keyed_change(tiles, window, cx);
|
|
}
|
|
})
|
|
.child(div().size_full().child(card))
|
|
.child(
|
|
div()
|
|
.id((id.clone(), format!("move-{}", tile.key)))
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.right(grip)
|
|
.h(grip)
|
|
.cursor_grab()
|
|
.on_mouse_down(MouseButton::Left, start(Grip::Move))
|
|
.on_drag(Pull { owner }, |_, _, _, cx| cx.new(|_| EmptyView)),
|
|
)
|
|
.child(
|
|
div()
|
|
.id((id.clone(), format!("size-{}", tile.key)))
|
|
.absolute()
|
|
.bottom_0()
|
|
.right_0()
|
|
.size(grip)
|
|
.flex()
|
|
.items_end()
|
|
.justify_end()
|
|
.cursor(gpui::CursorStyle::ResizeUpLeftDownRight)
|
|
.on_mouse_down(MouseButton::Left, start(Grip::Size))
|
|
.on_drag(Pull { owner }, |_, _, _, cx| cx.new(|_| EmptyView))
|
|
.child(
|
|
Icon::new(IconName::GripHorizontal)
|
|
.size(IconSize::Xs)
|
|
.color(colors.fg_subtle),
|
|
),
|
|
)
|
|
.into_any_element(),
|
|
)
|
|
})
|
|
.collect();
|
|
if !cards.is_empty() {
|
|
let keys: Vec<_> = cards.iter().map(|(key, _)| key).collect();
|
|
log::error!("dashboard grid: cards with no tile {keys:?}; left out");
|
|
}
|
|
let ghost = landing.as_ref().map(|tile| {
|
|
div()
|
|
.absolute()
|
|
.left(Pixels::from(tile.x as f32 * cell.0))
|
|
.top(Pixels::from(tile.y as f32 * cell.1))
|
|
.w(Pixels::from(tile.w as f32 * cell.0 - gap))
|
|
.h(Pixels::from(tile.h as f32 * cell.1 - gap))
|
|
.rounded(radius)
|
|
.bg(colors.accent.alpha(0.06))
|
|
.border_1()
|
|
.border_color(colors.accent.alpha(0.4))
|
|
});
|
|
let (moved, measured, dropped, released, left) = (
|
|
board.clone(),
|
|
board.clone(),
|
|
board.clone(),
|
|
board.clone(),
|
|
board.clone(),
|
|
);
|
|
let end = move |board: &gpui::Entity<Board>,
|
|
tiles: &[Tile],
|
|
on_change: &OnTiles,
|
|
window: &mut Window,
|
|
cx: &mut App| {
|
|
let landing = board.update(cx, |board, cx| {
|
|
board.pull = None;
|
|
cx.notify();
|
|
board.landing.take()
|
|
});
|
|
if let Some(to) = landing {
|
|
log::info!("dashboard grid: {} lands at {to:?}", to.key);
|
|
if let Some(tiles) = arranged(tiles, to, columns) {
|
|
on_change(tiles, window, cx);
|
|
}
|
|
}
|
|
};
|
|
let end = Rc::new(end);
|
|
let (drop_end, up_end, out_end) = (end.clone(), end.clone(), end);
|
|
let (drop_tiles, up_tiles, out_tiles) = (tiles.clone(), tiles.clone(), tiles);
|
|
let (drop_change, up_change, out_change) =
|
|
(on_change.clone(), on_change.clone(), on_change);
|
|
div()
|
|
.id(id)
|
|
.relative()
|
|
.h(Pixels::from(height.max(cell.1)))
|
|
.child(measure(measured))
|
|
.on_drag_move::<Pull>(move |event, _, cx| {
|
|
if event.drag(cx).owner != owner {
|
|
return;
|
|
}
|
|
let Some((_, grip, from, start)) = moved.read(cx).pull.clone() else {
|
|
return;
|
|
};
|
|
let by = event.event.position - from;
|
|
let to = pulled(&start, grip, (f32::from(by.x), f32::from(by.y)), cell);
|
|
if moved.read(cx).landing.as_ref() != Some(&to) {
|
|
moved.update(cx, |board, cx| {
|
|
board.landing = Some(to);
|
|
cx.notify();
|
|
});
|
|
}
|
|
})
|
|
.on_drop(move |_: &Pull, window, cx| {
|
|
drop_end(&dropped, &drop_tiles, &drop_change, window, cx)
|
|
})
|
|
.on_mouse_up(MouseButton::Left, move |_, window, cx| {
|
|
up_end(&released, &up_tiles, &up_change, window, cx)
|
|
})
|
|
.on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
|
|
out_end(&left, &out_tiles, &out_change, window, cx)
|
|
})
|
|
.children(ghost)
|
|
.children(placed)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Grip, pulled};
|
|
use crate::dashboard::Tile;
|
|
|
|
#[test]
|
|
fn a_pull_moves_or_grows_a_tile_by_whole_cells() {
|
|
let tile = Tile::new("a", (2, 1), (3, 2));
|
|
assert_eq!(
|
|
pulled(&tile, Grip::Move, (130.0, -70.0), (100.0, 80.0)),
|
|
Tile::new("a", (3, 0), (3, 2))
|
|
);
|
|
assert_eq!(
|
|
pulled(&tile, Grip::Move, (-900.0, 0.0), (100.0, 80.0)),
|
|
Tile::new("a", (0, 1), (3, 2)),
|
|
"not past the left edge"
|
|
);
|
|
assert_eq!(
|
|
pulled(&tile, Grip::Size, (-400.0, 90.0), (100.0, 80.0)),
|
|
Tile::new("a", (2, 1), (1, 3)),
|
|
"at least a cell"
|
|
);
|
|
}
|
|
}
|