Skip to main content

fluent_app/
title_bar.rs

1use fluent_core::{Theme, ThemeProvider as _};
2use gpui::{
3    div, prelude::*, px, svg, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent,
4    Render, SharedString, Window,
5};
6
7use crate::window_title::WindowTitle;
8
9/// Pixel threshold below which two left-button presses on the title bar are
10/// treated as a double-click that maximizes / restores the window.
11const DOUBLE_CLICK_WINDOW_MS: u64 = 350;
12
13/// A custom frameless title bar that replaces the OS-provided one.
14///
15/// Draggable via `window.start_window_move()`. Renders the app title and
16/// window controls (min/max/close) on the right. Enable frameless mode with
17/// `TitlebarOptions { appears_transparent: true }` + `WindowDecorations::Client`.
18pub struct TitleBar {
19    pub title: SharedString,
20    pub icon: Option<SharedString>,
21    pub show_controls: bool,
22    follow_window_title: bool,
23    last_press_ms: u64,
24}
25
26impl TitleBar {
27    pub fn new(cx: &mut Context<Self>, title: impl Into<SharedString>) -> Self {
28        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
29        cx.observe_global::<WindowTitle>(|_, cx| cx.notify())
30            .detach();
31        Self {
32            title: title.into(),
33            icon: None,
34            show_controls: true,
35            follow_window_title: false,
36            last_press_ms: 0,
37        }
38    }
39
40    pub fn from_window_title(cx: &mut Context<Self>) -> Self {
41        let title = cx
42            .try_global::<WindowTitle>()
43            .map(|title| title.title().clone())
44            .unwrap_or_default();
45
46        Self::new(cx, title).follow_window_title(true)
47    }
48
49    pub fn set_title(&mut self, title: impl Into<SharedString>, cx: &mut Context<Self>) {
50        self.title = title.into();
51        self.follow_window_title = false;
52        cx.notify();
53    }
54
55    pub fn show_controls(mut self, show: bool) -> Self {
56        self.show_controls = show;
57        self
58    }
59
60    pub fn follow_window_title(mut self, follow: bool) -> Self {
61        self.follow_window_title = follow;
62        self
63    }
64
65    /// Set a leading 16×16 SVG icon (typically the app logo) shown left of
66    /// the title. Renders in `on_neutral_accent`.
67    pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
68        self.icon = Some(path.into());
69        self
70    }
71}
72
73impl Render for TitleBar {
74    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
75        let theme = cx.theme();
76        let colors = theme.colors.clone();
77        let spacing = theme.spacing;
78        let typography = theme.typography;
79
80        let (title, status) = if self.follow_window_title {
81            cx.try_global::<WindowTitle>()
82                .map(|window_title| (window_title.title().clone(), window_title.status().cloned()))
83                .unwrap_or_else(|| (self.title.clone(), None))
84        } else {
85            (self.title.clone(), None)
86        };
87        let show_controls = self.show_controls;
88
89        let bar_bg = colors.surface_dim;
90        let fg = colors.on_neutral;
91        let ctrl_hover = colors.subtle_hover;
92        let close_hover: gpui::Hsla = gpui::rgb(0xC42B1C).into();
93
94        // Drag: left-button press starts window move; consecutive presses
95        // within DOUBLE_CLICK_WINDOW_MS toggle maximize / restore instead.
96        let drag_handler = cx.listener(
97            |this: &mut TitleBar, _: &MouseDownEvent, window: &mut Window, _| {
98                let now = std::time::SystemTime::now()
99                    .duration_since(std::time::UNIX_EPOCH)
100                    .map(|d| d.as_millis() as u64)
101                    .unwrap_or(0);
102                if now.saturating_sub(this.last_press_ms) < DOUBLE_CLICK_WINDOW_MS {
103                    this.last_press_ms = 0;
104                    window.zoom_window();
105                    return;
106                }
107                this.last_press_ms = now;
108                window.start_window_move();
109            },
110        );
111
112        let min_handler =
113            cx.listener(|_: &mut TitleBar, _: &ClickEvent, window: &mut Window, _| {
114                window.minimize_window();
115            });
116        let max_handler =
117            cx.listener(|_: &mut TitleBar, _: &ClickEvent, window: &mut Window, _| {
118                window.zoom_window();
119            });
120        let close_handler =
121            cx.listener(|_: &mut TitleBar, _: &ClickEvent, window: &mut Window, _| {
122                window.remove_window();
123            });
124
125        // The drag area is only the title text, NOT the window control buttons.
126        // Putting on_mouse_down on the full bar would intercept clicks on min/max/close.
127        let icon = self.icon.clone();
128        let accent_fg = colors.on_neutral_accent;
129        let mut title_area = div()
130            .flex_1()
131            .h_full()
132            .flex()
133            .items_center()
134            .gap(px(spacing.sm))
135            .pl(px(spacing.md))
136            .text_size(px(typography.caption.size))
137            .text_color(fg)
138            .on_mouse_down(MouseButton::Left, drag_handler);
139        if let Some(path) = icon {
140            title_area = title_area.child(svg().path(path).size(px(16.0)).text_color(accent_fg));
141        }
142        let mut title_area = title_area.child(title);
143        if let Some(status) = status {
144            title_area = title_area.child(
145                div()
146                    .text_size(px(typography.caption.size))
147                    .text_color(colors.on_subtle)
148                    .child(status),
149            );
150        }
151
152        let bar = div()
153            .flex()
154            .flex_row()
155            .h(px(36.0))
156            .bg(bar_bg)
157            .child(title_area);
158
159        if !show_controls {
160            return bar;
161        }
162
163        // Controls container fills full bar height so hover fills the entire button box.
164        bar.child(
165            div()
166                .flex()
167                .flex_row()
168                .h_full()
169                .child(
170                    div()
171                        .id("titlebar-min")
172                        .w(px(46.0))
173                        .h_full()
174                        .flex()
175                        .items_center()
176                        .justify_center()
177                        .cursor_pointer()
178                        .hover(move |s| s.bg(ctrl_hover))
179                        .on_click(min_handler)
180                        .child(
181                            svg()
182                                .path("icons/minimize.svg")
183                                .size(px(10.0))
184                                .text_color(fg),
185                        ),
186                )
187                .child(
188                    div()
189                        .id("titlebar-max")
190                        .w(px(46.0))
191                        .h_full()
192                        .flex()
193                        .items_center()
194                        .justify_center()
195                        .cursor_pointer()
196                        .hover(move |s| s.bg(ctrl_hover))
197                        .on_click(max_handler)
198                        .child(
199                            svg()
200                                .path("icons/maximize.svg")
201                                .size(px(10.0))
202                                .text_color(fg),
203                        ),
204                )
205                .child(
206                    div()
207                        .id("titlebar-close")
208                        .w(px(46.0))
209                        .h_full()
210                        .flex()
211                        .items_center()
212                        .justify_center()
213                        .cursor_pointer()
214                        .hover(move |s| s.bg(close_hover))
215                        .on_click(close_handler)
216                        .child(
217                            svg()
218                                .path("icons/dismiss.svg")
219                                .size(px(10.0))
220                                .text_color(fg),
221                        ),
222                ),
223        )
224    }
225}