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)]
623pub struct ShellOptions {
624 pub high_contrast: Option<bool>,
626 pub accent: Option<u32>,
628 pub text_scale: Option<f32>,
630 pub typography: Option<ShellTypography>,
632 pub reduced_motion: Option<bool>,
634 pub double_click_action: Option<TitlebarAction>,
636 pub middle_click_action: Option<TitlebarAction>,
638 pub right_click_action: Option<TitlebarAction>,
640}
641
642fn shell_scale(scale: f32) -> f32 {
643 if scale.is_finite() {
644 scale.clamp(0.5, 4.)
645 } else {
646 1.
647 }
648}
649
650#[derive(Clone, Debug, PartialEq)]
652pub struct ResolvedShellPreferences {
653 pub high_contrast: bool,
655 pub accent: u32,
657 pub text_scale: f32,
659 pub typography: ShellTypography,
661 pub reduced_motion: bool,
663 pub double_click_action: TitlebarAction,
665 pub middle_click_action: TitlebarAction,
667 pub right_click_action: TitlebarAction,
669}
670
671impl ShellOptions {
672 pub fn resolved_preferences(&self, desktop: &DesktopPreferences) -> ResolvedShellPreferences {
675 let accent = desktop
676 .accent_color
677 .filter(|c| c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)))
678 .map(|[r, g, b]| {
679 ((r * 255.).round() as u32) << 16
680 | ((g * 255.).round() as u32) << 8
681 | (b * 255.).round() as u32
682 });
683 ResolvedShellPreferences {
684 high_contrast: self
685 .high_contrast
686 .or(desktop.high_contrast)
687 .unwrap_or(false),
688 accent: self.accent.or(accent).unwrap_or(0x3584e4),
689 text_scale: shell_scale(self.text_scale.or(desktop.text_scale).unwrap_or(1.)),
690 typography: self
691 .typography
692 .clone()
693 .or_else(|| {
694 desktop
695 .font
696 .as_deref()
697 .and_then(ShellTypography::from_desktop_description)
698 })
699 .unwrap_or_default()
700 .normalized(),
701 reduced_motion: self
702 .reduced_motion
703 .or(desktop.reduced_motion)
704 .unwrap_or(false),
705 double_click_action: self
706 .double_click_action
707 .or_else(|| {
708 desktop
709 .titlebar_double_click
710 .map(TitlebarAction::from_platform)
711 })
712 .unwrap_or(TitlebarAction::ToggleMaximize),
713 middle_click_action: self
714 .middle_click_action
715 .or_else(|| {
716 desktop
717 .titlebar_middle_click
718 .map(TitlebarAction::from_platform)
719 })
720 .unwrap_or(TitlebarAction::None),
721 right_click_action: self
722 .right_click_action
723 .or_else(|| {
724 desktop
725 .titlebar_right_click
726 .map(TitlebarAction::from_platform)
727 })
728 .unwrap_or(TitlebarAction::Menu),
729 }
730 }
731}
732
733#[derive(Clone, Copy, Debug, PartialEq, Eq)]
735pub struct ShellColors {
736 pub header: u32,
737 pub text: u32,
738 pub border: u32,
739 pub hover: u32,
740}
741
742#[derive(Clone, Debug, PartialEq)]
745pub struct ResolvedShellLook {
746 pub colors: ShellColors,
747 pub focus: u32,
749 pub typography: ShellTypography,
751 pub titlebar_height: f32,
752 pub button_size: f32,
753 pub reduced_motion: bool,
755}
756
757impl ResolvedShellPreferences {
758 pub fn resolved_look(&self, appearance: WindowAppearance, active: bool) -> ResolvedShellLook {
760 let colors = ShellColors::new(appearance, active, self.high_contrast);
761 let mut typography = self.typography.clone();
762 typography.size *= self.text_scale;
763 ResolvedShellLook {
764 colors,
765 focus: if self.high_contrast {
766 colors.text
767 } else {
768 self.accent
769 },
770 titlebar_height: (40.0 * self.text_scale)
771 .max(typography.size * 1.5 + 12.0 * self.text_scale),
772 button_size: 33.0 * self.text_scale,
773 typography,
774 reduced_motion: self.reduced_motion,
775 }
776 }
777}
778
779#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
781pub enum TitlebarAction {
782 #[default]
783 ToggleMaximize,
784 Minimize,
785 None,
786 Menu,
788}
789impl TitlebarAction {
790 fn from_platform(action: gpui::TitlebarAction) -> Self {
791 match action {
792 gpui::TitlebarAction::None => Self::None,
793 gpui::TitlebarAction::ToggleMaximize => Self::ToggleMaximize,
794 gpui::TitlebarAction::Minimize => Self::Minimize,
795 gpui::TitlebarAction::Menu => Self::Menu,
796 }
797 }
798
799 fn perform_at(self, window: &mut Window, position: Point<Pixels>) {
800 match self {
801 Self::ToggleMaximize if window.window_controls().maximize && window.is_resizable() => {
802 window.zoom_window()
803 }
804 Self::Minimize if window.window_controls().minimize && window.is_minimizable() => {
805 window.minimize_window()
806 }
807 Self::Menu if window.window_controls().window_menu => window.show_window_menu(position),
808 _ => (),
809 }
810 }
811}
812
813mod frame {
814 use super::*;
815 pub(super) fn visible_buttons(
816 side: [Option<WindowButton>; MAX_BUTTONS_PER_SIDE],
817 controls: WindowControls,
818 ) -> Vec<WindowButton> {
819 side.into_iter()
820 .flatten()
821 .filter(|button| match button {
822 WindowButton::Minimize => controls.minimize,
823 WindowButton::Maximize => controls.maximize,
824 WindowButton::Close => true,
825 })
826 .collect()
827 }
828 pub(super) fn control_label(button: WindowButton, maximized: bool) -> &'static str {
829 match button {
830 WindowButton::Minimize => "Minimize window",
831 WindowButton::Maximize if maximized => "Restore window",
832 WindowButton::Maximize => "Maximize window",
833 WindowButton::Close => "Close window",
834 }
835 }
836 pub(super) fn activate_control(button: WindowButton, window: &mut Window) {
837 match button {
839 WindowButton::Minimize
840 if window.window_controls().minimize && window.is_minimizable() =>
841 {
842 window.minimize_window()
843 }
844 WindowButton::Maximize
845 if window.window_controls().maximize && window.is_resizable() =>
846 {
847 window.zoom_window()
848 }
849 WindowButton::Close => window.remove_window(),
850 _ => (),
851 }
852 }
853 pub(super) type Palette = ShellColors;
854 impl Palette {
855 pub(super) fn new(appearance: WindowAppearance, active: bool, contrast: bool) -> Self {
856 let dark = matches!(
857 appearance,
858 WindowAppearance::Dark | WindowAppearance::VibrantDark
859 );
860 match (dark, active, contrast) {
861 (true, _, true) => Self {
862 header: 0x000000,
863 text: 0xffffff,
864 border: 0xffffff,
865 hover: 0x333333,
866 },
867 (false, _, true) => Self {
868 header: 0xffffff,
869 text: 0x000000,
870 border: 0x000000,
871 hover: 0xdddddd,
872 },
873 (true, true, false) => Self {
874 header: 0x303030,
875 text: 0xf6f5f4,
876 border: 0x141414,
877 hover: 0x484848,
878 },
879 (true, false, false) => Self {
880 header: 0x282828,
881 text: 0xaaaaaa,
882 border: 0x202020,
883 hover: 0x404040,
884 },
885 (false, true, false) => Self {
886 header: 0xebebeb,
887 text: 0x202020,
888 border: 0xb8b8b8,
889 hover: 0xd8d8d8,
890 },
891 (false, false, false) => Self {
892 header: 0xf5f5f5,
893 text: 0x707070,
894 border: 0xd5d5d5,
895 hover: 0xe3e3e3,
896 },
897 }
898 }
899 }
900 struct ControlTooltip(&'static str);
901 impl Render for ControlTooltip {
902 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
903 let palette = Palette::new(window.appearance(), true, false);
904 div()
905 .px_2()
906 .py_1()
907 .rounded_md()
908 .bg(rgb(palette.header))
909 .text_color(rgb(palette.text))
910 .child(self.0)
911 }
912 }
913 pub(super) fn control_button(
914 id: &'static str,
915 label: &'static str,
916 palette: Palette,
917 accent: u32,
918 size: Pixels,
919 interactive: bool,
920 (preview_focus, preview_hover): (bool, bool),
921 ) -> Stateful<Div> {
922 div()
923 .id(id)
924 .role(if interactive {
925 Role::Button
926 } else {
927 Role::Image
928 })
929 .aria_label(if interactive {
930 label.to_owned()
931 } else {
932 format!("Preview: {label}")
933 })
934 .when(interactive, |s| s.focusable().tab_stop(true))
935 .size(size)
936 .flex_none()
937 .flex()
938 .items_center()
939 .justify_center()
940 .rounded_full()
941 .border_2()
942 .border_color(transparent_black())
943 .when(preview_focus, |s| s.border_color(rgb(accent)))
944 .when(preview_hover, |s| s.bg(rgb(palette.hover)))
945 .when(interactive, |s| {
946 s.cursor_pointer().hover(move |s| s.bg(rgb(palette.hover)))
947 })
948 .focus_visible(move |s| s.border_color(rgb(accent)))
949 .when(interactive, |s| {
950 s.tooltip(move |_, cx| cx.new(|_| ControlTooltip(label)).into())
951 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
952 })
953 }
954
955 #[cfg(test)]
956 mod tests {
957 use super::*;
958 #[::core::prelude::v1::test]
959 fn omitted_and_unavailable_buttons_are_not_reintroduced() {
960 let close_only = WindowButtonLayout::parse(":close").unwrap();
961 assert_eq!(
962 visible_buttons(close_only.right, WindowControls::default()),
963 vec![WindowButton::Close]
964 );
965 let none = WindowButtonLayout::parse(":").unwrap();
966 assert!(visible_buttons(none.left, WindowControls::default()).is_empty());
967 assert!(visible_buttons(none.right, WindowControls::default()).is_empty());
968 let controls = WindowControls {
969 minimize: false,
970 maximize: false,
971 ..Default::default()
972 };
973 assert_eq!(
974 visible_buttons(WindowButtonLayout::linux_default().right, controls),
975 vec![WindowButton::Close]
976 );
977 }
978 #[::core::prelude::v1::test]
979 fn simulated_hover_reaches_the_control_background_without_enabling_actions() {
980 let palette = Palette::new(WindowAppearance::Light, true, false);
981 let mut normal = control_button(
982 "normal",
983 "Close",
984 palette,
985 0x3584e4,
986 px(33.),
987 false,
988 (false, false),
989 );
990 let mut hovered = control_button(
991 "hovered",
992 "Close",
993 palette,
994 0x3584e4,
995 px(33.),
996 false,
997 (false, true),
998 );
999 assert!(normal.style().background.is_none());
1000 assert!(hovered.style().background.is_some());
1001 }
1002
1003 #[::core::prelude::v1::test]
1004 fn light_dark_inactive_and_contrast_have_distinct_tokens() {
1005 let light = Palette::new(WindowAppearance::Light, true, false);
1006 let dark = Palette::new(WindowAppearance::Dark, true, false);
1007 assert_ne!(light.header, dark.header);
1008 assert_ne!(
1009 light.text,
1010 Palette::new(WindowAppearance::Light, false, false).text
1011 );
1012 assert_eq!(
1013 Palette::new(WindowAppearance::Dark, true, true).text,
1014 0xffffff
1015 );
1016 assert_eq!(
1017 Palette::new(WindowAppearance::Light, true, true).text,
1018 0x000000
1019 );
1020 }
1021 #[::core::prelude::v1::test]
1022 fn invalid_text_scales_cannot_break_layout() {
1023 assert_eq!(shell_scale(f32::NAN), 1.);
1024 assert_eq!(shell_scale(f32::INFINITY), 1.);
1025 assert_eq!(shell_scale(-1.), 0.5);
1026 assert_eq!(shell_scale(1.5), 1.5);
1027 assert_eq!(shell_scale(100.), 4.);
1028 }
1029 }
1030}
1031
1032#[cfg(test)]
1033mod input_tests {
1034 use super::*;
1035
1036 #[::core::prelude::v1::test]
1037 fn desktop_preferences_follow_live_values_and_explicit_overrides_win() {
1038 let desktop = DesktopPreferences {
1039 high_contrast: Some(true),
1040 accent_color: Some([1., 0., 0.]),
1041 text_scale: Some(1.5),
1042 titlebar_double_click: Some(gpui::TitlebarAction::Minimize),
1043 titlebar_middle_click: Some(gpui::TitlebarAction::Menu),
1044 ..Default::default()
1045 };
1046 let inherited = ShellOptions::default().resolved_preferences(&desktop);
1047 assert!(inherited.high_contrast);
1048 assert_eq!(inherited.accent, 0xff0000);
1049 assert_eq!(inherited.text_scale, 1.5);
1050 assert_eq!(inherited.double_click_action, TitlebarAction::Minimize);
1051 assert_eq!(inherited.middle_click_action, TitlebarAction::Menu);
1052 assert_eq!(inherited.right_click_action, TitlebarAction::Menu);
1053 let overridden = ShellOptions {
1054 high_contrast: Some(false),
1055 accent: Some(0),
1056 text_scale: Some(1.),
1057 double_click_action: Some(TitlebarAction::None),
1058 middle_click_action: Some(TitlebarAction::None),
1059 right_click_action: Some(TitlebarAction::None),
1060 ..Default::default()
1061 }
1062 .resolved_preferences(&desktop);
1063 assert!(!overridden.high_contrast);
1064 assert_eq!(overridden.accent, 0);
1065 assert_eq!(overridden.text_scale, 1.);
1066 assert_eq!(overridden.double_click_action, TitlebarAction::None);
1067 assert_eq!(overridden.middle_click_action, TitlebarAction::None);
1068 assert_eq!(overridden.right_click_action, TitlebarAction::None);
1069 let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences::default());
1070 assert!(!reset.high_contrast);
1071 assert_eq!(reset.text_scale, 1.);
1072 assert_eq!(reset.double_click_action, TitlebarAction::ToggleMaximize);
1073 }
1074
1075 #[::core::prelude::v1::test]
1076 fn look_resolution_applies_typography_scale_once_and_preserves_explicit_policy() {
1077 let desktop = DesktopPreferences {
1078 font: Some("Noto Sans 12".into()),
1079 text_scale: Some(1.5),
1080 reduced_motion: Some(true),
1081 ..Default::default()
1082 };
1083 let resolved = ShellOptions::default().resolved_preferences(&desktop);
1084 assert_eq!(resolved.typography.size, 16.0);
1085 let look = resolved.resolved_look(WindowAppearance::Light, true);
1086 assert_eq!(look.typography.size, 24.0);
1087 assert!(look.titlebar_height >= look.typography.size * 1.5);
1088 assert!(look.reduced_motion);
1089 let options = ShellOptions {
1090 typography: Some(ShellTypography {
1091 font: font("Explicit Family"),
1092 size: 24.0,
1093 }),
1094 reduced_motion: Some(false),
1095 high_contrast: Some(true),
1096 accent: Some(0xff0000),
1097 ..Default::default()
1098 };
1099 let look = options
1100 .resolved_preferences(&desktop)
1101 .resolved_look(WindowAppearance::Dark, false);
1102 assert_eq!(look.typography.font.family.as_ref(), "Explicit Family");
1103 assert_eq!(look.typography.size, 36.0);
1104 assert_eq!(look.focus, look.colors.text);
1105 assert!(!look.reduced_motion);
1106 let reset = ShellOptions::default().resolved_preferences(&DesktopPreferences {
1107 font: Some("Sans Condensed 12".into()),
1108 ..Default::default()
1109 });
1110 assert_eq!(reset.typography, ShellTypography::default());
1111 }
1112
1113 #[::core::prelude::v1::test]
1114 fn activation_keys_do_not_consume_modified_shortcuts_or_repeats() {
1115 let key = |text: &'static str, held| KeyDownEvent {
1116 keystroke: Keystroke::parse(text).unwrap(),
1117 is_held: held,
1118 prefer_character_input: false,
1119 };
1120 assert!(activation_key(&key("enter", false)));
1121 assert!(activation_key(&key("space", false)));
1122 assert!(!activation_key(&key("ctrl-enter", false)));
1123 assert!(!activation_key(&key("alt-space", false)));
1124 assert!(!activation_key(&key("enter", true)));
1125 assert!(!activation_key(&key("a", false)));
1126 }
1127}
1128
1129#[cfg(test)]
1130mod lifecycle_tests {
1131 use super::*;
1132 use ::core::prelude::v1::test;
1133 struct Content;
1134 impl Render for Content {
1135 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1136 div()
1137 }
1138 }
1139 #[gpui::test]
1140 fn independent_titles_are_released_on_close(cx: &mut TestAppContext) {
1141 let (one, two) = cx.update(|cx| {
1142 let mut open = |title: &str| {
1143 cx.open_shell_window(
1144 WindowOptions {
1145 titlebar: Some(TitlebarOptions {
1146 title: Some(title.to_owned().into()),
1147 ..Default::default()
1148 }),
1149 ..Default::default()
1150 },
1151 ShellOptions::default(),
1152 |_, cx| cx.new(|_| Content),
1153 )
1154 .unwrap()
1155 };
1156 (open("First"), open("Second"))
1157 });
1158 let one_id = one.window_id();
1159 let two_id = two.window_id();
1160 cx.update_window(*one, |_, window, _| {
1161 assert_eq!(window.shell_title(), "First");
1162 window.set_shell_title("Updated");
1163 })
1164 .unwrap();
1165 cx.update_window(*two, |_, window, _| {
1166 assert_eq!(window.shell_title(), "Second")
1167 })
1168 .unwrap();
1169 cx.update_window(*one, |_, window, _| {
1170 assert_eq!(window.shell_title(), "Updated");
1171 window.remove_window();
1172 })
1173 .unwrap();
1174 cx.run_until_parked();
1175 SHELL_TITLES.with(|titles| {
1176 assert!(!titles.borrow().contains_key(&one_id));
1177 assert!(titles.borrow().contains_key(&two_id));
1178 });
1179 cx.update_window(*two, |_, window, _| window.remove_window())
1180 .unwrap();
1181 cx.run_until_parked();
1182 SHELL_TITLES.with(|titles| assert!(!titles.borrow().contains_key(&two_id)));
1183 }
1184}
1185
1186#[cfg(test)]
1187mod preview_tests {
1188 use super::*;
1189 use ::core::prelude::v1::test;
1190
1191 struct PreviewHost;
1192 impl Render for PreviewHost {
1193 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1194 div().size_full().child(ClientFramePreview::new(
1195 "Preview only",
1196 ShellOptions::default(),
1197 div().size_full().child("Content"),
1198 ))
1199 }
1200 }
1201
1202 struct ContentStyleHost(std::rc::Rc<RefCell<Option<SharedString>>>);
1203 impl Render for ContentStyleHost {
1204 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1205 let observed = self.0.clone();
1206 div()
1207 .size_full()
1208 .font_family("Content Typeface")
1209 .child(ClientFramePreview::new(
1210 "Different chrome typography",
1211 ShellOptions {
1212 typography: Some(ShellTypography {
1213 font: font("Frame Typeface"),
1214 size: 30.0,
1215 }),
1216 ..Default::default()
1217 },
1218 canvas(
1219 move |_, window, _| {
1220 *observed.borrow_mut() = Some(window.text_style().font_family);
1221 },
1222 |_, _, _, _| (),
1223 )
1224 .size_full(),
1225 ))
1226 }
1227 }
1228
1229 #[gpui::test]
1230 fn client_frame_typography_does_not_leak_into_application_content(cx: &mut TestAppContext) {
1231 let observed = std::rc::Rc::new(RefCell::new(None));
1232 let copy = observed.clone();
1233 let host = cx.open_window(size(px(600.), px(240.)), move |_, _| ContentStyleHost(copy));
1234 let mut test_window = VisualTestContext::from_window(host.into(), cx);
1235 test_window.update(|window, app| window.draw(app).clear(app));
1236 assert_eq!(
1237 observed.borrow().as_ref().map(|s| s.as_ref()),
1238 Some("Content Typeface")
1239 );
1240 }
1241
1242 #[gpui::test]
1243 fn preview_pointer_events_do_not_change_or_close_the_host(cx: &mut TestAppContext) {
1244 let host = cx.open_window(size(px(600.), px(200.)), |_, _| PreviewHost);
1245 let mut test_window = VisualTestContext::from_window(host.into(), cx);
1246 test_window.update(|window, app| window.draw(app).clear(app));
1247 let before = cx
1248 .update_window(*host, |_, window, _| {
1249 (
1250 window.window_bounds(),
1251 window.window_decorations(),
1252 window.client_inset(),
1253 window.is_maximized(),
1254 window.is_fullscreen(),
1255 )
1256 })
1257 .unwrap();
1258 for position in [
1261 point(px(575.), px(20.)),
1262 point(px(542.), px(20.)),
1263 point(px(509.), px(20.)),
1264 point(px(300.), px(20.)),
1265 point(px(2.), px(80.)),
1266 ] {
1267 test_window.simulate_click(position, Modifiers::default());
1268 }
1269 test_window.simulate_event(MouseDownEvent {
1270 position: point(px(300.), px(20.)),
1271 button: MouseButton::Left,
1272 click_count: 2,
1273 first_mouse: false,
1274 modifiers: Modifiers::default(),
1275 });
1276 test_window.simulate_mouse_up(
1277 point(px(300.), px(20.)),
1278 MouseButton::Left,
1279 Modifiers::default(),
1280 );
1281 let after = cx
1282 .update_window(*host, |_, window, _| {
1283 (
1284 window.window_bounds(),
1285 window.window_decorations(),
1286 window.client_inset(),
1287 window.is_maximized(),
1288 window.is_fullscreen(),
1289 )
1290 })
1291 .expect("preview close must not close the real window");
1292 assert_eq!(before, after);
1293 }
1294}
1295
1296#[cfg(test)]
1297mod composition_tests {
1298 use super::*;
1299 use ::core::prelude::v1::test;
1300 use std::{cell::Cell, rc::Rc};
1301 struct Header(Rc<Cell<usize>>);
1302 impl Render for Header {
1303 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1304 let clicks = self.0.clone();
1305 div()
1306 .size_full()
1307 .flex()
1308 .items_center()
1309 .child(
1310 div()
1311 .id("app-button")
1312 .debug_selector(|| "app-button".into())
1313 .w(px(32.))
1314 .h_full()
1315 .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
1316 .child("App"),
1317 )
1318 .child(ShellDragRegion::new(
1319 "app-drag",
1320 div().size_full().child("App title"),
1321 ))
1322 }
1323 }
1324 struct Body;
1325 impl Render for Body {
1326 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1327 div()
1328 .debug_selector(|| "app-body".into())
1329 .size_full()
1330 .child("Body")
1331 }
1332 }
1333 #[gpui::test]
1334 fn composed_mac_header_reserves_native_control_space_and_preserves_app_clicks(
1335 cx: &mut TestAppContext,
1336 ) {
1337 cx.set_platform_info(PlatformInfo {
1338 os: OperatingSystem::Mac,
1339 target: PlatformTarget::Native,
1340 });
1341 let clicks = Rc::new(Cell::new(0));
1342 let copy = clicks.clone();
1343 let handle = cx.update(|app| {
1344 app.open_shell_window_with_titlebar(
1345 WindowOptions {
1346 window_bounds: Some(WindowBounds::Windowed(Bounds {
1347 origin: Point::default(),
1348 size: size(px(600.), px(240.)),
1349 })),
1350 ..Default::default()
1351 },
1352 ShellOptions::default(),
1353 |_, app| (app.new(|_| Header(copy)), app.new(|_| Body)),
1354 )
1355 .unwrap()
1356 });
1357 let mut visual = VisualTestContext::from_window(handle.into(), cx);
1358 visual.update(|window, app| window.draw(app).clear(app));
1359 let button = visual
1360 .debug_bounds("app-button")
1361 .expect("app header rendered");
1362 let body = visual.debug_bounds("app-body").expect("app body rendered");
1363 let header = visual.debug_bounds("shell-titlebar-bounds").unwrap();
1364 assert!(
1365 button.left() >= px(80.),
1366 "reserve native traffic-light space"
1367 );
1368 assert_eq!(body.top(), header.bottom());
1369 visual.simulate_click(button.center(), Modifiers::default());
1370 assert_eq!(clicks.get(), 1);
1371 assert_eq!(visual.debug_bounds("app-body").unwrap(), body);
1372 }
1373}