//! The window controls: minimize, maximize/restore, close — in the platform's //! own dress and behavior. Titlebar strips are app-owned: they move the //! window themselves via [`titlebar_strip`], so a dragged tab or button press //! never moves it; macOS draws its traffic lights in the shell's reserved pad //! and needs no controls of ours. use gpui::WindowControlArea; use gpui::{App, ClickEvent, Div, ElementId, IntoElement, RenderOnce, Stateful, Window, div}; use gpui::{InteractiveElement as _, MouseButton, StatefulInteractiveElement as _}; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui::prelude::FluentBuilder as _; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui::{AnyElement, ParentElement as _}; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui_native_icons::{IconRole, icon}; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui_native_theme::ActiveTheme as _; #[cfg(target_os = "linux")] use gpui::Decorations; #[cfg(target_os = "windows")] use gpui::rgba; /// macOS traffic-lights zone: the pad a leading titlebar strip reserves. pub const TRAFFIC_LIGHT_PAD: f32 = 78.0; /// The width of a Windows caption button. #[cfg(target_os = "windows")] const CAPTION_WIDTH: f32 = 36.0; /// Windows paints its caption close hover red in every theme. #[cfg(target_os = "windows")] const WINDOWS_CLOSE_HOVER: (f32, f32, f32) = (232.0 / 255.0, 17.0 / 255.0, 32.0 / 255.0); /// The window controls for the trailing end of a titlebar strip. #[derive(IntoElement)] pub struct WindowControls { id: ElementId, } impl WindowControls { /// Creates the controls row. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into() } } } impl RenderOnce for WindowControls { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id.clone(); #[cfg(target_os = "macos")] { let _ = (id, window, cx); div() } #[cfg(target_os = "windows")] { let controls = window.window_controls(); div() .flex_none() .flex() .flex_row() .h_full() .when(controls.minimize, |row| { row.child(caption_button( cx, SharedString::from(format!("{id}-minimize")), IconRole::WindowMinimize, WindowControlArea::Min, window.is_minimizable(), false, )) }) .when(controls.maximize, |row| { row.child(caption_button( cx, SharedString::from(format!("{id}-maximize")), if window.is_maximized() { IconRole::WindowRestore } else { IconRole::WindowMaximize }, WindowControlArea::Max, window.is_resizable(), false, )) }) .child(caption_button( cx, SharedString::from(format!("{id}-close")), IconRole::Close, WindowControlArea::Close, true, true, )) } #[cfg(target_os = "linux")] { let _ = id; let controls = window.window_controls(); let client_decorated = matches!(window.window_decorations(), Decorations::Client { .. }); div() .flex_none() .flex() .flex_row() .items_center() .gap_1() .when(client_decorated && controls.minimize, |row| { row.child(circle_button( cx, "window-minimize", IconRole::WindowMinimize, window.is_minimizable(), |window, _cx| window.minimize_window(), )) }) .when(client_decorated && controls.maximize, |row| { row.child(circle_button( cx, "window-maximize", if window.is_maximized() { IconRole::WindowRestore } else { IconRole::WindowMaximize }, window.is_resizable(), |window, _cx| window.zoom_window(), )) }) .when(client_decorated, |row| { row.child(circle_button( cx, "window-close", IconRole::Close, true, |window, _cx| window.remove_window(), )) }) } } } /// Double-click on a titlebar strip maximizes where the platform does not do it. pub fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static { move |event, window, _cx| { if event.click_count() != 2 { return; } #[cfg(target_os = "macos")] window.titlebar_double_click(); #[cfg(target_os = "linux")] window.zoom_window(); #[cfg(target_os = "windows")] { let _ = (event, window); } } } /// Makes an element a titlebar strip: pressing and dragging it moves the /// window, double-clicking maximizes, and Windows keeps its native caption /// behavior through the `Drag` control area. pub fn titlebar_strip(this: Stateful
, window: &mut Window, cx: &mut App) -> Stateful
{ let should_move = window.use_state(cx, |_, _| false); this.window_control_area(WindowControlArea::Drag) .on_mouse_down_out(window.listener_for(&should_move, |should_move, _, _, _| { *should_move = false; })) .on_mouse_down( MouseButton::Left, window.listener_for(&should_move, |should_move, _, _, _| { *should_move = true; }), ) .on_mouse_up( MouseButton::Left, window.listener_for(&should_move, |should_move, _, _, _| { *should_move = false; }), ) .on_mouse_move( window.listener_for(&should_move, |should_move, _, window, cx| { if *should_move { *should_move = false; if !cx.has_active_drag() { window.start_window_move(); } } }), ) .on_click(maximize_on_double_click()) } /// One Windows caption button. #[cfg(target_os = "windows")] fn caption_button( cx: &App, id: ElementId, role: IconRole, area: WindowControlArea, enabled: bool, close: bool, ) -> AnyElement { let theme = cx.theme(); let glyph_size = theme.sizes.small.caption_size; let (close_r, close_g, close_b) = WINDOWS_CLOSE_HOVER; let close_hover = rgba(( (close_r * 255.0) as u8, (close_g * 255.0) as u8, (close_b * 255.0) as u8, 0xff, )); let hover = if close { close_hover } else { theme.colors.muted_hover }; div() .id(id) .debug_selector({ let id = SharedString::from(format!("window-control-{}", role.slug())); move || id.to_string() }) .occlude() .flex() .items_center() .justify_center() .w(px(CAPTION_WIDTH)) .h_full() .window_control_area(area) .text_color(if enabled { theme.colors.foreground } else { theme.colors.muted_foreground }) .when(enabled, |button| { button .cursor_pointer() .hover(|hovered| { hovered.bg(hover).text_color(if close { gpui::white() } else { theme.colors.foreground }) }) .active(|pressed| { pressed .bg(if close { hover.opacity(0.8) } else { theme.ramps.secondary.pressed }) .text_color(theme.colors.foreground) }) }) .child(icon(role).size(glyph_size)) .into_any_element() } /// Adwaita's headerbar control: a small circle around a symbolic glyph. #[cfg(target_os = "linux")] fn circle_button( cx: &App, id: &'static str, role: IconRole, enabled: bool, action: F, ) -> AnyElement where F: Fn(&mut Window, &mut App) + 'static, { let theme = cx.theme(); let small = theme.sizes.small; div() .id(id) .debug_selector(move || format!("window-control-{}", role.slug())) .flex() .items_center() .justify_center() .size(small.control_height) .rounded(theme.radius.full) .text_color(if enabled { theme.colors.foreground } else { theme.colors.muted_foreground }) .when(enabled, |button| { button .cursor_pointer() .hover(|hovered| hovered.bg(theme.colors.muted_hover)) .on_click(move |_, window, cx| { cx.stop_propagation(); action(window, cx); }) }) .child(icon(role).size(small.dense_icon_size)) .into_any_element() }