linux: Add title bar for rules library (#33025)

Closes #30513

- Abstract away common wrapper component to `platform_title_bar`.
- Use it in both zed and rules library.
- For rules library, keep traffic like only style for macOS, and add
custom title bar for Linux and Windows.

Release Notes:

- Added way to minimize, maximize, and close the rules library window
for Linux.
This commit is contained in:
Smit Barmase 2025-06-19 18:23:09 +05:30 committed by GitHub
parent c8d49408d3
commit 1bd49a77e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 339 additions and 280 deletions

1
Cargo.lock generated
View file

@ -13607,6 +13607,7 @@ dependencies = [
"serde", "serde",
"settings", "settings",
"theme", "theme",
"title_bar",
"ui", "ui",
"util", "util",
"workspace", "workspace",

View file

@ -27,6 +27,7 @@ rope.workspace = true
serde.workspace = true serde.workspace = true
settings.workspace = true settings.workspace = true
theme.workspace = true theme.workspace = true
title_bar.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
workspace-hack.workspace = true workspace-hack.workspace = true

View file

@ -20,12 +20,13 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::time::Duration; use std::time::Duration;
use theme::ThemeSettings; use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{ use ui::{
Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render, Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
SharedString, Styled, Tooltip, Window, div, prelude::*, SharedString, Styled, Tooltip, Window, div, prelude::*,
}; };
use util::{ResultExt, TryFutureExt}; use util::{ResultExt, TryFutureExt};
use workspace::Workspace; use workspace::{Workspace, client_side_decorations};
use zed_actions::assistant::InlineAssist; use zed_actions::assistant::InlineAssist;
use prompt_store::*; use prompt_store::*;
@ -110,15 +111,22 @@ pub fn open_rules_library(
cx.update(|cx| { cx.update(|cx| {
let app_id = ReleaseChannel::global(cx).app_id(); let app_id = ReleaseChannel::global(cx).app_id();
let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx); let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
Ok(val) if val == "server" => gpui::WindowDecorations::Server,
Ok(val) if val == "client" => gpui::WindowDecorations::Client,
_ => gpui::WindowDecorations::Client,
};
cx.open_window( cx.open_window(
WindowOptions { WindowOptions {
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: Some("Rules Library".into()), title: Some("Rules Library".into()),
appears_transparent: cfg!(target_os = "macos"), appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))), traffic_light_position: Some(point(px(9.0), px(9.0))),
}), }),
app_id: Some(app_id.to_owned()), app_id: Some(app_id.to_owned()),
window_bounds: Some(WindowBounds::Windowed(bounds)), window_bounds: Some(WindowBounds::Windowed(bounds)),
window_background: cx.theme().window_background_appearance(),
window_decorations: Some(window_decorations),
..Default::default() ..Default::default()
}, },
|window, cx| { |window, cx| {
@ -140,6 +148,7 @@ pub fn open_rules_library(
} }
pub struct RulesLibrary { pub struct RulesLibrary {
title_bar: Option<Entity<PlatformTitleBar>>,
store: Entity<PromptStore>, store: Entity<PromptStore>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
rule_editors: HashMap<PromptId, RuleEditor>, rule_editors: HashMap<PromptId, RuleEditor>,
@ -395,6 +404,11 @@ impl RulesLibrary {
picker picker
}); });
Self { Self {
title_bar: if !cfg!(target_os = "macos") {
Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar")))
} else {
None
},
store: store.clone(), store: store.clone(),
language_registry, language_registry,
rule_editors: HashMap::default(), rule_editors: HashMap::default(),
@ -1225,75 +1239,90 @@ impl Render for RulesLibrary {
let ui_font = theme::setup_ui_font(window, cx); let ui_font = theme::setup_ui_font(window, cx);
let theme = cx.theme().clone(); let theme = cx.theme().clone();
h_flex() client_side_decorations(
.id("rules-library") v_flex()
.key_context("PromptLibrary") .id("rules-library")
.on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx))) .key_context("PromptLibrary")
.on_action( .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
cx.listener(|this, &DeleteRule, window, cx| this.delete_active_rule(window, cx)), .on_action(
) cx.listener(|this, &DeleteRule, window, cx| {
.on_action(cx.listener(|this, &DuplicateRule, window, cx| { this.delete_active_rule(window, cx)
this.duplicate_active_rule(window, cx) }),
})) )
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| { .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
this.toggle_default_for_active_rule(window, cx) this.duplicate_active_rule(window, cx)
})) }))
.size_full() .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
.overflow_hidden() this.toggle_default_for_active_rule(window, cx)
.font(ui_font) }))
.text_color(theme.colors().text) .size_full()
.child(self.render_rule_list(cx)) .overflow_hidden()
.map(|el| { .font(ui_font)
if self.store.read(cx).prompt_count() == 0 { .text_color(theme.colors().text)
el.child( .children(self.title_bar.clone())
v_flex() .child(
.w_2_3() h_flex()
.h_full() .flex_1()
.items_center() .child(self.render_rule_list(cx))
.justify_center() .map(|el| {
.gap_4() if self.store.read(cx).prompt_count() == 0 {
.bg(cx.theme().colors().editor_background) el.child(
.child( v_flex()
h_flex() .w_2_3()
.gap_2() .h_full()
.child( .items_center()
Icon::new(IconName::Book) .justify_center()
.size(IconSize::Medium) .gap_4()
.color(Color::Muted), .bg(cx.theme().colors().editor_background)
) .child(
.child( h_flex()
Label::new("No rules yet") .gap_2()
.size(LabelSize::Large) .child(
.color(Color::Muted), Icon::new(IconName::Book)
), .size(IconSize::Medium)
) .color(Color::Muted),
.child( )
h_flex() .child(
.child(h_flex()) Label::new("No rules yet")
.child( .size(LabelSize::Large)
v_flex() .color(Color::Muted),
.gap_1() ),
.child(Label::new("Create your first rule:")) )
.child( .child(
Button::new("create-rule", "New Rule") h_flex()
.full_width() .child(h_flex())
.key_binding(KeyBinding::for_action( .child(
&NewRule, window, cx, v_flex()
)) .gap_1()
.on_click(|_, window, cx| { .child(Label::new(
window.dispatch_action( "Create your first rule:",
NewRule.boxed_clone(), ))
cx, .child(
) Button::new("create-rule", "New Rule")
}), .full_width()
), .key_binding(
) KeyBinding::for_action(
.child(h_flex()), &NewRule, window, cx,
), ),
) )
} else { .on_click(|_, window, cx| {
el.child(self.render_active_rule(cx)) window.dispatch_action(
} NewRule.boxed_clone(),
}) cx,
)
}),
),
)
.child(h_flex()),
),
)
} else {
el.child(self.render_active_rule(cx))
}
}),
),
window,
cx,
)
} }
} }

View file

@ -0,0 +1,169 @@
use gpui::{
AnyElement, Context, Decorations, InteractiveElement, IntoElement, MouseButton, ParentElement,
Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
};
use smallvec::SmallVec;
use std::mem;
use ui::prelude::*;
use crate::platforms::{platform_linux, platform_mac, platform_windows};
pub struct PlatformTitleBar {
id: ElementId,
platform_style: PlatformStyle,
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
}
impl PlatformTitleBar {
pub fn new(id: impl Into<ElementId>) -> Self {
let platform_style = PlatformStyle::platform();
Self {
id: id.into(),
platform_style,
children: SmallVec::new(),
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(_window: &mut Window) -> Pixels {
// todo(windows) instead of hard coded size report the actual size to the Windows platform API
px(32.)
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl Render for PlatformTitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
let height = Self::height(window);
let titlebar_color = if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().colors().title_bar_background
} else {
cx.theme().colors().title_bar_inactive_background
}
} else {
cx.theme().colors().title_bar_background
};
let close_action = Box::new(workspace::CloseWindow);
let children = mem::take(&mut self.children);
h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
.h(height)
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
} else if self.platform_style == PlatformStyle::Mac {
this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
}
})
.map(|el| match decorations {
Decorations::Server => el,
Decorations::Client { tiling, .. } => el
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
// this border is to avoid a transparent gap in the rounded corners
.mt(px(-1.))
.border(px(1.))
.border_color(titlebar_color),
})
.bg(titlebar_color)
.content_stretch()
.child(
div()
.id(self.id.clone())
.flex()
.flex_row()
.items_center()
.justify_between()
.w_full()
// Note: On Windows the title bar behavior is handled by the platform implementation.
.when(self.platform_style == PlatformStyle::Mac, |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.titlebar_double_click();
}
})
})
.when(self.platform_style == PlatformStyle::Linux, |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.zoom_window();
}
})
})
.children(children),
)
.when(!window.is_fullscreen(), |title_bar| {
match self.platform_style {
PlatformStyle::Mac => title_bar,
PlatformStyle::Linux => {
if matches!(decorations, Decorations::Client { .. }) {
title_bar
.child(platform_linux::LinuxWindowControls::new(close_action))
.when(supported_controls.window_menu, |titlebar| {
titlebar
.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
title_bar
}
}
PlatformStyle::Windows => {
title_bar.child(platform_windows::WindowsWindowControls::new(height))
}
}
})
}
}
impl ParentElement for PlatformTitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}

View file

@ -1,34 +1,32 @@
mod application_menu; mod application_menu;
mod collab; mod collab;
mod onboarding_banner; mod onboarding_banner;
pub mod platform_title_bar;
mod platforms; mod platforms;
mod title_bar_settings; mod title_bar_settings;
#[cfg(feature = "stories")] #[cfg(feature = "stories")]
mod stories; mod stories;
use crate::application_menu::ApplicationMenu; use crate::{application_menu::ApplicationMenu, platform_title_bar::PlatformTitleBar};
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
use crate::application_menu::{ use crate::application_menu::{
ActivateDirection, ActivateMenuLeft, ActivateMenuRight, OpenApplicationMenu, ActivateDirection, ActivateMenuLeft, ActivateMenuRight, OpenApplicationMenu,
}; };
use crate::platforms::{platform_linux, platform_mac, platform_windows};
use auto_update::AutoUpdateStatus; use auto_update::AutoUpdateStatus;
use call::ActiveCall; use call::ActiveCall;
use client::{Client, UserStore}; use client::{Client, UserStore};
use gpui::{ use gpui::{
Action, AnyElement, App, Context, Corner, Decorations, Element, Entity, InteractiveElement, Action, AnyElement, App, Context, Corner, Element, Entity, InteractiveElement, IntoElement,
Interactivity, IntoElement, MouseButton, ParentElement, Render, Stateful, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, WindowControlArea, WeakEntity, Window, actions, div,
actions, div, px,
}; };
use onboarding_banner::OnboardingBanner; use onboarding_banner::OnboardingBanner;
use project::Project; use project::Project;
use rpc::proto; use rpc::proto;
use settings::Settings as _; use settings::Settings as _;
use smallvec::SmallVec;
use std::sync::Arc; use std::sync::Arc;
use theme::ActiveTheme; use theme::ActiveTheme;
use title_bar_settings::TitleBarSettings; use title_bar_settings::TitleBarSettings;
@ -111,14 +109,11 @@ pub fn init(cx: &mut App) {
} }
pub struct TitleBar { pub struct TitleBar {
platform_style: PlatformStyle, platform_titlebar: Entity<PlatformTitleBar>,
content: Stateful<Div>,
children: SmallVec<[AnyElement; 2]>,
project: Entity<Project>, project: Entity<Project>,
user_store: Entity<UserStore>, user_store: Entity<UserStore>,
client: Arc<Client>, client: Arc<Client>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
should_move: bool,
application_menu: Option<Entity<ApplicationMenu>>, application_menu: Option<Entity<ApplicationMenu>>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
banner: Entity<OnboardingBanner>, banner: Entity<OnboardingBanner>,
@ -127,173 +122,69 @@ pub struct TitleBar {
impl Render for TitleBar { impl Render for TitleBar {
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 title_bar_settings = *TitleBarSettings::get_global(cx); let title_bar_settings = *TitleBarSettings::get_global(cx);
let close_action = Box::new(workspace::CloseWindow);
let height = Self::height(window);
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
let titlebar_color = if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().colors().title_bar_background
} else {
cx.theme().colors().title_bar_inactive_background
}
} else {
cx.theme().colors().title_bar_background
};
h_flex() let mut children = Vec::new();
.id("titlebar")
.window_control_area(WindowControlArea::Drag) children.push(
.w_full() h_flex()
.h(height) .gap_1()
.map(|this| { .map(|title_bar| {
if window.is_fullscreen() { let mut render_project_items = title_bar_settings.show_branch_name
this.pl_2() || title_bar_settings.show_project_items;
} else if self.platform_style == PlatformStyle::Mac { title_bar
this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING)) .when_some(self.application_menu.clone(), |title_bar, menu| {
} else { render_project_items &= !menu.read(cx).all_menus_shown();
this.pl_2() title_bar.child(menu)
}
})
.map(|el| match decorations {
Decorations::Server => el,
Decorations::Client { tiling, .. } => el
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
// this border is to avoid a transparent gap in the rounded corners
.mt(px(-1.))
.border(px(1.))
.border_color(titlebar_color),
})
.bg(titlebar_color)
.content_stretch()
.child(
div()
.id("titlebar-content")
.flex()
.flex_row()
.items_center()
.justify_between()
.w_full()
// Note: On Windows the title bar behavior is handled by the platform implementation.
.when(self.platform_style == PlatformStyle::Mac, |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.titlebar_double_click();
}
}) })
}) .when(render_project_items, |title_bar| {
.when(self.platform_style == PlatformStyle::Linux, |this| {
this.on_click(|event, window, _| {
if event.up.click_count == 2 {
window.zoom_window();
}
})
})
.child(
h_flex()
.gap_1()
.map(|title_bar| {
let mut render_project_items = title_bar_settings.show_branch_name
|| title_bar_settings.show_project_items;
title_bar
.when_some(self.application_menu.clone(), |title_bar, menu| {
render_project_items &= !menu.read(cx).all_menus_shown();
title_bar.child(menu)
})
.when(render_project_items, |title_bar| {
title_bar
.when(
title_bar_settings.show_project_items,
|title_bar| {
title_bar
.children(self.render_project_host(cx))
.child(self.render_project_name(cx))
},
)
.when(
title_bar_settings.show_branch_name,
|title_bar| {
title_bar
.children(self.render_project_branch(cx))
},
)
})
})
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()),
)
.child(self.render_collaborator_list(window, cx))
.when(title_bar_settings.show_onboarding_banner, |title_bar| {
title_bar.child(self.banner.clone())
})
.child(
h_flex()
.gap_1()
.pr_1()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.children(self.render_call_controls(window, cx))
.map(|el| {
let status = self.client.status();
let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
el.child(self.render_user_menu_button(cx))
} else {
el.children(self.render_connection_status(status, cx))
.when(TitleBarSettings::get_global(cx).show_sign_in, |el| {
el.child(self.render_sign_in_button(cx))
})
.child(self.render_user_menu_button(cx))
}
}),
),
)
.when(!window.is_fullscreen(), |title_bar| {
match self.platform_style {
PlatformStyle::Mac => title_bar,
PlatformStyle::Linux => {
if matches!(decorations, Decorations::Client { .. }) {
title_bar title_bar
.child(platform_linux::LinuxWindowControls::new(close_action)) .when(title_bar_settings.show_project_items, |title_bar| {
.when(supported_controls.window_menu, |titlebar| { title_bar
titlebar.on_mouse_down( .children(self.render_project_host(cx))
gpui::MouseButton::Right, .child(self.render_project_name(cx))
move |ev, window, _| window.show_window_menu(ev.position),
)
}) })
.on_mouse_move(cx.listener(move |this, _ev, window, _| { .when(title_bar_settings.show_branch_name, |title_bar| {
if this.should_move { title_bar.children(self.render_project_branch(cx))
this.should_move = false; })
window.start_window_move(); })
} })
})) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| { .into_any_element(),
this.should_move = false; );
}))
.on_mouse_up( children.push(self.render_collaborator_list(window, cx).into_any_element());
gpui::MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| { if title_bar_settings.show_onboarding_banner {
this.should_move = false; children.push(self.banner.clone().into_any_element())
}), }
)
.on_mouse_down( children.push(
gpui::MouseButton::Left, h_flex()
cx.listener(move |this, _ev, _window, _cx| { .gap_1()
this.should_move = true; .pr_1()
}), .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
) .children(self.render_call_controls(window, cx))
} else { .map(|el| {
title_bar let status = self.client.status();
} let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
el.child(self.render_user_menu_button(cx))
} else {
el.children(self.render_connection_status(status, cx))
.when(TitleBarSettings::get_global(cx).show_sign_in, |el| {
el.child(self.render_sign_in_button(cx))
})
.child(self.render_user_menu_button(cx))
} }
PlatformStyle::Windows => { })
title_bar.child(platform_windows::WindowsWindowControls::new(height)) .into_any_element(),
} );
}
}) self.platform_titlebar.update(cx, |this, _| {
this.set_children(children);
});
self.platform_titlebar.clone().into_any_element()
} }
} }
@ -345,13 +236,12 @@ impl TitleBar {
) )
}); });
let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id));
Self { Self {
platform_style, platform_titlebar,
content: div().id(id.into()),
children: SmallVec::new(),
application_menu, application_menu,
workspace: workspace.weak_handle(), workspace: workspace.weak_handle(),
should_move: false,
project, project,
user_store, user_store,
client, client,
@ -360,23 +250,6 @@ impl TitleBar {
} }
} }
#[cfg(not(target_os = "windows"))]
pub fn height(window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(_window: &mut Window) -> Pixels {
// todo(windows) instead of hard coded size report the actual size to the Windows platform API
px(32.)
}
/// Sets the platform style.
pub fn platform_style(mut self, style: PlatformStyle) -> Self {
self.platform_style = style;
self
}
fn render_ssh_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> { fn render_ssh_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let options = self.project.read(cx).ssh_connection_options(cx)?; let options = self.project.read(cx).ssh_connection_options(cx)?;
let host: SharedString = options.connection_string().into(); let host: SharedString = options.connection_string().into();
@ -796,17 +669,3 @@ impl TitleBar {
} }
} }
} }
impl InteractiveElement for TitleBar {
fn interactivity(&mut self) -> &mut Interactivity {
self.content.interactivity()
}
}
impl StatefulInteractiveElement for TitleBar {}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}