1use super::caption::{Caption, CaptionButtons, CaptionLabels, CloseRequest};
10use gpui::{
11 AnyElement, App, Bounds, InteractiveElement as _, IntoElement, MouseButton, ParentElement,
12 Pixels, Point, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
13 StyleRefinement, Styled, TitlebarOptions, Window, WindowBounds, WindowControlArea,
14 WindowDecorations, WindowOptions, div, point, px,
15};
16use std::rc::Rc;
17
18const TRAFFIC_LIGHT: f32 = 14.;
20const TRAFFIC_LIGHT_GAP: f32 = 6.;
21const TRAFFIC_LIGHT_MAX_INSET: f32 = 20.;
23const CONTENT_GAP: f32 = 16.;
25
26pub fn traffic_light_position(height: Pixels) -> Point<Pixels> {
29 let inset = ((f32::from(height) - TRAFFIC_LIGHT) / 2.).max(0.);
30 point(px(inset.min(TRAFFIC_LIGHT_MAX_INSET)), px(inset))
31}
32
33pub fn title_bar_window_options(
41 title: impl Into<SharedString>,
42 height: Pixels,
43 window_size: gpui::Size<Pixels>,
44 cx: &App,
45) -> WindowOptions {
46 WindowOptions {
47 window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
48 None,
49 window_size,
50 cx,
51 ))),
52 titlebar: Some(TitlebarOptions {
53 title: Some(title.into()),
54 appears_transparent: true,
55 traffic_light_position: Some(traffic_light_position(height)),
56 }),
57 app_owns_titlebar_drag: true,
58 window_decorations: Some(WindowDecorations::Client),
59 ..Default::default()
60 }
61}
62
63pub fn title_bar_leading(height: Pixels, window: &Window) -> f32 {
66 leading_for(cfg!(target_os = "macos") && !window.is_fullscreen(), height)
67}
68
69fn leading_for(traffic_lights: bool, height: Pixels) -> f32 {
70 if traffic_lights {
71 f32::from(traffic_light_position(height).x)
72 + 3. * TRAFFIC_LIGHT
73 + 2. * TRAFFIC_LIGHT_GAP
74 + CONTENT_GAP
75 } else {
76 CONTENT_GAP
77 }
78}
79
80#[derive(gpui::IntoElement)]
88pub struct TitleBar {
89 id: gpui::ElementId,
90 base: Stateful<gpui::Div>,
91 height: Pixels,
92 labels: CaptionLabels,
93 caption: Option<Caption>,
94 on_close: Option<CloseRequest>,
95}
96
97impl TitleBar {
98 pub fn new(id: impl Into<gpui::ElementId>, height: Pixels, labels: CaptionLabels) -> Self {
102 let id = id.into();
103 Self {
104 base: div().id(id.clone()),
105 id,
106 height,
107 labels,
108 caption: None,
109 on_close: None,
110 }
111 }
112
113 pub fn caption(mut self, caption: Caption) -> Self {
116 self.caption = Some(caption);
117 self
118 }
119
120 pub fn labels(mut self, labels: CaptionLabels) -> Self {
122 self.labels = labels;
123 self
124 }
125
126 pub fn on_close(mut self, request: impl Fn(&mut Window, &mut App) -> bool + 'static) -> Self {
129 self.on_close = Some(Rc::new(request));
130 self
131 }
132}
133
134impl Styled for TitleBar {
135 fn style(&mut self) -> &mut StyleRefinement {
136 self.base.style()
137 }
138}
139
140impl ParentElement for TitleBar {
141 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
142 self.base.extend(elements)
143 }
144}
145
146#[derive(Default)]
148struct DragIntent(bool);
149
150fn start_window_move(window: &mut Window) {
153 #[cfg(test)]
154 {
155 let _ = window;
156 tests::MOVES.with(|moves| moves.set(moves.get() + 1));
157 }
158 #[cfg(not(test))]
159 window.start_window_move();
160}
161
162impl RenderOnce for TitleBar {
163 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
164 let intent = window.use_keyed_state(
165 gpui::ElementId::NamedChild(self.id.into(), "drag".into()),
166 cx,
167 |_, _| DragIntent::default(),
168 );
169 let press = intent.clone();
170 let release = intent.clone();
171 let mut caption = CaptionButtons::new(self.height, self.labels);
172 let trailing = match self.caption {
173 Some(style) => {
174 caption = caption.caption(style);
175 CaptionButtons::trailing_for_style(style, window)
176 }
177 None => CaptionButtons::trailing(window),
178 };
179 if let Some(request) = self.on_close {
180 caption = caption.on_close(move |window, cx| request(window, cx));
181 }
182 self.base
183 .flex()
184 .flex_shrink_0()
185 .items_center()
186 .h(self.height)
187 .pl(px(title_bar_leading(self.height, window)))
188 .pr(px(trailing))
189 .window_control_area(WindowControlArea::Drag)
192 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
193 press.update(cx, |intent, _| intent.0 = true)
194 })
195 .on_mouse_up(MouseButton::Left, move |_, _, cx| {
196 release.update(cx, |intent, _| intent.0 = false)
197 })
198 .on_mouse_move(move |event, window, cx| {
199 let started = intent.update(cx, |intent, _| std::mem::take(&mut intent.0));
200 if started && event.dragging() && !cfg!(target_os = "windows") {
201 start_window_move(window);
202 }
203 })
204 .on_mouse_down(MouseButton::Right, |event, window, _| {
205 if cfg!(any(target_os = "linux", target_os = "freebsd")) {
206 window.show_window_menu(event.position);
207 }
208 })
209 .on_click(|event, window, _| {
210 if event.click_count() != 2 {
211 return;
212 }
213 if cfg!(target_os = "macos") {
214 window.titlebar_double_click();
216 } else if !cfg!(any(target_os = "windows", target_family = "wasm")) {
217 window.zoom_window();
218 }
219 })
220 .child(div().absolute().child(caption))
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::components::caption::tests::test_labels;
230 use crate::components::{ButtonVariant, material_button, title_bar_control};
231 use gpui::{Context, Modifiers, Render, TestAppContext, size};
232 use std::cell::Cell;
233
234 thread_local! {
235 pub(super) static MOVES: Cell<usize> = const { Cell::new(0) };
237 }
238
239 #[test]
240 fn traffic_lights_are_centred_in_the_bar() {
241 assert_eq!(traffic_light_position(px(32.)), point(px(9.), px(9.)));
242 assert_eq!(traffic_light_position(px(64.)), point(px(20.), px(25.)));
244 }
245
246 #[test]
247 fn content_starts_after_the_traffic_lights() {
248 assert_eq!(leading_for(true, px(64.)), 20. + 42. + 12. + 16.);
250 assert_eq!(leading_for(false, px(64.)), 16.);
251 }
252
253 #[gpui::test]
254 fn window_options_let_the_application_own_title_bar_drags(cx: &mut TestAppContext) {
255 let options = cx
256 .update(|cx| title_bar_window_options("Title", px(64.), size(px(800.), px(600.)), cx));
257 assert!(options.app_owns_titlebar_drag);
258 let titlebar = options.titlebar.expect("a title bar");
259 assert!(titlebar.appears_transparent);
260 assert_eq!(
261 titlebar.traffic_light_position,
262 Some(point(px(20.), px(25.)))
263 );
264 }
265
266 struct Harness {
267 clicks: usize,
268 }
269
270 impl Render for Harness {
271 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
272 div().size_full().child(
273 TitleBar::new("bar", px(48.), test_labels())
274 .w_full()
275 .gap(px(16.))
276 .child(title_bar_control(
277 material_button("in-bar", ButtonVariant::Text)
278 .debug_selector(|| "in-bar".into())
279 .child("Action")
280 .on_click(cx.listener(|this, _, _, _| this.clicks += 1)),
281 ))
282 .child(div().flex_1())
283 .child(div().debug_selector(|| "last".into()).size(px(24.))),
284 )
285 }
286 }
287
288 #[gpui::test]
289 fn controls_in_the_bar_take_their_clicks(cx: &mut TestAppContext) {
290 let (view, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
291 cx.run_until_parked();
292 let center = cx
293 .debug_bounds("in-bar")
294 .expect("the bar's button")
295 .center();
296 cx.simulate_click(center, Modifiers::default());
297 cx.simulate_click(point(px(400.), px(24.)), Modifiers::default());
299 view.read_with(cx, |v, _| assert_eq!(v.clicks, 1));
300 }
301
302 fn moves() -> usize {
303 MOVES.with(Cell::get)
304 }
305
306 #[gpui::test]
307 fn only_empty_bar_space_starts_a_window_drag(cx: &mut TestAppContext) {
308 MOVES.with(|moves| moves.set(0));
309 let (_, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
310 cx.run_until_parked();
311 let control = cx
312 .debug_bounds("in-bar")
313 .expect("the bar's button")
314 .center();
315 let empty = point(px(400.), px(24.));
316 let left = Some(MouseButton::Left);
317 cx.simulate_mouse_down(control, MouseButton::Left, Modifiers::default());
319 cx.simulate_mouse_move(control + point(px(8.), px(0.)), left, Modifiers::default());
320 cx.simulate_mouse_up(control, MouseButton::Left, Modifiers::default());
321 assert_eq!(moves(), 0);
322 cx.simulate_mouse_down(empty, MouseButton::Left, Modifiers::default());
324 cx.simulate_mouse_up(empty, MouseButton::Left, Modifiers::default());
325 cx.simulate_mouse_move(empty, None, Modifiers::default());
326 assert_eq!(moves(), 0);
327 cx.simulate_mouse_down(empty, MouseButton::Left, Modifiers::default());
329 cx.simulate_mouse_move(empty + point(px(8.), px(0.)), left, Modifiers::default());
330 assert_eq!(moves(), usize::from(!cfg!(target_os = "windows")));
331 }
332
333 #[gpui::test]
334 fn the_caller_gap_stops_at_the_last_child(cx: &mut TestAppContext) {
335 let (_, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
336 cx.run_until_parked();
337 let last = cx.debug_bounds("last").expect("the last child");
338 let (width, trailing) = cx.update(|window, _| {
339 (
340 window.viewport_size().width,
341 CaptionButtons::trailing(window),
342 )
343 });
344 assert_eq!(last.right(), width - px(trailing));
345 }
346}