1use 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 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
53pub 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 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 pub fn set_options(&mut self, options: ShellOptions, cx: &mut Context<Self>) {
153 self.shell = options;
154 cx.notify();
155 }
156
157 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 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#[derive(IntoElement)]
302pub struct ClientFramePreview {
303 pub title: SharedString,
305 pub options: ShellOptions,
307 pub appearance: WindowAppearance,
309 pub active: bool,
311 pub maximized: bool,
313 pub fullscreen: bool,
315 pub tiling: Tiling,
317 pub button_layout: Option<WindowButtonLayout>,
319 pub show_focus: bool,
321 pub show_hover: bool,
323 pub control_presenter: Option<ShellControlPresenter>,
325 content: AnyElement,
326}
327
328impl ClientFramePreview {
329 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 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
611fn 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 .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
648const NATIVE_CAPTION_CLICKS: bool = cfg!(target_os = "windows");
651
652fn 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
667struct 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 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 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
833fn 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 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#[derive(Default)]
877pub struct ShellOptions {
878 pub titlebar_background: Option<u32>,
882 pub colors: Option<ShellColors>,
887 pub high_contrast: Option<bool>,
889 pub accent: Option<u32>,
891 pub text_scale: Option<f32>,
893 pub typography: Option<ShellTypography>,
895 pub reduced_motion: Option<bool>,
897 pub double_click_action: Option<TitlebarAction>,
899 pub middle_click_action: Option<TitlebarAction>,
901 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#[derive(Clone, Debug, PartialEq)]
915pub struct ResolvedShellPreferences {
916 pub titlebar_background: Option<u32>,
918 pub colors: Option<ShellColors>,
920 pub high_contrast: bool,
922 pub accent: u32,
924 pub text_scale: f32,
926 pub typography: ShellTypography,
928 pub reduced_motion: bool,
930 pub double_click_action: TitlebarAction,
932 pub middle_click_action: TitlebarAction,
934 pub right_click_action: TitlebarAction,
936}
937
938impl ShellOptions {
939 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#[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#[derive(Clone, Debug, PartialEq)]
1014pub struct ResolvedShellLook {
1015 pub colors: ShellColors,
1016 pub focus: u32,
1018 pub typography: ShellTypography,
1020 pub titlebar_height: f32,
1021 pub button_size: f32,
1022 pub reduced_motion: bool,
1024}
1025
1026impl ResolvedShellPreferences {
1027 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1055pub enum TitlebarAction {
1056 #[default]
1057 ToggleMaximize,
1058 Minimize,
1059 None,
1060 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 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 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 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 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 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 visual.simulate_click(close.center(), Modifiers::default());
1904 cx.run_until_parked();
1905 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}