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