T03 layout: stacks, grids, surfaces, scroll, splits, sheets, dock, panes, workspace, viewport

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 02:17:37 -04:00
1 parent b35530c80d
commit 2bdf902bf4
37 files changed
+5283 -50

No files matched your search

+214
View File
@@ -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(())
}
}
+347
View File
@@ -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()
}
}