1use 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
40pub 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
64pub 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}