redesign dock and titlebar

This commit is contained in:
2026-09-18 13:21:40 +07:00
parent 6ff1eeddbb
commit 2ccbfcd4a8
5 changed files with 313 additions and 178 deletions
+108 -43
View File
@@ -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<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area(
id: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> Entity<DockArea> {
let shared = Rc::new(SkinShared::default());
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
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<NodeId> {
struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
}
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),
)
.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::<DragPanel>(|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::<DragPanel>(|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),
)
})
});
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,13 +809,8 @@ impl TabGroupSkin {
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
div()
.id("tab-panel")
.p_1()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(!group.is_collapsed(), |this| {
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
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)
@@ -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())),
+56 -4
View File
@@ -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<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
}
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
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") {
+14 -101
View File
@@ -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<Sidebar>,
/// App's Dock Area
dock: Entity<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
@@ -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<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
h_flex()
.flex_shrink_0()
.gap_2()
.when_none(&current_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<Self>) -> 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,21 +694,13 @@ 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()
.size_full()
// Title Bar
.child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Main
.child(
h_flex()
.size_full()
@@ -798,7 +712,6 @@ impl Render for Workspace {
.child(self.sidebar.clone()),
)
.child(self.dock.clone()),
),
)
// Notifications
.children(notification_layer)
+106 -6
View File
@@ -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<Self>) -> 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(&current_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<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
+6 -1
View File
@@ -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()
};