1use std::{ops::Deref as _, 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, tab_panel::TabGroupSkin,
23 },
24 resizable::{render_resize_handle, resize_handle_appearance},
25 resize_handle,
26};
27
28#[derive(Clone)]
31struct ResizePanel;
32
33impl Render for ResizePanel {
34 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
35 Empty
36 }
37}
38
39impl DockAreaRenderer for DockSkin {
40 fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
44 div().id("dock-area")
45 }
46
47 fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
48 div().id("dock-area-center")
49 }
50
51 fn render_split_handle(
52 &self,
53 handle: &ResizeHandleContext,
54 window: &mut Window,
55 cx: &mut App,
56 ) -> Option<AnyElement> {
57 Some(render_resize_handle(handle, window, cx))
58 }
59
60 fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
61 div()
64 .id(("dock-split-frame", node.as_u64()))
65 .bg(cx.theme().tokens.tab_bar)
66 }
67
68 fn render_dock(
69 &self,
70 dock: &DockContext,
71 content: AnyElement,
72 window: &mut Window,
73 cx: &mut App,
74 ) -> AnyElement {
75 div()
80 .flex()
81 .size_full()
82 .relative()
83 .child(content)
84 .child(self.render_resize_handle(dock, window, cx))
85 .child(DockResizeTracker {
86 dock: dock.clone(),
87 shared: self.shared().clone(),
88 })
89 .into_any_element()
90 }
91
92 fn build_placeholder(
97 &self,
98 state: &PanelState,
99 _: &mut Window,
100 cx: &mut App,
101 ) -> Option<Arc<dyn PanelView>> {
102 let state = state.clone();
103 Some(panel_handle(cx.new(|cx| {
104 InvalidPanel::new(state.panel_name.clone(), state, cx)
105 })))
106 }
107
108 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
109 Rc::new(TabGroupSkin::new(self.shared().clone()))
110 }
111}
112
113impl DockSkin {
114 fn render_resize_handle(
115 &self,
116 dock: &DockContext,
117 _: &mut Window,
118 _: &mut App,
119 ) -> impl IntoElement {
120 let placement = dock.placement();
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())
143 .with_appearance(resize_handle_appearance())
144 .inside(edge)
145 .on_drag(ResizePanel, move |info, _, _, cx| {
146 cx.stop_propagation();
147 shared.resizing_dock().set(Some(placement));
148 cx.new(|_| info.deref().clone())
149 })
150 }
151}
152
153struct DockResizeTracker {
162 dock: DockContext,
163 shared: Rc<SkinShared>,
164}
165
166impl IntoElement for DockResizeTracker {
167 type Element = Self;
168
169 fn into_element(self) -> Self::Element {
170 self
171 }
172}
173
174impl Element for DockResizeTracker {
175 type RequestLayoutState = ();
176 type PrepaintState = ();
177
178 fn id(&self) -> Option<gpui::ElementId> {
179 None
180 }
181
182 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
183 None
184 }
185
186 fn request_layout(
187 &mut self,
188 _: Option<&gpui::GlobalElementId>,
189 _: Option<&gpui::InspectorElementId>,
190 window: &mut Window,
191 cx: &mut App,
192 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
193 (window.request_layout(Style::default(), None, cx), ())
194 }
195
196 fn prepaint(
197 &mut self,
198 _: Option<&gpui::GlobalElementId>,
199 _: Option<&gpui::InspectorElementId>,
200 _: gpui::Bounds<Pixels>,
201 _: &mut Self::RequestLayoutState,
202 _: &mut Window,
203 _: &mut App,
204 ) -> Self::PrepaintState {
205 }
206
207 fn paint(
208 &mut self,
209 _: Option<&gpui::GlobalElementId>,
210 _: Option<&gpui::InspectorElementId>,
211 _: gpui::Bounds<Pixels>,
212 _: &mut Self::RequestLayoutState,
213 _: &mut Self::PrepaintState,
214 window: &mut Window,
215 _: &mut App,
216 ) {
217 let placement = self.dock.placement();
218
219 window.on_mouse_event({
220 let dock = self.dock.clone();
221 let shared = self.shared.clone();
222 move |event: &MouseMoveEvent, phase, window, cx| {
223 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
224 return;
225 }
226 dock.resize_to(event.position, window, cx);
229 }
230 });
231
232 window.on_mouse_event({
233 let dock = self.dock.clone();
234 let shared = self.shared.clone();
235 move |_: &MouseUpEvent, phase, window, cx| {
236 if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
237 return;
238 }
239 shared.resizing_dock().set(None);
240 dock.end_resize(window, cx);
241 _ = shared
244 .area()
245 .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
246 }
247 });
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use std::rc::Rc;
254
255 use gpui::{
256 App, Entity, IntoElement as _, Modifiers, MouseButton, TestAppContext, VisualTestContext,
257 Window, point, px, size,
258 };
259
260 use std::cell::Cell;
261
262 use gpui_base::dock::DockAreaRenderer;
263
264 use gpui_base::PANEL_MIN_SIZE;
265
266 use crate::dock::{
267 DockArea, DockLayout, DockPlacement, DockSkin,
268 test_support::{MeasuredProbe, SizedProbe},
269 };
270
271 struct ChromelessDockSkin;
277
278 impl DockAreaRenderer for ChromelessDockSkin {
279 fn tab_group_renderer(&self) -> Rc<dyn gpui_base::dock::TabGroupRenderer> {
280 Rc::new(ChromelessTabs)
281 }
282 }
283
284 struct ChromelessTabs;
285 impl gpui_base::dock::TabGroupRenderer for ChromelessTabs {
286 fn render_tab_bar(
290 &self,
291 _: &gpui_base::dock::TabGroupContext,
292 _: &mut Window,
293 _: &mut App,
294 ) -> gpui::AnyElement {
295 gpui::Empty.into_any_element()
296 }
297 }
298
299 #[gpui::test]
308 fn a_dock_is_its_own_width_under_a_renderer_that_draws_no_chrome(cx: &mut TestAppContext) {
309 cx.update(|cx| crate::init(cx));
310 let measured = Rc::new(Cell::new(gpui::Size::default()));
311 let probe = measured.clone();
312 let centre = Rc::new(Cell::new(gpui::Size::default()));
313 let centre_probe = centre.clone();
314 let (area, cx) = cx.add_window_view(|window, cx| {
315 DockArea::new("test", None, window, cx).with_renderer(Rc::new(ChromelessDockSkin))
316 });
317 cx.simulate_resize(size(px(800.), px(600.)));
318 cx.update(|window, cx| {
319 area.update(cx, |area, cx| {
320 area.set_center(
321 DockLayout::tabs().panel(SizedProbe::new(centre_probe, cx)),
322 window,
323 cx,
324 );
325 area.set_dock(
326 DockPlacement::Right,
327 DockLayout::tabs().panel(SizedProbe::new(probe, cx)),
328 window,
329 cx,
330 );
331 area.set_dock_size(DockPlacement::Right, px(200.), window, cx);
332 });
333 });
334 cx.run_until_parked();
335 cx.update(|window, cx| {
336 area.update(cx, |area, cx| {
337 if !area.is_dock_open(DockPlacement::Right) {
338 area.toggle_dock(DockPlacement::Right, window, cx);
339 }
340 });
341 });
342 cx.run_until_parked();
343 cx.update(|window, cx| window.draw(cx).clear(cx));
344
345 let dock = measured.get().width;
346 assert_eq!(
347 dock,
348 px(200.),
349 "the right dock has to be its own width, not the area's: got {dock:?}"
350 );
351 let middle = centre.get().width;
355 assert_eq!(
356 middle,
357 px(600.),
358 "the centre has to be what the docks leave: got {middle:?}"
359 );
360 let tall = centre.get().height;
366 assert_eq!(
367 tall,
368 px(600.),
369 "a group has to fill its slot, or its panel gets no height: got {tall:?}"
370 );
371 }
372
373 fn area_with_side_docks(cx: &mut TestAppContext) -> (Entity<DockArea>, &mut VisualTestContext) {
374 cx.update(|cx| crate::init(cx));
375 let (area, cx) = cx.add_window_view(|window, cx| {
376 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
377 });
378 cx.simulate_resize(size(px(800.), px(600.)));
379 cx.update(|window, cx| {
380 area.update(cx, |area, cx| {
381 area.set_center(
382 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
383 window,
384 cx,
385 );
386 area.set_dock(
387 DockPlacement::Left,
388 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
389 window,
390 cx,
391 );
392 area.set_dock(
393 DockPlacement::Right,
394 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
395 window,
396 cx,
397 );
398 });
399 });
400 cx.run_until_parked();
401 (area, cx)
402 }
403
404 #[gpui::test]
411 fn dragging_the_left_handle_resizes_only_the_left_dock(cx: &mut TestAppContext) {
412 let (area, cx) = area_with_side_docks(cx);
413 cx.update(|window, cx| window.draw(cx).clear(cx));
414
415 cx.simulate_mouse_down(
417 point(px(198.5), px(300.)),
418 MouseButton::Left,
419 Modifiers::none(),
420 );
421 cx.simulate_mouse_move(
423 point(px(204.), px(300.)),
424 MouseButton::Left,
425 Modifiers::none(),
426 );
427 cx.simulate_mouse_move(
429 point(px(240.), px(300.)),
430 MouseButton::Left,
431 Modifiers::none(),
432 );
433 cx.simulate_mouse_up(
434 point(px(240.), px(300.)),
435 MouseButton::Left,
436 Modifiers::none(),
437 );
438 cx.run_until_parked();
439
440 let (left, right) = cx.update(|_, cx| {
441 let area = area.read(cx);
442 (
443 area.dock_size(DockPlacement::Left),
444 area.dock_size(DockPlacement::Right),
445 )
446 });
447 assert_eq!(
448 right,
449 Some(px(200.)),
450 "the right dock must not move when the left handle is dragged"
451 );
452 assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
453 }
454 #[gpui::test]
455 fn dragging_the_bottom_handle_below_the_minimum_snaps_to_the_nearer_end(
456 cx: &mut TestAppContext,
457 ) {
458 cx.update(|cx| crate::init(cx));
459 let (area, cx) = cx.add_window_view(|window, cx| {
460 DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
461 });
462 cx.simulate_resize(size(px(800.), px(600.)));
463 cx.update(|window, cx| {
464 area.update(cx, |area, cx| {
465 area.set_center(
466 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
467 window,
468 cx,
469 );
470 area.set_dock(
471 DockPlacement::Bottom,
472 DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
473 window,
474 cx,
475 );
476 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
477 });
478 });
479 cx.run_until_parked();
480
481 let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
482 cx.update(|window, cx| window.draw(cx).clear(cx));
483 let (first, rest) = ys.split_first().unwrap();
484 cx.simulate_mouse_down(
485 point(px(400.), px(*first)),
486 MouseButton::Left,
487 Modifiers::none(),
488 );
489 for y in rest {
490 cx.simulate_mouse_move(
491 point(px(400.), px(*y)),
492 MouseButton::Left,
493 Modifiers::none(),
494 );
495 }
496 cx.simulate_mouse_up(
497 point(px(400.), px(*ys.last().unwrap())),
498 MouseButton::Left,
499 Modifiers::none(),
500 );
501 cx.run_until_parked();
502 };
503 let bottom = |cx: &mut VisualTestContext| {
504 cx.update(|_, cx| {
505 let area = area.read(cx);
506 (
507 area.is_dock_open(DockPlacement::Bottom),
508 area.dock_size(DockPlacement::Bottom),
509 )
510 })
511 };
512
513 drag(cx, &[400.5, 406., 580., 540.]);
517 assert!(!bottom(cx).0, "released nearer the strip, the dock closes");
518
519 drag(cx, &[571.5, 565., 520.]);
522 assert_eq!(
523 bottom(cx),
524 (true, Some(PANEL_MIN_SIZE)),
525 "released nearer the minimum, the dock opens at the minimum"
526 );
527 }
528}