Skip to main content

gpui_kit_material/components/
pane_scaffold.rs

1//! Canonical layouts: list-detail and supporting pane, the panes adapting to
2//! the scaffold's own width and moving on Compose's pane motion, and the feed.
3//!
4//! Source: only m3e lists these (the Figma kit and md3.io do not).
5//! `packages/m3e-css/src/layout.css`, `m3e-react/src/layout/PaneScaffold.tsx`
6//! and `FeedLayout.tsx`; rules in [`super::pane_logic`]. Timing is this crate's
7//! default spatial token, which is what Compose's pane spring resolves to
8//! under each motion scheme. The feed's values are m3.material.io's through
9//! m3e: 16/24dp margins, 8dp between items. Its 240dp column minimum is m3e's
10//! own default, since no reference defines one.
11use super::adaptive::{WindowSizeClass, WindowWidthClass, sized_content};
12use super::pane_logic::*;
13use crate::{
14    color::ColorRole,
15    motion::{self, MotionToken},
16    theme::color,
17    tokens::typography::{BASE_PX, rem},
18};
19use gpui::{prelude::*, *};
20use std::rc::Rc;
21
22#[cfg(not(target_family = "wasm"))]
23use std::time::Instant;
24#[cfg(target_family = "wasm")]
25use web_time::Instant;
26
27/// Compose `PaneMotionDefaults.DelayedAnimationSpec`'s delayed ratio.
28const DELAYED_RATIO: f32 = 0.1;
29/// The drag handle's touch target (Compose
30/// `minimumInteractiveComponentSize`), in dp.
31const HANDLE_TARGET: f32 = 48.;
32/// The drag handle's bar at rest (`DragHandleTokens`, through m3e), in dp.
33const HANDLE_BAR: (f32, f32) = (4., 48.);
34/// The bar while pressed or dragged, in dp.
35const HANDLE_BAR_ACTIVE: (f32, f32) = (12., 52.);
36/// Travel below which a press and release on the handle is a click, in px.
37const CLICK_SLOP: f32 = 3.;
38/// The active bar's corner (medium), in dp.
39const HANDLE_BAR_ACTIVE_RADIUS: f32 = 12.;
40
41#[derive(Clone, Copy)]
42struct PaneRun {
43    from: PaneRect,
44    to: PaneRect,
45    delayed: bool,
46    started: Instant,
47}
48
49/// Transient motion and split state. The history stays in the navigator.
50struct ScaffoldState {
51    value: Option<ThreePaneValue>,
52    size: (f32, f32),
53    targets: [Option<PaneRect>; 3],
54    runs: [Option<PaneRun>; 3],
55    first_pane_width: Option<f32>,
56    dragging: Option<f32>,
57    /// Where the handle was pressed: a release there is a click.
58    pressed_at: f32,
59
60    history_len: Option<usize>,
61    focus_stack: Vec<FocusHandle>,
62    pane_focus: [FocusHandle; 3],
63    handle_focus: FocusHandle,
64}
65
66impl ScaffoldState {
67    fn sample(&self, role: usize, now: Instant) -> (Option<PaneRect>, bool) {
68        let Some(run) = self.runs[role] else {
69            return (self.targets[role], false);
70        };
71        let curve = motion::curve(MotionToken::DefaultSpatial);
72        let total = curve.duration().as_secs_f32().max(f32::EPSILON);
73        let mut elapsed = now.saturating_duration_since(run.started).as_secs_f32() / total;
74        if run.delayed {
75            elapsed = (elapsed - DELAYED_RATIO) / (1. - DELAYED_RATIO);
76        }
77        if elapsed >= 1. {
78            return (Some(run.to), false);
79        }
80        let t = (curve.ease())(elapsed.max(0.));
81        (Some(run.from.lerp(&run.to, t)), true)
82    }
83}
84
85/// A list-detail or supporting pane layout around caller-owned panes.
86///
87/// Panes adapt to the scaffold's own width, so a layout inside a navigation
88/// suite adapts to the room it has. Give it a definite height. Each pane scrolls
89/// on its own, and focus follows the destination and returns on back.
90///
91/// The navigator is controlled: call its `navigate_to` and `navigate_back` in
92/// your handlers, and `notify` its entity.
93#[derive(gpui::IntoElement)]
94pub struct PaneScaffold<K: Clone + PartialEq + 'static> {
95    id: ElementId,
96    navigator: Entity<PaneNavigator<K>>,
97    order: [PaneRole; 3],
98    rule: PaneRule,
99    size_class: Option<WindowSizeClass>,
100    rtl: bool,
101    panes: [Option<(SharedString, AnyElement)>; 3],
102    preferred_widths: [Option<f32>; 3],
103    handle: Option<SharedString>,
104    anchors: Rc<[PaneAnchor]>,
105}
106
107impl<K: Clone + PartialEq + 'static> PaneScaffold<K> {
108    fn new(
109        id: impl Into<ElementId>,
110        navigator: &Entity<PaneNavigator<K>>,
111        order: [PaneRole; 3],
112    ) -> Self {
113        Self {
114            id: id.into(),
115            navigator: navigator.clone(),
116            order,
117            rule: PaneRule::default(),
118            size_class: None,
119            rtl: false,
120            panes: [None, None, None],
121            preferred_widths: [None; 3],
122            handle: None,
123            anchors: Rc::from(DEFAULT_PANE_ANCHORS),
124        }
125    }
126
127    /// A list-detail layout: list, detail, then the optional extra pane.
128    pub fn list_detail(id: impl Into<ElementId>, navigator: &Entity<PaneNavigator<K>>) -> Self {
129        Self::new(id, navigator, LIST_DETAIL_ORDER)
130    }
131
132    /// A supporting pane layout: main, supporting, then the optional extra pane.
133    pub fn supporting_pane(id: impl Into<ElementId>, navigator: &Entity<PaneNavigator<K>>) -> Self {
134        Self::new(id, navigator, SUPPORTING_PANE_ORDER)
135    }
136
137    /// Supply a pane; `label` names its region for assistive technology.
138    pub fn pane(
139        mut self,
140        role: PaneRole,
141        label: impl Into<SharedString>,
142        content: impl IntoElement,
143    ) -> Self {
144        self.panes[role.index()] = Some((label.into(), content.into_any_element()));
145        self
146    }
147
148    /// How many panes may sit side by side.
149    pub fn rule(mut self, rule: PaneRule) -> Self {
150        self.rule = rule;
151        self
152    }
153
154    /// Classify this size instead of the scaffold's own, for a layout shown
155    /// smaller than the window it stands for, as previews are. `None` reads
156    /// the scaffold's width.
157    pub fn size_class(mut self, size_class: Option<WindowSizeClass>) -> Self {
158        self.size_class = size_class;
159        self
160    }
161
162    /// A pane's preferred width in dp, overriding the directive's.
163    pub fn preferred_width(mut self, role: PaneRole, width: f32) -> Self {
164        self.preferred_widths[role.index()] = (width.is_finite() && width >= 0.).then_some(width);
165        self
166    }
167
168    /// Mirror the pane order for a right-to-left language.
169    pub fn rtl(mut self, rtl: bool) -> Self {
170        self.rtl = rtl;
171        self
172    }
173
174    /// Show a drag handle between two panes; `label` is its accessible name.
175    /// It takes a drag, the arrows, Home, End, and Enter for the next anchor.
176    pub fn drag_handle(mut self, label: impl Into<SharedString>) -> Self {
177        self.handle = Some(label.into());
178        self
179    }
180
181    /// The positions the handle settles at.
182    pub fn anchors(mut self, anchors: impl IntoIterator<Item = PaneAnchor>) -> Self {
183        self.anchors = anchors.into_iter().collect();
184        self
185    }
186}
187
188const PANE_SELECTORS: [&str; 3] = ["pane-primary", "pane-secondary", "pane-tertiary"];
189
190/// Move to a settled handle position: the first pane's width from its center.
191fn settle_handle(
192    state: &Entity<ScaffoldState>,
193    position: f32,
194    spacer: f32,
195    window_notify: &mut App,
196) {
197    state.update(window_notify, |state, cx| {
198        state.first_pane_width = Some((position - (spacer / 2.).trunc()).max(0.));
199        state.dragging = None;
200        cx.notify();
201    });
202}
203
204impl<K: Clone + PartialEq + 'static> RenderOnce for PaneScaffold<K> {
205    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
206        let Self {
207            id,
208            navigator,
209            order,
210            rule,
211            size_class,
212            rtl,
213            panes,
214            preferred_widths,
215            handle,
216            anchors,
217        } = self;
218        let order = if rtl {
219            [order[2], order[1], order[0]]
220        } else {
221            order
222        };
223        let builder_id = id.clone();
224        div()
225            .id(id)
226            .size_full()
227            .overflow_hidden()
228            .child(sized_content(
229                (builder_id.clone(), "panes"),
230                move |bounds, window, cx| {
231                    let size = bounds.size;
232                    let (width, height) = (f32::from(size.width), f32::from(size.height));
233                    let directive = pane_directive(
234                        size_class.unwrap_or_else(|| WindowSizeClass::of_size(size)),
235                        rule,
236                    );
237                    navigator.update(cx, |navigator, cx| {
238                        if navigator.set_directive(directive) {
239                            cx.notify();
240                        }
241                    });
242                    let (value, strategies, history_len, destination) = {
243                        let navigator = navigator.read(cx);
244                        (
245                            navigator.value(),
246                            *navigator.strategies(),
247                            navigator.history().len(),
248                            navigator.current().map(|current| current.pane),
249                        )
250                    };
251                    let state =
252                        window.use_keyed_state((builder_id.clone(), "pane-state"), cx, |_, cx| {
253                            ScaffoldState {
254                                value: None,
255                                size: (0., 0.),
256                                targets: [None; 3],
257                                runs: [None; 3],
258                                first_pane_width: None,
259                                dragging: None,
260                                pressed_at: 0.,
261
262                                history_len: None,
263                                focus_stack: Vec::new(),
264                                pane_focus: [
265                                    cx.focus_handle(),
266                                    cx.focus_handle(),
267                                    cx.focus_handle(),
268                                ],
269                                handle_focus: cx.focus_handle().tab_stop(true),
270                            }
271                        });
272                    let now = cx.background_executor().now();
273                    let reduced = cx.reduce_motion();
274                    let (dragging, first_pane_width) = {
275                        let state = state.read(cx);
276                        (state.dragging, state.first_pane_width)
277                    };
278                    let layout = three_pane_layout(&PaneLayoutInput {
279                        width,
280                        height,
281                        directive,
282                        value,
283                        order: &order,
284                        strategies: &strategies,
285                        present: [panes[0].is_some(), panes[1].is_some(), panes[2].is_some()],
286                        preferred_widths,
287                        dragging_offset: dragging,
288                        first_pane_width,
289                    });
290                    state.update(cx, |state, _| {
291                        let origin: [Option<PaneRect>; 3] =
292                            std::array::from_fn(|role| state.sample(role, now).0);
293                        let resized = state.size != (width, height);
294                        match state.value {
295                            // The first frame, or a resize: the panes are simply there.
296                            Some(previous) if !resized && !reduced => {
297                                if previous != value {
298                                    let motions = pane_motions(previous, value, &order);
299                                    let transitions = pane_transitions(
300                                        &motions,
301                                        &order,
302                                        &origin,
303                                        &layout.panes,
304                                        width,
305                                    );
306                                    for (role, transition) in transitions.iter().enumerate() {
307                                        state.runs[role] = transition.map(|transition| PaneRun {
308                                            from: transition.from,
309                                            to: transition.to,
310                                            delayed: transition.motion.delayed(),
311                                            started: now,
312                                        });
313                                    }
314                                } else if dragging.is_some() {
315                                    state.runs = [None; 3];
316                                } else {
317                                    for (role, from) in origin.iter().enumerate() {
318                                        if let (Some(from), Some(to)) = (*from, layout.panes[role])
319                                            && state.targets[role] != Some(to)
320                                        {
321                                            state.runs[role] = Some(PaneRun {
322                                                from,
323                                                to,
324                                                delayed: false,
325                                                started: now,
326                                            });
327                                        }
328                                    }
329                                }
330                            }
331                            _ => state.runs = [None; 3],
332                        }
333                        state.value = Some(value);
334                        state.size = (width, height);
335                        state.targets = layout.panes;
336                    });
337
338                    // Focus follows the destination, and back returns it.
339                    let previous_len = state.read(cx).history_len;
340                    if previous_len != Some(history_len) {
341                        let target =
342                            destination.map(|role| state.read(cx).pane_focus[role.index()].clone());
343                        let grew = previous_len.is_some_and(|len| history_len > len);
344                        let shrank = previous_len.is_some_and(|len| history_len < len);
345                        let focused = window.focused(cx);
346                        let restore = state.update(cx, |state, _| {
347                            state.history_len = Some(history_len);
348                            if grew {
349                                state.focus_stack.extend(focused);
350                                None
351                            } else if shrank {
352                                state.focus_stack.pop()
353                            } else {
354                                None
355                            }
356                        });
357                        let next = if grew { target } else { restore.or(target) };
358                        if let Some(next) = next {
359                            window.defer(cx, move |window, cx| next.focus(window, cx));
360                        }
361                    }
362
363                    let mut running = false;
364                    let mut shown = [None; 3];
365                    {
366                        let current = state.read(cx);
367                        for (slot, role) in order.into_iter().enumerate() {
368                            let index = role.index();
369                            let (rect, moving) = current.sample(index, now);
370                            running |= moving;
371                            // An exiting pane stays only while it moves.
372                            let settled_hidden = layout.panes[index].is_none() && !moving;
373                            shown[slot] = rect.filter(|_| !settled_hidden).map(|rect| (role, rect));
374                        }
375                    }
376
377                    if !running {
378                        let idle = state.read(cx).runs.iter().any(Option::is_some);
379                        if idle {
380                            state.update(cx, |state, _| state.runs = [None; 3]);
381                        }
382                    } else {
383                        window.request_animation_frame();
384                    }
385
386                    let mut panes = panes;
387                    let mut root = div().relative().size_full();
388                    for (role, rect) in shown.into_iter().flatten() {
389                        let Some((label, content)) = panes[role.index()].take() else {
390                            continue;
391                        };
392                        let focus = state.read(cx).pane_focus[role.index()].clone();
393
394                        if rect.width <= 0. {
395                            continue;
396                        }
397                        let selector = PANE_SELECTORS[role.index()];
398                        root = root.child(
399                            div()
400                                .id((builder_id.clone(), selector))
401                                .debug_selector(move || selector.into())
402                                .role(Role::Region)
403                                .aria_label(label)
404                                .track_focus(&focus)
405                                .absolute()
406                                .left(px(rect.x))
407                                .top(px(rect.y))
408                                .w(px(rect.width))
409                                .h(px(rect.height))
410                                .overflow_y_scroll()
411                                .child(content),
412                        );
413                    }
414                    if let (Some(label), Some(offset)) = (handle, layout.handle_offset) {
415                        root = root.child(drag_handle(
416                            (builder_id.clone(), "pane-handle"),
417                            label,
418                            offset,
419                            bounds,
420                            directive.horizontal_spacer,
421                            anchors,
422                            &state,
423                            window,
424                            cx,
425                        ));
426                    }
427                    root.into_any_element()
428                },
429            ))
430    }
431}
432
433/// A release of the handle at `x` from the scaffold's left edge: a click moves
434/// to the next anchor, as Compose's does, and a drag settles on the closest.
435#[allow(clippy::too_many_arguments)]
436fn release_handle(
437    state: &Entity<ScaffoldState>,
438    anchors: &[PaneAnchor],
439    width: f32,
440    offset: f32,
441    spacer: f32,
442    x: f32,
443    cx: &mut App,
444) {
445    let current = state.read(cx);
446    if current.dragging.is_none() {
447        return;
448    }
449    let clicked = (x - current.pressed_at).abs() < CLICK_SLOP;
450    let position = if clicked {
451        next_pane_anchor(anchors, width, offset)
452    } else {
453        closest_pane_anchor(anchors, width, x)
454    };
455    settle_handle(state, position.unwrap_or(x), spacer, cx);
456}
457
458/// The handle between two expanded panes.
459#[allow(clippy::too_many_arguments)]
460fn drag_handle(
461    id: impl Into<ElementId>,
462    label: SharedString,
463    offset: f32,
464    bounds: Bounds<Pixels>,
465    spacer: f32,
466    anchors: Rc<[PaneAnchor]>,
467    state: &Entity<ScaffoldState>,
468    window: &mut Window,
469    cx: &mut App,
470) -> AnyElement {
471    let width = f32::from(bounds.size.width);
472    let origin = bounds.origin.x;
473    let local = move |x: Pixels| f32::from(x - origin).clamp(0., width.max(0.));
474    let (dragged, focus) = {
475        let state = state.read(cx);
476        (state.dragging.is_some(), state.handle_focus.clone())
477    };
478    let target = f32::from(rem(HANDLE_TARGET).to_pixels(window.rem_size()));
479    let (bar_w, bar_h) = if dragged {
480        HANDLE_BAR_ACTIVE
481    } else {
482        HANDLE_BAR
483    };
484    let keys = {
485        let state = state.clone();
486        let anchors = anchors.clone();
487        move |event: &KeyDownEvent, _: &mut Window, cx: &mut App| {
488            let position = match event.keystroke.key.as_str() {
489                "left" => previous_pane_anchor(&anchors, width, offset),
490                "right" | "enter" => next_pane_anchor(&anchors, width, offset),
491                "home" => Some(0.),
492                "end" => Some(width),
493                _ => None,
494            };
495            if let Some(position) = position {
496                settle_handle(&state, position, spacer, cx);
497                cx.stop_propagation();
498            }
499        }
500    };
501    let press = {
502        let state = state.clone();
503        move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
504            let focus = state.read(cx).handle_focus.clone();
505            focus.focus(window, cx);
506            state.update(cx, |state, cx| {
507                state.dragging = Some(offset);
508                state.pressed_at = local(event.position.x);
509                cx.notify();
510            });
511            cx.stop_propagation();
512        }
513    };
514    // A press and release can arrive before the frame that would install the
515    // window listeners, so the handle takes a release itself as well.
516    let up = {
517        let state = state.clone();
518        let anchors = anchors.clone();
519        move |event: &MouseUpEvent, _: &mut Window, cx: &mut App| {
520            release_handle(
521                &state,
522                &anchors,
523                width,
524                offset,
525                spacer,
526                local(event.position.x),
527                cx,
528            );
529        }
530    };
531    // Only a drag in progress listens to the whole window.
532    let tracking = dragged.then(|| {
533        let state = state.clone();
534        canvas(
535            |_, _, _| {},
536            move |_, _, window, _| {
537                let moving = state.clone();
538                window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
539                    if phase == DispatchPhase::Capture
540                        && event.dragging()
541                        && moving.read(cx).dragging.is_some()
542                    {
543                        let x = local(event.position.x);
544                        moving.update(cx, |state, cx| {
545                            state.dragging = Some(x);
546                            cx.notify();
547                        });
548                    }
549                });
550                window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
551                    if phase == DispatchPhase::Capture {
552                        release_handle(
553                            &state,
554                            &anchors,
555                            width,
556                            offset,
557                            spacer,
558                            local(event.position.x),
559                            cx,
560                        );
561                    }
562                });
563            },
564        )
565        .absolute()
566        .inset_0()
567    });
568    div()
569        .absolute()
570        .inset_0()
571        .children(tracking)
572        .child(
573            div()
574                .id(id.into())
575                .debug_selector(|| "pane-drag-handle".into())
576                .role(Role::Splitter)
577                .aria_label(label)
578                .track_focus(&focus)
579                .absolute()
580                .left(px(offset - target / 2.))
581                .top(px((f32::from(bounds.size.height) - target) / 2.))
582                .size(px(target))
583                .flex()
584                .items_center()
585                .justify_center()
586                .cursor_col_resize()
587                .rounded_full()
588                .focus_visible(|ring| ring.border_2().border_color(color(ColorRole::SECONDARY)))
589                .on_key_down(keys)
590                .on_mouse_down(MouseButton::Left, press)
591                .on_mouse_up(MouseButton::Left, up)
592                .child(
593                    div()
594                        .w(rem(bar_w))
595                        .h(rem(bar_h))
596                        .when(!dragged, |bar| bar.rounded_full())
597                        .when(dragged, |bar| bar.rounded(rem(HANDLE_BAR_ACTIVE_RADIUS)))
598                        .bg(color(if dragged {
599                            ColorRole::ON_SURFACE
600                        } else {
601                            ColorRole::OUTLINE
602                        })),
603                ),
604        )
605        .into_any_element()
606}
607
608/// The feed's margins below 600dp of its own width.
609pub const FEED_MARGIN_COMPACT: f32 = 16.;
610/// The feed's margins from 600dp.
611pub const FEED_MARGIN: f32 = 24.;
612/// The space between feed items.
613pub const FEED_GAP: f32 = 8.;
614/// The narrowest feed column.
615pub const FEED_COLUMN_MIN_WIDTH: f32 = 240.;
616
617/// How a feed lays out in a given width: use it to virtualize a long feed.
618#[derive(Clone, Copy, Debug, PartialEq, Eq)]
619pub struct FeedMetrics {
620    /// The number of columns.
621    pub columns: u16,
622    /// The side margin, in design px.
623    pub margin: u16,
624}
625
626/// The feed's layout at a measured width in px, given the px of a design px at
627/// the current text scale: one column below 600dp, then as many as fit.
628pub fn feed_metrics(width: f32, design_px: f32) -> FeedMetrics {
629    if WindowWidthClass::of(width) == WindowWidthClass::Compact {
630        return FeedMetrics {
631            columns: 1,
632            margin: FEED_MARGIN_COMPACT as u16,
633        };
634    }
635    let inner = width - 2. * FEED_MARGIN * design_px;
636    let gap = FEED_GAP * design_px;
637    let columns = ((inner + gap) / (FEED_COLUMN_MIN_WIDTH * design_px + gap)).floor();
638    FeedMetrics {
639        columns: if columns.is_finite() {
640            columns.clamp(1., u16::MAX as f32) as u16
641        } else {
642            1
643        },
644        margin: FEED_MARGIN as u16,
645    }
646}
647
648/// A feed: a grid of items, as many columns as fit.
649///
650/// It takes its height from its items, so put it in a scrolling parent. Columns
651/// come from the feed's width in the previous frame, the window's on the first.
652/// It builds every item: for a long feed, drive a virtualized list from
653/// [`feed_metrics`] instead.
654///
655/// ```
656/// use gpui_kit_material::prelude::*;
657///
658/// let feed = FeedLayout::new("stories");
659/// # let _ = feed;
660/// assert_eq!(feed_metrics(360., 1.).columns, 1);
661/// assert_eq!(feed_metrics(1000., 1.).columns, 3);
662/// ```
663#[derive(gpui::IntoElement)]
664pub struct FeedLayout {
665    id: ElementId,
666    items: Vec<(AnyElement, bool)>,
667}
668
669impl FeedLayout {
670    /// An empty feed.
671    pub fn new(id: impl Into<ElementId>) -> Self {
672        Self {
673            id: id.into(),
674            items: Vec::new(),
675        }
676    }
677
678    /// Add an item one column wide.
679    pub fn item(mut self, item: impl IntoElement) -> Self {
680        self.items.push((item.into_any_element(), false));
681        self
682    }
683
684    /// Add an item two columns wide, or one where only one fits.
685    pub fn wide_item(mut self, item: impl IntoElement) -> Self {
686        self.items.push((item.into_any_element(), true));
687        self
688    }
689}
690
691impl RenderOnce for FeedLayout {
692    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
693        let design_px = f32::from(window.rem_size()) / BASE_PX;
694        let initial = feed_metrics(f32::from(window.viewport_size().width), design_px);
695        let metrics = window.use_keyed_state((self.id.clone(), "feed-metrics"), cx, |_, _| initial);
696        let current = *metrics.read(cx);
697        let columns = current.columns.max(1);
698        div()
699            .id(self.id)
700            .debug_selector(|| "feed-layout".into())
701            .relative()
702            .w_full()
703            .grid()
704            .grid_cols(columns)
705            .gap(rem(FEED_GAP))
706            .px(rem(f32::from(current.margin)))
707            .child(
708                canvas(
709                    move |bounds, _, cx| {
710                        let measured = feed_metrics(f32::from(bounds.size.width), design_px);
711                        metrics.update(cx, |metrics, cx| {
712                            if *metrics != measured {
713                                *metrics = measured;
714                                cx.notify();
715                            }
716                        });
717                    },
718                    |_, _, _, _| {},
719                )
720                .absolute()
721                .inset_0(),
722            )
723            .children(self.items.into_iter().map(|(item, wide)| {
724                div()
725                    .min_w_0()
726                    .when(wide, |cell| cell.col_span(2.min(columns)))
727                    .child(item)
728            }))
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use core::prelude::v1::test;
736
737    #[test]
738    fn the_feed_has_one_column_when_compact_then_as_many_as_fit() {
739        assert_eq!(
740            feed_metrics(360., 1.),
741            FeedMetrics {
742                columns: 1,
743                margin: 16
744            }
745        );
746        assert_eq!(
747            feed_metrics(599., 1.),
748            FeedMetrics {
749                columns: 1,
750                margin: 16
751            }
752        );
753        // 600 - 48 = 552 holds two 240s and a gap.
754        assert_eq!(feed_metrics(600., 1.).columns, 2);
755        assert_eq!(feed_metrics(600., 1.).margin, 24);
756        // 1000 - 48 = 952 holds three (736) but not four (984).
757        assert_eq!(feed_metrics(1000., 1.).columns, 3);
758        // Text at 200% doubles the column minimum.
759        assert_eq!(feed_metrics(1000., 2.).columns, 1);
760        assert_eq!(feed_metrics(f32::NAN, 1.).columns, 1);
761        assert_eq!(feed_metrics(0., 1.).columns, 1);
762    }
763
764    struct Screen {
765        navigator: Entity<PaneNavigator<u32>>,
766        width: f32,
767        rtl: bool,
768        handle: bool,
769    }
770
771    impl Render for Screen {
772        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
773            window.set_rem_size(px(BASE_PX));
774            let scaffold = PaneScaffold::list_detail("layout", &self.navigator)
775                .rtl(self.rtl)
776                .pane(PaneRole::Secondary, "Inbox", div().child("List"))
777                .pane(PaneRole::Primary, "Message", div().child("Detail"))
778                .pane(PaneRole::Tertiary, "Notes", div().child("Notes"));
779            div().w(px(self.width)).h(px(800.)).child(if self.handle {
780                scaffold.drag_handle("Resize panes")
781            } else {
782                scaffold
783            })
784        }
785    }
786
787    fn open(
788        cx: &mut gpui::TestAppContext,
789        width: f32,
790        reduced: bool,
791    ) -> (Entity<Screen>, &mut gpui::VisualTestContext) {
792        cx.update(|cx| cx.set_reduce_motion(reduced));
793        let (view, cx) = cx.add_window_view(move |_, cx| Screen {
794            navigator: cx.new(|_| PaneNavigator::list_detail()),
795            width,
796            rtl: false,
797            handle: false,
798        });
799        draw(cx);
800        (view, cx)
801    }
802
803    fn draw(cx: &mut gpui::VisualTestContext) {
804        for _ in 0..3 {
805            cx.update(|window, cx| {
806                window.refresh();
807                window.draw(cx).clear(cx);
808            });
809            cx.run_until_parked();
810        }
811    }
812
813    fn navigate(
814        view: &Entity<Screen>,
815        cx: &mut gpui::VisualTestContext,
816        change: impl FnOnce(&mut PaneNavigator<u32>),
817    ) {
818        cx.update(|_, cx| {
819            let navigator = view.read(cx).navigator.clone();
820            navigator.update(cx, |navigator, cx| {
821                change(navigator);
822                cx.notify();
823            });
824            view.update(cx, |_, cx| cx.notify());
825        });
826        draw(cx);
827    }
828
829    #[gpui::test]
830    fn the_scaffold_adapts_to_its_own_width_not_the_windows(cx: &mut gpui::TestAppContext) {
831        let (view, cx) = open(cx, 500., true);
832        assert!(cx.debug_bounds("pane-secondary").is_some(), "the list");
833        assert!(cx.debug_bounds("pane-primary").is_none());
834        cx.update(|_, cx| view.update(cx, |view, _| view.width = 1000.));
835        draw(cx);
836        let list = cx.debug_bounds("pane-secondary").unwrap();
837        let detail = cx.debug_bounds("pane-primary").unwrap();
838        assert_eq!(list.size.width, px(360.));
839        assert_eq!(detail.left() - list.right(), px(24.));
840        assert_eq!(detail.size.width, px(616.));
841        assert!(cx.debug_bounds("pane-tertiary").is_none());
842        cx.update(|_, cx| view.update(cx, |view, _| view.width = 1700.));
843        draw(cx);
844        assert!(cx.debug_bounds("pane-tertiary").is_some());
845    }
846
847    #[gpui::test]
848    fn one_pane_follows_the_destination_and_back_returns(cx: &mut gpui::TestAppContext) {
849        let (view, cx) = open(cx, 500., true);
850        navigate(&view, cx, |navigator| {
851            navigator.navigate_to(PaneRole::Primary, Some(7))
852        });
853        assert!(cx.debug_bounds("pane-primary").is_some());
854        assert!(cx.debug_bounds("pane-secondary").is_none());
855        navigate(&view, cx, |navigator| {
856            navigator.navigate_back(BackNavigationBehavior::default());
857        });
858        assert!(cx.debug_bounds("pane-secondary").is_some());
859        assert!(cx.debug_bounds("pane-primary").is_none());
860    }
861
862    #[gpui::test]
863    fn the_panes_slide_and_the_old_pane_stays_until_it_is_out(cx: &mut gpui::TestAppContext) {
864        let (view, cx) = open(cx, 500., false);
865        navigate(&view, cx, |navigator| {
866            navigator.navigate_to(PaneRole::Primary, None)
867        });
868        // Mid-flight both panes exist, the old one moving left of the origin
869        // and the new one arriving from the right.
870        cx.executor()
871            .advance_clock(std::time::Duration::from_millis(60));
872        draw(cx);
873        let old = cx.debug_bounds("pane-secondary").expect("still leaving");
874        let new = cx.debug_bounds("pane-primary").expect("arriving");
875        assert!(old.left() < px(0.), "{:?}", old.left());
876        assert!(new.left() > px(0.), "{:?}", new.left());
877        cx.executor()
878            .advance_clock(std::time::Duration::from_secs(3));
879        draw(cx);
880        assert!(cx.debug_bounds("pane-secondary").is_none());
881        assert_eq!(cx.debug_bounds("pane-primary").unwrap().left(), px(0.));
882    }
883
884    #[gpui::test]
885    fn reduced_motion_moves_the_panes_at_once(cx: &mut gpui::TestAppContext) {
886        let (view, cx) = open(cx, 500., true);
887        navigate(&view, cx, |navigator| {
888            navigator.navigate_to(PaneRole::Primary, None)
889        });
890        assert!(cx.debug_bounds("pane-secondary").is_none());
891        assert_eq!(cx.debug_bounds("pane-primary").unwrap().left(), px(0.));
892    }
893
894    #[gpui::test]
895    fn right_to_left_mirrors_the_panes(cx: &mut gpui::TestAppContext) {
896        let (view, cx) = open(cx, 1000., true);
897        cx.update(|_, cx| view.update(cx, |view, _| view.rtl = true));
898        draw(cx);
899        let list = cx.debug_bounds("pane-secondary").unwrap();
900        let detail = cx.debug_bounds("pane-primary").unwrap();
901        assert!(list.left() > detail.left(), "the list is on the right");
902        assert_eq!(list.right(), px(1000.));
903    }
904
905    #[gpui::test]
906    fn focus_moves_to_the_new_pane_and_returns_on_back(cx: &mut gpui::TestAppContext) {
907        let (view, cx) = open(cx, 500., true);
908        navigate(&view, cx, |navigator| {
909            navigator.navigate_to(PaneRole::Primary, None)
910        });
911        let focused = cx.update(|window, cx| window.focused(cx));
912        assert!(focused.is_some(), "focus followed the destination");
913        navigate(&view, cx, |navigator| {
914            navigator.navigate_back(BackNavigationBehavior::default());
915        });
916        // Nothing was focused before, so focus follows the destination back.
917        let back = cx.update(|window, cx| window.focused(cx));
918        assert!(back.is_some());
919        assert_ne!(back, focused);
920    }
921
922    #[gpui::test]
923    fn the_handle_drags_settles_on_an_anchor_and_takes_keys(cx: &mut gpui::TestAppContext) {
924        let (view, cx) = open(cx, 1000., true);
925        cx.update(|_, cx| view.update(cx, |view, _| view.handle = true));
926        draw(cx);
927        let handle = cx.debug_bounds("pane-drag-handle").unwrap().center();
928        assert_eq!(handle.x, px(372.));
929        cx.simulate_mouse_down(handle, MouseButton::Left, Modifiers::none());
930        draw(cx);
931        let target = point(px(520.), handle.y);
932        cx.simulate_mouse_move(target, MouseButton::Left, Modifiers::none());
933        draw(cx);
934        assert_eq!(
935            cx.debug_bounds("pane-drag-handle").unwrap().center().x,
936            px(520.)
937        );
938        cx.simulate_mouse_up(target, MouseButton::Left, Modifiers::none());
939        draw(cx);
940        // The 50% anchor: a center of 500, so the first pane is 488 wide.
941        assert_eq!(
942            cx.debug_bounds("pane-drag-handle").unwrap().center().x,
943            px(500.)
944        );
945        assert_eq!(
946            cx.debug_bounds("pane-secondary").unwrap().size.width,
947            px(488.)
948        );
949        // A click moves to the next anchor and focuses the handle for the keys,
950        // even when its press and release arrive within one frame.
951        let handle = cx.debug_bounds("pane-drag-handle").unwrap().center();
952        cx.simulate_click(handle, Modifiers::none());
953        draw(cx);
954        assert_eq!(
955            cx.debug_bounds("pane-drag-handle").unwrap().center().x,
956            px(760.)
957        );
958        cx.simulate_keystrokes("left");
959        draw(cx);
960        assert_eq!(
961            cx.debug_bounds("pane-drag-handle").unwrap().center().x,
962            px(500.)
963        );
964        cx.simulate_keystrokes("home");
965        draw(cx);
966        assert_eq!(
967            cx.debug_bounds("pane-primary").unwrap().size.width,
968            px(1000.)
969        );
970    }
971}