diff --git a/Cargo.lock b/Cargo.lock index 50d017b..7c6d5e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "smol", "state", "theme", + "titlebar", "tracing-subscriber", "ui", ] @@ -6570,6 +6571,21 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "titlebar" +version = "0.3.0" +dependencies = [ + "anyhow", + "common", + "gpui", + "linicon", + "log", + "smallvec", + "theme", + "ui", + "windows 0.61.3", +] + [[package]] name = "tokio" version = "1.49.0" diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 59a81e3..d110f69 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -29,6 +29,7 @@ icons = [ [dependencies] assets = { path = "../assets" } ui = { path = "../ui" } +titlebar = { path = "../titlebar" } dock = { path = "../dock" } theme = { path = "../theme" } common = { path = "../common" } diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 094fc71..dc6e71f 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -13,6 +13,7 @@ use gpui::{ use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; use theme::{ActiveTheme, Theme, ThemeRegistry}; +use titlebar::TitleBar; use ui::button::{Button, ButtonVariants}; use ui::modal::ModalButtonProps; use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; @@ -28,6 +29,9 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { #[derive(Debug)] pub struct Workspace { + /// App's Title Bar + titlebar: Entity, + /// App's Dock Area dock: Entity, @@ -38,6 +42,7 @@ pub struct Workspace { impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); + let titlebar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); @@ -100,6 +105,7 @@ impl Workspace { }); Self { + titlebar, dock, _subscriptions: subscriptions, } @@ -278,8 +284,14 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_keyring)) .relative() .size_full() - // Dock - .child(self.dock.clone()) + .child( + v_flex() + .size_full() + // Title Bar + .child(self.titlebar.clone()) + // Dock + .child(self.dock.clone()), + ) // Notifications .children(notification_layer) // Modals diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index c960312..55a72a0 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -89,6 +89,10 @@ pub struct ThemeColors { pub drop_target_background: Hsla, pub cursor: Hsla, pub selection: Hsla, + + // System + pub titlebar: Hsla, + pub titlebar_inactive: Hsla, } /// The default colors for the theme. @@ -171,6 +175,9 @@ impl ThemeColors { drop_target_background: brand().dark_alpha().step_2(), cursor: hsl(200., 100., 50.), selection: hsl(200., 100., 50.).alpha(0.25), + + titlebar: neutral().dark().step_2(), + titlebar_inactive: neutral().dark().step_3(), } } } diff --git a/crates/titlebar/Cargo.toml b/crates/titlebar/Cargo.toml new file mode 100644 index 0000000..1ddf8e1 --- /dev/null +++ b/crates/titlebar/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "titlebar" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +theme = { path = "../theme" } +ui = { path = "../ui" } + +gpui.workspace = true +smallvec.workspace = true +anyhow.workspace = true +log.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = ["Wdk_System_SystemServices"] } + +[target.'cfg(target_os = "linux")'.dependencies] +linicon = "2.3.0" diff --git a/crates/titlebar/src/lib.rs b/crates/titlebar/src/lib.rs new file mode 100644 index 0000000..5e6c3b6 --- /dev/null +++ b/crates/titlebar/src/lib.rs @@ -0,0 +1,181 @@ +use std::mem; + +use gpui::prelude::FluentBuilder; +#[cfg(target_os = "linux")] +use gpui::MouseButton; +use gpui::{ + div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, + ParentElement, Pixels, Render, StatefulInteractiveElement as _, Styled, Window, + WindowControlArea, +}; +use smallvec::{smallvec, SmallVec}; +use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING}; +use ui::h_flex; + +#[cfg(target_os = "linux")] +use crate::platforms::linux::LinuxWindowControls; +use crate::platforms::windows::WindowsWindowControls; + +mod platforms; + +pub struct TitleBar { + children: SmallVec<[AnyElement; 2]>, + should_move: bool, +} + +impl Default for TitleBar { + fn default() -> Self { + Self::new() + } +} + +impl TitleBar { + pub fn new() -> Self { + Self { + children: smallvec![], + 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 { + px(32.) + } + + pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { + if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if window.is_window_active() && !self.should_move { + cx.theme().titlebar + } else { + cx.theme().titlebar_inactive + } + } else { + cx.theme().titlebar + } + } + + pub fn set_children(&mut self, children: T) + where + T: IntoIterator, + { + self.children = children.into_iter().collect(); + } +} + +impl ParentElement for TitleBar { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Render for TitleBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + #[cfg(target_os = "linux")] + let supported_controls = window.window_controls(); + let decorations = window.window_decorations(); + let height = Self::height(window); + let color = self.title_bar_color(window, cx); + let children = mem::take(&mut self.children); + + h_flex() + .window_control_area(WindowControlArea::Drag) + .h(height) + .w_full() + .map(|this| { + if window.is_fullscreen() { + this.px_2() + } else if cx.theme().platform.is_mac() { + this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING)) + .pr_2() + .when(children.len() <= 1, |this| { + this.pr(px(platforms::mac::TRAFFIC_LIGHT_PADDING)) + }) + } else { + this.px_2() + } + }) + .map(|this| match decorations { + Decorations::Server => this, + Decorations::Client { tiling } => this + .when(!(tiling.top || tiling.right), |el| { + el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.top || tiling.left), |el| { + el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) + }), + }) + .bg(color) + .border_b_1() + .border_color(cx.theme().border) + .content_stretch() + .child( + div() + .id("title-bar") + .flex() + .flex_row() + .items_center() + .justify_between() + .w_full() + .when(cx.theme().platform.is_mac(), |this| { + this.on_click(|event, window, _| { + if event.click_count() == 2 { + window.titlebar_double_click(); + } + }) + }) + .when(cx.theme().platform.is_linux(), |this| { + this.on_click(|event, window, _| { + if event.click_count() == 2 { + window.zoom_window(); + } + }) + }) + .children(children), + ) + .when(!window.is_fullscreen(), |this| match cx.theme().platform { + PlatformKind::Linux => { + #[cfg(target_os = "linux")] + if matches!(decorations, Decorations::Client { .. }) { + this.child(LinuxWindowControls::new(None)) + .when(supported_controls.window_menu, |this| { + this.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 { + this + } + #[cfg(not(target_os = "linux"))] + this + } + PlatformKind::Windows => this.child(WindowsWindowControls::new(height)), + PlatformKind::Mac => this, + }) + } +} diff --git a/crates/titlebar/src/platforms/linux.rs b/crates/titlebar/src/platforms/linux.rs new file mode 100644 index 0000000..c0d2281 --- /dev/null +++ b/crates/titlebar/src/platforms/linux.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +use gpui::prelude::FluentBuilder; +use gpui::{ + svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, +}; +use linicon::{lookup_icon, IconType}; +use theme::ActiveTheme; +use ui::{h_flex, Icon, IconName, Sizable}; + +#[derive(IntoElement)] +pub struct LinuxWindowControls { + close_window_action: Option>, +} + +impl LinuxWindowControls { + pub fn new(close_window_action: Option>) -> Self { + Self { + close_window_action, + } + } +} + +impl RenderOnce for LinuxWindowControls { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + h_flex() + .id("linux-window-controls") + .gap_2() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(WindowControl::new( + LinuxControl::Minimize, + IconName::WindowMinimize, + )) + .child({ + if window.is_maximized() { + WindowControl::new(LinuxControl::Restore, IconName::WindowRestore) + } else { + WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize) + } + }) + .child( + WindowControl::new(LinuxControl::Close, IconName::WindowClose) + .when_some(self.close_window_action, |this, close_action| { + this.close_action(close_action) + }), + ) + } +} + +#[derive(IntoElement)] +pub struct WindowControl { + kind: LinuxControl, + fallback: IconName, + close_action: Option>, +} + +impl WindowControl { + pub fn new(kind: LinuxControl, fallback: IconName) -> Self { + Self { + kind, + fallback, + close_action: None, + } + } + + pub fn close_action(mut self, action: Box) -> Self { + self.close_action = Some(action); + self + } + + pub fn is_gnome(&self) -> bool { + matches!(detect_desktop_environment(), DesktopEnvironment::Gnome) + } +} + +impl RenderOnce for WindowControl { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let is_gnome = self.is_gnome(); + + h_flex() + .id(self.kind.as_icon_name()) + .group("") + .justify_center() + .items_center() + .rounded_full() + .size_6() + .when(is_gnome, |this| { + this.bg(cx.theme().ghost_element_background_alt) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .active(|this| this.bg(cx.theme().ghost_element_active)) + }) + .map(|this| { + if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() { + this.child( + svg() + .external_path(SharedString::from(path)) + .size_4() + .text_color(cx.theme().text), + ) + } else { + this.child(Icon::new(self.fallback).small().text_color(cx.theme().text)) + } + }) + .on_mouse_move(|_, _window, cx| cx.stop_propagation()) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + match self.kind { + LinuxControl::Minimize => window.minimize_window(), + LinuxControl::Restore => window.zoom_window(), + LinuxControl::Maximize => window.zoom_window(), + LinuxControl::Close => cx.quit(), + } + }) + } +} + +static DE: OnceLock = OnceLock::new(); +static LINUX_CONTROLS: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DesktopEnvironment { + Gnome, + Kde, + Unknown, +} + +/// Detect the current desktop environment +pub fn detect_desktop_environment() -> &'static DesktopEnvironment { + DE.get_or_init(|| { + // Try to use environment variables first + if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") { + let desktop = output.to_lowercase(); + if desktop.contains("gnome") { + return DesktopEnvironment::Gnome; + } else if desktop.contains("kde") { + return DesktopEnvironment::Kde; + } + } + + // Fallback detection methods + if let Ok(output) = std::env::var("DESKTOP_SESSION") { + let session = output.to_lowercase(); + if session.contains("gnome") { + return DesktopEnvironment::Gnome; + } else if session.contains("kde") || session.contains("plasma") { + return DesktopEnvironment::Kde; + } + } + + DesktopEnvironment::Unknown + }) +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum LinuxControl { + Minimize, + Restore, + Maximize, + Close, +} + +impl LinuxControl { + pub fn as_icon_name(&self) -> &'static str { + match self { + LinuxControl::Close => "window-close", + LinuxControl::Minimize => "window-minimize", + LinuxControl::Maximize => "window-maximize", + LinuxControl::Restore => "window-restore", + } + } +} + +fn linux_controls() -> &'static HashMap> { + LINUX_CONTROLS.get_or_init(|| { + let mut icons = HashMap::new(); + icons.insert(LinuxControl::Close, None); + icons.insert(LinuxControl::Minimize, None); + icons.insert(LinuxControl::Maximize, None); + icons.insert(LinuxControl::Restore, None); + + let icon_names = [ + (LinuxControl::Close, vec!["window-close", "dialog-close"]), + ( + LinuxControl::Minimize, + vec!["window-minimize", "window-lower"], + ), + ( + LinuxControl::Maximize, + vec!["window-maximize", "window-expand"], + ), + ( + LinuxControl::Restore, + vec!["window-restore", "window-return"], + ), + ]; + + for (control, icon_names) in icon_names { + for icon_name in icon_names { + // Try GNOME-style naming first + let mut control_icon = lookup_icon(format!("{icon_name}-symbolic")) + .find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG)); + + // If not found, try KDE-style naming + if control_icon.is_none() { + control_icon = lookup_icon(icon_name) + .find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG)); + } + + if let Some(Ok(icon)) = control_icon { + icons + .entry(control) + .and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string())); + } + } + } + + icons + }) +} diff --git a/crates/titlebar/src/platforms/mac.rs b/crates/titlebar/src/platforms/mac.rs new file mode 100644 index 0000000..526e245 --- /dev/null +++ b/crates/titlebar/src/platforms/mac.rs @@ -0,0 +1,6 @@ +/// Use pixels here instead of a rem-based size because the macOS traffic +/// lights are a static size, and don't scale with the rest of the UI. +/// +/// Magic number: There is one extra pixel of padding on the left side due to +/// the 1px border around the window on macOS apps. +pub const TRAFFIC_LIGHT_PADDING: f32 = 80.; diff --git a/crates/titlebar/src/platforms/mod.rs b/crates/titlebar/src/platforms/mod.rs new file mode 100644 index 0000000..e0ff781 --- /dev/null +++ b/crates/titlebar/src/platforms/mod.rs @@ -0,0 +1,4 @@ +#[cfg(target_os = "linux")] +pub mod linux; +pub mod mac; +pub mod windows; diff --git a/crates/titlebar/src/platforms/windows.rs b/crates/titlebar/src/platforms/windows.rs new file mode 100644 index 0000000..f28180b --- /dev/null +++ b/crates/titlebar/src/platforms/windows.rs @@ -0,0 +1,147 @@ +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, + RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea, +}; +use theme::ActiveTheme; +use ui::h_flex; + +#[derive(IntoElement)] +pub struct WindowsWindowControls { + button_height: Pixels, +} + +impl WindowsWindowControls { + pub fn new(button_height: Pixels) -> Self { + Self { button_height } + } + + #[cfg(not(target_os = "windows"))] + fn get_font() -> &'static str { + "Segoe Fluent Icons" + } + + #[cfg(target_os = "windows")] + fn get_font() -> &'static str { + use windows::Wdk::System::SystemServices::RtlGetVersion; + + let mut version = unsafe { std::mem::zeroed() }; + let status = unsafe { RtlGetVersion(&mut version) }; + + if status.is_ok() && version.dwBuildNumber >= 22000 { + "Segoe Fluent Icons" + } else { + "Segoe MDL2 Assets" + } + } +} + +impl RenderOnce for WindowsWindowControls { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let close_button_hover_color = Rgba { + r: 232.0 / 255.0, + g: 17.0 / 255.0, + b: 32.0 / 255.0, + a: 1.0, + }; + + let button_hover_color = cx.theme().ghost_element_hover; + let button_active_color = cx.theme().ghost_element_active; + + div() + .id("windows-window-controls") + .font_family(Self::get_font()) + .flex() + .flex_row() + .justify_center() + .content_stretch() + .max_h(self.button_height) + .min_h(self.button_height) + .child(WindowsCaptionButton::new( + "minimize", + WindowsCaptionButtonIcon::Minimize, + button_hover_color, + button_active_color, + )) + .child(WindowsCaptionButton::new( + "maximize-or-restore", + if window.is_maximized() { + WindowsCaptionButtonIcon::Restore + } else { + WindowsCaptionButtonIcon::Maximize + }, + button_hover_color, + button_active_color, + )) + .child(WindowsCaptionButton::new( + "close", + WindowsCaptionButtonIcon::Close, + close_button_hover_color, + button_active_color, + )) + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +enum WindowsCaptionButtonIcon { + Minimize, + Restore, + Maximize, + Close, +} + +#[derive(IntoElement)] +struct WindowsCaptionButton { + id: ElementId, + icon: WindowsCaptionButtonIcon, + hover_background_color: Hsla, + active_background_color: Hsla, +} + +impl WindowsCaptionButton { + pub fn new( + id: impl Into, + icon: WindowsCaptionButtonIcon, + hover_background_color: impl Into, + active_background_color: impl Into, + ) -> Self { + Self { + id: id.into(), + icon, + hover_background_color: hover_background_color.into(), + active_background_color: active_background_color.into(), + } + } +} + +impl RenderOnce for WindowsCaptionButton { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .justify_center() + .content_center() + .occlude() + .w(px(36.)) + .h_full() + .text_size(px(10.0)) + .hover(|style| style.bg(self.hover_background_color)) + .active(|style| style.bg(self.active_background_color)) + .map(|this| match self.icon { + WindowsCaptionButtonIcon::Close => { + this.window_control_area(WindowControlArea::Close) + } + WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => { + this.window_control_area(WindowControlArea::Max) + } + WindowsCaptionButtonIcon::Minimize => { + this.window_control_area(WindowControlArea::Min) + } + }) + .child(match self.icon { + WindowsCaptionButtonIcon::Minimize => "\u{e921}", + WindowsCaptionButtonIcon::Restore => "\u{e923}", + WindowsCaptionButtonIcon::Maximize => "\u{e922}", + WindowsCaptionButtonIcon::Close => "\u{e8bb}", + }) + } +}