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 controls;
6mod typography;
7pub use app_titlebar::ShellDragRegion;
8pub use controls::{ShellControl, ShellControlPresenter, ShellControlState};
9pub use typography::ShellTypography;
10
11use frame::*;
12use std::{cell::RefCell, collections::HashMap};
13
14fn activation_key(event: &KeyDownEvent) -> bool {
15    matches!(event.keystroke.key.as_str(), "enter" | "space")
16        && !event.keystroke.modifiers.control
17        && !event.keystroke.modifiers.alt
18        && !event.keystroke.modifiers.platform
19        && !event.is_held
20}
21
22thread_local! {
23    // Platform title reads are macOS-only. Keep the shell chrome's title locally.
24    static SHELL_TITLES: RefCell<HashMap<WindowId, String>> = RefCell::new(HashMap::new());
25}
26pub trait ShellWindowTitle {
27    fn set_shell_title(&mut self, title: &str);
28    fn shell_title(&self) -> String;
29}
30impl ShellWindowTitle for Window {
31    fn set_shell_title(&mut self, title: &str) {
32        self.set_window_title(title);
33        SHELL_TITLES.with(|titles| {
34            titles
35                .borrow_mut()
36                .insert(self.window_handle().window_id(), title.to_owned())
37        });
38        self.refresh();
39    }
40    fn shell_title(&self) -> String {
41        SHELL_TITLES
42            .with(|titles| {
43                titles
44                    .borrow()
45                    .get(&self.window_handle().window_id())
46                    .cloned()
47            })
48            .filter(|title| !title.trim().is_empty())
49            .unwrap_or_else(|| "XGPUI".to_owned())
50    }
51}
52
53/// Open an application with compositor-provided chrome or a shared client-side frame.
54pub trait ShellApp {
55    fn open_shell_window<V: Render + 'static>(
56        &mut self,
57        options: WindowOptions,
58        shell: ShellOptions,
59        build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
60    ) -> anyhow::Result<WindowHandle<ShellWindow>>;
61    /// Supply an app-owned title-bar view and a separate content view. Native macOS
62    /// traffic lights remain native; client-frame window controls remain shell-owned.
63    /// Compose arbitrary app controls and use ShellDragRegion only for drag surfaces.
64    fn open_shell_window_with_titlebar<H: Render + 'static, V: Render + 'static>(
65        &mut self,
66        options: WindowOptions,
67        shell: ShellOptions,
68        build: impl FnOnce(&mut Window, &mut App) -> (Entity<H>, Entity<V>),
69    ) -> anyhow::Result<WindowHandle<ShellWindow>>;
70}
71impl ShellApp for App {
72    fn open_shell_window<V: Render + 'static>(
73        &mut self,
74        options: WindowOptions,
75        shell: ShellOptions,
76        build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
77    ) -> anyhow::Result<WindowHandle<ShellWindow>> {
78        open_shell(self, options, shell, |window, cx| {
79            (None, build(window, cx).into())
80        })
81    }
82
83    fn open_shell_window_with_titlebar<H: Render + 'static, V: Render + 'static>(
84        &mut self,
85        mut options: WindowOptions,
86        shell: ShellOptions,
87        build: impl FnOnce(&mut Window, &mut App) -> (Entity<H>, Entity<V>),
88    ) -> anyhow::Result<WindowHandle<ShellWindow>> {
89        app_titlebar::configure_window(&mut options);
90        open_shell(self, options, shell, |window, cx| {
91            let (titlebar, content) = build(window, cx);
92            (Some(titlebar.into()), content.into())
93        })
94    }
95}
96
97fn open_shell(
98    app: &mut App,
99    options: WindowOptions,
100    shell: ShellOptions,
101    build: impl FnOnce(&mut Window, &mut App) -> (Option<AnyView>, AnyView),
102) -> anyhow::Result<WindowHandle<ShellWindow>> {
103    let show_chrome = options.show;
104    let initial_title = options
105        .titlebar
106        .as_ref()
107        .and_then(|titlebar| titlebar.title.clone());
108    app.open_window(options, |window, cx| {
109        if let Some(title) = initial_title {
110            window.set_shell_title(&title);
111        }
112        let (titlebar, content) = build(window, cx);
113        cx.new(|cx| {
114            window
115                .observe_button_layout_changed(|window, _| window.refresh())
116                .detach();
117            window
118                .observe_window_appearance(|window, _| window.refresh())
119                .detach();
120            cx.observe_window_activation(window, |_, window, _| window.refresh())
121                .detach();
122            cx.observe_window_bounds(window, |_, window, _| window.refresh())
123                .detach();
124            let shell_entity = cx.entity().downgrade();
125            let preferences_subscription = cx.observe_desktop_preferences(move |app| {
126                let _ = shell_entity.update(app, |_, cx| cx.notify());
127            });
128            ShellWindow {
129                _preferences_subscription: preferences_subscription,
130                shell,
131                presenter: None,
132                content,
133                titlebar,
134                show_chrome,
135                window_id: window.window_handle().window_id(),
136            }
137        })
138    })
139}
140
141pub struct ShellWindow {
142    _preferences_subscription: Subscription,
143    shell: ShellOptions,
144    presenter: Option<ShellControlPresenter>,
145    content: AnyView,
146    titlebar: Option<AnyView>,
147    show_chrome: bool,
148    window_id: WindowId,
149}
150impl ShellWindow {
151    /// Replace application-supplied overrides and redraw this window.
152    pub fn set_options(&mut self, options: ShellOptions, cx: &mut Context<Self>) {
153        self.shell = options;
154        cx.notify();
155    }
156
157    /// Replace the artwork of shell-drawn window controls, or pass `None` for the
158    /// built-in look. Applies wherever the shell draws controls (Linux client
159    /// frames and Windows app-composed title bars); native macOS traffic lights
160    /// and compositor decorations are unaffected. Set it right after opening the
161    /// window to have it in place before the first frame.
162    pub fn set_control_presenter(
163        &mut self,
164        presenter: Option<ShellControlPresenter>,
165        cx: &mut Context<Self>,
166    ) {
167        self.presenter = presenter;
168        cx.notify();
169    }
170}
171impl Drop for ShellWindow {
172    fn drop(&mut self) {
173        let _ = SHELL_TITLES.try_with(|titles| titles.borrow_mut().remove(&self.window_id));
174    }
175}
176impl Render for ShellWindow {
177    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
178        let content = self.content.clone();
179        #[cfg(any(test, target_os = "linux", target_os = "freebsd"))]
180        if self.show_chrome
181            && matches!(
182                cx.platform_info().os,
183                OperatingSystem::Linux | OperatingSystem::FreeBsd
184            )
185            && !window.is_fullscreen()
186            && let Decorations::Client { .. } = window.window_decorations()
187        {
188            let mut state = ClientFrameState::for_window(&self.shell, window, cx);
189            state.presenter = self.presenter.clone();
190            window.set_client_inset(if state.maximized { px(0.) } else { px(5.) });
191            return render_client_frame(
192                state,
193                content.into_any_element(),
194                self.titlebar.clone().map(IntoElement::into_any_element),
195                true,
196                window,
197                cx,
198            );
199        }
200        // Clear any prior client frame when switching to server chrome/fullscreen.
201        window.set_client_inset(px(0.));
202        if self.show_chrome
203            && let Some(titlebar) = self.titlebar.clone()
204        {
205            let mut state = ClientFrameState::for_window(&self.shell, window, cx);
206            state.presenter = self.presenter.clone();
207            let mac_native = cx.platform_info().os == OperatingSystem::Mac;
208            let custom_windows = cx.platform_info().os == OperatingSystem::Windows;
209            if mac_native || !custom_windows || window.is_fullscreen() {
210                state.button_layout = WindowButtonLayout {
211                    left: [None; 3],
212                    right: [None; 3],
213                };
214            }
215            #[cfg(target_os = "macos")]
216            if mac_native && !window.is_fullscreen() {
217                let height = state
218                    .preferences
219                    .resolved_look(state.appearance, state.active)
220                    .titlebar_height;
221                window.set_traffic_light_position(point(px(9.), px((height - 16.).max(0.) / 2.)));
222            }
223            let fullscreen = window.is_fullscreen();
224            let header = render_titlebar(
225                &state,
226                Some(titlebar.into_any_element()),
227                true,
228                mac_native && !fullscreen,
229                window,
230                cx,
231            );
232            return div()
233                .size_full()
234                .flex()
235                .flex_col()
236                .child(header)
237                .child(div().flex_1().min_h_0().child(content))
238                .into_any_element();
239        }
240        content.into_any_element()
241    }
242}
243
244struct ClientFrameState {
245    preferences: ResolvedShellPreferences,
246    appearance: WindowAppearance,
247    active: bool,
248    maximized: bool,
249    tiling: Tiling,
250    controls: WindowControls,
251    resizable: bool,
252    button_layout: WindowButtonLayout,
253    title: SharedString,
254    preview_focus: bool,
255    preview_hover: bool,
256    presenter: Option<ShellControlPresenter>,
257}
258
259impl ClientFrameState {
260    fn for_window(options: &ShellOptions, window: &Window, cx: &App) -> Self {
261        let tiling = match window.window_decorations() {
262            Decorations::Client { tiling } => tiling,
263            Decorations::Server => Tiling::default(),
264        };
265        let mut controls = window.window_controls();
266        controls.minimize &= window.is_minimizable();
267        controls.maximize &= window.is_resizable();
268        Self {
269            preferences: options.resolved_preferences(&cx.desktop_preferences()),
270            appearance: window.appearance(),
271            active: window.is_window_active(),
272            maximized: window.is_maximized(),
273            tiling,
274            controls,
275            resizable: window.is_resizable(),
276            button_layout: cx.button_layout().unwrap_or_else(fallback_button_layout),
277            title: window.shell_title().into(),
278            preview_focus: false,
279            preview_hover: false,
280            presenter: None,
281        }
282    }
283}
284
285fn fallback_button_layout() -> WindowButtonLayout {
286    WindowButtonLayout {
287        left: [None; MAX_BUTTONS_PER_SIDE],
288        right: [
289            Some(WindowButton::Minimize),
290            Some(WindowButton::Maximize),
291            Some(WindowButton::Close),
292        ],
293    }
294}
295
296/// A visual-only client-frame preview inside an application's content area.
297///
298/// Uses the production frame renderer and resolver, but attaches no window actions,
299/// drag/resize regions or focusable frame buttons. It never changes the host window's
300/// decorations, insets or state. Simulated state is not detected desktop information.
301#[derive(IntoElement)]
302pub struct ClientFramePreview {
303    /// Displayed preview title.
304    pub title: SharedString,
305    /// Per-window overrides to preview against the current desktop snapshot.
306    pub options: ShellOptions,
307    /// Simulated appearance, independent of native window appearance.
308    pub appearance: WindowAppearance,
309    /// Simulated activation state.
310    pub active: bool,
311    /// Simulated maximization state.
312    pub maximized: bool,
313    /// Hide preview chrome, as a real fullscreen client frame does.
314    pub fullscreen: bool,
315    /// Simulated tiled edges.
316    pub tiling: Tiling,
317    /// Preview button layout; None uses the current platform layout or the shell fallback.
318    pub button_layout: Option<WindowButtonLayout>,
319    /// Visually simulate keyboard focus on the close button without making it actionable.
320    pub show_focus: bool,
321    /// Visually simulate hovering over close, without a native window action.
322    pub show_hover: bool,
323    /// Draw controls with the same artwork as a real window; they stay inert.
324    pub control_presenter: Option<ShellControlPresenter>,
325    content: AnyElement,
326}
327
328impl ClientFramePreview {
329    /// Construct an active, floating, light preview. Give its parent a bounded size.
330    pub fn new(
331        title: impl Into<SharedString>,
332        options: ShellOptions,
333        content: impl IntoElement,
334    ) -> Self {
335        Self {
336            title: title.into(),
337            options,
338            appearance: WindowAppearance::Light,
339            active: true,
340            maximized: false,
341            fullscreen: false,
342            tiling: Tiling::default(),
343            button_layout: None,
344            show_focus: false,
345            show_hover: false,
346            control_presenter: None,
347            content: content.into_any_element(),
348        }
349    }
350}
351
352impl RenderOnce for ClientFramePreview {
353    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
354        if self.fullscreen {
355            return self.content;
356        }
357        render_client_frame(
358            ClientFrameState {
359                preferences: self.options.resolved_preferences(&cx.desktop_preferences()),
360                appearance: self.appearance,
361                active: self.active,
362                maximized: self.maximized,
363                tiling: self.tiling,
364                controls: WindowControls::default(),
365                resizable: true,
366                button_layout: self
367                    .button_layout
368                    .or_else(|| cx.button_layout())
369                    .unwrap_or_else(fallback_button_layout),
370                title: self.title,
371                preview_focus: self.show_focus,
372                preview_hover: self.show_hover,
373                presenter: self.control_presenter,
374            },
375            self.content,
376            None,
377            false,
378            window,
379            cx,
380        )
381    }
382}
383
384fn render_client_frame(
385    state: ClientFrameState,
386    content: AnyElement,
387    titlebar: Option<AnyElement>,
388    interactive: bool,
389    window: &mut Window,
390    cx: &mut App,
391) -> AnyElement {
392    let maximized = state.maximized;
393    let tiling = state.tiling;
394    let palette = state
395        .preferences
396        .resolved_look(state.appearance, state.active)
397        .colors;
398    let inset = if maximized { px(0.) } else { px(5.) };
399    let title = render_titlebar(&state, titlebar, interactive, false, window, cx);
400    // The inset handles resizing; content events must not initiate window moves.
401    let mut frame = div()
402        .id("shell-frame")
403        .size_full()
404        .relative()
405        .bg(rgb(palette.border));
406    let edges = [
407        (ResizeEdge::Top, !tiling.top, CursorStyle::ResizeUpDown),
408        (
409            ResizeEdge::Bottom,
410            !tiling.bottom,
411            CursorStyle::ResizeUpDown,
412        ),
413        (ResizeEdge::Left, !tiling.left, CursorStyle::ResizeLeftRight),
414        (
415            ResizeEdge::Right,
416            !tiling.right,
417            CursorStyle::ResizeLeftRight,
418        ),
419        (
420            ResizeEdge::TopLeft,
421            !tiling.top && !tiling.left,
422            CursorStyle::ResizeUpLeftDownRight,
423        ),
424        (
425            ResizeEdge::TopRight,
426            !tiling.top && !tiling.right,
427            CursorStyle::ResizeUpRightDownLeft,
428        ),
429        (
430            ResizeEdge::BottomLeft,
431            !tiling.bottom && !tiling.left,
432            CursorStyle::ResizeUpRightDownLeft,
433        ),
434        (
435            ResizeEdge::BottomRight,
436            !tiling.bottom && !tiling.right,
437            CursorStyle::ResizeUpLeftDownRight,
438        ),
439    ];
440    frame = frame
441        .when(!maximized && !tiling.top, |s| s.pt(inset))
442        .when(!maximized && !tiling.bottom, |s| s.pb(inset))
443        .when(!maximized && !tiling.left, |s| s.pl(inset))
444        .when(!maximized && !tiling.right, |s| s.pr(inset))
445        .child(
446            div()
447                .size_full()
448                .flex()
449                .flex_col()
450                .when(!maximized && !tiling.is_tiled(), |s| {
451                    s.rounded(px(10.)).overflow_hidden()
452                })
453                .child(title)
454                .child(
455                    div()
456                        .flex_1()
457                        .min_h_0()
458                        .min_w_0()
459                        .overflow_hidden()
460                        .child(content),
461                ),
462        );
463    if interactive && !maximized && state.resizable {
464        for (index, (edge, enabled, cursor)) in edges.into_iter().enumerate() {
465            if !enabled {
466                continue;
467            }
468            let region = div()
469                .id(("shell-resize", index))
470                .absolute()
471                .cursor(cursor)
472                .on_mouse_down(MouseButton::Left, move |_, window, cx| {
473                    cx.stop_propagation();
474                    window.start_window_resize(edge);
475                });
476            let region = match edge {
477                ResizeEdge::Top => region.top_0().left(inset).right(inset).h(inset),
478                ResizeEdge::Bottom => region.bottom_0().left(inset).right(inset).h(inset),
479                ResizeEdge::Left => region.left_0().top(inset).bottom(inset).w(inset),
480                ResizeEdge::Right => region.right_0().top(inset).bottom(inset).w(inset),
481                ResizeEdge::TopLeft => region.top_0().left_0().size(inset),
482                ResizeEdge::TopRight => region.top_0().right_0().size(inset),
483                ResizeEdge::BottomLeft => region.bottom_0().left_0().size(inset),
484                ResizeEdge::BottomRight => region.bottom_0().right_0().size(inset),
485            };
486            frame = frame.child(region);
487        }
488    }
489    frame.into_any_element()
490}
491
492fn render_titlebar(
493    state: &ClientFrameState,
494    titlebar: Option<AnyElement>,
495    interactive: bool,
496    mac_native: bool,
497    window: &mut Window,
498    cx: &mut App,
499) -> AnyElement {
500    let preferences = &state.preferences;
501    let look = preferences.resolved_look(state.appearance, state.active);
502    let palette = look.colors;
503    let scale = preferences.text_scale;
504    let controls = state.controls;
505    let left_buttons = visible_buttons(state.button_layout.left, controls);
506    let right_buttons = visible_buttons(state.button_layout.right, controls);
507    let side_width = px((left_buttons.len().max(right_buttons.len()) as f32 * 33. + 8.) * scale);
508    let custom_header = titlebar.is_some();
509    let mut buttons = |side: Vec<WindowButton>, right: bool| {
510        let mut row = div()
511            .flex()
512            .items_center()
513            .flex_none()
514            .min_w(if custom_header {
515                px((side.len() as f32 * 33. + 8.) * scale)
516            } else {
517                side_width
518            })
519            .when(right, |s| s.justify_end());
520        for button in side {
521            let preview_focus =
522                !interactive && state.preview_focus && button == WindowButton::Close;
523            let preview_hover =
524                !interactive && state.preview_hover && button == WindowButton::Close;
525            let element = if let Some(presenter) = &state.presenter {
526                presented_control(
527                    presenter,
528                    button,
529                    state,
530                    &look,
531                    interactive,
532                    (preview_focus, preview_hover),
533                    window,
534                    cx,
535                )
536            } else {
537                builtin_control(
538                    button,
539                    state,
540                    &look,
541                    interactive,
542                    (preview_focus, preview_hover),
543                )
544            };
545            row = row.child(element);
546        }
547        row
548    };
549    let left = buttons(left_buttons, false);
550    let right = buttons(right_buttons, true);
551    let title = div()
552        .id("shell-titlebar")
553        .debug_selector(|| "shell-titlebar-bounds".into())
554        .flex_none()
555        .h(px(look.titlebar_height))
556        .px(px(4. * scale))
557        .flex()
558        .items_center()
559        .bg(rgb(palette.header))
560        .text_color(rgb(palette.text))
561        .font(look.typography.font.clone())
562        .text_size(px(look.typography.size))
563        .border_b_1()
564        .border_color(rgb(palette.border))
565        .child(left)
566        .pl(px(if mac_native { 80.0 } else { 0.0 }))
567        .child(
568            div()
569                .id("shell-title-drag")
570                .flex_1()
571                .min_w_0()
572                .overflow_hidden()
573                .h_full()
574                .flex()
575                .items_center()
576                .justify_center()
577                .when(interactive && titlebar.is_none(), |title| {
578                    title
579                        .on_mouse_down(MouseButton::Left, {
580                            let action = preferences.double_click_action;
581                            move |event, window, _| {
582                                if event.click_count == 2 {
583                                    action.perform_at(window, event.position);
584                                } else {
585                                    window.start_window_move();
586                                }
587                            }
588                        })
589                        .on_mouse_down(MouseButton::Right, {
590                            let action = preferences.right_click_action;
591                            move |event, window, _| action.perform_at(window, event.position)
592                        })
593                        .on_mouse_down(MouseButton::Middle, {
594                            let action = preferences.middle_click_action;
595                            move |event, window, _| action.perform_at(window, event.position)
596                        })
597                })
598                .child(titlebar.unwrap_or_else(|| {
599                    div()
600                        .w_full()
601                        .truncate()
602                        .text_center()
603                        .child(state.title.clone())
604                        .into_any_element()
605                })),
606        )
607        .child(right);
608    title.into_any_element()
609}
610
611/// Shell-owned semantics shared by built-in and presented controls: Windows
612/// caption hit-testing, pointer/keyboard/accessibility activation.
613fn with_control_behavior(
614    element: Stateful<Div>,
615    button: WindowButton,
616    interactive: bool,
617) -> Stateful<Div> {
618    element
619        .when(interactive && cfg!(target_os = "windows"), |element| {
620            element.window_control_area(match button {
621                WindowButton::Close => WindowControlArea::Close,
622                WindowButton::Minimize => WindowControlArea::Min,
623                WindowButton::Maximize => WindowControlArea::Max,
624            })
625        })
626        .when(interactive, |element| {
627            element
628                // On Windows a pointer press on a caption area is handled by the
629                // platform (min/max/close, Snap Layouts), like native caption buttons.
630                .when(!NATIVE_CAPTION_CLICKS, |element| {
631                    element.on_click(move |_, window, cx| {
632                        cx.stop_propagation();
633                        activate_control(button, window);
634                    })
635                })
636                .on_key_down(move |event, window, cx| {
637                    if activation_key(event) {
638                        cx.stop_propagation();
639                        activate_control(button, window);
640                    }
641                })
642                .on_a11y_action(AccessibleAction::Click, move |_, window, _| {
643                    activate_control(button, window)
644                })
645        })
646}
647
648/// Windows routes pointer presses on caption areas to its own caption-button
649/// handling, and its caption buttons are not keyboard tab stops.
650const NATIVE_CAPTION_CLICKS: bool = cfg!(target_os = "windows");
651
652/// Windows 11 shows its Snap Layouts flyout over a hovered Maximize control; a
653/// tooltip there would cover it.
654fn shows_tooltip(button: WindowButton) -> bool {
655    !(cfg!(target_os = "windows") && button == WindowButton::Maximize)
656}
657
658fn shell_control(button: WindowButton, maximized: bool) -> ShellControl {
659    match button {
660        WindowButton::Minimize => ShellControl::Minimize,
661        WindowButton::Maximize if maximized => ShellControl::Restore,
662        WindowButton::Maximize => ShellControl::Maximize,
663        WindowButton::Close => ShellControl::Close,
664    }
665}
666
667/// Pointer state the shell tracks for presented controls.
668struct ControlInteraction {
669    hovered: bool,
670    pressed: bool,
671    focus: FocusHandle,
672}
673
674#[allow(clippy::too_many_arguments)]
675fn presented_control(
676    presenter: &ShellControlPresenter,
677    button: WindowButton,
678    state: &ClientFrameState,
679    look: &ResolvedShellLook,
680    interactive: bool,
681    (preview_focus, preview_hover): (bool, bool),
682    window: &mut Window,
683    cx: &mut App,
684) -> AnyElement {
685    let control = shell_control(button, state.maximized);
686    let label = control.accessible_label();
687    let target_size = px(look.button_size);
688    let interaction = interactive.then(|| {
689        window.use_keyed_state(
690            SharedString::from(format!("shell-control-state-{}", button.id())),
691            cx,
692            |_, cx| ControlInteraction {
693                hovered: false,
694                pressed: false,
695                focus: cx.focus_handle().tab_stop(!NATIVE_CAPTION_CLICKS),
696            },
697        )
698    });
699    let (hovered, pressed, focused) = match &interaction {
700        Some(interaction) => {
701            let interaction = interaction.read(cx);
702            (
703                interaction.hovered,
704                interaction.pressed,
705                interaction.focus.is_focused(window) && window.last_input_was_keyboard(),
706            )
707        }
708        None => (preview_hover, false, preview_focus),
709    };
710    let control_state = ShellControlState {
711        control,
712        hovered,
713        pressed,
714        focused,
715        window_active: state.active,
716        appearance: state.appearance,
717        high_contrast: state.preferences.high_contrast,
718        colors: look.colors,
719        focus_color: look.focus,
720        scale: state.preferences.text_scale,
721        target_size,
722        preview: !interactive,
723    };
724    let artwork = presenter.render(&control_state, window, cx);
725    let accent = look.focus;
726    // The shell paints only the focus ring; the presenter owns everything inside.
727    let mut element = div()
728        .id(button.id())
729        .debug_selector(move || format!("shell-control-{}", button.id()))
730        .role(if interactive {
731            Role::Button
732        } else {
733            Role::Image
734        })
735        .aria_label(if interactive {
736            label.to_owned()
737        } else {
738            format!("Preview: {label}")
739        })
740        .size(target_size)
741        .flex_none()
742        .flex()
743        .items_center()
744        .justify_center()
745        .overflow_hidden()
746        .rounded_full()
747        .border_2()
748        .border_color(transparent_black())
749        .when(focused, |s| s.border_color(rgb(accent)))
750        .child(artwork);
751    if let Some(interaction) = interaction {
752        let focus = interaction.read(cx).focus.clone();
753        let (hover_state, down_state, up_state, out_state) = (
754            interaction.clone(),
755            interaction.clone(),
756            interaction.clone(),
757            interaction,
758        );
759        element = element
760            .track_focus(&focus)
761            .cursor_pointer()
762            .when(shows_tooltip(button), |s| {
763                s.tooltip(move |_, cx| cx.new(|_| ControlTooltip(label)).into())
764            })
765            .on_hover(move |hovered, _, cx| {
766                hover_state.update(cx, |state, cx| {
767                    state.hovered = *hovered;
768                    if !*hovered {
769                        state.pressed = false;
770                    }
771                    cx.notify();
772                })
773            })
774            .on_mouse_down(MouseButton::Left, move |_, _, cx| {
775                // Never let a control press start a window drag. On Windows the
776                // press must reach the platform's caption-button handling.
777                if !NATIVE_CAPTION_CLICKS {
778                    cx.stop_propagation();
779                }
780                down_state.update(cx, |state, cx| {
781                    state.pressed = true;
782                    cx.notify();
783                })
784            })
785            .on_mouse_up(MouseButton::Left, move |_, _, cx| {
786                up_state.update(cx, |state, cx| {
787                    state.pressed = false;
788                    cx.notify();
789                })
790            })
791            .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
792                out_state.update(cx, |state, cx| {
793                    state.pressed = false;
794                    cx.notify();
795                })
796            });
797    }
798    with_control_behavior(element, button, interactive).into_any_element()
799}
800
801fn builtin_control(
802    button: WindowButton,
803    state: &ClientFrameState,
804    look: &ResolvedShellLook,
805    interactive: bool,
806    preview: (bool, bool),
807) -> AnyElement {
808    let maximized = state.maximized;
809    let palette = look.colors;
810    let scale = state.preferences.text_scale;
811    let label = control_label(button, maximized);
812    with_control_behavior(
813        control_button(
814            button.id(),
815            label,
816            palette,
817            look.focus,
818            px(look.button_size),
819            interactive,
820            preview,
821        ),
822        button,
823        interactive,
824    )
825    .child(builtin_symbol(
826        shell_control(button, maximized),
827        scale,
828        palette.text,
829    ))
830    .into_any_element()
831}
832
833/// The built-in control symbols, drawn as vectors in one shared 10px box so all
834/// four have the same visual size whatever fonts the system has.
835fn builtin_symbol(control: ShellControl, scale: f32, color: u32) -> AnyElement {
836    let side = px(10. * scale);
837    canvas(
838        |_, _, _| (),
839        move |bounds, _, window, _| {
840            let unit = |x: f32, y: f32| bounds.origin + point(side * (x / 10.), side * (y / 10.));
841            let lines: &[&[(f32, f32)]] = match control {
842                ShellControl::Minimize => &[&[(0., 5.), (10., 5.)]],
843                ShellControl::Maximize => {
844                    &[&[(0., 0.), (10., 0.), (10., 10.), (0., 10.), (0., 0.)]]
845                }
846                // Front window, then the visible edges of the one behind it.
847                ShellControl::Restore => &[
848                    &[(0., 2.5), (7.5, 2.5), (7.5, 10.), (0., 10.), (0., 2.5)],
849                    &[(2.5, 2.5), (2.5, 0.), (10., 0.), (10., 7.5), (7.5, 7.5)],
850                ],
851                ShellControl::Close => &[&[(0., 0.), (10., 10.)], &[(0., 10.), (10., 0.)]],
852            };
853            for polyline in lines {
854                let mut path = PathBuilder::stroke(px(1.25 * scale));
855                path.move_to(unit(polyline[0].0, polyline[0].1));
856                for &(x, y) in &polyline[1..] {
857                    path.line_to(unit(x, y));
858                }
859                if let Ok(path) = path.build() {
860                    window.paint_path(path, rgb(color));
861                }
862            }
863        },
864    )
865    .size(side)
866    .into_any_element()
867}
868
869/// Optional per-window overrides. `None` follows desktop preferences, then built-in fallbacks.
870///
871/// Unlike the initial experimental API, these fields are optional: wrap explicit values in
872/// `Some(...)`. This preserves explicit `false` and `TitlebarAction::None` overrides.
873/// Explicit colors take precedence over the desktop/default high-contrast palette;
874/// the application is responsible for their contrast and active/inactive variants.
875/// Native appearance and button placement retain the platform's existing APIs.
876#[derive(Default)]
877pub struct ShellOptions {
878    /// Opaque sRGB 0xRRGGBB background for the entire shared titlebar, including
879    /// reserved control space and padding. Wins over `colors.header`.
880    /// None uses the supplied palette header or the desktop/default header.
881    pub titlebar_background: Option<u32>,
882    /// Explicit shared-frame colors, overriding appearance, activation and the
883    /// default high-contrast palette. Does not theme application content, native
884    /// macOS artwork or compositor decorations, or synchronize native appearance.
885    /// Focus uses `accent`, except in high contrast where it uses `colors.text`.
886    pub colors: Option<ShellColors>,
887    /// Override system high contrast.
888    pub high_contrast: Option<bool>,
889    /// Override the sRGB focus accent as 0xRRGGBB.
890    pub accent: Option<u32>,
891    /// Override logical text/target scaling, independent of device DPI.
892    pub text_scale: Option<f32>,
893    /// Override client-frame typography; None parses the desktop description or uses system UI.
894    pub typography: Option<ShellTypography>,
895    /// Resolve motion policy for consumers; the current frame has no animations.
896    pub reduced_motion: Option<bool>,
897    /// Override the desktop double-click action; fallback is maximize/restore.
898    pub double_click_action: Option<TitlebarAction>,
899    /// Override the desktop middle-click action; fallback is no action.
900    pub middle_click_action: Option<TitlebarAction>,
901    /// Override the desktop right-click action; fallback is the window menu.
902    pub right_click_action: Option<TitlebarAction>,
903}
904
905fn shell_scale(scale: f32) -> f32 {
906    if scale.is_finite() {
907        scale.clamp(0.5, 4.)
908    } else {
909        1.
910    }
911}
912
913/// The values actually used by the shared client frame after overrides and fallbacks.
914#[derive(Clone, Debug, PartialEq)]
915pub struct ResolvedShellPreferences {
916    /// Explicit titlebar background, applied after palette resolution.
917    pub titlebar_background: Option<u32>,
918    /// Explicit palette; None follows appearance, activation and contrast.
919    pub colors: Option<ShellColors>,
920    /// Whether the frame uses high-contrast colors.
921    pub high_contrast: bool,
922    /// Encoded sRGB focus accent, 0xRRGGBB.
923    pub accent: u32,
924    /// Logical text/target scale, clamped to 0.5–4 independently of device DPI.
925    pub text_scale: f32,
926    /// Unscaled client-frame typography.
927    pub typography: ShellTypography,
928    /// Resolved policy; no frame animations are currently implemented.
929    pub reduced_motion: bool,
930    /// Effective title-bar double-click action.
931    pub double_click_action: TitlebarAction,
932    /// Effective title-bar middle-click action.
933    pub middle_click_action: TitlebarAction,
934    /// Effective title-bar right-click action.
935    pub right_click_action: TitlebarAction,
936}
937
938impl ShellOptions {
939    /// Resolve the same values used by the client frame. This does not apply them to
940    /// native/server decorations or alter desktop settings. Useful for diagnostics.
941    pub fn resolved_preferences(&self, desktop: &DesktopPreferences) -> ResolvedShellPreferences {
942        let accent = desktop
943            .accent_color
944            .filter(|c| c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)))
945            .map(|[r, g, b]| {
946                ((r * 255.).round() as u32) << 16
947                    | ((g * 255.).round() as u32) << 8
948                    | (b * 255.).round() as u32
949            });
950        ResolvedShellPreferences {
951            titlebar_background: self.titlebar_background,
952            colors: self.colors,
953            high_contrast: self
954                .high_contrast
955                .or(desktop.high_contrast)
956                .unwrap_or(false),
957            accent: self.accent.or(accent).unwrap_or(0x3584e4),
958            text_scale: shell_scale(self.text_scale.or(desktop.text_scale).unwrap_or(1.)),
959            typography: self
960                .typography
961                .clone()
962                .or_else(|| {
963                    desktop
964                        .font
965                        .as_deref()
966                        .and_then(ShellTypography::from_desktop_description)
967                })
968                .unwrap_or_default()
969                .normalized(),
970            reduced_motion: self
971                .reduced_motion
972                .or(desktop.reduced_motion)
973                .unwrap_or(false),
974            double_click_action: self
975                .double_click_action
976                .or_else(|| {
977                    desktop
978                        .titlebar_double_click
979                        .map(TitlebarAction::from_platform)
980                })
981                .unwrap_or(TitlebarAction::ToggleMaximize),
982            middle_click_action: self
983                .middle_click_action
984                .or_else(|| {
985                    desktop
986                        .titlebar_middle_click
987                        .map(TitlebarAction::from_platform)
988                })
989                .unwrap_or(TitlebarAction::None),
990            right_click_action: self
991                .right_click_action
992                .or_else(|| {
993                    desktop
994                        .titlebar_right_click
995                        .map(TitlebarAction::from_platform)
996                })
997                .unwrap_or(TitlebarAction::Menu),
998        }
999    }
1000}
1001
1002/// Client-frame colors after appearance, activation and contrast resolution.
1003#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1004pub struct ShellColors {
1005    pub header: u32,
1006    pub text: u32,
1007    pub border: u32,
1008    pub hover: u32,
1009}
1010
1011/// The exact logical style tokens consumed by the shared frame renderer.
1012/// Does not theme application content or native/server decorations.
1013#[derive(Clone, Debug, PartialEq)]
1014pub struct ResolvedShellLook {
1015    pub colors: ShellColors,
1016    /// Effective focus-ring color (foreground in high contrast).
1017    pub focus: u32,
1018    /// Typography with logical text scaling already applied once.
1019    pub typography: ShellTypography,
1020    pub titlebar_height: f32,
1021    pub button_size: f32,
1022    /// Motion policy only; current hover/focus changes are instantaneous.
1023    pub reduced_motion: bool,
1024}
1025
1026impl ResolvedShellPreferences {
1027    /// Resolve the same style tokens used by the native client frame and its preview.
1028    pub fn resolved_look(&self, appearance: WindowAppearance, active: bool) -> ResolvedShellLook {
1029        let mut colors = self
1030            .colors
1031            .unwrap_or_else(|| ShellColors::new(appearance, active, self.high_contrast));
1032        if let Some(background) = self.titlebar_background {
1033            colors.header = background;
1034        }
1035        let mut typography = self.typography.clone();
1036        typography.size *= self.text_scale;
1037        ResolvedShellLook {
1038            colors,
1039            focus: if self.high_contrast {
1040                colors.text
1041            } else {
1042                self.accent
1043            },
1044            titlebar_height: (40.0 * self.text_scale)
1045                .max(typography.size * 1.5 + 12.0 * self.text_scale),
1046            button_size: 33.0 * self.text_scale,
1047            typography,
1048            reduced_motion: self.reduced_motion,
1049        }
1050    }
1051}
1052
1053/// Supported title-bar interaction, resolved from desktop preferences or an explicit override.
1054#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1055pub enum TitlebarAction {
1056    #[default]
1057    ToggleMaximize,
1058    Minimize,
1059    None,
1060    /// Display the platform window menu when supported.
1061    Menu,
1062}
1063impl TitlebarAction {
1064    fn from_platform(action: gpui::TitlebarAction) -> Self {
1065        match action {
1066            gpui::TitlebarAction::None => Self::None,
1067            gpui::TitlebarAction::ToggleMaximize => Self::ToggleMaximize,
1068            gpui::TitlebarAction::Minimize => Self::Minimize,
1069            gpui::TitlebarAction::Menu => Self::Menu,
1070        }
1071    }
1072
1073    fn perform_at(self, window: &mut Window, position: Point<Pixels>) {
1074        match self {
1075            Self::ToggleMaximize if window.window_controls().maximize && window.is_resizable() => {
1076                window.zoom_window()
1077            }
1078            Self::Minimize if window.window_controls().minimize && window.is_minimizable() => {
1079                window.minimize_window()
1080            }
1081            Self::Menu if window.window_controls().window_menu => window.show_window_menu(position),
1082            _ => (),
1083        }
1084    }
1085}
1086
1087mod frame {
1088    use super::*;
1089    pub(super) fn visible_buttons(
1090        side: [Option<WindowButton>; MAX_BUTTONS_PER_SIDE],
1091        controls: WindowControls,
1092    ) -> Vec<WindowButton> {
1093        side.into_iter()
1094            .flatten()
1095            .filter(|button| match button {
1096                WindowButton::Minimize => controls.minimize,
1097                WindowButton::Maximize => controls.maximize,
1098                WindowButton::Close => true,
1099            })
1100            .collect()
1101    }
1102    pub(super) fn control_label(button: WindowButton, maximized: bool) -> &'static str {
1103        match button {
1104            WindowButton::Minimize => "Minimize window",
1105            WindowButton::Maximize if maximized => "Restore window",
1106            WindowButton::Maximize => "Maximize window",
1107            WindowButton::Close => "Close window",
1108        }
1109    }
1110    pub(super) fn activate_control(button: WindowButton, window: &mut Window) {
1111        // Capabilities may have changed between rendering and activation.
1112        match button {
1113            WindowButton::Minimize
1114                if window.window_controls().minimize && window.is_minimizable() =>
1115            {
1116                window.minimize_window()
1117            }
1118            WindowButton::Maximize
1119                if window.window_controls().maximize && window.is_resizable() =>
1120            {
1121                window.zoom_window()
1122            }
1123            WindowButton::Close => window.remove_window(),
1124            _ => (),
1125        }
1126    }
1127    pub(super) type Palette = ShellColors;
1128    impl Palette {
1129        pub(super) fn new(appearance: WindowAppearance, active: bool, contrast: bool) -> Self {
1130            let dark = matches!(
1131                appearance,
1132                WindowAppearance::Dark | WindowAppearance::VibrantDark
1133            );
1134            match (dark, active, contrast) {
1135                (true, _, true) => Self {
1136                    header: 0x000000,
1137                    text: 0xffffff,
1138                    border: 0xffffff,
1139                    hover: 0x333333,
1140                },
1141                (false, _, true) => Self {
1142                    header: 0xffffff,
1143                    text: 0x000000,
1144                    border: 0x000000,
1145                    hover: 0xdddddd,
1146                },
1147                (true, true, false) => Self {
1148                    header: 0x303030,
1149                    text: 0xf6f5f4,
1150                    border: 0x141414,
1151                    hover: 0x484848,
1152                },
1153                (true, false, false) => Self {
1154                    header: 0x282828,
1155                    text: 0xaaaaaa,
1156                    border: 0x202020,
1157                    hover: 0x404040,
1158                },
1159                (false, true, false) => Self {
1160                    header: 0xebebeb,
1161                    text: 0x202020,
1162                    border: 0xb8b8b8,
1163                    hover: 0xd8d8d8,
1164                },
1165                (false, false, false) => Self {
1166                    header: 0xf5f5f5,
1167                    text: 0x707070,
1168                    border: 0xd5d5d5,
1169                    hover: 0xe3e3e3,
1170                },
1171            }
1172        }
1173    }
1174    pub(super) struct ControlTooltip(pub(super) &'static str);
1175    impl Render for ControlTooltip {
1176        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1177            let palette = Palette::new(window.appearance(), true, false);
1178            div()
1179                .px_2()
1180                .py_1()
1181                .rounded_md()
1182                .bg(rgb(palette.header))
1183                .text_color(rgb(palette.text))
1184                .child(self.0)
1185        }
1186    }
1187    pub(super) fn control_button(
1188        id: &'static str,
1189        label: &'static str,
1190        palette: Palette,
1191        accent: u32,
1192        size: Pixels,
1193        interactive: bool,
1194        (preview_focus, preview_hover): (bool, bool),
1195    ) -> Stateful<Div> {
1196        div()
1197            .id(id)
1198            .role(if interactive {
1199                Role::Button
1200            } else {
1201                Role::Image
1202            })
1203            .aria_label(if interactive {
1204                label.to_owned()
1205            } else {
1206                format!("Preview: {label}")
1207            })
1208            .when(interactive, |s| {
1209                s.focusable().tab_stop(!NATIVE_CAPTION_CLICKS)
1210            })
1211            .size(size)
1212            .flex_none()
1213            .flex()
1214            .items_center()
1215            .justify_center()
1216            .rounded_full()
1217            .border_2()
1218            .border_color(transparent_black())
1219            .when(preview_focus, |s| s.border_color(rgb(accent)))
1220            .when(preview_hover, |s| s.bg(rgb(palette.hover)))
1221            .when(interactive, |s| {
1222                s.cursor_pointer().hover(move |s| s.bg(rgb(palette.hover)))
1223            })
1224            .focus_visible(move |s| s.border_color(rgb(accent)))
1225            .when(interactive, |s| {
1226                // No tooltip over the Windows Snap Layouts flyout (see `shows_tooltip`).
1227                s.when(
1228                    !(cfg!(target_os = "windows") && id == WindowButton::Maximize.id()),
1229                    |s| s.tooltip(move |_, cx| cx.new(|_| ControlTooltip(label)).into()),
1230                )
1231                .on_mouse_down(MouseButton::Left, |_, _, cx| {
1232                    if !NATIVE_CAPTION_CLICKS {
1233                        cx.stop_propagation()
1234                    }
1235                })
1236            })
1237    }
1238
1239    #[cfg(test)]
1240    mod tests {
1241        use super::*;
1242        #[::core::prelude::v1::test]
1243        fn omitted_and_unavailable_buttons_are_not_reintroduced() {
1244            let close_only = WindowButtonLayout::parse(":close").unwrap();
1245            assert_eq!(
1246                visible_buttons(close_only.right, WindowControls::default()),
1247                vec![WindowButton::Close]
1248            );
1249            let none = WindowButtonLayout::parse(":").unwrap();
1250            assert!(visible_buttons(none.left, WindowControls::default()).is_empty());
1251            assert!(visible_buttons(none.right, WindowControls::default()).is_empty());
1252            let controls = WindowControls {
1253                minimize: false,
1254                maximize: false,
1255                ..Default::default()
1256            };
1257            assert_eq!(
1258                visible_buttons(WindowButtonLayout::linux_default().right, controls),
1259                vec![WindowButton::Close]
1260            );
1261        }
1262        #[::core::prelude::v1::test]
1263        fn simulated_hover_reaches_the_control_background_without_enabling_actions() {
1264            let palette = Palette::new(WindowAppearance::Light, true, false);
1265            let mut normal = control_button(
1266                "normal",
1267                "Close",
1268                palette,
1269                0x3584e4,
1270                px(33.),
1271                false,
1272                (false, false),
1273            );
1274            let mut hovered = control_button(
1275                "hovered",
1276                "Close",
1277                palette,
1278                0x3584e4,
1279                px(33.),
1280                false,
1281                (false, true),
1282            );
1283            assert!(normal.style().background.is_none());
1284            assert!(hovered.style().background.is_some());
1285        }
1286
1287        #[::core::prelude::v1::test]
1288        fn light_dark_inactive_and_contrast_have_distinct_tokens() {
1289            let light = Palette::new(WindowAppearance::Light, true, false);
1290            let dark = Palette::new(WindowAppearance::Dark, true, false);
1291            assert_ne!(light.header, dark.header);
1292            assert_ne!(
1293                light.text,
1294                Palette::new(WindowAppearance::Light, false, false).text
1295            );
1296            assert_eq!(
1297                Palette::new(WindowAppearance::Dark, true, true).text,
1298                0xffffff
1299            );
1300            assert_eq!(
1301                Palette::new(WindowAppearance::Light, true, true).text,
1302                0x000000
1303            );
1304        }
1305        #[::core::prelude::v1::test]
1306        fn invalid_text_scales_cannot_break_layout() {
1307            assert_eq!(shell_scale(f32::NAN), 1.);
1308            assert_eq!(shell_scale(f32::INFINITY), 1.);
1309            assert_eq!(shell_scale(-1.), 0.5);
1310            assert_eq!(shell_scale(1.5), 1.5);
1311            assert_eq!(shell_scale(100.), 4.);
1312        }
1313    }
1314}
1315
1316#[cfg(test)]
1317mod input_tests {
1318    use super::*;
1319
1320    #[::core::prelude::v1::test]
1321    fn desktop_preferences_follow_live_values_and_explicit_overrides_win() {
1322        let desktop = DesktopPreferences {
1323            high_contrast: Some(true),
1324            accent_color: Some([1., 0., 0.]),
1325            text_scale: Some(1.5),
1326            titlebar_double_click: Some(gpui::TitlebarAction::Minimize),
1327            titlebar_middle_click: Some(gpui::TitlebarAction::Menu),
1328            ..Default::default()
1329        };
1330        let inherited = ShellOptions::default().resolved_preferences(&desktop);
1331        assert!(inherited.high_contrast);
1332        assert_eq!(inherited.accent, 0xff0000);
1333        assert_eq!(inherited.text_scale, 1.5);
1334        assert_eq!(inherited.double_click_action, TitlebarAction::Minimize);
1335        assert_eq!(inherited.middle_click_action, TitlebarAction::Menu);
1336        assert_eq!(inherited.right_click_action, TitlebarAction::Menu);
1337        let overridden = ShellOptions {
1338            high_contrast: Some(false),
1339            accent: Some(0),
1340            text_scale: Some(1.),
1341            double_click_action: Some(TitlebarAction::None),
1342            middle_click_action: Some(TitlebarAction::None),
1343            right_click_action: Some(TitlebarAction::None),
1344            ..Default::default()
1345        }
1346        .resolved_preferences(&desktop);
1347        assert!(!overridden.high_contrast);
1348        assert_eq!(overridden.accent, 0);
1349        assert_eq!(overridden.text_scale, 1.);
1350        assert_eq!(overridden.double_click_action, TitlebarAction::None);
1351        assert_eq!(overridden.middle_click_action, TitlebarAction::None);
1352        assert_eq!(overridden.right_click_action, TitlebarAction::None);
1353        let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences::default());
1354        assert!(!reset.high_contrast);
1355        assert_eq!(reset.text_scale, 1.);
1356        assert_eq!(reset.double_click_action, TitlebarAction::ToggleMaximize);
1357    }
1358
1359    #[::core::prelude::v1::test]
1360    fn look_resolution_applies_typography_scale_once_and_preserves_explicit_policy() {
1361        let desktop = DesktopPreferences {
1362            font: Some("Noto Sans 12".into()),
1363            text_scale: Some(1.5),
1364            reduced_motion: Some(true),
1365            ..Default::default()
1366        };
1367        let resolved = ShellOptions::default().resolved_preferences(&desktop);
1368        assert_eq!(resolved.typography.size, 16.0);
1369        let look = resolved.resolved_look(WindowAppearance::Light, true);
1370        assert_eq!(look.typography.size, 24.0);
1371        assert!(look.titlebar_height >= look.typography.size * 1.5);
1372        assert!(look.reduced_motion);
1373        let options = ShellOptions {
1374            typography: Some(ShellTypography {
1375                font: font("Explicit Family"),
1376                size: 24.0,
1377            }),
1378            reduced_motion: Some(false),
1379            high_contrast: Some(true),
1380            accent: Some(0xff0000),
1381            ..Default::default()
1382        };
1383        let look = options
1384            .resolved_preferences(&desktop)
1385            .resolved_look(WindowAppearance::Dark, false);
1386        assert_eq!(look.typography.font.family.as_ref(), "Explicit Family");
1387        assert_eq!(look.typography.size, 36.0);
1388        assert_eq!(look.focus, look.colors.text);
1389        assert!(!look.reduced_motion);
1390        let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences {
1391            font: Some("Sans Condensed 12".into()),
1392            ..Default::default()
1393        });
1394        assert_eq!(reset.typography, ShellTypography::default());
1395    }
1396
1397    #[::core::prelude::v1::test]
1398    fn color_overrides_and_resets_follow_explicit_precedence() {
1399        let desktop = DesktopPreferences {
1400            high_contrast: Some(true),
1401            ..Default::default()
1402        };
1403        let palette = ShellColors {
1404            header: 0x123456,
1405            text: 0xffffff,
1406            border: 0x654321,
1407            hover: 0xabcdef,
1408        };
1409        let mut options = ShellOptions {
1410            colors: Some(palette),
1411            titlebar_background: Some(0),
1412            accent: Some(0xff0000),
1413            ..Default::default()
1414        };
1415        for appearance in [WindowAppearance::Light, WindowAppearance::Dark] {
1416            for active in [false, true] {
1417                let look = options
1418                    .resolved_preferences(&desktop)
1419                    .resolved_look(appearance, active);
1420                assert_eq!(
1421                    look.colors,
1422                    ShellColors {
1423                        header: 0,
1424                        ..palette
1425                    }
1426                );
1427                assert_eq!(look.focus, palette.text);
1428            }
1429        }
1430        options.titlebar_background = None;
1431        assert_eq!(
1432            options
1433                .resolved_preferences(&desktop)
1434                .resolved_look(WindowAppearance::Light, true)
1435                .colors,
1436            palette
1437        );
1438        options.high_contrast = Some(false);
1439        assert_eq!(
1440            options
1441                .resolved_preferences(&desktop)
1442                .resolved_look(WindowAppearance::Light, true)
1443                .focus,
1444            0xff0000
1445        );
1446        options.colors = None;
1447        options.titlebar_background = Some(0);
1448        let look = options
1449            .resolved_preferences(&desktop)
1450            .resolved_look(WindowAppearance::Light, true);
1451        assert_eq!(
1452            look.colors,
1453            ShellColors {
1454                header: 0,
1455                ..ShellColors::new(WindowAppearance::Light, true, false)
1456            }
1457        );
1458        options.titlebar_background = None;
1459        options.high_contrast = None;
1460        for active in [false, true] {
1461            assert_eq!(
1462                options
1463                    .resolved_preferences(&desktop)
1464                    .resolved_look(WindowAppearance::Dark, active)
1465                    .colors,
1466                ShellColors::new(WindowAppearance::Dark, active, true)
1467            );
1468        }
1469    }
1470
1471    #[::core::prelude::v1::test]
1472    fn activation_keys_do_not_consume_modified_shortcuts_or_repeats() {
1473        let key = |text: &'static str, held| KeyDownEvent {
1474            keystroke: Keystroke::parse(text).unwrap(),
1475            is_held: held,
1476            prefer_character_input: false,
1477        };
1478        assert!(activation_key(&key("enter", false)));
1479        assert!(activation_key(&key("space", false)));
1480        assert!(!activation_key(&key("ctrl-enter", false)));
1481        assert!(!activation_key(&key("alt-space", false)));
1482        assert!(!activation_key(&key("enter", true)));
1483        assert!(!activation_key(&key("a", false)));
1484    }
1485}
1486
1487#[cfg(test)]
1488mod lifecycle_tests {
1489    use super::*;
1490    use ::core::prelude::v1::test;
1491    struct Content;
1492    impl Render for Content {
1493        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1494            div()
1495        }
1496    }
1497    #[gpui::test]
1498    fn independent_titles_are_released_on_close(cx: &mut TestAppContext) {
1499        let (one, two) = cx.update(|cx| {
1500            let mut open = |title: &str| {
1501                cx.open_shell_window(
1502                    WindowOptions {
1503                        titlebar: Some(TitlebarOptions {
1504                            title: Some(title.to_owned().into()),
1505                            ..Default::default()
1506                        }),
1507                        ..Default::default()
1508                    },
1509                    ShellOptions::default(),
1510                    |_, cx| cx.new(|_| Content),
1511                )
1512                .unwrap()
1513            };
1514            (open("First"), open("Second"))
1515        });
1516        let one_id = one.window_id();
1517        let two_id = two.window_id();
1518        cx.update_window(*one, |_, window, _| {
1519            assert_eq!(window.shell_title(), "First");
1520            window.set_shell_title("Updated");
1521        })
1522        .unwrap();
1523        cx.update_window(*two, |_, window, _| {
1524            assert_eq!(window.shell_title(), "Second")
1525        })
1526        .unwrap();
1527        cx.update_window(*one, |_, window, _| {
1528            assert_eq!(window.shell_title(), "Updated");
1529            window.remove_window();
1530        })
1531        .unwrap();
1532        cx.run_until_parked();
1533        SHELL_TITLES.with(|titles| {
1534            assert!(!titles.borrow().contains_key(&one_id));
1535            assert!(titles.borrow().contains_key(&two_id));
1536        });
1537        cx.update_window(*two, |_, window, _| window.remove_window())
1538            .unwrap();
1539        cx.run_until_parked();
1540        SHELL_TITLES.with(|titles| assert!(!titles.borrow().contains_key(&two_id)));
1541    }
1542}
1543
1544#[cfg(test)]
1545mod preview_tests {
1546    use super::*;
1547    use ::core::prelude::v1::test;
1548
1549    struct PreviewHost;
1550    impl Render for PreviewHost {
1551        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1552            div().size_full().child(ClientFramePreview::new(
1553                "Preview only",
1554                ShellOptions::default(),
1555                div().size_full().child("Content"),
1556            ))
1557        }
1558    }
1559
1560    struct ContentStyleHost(std::rc::Rc<RefCell<Option<SharedString>>>);
1561    impl Render for ContentStyleHost {
1562        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1563            let observed = self.0.clone();
1564            div()
1565                .size_full()
1566                .font_family("Content Typeface")
1567                .child(ClientFramePreview::new(
1568                    "Different chrome typography",
1569                    ShellOptions {
1570                        typography: Some(ShellTypography {
1571                            font: font("Frame Typeface"),
1572                            size: 30.0,
1573                        }),
1574                        ..Default::default()
1575                    },
1576                    canvas(
1577                        move |_, window, _| {
1578                            *observed.borrow_mut() = Some(window.text_style().font_family);
1579                        },
1580                        |_, _, _, _| (),
1581                    )
1582                    .size_full(),
1583                ))
1584        }
1585    }
1586
1587    #[gpui::test]
1588    fn client_frame_typography_does_not_leak_into_application_content(cx: &mut TestAppContext) {
1589        let observed = std::rc::Rc::new(RefCell::new(None));
1590        let copy = observed.clone();
1591        let host = cx.open_window(size(px(600.), px(240.)), move |_, _| ContentStyleHost(copy));
1592        let mut test_window = VisualTestContext::from_window(host.into(), cx);
1593        test_window.update(|window, app| window.draw(app).clear(app));
1594        assert_eq!(
1595            observed.borrow().as_ref().map(|s| s.as_ref()),
1596            Some("Content Typeface")
1597        );
1598    }
1599
1600    #[gpui::test]
1601    fn preview_pointer_events_do_not_change_or_close_the_host(cx: &mut TestAppContext) {
1602        let host = cx.open_window(size(px(600.), px(200.)), |_, _| PreviewHost);
1603        let mut test_window = VisualTestContext::from_window(host.into(), cx);
1604        test_window.update(|window, app| window.draw(app).clear(app));
1605        let before = cx
1606            .update_window(*host, |_, window, _| {
1607                (
1608                    window.window_bounds(),
1609                    window.window_decorations(),
1610                    window.client_inset(),
1611                    window.is_maximized(),
1612                    window.is_fullscreen(),
1613                )
1614            })
1615            .unwrap();
1616        // The three preview window controls, title drag area, and resize edge must
1617        // all remain decorative even though they share the real frame's renderer.
1618        for position in [
1619            point(px(575.), px(20.)),
1620            point(px(542.), px(20.)),
1621            point(px(509.), px(20.)),
1622            point(px(300.), px(20.)),
1623            point(px(2.), px(80.)),
1624        ] {
1625            test_window.simulate_click(position, Modifiers::default());
1626        }
1627        test_window.simulate_event(MouseDownEvent {
1628            position: point(px(300.), px(20.)),
1629            button: MouseButton::Left,
1630            click_count: 2,
1631            first_mouse: false,
1632            modifiers: Modifiers::default(),
1633        });
1634        test_window.simulate_mouse_up(
1635            point(px(300.), px(20.)),
1636            MouseButton::Left,
1637            Modifiers::default(),
1638        );
1639        let after = cx
1640            .update_window(*host, |_, window, _| {
1641                (
1642                    window.window_bounds(),
1643                    window.window_decorations(),
1644                    window.client_inset(),
1645                    window.is_maximized(),
1646                    window.is_fullscreen(),
1647                )
1648            })
1649            .expect("preview close must not close the real window");
1650        assert_eq!(before, after);
1651    }
1652}
1653
1654#[cfg(test)]
1655mod composition_tests {
1656    use super::*;
1657    use ::core::prelude::v1::test;
1658    use std::{cell::Cell, rc::Rc};
1659    struct Header(Rc<Cell<usize>>);
1660    impl Render for Header {
1661        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1662            let clicks = self.0.clone();
1663            div()
1664                .size_full()
1665                .flex()
1666                .items_center()
1667                .child(
1668                    div()
1669                        .id("app-button")
1670                        .debug_selector(|| "app-button".into())
1671                        .w(px(32.))
1672                        .h_full()
1673                        .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
1674                        .child("App"),
1675                )
1676                .child(ShellDragRegion::new(
1677                    "app-drag",
1678                    div().size_full().child("App title"),
1679                ))
1680        }
1681    }
1682    struct Body;
1683    impl Render for Body {
1684        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1685            div()
1686                .debug_selector(|| "app-body".into())
1687                .size_full()
1688                .child("Body")
1689        }
1690    }
1691    #[gpui::test]
1692    fn composed_mac_header_reserves_native_control_space_and_preserves_app_clicks(
1693        cx: &mut TestAppContext,
1694    ) {
1695        cx.set_platform_info(PlatformInfo {
1696            os: OperatingSystem::Mac,
1697            target: PlatformTarget::Native,
1698        });
1699        let clicks = Rc::new(Cell::new(0));
1700        let copy = clicks.clone();
1701        let handle = cx.update(|app| {
1702            app.open_shell_window_with_titlebar(
1703                WindowOptions {
1704                    window_bounds: Some(WindowBounds::Windowed(Bounds {
1705                        origin: Point::default(),
1706                        size: size(px(600.), px(240.)),
1707                    })),
1708                    ..Default::default()
1709                },
1710                ShellOptions::default(),
1711                |_, app| (app.new(|_| Header(copy)), app.new(|_| Body)),
1712            )
1713            .unwrap()
1714        });
1715        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1716        visual.update(|window, app| window.draw(app).clear(app));
1717        let button = visual
1718            .debug_bounds("app-button")
1719            .expect("app header rendered");
1720        let body = visual.debug_bounds("app-body").expect("app body rendered");
1721        let header = visual.debug_bounds("shell-titlebar-bounds").unwrap();
1722        assert!(
1723            button.left() >= px(80.),
1724            "reserve native traffic-light space"
1725        );
1726        assert_eq!(body.top(), header.bottom());
1727        visual.simulate_click(button.center(), Modifiers::default());
1728        assert_eq!(clicks.get(), 1);
1729        assert_eq!(visual.debug_bounds("app-body").unwrap(), body);
1730        let identities = cx.update(|app| {
1731            let shell = handle.read(app).unwrap();
1732            (
1733                shell.content.entity_id(),
1734                shell.titlebar.as_ref().unwrap().entity_id(),
1735            )
1736        });
1737        let focus = visual.update(|window, app| {
1738            let focus = app.focus_handle();
1739            window.focus(&focus, app);
1740            focus
1741        });
1742        for background in [Some(0), Some(0xececed), Some(0xf0f0f0), None] {
1743            cx.update(|app| {
1744                handle
1745                    .update(app, |shell, _, cx| {
1746                        shell.set_options(
1747                            ShellOptions {
1748                                titlebar_background: background,
1749                                ..Default::default()
1750                            },
1751                            cx,
1752                        );
1753                        assert_eq!(
1754                            (
1755                                shell.content.entity_id(),
1756                                shell.titlebar.as_ref().unwrap().entity_id()
1757                            ),
1758                            identities
1759                        );
1760                    })
1761                    .unwrap();
1762            });
1763            visual.update(|window, app| window.draw(app).clear(app));
1764            assert_eq!(visual.debug_bounds("app-body").unwrap(), body);
1765            visual.update(|window, _| assert!(focus.is_focused(window)));
1766            if let Some(background) = background {
1767                visual.update(|window, _| {
1768                    let quad = window
1769                        .painted_quads()
1770                        .into_iter()
1771                        .find(|quad| quad.background == rgb(background).into())
1772                        .expect("shared header background is painted");
1773                    let visible = quad.bounds.intersect(&quad.content_mask.bounds);
1774                    assert_eq!(visible, header.scale(window.scale_factor()));
1775                });
1776            }
1777            visual.simulate_click(button.center(), Modifiers::default());
1778        }
1779        assert_eq!(
1780            clicks.get(),
1781            5,
1782            "one callback per click through option updates"
1783        );
1784    }
1785}
1786
1787#[cfg(test)]
1788mod presenter_tests {
1789    use super::*;
1790    use ::core::prelude::v1::test;
1791    use std::rc::Rc;
1792
1793    type Seen = Rc<RefCell<Vec<ShellControlState>>>;
1794
1795    fn recording(seen: &Seen) -> ShellControlPresenter {
1796        let seen = seen.clone();
1797        ShellControlPresenter::new(move |state, _, _| {
1798            seen.borrow_mut().push(state.clone());
1799            div()
1800                .debug_selector(|| "app-artwork".into())
1801                .size(px(16. * state.scale))
1802                .into_any_element()
1803        })
1804    }
1805
1806    struct Header;
1807    impl Render for Header {
1808        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1809            div().size_full().child("App header")
1810        }
1811    }
1812    struct Body;
1813    impl Render for Body {
1814        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1815            div().size_full()
1816        }
1817    }
1818
1819    /// A Windows app-composed title bar: the shell draws min/max/close itself.
1820    fn open_windows_shell(cx: &mut TestAppContext, seen: &Seen) -> WindowHandle<ShellWindow> {
1821        cx.set_platform_info(PlatformInfo {
1822            os: OperatingSystem::Windows,
1823            target: PlatformTarget::Native,
1824        });
1825        let presenter = recording(seen);
1826        let handle = cx.update(|app| {
1827            app.open_shell_window_with_titlebar(
1828                WindowOptions {
1829                    window_bounds: Some(WindowBounds::Windowed(Bounds {
1830                        origin: Point::default(),
1831                        size: size(px(600.), px(240.)),
1832                    })),
1833                    ..Default::default()
1834                },
1835                ShellOptions::default(),
1836                |_, app| (app.new(|_| Header), app.new(|_| Body)),
1837            )
1838            .unwrap()
1839        });
1840        handle
1841            .update(cx, |shell, _, cx| {
1842                shell.set_control_presenter(Some(presenter), cx)
1843            })
1844            .unwrap();
1845        handle
1846    }
1847
1848    fn last_frame(seen: &Seen, count: usize) -> Vec<ShellControlState> {
1849        let seen = seen.borrow();
1850        seen[seen.len() - count..].to_vec()
1851    }
1852
1853    #[gpui::test]
1854    fn presenter_draws_each_visible_control_with_shell_order_and_identity(cx: &mut TestAppContext) {
1855        let seen = Seen::default();
1856        let handle = open_windows_shell(cx, &seen);
1857        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1858        visual.update(|window, app| window.draw(app).clear(app));
1859        let frame = last_frame(&seen, 3);
1860        assert_eq!(
1861            frame.iter().map(|s| s.control).collect::<Vec<_>>(),
1862            [
1863                ShellControl::Minimize,
1864                ShellControl::Maximize,
1865                ShellControl::Close
1866            ]
1867        );
1868        for state in &frame {
1869            assert!(!state.preview && !state.hovered && !state.pressed && !state.focused);
1870            assert_eq!(state.target_size, px(33.));
1871            assert_eq!(state.scale, 1.);
1872        }
1873        // The built-in glyphs are replaced, not drawn underneath.
1874        assert!(visual.debug_bounds("app-artwork").is_some());
1875    }
1876
1877    #[gpui::test]
1878    fn hover_and_press_reach_the_presenter(cx: &mut TestAppContext) {
1879        let seen = Seen::default();
1880        let handle = open_windows_shell(cx, &seen);
1881        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1882        visual.update(|window, app| window.draw(app).clear(app));
1883        let close = visual.debug_bounds("shell-control-close").unwrap();
1884        visual.simulate_mouse_move(close.center(), None, Modifiers::default());
1885        visual.update(|window, app| window.draw(app).clear(app));
1886        let close_state = last_frame(&seen, 3)[2].clone();
1887        assert_eq!(close_state.control, ShellControl::Close);
1888        assert!(close_state.hovered);
1889        assert!(!last_frame(&seen, 3)[0].hovered, "only Close is hovered");
1890        visual.simulate_mouse_down(close.center(), MouseButton::Left, Modifiers::default());
1891        visual.update(|window, app| window.draw(app).clear(app));
1892        assert!(last_frame(&seen, 3)[2].pressed);
1893    }
1894
1895    #[gpui::test]
1896    fn presented_close_activates_once_without_starting_a_window_move(cx: &mut TestAppContext) {
1897        let seen = Seen::default();
1898        let handle = open_windows_shell(cx, &seen);
1899        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1900        visual.update(|window, app| window.draw(app).clear(app));
1901        let close = visual.debug_bounds("shell-control-close").unwrap();
1902        // The test window panics on start_window_move, so a drag would fail here.
1903        visual.simulate_click(close.center(), Modifiers::default());
1904        cx.run_until_parked();
1905        // On Windows the press is left to the platform's caption-button handling,
1906        // which the test platform doesn't have, so the shell must not close it.
1907        assert_eq!(
1908            handle.update(cx, |_, _, _| ()).is_err(),
1909            !NATIVE_CAPTION_CLICKS,
1910            "the shell closes on click everywhere except Windows"
1911        );
1912    }
1913
1914    #[gpui::test]
1915    fn removing_the_presenter_restores_the_builtin_controls(cx: &mut TestAppContext) {
1916        let seen = Seen::default();
1917        let handle = open_windows_shell(cx, &seen);
1918        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1919        visual.update(|window, app| window.draw(app).clear(app));
1920        assert!(visual.debug_bounds("app-artwork").is_some());
1921        handle
1922            .update(cx, |shell, _, cx| shell.set_control_presenter(None, cx))
1923            .unwrap();
1924        let before = seen.borrow().len();
1925        let mut visual = VisualTestContext::from_window(handle.into(), cx);
1926        visual.update(|window, app| window.draw(app).clear(app));
1927        assert_eq!(seen.borrow().len(), before, "presenter no longer called");
1928        assert!(visual.debug_bounds("app-artwork").is_none());
1929    }
1930
1931    struct PreviewHost(ShellControlPresenter);
1932    impl Render for PreviewHost {
1933        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1934            let mut preview =
1935                ClientFramePreview::new("Preview", ShellOptions::default(), div().size_full());
1936            preview.maximized = true;
1937            preview.show_hover = true;
1938            preview.show_focus = true;
1939            preview.control_presenter = Some(self.0.clone());
1940            div().size_full().child(preview)
1941        }
1942    }
1943
1944    #[gpui::test]
1945    fn preview_uses_the_presenter_with_simulated_state_and_stays_inert(cx: &mut TestAppContext) {
1946        let seen = Seen::default();
1947        let presenter = recording(&seen);
1948        let host = cx.open_window(size(px(600.), px(200.)), move |_, _| PreviewHost(presenter));
1949        let mut visual = VisualTestContext::from_window(host.into(), cx);
1950        visual.update(|window, app| window.draw(app).clear(app));
1951        let frame = last_frame(&seen, 3);
1952        assert_eq!(
1953            frame.iter().map(|s| s.control).collect::<Vec<_>>(),
1954            [
1955                ShellControl::Minimize,
1956                ShellControl::Restore,
1957                ShellControl::Close
1958            ]
1959        );
1960        assert!(frame.iter().all(|s| s.preview));
1961        assert!(frame[2].hovered && frame[2].focused);
1962        assert!(!frame[0].hovered && !frame[0].focused);
1963        let close = visual.debug_bounds("shell-control-close").unwrap();
1964        visual.simulate_click(close.center(), Modifiers::default());
1965        cx.run_until_parked();
1966        assert!(
1967            host.update(cx, |_, _, _| ()).is_ok(),
1968            "preview close must not close the host"
1969        );
1970    }
1971}