Skip to main content

xgpui_shell/
lib.rs

1//! Experimental desktop shell with app-composed title bars and optional Linux client frames.
2//! macOS retains native traffic lights; apps supply their own title-bar content and look.
3use gpui::{prelude::*, *};
4mod app_titlebar;
5mod typography;
6pub use app_titlebar::ShellDragRegion;
7pub use typography::ShellTypography;
8
9use frame::*;
10use std::{cell::RefCell, collections::HashMap};
11
12fn activation_key(event: &KeyDownEvent) -> bool {
13    matches!(event.keystroke.key.as_str(), "enter" | "space")
14        && !event.keystroke.modifiers.control
15        && !event.keystroke.modifiers.alt
16        && !event.keystroke.modifiers.platform
17        && !event.is_held
18}
19
20thread_local! {
21    // Platform title reads are macOS-only. Keep the shell chrome's title locally.
22    static SHELL_TITLES: RefCell<HashMap<WindowId, String>> = RefCell::new(HashMap::new());
23}
24pub trait ShellWindowTitle {
25    fn set_shell_title(&mut self, title: &str);
26    fn shell_title(&self) -> String;
27}
28impl ShellWindowTitle for Window {
29    fn set_shell_title(&mut self, title: &str) {
30        self.set_window_title(title);
31        SHELL_TITLES.with(|titles| {
32            titles
33                .borrow_mut()
34                .insert(self.window_handle().window_id(), title.to_owned())
35        });
36        self.refresh();
37    }
38    fn shell_title(&self) -> String {
39        SHELL_TITLES
40            .with(|titles| {
41                titles
42                    .borrow()
43                    .get(&self.window_handle().window_id())
44                    .cloned()
45            })
46            .filter(|title| !title.trim().is_empty())
47            .unwrap_or_else(|| "XGPUI".to_owned())
48    }
49}
50
51/// Open an application with compositor-provided chrome or a shared client-side frame.
52pub trait ShellApp {
53    fn open_shell_window<V: Render + 'static>(
54        &mut self,
55        options: WindowOptions,
56        shell: ShellOptions,
57        build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
58    ) -> anyhow::Result<WindowHandle<ShellWindow>>;
59    /// Supply an app-owned title-bar view and a separate content view. Native macOS
60    /// traffic lights remain native; client-frame window controls remain shell-owned.
61    /// Compose arbitrary app controls and use ShellDragRegion only for drag surfaces.
62    fn open_shell_window_with_titlebar<H: Render + 'static, V: Render + 'static>(
63        &mut self,
64        options: WindowOptions,
65        shell: ShellOptions,
66        build: impl FnOnce(&mut Window, &mut App) -> (Entity<H>, Entity<V>),
67    ) -> anyhow::Result<WindowHandle<ShellWindow>>;
68}
69impl ShellApp for App {
70    fn open_shell_window<V: Render + 'static>(
71        &mut self,
72        options: WindowOptions,
73        shell: ShellOptions,
74        build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
75    ) -> anyhow::Result<WindowHandle<ShellWindow>> {
76        open_shell(self, options, shell, |window, cx| {
77            (None, build(window, cx).into())
78        })
79    }
80
81    fn open_shell_window_with_titlebar<H: Render + 'static, V: Render + 'static>(
82        &mut self,
83        mut options: WindowOptions,
84        shell: ShellOptions,
85        build: impl FnOnce(&mut Window, &mut App) -> (Entity<H>, Entity<V>),
86    ) -> anyhow::Result<WindowHandle<ShellWindow>> {
87        app_titlebar::configure_window(&mut options);
88        open_shell(self, options, shell, |window, cx| {
89            let (titlebar, content) = build(window, cx);
90            (Some(titlebar.into()), content.into())
91        })
92    }
93}
94
95fn open_shell(
96    app: &mut App,
97    options: WindowOptions,
98    shell: ShellOptions,
99    build: impl FnOnce(&mut Window, &mut App) -> (Option<AnyView>, AnyView),
100) -> anyhow::Result<WindowHandle<ShellWindow>> {
101    let show_chrome = options.show;
102    let initial_title = options
103        .titlebar
104        .as_ref()
105        .and_then(|titlebar| titlebar.title.clone());
106    app.open_window(options, |window, cx| {
107        if let Some(title) = initial_title {
108            window.set_shell_title(&title);
109        }
110        let (titlebar, content) = build(window, cx);
111        cx.new(|cx| {
112            window
113                .observe_button_layout_changed(|window, _| window.refresh())
114                .detach();
115            window
116                .observe_window_appearance(|window, _| window.refresh())
117                .detach();
118            cx.observe_window_activation(window, |_, window, _| window.refresh())
119                .detach();
120            cx.observe_window_bounds(window, |_, window, _| window.refresh())
121                .detach();
122            let shell_entity = cx.entity().downgrade();
123            let preferences_subscription = cx.observe_desktop_preferences(move |app| {
124                let _ = shell_entity.update(app, |_, cx| cx.notify());
125            });
126            ShellWindow {
127                _preferences_subscription: preferences_subscription,
128                shell,
129                content,
130                titlebar,
131                show_chrome,
132                window_id: window.window_handle().window_id(),
133            }
134        })
135    })
136}
137
138pub struct ShellWindow {
139    _preferences_subscription: Subscription,
140    shell: ShellOptions,
141    content: AnyView,
142    titlebar: Option<AnyView>,
143    show_chrome: bool,
144    window_id: WindowId,
145}
146impl ShellWindow {
147    /// Replace application-supplied overrides and redraw this window.
148    pub fn set_options(&mut self, options: ShellOptions, cx: &mut Context<Self>) {
149        self.shell = options;
150        cx.notify();
151    }
152}
153impl Drop for ShellWindow {
154    fn drop(&mut self) {
155        let _ = SHELL_TITLES.try_with(|titles| titles.borrow_mut().remove(&self.window_id));
156    }
157}
158impl Render for ShellWindow {
159    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
160        let content = self.content.clone();
161        #[cfg(any(test, target_os = "linux", target_os = "freebsd"))]
162        if self.show_chrome
163            && matches!(
164                cx.platform_info().os,
165                OperatingSystem::Linux | OperatingSystem::FreeBsd
166            )
167            && !window.is_fullscreen()
168            && let Decorations::Client { .. } = window.window_decorations()
169        {
170            let state = ClientFrameState::for_window(&self.shell, window, cx);
171            window.set_client_inset(if state.maximized { px(0.) } else { px(5.) });
172            return render_client_frame(
173                state,
174                content.into_any_element(),
175                self.titlebar.clone().map(IntoElement::into_any_element),
176                true,
177            );
178        }
179        // Clear any prior client frame when switching to server chrome/fullscreen.
180        window.set_client_inset(px(0.));
181        if self.show_chrome
182            && let Some(titlebar) = self.titlebar.clone()
183        {
184            let mut state = ClientFrameState::for_window(&self.shell, window, cx);
185            let mac_native = cx.platform_info().os == OperatingSystem::Mac;
186            let custom_windows = cx.platform_info().os == OperatingSystem::Windows;
187            if mac_native || !custom_windows || window.is_fullscreen() {
188                state.button_layout = WindowButtonLayout {
189                    left: [None; 3],
190                    right: [None; 3],
191                };
192            }
193            #[cfg(target_os = "macos")]
194            if mac_native && !window.is_fullscreen() {
195                let height = state
196                    .preferences
197                    .resolved_look(state.appearance, state.active)
198                    .titlebar_height;
199                window.set_traffic_light_position(point(px(9.), px((height - 16.).max(0.) / 2.)));
200            }
201            let header = render_titlebar(
202                &state,
203                Some(titlebar.into_any_element()),
204                true,
205                mac_native && !window.is_fullscreen(),
206            );
207            return div()
208                .size_full()
209                .flex()
210                .flex_col()
211                .child(header)
212                .child(div().flex_1().min_h_0().child(content))
213                .into_any_element();
214        }
215        content.into_any_element()
216    }
217}
218
219struct ClientFrameState {
220    preferences: ResolvedShellPreferences,
221    appearance: WindowAppearance,
222    active: bool,
223    maximized: bool,
224    tiling: Tiling,
225    controls: WindowControls,
226    resizable: bool,
227    button_layout: WindowButtonLayout,
228    title: SharedString,
229    preview_focus: bool,
230    preview_hover: bool,
231}
232
233impl ClientFrameState {
234    fn for_window(options: &ShellOptions, window: &Window, cx: &App) -> Self {
235        let tiling = match window.window_decorations() {
236            Decorations::Client { tiling } => tiling,
237            Decorations::Server => Tiling::default(),
238        };
239        let mut controls = window.window_controls();
240        controls.minimize &= window.is_minimizable();
241        controls.maximize &= window.is_resizable();
242        Self {
243            preferences: options.resolved_preferences(&cx.desktop_preferences()),
244            appearance: window.appearance(),
245            active: window.is_window_active(),
246            maximized: window.is_maximized(),
247            tiling,
248            controls,
249            resizable: window.is_resizable(),
250            button_layout: cx.button_layout().unwrap_or_else(fallback_button_layout),
251            title: window.shell_title().into(),
252            preview_focus: false,
253            preview_hover: false,
254        }
255    }
256}
257
258fn fallback_button_layout() -> WindowButtonLayout {
259    WindowButtonLayout {
260        left: [None; MAX_BUTTONS_PER_SIDE],
261        right: [
262            Some(WindowButton::Minimize),
263            Some(WindowButton::Maximize),
264            Some(WindowButton::Close),
265        ],
266    }
267}
268
269/// A visual-only client-frame preview inside an application's content area.
270///
271/// Uses the production frame renderer and resolver, but attaches no window actions,
272/// drag/resize regions or focusable frame buttons. It never changes the host window's
273/// decorations, insets or state. Simulated state is not detected desktop information.
274#[derive(IntoElement)]
275pub struct ClientFramePreview {
276    /// Displayed preview title.
277    pub title: SharedString,
278    /// Per-window overrides to preview against the current desktop snapshot.
279    pub options: ShellOptions,
280    /// Simulated appearance, independent of native window appearance.
281    pub appearance: WindowAppearance,
282    /// Simulated activation state.
283    pub active: bool,
284    /// Simulated maximization state.
285    pub maximized: bool,
286    /// Hide preview chrome, as a real fullscreen client frame does.
287    pub fullscreen: bool,
288    /// Simulated tiled edges.
289    pub tiling: Tiling,
290    /// Preview button layout; None uses the current platform layout or the shell fallback.
291    pub button_layout: Option<WindowButtonLayout>,
292    /// Visually simulate keyboard focus on the close button without making it actionable.
293    pub show_focus: bool,
294    /// Visually simulate hovering over close, without a native window action.
295    pub show_hover: bool,
296    content: AnyElement,
297}
298
299impl ClientFramePreview {
300    /// Construct an active, floating, light preview. Give its parent a bounded size.
301    pub fn new(
302        title: impl Into<SharedString>,
303        options: ShellOptions,
304        content: impl IntoElement,
305    ) -> Self {
306        Self {
307            title: title.into(),
308            options,
309            appearance: WindowAppearance::Light,
310            active: true,
311            maximized: false,
312            fullscreen: false,
313            tiling: Tiling::default(),
314            button_layout: None,
315            show_focus: false,
316            show_hover: false,
317            content: content.into_any_element(),
318        }
319    }
320}
321
322impl RenderOnce for ClientFramePreview {
323    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
324        if self.fullscreen {
325            return self.content;
326        }
327        render_client_frame(
328            ClientFrameState {
329                preferences: self.options.resolved_preferences(&cx.desktop_preferences()),
330                appearance: self.appearance,
331                active: self.active,
332                maximized: self.maximized,
333                tiling: self.tiling,
334                controls: WindowControls::default(),
335                resizable: true,
336                button_layout: self
337                    .button_layout
338                    .or_else(|| cx.button_layout())
339                    .unwrap_or_else(fallback_button_layout),
340                title: self.title,
341                preview_focus: self.show_focus,
342                preview_hover: self.show_hover,
343            },
344            self.content,
345            None,
346            false,
347        )
348    }
349}
350
351fn render_client_frame(
352    state: ClientFrameState,
353    content: AnyElement,
354    titlebar: Option<AnyElement>,
355    interactive: bool,
356) -> AnyElement {
357    let maximized = state.maximized;
358    let tiling = state.tiling;
359    let palette = state
360        .preferences
361        .resolved_look(state.appearance, state.active)
362        .colors;
363    let inset = if maximized { px(0.) } else { px(5.) };
364    let title = render_titlebar(&state, titlebar, interactive, false);
365    // The inset handles resizing; content events must not initiate window moves.
366    let mut frame = div()
367        .id("shell-frame")
368        .size_full()
369        .relative()
370        .bg(rgb(palette.border));
371    let edges = [
372        (ResizeEdge::Top, !tiling.top, CursorStyle::ResizeUpDown),
373        (
374            ResizeEdge::Bottom,
375            !tiling.bottom,
376            CursorStyle::ResizeUpDown,
377        ),
378        (ResizeEdge::Left, !tiling.left, CursorStyle::ResizeLeftRight),
379        (
380            ResizeEdge::Right,
381            !tiling.right,
382            CursorStyle::ResizeLeftRight,
383        ),
384        (
385            ResizeEdge::TopLeft,
386            !tiling.top && !tiling.left,
387            CursorStyle::ResizeUpLeftDownRight,
388        ),
389        (
390            ResizeEdge::TopRight,
391            !tiling.top && !tiling.right,
392            CursorStyle::ResizeUpRightDownLeft,
393        ),
394        (
395            ResizeEdge::BottomLeft,
396            !tiling.bottom && !tiling.left,
397            CursorStyle::ResizeUpRightDownLeft,
398        ),
399        (
400            ResizeEdge::BottomRight,
401            !tiling.bottom && !tiling.right,
402            CursorStyle::ResizeUpLeftDownRight,
403        ),
404    ];
405    frame = frame
406        .when(!maximized && !tiling.top, |s| s.pt(inset))
407        .when(!maximized && !tiling.bottom, |s| s.pb(inset))
408        .when(!maximized && !tiling.left, |s| s.pl(inset))
409        .when(!maximized && !tiling.right, |s| s.pr(inset))
410        .child(
411            div()
412                .size_full()
413                .flex()
414                .flex_col()
415                .when(!maximized && !tiling.is_tiled(), |s| {
416                    s.rounded(px(10.)).overflow_hidden()
417                })
418                .child(title)
419                .child(
420                    div()
421                        .flex_1()
422                        .min_h_0()
423                        .min_w_0()
424                        .overflow_hidden()
425                        .child(content),
426                ),
427        );
428    if interactive && !maximized && state.resizable {
429        for (index, (edge, enabled, cursor)) in edges.into_iter().enumerate() {
430            if !enabled {
431                continue;
432            }
433            let region = div()
434                .id(("shell-resize", index))
435                .absolute()
436                .cursor(cursor)
437                .on_mouse_down(MouseButton::Left, move |_, window, cx| {
438                    cx.stop_propagation();
439                    window.start_window_resize(edge);
440                });
441            let region = match edge {
442                ResizeEdge::Top => region.top_0().left(inset).right(inset).h(inset),
443                ResizeEdge::Bottom => region.bottom_0().left(inset).right(inset).h(inset),
444                ResizeEdge::Left => region.left_0().top(inset).bottom(inset).w(inset),
445                ResizeEdge::Right => region.right_0().top(inset).bottom(inset).w(inset),
446                ResizeEdge::TopLeft => region.top_0().left_0().size(inset),
447                ResizeEdge::TopRight => region.top_0().right_0().size(inset),
448                ResizeEdge::BottomLeft => region.bottom_0().left_0().size(inset),
449                ResizeEdge::BottomRight => region.bottom_0().right_0().size(inset),
450            };
451            frame = frame.child(region);
452        }
453    }
454    frame.into_any_element()
455}
456
457fn render_titlebar(
458    state: &ClientFrameState,
459    titlebar: Option<AnyElement>,
460    interactive: bool,
461    mac_native: bool,
462) -> AnyElement {
463    let maximized = state.maximized;
464    let preferences = &state.preferences;
465    let look = preferences.resolved_look(state.appearance, state.active);
466    let palette = look.colors;
467    let scale = preferences.text_scale;
468    let button_size = px(look.button_size);
469    let controls = state.controls;
470    let left_buttons = visible_buttons(state.button_layout.left, controls);
471    let right_buttons = visible_buttons(state.button_layout.right, controls);
472    let side_width = px((left_buttons.len().max(right_buttons.len()) as f32 * 33. + 8.) * scale);
473    let custom_header = titlebar.is_some();
474    let buttons = |side: Vec<WindowButton>, right: bool| {
475        div()
476            .flex()
477            .items_center()
478            .flex_none()
479            .min_w(if custom_header {
480                px((side.len() as f32 * 33. + 8.) * scale)
481            } else {
482                side_width
483            })
484            .when(right, |s| s.justify_end())
485            .children(side.into_iter().map(|button| {
486                let label = control_label(button, maximized);
487                control_button(
488                    button.id(),
489                    label,
490                    palette,
491                    look.focus,
492                    button_size,
493                    interactive,
494                    (
495                        !interactive && state.preview_focus && button == WindowButton::Close,
496                        !interactive && state.preview_hover && button == WindowButton::Close,
497                    ),
498                )
499                .when(interactive && cfg!(target_os = "windows"), |element| {
500                    element.window_control_area(match button {
501                        WindowButton::Close => WindowControlArea::Close,
502                        WindowButton::Minimize => WindowControlArea::Min,
503                        WindowButton::Maximize => WindowControlArea::Max,
504                    })
505                })
506                .when(interactive, |element| {
507                    element
508                        .on_click(move |_, window, cx| {
509                            cx.stop_propagation();
510                            activate_control(button, window);
511                        })
512                        .on_key_down(move |event, window, cx| {
513                            if activation_key(event) {
514                                cx.stop_propagation();
515                                activate_control(button, window);
516                            }
517                        })
518                        .on_a11y_action(AccessibleAction::Click, move |_, window, _| {
519                            activate_control(button, window)
520                        })
521                })
522                .child(if button == WindowButton::Close {
523                    // Draw around the geometric center, independent of font baselines.
524                    canvas(
525                        |_, _, _| (),
526                        move |bounds, _, window, _| {
527                            let margin = px(2. * scale);
528                            let start = bounds.origin + point(margin, margin);
529                            let end = bounds.bottom_right() - point(margin, margin);
530                            for (a, b) in
531                                [(start, end), (point(start.x, end.y), point(end.x, start.y))]
532                            {
533                                let mut path = PathBuilder::stroke(px(2. * scale));
534                                path.move_to(a);
535                                path.line_to(b);
536                                if let Ok(path) = path.build() {
537                                    window.paint_path(path, rgb(palette.text));
538                                }
539                            }
540                        },
541                    )
542                    .size(px(20. * scale))
543                    .into_any_element()
544                } else {
545                    div()
546                        .font(gpui::font(".SystemUIFont"))
547                        .text_size(px(14. * scale))
548                        .child(match button {
549                            WindowButton::Minimize => "−",
550                            WindowButton::Maximize if maximized => "â–£",
551                            _ => "â–¡",
552                        })
553                        .into_any_element()
554                })
555            }))
556    };
557    let title = div()
558        .id("shell-titlebar")
559        .debug_selector(|| "shell-titlebar-bounds".into())
560        .flex_none()
561        .h(px(look.titlebar_height))
562        .px(px(4. * scale))
563        .flex()
564        .items_center()
565        .bg(rgb(palette.header))
566        .text_color(rgb(palette.text))
567        .font(look.typography.font.clone())
568        .text_size(px(look.typography.size))
569        .border_b_1()
570        .border_color(rgb(palette.border))
571        .child(buttons(left_buttons, false))
572        .pl(px(if mac_native { 80.0 } else { 0.0 }))
573        .child(
574            div()
575                .id("shell-title-drag")
576                .flex_1()
577                .min_w_0()
578                .overflow_hidden()
579                .h_full()
580                .flex()
581                .items_center()
582                .justify_center()
583                .when(interactive && titlebar.is_none(), |title| {
584                    title
585                        .on_mouse_down(MouseButton::Left, {
586                            let action = preferences.double_click_action;
587                            move |event, window, _| {
588                                if event.click_count == 2 {
589                                    action.perform_at(window, event.position);
590                                } else {
591                                    window.start_window_move();
592                                }
593                            }
594                        })
595                        .on_mouse_down(MouseButton::Right, {
596                            let action = preferences.right_click_action;
597                            move |event, window, _| action.perform_at(window, event.position)
598                        })
599                        .on_mouse_down(MouseButton::Middle, {
600                            let action = preferences.middle_click_action;
601                            move |event, window, _| action.perform_at(window, event.position)
602                        })
603                })
604                .child(titlebar.unwrap_or_else(|| {
605                    div()
606                        .w_full()
607                        .truncate()
608                        .text_center()
609                        .child(state.title.clone())
610                        .into_any_element()
611                })),
612        )
613        .child(buttons(right_buttons, true));
614    title.into_any_element()
615}
616
617/// Optional per-window overrides. `None` follows desktop preferences, then built-in fallbacks.
618///
619/// Unlike the initial experimental API, these fields are optional: wrap explicit values in
620/// `Some(...)`. This preserves explicit `false` and `TitlebarAction::None` overrides.
621/// Light/dark appearance and button placement retain the platform's existing APIs.
622#[derive(Default)]
623pub struct ShellOptions {
624    /// Override system high contrast.
625    pub high_contrast: Option<bool>,
626    /// Override the sRGB focus accent as 0xRRGGBB.
627    pub accent: Option<u32>,
628    /// Override logical text/target scaling, independent of device DPI.
629    pub text_scale: Option<f32>,
630    /// Override client-frame typography; None parses the desktop description or uses system UI.
631    pub typography: Option<ShellTypography>,
632    /// Resolve motion policy for consumers; the current frame has no animations.
633    pub reduced_motion: Option<bool>,
634    /// Override the desktop double-click action; fallback is maximize/restore.
635    pub double_click_action: Option<TitlebarAction>,
636    /// Override the desktop middle-click action; fallback is no action.
637    pub middle_click_action: Option<TitlebarAction>,
638    /// Override the desktop right-click action; fallback is the window menu.
639    pub right_click_action: Option<TitlebarAction>,
640}
641
642fn shell_scale(scale: f32) -> f32 {
643    if scale.is_finite() {
644        scale.clamp(0.5, 4.)
645    } else {
646        1.
647    }
648}
649
650/// The values actually used by the shared client frame after overrides and fallbacks.
651#[derive(Clone, Debug, PartialEq)]
652pub struct ResolvedShellPreferences {
653    /// Whether the frame uses high-contrast colors.
654    pub high_contrast: bool,
655    /// Encoded sRGB focus accent, 0xRRGGBB.
656    pub accent: u32,
657    /// Logical text/target scale, clamped to 0.5–4 independently of device DPI.
658    pub text_scale: f32,
659    /// Unscaled client-frame typography.
660    pub typography: ShellTypography,
661    /// Resolved policy; no frame animations are currently implemented.
662    pub reduced_motion: bool,
663    /// Effective title-bar double-click action.
664    pub double_click_action: TitlebarAction,
665    /// Effective title-bar middle-click action.
666    pub middle_click_action: TitlebarAction,
667    /// Effective title-bar right-click action.
668    pub right_click_action: TitlebarAction,
669}
670
671impl ShellOptions {
672    /// Resolve the same values used by the client frame. This does not apply them to
673    /// native/server decorations or alter desktop settings. Useful for diagnostics.
674    pub fn resolved_preferences(&self, desktop: &DesktopPreferences) -> ResolvedShellPreferences {
675        let accent = desktop
676            .accent_color
677            .filter(|c| c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)))
678            .map(|[r, g, b]| {
679                ((r * 255.).round() as u32) << 16
680                    | ((g * 255.).round() as u32) << 8
681                    | (b * 255.).round() as u32
682            });
683        ResolvedShellPreferences {
684            high_contrast: self
685                .high_contrast
686                .or(desktop.high_contrast)
687                .unwrap_or(false),
688            accent: self.accent.or(accent).unwrap_or(0x3584e4),
689            text_scale: shell_scale(self.text_scale.or(desktop.text_scale).unwrap_or(1.)),
690            typography: self
691                .typography
692                .clone()
693                .or_else(|| {
694                    desktop
695                        .font
696                        .as_deref()
697                        .and_then(ShellTypography::from_desktop_description)
698                })
699                .unwrap_or_default()
700                .normalized(),
701            reduced_motion: self
702                .reduced_motion
703                .or(desktop.reduced_motion)
704                .unwrap_or(false),
705            double_click_action: self
706                .double_click_action
707                .or_else(|| {
708                    desktop
709                        .titlebar_double_click
710                        .map(TitlebarAction::from_platform)
711                })
712                .unwrap_or(TitlebarAction::ToggleMaximize),
713            middle_click_action: self
714                .middle_click_action
715                .or_else(|| {
716                    desktop
717                        .titlebar_middle_click
718                        .map(TitlebarAction::from_platform)
719                })
720                .unwrap_or(TitlebarAction::None),
721            right_click_action: self
722                .right_click_action
723                .or_else(|| {
724                    desktop
725                        .titlebar_right_click
726                        .map(TitlebarAction::from_platform)
727                })
728                .unwrap_or(TitlebarAction::Menu),
729        }
730    }
731}
732
733/// Client-frame colors after appearance, activation and contrast resolution.
734#[derive(Clone, Copy, Debug, PartialEq, Eq)]
735pub struct ShellColors {
736    pub header: u32,
737    pub text: u32,
738    pub border: u32,
739    pub hover: u32,
740}
741
742/// The exact logical style tokens consumed by the shared frame renderer.
743/// Does not theme application content or native/server decorations.
744#[derive(Clone, Debug, PartialEq)]
745pub struct ResolvedShellLook {
746    pub colors: ShellColors,
747    /// Effective focus-ring color (foreground in high contrast).
748    pub focus: u32,
749    /// Typography with logical text scaling already applied once.
750    pub typography: ShellTypography,
751    pub titlebar_height: f32,
752    pub button_size: f32,
753    /// Motion policy only; current hover/focus changes are instantaneous.
754    pub reduced_motion: bool,
755}
756
757impl ResolvedShellPreferences {
758    /// Resolve the same style tokens used by the native client frame and its preview.
759    pub fn resolved_look(&self, appearance: WindowAppearance, active: bool) -> ResolvedShellLook {
760        let colors = ShellColors::new(appearance, active, self.high_contrast);
761        let mut typography = self.typography.clone();
762        typography.size *= self.text_scale;
763        ResolvedShellLook {
764            colors,
765            focus: if self.high_contrast {
766                colors.text
767            } else {
768                self.accent
769            },
770            titlebar_height: (40.0 * self.text_scale)
771                .max(typography.size * 1.5 + 12.0 * self.text_scale),
772            button_size: 33.0 * self.text_scale,
773            typography,
774            reduced_motion: self.reduced_motion,
775        }
776    }
777}
778
779/// Supported title-bar interaction, resolved from desktop preferences or an explicit override.
780#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
781pub enum TitlebarAction {
782    #[default]
783    ToggleMaximize,
784    Minimize,
785    None,
786    /// Display the platform window menu when supported.
787    Menu,
788}
789impl TitlebarAction {
790    fn from_platform(action: gpui::TitlebarAction) -> Self {
791        match action {
792            gpui::TitlebarAction::None => Self::None,
793            gpui::TitlebarAction::ToggleMaximize => Self::ToggleMaximize,
794            gpui::TitlebarAction::Minimize => Self::Minimize,
795            gpui::TitlebarAction::Menu => Self::Menu,
796        }
797    }
798
799    fn perform_at(self, window: &mut Window, position: Point<Pixels>) {
800        match self {
801            Self::ToggleMaximize if window.window_controls().maximize && window.is_resizable() => {
802                window.zoom_window()
803            }
804            Self::Minimize if window.window_controls().minimize && window.is_minimizable() => {
805                window.minimize_window()
806            }
807            Self::Menu if window.window_controls().window_menu => window.show_window_menu(position),
808            _ => (),
809        }
810    }
811}
812
813mod frame {
814    use super::*;
815    pub(super) fn visible_buttons(
816        side: [Option<WindowButton>; MAX_BUTTONS_PER_SIDE],
817        controls: WindowControls,
818    ) -> Vec<WindowButton> {
819        side.into_iter()
820            .flatten()
821            .filter(|button| match button {
822                WindowButton::Minimize => controls.minimize,
823                WindowButton::Maximize => controls.maximize,
824                WindowButton::Close => true,
825            })
826            .collect()
827    }
828    pub(super) fn control_label(button: WindowButton, maximized: bool) -> &'static str {
829        match button {
830            WindowButton::Minimize => "Minimize window",
831            WindowButton::Maximize if maximized => "Restore window",
832            WindowButton::Maximize => "Maximize window",
833            WindowButton::Close => "Close window",
834        }
835    }
836    pub(super) fn activate_control(button: WindowButton, window: &mut Window) {
837        // Capabilities may have changed between rendering and activation.
838        match button {
839            WindowButton::Minimize
840                if window.window_controls().minimize && window.is_minimizable() =>
841            {
842                window.minimize_window()
843            }
844            WindowButton::Maximize
845                if window.window_controls().maximize && window.is_resizable() =>
846            {
847                window.zoom_window()
848            }
849            WindowButton::Close => window.remove_window(),
850            _ => (),
851        }
852    }
853    pub(super) type Palette = ShellColors;
854    impl Palette {
855        pub(super) fn new(appearance: WindowAppearance, active: bool, contrast: bool) -> Self {
856            let dark = matches!(
857                appearance,
858                WindowAppearance::Dark | WindowAppearance::VibrantDark
859            );
860            match (dark, active, contrast) {
861                (true, _, true) => Self {
862                    header: 0x000000,
863                    text: 0xffffff,
864                    border: 0xffffff,
865                    hover: 0x333333,
866                },
867                (false, _, true) => Self {
868                    header: 0xffffff,
869                    text: 0x000000,
870                    border: 0x000000,
871                    hover: 0xdddddd,
872                },
873                (true, true, false) => Self {
874                    header: 0x303030,
875                    text: 0xf6f5f4,
876                    border: 0x141414,
877                    hover: 0x484848,
878                },
879                (true, false, false) => Self {
880                    header: 0x282828,
881                    text: 0xaaaaaa,
882                    border: 0x202020,
883                    hover: 0x404040,
884                },
885                (false, true, false) => Self {
886                    header: 0xebebeb,
887                    text: 0x202020,
888                    border: 0xb8b8b8,
889                    hover: 0xd8d8d8,
890                },
891                (false, false, false) => Self {
892                    header: 0xf5f5f5,
893                    text: 0x707070,
894                    border: 0xd5d5d5,
895                    hover: 0xe3e3e3,
896                },
897            }
898        }
899    }
900    struct ControlTooltip(&'static str);
901    impl Render for ControlTooltip {
902        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
903            let palette = Palette::new(window.appearance(), true, false);
904            div()
905                .px_2()
906                .py_1()
907                .rounded_md()
908                .bg(rgb(palette.header))
909                .text_color(rgb(palette.text))
910                .child(self.0)
911        }
912    }
913    pub(super) fn control_button(
914        id: &'static str,
915        label: &'static str,
916        palette: Palette,
917        accent: u32,
918        size: Pixels,
919        interactive: bool,
920        (preview_focus, preview_hover): (bool, bool),
921    ) -> Stateful<Div> {
922        div()
923            .id(id)
924            .role(if interactive {
925                Role::Button
926            } else {
927                Role::Image
928            })
929            .aria_label(if interactive {
930                label.to_owned()
931            } else {
932                format!("Preview: {label}")
933            })
934            .when(interactive, |s| s.focusable().tab_stop(true))
935            .size(size)
936            .flex_none()
937            .flex()
938            .items_center()
939            .justify_center()
940            .rounded_full()
941            .border_2()
942            .border_color(transparent_black())
943            .when(preview_focus, |s| s.border_color(rgb(accent)))
944            .when(preview_hover, |s| s.bg(rgb(palette.hover)))
945            .when(interactive, |s| {
946                s.cursor_pointer().hover(move |s| s.bg(rgb(palette.hover)))
947            })
948            .focus_visible(move |s| s.border_color(rgb(accent)))
949            .when(interactive, |s| {
950                s.tooltip(move |_, cx| cx.new(|_| ControlTooltip(label)).into())
951                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
952            })
953    }
954
955    #[cfg(test)]
956    mod tests {
957        use super::*;
958        #[::core::prelude::v1::test]
959        fn omitted_and_unavailable_buttons_are_not_reintroduced() {
960            let close_only = WindowButtonLayout::parse(":close").unwrap();
961            assert_eq!(
962                visible_buttons(close_only.right, WindowControls::default()),
963                vec![WindowButton::Close]
964            );
965            let none = WindowButtonLayout::parse(":").unwrap();
966            assert!(visible_buttons(none.left, WindowControls::default()).is_empty());
967            assert!(visible_buttons(none.right, WindowControls::default()).is_empty());
968            let controls = WindowControls {
969                minimize: false,
970                maximize: false,
971                ..Default::default()
972            };
973            assert_eq!(
974                visible_buttons(WindowButtonLayout::linux_default().right, controls),
975                vec![WindowButton::Close]
976            );
977        }
978        #[::core::prelude::v1::test]
979        fn simulated_hover_reaches_the_control_background_without_enabling_actions() {
980            let palette = Palette::new(WindowAppearance::Light, true, false);
981            let mut normal = control_button(
982                "normal",
983                "Close",
984                palette,
985                0x3584e4,
986                px(33.),
987                false,
988                (false, false),
989            );
990            let mut hovered = control_button(
991                "hovered",
992                "Close",
993                palette,
994                0x3584e4,
995                px(33.),
996                false,
997                (false, true),
998            );
999            assert!(normal.style().background.is_none());
1000            assert!(hovered.style().background.is_some());
1001        }
1002
1003        #[::core::prelude::v1::test]
1004        fn light_dark_inactive_and_contrast_have_distinct_tokens() {
1005            let light = Palette::new(WindowAppearance::Light, true, false);
1006            let dark = Palette::new(WindowAppearance::Dark, true, false);
1007            assert_ne!(light.header, dark.header);
1008            assert_ne!(
1009                light.text,
1010                Palette::new(WindowAppearance::Light, false, false).text
1011            );
1012            assert_eq!(
1013                Palette::new(WindowAppearance::Dark, true, true).text,
1014                0xffffff
1015            );
1016            assert_eq!(
1017                Palette::new(WindowAppearance::Light, true, true).text,
1018                0x000000
1019            );
1020        }
1021        #[::core::prelude::v1::test]
1022        fn invalid_text_scales_cannot_break_layout() {
1023            assert_eq!(shell_scale(f32::NAN), 1.);
1024            assert_eq!(shell_scale(f32::INFINITY), 1.);
1025            assert_eq!(shell_scale(-1.), 0.5);
1026            assert_eq!(shell_scale(1.5), 1.5);
1027            assert_eq!(shell_scale(100.), 4.);
1028        }
1029    }
1030}
1031
1032#[cfg(test)]
1033mod input_tests {
1034    use super::*;
1035
1036    #[::core::prelude::v1::test]
1037    fn desktop_preferences_follow_live_values_and_explicit_overrides_win() {
1038        let desktop = DesktopPreferences {
1039            high_contrast: Some(true),
1040            accent_color: Some([1., 0., 0.]),
1041            text_scale: Some(1.5),
1042            titlebar_double_click: Some(gpui::TitlebarAction::Minimize),
1043            titlebar_middle_click: Some(gpui::TitlebarAction::Menu),
1044            ..Default::default()
1045        };
1046        let inherited = ShellOptions::default().resolved_preferences(&desktop);
1047        assert!(inherited.high_contrast);
1048        assert_eq!(inherited.accent, 0xff0000);
1049        assert_eq!(inherited.text_scale, 1.5);
1050        assert_eq!(inherited.double_click_action, TitlebarAction::Minimize);
1051        assert_eq!(inherited.middle_click_action, TitlebarAction::Menu);
1052        assert_eq!(inherited.right_click_action, TitlebarAction::Menu);
1053        let overridden = ShellOptions {
1054            high_contrast: Some(false),
1055            accent: Some(0),
1056            text_scale: Some(1.),
1057            double_click_action: Some(TitlebarAction::None),
1058            middle_click_action: Some(TitlebarAction::None),
1059            right_click_action: Some(TitlebarAction::None),
1060            ..Default::default()
1061        }
1062        .resolved_preferences(&desktop);
1063        assert!(!overridden.high_contrast);
1064        assert_eq!(overridden.accent, 0);
1065        assert_eq!(overridden.text_scale, 1.);
1066        assert_eq!(overridden.double_click_action, TitlebarAction::None);
1067        assert_eq!(overridden.middle_click_action, TitlebarAction::None);
1068        assert_eq!(overridden.right_click_action, TitlebarAction::None);
1069        let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences::default());
1070        assert!(!reset.high_contrast);
1071        assert_eq!(reset.text_scale, 1.);
1072        assert_eq!(reset.double_click_action, TitlebarAction::ToggleMaximize);
1073    }
1074
1075    #[::core::prelude::v1::test]
1076    fn look_resolution_applies_typography_scale_once_and_preserves_explicit_policy() {
1077        let desktop = DesktopPreferences {
1078            font: Some("Noto Sans 12".into()),
1079            text_scale: Some(1.5),
1080            reduced_motion: Some(true),
1081            ..Default::default()
1082        };
1083        let resolved = ShellOptions::default().resolved_preferences(&desktop);
1084        assert_eq!(resolved.typography.size, 16.0);
1085        let look = resolved.resolved_look(WindowAppearance::Light, true);
1086        assert_eq!(look.typography.size, 24.0);
1087        assert!(look.titlebar_height >= look.typography.size * 1.5);
1088        assert!(look.reduced_motion);
1089        let options = ShellOptions {
1090            typography: Some(ShellTypography {
1091                font: font("Explicit Family"),
1092                size: 24.0,
1093            }),
1094            reduced_motion: Some(false),
1095            high_contrast: Some(true),
1096            accent: Some(0xff0000),
1097            ..Default::default()
1098        };
1099        let look = options
1100            .resolved_preferences(&desktop)
1101            .resolved_look(WindowAppearance::Dark, false);
1102        assert_eq!(look.typography.font.family.as_ref(), "Explicit Family");
1103        assert_eq!(look.typography.size, 36.0);
1104        assert_eq!(look.focus, look.colors.text);
1105        assert!(!look.reduced_motion);
1106        let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences {
1107            font: Some("Sans Condensed 12".into()),
1108            ..Default::default()
1109        });
1110        assert_eq!(reset.typography, ShellTypography::default());
1111    }
1112
1113    #[::core::prelude::v1::test]
1114    fn activation_keys_do_not_consume_modified_shortcuts_or_repeats() {
1115        let key = |text: &'static str, held| KeyDownEvent {
1116            keystroke: Keystroke::parse(text).unwrap(),
1117            is_held: held,
1118            prefer_character_input: false,
1119        };
1120        assert!(activation_key(&key("enter", false)));
1121        assert!(activation_key(&key("space", false)));
1122        assert!(!activation_key(&key("ctrl-enter", false)));
1123        assert!(!activation_key(&key("alt-space", false)));
1124        assert!(!activation_key(&key("enter", true)));
1125        assert!(!activation_key(&key("a", false)));
1126    }
1127}
1128
1129#[cfg(test)]
1130mod lifecycle_tests {
1131    use super::*;
1132    use ::core::prelude::v1::test;
1133    struct Content;
1134    impl Render for Content {
1135        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1136            div()
1137        }
1138    }
1139    #[gpui::test]
1140    fn independent_titles_are_released_on_close(cx: &mut TestAppContext) {
1141        let (one, two) = cx.update(|cx| {
1142            let mut open = |title: &str| {
1143                cx.open_shell_window(
1144                    WindowOptions {
1145                        titlebar: Some(TitlebarOptions {
1146                            title: Some(title.to_owned().into()),
1147                            ..Default::default()
1148                        }),
1149                        ..Default::default()
1150                    },
1151                    ShellOptions::default(),
1152                    |_, cx| cx.new(|_| Content),
1153                )
1154                .unwrap()
1155            };
1156            (open("First"), open("Second"))
1157        });
1158        let one_id = one.window_id();
1159        let two_id = two.window_id();
1160        cx.update_window(*one, |_, window, _| {
1161            assert_eq!(window.shell_title(), "First");
1162            window.set_shell_title("Updated");
1163        })
1164        .unwrap();
1165        cx.update_window(*two, |_, window, _| {
1166            assert_eq!(window.shell_title(), "Second")
1167        })
1168        .unwrap();
1169        cx.update_window(*one, |_, window, _| {
1170            assert_eq!(window.shell_title(), "Updated");
1171            window.remove_window();
1172        })
1173        .unwrap();
1174        cx.run_until_parked();
1175        SHELL_TITLES.with(|titles| {
1176            assert!(!titles.borrow().contains_key(&one_id));
1177            assert!(titles.borrow().contains_key(&two_id));
1178        });
1179        cx.update_window(*two, |_, window, _| window.remove_window())
1180            .unwrap();
1181        cx.run_until_parked();
1182        SHELL_TITLES.with(|titles| assert!(!titles.borrow().contains_key(&two_id)));
1183    }
1184}
1185
1186#[cfg(test)]
1187mod preview_tests {
1188    use super::*;
1189    use ::core::prelude::v1::test;
1190
1191    struct PreviewHost;
1192    impl Render for PreviewHost {
1193        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1194            div().size_full().child(ClientFramePreview::new(
1195                "Preview only",
1196                ShellOptions::default(),
1197                div().size_full().child("Content"),
1198            ))
1199        }
1200    }
1201
1202    struct ContentStyleHost(std::rc::Rc<RefCell<Option<SharedString>>>);
1203    impl Render for ContentStyleHost {
1204        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1205            let observed = self.0.clone();
1206            div()
1207                .size_full()
1208                .font_family("Content Typeface")
1209                .child(ClientFramePreview::new(
1210                    "Different chrome typography",
1211                    ShellOptions {
1212                        typography: Some(ShellTypography {
1213                            font: font("Frame Typeface"),
1214                            size: 30.0,
1215                        }),
1216                        ..Default::default()
1217                    },
1218                    canvas(
1219                        move |_, window, _| {
1220                            *observed.borrow_mut() = Some(window.text_style().font_family);
1221                        },
1222                        |_, _, _, _| (),
1223                    )
1224                    .size_full(),
1225                ))
1226        }
1227    }
1228
1229    #[gpui::test]
1230    fn client_frame_typography_does_not_leak_into_application_content(cx: &mut TestAppContext) {
1231        let observed = std::rc::Rc::new(RefCell::new(None));
1232        let copy = observed.clone();
1233        let host = cx.open_window(size(px(600.), px(240.)), move |_, _| ContentStyleHost(copy));
1234        let mut test_window = VisualTestContext::from_window(host.into(), cx);
1235        test_window.update(|window, app| window.draw(app).clear(app));
1236        assert_eq!(
1237            observed.borrow().as_ref().map(|s| s.as_ref()),
1238            Some("Content Typeface")
1239        );
1240    }
1241
1242    #[gpui::test]
1243    fn preview_pointer_events_do_not_change_or_close_the_host(cx: &mut TestAppContext) {
1244        let host = cx.open_window(size(px(600.), px(200.)), |_, _| PreviewHost);
1245        let mut test_window = VisualTestContext::from_window(host.into(), cx);
1246        test_window.update(|window, app| window.draw(app).clear(app));
1247        let before = cx
1248            .update_window(*host, |_, window, _| {
1249                (
1250                    window.window_bounds(),
1251                    window.window_decorations(),
1252                    window.client_inset(),
1253                    window.is_maximized(),
1254                    window.is_fullscreen(),
1255                )
1256            })
1257            .unwrap();
1258        // The three preview window controls, title drag area, and resize edge must
1259        // all remain decorative even though they share the real frame's renderer.
1260        for position in [
1261            point(px(575.), px(20.)),
1262            point(px(542.), px(20.)),
1263            point(px(509.), px(20.)),
1264            point(px(300.), px(20.)),
1265            point(px(2.), px(80.)),
1266        ] {
1267            test_window.simulate_click(position, Modifiers::default());
1268        }
1269        test_window.simulate_event(MouseDownEvent {
1270            position: point(px(300.), px(20.)),
1271            button: MouseButton::Left,
1272            click_count: 2,
1273            first_mouse: false,
1274            modifiers: Modifiers::default(),
1275        });
1276        test_window.simulate_mouse_up(
1277            point(px(300.), px(20.)),
1278            MouseButton::Left,
1279            Modifiers::default(),
1280        );
1281        let after = cx
1282            .update_window(*host, |_, window, _| {
1283                (
1284                    window.window_bounds(),
1285                    window.window_decorations(),
1286                    window.client_inset(),
1287                    window.is_maximized(),
1288                    window.is_fullscreen(),
1289                )
1290            })
1291            .expect("preview close must not close the real window");
1292        assert_eq!(before, after);
1293    }
1294}
1295
1296#[cfg(test)]
1297mod composition_tests {
1298    use super::*;
1299    use ::core::prelude::v1::test;
1300    use std::{cell::Cell, rc::Rc};
1301    struct Header(Rc<Cell<usize>>);
1302    impl Render for Header {
1303        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1304            let clicks = self.0.clone();
1305            div()
1306                .size_full()
1307                .flex()
1308                .items_center()
1309                .child(
1310                    div()
1311                        .id("app-button")
1312                        .debug_selector(|| "app-button".into())
1313                        .w(px(32.))
1314                        .h_full()
1315                        .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
1316                        .child("App"),
1317                )
1318                .child(ShellDragRegion::new(
1319                    "app-drag",
1320                    div().size_full().child("App title"),
1321                ))
1322        }
1323    }
1324    struct Body;
1325    impl Render for Body {
1326        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1327            div()
1328                .debug_selector(|| "app-body".into())
1329                .size_full()
1330                .child("Body")
1331        }
1332    }
1333    #[gpui::test]
1334    fn composed_mac_header_reserves_native_control_space_and_preserves_app_clicks(
1335        cx: &mut TestAppContext,
1336    ) {
1337        cx.set_platform_info(PlatformInfo {
1338            os: OperatingSystem::Mac,
1339            target: PlatformTarget::Native,
1340        });
1341        let clicks = Rc::new(Cell::new(0));
1342        let copy = clicks.clone();
1343        let handle = cx.update(|app| {
1344            app.open_shell_window_with_titlebar(
1345                WindowOptions {
1346                    window_bounds: Some(WindowBounds::Windowed(Bounds {
1347                        origin: Point::default(),
1348                        size: size(px(600.), px(240.)),
1349                    })),
1350                    ..Default::default()
1351                },
1352                ShellOptions::default(),
1353                |_, app| (app.new(|_| Header(copy)), app.new(|_| Body)),
1354            )
1355            .unwrap()
1356        });
1357        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1358        visual.update(|window, app| window.draw(app).clear(app));
1359        let button = visual
1360            .debug_bounds("app-button")
1361            .expect("app header rendered");
1362        let body = visual.debug_bounds("app-body").expect("app body rendered");
1363        let header = visual.debug_bounds("shell-titlebar-bounds").unwrap();
1364        assert!(
1365            button.left() >= px(80.),
1366            "reserve native traffic-light space"
1367        );
1368        assert_eq!(body.top(), header.bottom());
1369        visual.simulate_click(button.center(), Modifiers::default());
1370        assert_eq!(clicks.get(), 1);
1371        assert_eq!(visual.debug_bounds("app-body").unwrap(), body);
1372    }
1373}