1use std::{rc::Rc, sync::Arc};
5
6use gpui::{
7 AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
8 IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
9 Styled as _, Window, div,
10};
11use gpui_base::{
12 HandleEdge, ResizeHandleContext,
13 dock::{
14 DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, PanelView,
15 TabGroupRenderer,
16 },
17};
18
19use crate::{
20 ActiveTheme as _,
21 dock::{
22 DockSkin, SkinShared, invalid_panel::InvalidPanel, panel_handle, report_user_toggle,
23 tab_panel::TabGroupSkin,
24 },
25 resizable::render_resize_handle,
26 resize_handle,
27};
28
29#[derive(Clone)]
32struct ResizePanel;
33
34impl Render for ResizePanel {
35 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
36 Empty
37 }
38}
39
40impl DockAreaRenderer for DockSkin {
41 fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
45 div().id("dock-area")
46 }
47
48 fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
49 div().id("dock-area-center")
50 }
51
52 fn render_split_handle(
53 &self,
54 handle: &ResizeHandleContext,
55 window: &mut Window,
56 cx: &mut App,
57 ) -> Option<AnyElement> {
58 Some(render_resize_handle(handle, window, cx))
59 }
60
61 fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
62 div()
65 .id(("dock-split-frame", node.as_u64()))
66 .bg(cx.theme().tokens.tab_bar)
67 }
68
69 fn render_dock(
70 &self,
71 dock: &DockContext,
72 content: AnyElement,
73 window: &mut Window,
74 cx: &mut App,
75 ) -> AnyElement {
76 div()
81 .flex()
82 .size_full()
83 .relative()
84 .child(content)
85 .child(self.render_resize_handle(dock, window, cx))
86 .child(DockResizeTracker {
87 dock: dock.clone(),
88 shared: self.shared().clone(),
89 })
90 .into_any_element()
91 }
92
93 fn build_placeholder(
98 &self,
99 state: &PanelState,
100 _: &mut Window,
101 cx: &mut App,
102 ) -> Option<Arc<dyn PanelView>> {
103 let state = state.clone();
104 Some(panel_handle(cx.new(|cx| InvalidPanel::new(state, cx))))
105 }
106
107 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
108 Rc::new(TabGroupSkin::new(self.shared().clone()))
109 }
110}
111
112impl DockSkin {
113 fn render_resize_handle(
114 &self,
115 dock: &DockContext,
116 _: &mut Window,
117 _: &mut App,
118 ) -> impl IntoElement {
119 let placement = dock.placement();
120 let open = dock.is_open();
121 let shared = self.shared().clone();
122
123 let id = match placement {
128 DockPlacement::Left => "resize-handle-left",
129 DockPlacement::Right => "resize-handle-right",
130 DockPlacement::Bottom => "resize-handle-bottom",
131 DockPlacement::Center => "resize-handle-center",
132 };
133
134 let edge = match placement {
138 DockPlacement::Left => HandleEdge::Trailing,
139 _ => HandleEdge::Leading,
140 };
141
142 resize_handle(id, placement.axis()).inside(edge).on_drag(
143 ResizePanel,
144 move |info, _, _, cx| {
145 cx.stop_propagation();
146 shared.resizing_dock().set(Some(placement));
147 shared.resize_started_open().set(open);
148 cx.new(|_| info.clone())
149 },
150 )
151 }
152}
153
154struct DockResizeTracker {
163 dock: DockContext,
164 shared: Rc<SkinShared>,
165}
166
167impl IntoElement for DockResizeTracker {
168 type Element = Self;
169
170 fn into_element(self) -> Self::Element {
171 self
172 }
173}
174
175impl Element for DockResizeTracker {
176 type RequestLayoutState = ();
177 type PrepaintState = ();
178
179 fn id(&self) -> Option<gpui::ElementId> {
180 None
181 }
182
183 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
184 None
185 }
186
187 fn request_layout(
188 &mut self,
189 _: Option<&gpui::GlobalElementId>,
190 _: Option<&gpui::InspectorElementId>,
191 window: &mut Window,
192 cx: &mut App,
193 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
194 (window.request_layout(Style::default(), None, cx), ())
195 }
196
197 fn prepaint(
198 &mut self,
199 _: Option<&gpui::GlobalElementId>,
200 _: Option<&gpui::InspectorElementId>,
201 _: gpui::Bounds<Pixels>,
202 _: &mut Self::RequestLayoutState,
203 _: &mut Window,
204 _: &mut App,
205 ) -> Self::PrepaintState {
206 }
207
208 fn paint(
209 &mut self,
210 _: Option<&gpui::GlobalElementId>,
211 _: Option<&gpui::InspectorElementId>,
212 _: gpui::Bounds<Pixels>,
213 _: &mut Self::RequestLayoutState,
214 _: &mut Self::PrepaintState,
215 window: &mut Window,
216 _: &mut App,
217 ) {
218 let placement = self.dock.placement();
219
220 window.on_mouse_event({
221 let dock = self.dock.clone();
222 let shared = self.shared.clone();
223 move |event: &MouseMoveEvent, phase, window, cx| {
224 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
225 return;
226 }
227 dock.resize_to(event.position, window, cx);
230 }
231 });
232
233 window.on_mouse_event({
234 let dock = self.dock.clone();
235 let shared = self.shared.clone();
236 move |_: &MouseUpEvent, phase, window, cx| {
237 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
238 return;
239 }
240 shared.resizing_dock().set(None);
241 dock.end_resize(window, cx);
242 let was_open = shared.resize_started_open().get();
243 _ = shared.area().update(cx, |area, cx| {
246 cx.emit(DockEvent::LayoutChanged);
247 report_user_toggle(area, placement, was_open, cx);
248 });
249 }
250 });
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use std::rc::Rc;
257
258 use gpui::{
259 App, Entity, IntoElement as _, Modifiers, MouseButton, TestAppContext, VisualTestContext,
260 Window, point, px, size,
261 };
262
263 use std::cell::Cell;
264
265 use gpui_base::dock::DockAreaRenderer;
266
267 use gpui_base::PANEL_MIN_SIZE;
268
269 use crate::dock::{
270 DockArea, DockLayout, DockPlacement, DockSkin,
271 test_support::{MeasuredProbe, SizedProbe},
272 };
273
274 struct ChromelessDockSkin;
280
281 impl DockAreaRenderer for ChromelessDockSkin {
282 fn tab_group_renderer(&self) -> Rc<dyn gpui_base::dock::TabGroupRenderer> {
283 Rc::new(ChromelessTabs)
284 }
285 }
286
287 struct ChromelessTabs;
288 impl gpui_base::dock::TabGroupRenderer for ChromelessTabs {
289 fn render_tab_bar(
293 &self,
294 _: &gpui_base::dock::TabGroupContext,
295 _: &mut Window,
296 _: &mut App,
297 ) -> gpui::AnyElement {
298 gpui::Empty.into_any_element()
299 }
300 }
301
302 #[gpui::test]
311 fn a_dock_is_its_own_width_under_a_renderer_that_draws_no_chrome(cx: &mut TestAppContext) {
312 cx.update(|cx| crate::init(cx));
313 let measured = Rc::new(Cell::new(gpui::Size::default()));
314 let probe = measured.clone();
315 let centre = Rc::new(Cell::new(gpui::Size::default()));
316 let centre_probe = centre.clone();
317 let (area, cx) = cx.add_window_view(|window, cx| {
318 DockArea::new("test", None, window, cx).with_renderer(Rc::new(ChromelessDockSkin))
319 });
320 cx.simulate_resize(size(px(800.), px(600.)));
321 cx.update(|window, cx| {
322 area.update(cx, |area, cx| {
323 area.set_center(
324 DockLayout::tabs().panel(SizedProbe::new(centre_probe, cx)),
325 window,
326 cx,
327 );
328 area.set_dock(
329 DockPlacement::Right,
330 DockLayout::tabs().panel(SizedProbe::new(probe, cx)),
331 window,
332 cx,
333 );
334 area.set_dock_size(DockPlacement::Right, px(200.), window, cx);
335 });
336 });
337 cx.run_until_parked();
338 cx.update(|window, cx| {
339 area.update(cx, |area, cx| {
340 if !area.is_dock_open(DockPlacement::Right) {
341 area.toggle_dock(DockPlacement::Right, window, cx);
342 }
343 });
344 });
345 cx.run_until_parked();
346 cx.update(|window, cx| window.draw(cx).clear(cx));
347
348 let dock = measured.get().width;
349 assert_eq!(
350 dock,
351 px(200.),
352 "the right dock has to be its own width, not the area's: got {dock:?}"
353 );
354 let middle = centre.get().width;
358 assert_eq!(
359 middle,
360 px(600.),
361 "the centre has to be what the docks leave: got {middle:?}"
362 );
363 let tall = centre.get().height;
369 assert_eq!(
370 tall,
371 px(600.),
372 "a group has to fill its slot, or its panel gets no height: got {tall:?}"
373 );
374 }
375
376 fn area_with_side_docks(cx: &mut TestAppContext) -> (Entity<DockArea>, &mut VisualTestContext) {
377 cx.update(|cx| crate::init(cx));
378 let (area, cx) = cx.add_window_view(|window, cx| {
379 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
380 });
381 cx.simulate_resize(size(px(800.), px(600.)));
382 cx.update(|window, cx| {
383 area.update(cx, |area, cx| {
384 area.set_center(
385 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
386 window,
387 cx,
388 );
389 area.set_dock(
390 DockPlacement::Left,
391 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
392 window,
393 cx,
394 );
395 area.set_dock(
396 DockPlacement::Right,
397 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
398 window,
399 cx,
400 );
401 });
402 });
403 cx.run_until_parked();
404 (area, cx)
405 }
406
407 #[gpui::test]
414 fn dragging_the_left_handle_resizes_only_the_left_dock(cx: &mut TestAppContext) {
415 let (area, cx) = area_with_side_docks(cx);
416 cx.update(|window, cx| window.draw(cx).clear(cx));
417
418 cx.simulate_mouse_down(
420 point(px(198.5), px(300.)),
421 MouseButton::Left,
422 Modifiers::none(),
423 );
424 cx.simulate_mouse_move(
426 point(px(204.), px(300.)),
427 MouseButton::Left,
428 Modifiers::none(),
429 );
430 cx.simulate_mouse_move(
432 point(px(240.), px(300.)),
433 MouseButton::Left,
434 Modifiers::none(),
435 );
436 cx.simulate_mouse_up(
437 point(px(240.), px(300.)),
438 MouseButton::Left,
439 Modifiers::none(),
440 );
441 cx.run_until_parked();
442
443 let (left, right) = cx.update(|_, cx| {
444 let area = area.read(cx);
445 (
446 area.dock_size(DockPlacement::Left),
447 area.dock_size(DockPlacement::Right),
448 )
449 });
450 assert_eq!(
451 right,
452 Some(px(200.)),
453 "the right dock must not move when the left handle is dragged"
454 );
455 assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
456 }
457 #[gpui::test]
458 fn dragging_the_bottom_dock_closed_and_open_reports_each_toggle(cx: &mut TestAppContext) {
459 cx.update(|cx| crate::init(cx));
460 let (area, cx) = cx.add_window_view(|window, cx| {
461 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
462 });
463 cx.simulate_resize(size(px(800.), px(600.)));
464 let events = Rc::new(std::cell::RefCell::new(Vec::new()));
465 cx.update(|window, cx| {
466 area.update(cx, |area, cx| {
467 area.set_center(
468 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
469 window,
470 cx,
471 );
472 area.set_dock(
473 DockPlacement::Bottom,
474 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
475 window,
476 cx,
477 );
478 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
479 });
480 let events = events.clone();
481 cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
482 events
483 .borrow_mut()
484 .push((event.placement(), event.is_open()));
485 })
486 .detach();
487 });
488 cx.run_until_parked();
489
490 let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
491 cx.update(|window, cx| window.draw(cx).clear(cx));
492 let (first, rest) = ys.split_first().unwrap();
493 cx.simulate_mouse_down(
494 point(px(400.), px(*first)),
495 MouseButton::Left,
496 Modifiers::none(),
497 );
498 for y in rest {
499 cx.simulate_mouse_move(
500 point(px(400.), px(*y)),
501 MouseButton::Left,
502 Modifiers::none(),
503 );
504 }
505 cx.simulate_mouse_up(
506 point(px(400.), px(*ys.last().unwrap())),
507 MouseButton::Left,
508 Modifiers::none(),
509 );
510 cx.run_until_parked();
511 };
512
513 drag(cx, &[400.5, 380., 350.]);
515 assert!(events.borrow().is_empty(), "a plain resize is not reported");
516
517 drag(cx, &[350.5, 356., 580., 540.]);
519 drag(cx, &[571.5, 565., 520.]);
520 assert_eq!(
521 *events.borrow(),
522 vec![
523 (DockPlacement::Bottom, false),
524 (DockPlacement::Bottom, true)
525 ]
526 );
527 }
528
529 #[gpui::test]
530 fn dragging_the_bottom_handle_below_the_minimum_snaps_to_the_nearer_end(
531 cx: &mut TestAppContext,
532 ) {
533 cx.update(|cx| crate::init(cx));
534 let (area, cx) = cx.add_window_view(|window, cx| {
535 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
536 });
537 cx.simulate_resize(size(px(800.), px(600.)));
538 cx.update(|window, cx| {
539 area.update(cx, |area, cx| {
540 area.set_center(
541 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
542 window,
543 cx,
544 );
545 area.set_dock(
546 DockPlacement::Bottom,
547 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
548 window,
549 cx,
550 );
551 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
552 });
553 });
554 cx.run_until_parked();
555
556 let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
557 cx.update(|window, cx| window.draw(cx).clear(cx));
558 let (first, rest) = ys.split_first().unwrap();
559 cx.simulate_mouse_down(
560 point(px(400.), px(*first)),
561 MouseButton::Left,
562 Modifiers::none(),
563 );
564 for y in rest {
565 cx.simulate_mouse_move(
566 point(px(400.), px(*y)),
567 MouseButton::Left,
568 Modifiers::none(),
569 );
570 }
571 cx.simulate_mouse_up(
572 point(px(400.), px(*ys.last().unwrap())),
573 MouseButton::Left,
574 Modifiers::none(),
575 );
576 cx.run_until_parked();
577 };
578 let bottom = |cx: &mut VisualTestContext| {
579 cx.update(|_, cx| {
580 let area = area.read(cx);
581 (
582 area.is_dock_open(DockPlacement::Bottom),
583 area.dock_size(DockPlacement::Bottom),
584 )
585 })
586 };
587
588 drag(cx, &[400.5, 406., 580., 540.]);
592 assert!(!bottom(cx).0, "released nearer the strip, the dock closes");
593
594 drag(cx, &[571.5, 565., 520.]);
597 assert_eq!(
598 bottom(cx),
599 (true, Some(PANEL_MIN_SIZE)),
600 "released nearer the minimum, the dock opens at the minimum"
601 );
602 }
603}