1use std::sync::Arc;
58
59use gpui::{
60 div, prelude::FluentBuilder as _, px, AnyElement, App, Decorations, Edges, ElementId,
61 InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
62 ResizeEdge, SharedString, StatefulInteractiveElement, Styled, Tiling, TitlebarOptions, Window,
63 WindowControlArea, WindowDecorations, WindowOptions,
64};
65use herogpui_core::element_id;
66use herogpui_theme::ActiveTheme;
67
68use crate::a11y::{self, A11y as _};
69use crate::{util, IconName};
70
71pub const TITLE_BAR_HEIGHT: Pixels = px(36.);
73pub const TRAFFIC_LIGHTS_WIDTH: Pixels = px(80.);
75const CONTROL_WIDTH: Pixels = px(46.);
77
78type ActionCallback = Arc<dyn Fn(&WindowAction, &mut Window, &mut App) + 'static>;
79
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub enum TitleBarControls {
83 #[default]
85 Auto,
86 Custom,
88 Hidden,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq)]
94#[non_exhaustive]
95pub enum WindowAction {
96 Minimize,
98 Zoom,
100 Close,
102 Move,
104 DoubleClick,
106 ShowWindowMenu(Point<Pixels>),
108}
109
110impl WindowAction {
111 pub fn perform(&self, window: &mut Window) {
115 match *self {
116 WindowAction::Minimize => window.minimize_window(),
117 WindowAction::Zoom => window.zoom_window(),
118 WindowAction::Close => window.remove_window(),
119 WindowAction::Move => window.start_window_move(),
120 WindowAction::DoubleClick => {
121 if cfg!(target_os = "macos") {
122 window.titlebar_double_click();
123 } else {
124 window.zoom_window();
125 }
126 }
127 WindowAction::ShowWindowMenu(at) => window.show_window_menu(at),
128 }
129 }
130}
131
132#[derive(Clone, Copy, PartialEq, Eq, Debug)]
134enum Host {
135 Mac,
136 Windows,
137 Linux,
138 Web,
139}
140
141impl Host {
142 fn current() -> Self {
143 if cfg!(target_family = "wasm") {
144 Host::Web
145 } else if cfg!(target_os = "macos") {
146 Host::Mac
147 } else if cfg!(target_os = "windows") {
148 Host::Windows
149 } else {
150 Host::Linux
151 }
152 }
153}
154
155#[derive(Clone, Copy, PartialEq, Eq, Debug)]
157enum ControlsPlan {
158 None,
159 Drawn,
161 Native,
163}
164
165fn controls_plan(controls: TitleBarControls, host: Host, client_decorated: bool) -> ControlsPlan {
166 match controls {
167 TitleBarControls::Hidden => ControlsPlan::None,
168 TitleBarControls::Custom => ControlsPlan::Drawn,
169 TitleBarControls::Auto => match host {
170 Host::Mac | Host::Web => ControlsPlan::None,
171 Host::Windows => ControlsPlan::Native,
172 Host::Linux if client_decorated => ControlsPlan::Drawn,
173 Host::Linux => ControlsPlan::None,
174 },
175 }
176}
177
178#[must_use]
180#[derive(IntoElement)]
181pub struct TitleBar {
182 id: ElementId,
183 title: Option<SharedString>,
184 children: Vec<AnyElement>,
185 height: Pixels,
186 controls: TitleBarControls,
187 on_window_action: Option<ActionCallback>,
188 sx: Option<Box<gpui::StyleRefinement>>,
189}
190
191impl TitleBar {
192 pub fn new(id: impl Into<ElementId>) -> Self {
194 Self {
195 id: id.into(),
196 title: None,
197 children: Vec::new(),
198 height: TITLE_BAR_HEIGHT,
199 controls: TitleBarControls::Auto,
200 on_window_action: None,
201 sx: None,
202 }
203 }
204
205 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
208 self.title = Some(title.into());
209 self
210 }
211
212 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
214 self.height = height.into();
215 self
216 }
217
218 pub fn controls(mut self, controls: TitleBarControls) -> Self {
220 self.controls = controls;
221 self
222 }
223
224 pub fn on_window_action(
228 mut self,
229 f: impl Fn(&WindowAction, &mut Window, &mut App) + 'static,
230 ) -> Self {
231 self.on_window_action = Some(Arc::new(f));
232 self
233 }
234
235 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
237 util::refine_sx(&mut self.sx, style);
238 self
239 }
240
241 pub fn window_options() -> WindowOptions {
245 WindowOptions {
246 titlebar: Some(Self::titlebar_options()),
247 app_owns_titlebar_drag: true,
248 window_decorations: Some(WindowDecorations::Client),
249 ..Default::default()
250 }
251 }
252
253 pub fn titlebar_options() -> TitlebarOptions {
255 TitlebarOptions {
256 title: None,
257 appears_transparent: true,
258 traffic_light_position: Some(gpui::point(px(12.), px(12.))),
259 }
260 }
261}
262
263impl ParentElement for TitleBar {
264 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
265 self.children.extend(elements);
266 }
267}
268
269fn dispatch(
271 hook: &Option<ActionCallback>,
272 action: WindowAction,
273 window: &mut Window,
274 cx: &mut App,
275) {
276 match hook {
277 Some(f) => f(&action, window, cx),
278 None => action.perform(window),
279 }
280}
281
282fn selector_base(id: &ElementId) -> String {
284 match id {
285 ElementId::Name(name) => name.to_string(),
286 other => format!("{other:?}"),
287 }
288}
289
290impl RenderOnce for TitleBar {
291 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
292 let base = self.id.clone();
293 let selector = selector_base(&base);
294 let host = Host::current();
295 let decorations = window.window_decorations();
296 let client_decorated = matches!(decorations, Decorations::Client { .. });
297 let plan = controls_plan(self.controls, host, client_decorated);
298 let armed = window.use_keyed_state(element_id::scoped(&base, "armed"), cx, |_, _| false);
301 let colors = cx.colors().clone();
302 let layout = cx.layout().clone();
303 let hook = self.on_window_action.clone();
304 let leading = if host == Host::Mac
305 && self.controls == TitleBarControls::Auto
306 && !window.is_fullscreen()
307 {
308 TRAFFIC_LIGHTS_WIDTH
309 } else {
310 px(12.)
311 };
312
313 let mut drag = div()
316 .id(element_id::scoped(&base, "drag"))
317 .absolute()
318 .inset_0()
319 .debug_selector({
320 let name = format!("{selector}-drag");
321 move || name
322 });
323 if host != Host::Web {
324 drag = drag.window_control_area(WindowControlArea::Drag);
325 }
326 {
327 let armed_down = armed.clone();
328 let hook_down = hook.clone();
329 drag = drag.on_mouse_down(MouseButton::Left, move |event, window, cx| {
330 if event.click_count >= 2 {
331 armed_down.update(cx, |a, _| *a = false);
332 if host != Host::Windows {
333 dispatch(&hook_down, WindowAction::DoubleClick, window, cx);
334 }
335 } else {
336 armed_down.update(cx, |a, _| *a = true);
337 }
338 });
339 let armed_up = armed.clone();
340 drag = drag.on_mouse_up(MouseButton::Left, move |_, _, cx| {
341 armed_up.update(cx, |a, _| *a = false);
342 });
343 let armed_out = armed.clone();
344 drag = drag.on_mouse_down_out(move |_, _, cx| {
345 armed_out.update(cx, |a, _| *a = false);
346 });
347 let hook_move = hook.clone();
348 drag = drag.on_mouse_move(move |event, window, cx| {
349 if event.pressed_button != Some(MouseButton::Left) || !*armed.read(cx) {
350 return;
351 }
352 armed.update(cx, |a, _| *a = false);
353 dispatch(&hook_move, WindowAction::Move, window, cx);
354 });
355 if host == Host::Linux && client_decorated {
356 let hook_menu = hook.clone();
357 drag = drag.on_mouse_down(MouseButton::Right, move |event, window, cx| {
358 dispatch(
359 &hook_menu,
360 WindowAction::ShowWindowMenu(event.position),
361 window,
362 cx,
363 );
364 });
365 }
366 }
367
368 let mut content = div()
369 .relative()
370 .flex()
371 .flex_1()
372 .min_w_0()
373 .h_full()
374 .items_center()
375 .gap(px(8.))
376 .pl(leading)
377 .pr(px(8.));
378 if let Some(title) = &self.title {
379 content = content.child(
380 div()
381 .flex_shrink_0()
382 .text_size(px(13.))
383 .font_weight(gpui::FontWeight::MEDIUM)
384 .text_color(colors.foreground)
385 .whitespace_nowrap()
386 .child(title.clone()),
387 );
388 }
389 if !self.children.is_empty() {
390 content = content.child(
391 div()
392 .id(element_id::scoped(&base, "content"))
393 .flex()
394 .items_center()
395 .gap(px(8.))
396 .min_w_0()
397 .block_mouse_except_scroll()
400 .children(self.children),
401 );
402 }
403
404 let mut bar = div()
405 .id(base.clone())
406 .a11y_named(a11y::Role::TitleBar, &a11y::Name::maybe(self.title.clone()))
407 .relative()
408 .flex()
409 .flex_row()
410 .items_center()
411 .w_full()
412 .h(self.height)
413 .flex_shrink_0()
414 .bg(colors.surface.background)
415 .border_b(layout.border_width)
416 .border_color(colors.separator)
417 .debug_selector({
418 let name = format!("{selector}-title-bar");
419 move || name
420 })
421 .child(drag)
422 .child(content);
423
424 if plan != ControlsPlan::None {
425 let supported = window.window_controls();
426 let maximized = window.is_maximized();
427 let mut controls = div()
428 .relative()
429 .flex()
430 .h_full()
431 .flex_shrink_0()
432 .block_mouse_except_scroll();
433 let mut buttons: Vec<(WindowAction, &'static str, SharedString, IconName)> = Vec::new();
434 if supported.minimize {
435 buttons.push((
436 WindowAction::Minimize,
437 "minimize",
438 "Minimize".into(),
439 IconName::Minus,
440 ));
441 }
442 if supported.maximize {
443 let (name, icon) = if maximized {
444 ("Restore", IconName::Copy)
445 } else {
446 ("Maximize", IconName::Square)
447 };
448 buttons.push((WindowAction::Zoom, "zoom", name.into(), icon));
449 }
450 buttons.push((
451 WindowAction::Close,
452 "close",
453 crate::i18n::ui_string(crate::i18n::UiString::Close, cx),
454 IconName::X,
455 ));
456 for (action, part, name, icon) in buttons {
457 let is_close = action == WindowAction::Close;
458 let (hover_bg, hover_fg) = if is_close {
459 (colors.danger.color, colors.danger.foreground)
460 } else {
461 (colors.default.color, colors.foreground)
462 };
463 let mut button = div()
464 .id(element_id::scoped(&base, part))
465 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(name))
466 .flex()
467 .items_center()
468 .justify_center()
469 .w(CONTROL_WIDTH)
470 .h_full()
471 .text_color(colors.foreground)
472 .hover(move |s| s.bg(hover_bg).text_color(hover_fg))
473 .debug_selector({
474 let name = format!("{selector}-{part}");
475 move || name
476 })
477 .child(
478 gpui::svg()
479 .size(px(14.))
480 .path(icon)
481 .text_color(colors.foreground),
482 );
483 match plan {
484 ControlsPlan::Native => {
485 button = button.window_control_area(match action {
486 WindowAction::Minimize => WindowControlArea::Min,
487 WindowAction::Zoom => WindowControlArea::Max,
488 _ => WindowControlArea::Close,
489 });
490 }
491 _ => {
492 let hook = hook.clone();
493 button = button
494 .on_mouse_down(MouseButton::Left, |_, window, cx| {
495 window.prevent_default();
496 cx.stop_propagation();
497 })
498 .on_click(move |_, window, cx| {
499 cx.stop_propagation();
500 dispatch(&hook, action, window, cx);
501 });
502 }
503 }
504 controls = controls.child(button);
505 }
506 bar = bar.child(controls);
507 }
508 util::apply_sx(bar, &self.sx)
509 }
510}
511
512pub const WINDOW_SHADOW_SIZE: Pixels = px(12.);
514const RESIZE_HIT: Pixels = px(4.);
516
517#[must_use]
522#[derive(IntoElement)]
523pub struct WindowBorder {
524 children: Vec<AnyElement>,
525 shadow_size: Pixels,
526}
527
528impl WindowBorder {
529 pub fn new() -> Self {
531 Self {
532 children: Vec::new(),
533 shadow_size: WINDOW_SHADOW_SIZE,
534 }
535 }
536
537 pub fn shadow_size(mut self, size: impl Into<Pixels>) -> Self {
540 self.shadow_size = size.into();
541 self
542 }
543}
544
545impl Default for WindowBorder {
546 fn default() -> Self {
547 Self::new()
548 }
549}
550
551impl ParentElement for WindowBorder {
552 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
553 self.children.extend(elements);
554 }
555}
556
557fn frame_insets(shadow: Pixels, tiling: Tiling) -> Edges<Pixels> {
560 Edges {
561 top: if tiling.top { px(0.) } else { shadow },
562 right: if tiling.right { px(0.) } else { shadow },
563 bottom: if tiling.bottom { px(0.) } else { shadow },
564 left: if tiling.left { px(0.) } else { shadow },
565 }
566}
567
568fn resize_edge(
571 pos: Point<Pixels>,
572 size: gpui::Size<Pixels>,
573 insets: Edges<Pixels>,
574 tiling: Tiling,
575) -> Option<ResizeEdge> {
576 let near = |distance: Pixels| distance.abs() <= RESIZE_HIT;
577 let top = !tiling.top && near(pos.y - insets.top);
578 let bottom = !tiling.bottom && near(size.height - insets.bottom - pos.y);
579 let left = !tiling.left && near(pos.x - insets.left);
580 let right = !tiling.right && near(size.width - insets.right - pos.x);
581 let corner = RESIZE_HIT * 4.;
583 let near_top = !tiling.top && (pos.y - insets.top).abs() <= corner;
584 let near_bottom = !tiling.bottom && (size.height - insets.bottom - pos.y).abs() <= corner;
585 let near_left = !tiling.left && (pos.x - insets.left).abs() <= corner;
586 let near_right = !tiling.right && (size.width - insets.right - pos.x).abs() <= corner;
587 match () {
588 _ if (top && near_left) || (left && near_top) => Some(ResizeEdge::TopLeft),
589 _ if (top && near_right) || (right && near_top) => Some(ResizeEdge::TopRight),
590 _ if (bottom && near_left) || (left && near_bottom) => Some(ResizeEdge::BottomLeft),
591 _ if (bottom && near_right) || (right && near_bottom) => Some(ResizeEdge::BottomRight),
592 _ if top => Some(ResizeEdge::Top),
593 _ if bottom => Some(ResizeEdge::Bottom),
594 _ if left => Some(ResizeEdge::Left),
595 _ if right => Some(ResizeEdge::Right),
596 _ => None,
597 }
598}
599
600fn resize_cursor(edge: ResizeEdge) -> gpui::CursorStyle {
601 use gpui::CursorStyle;
602 match edge {
603 ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
604 ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
605 ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
606 ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
607 }
608}
609
610impl RenderOnce for WindowBorder {
611 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
612 let Decorations::Client { tiling } = window.window_decorations() else {
613 return div().size_full().flex().flex_col().children(self.children);
614 };
615 let shadow = self.shadow_size;
616 window.set_client_inset(shadow);
619 let insets = frame_insets(shadow, tiling);
620 let colors = cx.colors().clone();
621 let layout = cx.layout().clone();
622 let size = window.window_bounds().get_bounds().size;
623 let active = window.is_window_active();
624 let cursor = resize_edge(window.mouse_position(), size, insets, tiling)
625 .map(resize_cursor)
626 .unwrap_or_default();
627
628 div()
629 .size_full()
630 .flex()
631 .flex_col()
632 .bg(gpui::transparent_black())
633 .pt(insets.top)
634 .pr(insets.right)
635 .pb(insets.bottom)
636 .pl(insets.left)
637 .cursor(cursor)
638 .on_mouse_move(|_, window, _| window.refresh())
639 .on_mouse_down(MouseButton::Left, move |event, window, _| {
640 if let Some(edge) = resize_edge(event.position, size, insets, tiling) {
641 window.start_window_resize(edge);
642 }
643 })
644 .child(
645 div()
646 .flex()
647 .flex_col()
648 .flex_1()
649 .min_h_0()
650 .min_w_0()
651 .overflow_hidden()
652 .cursor(gpui::CursorStyle::default())
653 .bg(colors.background)
654 .border_color(colors.border)
655 .when(!tiling.top, |el| el.border_t(layout.border_width))
656 .when(!tiling.right, |el| el.border_r(layout.border_width))
657 .when(!tiling.bottom, |el| el.border_b(layout.border_width))
658 .when(!tiling.left, |el| el.border_l(layout.border_width))
659 .when(!tiling.is_tiled(), |el| {
660 el.shadow(vec![gpui::BoxShadow {
661 color: gpui::black().opacity(if active { 0.2 } else { 0.12 }),
662 blur_radius: shadow / 2.,
663 spread_radius: px(0.),
664 offset: gpui::point(px(0.), px(1.)),
665 inset: false,
666 }])
667 })
668 .children(self.children),
669 )
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn auto_controls_follow_the_platform_convention() {
679 use ControlsPlan::*;
680 let auto = TitleBarControls::Auto;
681 assert_eq!(controls_plan(auto, Host::Mac, false), None);
682 assert_eq!(controls_plan(auto, Host::Web, true), None);
683 assert_eq!(controls_plan(auto, Host::Windows, false), Native);
684 assert_eq!(controls_plan(auto, Host::Linux, true), Drawn);
685 assert_eq!(controls_plan(auto, Host::Linux, false), None);
686 for host in [Host::Mac, Host::Windows, Host::Linux, Host::Web] {
687 assert_eq!(controls_plan(TitleBarControls::Custom, host, false), Drawn);
688 assert_eq!(controls_plan(TitleBarControls::Hidden, host, true), None);
689 }
690 }
691
692 #[test]
693 fn resize_edges_are_bands_around_the_frame_and_corners_win() {
694 let size = gpui::size(px(800.), px(600.));
695 let tiling = Tiling::default();
696 let insets = frame_insets(px(12.), tiling);
697 let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
698 assert_eq!(at(400., 12.), Some(ResizeEdge::Top));
699 assert_eq!(at(400., 9.), Some(ResizeEdge::Top), "outside the frame too");
700 assert_eq!(at(400., 588.), Some(ResizeEdge::Bottom));
701 assert_eq!(at(12., 300.), Some(ResizeEdge::Left));
702 assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
703 assert_eq!(at(14., 14.), Some(ResizeEdge::TopLeft));
704 assert_eq!(at(786., 586.), Some(ResizeEdge::BottomRight));
705 assert_eq!(at(400., 300.), None, "the content is not an edge");
706 assert_eq!(at(400., 40.), None);
707 }
708
709 #[test]
710 fn tiled_sides_neither_inset_nor_resize() {
711 let size = gpui::size(px(800.), px(600.));
712 let tiling = Tiling {
713 top: true,
714 left: true,
715 right: false,
716 bottom: false,
717 };
718 let insets = frame_insets(px(12.), tiling);
719 assert_eq!(insets.top, px(0.));
720 assert_eq!(insets.right, px(12.));
721 let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
722 assert_eq!(at(400., 0.), None);
723 assert_eq!(at(0., 300.), None);
724 assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
725 }
726}
727
728crate::util::impl_component_styled!(TitleBar);