use std::sync::Arc;
use gpui::{
div, prelude::FluentBuilder as _, px, AnyElement, App, Decorations, Edges, ElementId,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
ResizeEdge, SharedString, StatefulInteractiveElement, Styled, Tiling, TitlebarOptions, Window,
WindowControlArea, WindowDecorations, WindowOptions,
};
use herogpui_core::element_id;
use herogpui_theme::ActiveTheme;
use crate::a11y::{self, A11y as _};
use crate::{util, IconName};
pub const TITLE_BAR_HEIGHT: Pixels = px(36.);
pub const TRAFFIC_LIGHTS_WIDTH: Pixels = px(80.);
const CONTROL_WIDTH: Pixels = px(46.);
type ActionCallback = Arc<dyn Fn(&WindowAction, &mut Window, &mut App) + 'static>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TitleBarControls {
#[default]
Auto,
Custom,
Hidden,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum WindowAction {
Minimize,
Zoom,
Close,
Move,
DoubleClick,
ShowWindowMenu(Point<Pixels>),
}
impl WindowAction {
pub fn perform(&self, window: &mut Window) {
match *self {
WindowAction::Minimize => window.minimize_window(),
WindowAction::Zoom => window.zoom_window(),
WindowAction::Close => window.remove_window(),
WindowAction::Move => window.start_window_move(),
WindowAction::DoubleClick => {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
WindowAction::ShowWindowMenu(at) => window.show_window_menu(at),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Host {
Mac,
Windows,
Linux,
Web,
}
impl Host {
fn current() -> Self {
if cfg!(target_family = "wasm") {
Host::Web
} else if cfg!(target_os = "macos") {
Host::Mac
} else if cfg!(target_os = "windows") {
Host::Windows
} else {
Host::Linux
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ControlsPlan {
None,
Drawn,
Native,
}
fn controls_plan(controls: TitleBarControls, host: Host, client_decorated: bool) -> ControlsPlan {
match controls {
TitleBarControls::Hidden => ControlsPlan::None,
TitleBarControls::Custom => ControlsPlan::Drawn,
TitleBarControls::Auto => match host {
Host::Mac | Host::Web => ControlsPlan::None,
Host::Windows => ControlsPlan::Native,
Host::Linux if client_decorated => ControlsPlan::Drawn,
Host::Linux => ControlsPlan::None,
},
}
}
#[must_use]
#[derive(IntoElement)]
pub struct TitleBar {
id: ElementId,
title: Option<SharedString>,
children: Vec<AnyElement>,
height: Pixels,
controls: TitleBarControls,
on_window_action: Option<ActionCallback>,
sx: Option<Box<gpui::StyleRefinement>>,
}
impl TitleBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
title: None,
children: Vec::new(),
height: TITLE_BAR_HEIGHT,
controls: TitleBarControls::Auto,
on_window_action: None,
sx: None,
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn height(mut self, height: impl Into<Pixels>) -> Self {
self.height = height.into();
self
}
pub fn controls(mut self, controls: TitleBarControls) -> Self {
self.controls = controls;
self
}
pub fn on_window_action(
mut self,
f: impl Fn(&WindowAction, &mut Window, &mut App) + 'static,
) -> Self {
self.on_window_action = Some(Arc::new(f));
self
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
util::refine_sx(&mut self.sx, style);
self
}
pub fn window_options() -> WindowOptions {
WindowOptions {
titlebar: Some(Self::titlebar_options()),
app_owns_titlebar_drag: true,
window_decorations: Some(WindowDecorations::Client),
..Default::default()
}
}
pub fn titlebar_options() -> TitlebarOptions {
TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(12.), px(12.))),
}
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
fn dispatch(
hook: &Option<ActionCallback>,
action: WindowAction,
window: &mut Window,
cx: &mut App,
) {
match hook {
Some(f) => f(&action, window, cx),
None => action.perform(window),
}
}
fn selector_base(id: &ElementId) -> String {
match id {
ElementId::Name(name) => name.to_string(),
other => format!("{other:?}"),
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let base = self.id.clone();
let selector = selector_base(&base);
let host = Host::current();
let decorations = window.window_decorations();
let client_decorated = matches!(decorations, Decorations::Client { .. });
let plan = controls_plan(self.controls, host, client_decorated);
let armed = window.use_keyed_state(element_id::scoped(&base, "armed"), cx, |_, _| false);
let colors = cx.colors().clone();
let layout = cx.layout().clone();
let hook = self.on_window_action.clone();
let leading = if host == Host::Mac
&& self.controls == TitleBarControls::Auto
&& !window.is_fullscreen()
{
TRAFFIC_LIGHTS_WIDTH
} else {
px(12.)
};
let mut drag = div()
.id(element_id::scoped(&base, "drag"))
.absolute()
.inset_0()
.debug_selector({
let name = format!("{selector}-drag");
move || name
});
if host != Host::Web {
drag = drag.window_control_area(WindowControlArea::Drag);
}
{
let armed_down = armed.clone();
let hook_down = hook.clone();
drag = drag.on_mouse_down(MouseButton::Left, move |event, window, cx| {
if event.click_count >= 2 {
armed_down.update(cx, |a, _| *a = false);
if host != Host::Windows {
dispatch(&hook_down, WindowAction::DoubleClick, window, cx);
}
} else {
armed_down.update(cx, |a, _| *a = true);
}
});
let armed_up = armed.clone();
drag = drag.on_mouse_up(MouseButton::Left, move |_, _, cx| {
armed_up.update(cx, |a, _| *a = false);
});
let armed_out = armed.clone();
drag = drag.on_mouse_down_out(move |_, _, cx| {
armed_out.update(cx, |a, _| *a = false);
});
let hook_move = hook.clone();
drag = drag.on_mouse_move(move |event, window, cx| {
if event.pressed_button != Some(MouseButton::Left) || !*armed.read(cx) {
return;
}
armed.update(cx, |a, _| *a = false);
dispatch(&hook_move, WindowAction::Move, window, cx);
});
if host == Host::Linux && client_decorated {
let hook_menu = hook.clone();
drag = drag.on_mouse_down(MouseButton::Right, move |event, window, cx| {
dispatch(
&hook_menu,
WindowAction::ShowWindowMenu(event.position),
window,
cx,
);
});
}
}
let mut content = div()
.relative()
.flex()
.flex_1()
.min_w_0()
.h_full()
.items_center()
.gap(px(8.))
.pl(leading)
.pr(px(8.));
if let Some(title) = &self.title {
content = content.child(
div()
.flex_shrink_0()
.text_size(px(13.))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(colors.foreground)
.whitespace_nowrap()
.child(title.clone()),
);
}
if !self.children.is_empty() {
content = content.child(
div()
.id(element_id::scoped(&base, "content"))
.flex()
.items_center()
.gap(px(8.))
.min_w_0()
.block_mouse_except_scroll()
.children(self.children),
);
}
let mut bar = div()
.id(base.clone())
.a11y_named(a11y::Role::TitleBar, &a11y::Name::maybe(self.title.clone()))
.relative()
.flex()
.flex_row()
.items_center()
.w_full()
.h(self.height)
.flex_shrink_0()
.bg(colors.surface.background)
.border_b(layout.border_width)
.border_color(colors.separator)
.debug_selector({
let name = format!("{selector}-title-bar");
move || name
})
.child(drag)
.child(content);
if plan != ControlsPlan::None {
let supported = window.window_controls();
let maximized = window.is_maximized();
let mut controls = div()
.relative()
.flex()
.h_full()
.flex_shrink_0()
.block_mouse_except_scroll();
let mut buttons: Vec<(WindowAction, &'static str, SharedString, IconName)> = Vec::new();
if supported.minimize {
buttons.push((
WindowAction::Minimize,
"minimize",
"Minimize".into(),
IconName::Minus,
));
}
if supported.maximize {
let (name, icon) = if maximized {
("Restore", IconName::Copy)
} else {
("Maximize", IconName::Square)
};
buttons.push((WindowAction::Zoom, "zoom", name.into(), icon));
}
buttons.push((
WindowAction::Close,
"close",
crate::i18n::ui_string(crate::i18n::UiString::Close, cx),
IconName::X,
));
for (action, part, name, icon) in buttons {
let is_close = action == WindowAction::Close;
let (hover_bg, hover_fg) = if is_close {
(colors.danger.color, colors.danger.foreground)
} else {
(colors.default.color, colors.foreground)
};
let mut button = div()
.id(element_id::scoped(&base, part))
.a11y_named(a11y::Role::Button, &a11y::Name::labelled(name))
.flex()
.items_center()
.justify_center()
.w(CONTROL_WIDTH)
.h_full()
.text_color(colors.foreground)
.hover(move |s| s.bg(hover_bg).text_color(hover_fg))
.debug_selector({
let name = format!("{selector}-{part}");
move || name
})
.child(
gpui::svg()
.size(px(14.))
.path(icon)
.text_color(colors.foreground),
);
match plan {
ControlsPlan::Native => {
button = button.window_control_area(match action {
WindowAction::Minimize => WindowControlArea::Min,
WindowAction::Zoom => WindowControlArea::Max,
_ => WindowControlArea::Close,
});
}
_ => {
let hook = hook.clone();
button = button
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
dispatch(&hook, action, window, cx);
});
}
}
controls = controls.child(button);
}
bar = bar.child(controls);
}
util::apply_sx(bar, &self.sx)
}
}
pub const WINDOW_SHADOW_SIZE: Pixels = px(12.);
const RESIZE_HIT: Pixels = px(4.);
#[must_use]
#[derive(IntoElement)]
pub struct WindowBorder {
children: Vec<AnyElement>,
shadow_size: Pixels,
}
impl WindowBorder {
pub fn new() -> Self {
Self {
children: Vec::new(),
shadow_size: WINDOW_SHADOW_SIZE,
}
}
pub fn shadow_size(mut self, size: impl Into<Pixels>) -> Self {
self.shadow_size = size.into();
self
}
}
impl Default for WindowBorder {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for WindowBorder {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
fn frame_insets(shadow: Pixels, tiling: Tiling) -> Edges<Pixels> {
Edges {
top: if tiling.top { px(0.) } else { shadow },
right: if tiling.right { px(0.) } else { shadow },
bottom: if tiling.bottom { px(0.) } else { shadow },
left: if tiling.left { px(0.) } else { shadow },
}
}
fn resize_edge(
pos: Point<Pixels>,
size: gpui::Size<Pixels>,
insets: Edges<Pixels>,
tiling: Tiling,
) -> Option<ResizeEdge> {
let near = |distance: Pixels| distance.abs() <= RESIZE_HIT;
let top = !tiling.top && near(pos.y - insets.top);
let bottom = !tiling.bottom && near(size.height - insets.bottom - pos.y);
let left = !tiling.left && near(pos.x - insets.left);
let right = !tiling.right && near(size.width - insets.right - pos.x);
let corner = RESIZE_HIT * 4.;
let near_top = !tiling.top && (pos.y - insets.top).abs() <= corner;
let near_bottom = !tiling.bottom && (size.height - insets.bottom - pos.y).abs() <= corner;
let near_left = !tiling.left && (pos.x - insets.left).abs() <= corner;
let near_right = !tiling.right && (size.width - insets.right - pos.x).abs() <= corner;
match () {
_ if (top && near_left) || (left && near_top) => Some(ResizeEdge::TopLeft),
_ if (top && near_right) || (right && near_top) => Some(ResizeEdge::TopRight),
_ if (bottom && near_left) || (left && near_bottom) => Some(ResizeEdge::BottomLeft),
_ if (bottom && near_right) || (right && near_bottom) => Some(ResizeEdge::BottomRight),
_ if top => Some(ResizeEdge::Top),
_ if bottom => Some(ResizeEdge::Bottom),
_ if left => Some(ResizeEdge::Left),
_ if right => Some(ResizeEdge::Right),
_ => None,
}
}
fn resize_cursor(edge: ResizeEdge) -> gpui::CursorStyle {
use gpui::CursorStyle;
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
}
}
impl RenderOnce for WindowBorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Decorations::Client { tiling } = window.window_decorations() else {
return div().size_full().flex().flex_col().children(self.children);
};
let shadow = self.shadow_size;
window.set_client_inset(shadow);
let insets = frame_insets(shadow, tiling);
let colors = cx.colors().clone();
let layout = cx.layout().clone();
let size = window.window_bounds().get_bounds().size;
let active = window.is_window_active();
let cursor = resize_edge(window.mouse_position(), size, insets, tiling)
.map(resize_cursor)
.unwrap_or_default();
div()
.size_full()
.flex()
.flex_col()
.bg(gpui::transparent_black())
.pt(insets.top)
.pr(insets.right)
.pb(insets.bottom)
.pl(insets.left)
.cursor(cursor)
.on_mouse_move(|_, window, _| window.refresh())
.on_mouse_down(MouseButton::Left, move |event, window, _| {
if let Some(edge) = resize_edge(event.position, size, insets, tiling) {
window.start_window_resize(edge);
}
})
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_h_0()
.min_w_0()
.overflow_hidden()
.cursor(gpui::CursorStyle::default())
.bg(colors.background)
.border_color(colors.border)
.when(!tiling.top, |el| el.border_t(layout.border_width))
.when(!tiling.right, |el| el.border_r(layout.border_width))
.when(!tiling.bottom, |el| el.border_b(layout.border_width))
.when(!tiling.left, |el| el.border_l(layout.border_width))
.when(!tiling.is_tiled(), |el| {
el.shadow(vec![gpui::BoxShadow {
color: gpui::black().opacity(if active { 0.2 } else { 0.12 }),
blur_radius: shadow / 2.,
spread_radius: px(0.),
offset: gpui::point(px(0.), px(1.)),
inset: false,
}])
})
.children(self.children),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_controls_follow_the_platform_convention() {
use ControlsPlan::*;
let auto = TitleBarControls::Auto;
assert_eq!(controls_plan(auto, Host::Mac, false), None);
assert_eq!(controls_plan(auto, Host::Web, true), None);
assert_eq!(controls_plan(auto, Host::Windows, false), Native);
assert_eq!(controls_plan(auto, Host::Linux, true), Drawn);
assert_eq!(controls_plan(auto, Host::Linux, false), None);
for host in [Host::Mac, Host::Windows, Host::Linux, Host::Web] {
assert_eq!(controls_plan(TitleBarControls::Custom, host, false), Drawn);
assert_eq!(controls_plan(TitleBarControls::Hidden, host, true), None);
}
}
#[test]
fn resize_edges_are_bands_around_the_frame_and_corners_win() {
let size = gpui::size(px(800.), px(600.));
let tiling = Tiling::default();
let insets = frame_insets(px(12.), tiling);
let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
assert_eq!(at(400., 12.), Some(ResizeEdge::Top));
assert_eq!(at(400., 9.), Some(ResizeEdge::Top), "outside the frame too");
assert_eq!(at(400., 588.), Some(ResizeEdge::Bottom));
assert_eq!(at(12., 300.), Some(ResizeEdge::Left));
assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
assert_eq!(at(14., 14.), Some(ResizeEdge::TopLeft));
assert_eq!(at(786., 586.), Some(ResizeEdge::BottomRight));
assert_eq!(at(400., 300.), None, "the content is not an edge");
assert_eq!(at(400., 40.), None);
}
#[test]
fn tiled_sides_neither_inset_nor_resize() {
let size = gpui::size(px(800.), px(600.));
let tiling = Tiling {
top: true,
left: true,
right: false,
bottom: false,
};
let insets = frame_insets(px(12.), tiling);
assert_eq!(insets.top, px(0.));
assert_eq!(insets.right, px(12.));
let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
assert_eq!(at(400., 0.), None);
assert_eq!(at(0., 300.), None);
assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
}
}
crate::util::impl_component_styled!(TitleBar);