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