Dashboard: a grid of widgets, what fires, what is up, and the stream
DashboardGrid lays widgets on columns: a tile's strip drags it and its corner resizes it a cell at a time, others move down and rise to fill gaps, a focused tile steps with the arrows, and under the theme's stack_below width the tiles stand in one column. DashboardCard frames a widget. AlertList acknowledges a firing alert from its row and opens one on Enter; IncidentCard shows an incident's gravity, phase and updates. UptimeBar, ServiceStatus and HealthCheck show what is up, a check's detail wrapping under its name when narrow. EventStream keeps kinds and holds the list while new events count up. RefreshIntervalSelector and DashboardFilterBar set the window, the filters and the refresh. StatCard, MetricsChart, ResourceGauge, LogStream, TraceWaterfall, SystemMonitor, ProcessTable and NetworkGraph point to their homes; the maps wait for T41.
This commit is contained in:
1 parent
f3332f935b
commit
9fddf4b6bf
17 files changed
+2661
-18
No files matched your search
@@ -0,0 +1,394 @@
|
||||
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: Option<OnTiles>,
|
||||
}
|
||||
|
||||
impl DashboardGrid {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
columns: u32,
|
||||
tiles: impl IntoIterator<Item = Tile>,
|
||||
) -> Self {
|
||||
assert!(columns > 0, "a grid of no columns");
|
||||
Self {
|
||||
id: id.into(),
|
||||
columns,
|
||||
tiles: tiles.into_iter().collect(),
|
||||
cards: Vec::new(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Vec<Tile>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
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()
|
||||
.map(|tile| {
|
||||
let at = cards
|
||||
.iter()
|
||||
.position(|(key, _)| *key == tile.key)
|
||||
.unwrap_or_else(|| panic!("dashboard grid {id:?}: no card for tile {}", tile.key));
|
||||
let card = cards.remove(at).1;
|
||||
let key = tile.key.clone();
|
||||
div()
|
||||
.debug_selector(move || format!("stacked-{key}"))
|
||||
.h((sizes.row + sizes.gap) * tile.h as f32 - sizes.gap)
|
||||
.child(card)
|
||||
})
|
||||
.collect();
|
||||
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),
|
||||
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
|
||||
.unwrap_or_else(|| panic!("dashboard grid {id:?} has no 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()
|
||||
.map(|tile| {
|
||||
let at = cards
|
||||
.iter()
|
||||
.position(|(key, _)| *key == tile.key)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("dashboard grid {id:?}: no card for tile {}", tile.key)
|
||||
});
|
||||
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());
|
||||
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);
|
||||
keyed_change(arranged(&keyed_tiles, to, columns), 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();
|
||||
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);
|
||||
on_change(arranged(tiles, to, columns), 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user