Skip to main content

gpui_base/dock/
tiles_state.rs

1//! A tiles canvas's behavior, with no appearance of its own.
2
3use crate::TestSupportExt as _;
4use std::{rc::Rc, sync::Arc};
5
6use gpui::{
7    AnyElement, App, Bounds, Context, Div, Empty, EntityId, EventEmitter, FocusHandle, Focusable,
8    InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render, Size,
9    Stateful, Styled as _, WeakEntity, Window, div, prelude::FluentBuilder as _, px,
10};
11
12use crate::UndoHistory;
13
14use super::{
15    drag::AnyDrag,
16    layout::{NodeId, PanelId},
17    panel::PanelView,
18    tiles_geometry::{
19        MINIMUM_SIZE, ResizeDrag, ResizeSide, TileChange, apply_boundary_constraints,
20        compute_resized_bounds, content_size, magnetic_snap,
21    },
22};
23
24/// What a tiles canvas cannot carry out on its own.
25///
26/// The canvas mirrors one `Tiles` node but does not own the tree that node
27/// lives in, so — exactly as with [`TabGroupEvent`](super::TabGroupEvent) —
28/// every outcome is reported as an intent and applied by the container
29/// through `PaneTree::set_tile_bounds` / `PaneTree::bring_to_front`.
30#[non_exhaustive]
31pub enum TilesEvent {
32    /// A tile finished moving or resizing at `bounds`.
33    BoundsChanged {
34        panel: PanelId,
35        bounds: Bounds<Pixels>,
36    },
37    /// A tile was interacted with and should stack above its peers.
38    BringToFront { panel: PanelId },
39    /// The user asked to close `panel`, dismissing its tile.
40    ClosePanel { panel: PanelId },
41    /// A host-owned drag landed on the canvas. The canvas has free
42    /// coordinates, so the host reads the landing position itself.
43    DragDrop { item: AnyDrag },
44    /// One tile asked to fill the whole dock. The container installs the
45    /// *canvas* as its zoomed view, and the canvas draws that one tile with
46    /// its chrome — which is where the control that zooms back out lives.
47    ZoomIn { panel: PanelId },
48    /// The zoomed tile gave the dock back.
49    ZoomOut,
50}
51
52/// One tile, mirrored from a `Tiles` node.
53#[derive(Clone)]
54struct Tile {
55    panel: Arc<dyn PanelView>,
56    id: PanelId,
57    bounds: Bounds<Pixels>,
58    z_index: usize,
59}
60
61/// An in-flight move: which tile, and where the pointer and the tile were
62/// when it started.
63#[derive(Clone, Copy)]
64struct TileMove {
65    panel: PanelId,
66    initial_pointer: Point<Pixels>,
67    initial_bounds: Bounds<Pixels>,
68}
69
70/// An in-flight resize: which tile, and the geometry module's own drag record.
71#[derive(Clone, Copy)]
72struct TileResize {
73    panel: PanelId,
74    initial_bounds: Bounds<Pixels>,
75    drag: ResizeDrag,
76}
77
78/// A tiles canvas's behavior, with no appearance of its own.
79///
80/// It owns the tile list mirrored from the layout tree, the in-flight move and
81/// resize state, and the undo stack. Everything visible is produced by the
82/// [`TilesRenderer`] the host installs.
83pub struct TilesState {
84    node: NodeId,
85    /// Handed to the callbacks in [`TileContext`], which are built from a
86    /// plain `&App` and so cannot ask for it.
87    this: WeakEntity<Self>,
88    tiles: Vec<Tile>,
89    focus_handle: FocusHandle,
90    /// The tile filling the whole dock, if one is. Driven by the container
91    /// through [`Self::set_zoomed`], so the canvas and the container name the
92    /// same tile or neither does.
93    zoomed: Option<PanelId>,
94    moving: Option<TileMove>,
95    resizing: Option<TileResize>,
96    history: UndoHistory<TileChange>,
97    renderer: Rc<dyn TilesRenderer>,
98}
99
100impl TilesState {
101    /// Only a container builds canvases: a canvas is the entity mirror of one
102    /// `Tiles` node, created when that node first appears in the tree.
103    pub(crate) fn new(node: NodeId, _window: &mut Window, cx: &mut Context<Self>) -> Self {
104        Self {
105            node,
106            this: cx.weak_entity(),
107            tiles: Vec::new(),
108            focus_handle: cx.focus_handle(),
109            zoomed: None,
110            moving: None,
111            resizing: None,
112            history: UndoHistory::new().group_interval(std::time::Duration::from_millis(100)),
113            renderer: Rc::new(BareTiles),
114        }
115    }
116
117    pub fn with_renderer(mut self, renderer: Rc<dyn TilesRenderer>) -> Self {
118        self.renderer = renderer;
119        self
120    }
121
122    /// The `Tiles` node this canvas mirrors.
123    pub fn node(&self) -> NodeId {
124        self.node
125    }
126
127    /// Every tile, in stacking order (lowest first).
128    pub fn tiles(&self, cx: &App) -> Vec<TileContext> {
129        let mut order: Vec<usize> = (0..self.tiles.len()).collect();
130        order.sort_by_key(|ix| (self.tiles[*ix].z_index, *ix));
131        order
132            .into_iter()
133            .map(|ix| self.tile_context(ix, cx))
134            .collect()
135    }
136
137    /// Mirror one `Tiles` node's membership and geometry into this canvas.
138    pub(crate) fn sync_from_tree(
139        &mut self,
140        tiles: Vec<(Arc<dyn PanelView>, Bounds<Pixels>, usize)>,
141        cx: &mut Context<Self>,
142    ) {
143        self.tiles = tiles
144            .into_iter()
145            .map(|(panel, bounds, z_index)| Tile {
146                id: panel.panel_id(cx),
147                panel,
148                bounds,
149                z_index,
150            })
151            .collect();
152        // A tile that left the canvas must not be resurrected by an in-flight
153        // gesture that outlived it.
154        if self
155            .moving
156            .is_some_and(|drag| self.index_of(drag.panel).is_none())
157        {
158            self.moving = None;
159        }
160        if self
161            .resizing
162            .is_some_and(|drag| self.index_of(drag.panel).is_none())
163        {
164            self.resizing = None;
165        }
166        cx.notify();
167    }
168
169    /// The tile filling the whole dock, if one is.
170    pub fn zoomed_tile(&self) -> Option<PanelId> {
171        self.zoomed
172    }
173
174    /// Flip one tile's zoom.
175    pub fn toggle_zoom(&mut self, panel: PanelId, window: &mut Window, cx: &mut Context<Self>) {
176        let zoomed = (self.zoomed != Some(panel)).then_some(panel);
177        self.set_zoomed(zoomed, window, cx);
178    }
179
180    /// Zoom one tile in, or zoom out with `None`: the flag changes, the panel
181    /// is told, and the container is asked to install or clear the zoomed
182    /// view.
183    ///
184    /// The container drives this too when it clears a zoom from outside, so
185    /// the canvas cannot be left naming a tile the container is not showing.
186    ///
187    /// Zooming *in* is refused, leaving the flag alone, for a tile that is not
188    /// on this canvas or whose panel is not zoomable. Zooming *out* is never
189    /// refused: a tile that became unzoomable while zoomed still has to be
190    /// able to give the dock back.
191    pub(crate) fn set_zoomed(
192        &mut self,
193        zoomed: Option<PanelId>,
194        window: &mut Window,
195        cx: &mut Context<Self>,
196    ) {
197        if self.zoomed == zoomed {
198            return;
199        }
200        // The outgoing tile hears about it as well as the incoming one, so a
201        // zoom moved straight from one tile to another leaves neither panel
202        // believing it still fills the dock.
203        let outgoing = self.zoomed.and_then(|panel| self.panel_view(panel));
204        let incoming = zoomed.and_then(|panel| self.panel_view(panel));
205        if zoomed.is_some() && !incoming.as_ref().is_some_and(|panel| panel.zoomable(cx)) {
206            return;
207        }
208
209        self.zoomed = zoomed;
210        cx.emit(match zoomed {
211            Some(panel) => TilesEvent::ZoomIn { panel },
212            None => TilesEvent::ZoomOut,
213        });
214
215        // Delivered outside this update so a `set_zoomed` handler may call
216        // back into the canvas.
217        cx.spawn_in(window, async move |_, cx| {
218            _ = cx.update(|window, cx| {
219                if let Some(panel) = outgoing {
220                    panel.set_zoomed(false, window, cx);
221                }
222                if let Some(panel) = incoming {
223                    panel.set_zoomed(true, window, cx);
224                }
225            });
226        })
227        .detach();
228        cx.notify();
229    }
230
231    /// Undo the most recent group of tile changes.
232    pub fn undo(&mut self, cx: &mut Context<Self>) {
233        let Some(changes) = self.history.undo() else {
234            return;
235        };
236        for change in changes {
237            if let (Some(panel), Some(bounds)) =
238                (self.panel_of(change.tile_id()), change.old_bounds())
239            {
240                cx.emit(TilesEvent::BoundsChanged { panel, bounds });
241            }
242        }
243        cx.notify();
244    }
245
246    /// Redo the most recently undone group of tile changes.
247    pub fn redo(&mut self, cx: &mut Context<Self>) {
248        let Some(changes) = self.history.redo() else {
249            return;
250        };
251        for change in changes {
252            if let (Some(panel), Some(bounds)) =
253                (self.panel_of(change.tile_id()), change.new_bounds())
254            {
255                cx.emit(TilesEvent::BoundsChanged { panel, bounds });
256            }
257        }
258        cx.notify();
259    }
260}
261
262impl TilesState {
263    fn index_of(&self, panel: PanelId) -> Option<usize> {
264        self.tiles.iter().position(|tile| tile.id == panel)
265    }
266
267    fn bounds_of(&self, panel: PanelId) -> Option<Bounds<Pixels>> {
268        self.index_of(panel).map(|ix| self.tiles[ix].bounds)
269    }
270
271    fn panel_view(&self, panel: PanelId) -> Option<Arc<dyn PanelView>> {
272        self.index_of(panel).map(|ix| self.tiles[ix].panel.clone())
273    }
274
275    /// The panel behind a history record's `EntityId`.
276    fn panel_of(&self, entity: EntityId) -> Option<PanelId> {
277        self.tiles
278            .iter()
279            .find(|tile| tile.panel.view().entity_id() == entity)
280            .map(|tile| tile.id)
281    }
282
283    /// Every other tile's bounds, which is what the snapping arithmetic
284    /// measures against.
285    fn other_bounds(&self, panel: PanelId) -> Vec<Bounds<Pixels>> {
286        self.tiles
287            .iter()
288            .filter(|tile| tile.id != panel)
289            .map(|tile| tile.bounds)
290            .collect()
291    }
292
293    fn grid_size(&self, cx: &App) -> Pixels {
294        self.renderer.grid_size(cx)
295    }
296
297    fn begin_move(&mut self, panel: PanelId, pointer: Point<Pixels>, cx: &mut Context<Self>) {
298        // A zoomed tile fills the dock rather than sitting at its stored
299        // bounds, so there is nothing for a move to mean — the same reason a
300        // zoomed tab group reports itself locked.
301        if self.zoomed.is_some() {
302            return;
303        }
304        let Some(initial_bounds) = self.bounds_of(panel) else {
305            return;
306        };
307        self.moving = Some(TileMove {
308            panel,
309            initial_pointer: pointer,
310            initial_bounds,
311        });
312        cx.emit(TilesEvent::BringToFront { panel });
313        cx.notify();
314    }
315
316    fn move_to(&mut self, pointer: Point<Pixels>, cx: &mut Context<Self>) {
317        let Some(drag) = self.moving else {
318            return;
319        };
320        let delta = pointer - drag.initial_pointer;
321        let candidate = Bounds {
322            origin: apply_boundary_constraints(
323                drag.initial_bounds.origin + delta,
324                drag.initial_bounds.size.width,
325            ),
326            size: drag.initial_bounds.size,
327        };
328        let origin = magnetic_snap(
329            candidate,
330            &self.other_bounds(drag.panel),
331            self.grid_size(cx),
332        );
333
334        self.apply_bounds(
335            drag.panel,
336            Bounds {
337                origin,
338                size: drag.initial_bounds.size,
339            },
340            cx,
341        );
342    }
343
344    fn end_move(&mut self, cx: &mut Context<Self>) {
345        let Some(drag) = self.moving.take() else {
346            return;
347        };
348        self.record(drag.panel, drag.initial_bounds, cx);
349    }
350
351    fn begin_resize(
352        &mut self,
353        panel: PanelId,
354        side: ResizeSide,
355        pointer: Point<Pixels>,
356        cx: &mut Context<Self>,
357    ) {
358        if self.zoomed.is_some() {
359            return;
360        }
361        let Some(initial_bounds) = self.bounds_of(panel) else {
362            return;
363        };
364        self.resizing = Some(TileResize {
365            panel,
366            initial_bounds,
367            drag: ResizeDrag::new(side, pointer, initial_bounds),
368        });
369        cx.emit(TilesEvent::BringToFront { panel });
370        cx.notify();
371    }
372
373    fn resize_to(&mut self, pointer: Point<Pixels>, cx: &mut Context<Self>) {
374        let Some(resize) = self.resizing else {
375            return;
376        };
377        let previous = resize.drag.last_bounds();
378        // The pointer is in window coordinates and the bounds in canvas
379        // coordinates, so the moving edge is derived from how far the pointer
380        // has travelled since the drag began, applied to the bounds it began
381        // with — never from the pointer's position itself.
382        let initial = resize.initial_bounds;
383        let delta = pointer - resize.drag.start_position();
384        let (new_x, new_y, new_width, new_height) = match resize.drag.side() {
385            ResizeSide::Left => (Some(initial.origin.x + delta.x), None, None, None),
386            ResizeSide::Right => (
387                None,
388                None,
389                Some((initial.size.width + delta.x).max(MINIMUM_SIZE.width)),
390                None,
391            ),
392            ResizeSide::Top => (None, Some(initial.origin.y + delta.y), None, None),
393            ResizeSide::Bottom => (
394                None,
395                None,
396                None,
397                Some((initial.size.height + delta.y).max(MINIMUM_SIZE.height)),
398            ),
399            ResizeSide::BottomRight => (
400                None,
401                None,
402                Some((initial.size.width + delta.x).max(MINIMUM_SIZE.width)),
403                Some((initial.size.height + delta.y).max(MINIMUM_SIZE.height)),
404            ),
405        };
406
407        let bounds = compute_resized_bounds(
408            previous,
409            new_x,
410            new_y,
411            new_width,
412            new_height,
413            &self.other_bounds(resize.panel),
414            self.grid_size(cx),
415        );
416
417        self.resizing = Some(TileResize {
418            drag: resize.drag.with_last_bounds(bounds),
419            ..resize
420        });
421        self.apply_bounds(resize.panel, bounds, cx);
422    }
423
424    fn end_resize(&mut self, cx: &mut Context<Self>) {
425        let Some(resize) = self.resizing.take() else {
426            return;
427        };
428        self.record(resize.panel, resize.initial_bounds, cx);
429    }
430
431    /// Show the new geometry immediately and report it, so the container's
432    /// tree and this mirror never disagree for a frame.
433    fn apply_bounds(&mut self, panel: PanelId, bounds: Bounds<Pixels>, cx: &mut Context<Self>) {
434        let Some(ix) = self.index_of(panel) else {
435            return;
436        };
437        if self.tiles[ix].bounds == bounds {
438            return;
439        }
440        self.tiles[ix].bounds = bounds;
441        cx.emit(TilesEvent::BoundsChanged { panel, bounds });
442        cx.notify();
443    }
444
445    /// Push one completed gesture onto the undo stack.
446    fn record(&mut self, panel: PanelId, old_bounds: Bounds<Pixels>, cx: &mut Context<Self>) {
447        let Some(ix) = self.index_of(panel) else {
448            return;
449        };
450        let tile = &self.tiles[ix];
451        if tile.bounds == old_bounds {
452            return;
453        }
454        self.history.push(TileChange::bounds_change(
455            tile.panel.view().entity_id(),
456            old_bounds,
457            tile.bounds,
458        ));
459        cx.notify();
460    }
461
462    /// Ask the container to close `panel`. Nothing happens for a tile that
463    /// is not on this canvas, or for a panel that refuses to close.
464    fn close_tile(&mut self, panel: PanelId, cx: &mut Context<Self>) {
465        let closable = self
466            .tiles
467            .iter()
468            .any(|tile| tile.id == panel && tile.panel.closable(cx));
469        if !closable {
470            return;
471        }
472        cx.emit(TilesEvent::ClosePanel { panel });
473        cx.notify();
474    }
475
476    fn tile_context(&self, ix: usize, cx: &App) -> TileContext {
477        let tile = &self.tiles[ix];
478        let panel = tile.id;
479        let canvas = self.this.clone();
480
481        TileContext {
482            node: self.node,
483            panel: tile.panel.clone(),
484            id: panel,
485            bounds: tile.bounds,
486            z_index: tile.z_index,
487            moving: self.moving.is_some_and(|drag| drag.panel == panel),
488            resizing: self.resizing.is_some_and(|drag| drag.panel == panel),
489            closable: tile.panel.closable(cx),
490            zoomed: self.zoomed == Some(panel),
491            zoomable: tile.panel.zoomable(cx),
492            on_begin_move: {
493                let canvas = canvas.clone();
494                Rc::new(move |pointer, _, cx| {
495                    _ = canvas.update(cx, |canvas, cx| canvas.begin_move(panel, pointer, cx));
496                })
497            },
498            on_move_to: {
499                let canvas = canvas.clone();
500                Rc::new(move |pointer, _, cx| {
501                    _ = canvas.update(cx, |canvas, cx| canvas.move_to(pointer, cx));
502                })
503            },
504            on_end_move: {
505                let canvas = canvas.clone();
506                Rc::new(move |_, cx| {
507                    _ = canvas.update(cx, |canvas, cx| canvas.end_move(cx));
508                })
509            },
510            on_begin_resize: {
511                let canvas = canvas.clone();
512                Rc::new(move |side, pointer, _, cx| {
513                    _ = canvas.update(cx, |canvas, cx| {
514                        canvas.begin_resize(panel, side, pointer, cx)
515                    });
516                })
517            },
518            on_resize_to: {
519                let canvas = canvas.clone();
520                Rc::new(move |pointer, _, cx| {
521                    _ = canvas.update(cx, |canvas, cx| canvas.resize_to(pointer, cx));
522                })
523            },
524            on_end_resize: {
525                let canvas = canvas.clone();
526                Rc::new(move |_, cx| {
527                    _ = canvas.update(cx, |canvas, cx| canvas.end_resize(cx));
528                })
529            },
530            on_bring_to_front: {
531                let canvas = canvas.clone();
532                Rc::new(move |_, cx| {
533                    _ = canvas.update(cx, |_, cx| {
534                        cx.emit(TilesEvent::BringToFront { panel });
535                    });
536                })
537            },
538            on_toggle_zoom: {
539                let canvas = canvas.clone();
540                Rc::new(move |window, cx| {
541                    _ = canvas.update(cx, |canvas, cx| canvas.toggle_zoom(panel, window, cx));
542                })
543            },
544            on_close: Rc::new(move |_, cx| {
545                _ = canvas.update(cx, |canvas, cx| canvas.close_tile(panel, cx));
546            }),
547        }
548    }
549}
550
551impl EventEmitter<TilesEvent> for TilesState {}
552
553impl Focusable for TilesState {
554    fn focus_handle(&self, _: &App) -> FocusHandle {
555        self.focus_handle.clone()
556    }
557}
558
559impl Render for TilesState {
560    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
561        let renderer = self.renderer.clone();
562        let focus_handle = self.focus_handle.clone();
563        // A zoomed tile fills the dock on its own, so the tiles beside it are
564        // not drawn — just as the rest of the dock is not drawn behind it.
565        // A zoom naming a tile that has since left the canvas draws the
566        // canvas whole rather than nothing at all.
567        let zoomed = self.zoomed.filter(|panel| self.index_of(*panel).is_some());
568        let tiles: Vec<TileContext> = self
569            .tiles(cx)
570            .into_iter()
571            .filter(|tile| zoomed.is_none_or(|panel| tile.id == panel))
572            .collect();
573        // Every tile, not the drawn subset: an overlay scrollbar measures the
574        // whole canvas.
575        let content = content_size(
576            &self
577                .tiles
578                .iter()
579                .map(|tile| tile.bounds)
580                .collect::<Vec<_>>(),
581        );
582
583        renderer
584            .frame(window, cx)
585            .test_support()
586            .track_focus(&focus_handle)
587            .on_drop(cx.listener(|_, item: &AnyDrag, _, cx| {
588                cx.emit(TilesEvent::DragDrop { item: item.clone() });
589            }))
590            .children(
591                tiles
592                    .into_iter()
593                    .map(|tile| {
594                        renderer
595                            .tile_frame(&tile, window, cx)
596                            // The only positioning base installs anywhere in
597                            // the dock. A tiles canvas *is* "panels at stored
598                            // coordinates": drawing one somewhere other than
599                            // its own bounds would not be a different skin,
600                            // it would be a different data structure. A
601                            // zoomed tile is the exception — it is no longer
602                            // at its coordinates, and how it fills the dock
603                            // is the skin's to decide.
604                            .when(!tile.zoomed, |this| {
605                                this.absolute()
606                                    .left(tile.bounds.origin.x)
607                                    .top(tile.bounds.origin.y)
608                                    .w(tile.bounds.size.width)
609                                    .h(tile.bounds.size.height)
610                            })
611                            .child(renderer.render_drag_bar(&tile, window, cx))
612                            .child(
613                                renderer
614                                    .panel_frame(&tile, window, cx)
615                                    .child(tile.panel.view()),
616                            )
617                            // Nothing to resize against while zoomed, and the
618                            // canvas refuses the gesture anyway.
619                            .when(!tile.zoomed, |this| {
620                                this.child(renderer.render_resize_handles(&tile, window, cx))
621                            })
622                    })
623                    .collect::<Vec<_>>(),
624            )
625            // Last, so it paints and hit-tests above every tile. A zoomed
626            // canvas draws one tile filling the dock, so there is no canvas
627            // for an overlay to sit over.
628            .when(zoomed.is_none(), |this| {
629                this.children(renderer.render_overlay(content, window, cx))
630            })
631    }
632}
633
634type MovePointerHandler = Rc<dyn Fn(Point<Pixels>, &mut Window, &mut App)>;
635type ResizeStartHandler = Rc<dyn Fn(ResizeSide, Point<Pixels>, &mut Window, &mut App)>;
636type GestureEndHandler = Rc<dyn Fn(&mut Window, &mut App)>;
637
638/// What a skin needs to draw one tile, and the callbacks it invokes rather
639/// than reimplementing the snapping and resize arithmetic.
640#[derive(Clone)]
641pub struct TileContext {
642    node: NodeId,
643    panel: Arc<dyn PanelView>,
644    id: PanelId,
645    bounds: Bounds<Pixels>,
646    z_index: usize,
647    moving: bool,
648    resizing: bool,
649    closable: bool,
650    zoomed: bool,
651    zoomable: bool,
652    on_begin_move: MovePointerHandler,
653    on_move_to: MovePointerHandler,
654    on_end_move: GestureEndHandler,
655    on_begin_resize: ResizeStartHandler,
656    on_resize_to: MovePointerHandler,
657    on_end_resize: GestureEndHandler,
658    on_bring_to_front: GestureEndHandler,
659    on_toggle_zoom: GestureEndHandler,
660    on_close: GestureEndHandler,
661}
662
663impl TileContext {
664    /// The `Tiles` node this tile belongs to, for a skin that needs to name
665    /// the canvas in a drag payload or a drop target.
666    pub fn node(&self) -> NodeId {
667        self.node
668    }
669
670    pub fn panel(&self) -> &Arc<dyn PanelView> {
671        &self.panel
672    }
673
674    pub fn panel_id(&self) -> PanelId {
675        self.id
676    }
677
678    pub fn bounds(&self) -> Bounds<Pixels> {
679        self.bounds
680    }
681
682    pub fn z_index(&self) -> usize {
683        self.z_index
684    }
685
686    pub fn is_moving(&self) -> bool {
687        self.moving
688    }
689
690    pub fn is_resizing(&self) -> bool {
691        self.resizing
692    }
693
694    pub fn is_closable(&self) -> bool {
695        self.closable
696    }
697
698    /// Whether this tile is the one filling the whole dock.
699    ///
700    /// A zoomed tile is drawn without its stored bounds and takes no move or
701    /// resize gesture, so a skin should offer the way back out here rather
702    /// than the affordances of a tile that can still be dragged.
703    pub fn is_zoomed(&self) -> bool {
704        self.zoomed
705    }
706
707    /// Whether this tile's panel allows zooming at all. Where the zoom
708    /// control appears is the skin's decision; whether there is one to offer
709    /// is not.
710    pub fn is_zoomable(&self) -> bool {
711        self.zoomable
712    }
713
714    /// Pointer positions are in window coordinates: every gesture is resolved
715    /// against the position the gesture started at, so the skin never has to
716    /// convert into canvas space.
717    pub fn begin_move(&self, pointer: Point<Pixels>, window: &mut Window, cx: &mut App) {
718        (self.on_begin_move)(pointer, window, cx);
719    }
720
721    pub fn move_to(&self, pointer: Point<Pixels>, window: &mut Window, cx: &mut App) {
722        (self.on_move_to)(pointer, window, cx);
723    }
724
725    pub fn end_move(&self, window: &mut Window, cx: &mut App) {
726        (self.on_end_move)(window, cx);
727    }
728
729    pub fn begin_resize(
730        &self,
731        side: ResizeSide,
732        pointer: Point<Pixels>,
733        window: &mut Window,
734        cx: &mut App,
735    ) {
736        (self.on_begin_resize)(side, pointer, window, cx);
737    }
738
739    pub fn resize_to(&self, pointer: Point<Pixels>, window: &mut Window, cx: &mut App) {
740        (self.on_resize_to)(pointer, window, cx);
741    }
742
743    pub fn end_resize(&self, window: &mut Window, cx: &mut App) {
744        (self.on_end_resize)(window, cx);
745    }
746
747    pub fn bring_to_front(&self, window: &mut Window, cx: &mut App) {
748        (self.on_bring_to_front)(window, cx);
749    }
750
751    /// Flip this tile between filling the whole dock and sitting at its
752    /// stored bounds.
753    ///
754    /// Zooming *in* is refused when [`Self::is_zoomable`] is false, so a skin
755    /// that offers a Zoom control should gate it on that. Zooming out is
756    /// never refused.
757    pub fn toggle_zoom(&self, window: &mut Window, cx: &mut App) {
758        (self.on_toggle_zoom)(window, cx);
759    }
760
761    /// Dismiss this tile. Refused when [`Self::is_closable`] is false, so a
762    /// skin that offers a Close control should gate it on that.
763    pub fn close(&self, window: &mut Window, cx: &mut App) {
764        (self.on_close)(window, cx);
765    }
766}
767
768/// Appearance for a tiles canvas. Base draws none of it.
769///
770/// Like [`TabGroupRenderer`](super::TabGroupRenderer), the frame hooks return
771/// the element itself rather than wrapping one: base attaches focus and drop
772/// handling to the canvas frame and the stored bounds to the tile frame, so a
773/// wrapper would put the hit area and the painted area on different elements.
774/// That is also why there is no `wrap_canvas` hook — it would be exactly the
775/// wrapper the `TabGroupRenderer` review ruled out.
776#[allow(unused_variables)]
777pub trait TilesRenderer: 'static {
778    /// The canvas frame, which base tracks focus and drop handling on.
779    fn frame(&self, window: &mut Window, cx: &mut App) -> Stateful<Div> {
780        div().id("tiles")
781    }
782
783    /// One tile's frame, which base positions at the tile's stored bounds.
784    fn tile_frame(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> Stateful<Div> {
785        div().id("tile")
786    }
787
788    /// The strip the tile is dragged by. Its height is
789    /// [`DRAG_BAR_HEIGHT`](super::DRAG_BAR_HEIGHT), which base's snapping
790    /// arithmetic and the skin must agree on.
791    fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement;
792
793    /// The tile's resize affordances. Their hit size is
794    /// [`HANDLE_SIZE`](super::HANDLE_SIZE).
795    fn render_resize_handles(
796        &self,
797        tile: &TileContext,
798        window: &mut Window,
799        cx: &mut App,
800    ) -> AnyElement {
801        Empty.into_any_element()
802    }
803
804    /// The frame around one tile's panel view.
805    ///
806    /// A wrapper, unlike the other frame hooks, and for the same reason
807    /// [`DockAreaRenderer::split_frame`](super::DockAreaRenderer::split_frame)
808    /// is one: base attaches nothing to it, so there is no hit area to keep
809    /// together with the painted area. It exists because base draws the panel
810    /// as an ordinary child of the tile frame, and a panel that does not size
811    /// itself would otherwise have no size at all — the old canvas wrapped it
812    /// in `h_flex().overflow_hidden().size_full()`, and nothing else can put
813    /// that back.
814    fn panel_frame(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> Stateful<Div> {
815        div().id(("tile-panel", tile.panel_id().as_u64()))
816    }
817
818    /// An element drawn above every tile — a scrollbar, a drop hint.
819    ///
820    /// Rendered last, so it paints and hit-tests above the tiles. That
821    /// ordering is the whole point: the canvas frame is the scroll container,
822    /// so an overlay placed with the frame's own children would sit beneath
823    /// every tile. Not called while a tile is zoomed, because then the canvas
824    /// is one tile filling the dock rather than a canvas.
825    ///
826    /// `content` is the scrollable extent of every tile measured from the
827    /// canvas origin, which a scrollbar needs and a hook holding only
828    /// `&mut App` could not work out.
829    fn render_overlay(
830        &self,
831        content: Size<Pixels>,
832        window: &mut Window,
833        cx: &mut App,
834    ) -> Option<AnyElement> {
835        None
836    }
837
838    /// The grid a tile snaps to when no neighbouring edge is close enough.
839    ///
840    /// The old canvas read this off the theme, which base cannot see; the
841    /// default is the ten-pixel grid the original rounded to.
842    fn grid_size(&self, cx: &App) -> Pixels {
843        px(10.)
844    }
845}
846
847/// The renderer a canvas starts with: the tiles and nothing else.
848pub(crate) struct BareTiles;
849
850impl TilesRenderer for BareTiles {
851    fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
852        Empty.into_any_element()
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use std::{cell::RefCell, rc::Rc};
859
860    use gpui::{Bounds, Entity, TestAppContext, VisualTestContext, point, size};
861
862    use super::*;
863    use crate::ElementExt as _;
864    use crate::dock::{
865        DockArea, DockAreaRenderer, DockLayout, TabGroupRenderer, test_support::TestPanel,
866    };
867
868    /// What each hook drew, in the order the frame prepainted it.
869    ///
870    /// Prepaint order, not call order: the overlay has to be *below the tiles
871    /// in the element tree*, and a renderer that computed it early and added
872    /// it late would still be called first. Prepaint walks the tree, so this
873    /// records the property that matters.
874    #[derive(Default)]
875    struct DrawOrder {
876        painted: Vec<&'static str>,
877        content: Option<Size<Pixels>>,
878        /// The contexts the drag bar was handed, so a test can drive a tile
879        /// through the same seam a skin would.
880        tiles: Vec<TileContext>,
881    }
882
883    struct OrderRecorder {
884        order: Rc<RefCell<DrawOrder>>,
885    }
886
887    impl TilesRenderer for OrderRecorder {
888        fn render_drag_bar(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
889            self.order.borrow_mut().tiles.push(tile.clone());
890            Empty.into_any_element()
891        }
892
893        fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
894            let order = self.order.clone();
895            div()
896                .id(("tile", tile.panel_id().as_u64()))
897                .on_prepaint(move |_, _, _| order.borrow_mut().painted.push("tile"))
898        }
899
900        fn render_overlay(
901            &self,
902            content: Size<Pixels>,
903            _: &mut Window,
904            _: &mut App,
905        ) -> Option<AnyElement> {
906            let order = self.order.clone();
907            Some(
908                div()
909                    .on_prepaint(move |_, _, _| {
910                        let mut order = order.borrow_mut();
911                        order.painted.push("overlay");
912                        order.content = Some(content);
913                    })
914                    .into_any_element(),
915            )
916        }
917    }
918
919    impl TabGroupRenderer for OrderRecorder {
920        fn render_tab_bar(
921            &self,
922            _: &super::super::TabGroupContext,
923            _: &mut Window,
924            _: &mut App,
925        ) -> AnyElement {
926            Empty.into_any_element()
927        }
928    }
929
930    impl DockAreaRenderer for OrderRecorder {
931        fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
932            Rc::new(OrderRecorder {
933                order: self.order.clone(),
934            })
935        }
936
937        fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
938            Rc::new(OrderRecorder {
939                order: self.order.clone(),
940            })
941        }
942    }
943
944    fn setup_order(
945        cx: &mut TestAppContext,
946    ) -> (
947        Entity<DockArea>,
948        Rc<RefCell<DrawOrder>>,
949        &mut VisualTestContext,
950    ) {
951        cx.update(|cx| {
952            let _ = crate::Theme::global_mut(cx);
953        });
954        let order: Rc<RefCell<DrawOrder>> = Rc::default();
955        let renderer = Rc::new(OrderRecorder {
956            order: order.clone(),
957        });
958        let (area, cx) = cx.add_window_view(|window, cx| {
959            DockArea::new("tiles-order", None, window, cx).with_renderer(renderer)
960        });
961        (area, order, cx)
962    }
963
964    /// The canvas overlay is drawn after every tile.
965    ///
966    /// This is the whole reason the hook exists rather than the skin adding a
967    /// scrollbar to the canvas frame: the frame is the scroll container, and
968    /// base appends the tiles to whatever it carries, so anything placed there
969    /// paints and hit-tests underneath every tile. Move
970    /// `children(render_overlay(..))` above `children(tiles)` in
971    /// `TilesState::render` and this fails.
972    #[gpui::test]
973    fn the_canvas_overlay_is_drawn_after_every_tile(cx: &mut TestAppContext) {
974        let (area, order, cx) = setup_order(cx);
975
976        cx.update(|window, cx| {
977            let first = TestPanel::new("First", cx);
978            let second = TestPanel::new("Second", cx);
979            let layout = DockLayout::tiles()
980                .tile(
981                    first,
982                    Bounds {
983                        origin: point(px(20.), px(20.)),
984                        size: size(px(100.), px(80.)),
985                    },
986                )
987                .tile(
988                    second,
989                    Bounds {
990                        origin: point(px(140.), px(20.)),
991                        size: size(px(100.), px(80.)),
992                    },
993                );
994            area.update(cx, |area, cx| area.set_center(layout, window, cx));
995        });
996        cx.run_until_parked();
997        order.borrow_mut().painted.clear();
998        cx.update(|window, cx| window.draw(cx).clear(cx));
999
1000        assert_eq!(
1001            order.borrow().painted,
1002            vec!["tile", "tile", "overlay"],
1003            "the overlay must come after every tile, or it paints beneath them"
1004        );
1005
1006        // And it is handed the whole canvas, not the tile it happens to sit
1007        // over: 20..240 across and 20..100 down, measured from the origin.
1008        assert_eq!(
1009            order.borrow().content,
1010            Some(size(px(240.), px(100.))),
1011            "the overlay is given the canvas's scrollable extent"
1012        );
1013    }
1014
1015    /// A resize is resolved against the pointer's travel since the drag
1016    /// began, never against the pointer's position: pointer positions are
1017    /// window coordinates and tile bounds are canvas coordinates, and the two
1018    /// differ by the canvas's own offset in the window. Reading the position
1019    /// directly widened the tile by that offset the moment a drag started.
1020    #[gpui::test]
1021    fn a_resize_tracks_the_pointer_travel_not_its_window_position(cx: &mut TestAppContext) {
1022        let (area, order, cx) = setup_order(cx);
1023
1024        cx.update(|window, cx| {
1025            let panel = TestPanel::new("Only", cx);
1026            let layout = DockLayout::tiles().tile(
1027                panel,
1028                Bounds {
1029                    origin: point(px(20.), px(20.)),
1030                    size: size(px(100.), px(100.)),
1031                },
1032            );
1033            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1034        });
1035        cx.run_until_parked();
1036        cx.update(|window, cx| window.draw(cx).clear(cx));
1037        let tile = order.borrow().tiles.last().cloned().expect("the tile drew");
1038
1039        // The pointer's window position is nowhere near the tile's canvas
1040        // bounds, as it never is once the canvas sits offset in the window.
1041        let start = point(px(500.), px(300.));
1042        cx.update(|window, cx| tile.begin_resize(ResizeSide::Right, start, window, cx));
1043        cx.update(|window, cx| tile.resize_to(start, window, cx));
1044        cx.run_until_parked();
1045        cx.update(|window, cx| window.draw(cx).clear(cx));
1046        assert_eq!(
1047            order.borrow().tiles.last().unwrap().bounds().size.width,
1048            px(100.),
1049            "a pointer that has not moved must not resize the tile"
1050        );
1051
1052        // 32px of travel: 100 + 32 puts the right edge at 152, and the
1053        // ten-pixel grid rounds it to 150.
1054        cx.update(|window, cx| tile.resize_to(start + point(px(32.), px(0.)), window, cx));
1055        cx.update(|window, cx| tile.end_resize(window, cx));
1056        cx.run_until_parked();
1057        cx.update(|window, cx| window.draw(cx).clear(cx));
1058        assert_eq!(
1059            order.borrow().tiles.last().unwrap().bounds().size.width,
1060            px(130.),
1061            "the tile grows by the pointer's travel, grid-rounded"
1062        );
1063    }
1064
1065    /// A zoomed tile fills the dock, so there is no canvas to overlay.
1066    #[gpui::test]
1067    fn a_zoomed_canvas_draws_no_overlay(cx: &mut TestAppContext) {
1068        let (area, order, cx) = setup_order(cx);
1069
1070        let panel = cx.update(|window, cx| {
1071            let panel = TestPanel::new("Only", cx);
1072            let layout = DockLayout::tiles().tile(
1073                panel.clone(),
1074                Bounds {
1075                    origin: point(px(20.), px(20.)),
1076                    size: size(px(100.), px(80.)),
1077                },
1078            );
1079            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1080            panel
1081        });
1082        cx.run_until_parked();
1083
1084        // Zoomed through the seam a skin uses, not a back door.
1085        cx.update(|window, cx| window.draw(cx).clear(cx));
1086        let tile = order.borrow().tiles.last().cloned().expect("the tile drew");
1087        cx.update(|window, cx| tile.toggle_zoom(window, cx));
1088        cx.run_until_parked();
1089        assert_eq!(
1090            cx.read(|cx| area.read(cx).zoomed_tile()),
1091            Some(PanelId::from(panel.entity_id())),
1092            "the tile is the one filling the dock"
1093        );
1094        order.borrow_mut().painted.clear();
1095        cx.update(|window, cx| window.draw(cx).clear(cx));
1096
1097        assert_eq!(
1098            order.borrow().painted,
1099            vec!["tile"],
1100            "a zoomed tile fills the dock, so no overlay is drawn over it"
1101        );
1102    }
1103}