From 071cb9e7146cceefba7307bf464c87a60a8dc35c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 19 Aug 2026 09:14:42 +0700 Subject: [PATCH] update dock --- crates/dock/src/lib.rs | 8 +- crates/dock/src/tab_panel.rs | 780 ++++++++++++++++------ crates/dock/src/window_controls.rs | 165 +++++ crates/workspace/src/views/sidebar/mod.rs | 17 +- crates/workspace/src/workspace.rs | 69 +- desktop/src/main.rs | 7 +- 6 files changed, 758 insertions(+), 288 deletions(-) create mode 100644 crates/dock/src/window_controls.rs diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index 5f2227c..a902e39 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -1,10 +1,3 @@ -//! Dock (DockArea / Dock / Panel) components. -//! -//! Vendored from [`gpui-component`]'s `dock` module (v0.5.2, -//! rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902) so it can be customized -//! in-tree. Everything else (Button, TabBar, menus, icons, ...) is imported -//! from `gpui-component` directly. - mod dock; mod invalid_panel; mod panel; @@ -13,6 +6,7 @@ mod stack_panel; mod state; mod tab_panel; mod tiles; +mod window_controls; use std::sync::Arc; diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 713038a..32d4dbb 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -9,23 +9,23 @@ use gpui::prelude::FluentBuilder; use gpui::{ Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div, DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, ScrollHandle, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, WeakEntity, Window, div, - point, px, rems, + InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, Point, Render, + ScrollHandle, SharedString, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, + WeakEntity, Window, WindowControlArea, div, point, px, rems, }; use gpui_component::animation::{Lerp, ease_out_cubic}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ - ActiveTheme, AxisExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, + ActiveTheme, AxisExt, ElementExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, }; use super::{ AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl, PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom, }; -use crate::{PanelInfo, t}; +use crate::{PanelInfo, t, window_controls}; #[derive(Clone)] struct TabState { @@ -154,6 +154,17 @@ pub struct TabPanel { drop_placeholder_animation_name: SharedString, /// Is TabPanel used in Tiles. in_tiles: bool, + + /// 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: Option>, + /// 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: Option>, + /// Bounds of the tab bar's suffix (toolbar) area, which marks where the + /// draggable region ends. + title_bar_suffix_bounds: Option>, } impl Panel for TabPanel { @@ -228,6 +239,57 @@ impl Panel for TabPanel { } } +/// 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
, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + 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(); + } + } + }) +} + impl TabPanel { pub fn new( stack_panel: Option>, @@ -253,6 +315,9 @@ impl TabPanel { collapsed: false, closable: true, in_tiles: false, + title_bar_bounds: None, + title_bar_strip_bounds: None, + title_bar_suffix_bounds: None, } } @@ -751,6 +816,16 @@ impl TabPanel { let is_bottom_dock = bottom_dock_button.is_some(); + // macOS: the traffic lights overlay the window's top-left corner. The + // left dock (sidebar) normally clears them; when it is closed or + // absent, the center tab bar must reserve the space itself. + let needs_traffic_light_padding = cfg!(target_os = "macos") + && self + .dock_area + .upgrade() + .and_then(|area| area.read(cx).left_dock.clone()) + .is_none_or(|dock| !dock.read(cx).is_open()); + let panel_style = dock_area.read(cx).panel_style; let visible_panels = self.visible_panels(cx).collect::>(); @@ -763,58 +838,64 @@ impl TabPanel { let title_style = panel.title_style(cx); - return h_flex() - .justify_between() - .line_height(rems(1.0)) - .h(px(30.)) - .py_2() - .pl_3() - .pr_2() - .when(left_dock_button.is_some(), |this| this.pl_2()) - .when(right_dock_button.is_some(), |this| this.pr_2()) - .when_some(title_style, |this, theme| { - this.bg(theme.background).text_color(theme.foreground) - }) - .when(has_extend_dock_button, |this| { - this.child( + return title_bar_drag_handlers( + h_flex() + .id(("tab-panel-simple-title", cx.entity().entity_id())) + .justify_between() + .line_height(rems(1.0)) + .h(px(30.)) + .py_2() + .pl_3() + .pr_2() + .when(left_dock_button.is_some(), |this| this.pl_2()) + .when(needs_traffic_light_padding, |this| this.pl(px(80.))) + .when(right_dock_button.is_some(), |this| this.pr_2()) + .when_some(title_style, |this, theme| { + this.bg(theme.background).text_color(theme.foreground) + }) + .when(has_extend_dock_button, |this| { + this.child( + h_flex() + .flex_shrink_0() + .mr_1() + .gap_1() + .children(left_dock_button) + .children(bottom_dock_button), + ) + }) + .child( + div() + .id("tab") + .flex_1() + .min_w_16() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(panel.title(window, cx)) + .when(state.draggable, |this| { + this.on_drag( + DragPanel::new(panel.clone(), view), + |drag, offset, _, cx| { + cx.stop_propagation(); + drag.drag_offset.set(offset); + cx.new(|_| drag.clone()) + }, + ) + }), + ) + .children(panel.title_suffix(window, cx)) + .child( h_flex() .flex_shrink_0() - .mr_1() + .ml_1() .gap_1() - .children(left_dock_button) - .children(bottom_dock_button), - ) - }) - .child( - div() - .id("tab") - .flex_1() - .min_w_16() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .child(panel.title(window, cx)) - .when(state.draggable, |this| { - this.on_drag( - DragPanel::new(panel.clone(), view), - |drag, offset, _, cx| { - cx.stop_propagation(); - drag.drag_offset.set(offset); - cx.new(|_| drag.clone()) - }, - ) - }), - ) - .children(panel.title_suffix(window, cx)) - .child( - h_flex() - .flex_shrink_0() - .ml_1() - .gap_1() - .child(self.render_toolbar(state, window, cx)) - .children(right_dock_button), - ) - .into_any_element(); + .child(self.render_toolbar(state, window, cx)) + .children(right_dock_button), + ), + window, + cx, + ) + .into_any_element(); } if let Some(panel_ix) = self.pending_scroll_to_ix.take() @@ -830,164 +911,268 @@ impl TabPanel { let tabs_count = self.panels.len(); - TabBar::new("tab-bar") - .track_scroll(&self.tab_bar_scroll_handle) - .when(has_extend_dock_button, |this| { - this.prefix( - h_flex() - .items_center() - .top_0() - // Right -1 for avoid border overlap with the first tab - .right(-px(1.)) - .border_r_1() - .border_b_1() - .h_full() - .border_color(cx.theme().border) - .bg(cx.theme().tokens.tab_bar) - .px_2() - .children(left_dock_button) - .children(bottom_dock_button), - ) - }) - .children(self.panels.iter().enumerate().filter_map(|(ix, panel)| { - let mut active = state.active_panel.as_ref() == Some(panel); - let droppable = self.collapsed; + // TabBar lays out its scrollable content at content width, so the + // strip 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, + self.title_bar_strip_bounds, + self.title_bar_suffix_bounds, + ) { + (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", cx.entity().entity_id())) + .absolute() + .top_0() + .bottom_0() + .left(left) + .right(right) + .debug_selector(|| "title-bar-drag".into()), + window, + cx, + ) + }) + } + _ => None, + }; - if !panel.visible(cx) { - return None; - } - - // Always not show active tab style, if the panel is collapsed - if self.collapsed { - active = false; - } - - Some( - // Note: upstream also calls the crate-private `Tab::ix(ix)` and - // `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per - // child itself, and `tab_bar_prefix` only clears the first - // tab's left border when the bar has no prefix — both are - // inaccessible from outside gpui-component, so they are - // omitted. - Tab::new() - .map(|this| { - if let Some(tab_name) = panel.tab_name(cx) { - this.child(tab_name) - } else { - this.child(panel.title(window, cx)) - } - }) - .selected(active) - .on_click(cx.listener({ - let is_collapsed = self.collapsed; - let dock_area = self.dock_area.clone(); - move |view, _, window, cx| { - view.set_active_ix(ix, window, cx); - - // Open dock if clicked on the collapsed bottom dock - if is_bottom_dock && is_collapsed { - _ = dock_area.update(cx, |dock_area, cx| { - dock_area.toggle_dock(DockPlacement::Bottom, window, cx); - }); - } - } - })) - .when(!droppable, |this| { - this.when(state.draggable, |this| { - this.on_drag( - DragPanel::new(panel.clone(), view.clone()), - |drag, offset, _, cx| { - cx.stop_propagation(); - drag.drag_offset.set(offset); - cx.new(|_| drag.clone()) - }, - ) - }) - .when(state.droppable, |this| { - this.drag_over::(|this, _, _, cx| { - this.rounded_l_none() - .border_l_2() - .border_r_0() - .border_color(cx.theme().drag_border) - }) - .on_drop(cx.listener(move |this, drag: &DragPanel, window, cx| { - this.will_split_placement = None; - this.on_drop(drag, Some(ix), true, window, cx) - })) - .when(!self.in_tiles, |this| { - this.drag_over::(|this, _, _, cx| { - this.rounded_l_none() - .border_l_2() - .border_r_0() - .border_color(cx.theme().drag_border) - }) - .on_drop(cx.listener( - |this, item: &AnyDrag, _, cx| { - this.will_split_placement = None; - this.emit_drag_drop(item, None, cx); - }, - )) - }) - }) - }), - ) - })) - .last_empty_space( - // empty space to allow move to last tab right + div() + .flex() + .flex_row() + .child( div() - .id("tab-bar-empty-space") - .h_full() - .flex_grow_1() - .min_w_16() - .when(state.droppable, |this| { - this.drag_over::(|this, _, _, cx| { - this.bg(cx.theme().tokens.drop_target) - }) - .on_drop(cx.listener(move |this, drag: &DragPanel, window, cx| { - this.will_split_placement = None; - - let ix = if drag.tab_panel == view { - Some(tabs_count - 1) - } else { - None - }; - - this.on_drop(drag, ix, false, window, cx) - })) - .when(!self.in_tiles, |this| { - this.drag_over::(|this, _, _, cx| { - this.bg(cx.theme().tokens.drop_target) - }) - .on_drop(cx.listener( - |this, item: &AnyDrag, _, cx| { - this.will_split_placement = None; - this.emit_drag_drop(item, None, cx); + .relative() + .flex_1() + .min_w_0() + .on_prepaint({ + let view = view.clone(); + move |bounds, _, cx| { + view.update(cx, |this, cx| { + if this.title_bar_bounds != Some(bounds) { + this.title_bar_bounds = Some(bounds); + cx.notify(); + } + }); + } + }) + .child( + TabBar::new("tab-bar") + .track_scroll(&self.tab_bar_scroll_handle) + .when( + has_extend_dock_button || needs_traffic_light_padding, + |this| { + this.prefix( + h_flex() + .items_center() + .top_0() + // Right -1 for avoid border overlap with the first tab + .right(-px(1.)) + .border_r_1() + .border_b_1() + .h_full() + .border_color(cx.theme().border) + .bg(cx.theme().tokens.tab_bar) + .px_2() + .when(needs_traffic_light_padding, |this| { + this.pl(px(80.)) + }) + .children(left_dock_button) + .children(bottom_dock_button), + ) }, - )) - }) - }), + ) + .children(self.panels.iter().enumerate().filter_map(|(ix, panel)| { + let mut active = state.active_panel.as_ref() == Some(panel); + let droppable = self.collapsed; + + if !panel.visible(cx) { + return None; + } + + // Always not show active tab style, if the panel is collapsed + if self.collapsed { + active = false; + } + + Some( + // Note: upstream also calls the crate-private `Tab::ix(ix)` and + // `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per + // child itself, and `tab_bar_prefix` only clears the first + // tab's left border when the bar has no prefix — both are + // inaccessible from outside gpui-component, so they are + // omitted. + Tab::new() + .map(|this| { + if let Some(tab_name) = panel.tab_name(cx) { + this.child(tab_name) + } else { + this.child(panel.title(window, cx)) + } + }) + .selected(active) + .on_click(cx.listener({ + let is_collapsed = self.collapsed; + let dock_area = self.dock_area.clone(); + move |view, _, window, cx| { + view.set_active_ix(ix, window, cx); + + // Open dock if clicked on the collapsed bottom dock + if is_bottom_dock && is_collapsed { + _ = dock_area.update(cx, |dock_area, cx| { + dock_area.toggle_dock( + DockPlacement::Bottom, + window, + cx, + ); + }); + } + } + })) + .when(!droppable, |this| { + this.when(state.draggable, |this| { + this.on_drag( + DragPanel::new(panel.clone(), view.clone()), + |drag, offset, _, cx| { + cx.stop_propagation(); + drag.drag_offset.set(offset); + cx.new(|_| drag.clone()) + }, + ) + }) + .when( + state.droppable, + |this| { + this.drag_over::(|this, _, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop(cx.listener( + move |this, drag: &DragPanel, window, cx| { + this.will_split_placement = None; + this.on_drop(drag, Some(ix), true, window, cx) + }, + )) + .when( + !self.in_tiles, + |this| { + this.drag_over::(|this, _, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop(cx.listener( + |this, item: &AnyDrag, _, cx| { + this.will_split_placement = None; + this.emit_drag_drop(item, None, cx); + }, + )) + }, + ) + }, + ) + }), + ) + })) + .last_empty_space( + // Empty space to allow dropping at the last tab. Its left + // edge (right after the last tab) marks the start of the + // title-bar drag overlay. + div() + .id("tab-bar-empty-space") + .h_full() + .flex_grow_1() + .min_w_16() + .on_prepaint({ + let view = view.clone(); + move |bounds, _, cx| { + view.update(cx, |this, cx| { + if this.title_bar_strip_bounds != Some(bounds) { + this.title_bar_strip_bounds = Some(bounds); + cx.notify(); + } + }); + } + }) + .when(state.droppable, |this| { + let view = view.clone(); + this.drag_over::(|this, _, _, cx| { + this.bg(cx.theme().tokens.drop_target) + }) + .on_drop(cx.listener( + move |this, drag: &DragPanel, window, cx| { + this.will_split_placement = None; + + let ix = if drag.tab_panel == view { + Some(tabs_count - 1) + } else { + None + }; + + this.on_drop(drag, ix, false, window, cx) + }, + )) + .when( + !self.in_tiles, + |this| { + this.drag_over::(|this, _, _, cx| { + this.bg(cx.theme().tokens.drop_target) + }) + .on_drop(cx.listener( + |this, item: &AnyDrag, _, cx| { + this.will_split_placement = None; + this.emit_drag_drop(item, None, cx); + }, + )) + }, + ) + }), + ) + .when(!self.collapsed, |this| { + this.suffix( + h_flex() + .items_center() + .top_0() + .right_0() + .border_l_1() + .border_b_1() + .h_full() + .border_color(cx.theme().border) + .bg(cx.theme().tokens.tab_bar) + .px_2() + .gap_1() + .on_prepaint({ + let view = view.clone(); + move |bounds, _, cx| { + view.update(cx, |this, cx| { + if this.title_bar_suffix_bounds != Some(bounds) + { + this.title_bar_suffix_bounds = Some(bounds); + cx.notify(); + } + }); + } + }) + .children( + self.active_panel(cx) + .and_then(|panel| panel.title_suffix(window, cx)), + ) + .child(self.render_toolbar(state, window, cx)) + .when_some(right_dock_button, |this, btn| this.child(btn)), + ) + }), + ) + .when_some(drag_overlay, |this, overlay| this.child(overlay)), ) - .when(!self.collapsed, |this| { - this.suffix( - h_flex() - .items_center() - .top_0() - .right_0() - .border_l_1() - .border_b_1() - .h_full() - .border_color(cx.theme().border) - .bg(cx.theme().tokens.tab_bar) - .px_2() - .gap_1() - .children( - self.active_panel(cx) - .and_then(|panel| panel.title_suffix(window, cx)), - ) - .child(self.render_toolbar(state, window, cx)) - .when_some(right_dock_button, |this, btn| this.child(btn)), - ) - }) + .child(window_controls::window_controls(window, cx)) .into_any_element() } @@ -1494,9 +1679,11 @@ impl Render for TabPanel { #[cfg(test)] mod tests { use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; - use gpui::{TestAppContext, VisualTestContext, WindowHandle}; - use gpui_component::{Root, Theme}; + use gpui::{TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size}; + use gpui_component::tab::{Tab, TabBar}; + use gpui_component::{Root, Theme, v_flex}; use super::*; use crate::{DockItem, TileMeta}; @@ -2099,4 +2286,177 @@ mod tests { assert_eq!(drain(&fixture.log), [("A", false)]); } + + #[derive(Default)] + struct ProbeFlags { + empty_down: AtomicBool, + empty_click: AtomicBool, + control_down: AtomicBool, + control_click: AtomicBool, + } + + struct ProbeView { + flags: Entity, + } + + impl Render for ProbeView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let flags = self.flags.clone(); + + let empty = title_bar_drag_handlers( + div().id("empty-space").h_full().flex_grow_1().min_w_16(), + window, + cx, + ) + .debug_selector(|| "empty-space".into()) + .on_mouse_down(MouseButton::Left, { + let flags = flags.clone(); + move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst)) + }) + .on_click({ + let flags = flags.clone(); + move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst)) + }); + + let control = + title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx) + .debug_selector(|| "control-space".into()) + .on_mouse_down(MouseButton::Left, { + let flags = flags.clone(); + move |_, _, cx| { + flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst)) + } + }) + .on_click({ + let flags = flags.clone(); + move |_, _, cx| { + flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst)) + } + }); + + v_flex() + .size_full() + .child( + TabBar::new("probe-bar") + .child(Tab::new().label("Alpha")) + .child(Tab::new().label("Beta")) + .suffix(div()) + .last_empty_space(empty), + ) + .child(control) + } + } + + /// Diagnostic: verify that the tab bar's `last_empty_space` element + /// receives mouse events when wrapped by `title_bar_drag_handlers`. + #[gpui::test] + fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) { + let (flags, handle) = cx.update(|cx| { + cx.set_global(Theme::default()); + let flags = cx.new(|_| ProbeFlags::default()); + let handle = cx.open_window( + WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds { + origin: gpui::Point::default(), + size: size(px(800.), px(100.)), + })), + ..Default::default() + }, + |window, cx| { + let flags = flags.clone(); + let content = cx.new(|_| ProbeView { flags }); + cx.new(|cx| Root::new(content, window, cx)) + }, + ); + (flags, handle.unwrap()) + }); + let mut cx = VisualTestContext::from_window(handle.into(), cx); + + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + }); + + let empty_bounds = cx + .debug_bounds("empty-space") + .expect("empty-space must be laid out"); + let control_bounds = cx + .debug_bounds("control-space") + .expect("control-space must be laid out"); + let empty_center = empty_bounds.center(); + let control_center = control_bounds.center(); + + // Control: a plain div with the same handlers, outside the TabBar. + cx.simulate_click(control_center, Default::default()); + // Target: the TabBar's last_empty_space. + cx.simulate_click(empty_center, Default::default()); + + let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| { + let flags = flags.read(cx); + ( + flags.empty_down.load(Ordering::SeqCst), + flags.empty_click.load(Ordering::SeqCst), + flags.control_down.load(Ordering::SeqCst), + flags.control_click.load(Ordering::SeqCst), + ) + }); + assert!( + control_down, + "control mouse_down must fire at {control_center:?}" + ); + assert!( + control_click, + "control click must fire at {control_center:?}" + ); + assert!( + empty_down, + "empty-space mouse_down must fire at {empty_center:?}" + ); + assert!( + empty_click, + "empty-space click must fire at {empty_center:?}" + ); + } + + /// The title-bar drag overlay must cover the dead zone between the last + /// tab and the suffix (TabBar's scrollable content is content-width, so + /// that area has no element of its own). + #[gpui::test] + fn title_bar_drag_overlay_covers_the_dead_zone(cx: &mut TestAppContext) { + let fixture = setup(cx); + let mut cx = VisualTestContext::from_window(fixture.window.into(), cx); + let (item, _tab_panel, _) = build_tabs(&fixture, &["Alpha", "Beta"], None, &mut cx); + cx.update(|window, cx| { + fixture + .dock_area + .update(cx, |dock_area, cx| dock_area.set_center(item, window, cx)); + }); + cx.run_until_parked(); + + // Frame 1 measures the tab bar; the overlay renders from frame 2 on. + cx.update(|window, cx| { + _ = window.draw(cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + }); + cx.run_until_parked(); + + let overlay = cx + .debug_bounds("title-bar-drag") + .expect("title-bar drag overlay must be laid out"); + assert!( + overlay.size.height > px(20.), + "overlay must cover the tab bar height, got {overlay:?}" + ); + assert!( + overlay.size.width > px(100.), + "overlay must cover the dead zone, got {overlay:?}" + ); + + // A click in the middle of the dead zone must reach the overlay + // without panicking (its handlers start a window move on drag). + cx.simulate_click(overlay.center(), Default::default()); + } } diff --git a/crates/dock/src/window_controls.rs b/crates/dock/src/window_controls.rs new file mode 100644 index 0000000..18fba96 --- /dev/null +++ b/crates/dock/src/window_controls.rs @@ -0,0 +1,165 @@ +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}; + +/// 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()) + } +} + +/// Render the window control buttons, or an empty placeholder on macOS (the +/// traffic lights are drawn natively). +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() + .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()) +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 2a7bf26..35b7523 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,14 +1,14 @@ use std::sync::Arc; use assets::CustomIconName; +use dock::{DockArea, DockPlacement, Panel, PanelEvent, title_bar_drag_handlers}; use gpui::prelude::*; use gpui::{ App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, - SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, + SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, }; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; -use dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use signed_state::{Backend, BackendEvent, ProfileStore}; @@ -129,7 +129,7 @@ impl Focusable for SidebarPanel { } impl Render for SidebarPanel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.logged_in { return v_flex() .p_4() @@ -183,11 +183,16 @@ impl Render for SidebarPanel { let name = profile.name(); let picture = profile.picture(); - this.child( + this.child(title_bar_drag_handlers( h_flex() + .id("sidebar-profile") .h_12() .px_3() .gap_2() + // The macOS traffic lights overlay the + // top-left corner of the window; keep the + // profile content clear of them. + .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) .child( Avatar::new() .name(name.clone()) @@ -196,7 +201,9 @@ impl Render for SidebarPanel { .border_0(), ) .child(div().text_sm().child(name)), - ) + window, + cx, + )) }) .child( v_flex() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2747f0f..84d15fd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,20 +1,18 @@ use std::sync::Arc; -use assets::CustomIconName; use dock::{DockArea, DockItem}; use gpui::prelude::*; -use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; +use gpui::{Context, Entity, Render, Subscription, Window, div, px}; +use gpui_component::{Root, StyledExt, Theme}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; use crate::views::sidebar::passphrase_dialog; -/// Root view of the app: title bar, dock area, status bar. +/// Root view of the app: dock area (whose center tab bar doubles as the +/// window title bar), overlays. pub struct Workspace { dock: Entity, - status: SharedString, _subscriptions: Vec, _passphrase_subscription: Subscription, } @@ -40,16 +38,6 @@ impl Workspace { }); let backend = Backend::global(cx); - let connected = backend.read(cx).is_connected(); - let sync_progress = backend.read(cx).sync_progress(); - - let status = if let Some((total, current)) = sync_progress { - format!("Syncing repositories... {current}/{total}").into() - } else if connected { - "Connected".into() - } else { - "Connecting...".into() - }; let mut subscriptions = vec![]; @@ -57,19 +45,6 @@ impl Workspace { Theme::sync_system_appearance(Some(window), cx); })); - subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { - match event { - BackendEvent::SyncProgress { total, current } => { - this.status = format!("Syncing repositories... {current}/{total}").into() - } - BackendEvent::Synced => this.status = "Connected".into(), - BackendEvent::Connected => this.status = "Connected".into(), - BackendEvent::Error(error) => this.status = error.clone().into(), - _ => return, - } - cx.notify(); - })); - // Ask for the passphrase when the stored identity is NIP-49 // encrypted. Subscribed via the window, since opening a dialog // needs one. @@ -98,7 +73,6 @@ impl Workspace { Self { dock, - status, _subscriptions: subscriptions, _passphrase_subscription: passphrase_subscription, } @@ -111,44 +85,11 @@ impl Render for Workspace { let notification_layer = Root::render_notification_layer(window, cx); div() - // All `img` elements below (avatars, …) load through the shared - // bounded LRU cache instead of the window-global asset cache, - // so images can be freed when views close or when the cache is - // full. .image_cache(crate::image_cache::global(cx)) .id("workspace") .v_flex() .size_full() - .child( - v_flex() - .size_full() - // Title Bar - .child( - TitleBar::new() - // Left - .child(div()) - // Right - .child(h_flex().px_2().map(|this| { - if self.status == "Connected" { - this.child( - Button::new("relay") - .icon(CustomIconName::GlobalOn) - .small() - .ghost(), - ) - } else { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.clone()), - ) - } - })), - ) - // Dock Area - .child(self.dock.clone()), - ) + .child(self.dock.clone()) // Notifications .children(notification_layer) // Modals diff --git a/desktop/src/main.rs b/desktop/src/main.rs index b7ad0db..3aa8544 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -38,10 +38,12 @@ fn main() { std::fs::create_dir_all(paths::repos_dir()).ok(); signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); - // Set up the window bounds + // Set up the window options let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx); - // Set up the window options + // The dock's tab bar acts as the window title bar: the app owns + // title-bar dragging (via `start_window_move` on the tab bar), so + // AppKit must not treat the top strip as a native drag region. let opts = WindowOptions { window_background: WindowBackgroundAppearance::Opaque, window_decorations: Some(WindowDecorations::Client), @@ -54,6 +56,7 @@ fn main() { traffic_light_position: Some(point(px(9.0), px(9.0))), appears_transparent: true, }), + app_owns_titlebar_drag: true, ..Default::default() };