T03 layout: stacks, grids, surfaces, scroll, splits, sheets, dock, panes, workspace, viewport
This commit is contained in:
37 files changed
+5283
-50
No files matched your search
@@ -0,0 +1,214 @@
|
||||
mod view;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{AnyElement, App, Context, Rems, SharedString, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{primitives::IconName, theme::ActiveTheme};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DockSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl DockSide {
|
||||
const ALL: [DockSide; 4] = [
|
||||
DockSide::Left,
|
||||
DockSide::Right,
|
||||
DockSide::Top,
|
||||
DockSide::Bottom,
|
||||
];
|
||||
|
||||
fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a panel lives: a dock, or floating at a window position.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Spot {
|
||||
Docked(DockSide),
|
||||
Floating { x: f32, y: f32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DockPanel {
|
||||
pub id: SharedString,
|
||||
pub title: SharedString,
|
||||
pub icon: IconName,
|
||||
}
|
||||
|
||||
/// Saved dock arrangement, per side in `DockSide` order. Sizes are in rems.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DockLayout {
|
||||
pub spots: Vec<(String, Spot)>,
|
||||
pub active: [Option<String>; 4],
|
||||
pub sizes: [f32; 4],
|
||||
}
|
||||
|
||||
type Center = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
||||
type Content = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
/// Side docks around a center; panels move between docks and float.
|
||||
pub struct Dock {
|
||||
panels: Vec<DockPanel>,
|
||||
spots: Vec<Spot>,
|
||||
active: [Option<usize>; 4],
|
||||
sizes: [Rems; 4],
|
||||
center: Center,
|
||||
content: Content,
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl Dock {
|
||||
pub fn new(
|
||||
center: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
||||
content: impl Fn(&SharedString, &mut Window, &mut App) -> AnyElement + 'static,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
let theme = cx.theme();
|
||||
let (side, strip) = (theme.sidebar_width(false), theme.pane_min());
|
||||
Self {
|
||||
panels: Vec::new(),
|
||||
spots: Vec::new(),
|
||||
active: [None; 4],
|
||||
sizes: [side, side, strip, strip],
|
||||
center: Rc::new(center),
|
||||
content: Rc::new(content),
|
||||
dragging: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics on a repeated id.
|
||||
pub fn add(&mut self, panel: DockPanel, side: DockSide) {
|
||||
assert!(
|
||||
self.find(&panel.id).is_none(),
|
||||
"dock panel {} added twice",
|
||||
panel.id
|
||||
);
|
||||
self.panels.push(panel);
|
||||
self.spots.push(Spot::Docked(side));
|
||||
let slot = &mut self.active[side.index()];
|
||||
if slot.is_none() {
|
||||
*slot = Some(self.panels.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn find(&self, id: &str) -> Option<usize> {
|
||||
self.panels.iter().position(|panel| panel.id.as_ref() == id)
|
||||
}
|
||||
|
||||
fn on_side(&self, side: DockSide) -> Vec<usize> {
|
||||
(0..self.panels.len())
|
||||
.filter(|ix| self.spots[*ix] == Spot::Docked(side))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn activate(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
let ix = self
|
||||
.find(id)
|
||||
.unwrap_or_else(|| panic!("no dock panel {id}"));
|
||||
if let Spot::Docked(side) = self.spots[ix] {
|
||||
self.active[side.index()] = Some(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_panel(&mut self, id: &str, spot: Spot, cx: &mut Context<Self>) {
|
||||
let ix = self
|
||||
.find(id)
|
||||
.unwrap_or_else(|| panic!("no dock panel {id}"));
|
||||
let from = self.spots[ix];
|
||||
self.spots[ix] = spot;
|
||||
if let Spot::Docked(side) = from
|
||||
&& self.active[side.index()] == Some(ix)
|
||||
{
|
||||
self.active[side.index()] = self.on_side(side).first().copied();
|
||||
}
|
||||
if let Spot::Docked(side) = spot {
|
||||
self.active[side.index()] = Some(ix);
|
||||
}
|
||||
log::info!("dock: {id} {from:?} -> {spot:?}");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> DockLayout {
|
||||
let name = |ix: Option<usize>| ix.map(|ix| self.panels[ix].id.to_string());
|
||||
DockLayout {
|
||||
spots: self
|
||||
.panels
|
||||
.iter()
|
||||
.zip(&self.spots)
|
||||
.map(|(panel, spot)| (panel.id.to_string(), *spot))
|
||||
.collect(),
|
||||
active: DockSide::ALL.map(|side| name(self.active[side.index()])),
|
||||
sizes: self.sizes.map(|size| size.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// All or nothing. A side the layout leaves unset shows its first panel.
|
||||
pub fn restore(&mut self, layout: &DockLayout, cx: &mut Context<Self>) -> anyhow::Result<()> {
|
||||
let mut named: Vec<&str> = layout.spots.iter().map(|(id, _)| id.as_str()).collect();
|
||||
named.sort_unstable();
|
||||
named.dedup();
|
||||
anyhow::ensure!(
|
||||
named.len() == layout.spots.len(),
|
||||
"layout places a panel twice"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
layout
|
||||
.sizes
|
||||
.iter()
|
||||
.all(|size| size.is_finite() && *size > 0.0),
|
||||
"dock sizes {:?} must be finite and positive",
|
||||
layout.sizes
|
||||
);
|
||||
anyhow::ensure!(
|
||||
layout.spots.iter().all(|(_, spot)| match spot {
|
||||
Spot::Docked(_) => true,
|
||||
Spot::Floating { x, y } => x.is_finite() && y.is_finite(),
|
||||
}),
|
||||
"floating positions must be finite"
|
||||
);
|
||||
let unknown: Vec<&str> = layout
|
||||
.spots
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.chain(layout.active.iter().flatten().map(String::as_str))
|
||||
.filter(|id| self.find(id).is_none())
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
unknown.is_empty(),
|
||||
"layout names unknown panels: {unknown:?}"
|
||||
);
|
||||
let mut spots = self.spots.clone();
|
||||
for (id, spot) in &layout.spots {
|
||||
spots[self.find(id).expect("checked above")] = *spot;
|
||||
}
|
||||
let mut active = [None; 4];
|
||||
for side in DockSide::ALL {
|
||||
let chosen = layout.active[side.index()]
|
||||
.as_deref()
|
||||
.and_then(|id| self.find(id));
|
||||
if let Some(ix) = chosen {
|
||||
anyhow::ensure!(
|
||||
spots[ix] == Spot::Docked(side),
|
||||
"active panel {} is not docked {side:?}",
|
||||
self.panels[ix].id
|
||||
);
|
||||
}
|
||||
active[side.index()] =
|
||||
chosen.or_else(|| (0..spots.len()).find(|ix| spots[*ix] == Spot::Docked(side)));
|
||||
}
|
||||
self.spots = spots;
|
||||
self.active = active;
|
||||
self.sizes = layout.sizes.map(gpui::rems);
|
||||
log::info!("dock: restored {} panel spots", layout.spots.len());
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
use gpui::{
|
||||
AnyElement, Axis, Context, DragMoveEvent, EmptyView, EntityId, FontWeight, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Pixels, Point, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div, point, prelude::*, relative,
|
||||
};
|
||||
|
||||
use super::{Dock, DockSide, Spot};
|
||||
use crate::{
|
||||
layout::{FloatingPanel, resize_handle},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
struct DraggedPanel {
|
||||
dock: EntityId,
|
||||
id: SharedString,
|
||||
}
|
||||
|
||||
struct PanelGhost {
|
||||
title: SharedString,
|
||||
icon: IconName,
|
||||
}
|
||||
|
||||
impl Render for PanelGhost {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.overlay)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg)
|
||||
.child(
|
||||
Icon::new(self.icon)
|
||||
.size(IconSize::Sm)
|
||||
.color(theme.colors.fg_muted),
|
||||
)
|
||||
.child(self.title.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct DockResize {
|
||||
dock: EntityId,
|
||||
side: DockSide,
|
||||
}
|
||||
|
||||
impl Dock {
|
||||
fn tabs(&self, side: DockSide, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let active = self.active[side.index()];
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.h(theme.control_height(ControlSize::Lg))
|
||||
.px_1()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.children(self.on_side(side).into_iter().map(|ix| {
|
||||
let panel = &self.panels[ix];
|
||||
let selected = active == Some(ix);
|
||||
let (id, title, icon) = (panel.id.clone(), panel.title.clone(), panel.icon);
|
||||
let drag = DraggedPanel {
|
||||
dock: cx.entity_id(),
|
||||
id: id.clone(),
|
||||
};
|
||||
div()
|
||||
.id(SharedString::from(format!("tab-{id}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_2()
|
||||
.h(theme.control_height(ControlSize::Sm))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(if selected {
|
||||
theme.colors.fg
|
||||
} else {
|
||||
theme.colors.fg_subtle
|
||||
})
|
||||
.when(selected, |tab| {
|
||||
tab.bg(theme.colors.hover).font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.hover(|style| style.text_color(theme.colors.fg))
|
||||
.child(Icon::new(icon).size(IconSize::Sm).color(if selected {
|
||||
theme.colors.fg
|
||||
} else {
|
||||
theme.colors.fg_subtle
|
||||
}))
|
||||
.child(title.clone())
|
||||
.on_click(cx.listener(move |dock, _, _, cx| dock.activate(&id, cx)))
|
||||
.on_drag(drag, move |_, _, _, cx| {
|
||||
let (title, icon) = (title.clone(), icon);
|
||||
cx.new(|_| PanelGhost { title, icon })
|
||||
})
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn side(
|
||||
&self,
|
||||
side: DockSide,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let active = self.active[side.index()]?;
|
||||
let size = self.sizes[side.index()];
|
||||
let panel_id = self.panels[active].id.clone();
|
||||
let body = (self.content)(&panel_id, window, cx);
|
||||
let theme = cx.theme();
|
||||
let pane = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.bg(theme.colors.surface)
|
||||
.child(self.tabs(side, cx))
|
||||
.child(div().flex_1().min_h_0().child(body));
|
||||
let axis = match side {
|
||||
DockSide::Left | DockSide::Right => Axis::Horizontal,
|
||||
DockSide::Top | DockSide::Bottom => Axis::Vertical,
|
||||
};
|
||||
let resize = DockResize {
|
||||
dock: cx.entity_id(),
|
||||
side,
|
||||
};
|
||||
let handle = resize_handle(
|
||||
SharedString::from(format!("dock-handle-{side:?}")),
|
||||
axis,
|
||||
cx,
|
||||
)
|
||||
.on_drag(resize, |_, _, _, cx| cx.new(|_| EmptyView));
|
||||
let pane = match axis {
|
||||
Axis::Horizontal => pane.w(size).h_full(),
|
||||
Axis::Vertical => pane.h(size).w_full(),
|
||||
};
|
||||
let row = div().flex().flex_none();
|
||||
Some(
|
||||
match side {
|
||||
DockSide::Left => row.h_full().child(pane).child(handle),
|
||||
DockSide::Right => row.h_full().child(handle).child(pane),
|
||||
DockSide::Top => row.w_full().flex_col().child(pane).child(handle),
|
||||
DockSide::Bottom => row.w_full().flex_col().child(handle).child(pane),
|
||||
}
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn zones(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let (lit, edge, idle) = (
|
||||
theme.colors.focus.opacity(0.12),
|
||||
theme.colors.focus.opacity(0.7),
|
||||
theme.colors.border_strong,
|
||||
);
|
||||
let radius = theme.radius(Radius::Lg);
|
||||
let zone = |id: &'static str, spot: Option<DockSide>, cx: &mut Context<Self>| {
|
||||
div()
|
||||
.id(id)
|
||||
.absolute()
|
||||
.rounded(radius)
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(idle)
|
||||
.drag_over::<DraggedPanel>(move |style, _, _, _| style.bg(lit).border_color(edge))
|
||||
.on_drop(
|
||||
cx.listener(move |dock, dragged: &DraggedPanel, window, cx| {
|
||||
if dragged.dock != cx.entity_id() {
|
||||
return;
|
||||
}
|
||||
let target = match spot {
|
||||
Some(side) => Spot::Docked(side),
|
||||
None => {
|
||||
let at = window.mouse_position();
|
||||
Spot::Floating {
|
||||
x: f32::from(at.x),
|
||||
y: f32::from(at.y),
|
||||
}
|
||||
}
|
||||
};
|
||||
dock.dragging = false;
|
||||
dock.move_panel(&dragged.id, target, cx);
|
||||
}),
|
||||
)
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.p_2()
|
||||
.child(
|
||||
zone("zone-left", Some(DockSide::Left), cx)
|
||||
.top_2()
|
||||
.bottom_2()
|
||||
.left_2()
|
||||
.w(relative(0.2)),
|
||||
)
|
||||
.child(
|
||||
zone("zone-right", Some(DockSide::Right), cx)
|
||||
.top_2()
|
||||
.bottom_2()
|
||||
.right_2()
|
||||
.w(relative(0.2)),
|
||||
)
|
||||
.child(
|
||||
zone("zone-top", Some(DockSide::Top), cx)
|
||||
.top_2()
|
||||
.left(relative(0.22))
|
||||
.right(relative(0.22))
|
||||
.h(relative(0.25)),
|
||||
)
|
||||
.child(
|
||||
zone("zone-bottom", Some(DockSide::Bottom), cx)
|
||||
.bottom_2()
|
||||
.left(relative(0.22))
|
||||
.right(relative(0.22))
|
||||
.h(relative(0.25)),
|
||||
)
|
||||
.child(
|
||||
zone("zone-float", None, cx)
|
||||
.top(relative(0.3))
|
||||
.left(relative(0.3))
|
||||
.right(relative(0.3))
|
||||
.h(relative(0.4)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Dock {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let left = self.side(DockSide::Left, window, cx);
|
||||
let right = self.side(DockSide::Right, window, cx);
|
||||
let top = self.side(DockSide::Top, window, cx);
|
||||
let bottom = self.side(DockSide::Bottom, window, cx);
|
||||
let center = (self.center)(window, cx);
|
||||
let floating: Vec<AnyElement> = (0..self.panels.len())
|
||||
.filter_map(|ix| match self.spots[ix] {
|
||||
Spot::Floating { x, y } => Some((ix, point(Pixels::from(x), Pixels::from(y)))),
|
||||
Spot::Docked(_) => None,
|
||||
})
|
||||
.map(|(ix, start)| self.floating(ix, start, window, cx))
|
||||
.collect();
|
||||
let showing_zones = self.dragging && cx.has_active_drag();
|
||||
let rem = window.rem_size();
|
||||
let min = cx.theme().pane_min();
|
||||
let own = cx.entity_id();
|
||||
div()
|
||||
.id("dock")
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.on_drag_move(
|
||||
cx.listener(move |dock, event: &DragMoveEvent<DockResize>, _, cx| {
|
||||
let resize = event.drag(cx);
|
||||
if resize.dock != own {
|
||||
return;
|
||||
}
|
||||
let side = resize.side;
|
||||
let bounds = event.bounds;
|
||||
let pointer = event.event.position;
|
||||
let span = match side {
|
||||
DockSide::Left => pointer.x - bounds.left(),
|
||||
DockSide::Right => bounds.right() - pointer.x,
|
||||
DockSide::Top => pointer.y - bounds.top(),
|
||||
DockSide::Bottom => bounds.bottom() - pointer.y,
|
||||
};
|
||||
let limit = match side {
|
||||
DockSide::Left | DockSide::Right => bounds.size.width * 0.45,
|
||||
DockSide::Top | DockSide::Bottom => bounds.size.height * 0.45,
|
||||
};
|
||||
let size = span.max(min.to_pixels(rem)).min(limit);
|
||||
dock.sizes[side.index()] = gpui::rems(f32::from(size) / f32::from(rem));
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.on_drag_move(
|
||||
cx.listener(|dock, event: &DragMoveEvent<DraggedPanel>, _, cx| {
|
||||
if event.drag(cx).dock == cx.entity_id() && !dock.dragging {
|
||||
dock.dragging = true;
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|dock, _, _, _| dock.dragging = false),
|
||||
)
|
||||
.on_mouse_up_out(
|
||||
MouseButton::Left,
|
||||
cx.listener(|dock, _, _, _| dock.dragging = false),
|
||||
)
|
||||
.children(left)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(top)
|
||||
.child(div().flex_1().min_h_0().child(center))
|
||||
.children(bottom),
|
||||
)
|
||||
.children(right)
|
||||
.children(floating)
|
||||
.when(showing_zones, |dock| dock.child(self.zones(cx)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Dock {
|
||||
fn floating(
|
||||
&self,
|
||||
ix: usize,
|
||||
start: Point<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let panel = &self.panels[ix];
|
||||
let id = panel.id.clone();
|
||||
let body = (self.content)(&id, window, cx);
|
||||
let dock_id = id.clone();
|
||||
FloatingPanel::new(
|
||||
SharedString::from(format!("float-{id}")),
|
||||
panel.title.clone(),
|
||||
start,
|
||||
)
|
||||
.w(cx.theme().sidebar_width(false))
|
||||
.h(cx.theme().sheet_size())
|
||||
.on_move(cx.listener(move |dock, at: &Point<Pixels>, _, _| {
|
||||
let ix = dock.find(&dock_id).expect("dock panels are never removed");
|
||||
dock.spots[ix] = Spot::Floating {
|
||||
x: f32::from(at.x),
|
||||
y: f32::from(at.y),
|
||||
};
|
||||
}))
|
||||
.child(body)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user