309 lines
9.8 KiB
Rust
309 lines
9.8 KiB
Rust
//! 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<I>(id: I) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
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<Div>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
|
|
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<F>(
|
|
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()
|
|
}
|