From 2ccbfcd4a84377d35e73c0d70435f0b211e60e64 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 13:21:40 +0700 Subject: [PATCH] redesign dock and titlebar --- crates/ui/src/dock/mod.rs | 183 +++++++++++++++++++--------- crates/ui/src/title_bar.rs | 60 ++++++++- crates/workspace/src/lib.rs | 129 ++++---------------- crates/workspace/src/sidebar/mod.rs | 112 ++++++++++++++++- desktop/src/main.rs | 7 +- 5 files changed, 313 insertions(+), 178 deletions(-) diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index 03ced3e3..257515c1 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _; use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::tab::Tab; use crate::tab::tab_bar::TabBar; +use crate::title_bar::{title_bar_drag_handlers, window_controls}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; mod panel; @@ -31,12 +32,34 @@ pub use panel::*; actions!(dock, [ToggleZoom, ClosePanel]); +pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement; + +#[derive(Default)] +pub struct TitleBarChrome { + trailing: Cell>, +} + +impl TitleBarChrome { + pub fn set_trailing(&self, renderer: TitleBarRenderer) { + self.trailing.set(Some(renderer)); + } + + fn trailing(&self, window: &mut Window, cx: &mut App) -> Option { + self.trailing.get().map(|render| render(window, cx)) + } +} + pub fn dock_area( id: impl Into, window: &mut Window, cx: &mut App, -) -> Entity { - let shared = Rc::new(SkinShared::default()); +) -> (Entity, Rc) { + let chrome = Rc::new(TitleBarChrome::default()); + let shared = Rc::new(SkinShared { + area: RefCell::new(None), + resizing: Cell::new(None), + chrome: chrome.clone(), + }); let area = cx.new(|cx| { DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin { shared: shared.clone(), @@ -44,7 +67,7 @@ pub fn dock_area( }); *shared.area.borrow_mut() = Some(area.downgrade()); - area + (area, chrome) } pub fn add_panel( @@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option { struct SkinShared { area: RefCell>>, resizing: Cell>, + chrome: Rc, } impl SkinShared { @@ -394,6 +418,17 @@ impl TabGroupSkin { } } + fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool { + let Some(area) = self.shared.area() else { + return false; + }; + + area.read(cx) + .layout(DockPlacement::Center) + .and_then(|tree| left_top_group(tree.root())) + == Some(group.node()) + } + fn render_toolbar( &self, group: &TabGroupContext, @@ -473,16 +508,17 @@ impl TabGroupSkin { let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let has_leading = left_button.is_some() || bottom_button.is_some(); let drag = tab_drag(group, ix, cx); + let is_title_bar = self.is_title_bar_group(group, cx); + let trailing_chrome = is_title_bar + .then(|| self.shared.chrome.trailing(window, cx)) + .flatten(); - h_flex() + let bar = h_flex() + .id("tab-title-bar") .justify_between() .items_center() .line_height(rems(1.0)) .h(TABBAR_HEIGHT) - .py_2() - .pl_3() - .pr_2() - .rounded_t(cx.theme().radius_lg) .bg(cx.theme().panel_background) .when(left_button.is_some(), |this| this.pl_2()) .when(right_button.is_some(), |this| this.pr_2()) @@ -499,9 +535,9 @@ impl TabGroupSkin { .child( div() .id("tab") - .flex_1() + .flex_initial() + .min_w_0() .px_2() - .min_w_16() .overflow_hidden() .whitespace_nowrap() .child( @@ -524,6 +560,14 @@ impl TabGroupSkin { }) }), ) + .child({ + let space = div().id("tab-title-space").flex_1().h_full(); + if is_title_bar { + title_bar_drag_handlers(space, window, cx).into_any_element() + } else { + space.into_any_element() + } + }) .child( h_flex() .flex_shrink_0() @@ -532,7 +576,18 @@ impl TabGroupSkin { .child(self.render_toolbar(group, window, cx)) .children(right_button), ) - .into_any_element() + .when_some(trailing_chrome, |this, chrome| this.child(chrome)); + + if is_title_bar { + h_flex() + .h(TABBAR_HEIGHT) + .bg(cx.theme().panel_background) + .child(bar.flex_1()) + .child(window_controls()) + .into_any_element() + } else { + bar.into_any_element() + } } fn render_tabs( @@ -556,13 +611,36 @@ impl TabGroupSkin { .iter() .position(|panel| panel.panel_id(cx) == displayed) }); + let is_title_bar = self.is_title_bar_group(group, cx); + let trailing_chrome = is_title_bar + .then(|| self.shared.chrome.trailing(window, cx)) + .flatten(); + let empty_space = div() + .id("tab-bar-empty-space") + .h_full() + .flex_grow_1() + .min_w_16() + .when(droppable, |this| { + this.drag_over::(|this, _, _, cx| this.bg(cx.theme().surface_background)) + .on_drop({ + let group = TabGroupContext::clone(group); + move |drag: &DragPanel, window, cx| { + let ix = (drag.source() == group.node()).then(|| tabs_count - 1); + group.drop_panel(drag.clone(), ix, false, window, cx); + } + }) + }); + let empty_space = if is_title_bar { + title_bar_drag_handlers(empty_space, window, cx).into_any_element() + } else { + empty_space.into_any_element() + }; - TabBar::new("tab-bar") + let bar = TabBar::new("tab-bar") .track_scroll(&self.scroll_handle) .h(TABBAR_HEIGHT) .bg(cx.theme().panel_background) - .rounded_t(cx.theme().radius_lg) - .when(has_leading, |this| { + .when(is_title_bar || has_leading, |this| { this.prefix( h_flex() .items_center() @@ -639,26 +717,7 @@ impl TabGroupSkin { }) }) })) - .last_empty_space( - // Empty space so a panel can be moved past the last tab. - div() - .id("tab-bar-empty-space") - .h_full() - .flex_grow_1() - .min_w_16() - .when(droppable, |this| { - this.drag_over::(|this, _, _, cx| { - this.bg(cx.theme().surface_background) - }) - .on_drop({ - let group = TabGroupContext::clone(group); - move |drag: &DragPanel, window, cx| { - let ix = (drag.source() == group.node()).then(|| tabs_count - 1); - group.drop_panel(drag.clone(), ix, false, window, cx); - } - }) - }), - ) + .last_empty_space(empty_space) .when(!collapsed, |this| { this.suffix( h_flex() @@ -669,10 +728,22 @@ impl TabGroupSkin { .px_0p5() .gap_1() .child(self.render_toolbar(group, window, cx)) - .children(right_button), + .children(right_button) + .children(trailing_chrome), ) - }) - .into_any_element() + }); + + if is_title_bar { + h_flex() + .h(TABBAR_HEIGHT) + .w_full() + .bg(cx.theme().panel_background) + .child(bar.flex_1()) + .child(window_controls()) + .into_any_element() + } else { + bar.into_any_element() + } } fn dock_toggle_button( @@ -738,28 +809,23 @@ impl TabGroupSkin { } impl TabGroupRenderer for TabGroupSkin { - fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful
{ - div() - .id("tab-panel") - .p_1() - .rounded(cx.theme().radius_lg) - .when(cx.theme().shadow, |this| this.shadow_xs()) - .when(!group.is_collapsed(), |this| { - this.on_action({ - let group = TabGroupContext::clone(group); - move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx) - }) - .on_action({ - let group = TabGroupContext::clone(group); - move |_: &ClosePanel, window, cx| { - let Some(panel) = group.active_panel() else { - return; - }; - let panel = panel.panel_id(cx); - group.close(panel, window, cx); - } - }) + fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful
{ + div().id("tab-panel").when(!group.is_collapsed(), |this| { + this.on_action({ + let group = TabGroupContext::clone(group); + move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx) }) + .on_action({ + let group = TabGroupContext::clone(group); + move |_: &ClosePanel, window, cx| { + let Some(panel) = group.active_panel() else { + return; + }; + let panel = panel.panel_id(cx); + group.close(panel, window, cx); + } + }) + }) } fn render_tab_bar( @@ -811,7 +877,6 @@ impl TabGroupRenderer for TabGroupSkin { .child( div() .size_full() - .rounded_b(cx.theme().radius_lg) .bg(cx.theme().panel_background) .overflow_hidden() .child(panel.cached(StyleRefinement::default().v_flex().size_full())), diff --git a/crates/ui/src/title_bar.rs b/crates/ui/src/title_bar.rs index 83783f50..89e9da3f 100644 --- a/crates/ui/src/title_bar.rs +++ b/crates/ui/src/title_bar.rs @@ -2,9 +2,10 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, - StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px, + AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful, + StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window, + WindowControlArea, div, px, }; use smallvec::SmallVec; use theme::ActiveTheme; @@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon { #[derive(IntoElement)] #[allow(clippy::type_complexity)] -struct WindowControls { +pub(crate) struct WindowControls { on_close_window: Option>>, } +pub(crate) fn window_controls() -> WindowControls { + WindowControls { + on_close_window: None, + } +} + +pub fn title_bar_drag_handlers( + this: Stateful
, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + let state = window.use_state(cx, |_, _| TitleBarState { should_move: false }); + + let this = if cfg!(target_family = "wasm") { + this + } else { + this.window_control_area(WindowControlArea::Drag) + }; + + this.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 RenderOnce for WindowControls { fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { if cfg!(target_os = "macos") || cfg!(target_family = "wasm") { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 90f21415..85da8cd6 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,3 +1,4 @@ +use std::rc::Rc; use std::sync::Arc; use ::settings::AppSettings; @@ -8,8 +9,8 @@ use common::download_dir; use device::{DeviceEvent, DeviceRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, - Render, SharedString, Styled, Subscription, Task, Window, div, px, + Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, }; use nostr_sdk::prelude::*; use person::{PersonRegistry, shorten_pubkey}; @@ -17,12 +18,11 @@ use serde::Deserialize; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; -use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::{Notification, NotificationKind}; -use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex}; +use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; @@ -63,6 +63,7 @@ pub struct Workspace { sidebar: Entity, /// App's Dock Area dock: Entity, + title_bar_chrome: Rc, /// Async tasks tasks: Vec>>, @@ -78,7 +79,7 @@ impl Workspace { let nostr = NostrRegistry::global(cx); let sidebar = cx.new(|cx| Sidebar::new(window, cx)); - let dock = dock::dock_area("coop", window, cx); + let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx); let mut subscriptions = smallvec![]; @@ -225,6 +226,7 @@ impl Workspace { Self { sidebar, dock, + title_bar_chrome, tasks: vec![], _subscriptions: subscriptions, } @@ -518,95 +520,14 @@ impl Workspace { }); } - fn titlebar_left(&mut self, cx: &mut Context) -> impl IntoElement { - let nostr = NostrRegistry::global(cx); - let current_user = nostr.read(cx).current_user(); - - h_flex() - .flex_shrink_0() - .gap_2() - .when_none(¤t_user, |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Import your identity to continue")), - ) - }) - .when_some(current_user.as_ref(), |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(public_key, cx); - let avatar = profile.avatar(); - let name = profile.name(); - - this.child( - Button::new("current-user") - .child(Avatar::new(avatar.clone()).xsmall()) - .small() - .caret() - .compact() - .transparent() - .dropdown_menu(move |this, _window, cx| { - let avatar = avatar.clone(); - let name = name.clone(); - - this.min_w(px(256.)) - .item(PopupMenuItem::element(move |_window, cx| { - h_flex() - .gap_1p5() - .text_xs() - .text_color(cx.theme().text_muted) - .child(Avatar::new(avatar.clone()).xsmall()) - .child(name.clone()) - })) - .separator() - .menu_with_icon( - "Profile", - IconName::Profile, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Contact List", - IconName::Book, - Box::new(Command::ShowContactList), - ) - .menu_with_icon( - "Backup", - IconName::UserKey, - Box::new(Command::ShowBackup), - ) - .menu_with_icon( - "Themes", - IconName::Sun, - Box::new(Command::ToggleTheme), - ) - // Only offer in-app updates when auto-update is - // enabled (managed channels update themselves). - .when(AutoUpdater::is_available(cx), |this| { - this.separator().menu_with_icon( - "Check for Updates", - IconName::Device, - Box::new(Command::Update), - ) - }) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), - ) - }) - } - - fn titlebar_right(&mut self, cx: &mut Context) -> impl IntoElement { + fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement { let auto_updater = AutoUpdater::try_global(cx); let chat = ChatRegistry::global(cx); let nip4e_enabled = AppSettings::get_nip4e(cx); let nostr = NostrRegistry::global(cx); let Some(public_key) = nostr.read(cx).current_user() else { - return div(); + return div().into_any_element(); }; let persons = PersonRegistry::global(cx); @@ -635,11 +556,11 @@ impl Workspace { .tooltip("Quit and relaunch into the installed update") .small() .ghost() - .on_click(cx.listener(|_this, _event, _window, cx| { + .on_click(|_event, _window, cx| { if let Some(auto_updater) = AutoUpdater::try_global(cx) { auto_updater.update(cx, |this, cx| this.restart(cx)); } - })), + }), ) }) .when(nip4e_enabled, |this| { @@ -764,6 +685,7 @@ impl Workspace { ) }), ) + .into_any_element() } } @@ -772,33 +694,24 @@ impl Render for Workspace { let modal_layer = Root::render_modal_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); + self.title_bar_chrome.set_trailing(Self::titlebar_right); + div() .id("workspace") .on_action(cx.listener(Self::on_command)) .relative() .size_full() .child( - v_flex() + h_flex() .size_full() - // Title Bar .child( - TitleBar::new() - .child(self.titlebar_left(cx)) - .child(self.titlebar_right(cx)), + div() + .flex_shrink_0() + .h_full() + .w(SIDEBAR_WIDTH) + .child(self.sidebar.clone()), ) - // Main - .child( - h_flex() - .size_full() - .child( - div() - .flex_shrink_0() - .h_full() - .w(SIDEBAR_WIDTH) - .child(self.sidebar.clone()), - ) - .child(self.dock.clone()), - ), + .child(self.dock.clone()), ) // Notifications .children(notification_layer) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b72d590a..9fd7f585 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -2,28 +2,36 @@ use std::collections::HashSet; use std::ops::Range; use anyhow::Error; +use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::{DebouncedDelay, TimestampExt}; use entry::RoomEntry; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle, - Window, div, retain_all, uniform_list, + App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, + UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use instant::Duration; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry}; -use theme::{ActiveTheme, SIDEBAR_WIDTH}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::input::{Input, InputEvent, InputState}; +use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::{ + Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, + title_bar_drag_handlers, v_flex, +}; + +use crate::Command; mod entry; @@ -485,6 +493,97 @@ impl Sidebar { }) .collect() } + + fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); + + title_bar_drag_handlers( + h_flex() + .id("sidebar-user") + .w_full() + .h(TABBAR_HEIGHT) + .flex_shrink_0() + .items_center() + .gap_2() + .px_2() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) + .when_none(¤t_user, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Import your identity to continue")), + ) + }) + .when_some(current_user.as_ref(), |this, public_key| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(public_key, cx); + let avatar = profile.avatar(); + let name = profile.name(); + + this.child( + Button::new("current-user") + .child(Avatar::new(avatar.clone()).xsmall()) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, cx| { + let avatar = avatar.clone(); + let name = name.clone(); + + this.min_w(px(256.)) + .item(PopupMenuItem::element(move |_window, cx| { + h_flex() + .gap_1p5() + .text_xs() + .text_color(cx.theme().text_muted) + .child(Avatar::new(avatar.clone()).xsmall()) + .child(name.clone()) + })) + .separator() + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Contact List", + IconName::Book, + Box::new(Command::ShowContactList), + ) + .menu_with_icon( + "Backup", + IconName::UserKey, + Box::new(Command::ShowBackup), + ) + .menu_with_icon( + "Themes", + IconName::Sun, + Box::new(Command::ToggleTheme), + ) + .when(AutoUpdater::is_available(cx), |this| { + this.separator().menu_with_icon( + "Check for Updates", + IconName::Device, + Box::new(Command::Update), + ) + }) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ) + }), + window, + cx, + ) + } } impl Panel for Sidebar { @@ -502,7 +601,7 @@ impl Focusable for Sidebar { } impl Render for Sidebar { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); let logged_in = nostr.read(cx).current_user().is_some(); @@ -524,6 +623,7 @@ impl Render for Sidebar { .image_cache(retain_all("sidebar")) .size_full() .gap_2() + .child(self.render_user(window, cx)) .child( h_flex().px_2().py_1().child( Input::new(&self.find_input) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 2c20e2d4..86985ace 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -9,6 +9,7 @@ use gpui::{ use gpui_platform::application; use nostr_sdk::prelude::SecretKey; use state::{APP_ID, CLIENT_NAME}; +use theme::TABBAR_HEIGHT; use ui::Root; actions!(coop, [Quit]); @@ -66,9 +67,13 @@ fn main() { app_id: Some(APP_ID.to_owned()), titlebar: Some(TitlebarOptions { title: Some(SharedString::new_static(CLIENT_NAME)), - traffic_light_position: Some(point(px(9.0), px(9.0))), + traffic_light_position: Some(point( + px(9.0), + px(TABBAR_HEIGHT / px(2.) - 14. / 2.), + )), appears_transparent: true, }), + app_owns_titlebar_drag: true, ..Default::default() };