feat: out-of-box experience (#2)

Reviewed-on: https://git.reya.su/reya/signed/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-25 13:23:07 +00:00
parent 7249a323f8
commit dacfd49cdf
180 changed files with 16763 additions and 1042 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "dock"
description = "The Signed dock skin over gpui-component's upstream dock (gpui_base::dock engine + renderer traits)."
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+350
View File
@@ -0,0 +1,350 @@
//! The dock-area appearance: the outer frame, the split frames, and one
//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render
//! onto `gpui_base::dock::DockAreaRenderer`.
use std::cell::Cell;
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
Styled as _, WeakEntity, Window, div, px,
};
use gpui_base::dock::{
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
PanelView, TabGroupRenderer, TilesRenderer,
};
use gpui_base::resize_handle;
use gpui_component::scroll::ScrollbarMode;
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
use crate::invalid_panel::InvalidPanel;
use crate::tab_panel::SignedTabGroupSkin;
use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to.
///
/// The renderer is the only skin-owned object in the picture, so the settings
/// the old `DockArea` carried live here. It is shared by reference with the
/// per-container renderers, which are built once each and outlive any one
/// frame.
pub(crate) struct SkinShared {
area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
/// The dock whose resize handle is being dragged, if any. Only one can be.
resizing_dock: Cell<Option<DockPlacement>>,
}
impl SkinShared {
pub(crate) fn area(&self) -> &WeakEntity<DockArea> {
&self.area
}
pub(crate) fn is_toggle_button_visible(&self) -> bool {
self.toggle_button_visible.get()
}
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
self.tiles_scrollbar_mode.get()
}
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock
}
/// Redraw the area after a setting changed. The skin is not an entity, so
/// nothing else would notice.
pub(crate) fn notify(&self, cx: &mut App) {
_ = self.area.update(cx, |_, cx| cx.notify());
}
}
/// The Signed appearance for a [`DockArea`].
///
/// Install it at construction, where the area's own weak handle is available:
///
/// ```ignore
/// let dock = cx.new(|cx| {
/// let skin = SignedDockSkin::new(cx);
/// DockArea::new("dock", Some(1), window, cx).with_renderer(skin)
/// });
/// ```
pub struct SignedDockSkin {
shared: Rc<SkinShared>,
}
impl SignedDockSkin {
pub fn new(cx: &mut Context<DockArea>) -> Rc<Self> {
Rc::new(Self {
shared: Rc::new(SkinShared {
area: cx.weak_entity(),
toggle_button_visible: Cell::new(true),
tiles_scrollbar_mode: Cell::new(None),
resizing_dock: Cell::new(None),
}),
})
}
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
&self.shared
}
/// Whether tab bars offer the affordance that collapses a neighbouring
/// dock.
pub fn is_toggle_button_visible(&self) -> bool {
self.shared.is_toggle_button_visible()
}
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
self.shared.toggle_button_visible.set(visible);
self.shared.notify(cx);
}
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
self.shared.tiles_scrollbar_mode()
}
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
self.shared.tiles_scrollbar_mode.set(mode);
self.shared.notify(cx);
}
}
/// The payload a dock's resize handle drags. It draws nothing: the handle
/// itself is the affordance.
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
impl DockAreaRenderer for SignedDockSkin {
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.flex()
.flex_row()
}
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("dock-area-center")
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
}
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
// `size_full` is what the old `StackPanel::render` carried; `flex_1`
// is belt and braces so the frame never collapses to zero height in
// an unsizing parent.
div()
.id(("dock-split-frame", node.as_u64()))
.size_full()
.flex_1()
.min_h(px(0.))
.overflow_hidden()
.bg(cx.theme().tokens.tab_bar)
}
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let placement = dock.placement();
let open = dock.is_open();
// A closed left or right dock takes no space at all; a closed bottom
// dock keeps a strip so its tab bar stays clickable.
if !open && !placement.is_bottom() {
return div().into_any_element();
}
div()
.flex()
.flex_none()
.relative()
.overflow_hidden()
.map(|this| match placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
DockPlacement::Bottom => this.w_full().h(dock.size()),
// Base never builds a dock for the centre.
DockPlacement::Center => this,
})
// The closed bottom dock's strip is the tab bar itself, which is
// a full tab bar tall.
.when(!open && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT)
})
.child(content)
.child(self.render_resize_handle(dock, window, cx))
.child(DockResizeTracker {
dock: dock.clone(),
shared: self.shared().clone(),
})
.into_any_element()
}
/// The "unknown panel" message the old `InvalidPanel` drew.
///
/// It answers `dump` with the state it was handed, so a layout written by
/// a build that knows the panel survives a load and save here.
fn build_placeholder(
&self,
state: &PanelState,
_: &mut Window,
cx: &mut App,
) -> Option<Arc<dyn PanelView>> {
let state = state.clone();
Some(panel_handle(cx.new(|cx| {
InvalidPanel::new(state.panel_name.clone(), state, cx)
})))
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
}
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
Rc::new(SignedTilesSkin::new(self.shared().clone()))
}
}
impl SignedDockSkin {
fn render_resize_handle(
&self,
dock: &DockContext,
_: &mut Window,
_: &mut App,
) -> impl IntoElement {
let placement = dock.placement();
let shared = self.shared().clone();
resize_handle("resize-handle", placement.axis())
.when(placement.is_left(), |this| this.placement(Side::Left))
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
cx.new(|_| info.deref().clone())
})
}
}
/// Turns the window's mouse stream into dock resizing.
///
/// A resize is driven by pointer moves that land anywhere in the window, not
/// only on the handle, so it cannot be expressed as a listener on the handle
/// itself. This element paints nothing and exists for its `paint` hook, which
/// is the only place a window-level mouse listener can be registered.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
}
impl IntoElement for DockResizeTracker {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockResizeTracker {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let placement = self.dock.placement();
window.on_mouse_event({
let dock = self.dock.clone();
let shared = self.shared.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
// Dragging a closed dock's handle reopens it, as the old dock
// did. The live state is read rather than the render-time
// snapshot in `dock`, which would still say closed for the
// rest of the frame and toggle it shut again on the next move.
let open = shared
.area()
.upgrade()
.is_some_and(|area| area.read(cx).is_dock_open(placement));
if !open {
dock.toggle(window, cx);
}
dock.resize_to(event.position, window, cx);
}
});
window.on_mouse_event({
let shared = self.shared.clone();
move |_: &MouseUpEvent, phase, _, cx| {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
shared.resizing_dock().set(None);
// The size lives on the dock, not in the layout tree, so
// nothing else tells a subscriber to persist it.
_ = shared
.area()
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
}
});
}
}
+71
View File
@@ -0,0 +1,71 @@
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use gpui_base::dock::{PanelEvent, PanelState};
use gpui_component::ActiveTheme as _;
use crate::Panel;
/// Stands in for a panel this build cannot construct — one whose `panel_name`
/// no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder answers to.
///
/// It reports the original [`PanelState`] from
/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that
/// knows the panel survives a load and a save here rather than losing it.
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
old_state: PanelState,
}
impl InvalidPanel {
pub(crate) fn new(
name: impl Into<SharedString>,
state: PanelState,
cx: &mut Context<Self>,
) -> Self {
Self {
focus_handle: cx.focus_handle(),
name: name.into(),
old_state: state,
}
}
}
impl gpui_base::dock::Panel for InvalidPanel {
fn panel_name(&self) -> &'static str {
"InvalidPanel"
}
fn dump(&self, _: &App) -> PanelState {
self.old_state.clone()
}
}
impl Panel for InvalidPanel {}
impl EventEmitter<PanelEvent> for InvalidPanel {}
impl Focusable for InvalidPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InvalidPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.my_6()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_color(cx.theme().muted_foreground)
.child(format!(
"The `{}` panel type is not registered in PanelRegistry.",
self.name.clone()
))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! The Signed dock skin.
//!
//! The dock engine lives upstream: `gpui_base::dock` owns the layout tree,
//! the drags, the zoom and the persistence, and `gpui_component::dock`
//! supplies the default appearance. This crate is the appearance the app
//! used to vendor from gpui-component — a 44px tab bar that doubles as the
//! window title bar, with pill tabs, window controls, title-bar dragging and
//! previous/next tab buttons — re-implemented against upstream's renderer
//! traits.
//!
//! Everything `gpui_component::dock` exports is re-exported here, so the app
//! keeps importing the dock from a single place.
use gpui::{
App, Div, InteractiveElement as _, MouseButton, Pixels, Stateful,
StatefulInteractiveElement as _, Window, WindowControlArea, px,
};
mod dock_area;
mod invalid_panel;
mod tab_panel;
mod tiles;
mod window_controls;
pub use dock_area::SignedDockSkin;
pub use gpui_component::dock::{
AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent,
DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds,
DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle,
PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel,
};
/// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
pub(crate) fn t(key: &'static str) -> &'static str {
match key {
"Dock.Unnamed" => "Unnamed",
"Dock.Close" => "Close",
"Dock.Zoom In" => "Zoom In",
"Dock.Zoom Out" => "Zoom Out",
"Dock.Collapse" => "Collapse",
"Dock.Expand" => "Expand",
_ => key,
}
}
/// State used to move the window when the title bar area is dragged.
struct WindowDragState {
should_move: bool,
}
/// Make an element behave like a window title bar: dragging it moves the
/// window, and double-clicking zooms the window (or performs the platform's
/// default title-bar double-click action on macOS).
///
/// Only the bar's non-interactive areas should get this — tabs are draggable
/// (to reorder panels) and must not move the window.
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
this.window_control_area(WindowControlArea::Drag)
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
+902
View File
@@ -0,0 +1,902 @@
//! The Signed appearance for a tab group.
//!
//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
//! visible is here, ported from the vendored dock: the pill tab bar that
//! doubles as the window title bar (with window controls, title-bar
//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu,
//! the dock collapse affordance, the drop placeholder, and the styled drag
//! preview.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
Styled as _, Window, div, px, size,
};
use gpui_base::dock::{
AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef,
PanelView as BasePanelView, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
use gpui_component::animation::{Lerp as _, ease_out_cubic};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::menu::DropdownMenu as _;
use gpui_component::{
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use crate::dock_area::SkinShared;
use crate::{
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, title_bar_drag_handlers,
window_controls,
};
/// The size the styled drag preview occupies, reported to base so a drop
/// placeholder knows where to fly in from.
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
/// A panel's title, or its registered name when it reached base without this
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
pub(crate) fn panel_title(
panel: &Arc<dyn BasePanelView>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.title(window, cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
/// The preview that follows the cursor while a panel is dragged.
///
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
/// appearance half, reintroduced here.
struct DragPanelPreview {
panel: Arc<dyn BasePanelView>,
}
impl Render for DragPanelPreview {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("drag-panel")
.cursor_grab()
.py_1()
.px_3()
.w_24()
.overflow_hidden()
.whitespace_nowrap()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tokens.tab_active)
.opacity(0.75)
.child(panel_title(&self.panel, window, cx))
}
}
/// Where the zoom affordance goes for the group's displayed panel, or `None`
/// when there is none to offer.
///
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
/// whether zooming happens at all, and base refuses a zoom that fails it.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?;
panel
.zoomable(cx)
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
.flatten()
}
/// The left-most, top-most tab group in a container — where a left dock's
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// The right-most, top-most tab group. A vertical split stacks its children,
/// so its *first* child is the top one; a horizontal split's last child is
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { axis, children, .. } => match axis {
gpui::Axis::Vertical => children.first(),
gpui::Axis::Horizontal => children.last(),
}
.and_then(right_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// One tab group's appearance.
///
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
/// per container — so the tab bar's scroll position and the measured
/// title-bar geometry belong to the group they describe.
pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
/// The displayed tab the last frame drew, so a change scrolls the new tab
/// into view.
last_active_ix: Cell<Option<usize>>,
/// Bounds of the title bar row (the wrapper around the tab bar), in
/// window coordinates. Measured via `on_prepaint` to position the
/// title-bar drag overlay.
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's trailing empty space (right after the last
/// tab), which marks where the draggable region starts.
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
/// draggable region ends.
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl SignedTabGroupSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::default(),
last_active_ix: Cell::new(None),
title_bar_bounds: Rc::new(Cell::new(None)),
title_bar_strip_bounds: Rc::new(Cell::new(None)),
title_bar_suffix_bounds: Rc::new(Cell::new(None)),
}
}
/// A group that is the left dock's whole content with a single panel
/// draws no chrome at all — the vendored dock rendered such a panel bare,
/// and the sidebar is one.
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
let Some(area) = self.shared.area().upgrade() else {
return false;
};
let area = area.read(cx);
let Some(left) = area
.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
else {
return false;
};
left == group.node() && group.panels().len() == 1
}
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// The drag payload for the tab at `ix`, or `None` when this group must
/// not be rearranged. A locked group is never draggable; a group that is
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
}
/// Whether a dock's collapse affordance belongs in *this* group's tab
/// bar, and which way it points. `None` means this group draws none.
fn dock_toggle_button(
&self,
placement: DockPlacement,
group: &TabGroupContext,
cx: &mut App,
) -> Option<Button> {
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
return None;
}
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
// A dock that does not exist is not collapsible, so this covers the
// old `left_dock.is_some()` test too.
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
};
if designated != Some(group.node()) {
return None;
}
let is_open = area.is_dock_open(placement);
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
let area = self.shared.area().clone();
Some(
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip(match is_open {
true => t("Dock.Collapse"),
false => t("Dock.Expand"),
})
.on_click(move |_, window, cx| {
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
/// The previous/next tab buttons shown in the tab bar's leading prefix.
///
/// Unlike the dock toggle button they always render, but are disabled at
/// the ends of the tab strip (or while the panel is collapsed).
fn render_prev_next_tab_buttons(
&self,
group: &TabGroupContext,
_cx: &mut App,
) -> impl IntoElement {
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let panels_len = group.panels().len();
let prev_enabled = !collapsed && active_ix > 0;
let next_enabled = !collapsed && active_ix + 1 < panels_len;
h_flex()
.gap_1()
.child(
Button::new("tab:prev")
.icon(IconName::ArrowLeft)
.small()
.ghost()
.tab_stop(false)
.tooltip("Previous tab")
.disabled(!prev_enabled)
.on_click({
let group = group.clone();
move |_, window, cx| group.select_tab(active_ix - 1, window, cx)
}),
)
.child(
Button::new("tab:next")
.icon(IconName::ArrowRight)
.small()
.ghost()
.tab_stop(false)
.tooltip("Next tab")
.disabled(!next_enabled)
.on_click({
let group = group.clone();
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
}),
)
}
/// The trailing controls: the panel's own buttons, the zoom affordance,
/// and the ellipsis menu.
fn render_toolbar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
if group.is_collapsed() {
return div();
}
let zoomed = group.is_zoomed();
let handle = group.active_panel().and_then(PanelHandle::of);
let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible());
// A bottom/right dock's only panel cannot be closed through the
// group (base keeps a dock's last group), but the skin handles that
// close by removing the whole dock, so the item is offered.
let closable = group.is_closable()
|| (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
h_flex()
.gap_1()
.occlude()
.when_some(buttons, |this, buttons| {
this.children(
buttons
.into_iter()
.map(|button| button.small().ghost().tab_stop(false)),
)
})
.map(|this| {
let value = if zoomed {
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
} else if toolbar_zoom {
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
} else {
None
};
if let Some((id, icon, tooltip)) = value {
this.child(
Button::new(id)
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip_with_action(tooltip, &ToggleZoom, None)
.selected(zoomed)
.on_click({
let group = group.clone();
move |_, window, cx| group.toggle_zoom(window, cx)
}),
)
} else {
this
}
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.tab_stop(false)
.dropdown_menu(move |menu, window, cx| {
menu.when_some(panel.clone(), |menu, panel| {
panel.dropdown_menu(menu, window, cx)
})
.separator()
.menu_with_disabled(
if zoomed {
t("Dock.Zoom Out")
} else {
t("Dock.Zoom In")
},
Box::new(ToggleZoom),
!menu_zoom,
)
.when(closable, |menu| {
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
})
})
.anchor(Anchor::TopRight),
)
}
/// One tab of the pill strip.
///
/// While collapsed, tabs lose the active style and all interactions, and
/// the strip becomes the way a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
group: &TabGroupContext,
ix: usize,
panel: Arc<dyn BasePanelView>,
active: bool,
is_bottom_dock: bool,
window: &mut Window,
cx: &mut App,
) -> Tab {
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
Tab::new(ix)
.h_6()
.px_3()
.text_sm()
.whitespace_nowrap()
.flex()
.items_center()
.justify_center()
.gap_1()
.flex_shrink_0()
.overflow_hidden()
.rounded(cx.theme().radius)
.text_color(cx.theme().foreground)
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)),
})
// Pill presentation: the selected tab is the filled pill, the
// rest are transparent until hovered.
.styles(|styles| {
styles.selected(|style| {
style
.text_color(cx.theme().tab_active_foreground)
.bg(cx.theme().tab_active)
})
})
.hover(|this| {
if active {
this
} else {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_hover)
}
})
.selected(active)
.on_click({
let group = group.clone();
let area = self.shared.area().clone();
move |_, window, cx| {
group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock is how it
// is opened again.
if is_bottom_dock && collapsed {
_ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx);
});
}
}
})
.when(!collapsed, |this| {
this.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
drag.set_preview_size(DRAG_PREVIEW_SIZE);
cx.new(|_| DragPanelPreview {
panel: panel.clone(),
})
}
})
})
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |drag: &DragPanel, window, cx| {
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
})
})
})
}
/// The strip after the last tab: a drop target for panels and host-owned
/// drag items. Its left edge (right after the last tab) marks the start
/// of the title-bar drag overlay.
fn render_empty_space(
&self,
group: &TabGroupContext,
tabs_count: usize,
_cx: &mut App,
) -> AnyElement {
let strip_bounds = self.title_bar_strip_bounds.clone();
let shared = self.shared.clone();
let droppable = group.is_droppable();
let mut empty = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.on_prepaint(move |bounds, _, cx| {
if strip_bounds.get() != Some(bounds) {
strip_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
});
if droppable {
empty = empty
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
let node = group.node();
move |drag: &DragPanel, window, cx| {
// A panel dropped past its own last tab lands in the
// final slot; one from elsewhere is appended in the
// background.
let ix = (drag.source() == node).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
});
}
empty.into_any_element()
}
}
impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing,
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block
// layout a child's `flex_grow` is ignored — the content region below
// the tab bar would resolve to zero height.
v_flex()
.id("tab-panel")
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.background)
// A collapsed group is a strip of tabs with no content, and the
// actions act on content.
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel that
// offers none is not zoomed *in* by the keybinding
// either. Zooming out is never refused: a panel that
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
if !group.is_zoomed() && control.is_none() {
return;
}
group.toggle_zoom(window, cx);
}
})
.on_action({
let group = group.clone();
let shared = shared.clone();
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
if !panel.closable(cx) {
return;
}
let panel = panel.panel_id(cx);
match dock_to_remove {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel, window, cx),
}
}
})
})
}
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
v_flex()
.id("active-panel")
// A collapsed group draws its tab strip and nothing else, so the
// content region must not claim any space.
.when(!group.is_collapsed(), |this| this.flex_1())
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied
// bottom/right dock a moment later.
if group.panels().is_empty() {
return Empty.into_any_element();
}
// The sidebar group draws no chrome at all, like the vendored dock's
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) {
return Empty.into_any_element();
}
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let tabs_count = group.panels().len();
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
});
// Bring a newly displayed tab into view. The group owns selection
// now, so the skin notices the change rather than being told about it.
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
// The tab strip lays out its scrollable content at content width, so
// the area after the last tab only spans `min_w_16` — the rest of the
// tab bar has no element at all. Cover that dead zone with a
// measured overlay so the whole non-interactive area can drag the
// window. Its span is [last tab's right edge, suffix's left edge].
let drag_overlay = match (
self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(),
self.title_bar_suffix_bounds.get(),
) {
(Some(title), Some(strip), Some(suffix)) => {
let left = strip.left() - title.left();
let right = title.right() - suffix.left();
(left + right < title.size.width).then(|| {
title_bar_drag_handlers(
div()
.id(("title-bar-drag", group.node().as_u64()))
.absolute()
.top_0()
.bottom_0()
.left(left)
.right(right),
window,
cx,
)
})
}
_ => None,
};
let tabs: Vec<_> = group
.panels()
.iter()
.enumerate()
.filter_map(|(ix, panel)| {
let mut active = displayed == Some(panel.panel_id(cx));
if !panel.visible(cx) {
return None;
}
// A collapsed group shows no tab as active: the strip is a
// way back in, not a selection.
if collapsed {
active = false;
}
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
})
.collect();
let empty_space = self.render_empty_space(group, tabs_count, cx);
let title_bar_bounds = self.title_bar_bounds.clone();
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
let shared = self.shared.clone();
let suffix_shared = self.shared.clone();
div()
.flex()
.flex_row()
.child(
div()
.relative()
.flex_1()
.min_w_0()
.on_prepaint(move |bounds, _, cx| {
if title_bar_bounds.get() != Some(bounds) {
title_bar_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
})
.child(
Tabs::new("tab-bar")
.px(px(-1.))
.h(TAB_BAR_HEIGHT)
.flex()
.items_center()
.text_color(cx.theme().tab_foreground)
.child(
h_flex()
.items_center()
.top_0()
// Right -1 for avoid border overlap with the first tab
.right(-px(1.))
.h_full()
.gap_2()
.px_2()
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
.children(left_dock_button)
.children(bottom_dock_button)
.child(self.render_prev_next_tab_buttons(group, cx)),
)
.child(
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
h_flex()
.id("tabs-inner")
.relative()
.gap(px(4.))
.overflow_x_scroll()
.lock_scroll_axis()
.track_scroll(&self.scroll_handle)
.children(tabs)
.when(!collapsed, |this| this.child(empty_space)),
),
)
.when(!collapsed, |this| {
this.child(
h_flex()
.items_center()
.top_0()
.right_0()
.h_full()
.px_2()
.gap_1()
.on_prepaint(move |bounds, _, cx| {
if title_bar_suffix_bounds.get() != Some(bounds) {
title_bar_suffix_bounds.set(Some(bounds));
_ = suffix_shared
.area()
.update(cx, |_, cx| cx.notify());
}
})
.children(
group
.active_panel()
.and_then(PanelHandle::of)
.and_then(|handle| handle.title_suffix(window, cx)),
)
.child(self.render_toolbar(group, window, cx))
.children(right_dock_button),
)
}),
)
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
)
.child(window_controls::window_controls(window, cx))
.into_any_element()
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
_: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
div()
.id("tab-content")
.overflow_y_scroll()
.overflow_x_hidden()
.flex_1()
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let (from, to) = (indicator.from(), indicator.to());
// The placeholder animates from wherever it was to where the drop
// would land, so its own element is positioned at the destination and
// the animation only has to walk the difference back to zero.
let offset = from.origin() - to.origin();
Some(
div()
.absolute()
.left(to.origin().x)
.top(to.origin().y)
.w(to.size().width)
.h(to.size().height)
.child(
div()
.absolute()
.bg(cx.theme().tokens.drop_target)
.with_animation(
gpui::ElementId::NamedInteger(
"drop-placeholder".into(),
indicator.epoch(),
),
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
move |this, delta| {
let origin = offset.lerp(&Point::default(), delta);
let width = from.size().width.lerp(&to.size().width, delta);
let height = from.size().height.lerp(&to.size().height, delta);
this.left(origin.x).top(origin.y).w(width).h(height)
},
),
)
.into_any_element(),
)
}
}
+416
View File
@@ -0,0 +1,416 @@
//! The Signed appearance for a tiles canvas.
//!
//! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize
//! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile
//! frame, its title bar and its resize affordances are here, ported from
//! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so
//! there is no local look to preserve).
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _,
IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle,
Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px,
};
use gpui_base::dock::{
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use crate::dock_area::SkinShared;
use crate::tab_panel::panel_title;
use crate::{PanelHandle, t};
/// How far a resize handle sticks out past the tile's edge.
const HANDLE_OFFSET: Pixels = px(-4.);
/// The payload a tile drag carries, so one canvas ignores another's drags.
#[derive(Clone)]
struct DragMoving(NodeId);
impl Render for DragMoving {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// The payload a tile resize carries, for the same reason.
#[derive(Clone)]
struct DragResizing(NodeId);
impl Render for DragResizing {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// One tiles canvas's appearance.
///
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
/// container — so the scroll position belongs to the canvas it scrolls.
pub(crate) struct SignedTilesSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
}
impl SignedTilesSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::default(),
}
}
/// One edge or corner handle.
fn resize_handle(
&self,
tile: &TileContext,
id: &'static str,
side: ResizeSide,
build: impl FnOnce(Stateful<Div>) -> Stateful<Div>,
) -> Stateful<Div> {
let node = tile.node();
build(div().id(id).absolute())
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |event: &MouseDownEvent, window, cx| {
tile.begin_resize(side, event.position, window, cx);
cx.stop_propagation();
}
})
.on_drag(DragResizing(node), |drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
})
.on_drag_move({
let tile = tile.clone();
move |event: &DragMoveEvent<DragResizing>, window, cx| {
if event.drag(cx).0 != node {
return;
}
tile.resize_to(event.event.position, window, cx);
}
})
}
/// The trailing controls of a tile's title bar.
///
/// A tile has no tab bar to hang a toolbar off, so this is where its zoom,
/// close and ellipsis menu live. The entries use click handlers rather
/// than the [`ToggleZoom`](crate::ToggleZoom) and
/// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a
/// focused tab group, and a tile is not one.
fn render_tile_controls(
&self,
tile: &TileContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let handle = PanelHandle::of(tile.panel());
let control = handle.and_then(|handle| handle.zoom_control(cx));
let zoomed = tile.is_zoomed();
let toolbar_zoom =
tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible());
let closable = tile.is_closable();
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
h_flex()
.gap_1()
.flex_shrink_0()
.occlude()
.when_some(buttons, |this, buttons| {
this.children(
buttons
.into_iter()
.map(|button| button.xsmall().ghost().tab_stop(false)),
)
})
.when_some(
match (zoomed, toolbar_zoom) {
(true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))),
(false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))),
(false, false) => None,
},
|this, (id, icon, tooltip)| {
this.child(
Button::new(id)
.icon(icon)
.xsmall()
.ghost()
.tab_stop(false)
.tooltip(tooltip)
.selected(zoomed)
.on_click({
let tile = tile.clone();
move |_, window, cx| tile.toggle_zoom(window, cx)
}),
)
},
)
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.tab_stop(false)
.dropdown_menu({
let tile = tile.clone();
move |menu, window, cx| {
menu.when_some(panel.clone(), |menu, panel| {
panel.dropdown_menu(menu, window, cx)
})
.separator()
.item(
PopupMenuItem::new(match zoomed {
true => t("Dock.Zoom Out"),
false => t("Dock.Zoom In"),
})
.disabled(!menu_zoom && !zoomed)
.on_click({
let tile = tile.clone();
move |_, window, cx| tile.toggle_zoom(window, cx)
}),
)
.when(closable, |menu| {
menu.separator()
.item(PopupMenuItem::new(t("Dock.Close")).on_click({
let tile = tile.clone();
move |_, window, cx| tile.close(window, cx)
}))
})
}
})
.anchor(gpui::Anchor::TopRight),
)
}
}
impl TilesRenderer for SignedTilesSkin {
fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
div()
.id("tiles")
.relative()
.size_full()
.bg(cx.theme().tokens.tiles)
.track_scroll(&self.scroll_handle)
.overflow_scroll()
}
fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
v_flex()
.id(("tile", tile.panel_id().as_u64()))
.occlude()
.overflow_hidden()
.bg(cx.theme().tokens.background)
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().tile_radius)
// Room for the title bar, which is positioned over the padding so
// the panel below it is never covered. Base draws the panel view
// as a plain child, so this is the only way to keep the two from
// overlapping.
.pt(DRAG_BAR_HEIGHT)
// Base installs the stored bounds on an ordinary tile and nothing
// at all on a zoomed one — how a zoomed tile fills the dock is
// this skin's decision.
.when(tile.is_zoomed(), |this| this.size_full())
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| tile.bring_to_front(window, cx)
})
// A gesture can end with the pointer anywhere, so both halves are
// needed; each is a no-op unless this tile is the one moving.
.on_mouse_up(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| {
tile.end_move(window, cx);
tile.end_resize(window, cx);
}
})
.on_mouse_up_out(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| {
tile.end_move(window, cx);
tile.end_resize(window, cx);
}
})
}
fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement {
let node = tile.node();
let handle = PanelHandle::of(tile.panel());
let title_style = handle.and_then(|handle| handle.title_style(cx));
h_flex()
.id("drag-bar")
.absolute()
.top_0()
.left_0()
.w_full()
.h(DRAG_BAR_HEIGHT)
.items_center()
.gap_1()
.pl_3()
.pr_2()
.when_some(title_style, |this, style| {
this.bg(style.background).text_color(style.foreground)
})
.child(
div()
.flex_1()
.min_w_16()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.child(panel_title(tile.panel(), window, cx)),
)
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
.child(self.render_tile_controls(tile, window, cx))
// A zoomed tile is not at its stored bounds, so there is nothing
// for a move to mean; base refuses the gesture too.
.when(!tile.is_zoomed(), |this| {
this.cursor_grab()
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |event: &MouseDownEvent, window, cx| {
tile.begin_move(event.position, window, cx);
}
})
.on_drag(DragMoving(node), |drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
})
.on_drag_move({
let tile = tile.clone();
move |event: &DragMoveEvent<DragMoving>, window, cx| {
if event.drag(cx).0 != node {
return;
}
tile.move_to(event.event.position, window, cx);
}
})
})
.into_any_element()
}
fn render_resize_handles(
&self,
tile: &TileContext,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
let bounds = tile.bounds();
// A passive full-tile box so each handle is positioned against the
// tile rather than against whatever the flow put it next to. It
// registers no interaction of its own, so it does not shadow the panel
// underneath.
div()
.absolute()
.top_0()
.left_0()
.size_full()
.child(
self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| {
this.cursor_ew_resize()
.top_0()
.left(HANDLE_OFFSET)
.w(HANDLE_SIZE)
.h(bounds.size.height)
}),
)
.child(
self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| {
this.cursor_ew_resize()
.top_0()
.right(HANDLE_OFFSET)
.w(HANDLE_SIZE)
.h(bounds.size.height)
}),
)
.child(
self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| {
this.cursor_ns_resize()
.left_0()
.top(HANDLE_OFFSET)
.w(bounds.size.width)
.h(HANDLE_SIZE)
}),
)
.child(
self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| {
this.cursor_ns_resize()
.left_0()
.bottom(HANDLE_OFFSET)
.w(bounds.size.width)
.h(HANDLE_SIZE)
}),
)
.child(
Icon::new(IconName::ResizeCorner)
.size_3()
.absolute()
.right(px(1.))
.bottom(px(1.))
.text_color(cx.theme().muted_foreground.opacity(0.5)),
)
.child(self.resize_handle(
tile,
"corner-resize-handle",
ResizeSide::BottomRight,
|this| {
this.cursor_nwse_resize()
.right(HANDLE_OFFSET)
.bottom(HANDLE_OFFSET)
.size_3()
},
))
.into_any_element()
}
/// The panel of a tile gets `size_full` here; base draws the panel as a
/// plain child, so without it a panel that does not size itself has no
/// size.
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
h_flex()
.id(("tile-panel", tile.panel_id().as_u64()))
.overflow_hidden()
.size_full()
}
/// The canvas scrollbar.
///
/// It has to be an overlay rather than one of the frame's own children:
/// the frame is the scroll container and base appends the tiles after
/// whatever the frame carries, so a scrollbar placed there would paint and
/// hit-test underneath every tile.
fn render_overlay(
&self,
content: Size<Pixels>,
_: &mut Window,
_: &mut App,
) -> Option<AnyElement> {
Some(
Scrollbar::new(&self.scroll_handle)
.scroll_size(content)
.when_some(self.shared.tiles_scrollbar_mode(), |this, mode| {
this.mode(mode)
})
.into_any_element(),
)
}
fn grid_size(&self, cx: &App) -> Pixels {
cx.theme().tile_grid_size
}
}
+170
View File
@@ -0,0 +1,170 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div, px,
};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
use crate::TAB_BAR_HEIGHT;
/// The standard width of a window control button.
const CONTROL_WIDTH: f32 = 34.;
#[derive(IntoElement, Clone)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close,
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
fn close() -> Self {
Self::Close
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close => IconName::WindowClose,
}
}
fn window_control_area(&self) -> gpui::WindowControlArea {
match self {
Self::Minimize => gpui::WindowControlArea::Min,
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
Self::Close => gpui::WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close)
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
} else {
cx.theme().secondary_foreground
}
}
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger
} else {
cx.theme().secondary_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
} else {
cx.theme().secondary_active
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let icon = self.clone();
div()
.id(self.id())
.flex()
.w(px(CONTROL_WIDTH))
.h_full()
.flex_shrink_0()
.justify_center()
.content_center()
.items_center()
.text_color(cx.theme().foreground)
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close => window.remove_window(),
}
})
})
.child(Icon::new(self.icon()).small())
}
}
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
return div().id("window-controls");
}
let supported = window.window_controls();
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
// Like native windows apps, the controls span the title bar but never
// grow past the tab bar height.
.when(cfg!(target_os = "windows"), |this| {
this.max_h(TAB_BAR_HEIGHT)
})
.border_l_1()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tokens.tab_bar)
.when(supported.minimize, |this| {
this.child(ControlIcon::minimize())
})
.when(supported.maximize, |this| {
this.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
})
})
.child(ControlIcon::close())
}
+99
View File
@@ -0,0 +1,99 @@
//! Render-path smoke tests: the skin reads the dock area while rendering, and
//! GPUI panics if an entity is read while it is leased (being updated). These
//! pin that the first frame — docks, groups, tab bars — renders without
//! tripping the lease check.
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
use gpui::{
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
TestAppContext, Window,
};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
struct Probe {
focus_handle: FocusHandle,
}
impl Probe {
fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for Probe {
fn panel_name(&self) -> &'static str {
"Probe"
}
}
impl Panel for Probe {
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
"Probe"
}
}
impl EventEmitter<PanelEvent> for Probe {}
impl Focusable for Probe {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[gpui::test]
fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
});
let (area, cx) = cx.add_window_view(|window, cx| {
let skin = SignedDockSkin::new(cx);
DockArea::new("test", None, window, cx).with_renderer(skin)
});
let bottom = cx.update(|_, cx| cx.new(Probe::new));
cx.update(|window, cx| {
let left = cx.new(Probe::new);
let center = cx.new(Probe::new);
area.update(cx, |area, cx| {
area.set_dock(
DockPlacement::Left,
DockLayout::tabs().panel_view(panel_handle(left), cx),
window,
cx,
);
area.set_center(
DockLayout::tabs().panel_view(panel_handle(center), cx),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel_view(panel_handle(bottom.clone()), cx),
window,
cx,
);
});
});
// The first frame walks every render hook — the dock frame, each group's
// tab bar, the toolbar — all of which read the dock area.
cx.update(|window, cx| window.draw(cx).clear(cx));
// Emptying a dock leaves an empty group behind; its render must also be
// safe (and draw nothing).
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.remove_panel(bottom, window, cx);
});
});
cx.update(|window, cx| window.draw(cx).clear(cx));
}