Skip to main content

gpui_base/dock/
tiles_state.rs

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