Skip to main content

liora_components/
window_frame.rs

1//! Native app-window frame helpers.
2//!
3//! Liora apps can use the system window frame or opt into a lightweight custom
4//! native GPUI title bar. This module only wraps GPUI window/frame primitives;
5//! it does not introduce a web/runtime layer.
6
7use crate::{Button, Space, Text};
8use gpui::{
9    AnyElement, App, Component, InteractiveElement, IntoElement, MouseButton, ParentElement,
10    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, WindowControlArea,
11    WindowDecorations, WindowOptions, div, point, prelude::*, px,
12};
13use liora_core::Config;
14use liora_icons_lucide::IconName;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum WindowFrameMode {
18    #[default]
19    System,
20    Custom,
21}
22
23impl WindowFrameMode {
24    pub fn is_custom(self) -> bool {
25        matches!(self, Self::Custom)
26    }
27
28    pub fn from_custom(custom: bool) -> Self {
29        if custom { Self::Custom } else { Self::System }
30    }
31
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::System => "System frame",
35            Self::Custom => "Custom frame",
36        }
37    }
38}
39
40/// Applies the GPUI window options required by the selected frame mode.
41///
42/// `System` keeps platform/server decorations. `Custom` requests a transparent
43/// titlebar on macOS/Windows and client-side decorations on Linux/Wayland.
44pub fn apply_window_frame_mode(mut options: WindowOptions, mode: WindowFrameMode) -> WindowOptions {
45    match mode {
46        WindowFrameMode::System => {
47            if let Some(titlebar) = options.titlebar.as_mut() {
48                titlebar.appears_transparent = false;
49                titlebar.traffic_light_position = None;
50            }
51            options.window_decorations = Some(WindowDecorations::Server);
52        }
53        WindowFrameMode::Custom => {
54            if let Some(titlebar) = options.titlebar.as_mut() {
55                titlebar.appears_transparent = true;
56                titlebar.traffic_light_position = Some(point(px(12.0), px(12.0)));
57            }
58            options.window_decorations = Some(WindowDecorations::Client);
59        }
60    }
61    options
62}
63
64/// Convenience control for switching between system and custom frames.
65pub fn frame_mode_switch_row(switch: impl IntoElement, mode: WindowFrameMode) -> impl IntoElement {
66    Space::new()
67        .gap_sm()
68        .child(Text::new("Frame"))
69        .child(switch)
70        .child(Text::new(mode.label()).size(px(12.0)))
71}
72
73pub struct AppWindowFrame {
74    title: SharedString,
75    subtitle: Option<SharedString>,
76    mode: WindowFrameMode,
77    actions: Vec<AnyElement>,
78    content: AnyElement,
79    on_close: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
80}
81
82impl AppWindowFrame {
83    pub fn new(title: impl Into<SharedString>, content: impl IntoElement) -> Self {
84        Self {
85            title: title.into(),
86            subtitle: None,
87            mode: WindowFrameMode::System,
88            actions: Vec::new(),
89            content: content.into_any_element(),
90            on_close: None,
91        }
92    }
93
94    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
95        self.subtitle = Some(subtitle.into());
96        self
97    }
98
99    pub fn mode(mut self, mode: WindowFrameMode) -> Self {
100        self.mode = mode;
101        self
102    }
103
104    pub fn action(mut self, action: impl IntoElement) -> Self {
105        self.actions.push(action.into_any_element());
106        self
107    }
108
109    pub fn actions(mut self, actions: impl IntoIterator<Item = impl IntoElement>) -> Self {
110        self.actions
111            .extend(actions.into_iter().map(IntoElement::into_any_element));
112        self
113    }
114
115    pub fn on_close(mut self, close: impl Fn(&mut Window, &mut App) + 'static) -> Self {
116        self.on_close = Some(Box::new(close));
117        self
118    }
119}
120
121impl RenderOnce for AppWindowFrame {
122    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
123        if !self.mode.is_custom() {
124            return self.content;
125        }
126
127        let theme = cx.global::<Config>().theme.clone();
128        let title = self.title.clone();
129        let subtitle = self.subtitle.clone();
130        let on_close = self.on_close;
131
132        div()
133            .size_full()
134            .flex()
135            .flex_col()
136            .bg(theme.neutral.body)
137            .child(
138                div()
139                    .id("liora-window-frame-titlebar")
140                    .h(px(46.0))
141                    .w_full()
142                    .flex()
143                    .items_center()
144                    .justify_between()
145                    .border_b_1()
146                    .border_color(theme.neutral.border)
147                    .bg(theme.neutral.card.opacity(0.96))
148                    .child(
149                        div()
150                            .id("liora-window-frame-drag-region")
151                            .window_control_area(WindowControlArea::Drag)
152                            .cursor_default()
153                            .h_full()
154                            .flex_1()
155                            .min_w_0()
156                            .flex()
157                            .items_center()
158                            .px_4()
159                            .on_mouse_down(MouseButton::Left, |_, window, cx| {
160                                window.start_window_move();
161                                cx.stop_propagation();
162                            })
163                            .on_click(|event, window, _| {
164                                if event.click_count() == 2 {
165                                    window.titlebar_double_click();
166                                }
167                            })
168                            .child(
169                                Space::new()
170                                    .vertical()
171                                    .gap_xs()
172                                    .child(Text::new(title).bold().size(px(13.0)))
173                                    .when_some(subtitle, |s, subtitle| {
174                                        s.child(Text::new(subtitle).size(px(11.0)))
175                                    }),
176                            ),
177                    )
178                    .child(
179                        Space::new()
180                            .gap_sm()
181                            .child(Text::new("Custom Frame").size(px(12.0)))
182                            .children(self.actions),
183                    )
184                    .child(window_controls(on_close, window, theme.clone())),
185            )
186            .child(div().flex_1().min_h_0().child(self.content))
187            .into_any_element()
188    }
189}
190
191impl IntoElement for AppWindowFrame {
192    type Element = Component<Self>;
193
194    fn into_element(self) -> Self::Element {
195        Component::new(self)
196    }
197}
198
199fn window_controls(
200    on_close: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
201    window: &mut Window,
202    theme: liora_theme::Theme,
203) -> impl IntoElement {
204    Space::new()
205        .gap_xs()
206        .child(frame_control_button(
207            "liora-window-frame-minimize",
208            IconName::Minus,
209            WindowControlArea::Min,
210            false,
211            theme.clone(),
212            |window, _| window.minimize_window(),
213        ))
214        .child(frame_control_button(
215            "liora-window-frame-maximize",
216            if window.is_maximized() {
217                IconName::Minimize2
218            } else {
219                IconName::Maximize2
220            },
221            WindowControlArea::Max,
222            false,
223            theme.clone(),
224            |window, _| window.zoom_window(),
225        ))
226        .child(frame_control_button(
227            "liora-window-frame-close",
228            IconName::X,
229            WindowControlArea::Close,
230            true,
231            theme.clone(),
232            move |window, cx| {
233                if let Some(close) = on_close.as_ref() {
234                    close(window, cx);
235                } else {
236                    window.remove_window();
237                }
238            },
239        ))
240        .into_any_element()
241}
242
243fn frame_control_button(
244    id: &'static str,
245    icon: IconName,
246    control_area: WindowControlArea,
247    danger: bool,
248    theme: liora_theme::Theme,
249    on_click: impl Fn(&mut Window, &mut App) + 'static,
250) -> impl IntoElement {
251    Button::new("")
252        .id(id)
253        .text()
254        .small()
255        .icon_only(icon)
256        .on_click(move |_, window, cx| on_click(window, cx))
257        .into_element()
258        .map(move |button| {
259            div()
260                .window_control_area(control_area)
261                .rounded(px(8.0))
262                .when(danger, |s| {
263                    s.hover(move |s| s.bg(theme.danger.base).text_color(theme.neutral.inverted))
264                })
265                .child(button)
266        })
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn frame_mode_tracks_custom_state_and_labels() {
275        assert!(!WindowFrameMode::System.is_custom());
276        assert!(WindowFrameMode::Custom.is_custom());
277        assert_eq!(WindowFrameMode::from_custom(false), WindowFrameMode::System);
278        assert_eq!(WindowFrameMode::from_custom(true), WindowFrameMode::Custom);
279        assert_eq!(WindowFrameMode::Custom.label(), "Custom frame");
280    }
281
282    #[test]
283    fn window_frame_options_switch_between_server_and_client_decorations() {
284        let custom = apply_window_frame_mode(WindowOptions::default(), WindowFrameMode::Custom);
285        assert!(
286            custom
287                .titlebar
288                .as_ref()
289                .is_some_and(|titlebar| titlebar.appears_transparent)
290        );
291        assert_eq!(custom.window_decorations, Some(WindowDecorations::Client));
292
293        let system = apply_window_frame_mode(custom, WindowFrameMode::System);
294        assert!(
295            system
296                .titlebar
297                .as_ref()
298                .is_some_and(|titlebar| !titlebar.appears_transparent)
299        );
300        assert_eq!(system.window_decorations, Some(WindowDecorations::Server));
301    }
302
303    #[test]
304    fn custom_window_frame_renders_native_window_control_areas() {
305        let source = include_str!("window_frame.rs");
306        assert!(source.contains("WindowControlArea::Drag"));
307        assert!(source.contains("WindowControlArea::Min"));
308        assert!(source.contains("WindowControlArea::Max"));
309        assert!(source.contains("WindowControlArea::Close"));
310        assert!(source.contains("theme.danger.base"));
311        assert!(source.contains("theme.neutral.inverted"));
312        assert!(source.contains("start_window_move"));
313        assert!(source.contains("titlebar_double_click"));
314    }
315}