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| {
105 InvalidPanel::new(state.panel_name.clone(), state, cx)
106 })))
107 }
108
109 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
110 Rc::new(TabGroupSkin::new(self.shared().clone()))
111 }
112}
113
114impl DockSkin {
115 fn render_resize_handle(
116 &self,
117 dock: &DockContext,
118 _: &mut Window,
119 _: &mut App,
120 ) -> impl IntoElement {
121 let placement = dock.placement();
122 let open = dock.is_open();
123 let shared = self.shared().clone();
124
125 let id = match placement {
130 DockPlacement::Left => "resize-handle-left",
131 DockPlacement::Right => "resize-handle-right",
132 DockPlacement::Bottom => "resize-handle-bottom",
133 DockPlacement::Center => "resize-handle-center",
134 };
135
136 let edge = match placement {
140 DockPlacement::Left => HandleEdge::Trailing,
141 _ => HandleEdge::Leading,
142 };
143
144 resize_handle(id, placement.axis()).inside(edge).on_drag(
145 ResizePanel,
146 move |info, _, _, cx| {
147 cx.stop_propagation();
148 shared.resizing_dock().set(Some(placement));
149 shared.resize_started_open().set(open);
150 cx.new(|_| info.clone())
151 },
152 )
153 }
154}
155
156struct DockResizeTracker {
165 dock: DockContext,
166 shared: Rc<SkinShared>,
167}
168
169impl IntoElement for DockResizeTracker {
170 type Element = Self;
171
172 fn into_element(self) -> Self::Element {
173 self
174 }
175}
176
177impl Element for DockResizeTracker {
178 type RequestLayoutState = ();
179 type PrepaintState = ();
180
181 fn id(&self) -> Option<gpui::ElementId> {
182 None
183 }
184
185 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
186 None
187 }
188
189 fn request_layout(
190 &mut self,
191 _: Option<&gpui::GlobalElementId>,
192 _: Option<&gpui::InspectorElementId>,
193 window: &mut Window,
194 cx: &mut App,
195 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
196 (window.request_layout(Style::default(), None, cx), ())
197 }
198
199 fn prepaint(
200 &mut self,
201 _: Option<&gpui::GlobalElementId>,
202 _: Option<&gpui::InspectorElementId>,
203 _: gpui::Bounds<Pixels>,
204 _: &mut Self::RequestLayoutState,
205 _: &mut Window,
206 _: &mut App,
207 ) -> Self::PrepaintState {
208 }
209
210 fn paint(
211 &mut self,
212 _: Option<&gpui::GlobalElementId>,
213 _: Option<&gpui::InspectorElementId>,
214 _: gpui::Bounds<Pixels>,
215 _: &mut Self::RequestLayoutState,
216 _: &mut Self::PrepaintState,
217 window: &mut Window,
218 _: &mut App,
219 ) {
220 let placement = self.dock.placement();
221
222 window.on_mouse_event({
223 let dock = self.dock.clone();
224 let shared = self.shared.clone();
225 move |event: &MouseMoveEvent, phase, window, cx| {
226 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
227 return;
228 }
229 dock.resize_to(event.position, window, cx);
232 }
233 });
234
235 window.on_mouse_event({
236 let dock = self.dock.clone();
237 let shared = self.shared.clone();
238 move |_: &MouseUpEvent, phase, window, cx| {
239 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
240 return;
241 }
242 shared.resizing_dock().set(None);
243 dock.end_resize(window, cx);
244 let was_open = shared.resize_started_open().get();
245 _ = shared.area().update(cx, |area, cx| {
248 cx.emit(DockEvent::LayoutChanged);
249 report_user_toggle(area, placement, was_open, cx);
250 });
251 }
252 });
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use std::rc::Rc;
259
260 use gpui::{
261 App, Entity, IntoElement as _, Modifiers, MouseButton, TestAppContext, VisualTestContext,
262 Window, point, px, size,
263 };
264
265 use std::cell::Cell;
266
267 use gpui_base::dock::DockAreaRenderer;
268
269 use gpui_base::PANEL_MIN_SIZE;
270
271 use crate::dock::{
272 DockArea, DockLayout, DockPlacement, DockSkin,
273 test_support::{MeasuredProbe, SizedProbe},
274 };
275
276 struct ChromelessDockSkin;
282
283 impl DockAreaRenderer for ChromelessDockSkin {
284 fn tab_group_renderer(&self) -> Rc<dyn gpui_base::dock::TabGroupRenderer> {
285 Rc::new(ChromelessTabs)
286 }
287 }
288
289 struct ChromelessTabs;
290 impl gpui_base::dock::TabGroupRenderer for ChromelessTabs {
291 fn render_tab_bar(
295 &self,
296 _: &gpui_base::dock::TabGroupContext,
297 _: &mut Window,
298 _: &mut App,
299 ) -> gpui::AnyElement {
300 gpui::Empty.into_any_element()
301 }
302 }
303
304 #[gpui::test]
313 fn a_dock_is_its_own_width_under_a_renderer_that_draws_no_chrome(cx: &mut TestAppContext) {
314 cx.update(|cx| crate::init(cx));
315 let measured = Rc::new(Cell::new(gpui::Size::default()));
316 let probe = measured.clone();
317 let centre = Rc::new(Cell::new(gpui::Size::default()));
318 let centre_probe = centre.clone();
319 let (area, cx) = cx.add_window_view(|window, cx| {
320 DockArea::new("test", None, window, cx).with_renderer(Rc::new(ChromelessDockSkin))
321 });
322 cx.simulate_resize(size(px(800.), px(600.)));
323 cx.update(|window, cx| {
324 area.update(cx, |area, cx| {
325 area.set_center(
326 DockLayout::tabs().panel(SizedProbe::new(centre_probe, cx)),
327 window,
328 cx,
329 );
330 area.set_dock(
331 DockPlacement::Right,
332 DockLayout::tabs().panel(SizedProbe::new(probe, cx)),
333 window,
334 cx,
335 );
336 area.set_dock_size(DockPlacement::Right, px(200.), window, cx);
337 });
338 });
339 cx.run_until_parked();
340 cx.update(|window, cx| {
341 area.update(cx, |area, cx| {
342 if !area.is_dock_open(DockPlacement::Right) {
343 area.toggle_dock(DockPlacement::Right, window, cx);
344 }
345 });
346 });
347 cx.run_until_parked();
348 cx.update(|window, cx| window.draw(cx).clear(cx));
349
350 let dock = measured.get().width;
351 assert_eq!(
352 dock,
353 px(200.),
354 "the right dock has to be its own width, not the area's: got {dock:?}"
355 );
356 let middle = centre.get().width;
360 assert_eq!(
361 middle,
362 px(600.),
363 "the centre has to be what the docks leave: got {middle:?}"
364 );
365 let tall = centre.get().height;
371 assert_eq!(
372 tall,
373 px(600.),
374 "a group has to fill its slot, or its panel gets no height: got {tall:?}"
375 );
376 }
377
378 fn area_with_side_docks(cx: &mut TestAppContext) -> (Entity<DockArea>, &mut VisualTestContext) {
379 cx.update(|cx| crate::init(cx));
380 let (area, cx) = cx.add_window_view(|window, cx| {
381 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
382 });
383 cx.simulate_resize(size(px(800.), px(600.)));
384 cx.update(|window, cx| {
385 area.update(cx, |area, cx| {
386 area.set_center(
387 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
388 window,
389 cx,
390 );
391 area.set_dock(
392 DockPlacement::Left,
393 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
394 window,
395 cx,
396 );
397 area.set_dock(
398 DockPlacement::Right,
399 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
400 window,
401 cx,
402 );
403 });
404 });
405 cx.run_until_parked();
406 (area, cx)
407 }
408
409 #[gpui::test]
416 fn dragging_the_left_handle_resizes_only_the_left_dock(cx: &mut TestAppContext) {
417 let (area, cx) = area_with_side_docks(cx);
418 cx.update(|window, cx| window.draw(cx).clear(cx));
419
420 cx.simulate_mouse_down(
422 point(px(198.5), px(300.)),
423 MouseButton::Left,
424 Modifiers::none(),
425 );
426 cx.simulate_mouse_move(
428 point(px(204.), px(300.)),
429 MouseButton::Left,
430 Modifiers::none(),
431 );
432 cx.simulate_mouse_move(
434 point(px(240.), px(300.)),
435 MouseButton::Left,
436 Modifiers::none(),
437 );
438 cx.simulate_mouse_up(
439 point(px(240.), px(300.)),
440 MouseButton::Left,
441 Modifiers::none(),
442 );
443 cx.run_until_parked();
444
445 let (left, right) = cx.update(|_, cx| {
446 let area = area.read(cx);
447 (
448 area.dock_size(DockPlacement::Left),
449 area.dock_size(DockPlacement::Right),
450 )
451 });
452 assert_eq!(
453 right,
454 Some(px(200.)),
455 "the right dock must not move when the left handle is dragged"
456 );
457 assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
458 }
459 #[gpui::test]
460 fn dragging_the_bottom_dock_closed_and_open_reports_each_toggle(cx: &mut TestAppContext) {
461 cx.update(|cx| crate::init(cx));
462 let (area, cx) = cx.add_window_view(|window, cx| {
463 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
464 });
465 cx.simulate_resize(size(px(800.), px(600.)));
466 let events = Rc::new(std::cell::RefCell::new(Vec::new()));
467 cx.update(|window, cx| {
468 area.update(cx, |area, cx| {
469 area.set_center(
470 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
471 window,
472 cx,
473 );
474 area.set_dock(
475 DockPlacement::Bottom,
476 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
477 window,
478 cx,
479 );
480 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
481 });
482 let events = events.clone();
483 cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
484 events
485 .borrow_mut()
486 .push((event.placement(), event.is_open()));
487 })
488 .detach();
489 });
490 cx.run_until_parked();
491
492 let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
493 cx.update(|window, cx| window.draw(cx).clear(cx));
494 let (first, rest) = ys.split_first().unwrap();
495 cx.simulate_mouse_down(
496 point(px(400.), px(*first)),
497 MouseButton::Left,
498 Modifiers::none(),
499 );
500 for y in rest {
501 cx.simulate_mouse_move(
502 point(px(400.), px(*y)),
503 MouseButton::Left,
504 Modifiers::none(),
505 );
506 }
507 cx.simulate_mouse_up(
508 point(px(400.), px(*ys.last().unwrap())),
509 MouseButton::Left,
510 Modifiers::none(),
511 );
512 cx.run_until_parked();
513 };
514
515 drag(cx, &[400.5, 380., 350.]);
517 assert!(events.borrow().is_empty(), "a plain resize is not reported");
518
519 drag(cx, &[350.5, 356., 580., 540.]);
521 drag(cx, &[571.5, 565., 520.]);
522 assert_eq!(
523 *events.borrow(),
524 vec![
525 (DockPlacement::Bottom, false),
526 (DockPlacement::Bottom, true)
527 ]
528 );
529 }
530
531 #[gpui::test]
532 fn dragging_the_bottom_handle_below_the_minimum_snaps_to_the_nearer_end(
533 cx: &mut TestAppContext,
534 ) {
535 cx.update(|cx| crate::init(cx));
536 let (area, cx) = cx.add_window_view(|window, cx| {
537 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
538 });
539 cx.simulate_resize(size(px(800.), px(600.)));
540 cx.update(|window, cx| {
541 area.update(cx, |area, cx| {
542 area.set_center(
543 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
544 window,
545 cx,
546 );
547 area.set_dock(
548 DockPlacement::Bottom,
549 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
550 window,
551 cx,
552 );
553 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
554 });
555 });
556 cx.run_until_parked();
557
558 let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
559 cx.update(|window, cx| window.draw(cx).clear(cx));
560 let (first, rest) = ys.split_first().unwrap();
561 cx.simulate_mouse_down(
562 point(px(400.), px(*first)),
563 MouseButton::Left,
564 Modifiers::none(),
565 );
566 for y in rest {
567 cx.simulate_mouse_move(
568 point(px(400.), px(*y)),
569 MouseButton::Left,
570 Modifiers::none(),
571 );
572 }
573 cx.simulate_mouse_up(
574 point(px(400.), px(*ys.last().unwrap())),
575 MouseButton::Left,
576 Modifiers::none(),
577 );
578 cx.run_until_parked();
579 };
580 let bottom = |cx: &mut VisualTestContext| {
581 cx.update(|_, cx| {
582 let area = area.read(cx);
583 (
584 area.is_dock_open(DockPlacement::Bottom),
585 area.dock_size(DockPlacement::Bottom),
586 )
587 })
588 };
589
590 drag(cx, &[400.5, 406., 580., 540.]);
594 assert!(!bottom(cx).0, "released nearer the strip, the dock closes");
595
596 drag(cx, &[571.5, 565., 520.]);
599 assert_eq!(
600 bottom(cx),
601 (true, Some(PANEL_MIN_SIZE)),
602 "released nearer the minimum, the dock opens at the minimum"
603 );
604 }
605}