Skip to main content

herogpui_components/
resizable.rs

1//! `ResizablePanelGroup` and `ResizablePanel` — split panels with a drag
2//! handle between each pair (HeroGPUI extension; HeroUI v3 has no resizable
3//! or split-view component).
4//!
5//! A group lays its panels out side by side ([`Orientation::Horizontal`], the
6//! default) or stacked ([`Orientation::Vertical`]). Sizes are **percentages
7//! of the space the panels share** (the group's length minus its handles),
8//! so a layout survives a window resize unchanged; they always sum to 100.
9//! Sizes that do not (defaults or controlled) are scaled to 100, or split
10//! evenly when they sum to zero, and then moved inside the panels' limits.
11//!
12//! - **Pointer.** Pressing a handle focuses it and starts a drag. The drag
13//!   captures the pointer (`Window::capture_pointer`, re-taken every frame
14//!   because hitbox ids are per frame), so it keeps its resize cursor and
15//!   keeps resizing after the pointer leaves the handle or the group; the
16//!   release ends it.
17//! - **Keyboard.** Every handle is a tab stop. The arrow keys along the
18//!   group's axis (Left/Right for a horizontal group, Up/Down for a vertical
19//!   one) move it by [`ResizablePanelGroup::keyboard_step`] percent, four
20//!   times that with Shift; Home and End move it as far as the two panels'
21//!   limits allow toward the start and the end.
22//! - **Limits.** Moving a handle resizes only the two panels beside it, and
23//!   both stay inside their [`ResizablePanel::min_size`] /
24//!   [`ResizablePanel::max_size`] during a drag and on every key. Pixel
25//!   limits ([`ResizablePanel::min_size_px`] / [`ResizablePanel::max_size_px`])
26//!   are converted to percent of the group's measured length and combined
27//!   with those (the stricter wins), for the initial layout, a drag, a key
28//!   and a window resize alike. The group is measured when it is laid out,
29//!   so a pixel limit applies from the frame after the first.
30//! - **Collapsing.** A [`ResizablePanel::collapsible`] panel may also sit at
31//!   its [`ResizablePanel::collapsed_size`] (default 0), below its minimum.
32//!   Dragging it past halfway between the two collapses it, and dragging it
33//!   back past halfway restores the minimum; an arrow key that would leave it
34//!   between them jumps to the other side. Enter on a handle collapses the
35//!   panel before it (or, when only that one is collapsible, the panel after
36//!   it) and, when collapsed, restores the size it had before, as the
37//!   WAI-ARIA window splitter pattern describes.
38//! - **Callbacks.** [`ResizablePanelGroup::on_resize`] runs on every change,
39//!   [`ResizablePanelGroup::on_resize_end`] once per gesture: at the release
40//!   of a drag that changed the sizes, and after each key that did.
41//! - **Accessibility.** Each handle reports `Role::Splitter` (the WAI-ARIA
42//!   window splitter, a focusable `separator`) with the orientation of the
43//!   line it draws and a value range: the size of the panel before it, within
44//!   the limits the pair allows.
45//!
46//! ```
47//! use herogpui_components::{ResizablePanel, ResizablePanelGroup};
48//! use gpui::{div, prelude::*};
49//!
50//! let split = ResizablePanelGroup::new("editor-split")
51//!     .panel(
52//!         ResizablePanel::new()
53//!             .default_size(25.)
54//!             .min_size(15.)
55//!             .max_size(40.)
56//!             .child(div().child("Files")),
57//!     )
58//!     .panel(ResizablePanel::new().child(div().child("Editor")))
59//!     .on_resize(|sizes, _window, _cx| println!("{sizes:?}"));
60//! ```
61
62use std::cell::Cell;
63use std::rc::Rc;
64use std::sync::Arc;
65
66use gpui::{
67    div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds, ElementId, InteractiveElement,
68    IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled,
69    Window,
70};
71use herogpui_core::{element_id, Orientation};
72use herogpui_theme::ActiveTheme;
73
74use crate::a11y::{self, A11y as _};
75
76type ResizeCallback = Arc<dyn Fn(&[f32], &mut Window, &mut App) + 'static>;
77
78/// The thickness of a handle along the group's axis. The visible line is one
79/// pixel in its centre; the rest is the press target.
80pub const RESIZABLE_HANDLE_SIZE: Pixels = px(8.);
81
82/// One panel of a [`ResizablePanelGroup`]. Sizes are percentages of the space
83/// the group's panels share.
84#[must_use = "builder methods return a new value; pass it on to its component"]
85pub struct ResizablePanel {
86    default_size: Option<f32>,
87    min_size: f32,
88    max_size: f32,
89    min_size_px: Option<Pixels>,
90    max_size_px: Option<Pixels>,
91    collapsible: bool,
92    collapsed_size: f32,
93    children: Vec<AnyElement>,
94}
95
96impl Default for ResizablePanel {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl ResizablePanel {
103    /// A panel with no size of its own: panels without a
104    /// [`default_size`](Self::default_size) share what the others leave.
105    pub fn new() -> Self {
106        Self {
107            default_size: None,
108            min_size: 0.,
109            max_size: 100.,
110            min_size_px: None,
111            max_size_px: None,
112            collapsible: false,
113            collapsed_size: 0.,
114            children: Vec::new(),
115        }
116    }
117
118    /// The initial size in percent, used while the group is uncontrolled.
119    /// Defaults are scaled together when they do not add up to 100.
120    pub fn default_size(mut self, percent: f32) -> Self {
121        self.default_size = Some(percent.max(0.));
122        self
123    }
124
125    /// The smallest size in percent a resize may leave this panel at
126    /// (default 0).
127    pub fn min_size(mut self, percent: f32) -> Self {
128        self.min_size = percent.clamp(0., 100.);
129        self
130    }
131
132    /// The largest size in percent a resize may give this panel
133    /// (default 100).
134    pub fn max_size(mut self, percent: f32) -> Self {
135        self.max_size = percent.clamp(0., 100.);
136        self
137    }
138
139    /// The smallest length in pixels a resize may leave this panel at,
140    /// combined with [`min_size`](Self::min_size) (the larger wins).
141    pub fn min_size_px(mut self, min: impl Into<Pixels>) -> Self {
142        self.min_size_px = Some(min.into().max(px(0.)));
143        self
144    }
145
146    /// The largest length in pixels a resize may give this panel, combined
147    /// with [`max_size`](Self::max_size) (the smaller wins, but never below
148    /// the minimum).
149    pub fn max_size_px(mut self, max: impl Into<Pixels>) -> Self {
150        self.max_size_px = Some(max.into().max(px(0.)));
151        self
152    }
153
154    /// Lets the panel collapse to its [`collapsed_size`](Self::collapsed_size)
155    /// below its minimum: by a drag past halfway, an arrow key past the
156    /// minimum, Home/End, or Enter on the handle beside it.
157    pub fn collapsible(mut self, collapsible: bool) -> Self {
158        self.collapsible = collapsible;
159        self
160    }
161
162    /// The size in percent a [`collapsible`](Self::collapsible) panel
163    /// collapses to (default 0). A collapsed size at or above the minimum
164    /// leaves nothing to collapse.
165    pub fn collapsed_size(mut self, percent: f32) -> Self {
166        self.collapsed_size = percent.clamp(0., 100.);
167        self
168    }
169
170    /// The panel's limits in percent of `available` pixels (the group's
171    /// length minus its handles), or of the percentage limits alone while
172    /// the group has not been measured.
173    fn limits(&self, available: Option<f32>) -> Limit {
174        let mut min = self.min_size;
175        let mut max = self.max_size;
176        if let Some(available) = available.filter(|a| *a > 0.) {
177            if let Some(px_min) = self.min_size_px {
178                min = min.max(f32::from(px_min) / available * 100.);
179            }
180            if let Some(px_max) = self.max_size_px {
181                max = max.min(f32::from(px_max) / available * 100.);
182            }
183        }
184        let min = min.clamp(0., 100.);
185        let max = max.clamp(0., 100.).max(min);
186        let collapsed = self
187            .collapsible
188            .then_some(self.collapsed_size.min(min))
189            .filter(|collapsed| *collapsed < min - EPSILON);
190        Limit {
191            min,
192            max,
193            collapsed,
194        }
195    }
196
197    fn has_pixel_limits(&self) -> bool {
198        self.min_size_px.is_some() || self.max_size_px.is_some()
199    }
200}
201
202/// Slack for comparing percentages.
203const EPSILON: f32 = 0.0001;
204
205/// One panel's allowed sizes in percent: `min..=max`, and the collapsed
206/// size when the panel is collapsible (always below `min`).
207#[derive(Clone, Copy, Debug, PartialEq)]
208struct Limit {
209    min: f32,
210    max: f32,
211    collapsed: Option<f32>,
212}
213
214impl Limit {
215    #[cfg(test)]
216    fn range(min: f32, max: f32) -> Self {
217        Self {
218            min,
219            max,
220            collapsed: None,
221        }
222    }
223
224    /// The allowed sizes as closed ranges (the collapsed size is a point).
225    fn pieces(self) -> impl Iterator<Item = (f32, f32)> {
226        std::iter::once((self.min, self.max)).chain(self.collapsed.map(|c| (c, c)))
227    }
228
229    fn is_collapsed(self, size: f32) -> bool {
230        self.collapsed
231            .is_some_and(|collapsed| (size - collapsed).abs() <= 0.01)
232    }
233}
234
235impl ParentElement for ResizablePanel {
236    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
237        self.children.extend(elements);
238    }
239}
240
241/// A row or column of [`ResizablePanel`]s with a drag handle between each
242/// pair. See the [module docs](self).
243#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
244#[derive(IntoElement)]
245pub struct ResizablePanelGroup {
246    id: ElementId,
247    orientation: Orientation,
248    panels: Vec<ResizablePanel>,
249    /// The controlled sizes; `Some` only through [`Self::sizes`].
250    sizes: Option<Vec<f32>>,
251    keyboard_step: f32,
252    is_disabled: bool,
253    on_resize: Option<ResizeCallback>,
254    on_resize_end: Option<ResizeCallback>,
255}
256
257impl ResizablePanelGroup {
258    /// An empty horizontal group. `id` keys its sizes, drag and handle focus;
259    /// give every instance its own.
260    pub fn new(id: impl Into<ElementId>) -> Self {
261        Self {
262            id: id.into(),
263            orientation: Orientation::Horizontal,
264            panels: Vec::new(),
265            sizes: None,
266            keyboard_step: 5.,
267            is_disabled: false,
268            on_resize: None,
269            on_resize_end: None,
270        }
271    }
272
273    /// Side by side (`Horizontal`, the default) or stacked (`Vertical`).
274    pub fn orientation(mut self, orientation: Orientation) -> Self {
275        self.orientation = orientation;
276        self
277    }
278
279    /// Appends a panel.
280    pub fn panel(mut self, panel: ResizablePanel) -> Self {
281        self.panels.push(panel);
282        self
283    }
284
285    /// Appends several panels.
286    pub fn panels(mut self, panels: impl IntoIterator<Item = ResizablePanel>) -> Self {
287        self.panels.extend(panels);
288        self
289    }
290
291    /// Controlled sizes in percent, one per panel. The group then reports
292    /// every resize through [`Self::on_resize`] and renders only what it is
293    /// given, normalized as the defaults are: scaled to sum to 100 (split
294    /// evenly when they sum to zero; negative or non-finite entries count as
295    /// zero) and moved inside the panels' limits. A list whose length does not
296    /// match the panels is ignored in favour of the defaults.
297    pub fn sizes(mut self, sizes: impl IntoIterator<Item = f32>) -> Self {
298        self.sizes = Some(sizes.into_iter().collect());
299        self
300    }
301
302    /// How far one arrow key moves a handle, in percent (default 5; Shift
303    /// moves four times as far).
304    pub fn keyboard_step(mut self, percent: f32) -> Self {
305        self.keyboard_step = percent.max(0.);
306        self
307    }
308
309    /// A disabled group draws its handles but does not resize, and its
310    /// handles leave the tab order.
311    pub fn is_disabled(mut self, disabled: bool) -> Self {
312        self.is_disabled = disabled;
313        self
314    }
315
316    /// Runs with every panel's size, in percent, whenever a drag or a key
317    /// changes them.
318    pub fn on_resize(mut self, f: impl Fn(&[f32], &mut Window, &mut App) + 'static) -> Self {
319        self.on_resize = Some(Arc::new(f));
320        self
321    }
322
323    /// Runs with every panel's size, in percent, once a resize gesture ends:
324    /// at the release of a drag that changed the sizes, and after each key
325    /// (arrow, Home, End, Enter) that did. For persisting a layout without
326    /// writing on every pointer move.
327    pub fn on_resize_end(mut self, f: impl Fn(&[f32], &mut Window, &mut App) + 'static) -> Self {
328        self.on_resize_end = Some(Arc::new(f));
329        self
330    }
331}
332
333/// The sizes the panels start at: explicit defaults, the rest shared evenly,
334/// then [normalized](normalize) to 100 and the panels' limits.
335fn default_sizes(panels: &[ResizablePanel], limits: &[Limit]) -> Vec<f32> {
336    let fixed: f32 = panels
337        .iter()
338        .filter_map(|p| p.default_size)
339        .filter(|size| size.is_finite())
340        .sum();
341    let free = panels.iter().filter(|p| p.default_size.is_none()).count();
342    let share = if free > 0 {
343        (100. - fixed).max(0.) / free as f32
344    } else {
345        0.
346    };
347    let sizes: Vec<f32> = panels
348        .iter()
349        .map(|p| p.default_size.unwrap_or(share))
350        .collect();
351    normalize(&sizes, limits)
352}
353
354/// `sizes` scaled to sum to 100 (an even split when they sum to zero;
355/// negative and non-finite entries count as zero), then moved inside
356/// `limits` with the sum kept at 100: every panel is clamped to its own
357/// range and the difference is shared among the panels with room left, in
358/// proportion to that room. Limits no layout can satisfy (minimums over
359/// 100 or maximums under it) leave the scaled sizes unclamped. A
360/// collapsible panel under halfway between its collapsed size and its
361/// minimum is collapsed, and then holds that size.
362fn normalize(sizes: &[f32], limits: &[Limit]) -> Vec<f32> {
363    let n = sizes.len();
364    if n == 0 {
365        return Vec::new();
366    }
367    let mut out: Vec<f32> = sizes
368        .iter()
369        .map(|&v| if v.is_finite() && v > 0. { v } else { 0. })
370        .collect();
371    let total: f32 = out.iter().sum();
372    if !total.is_finite() || total <= 0. {
373        out = vec![100. / n as f32; n];
374    } else if (total - 100.).abs() > 0.001 {
375        for size in &mut out {
376            *size *= 100. / total;
377        }
378    }
379    if limits.len() != n {
380        return out;
381    }
382    let limits: Vec<(f32, f32)> = out
383        .iter()
384        .zip(limits)
385        .map(|(&size, limit)| match limit.collapsed {
386            Some(collapsed) if size < (collapsed + limit.min) / 2. => (collapsed, collapsed),
387            _ => (limit.min, limit.max),
388        })
389        .collect();
390    let limits = &limits[..];
391    let min_total: f32 = limits.iter().map(|l| l.0).sum();
392    let max_total: f32 = limits.iter().map(|l| l.1).sum();
393    if min_total > 100. + 0.001 || max_total < 100. - 0.001 {
394        return out;
395    }
396    for (size, &(min, max)) in out.iter_mut().zip(limits) {
397        *size = size.clamp(min, max);
398    }
399    let diff = 100. - out.iter().sum::<f32>();
400    if diff.abs() > 0.0001 {
401        let room: Vec<f32> = out
402            .iter()
403            .zip(limits)
404            .map(|(&size, &(min, max))| if diff > 0. { max - size } else { size - min })
405            .collect();
406        let total_room: f32 = room.iter().sum();
407        if total_room > 0. {
408            for (size, room) in out.iter_mut().zip(room) {
409                *size += diff * room / total_room;
410            }
411        }
412    }
413    out
414}
415
416/// The sizes the panel before handle `handle` may take while the pair keeps
417/// its combined size and both stay within their limits: at most four closed
418/// ranges (each panel's `min..=max` or collapsed point, against the other's).
419fn pair_pieces(sizes: &[f32], handle: usize, limits: &[Limit]) -> Vec<(f32, f32)> {
420    let total = sizes[handle] + sizes[handle + 1];
421    let mut pieces = Vec::new();
422    for (min_a, max_a) in limits[handle].pieces() {
423        for (min_b, max_b) in limits[handle + 1].pieces() {
424            let lower = min_a.max(total - max_b);
425            let upper = max_a.min(total - min_b);
426            if lower <= upper + EPSILON {
427                pieces.push((lower, upper.max(lower)));
428            }
429        }
430    }
431    pieces
432}
433
434/// The range the panel before handle `handle` may take: the span of
435/// [`pair_pieces`]. `None` when the two limits cannot both hold.
436fn pair_range(sizes: &[f32], handle: usize, limits: &[Limit]) -> Option<(f32, f32)> {
437    let pieces = pair_pieces(sizes, handle, limits);
438    let lower = pieces.iter().map(|p| p.0).reduce(f32::min)?;
439    let upper = pieces.iter().map(|p| p.1).reduce(f32::max)?;
440    Some((lower, upper))
441}
442
443/// The allowed size nearest `target`. For a collapsible panel that is the
444/// collapsed size below halfway to the minimum and the minimum above it.
445fn nearest(pieces: &[(f32, f32)], target: f32) -> Option<f32> {
446    pieces
447        .iter()
448        .map(|&(lower, upper)| target.clamp(lower, upper))
449        .min_by(|a, b| (a - target).abs().total_cmp(&(b - target).abs()))
450}
451
452/// `sizes` with the panel before `handle` resized toward `target` and the
453/// panel after it absorbing the difference, both inside their limits.
454fn resize_pair(sizes: &[f32], handle: usize, target: f32, limits: &[Limit]) -> Vec<f32> {
455    let mut next = sizes.to_vec();
456    let Some(first) = nearest(&pair_pieces(sizes, handle, limits), target) else {
457        return next;
458    };
459    let total = sizes[handle] + sizes[handle + 1];
460    next[handle] = first;
461    next[handle + 1] = total - first;
462    next
463}
464
465/// [`resize_pair`] for a key: a step that the nearest allowed size would
466/// swallow (a collapsed panel's arrow toward its minimum, or a panel at its
467/// minimum stepping toward its collapsed size) moves on to the next allowed
468/// size in the key's direction instead.
469fn step_pair(sizes: &[f32], handle: usize, target: f32, limits: &[Limit]) -> Vec<f32> {
470    let current = sizes[handle];
471    let mut next = resize_pair(sizes, handle, target, limits);
472    let pieces = pair_pieces(sizes, handle, limits);
473    let jump = if target > current + EPSILON && next[handle] <= current + EPSILON {
474        pieces
475            .iter()
476            .map(|p| p.0)
477            .filter(|lower| *lower > current + EPSILON)
478            .reduce(f32::min)
479    } else if target < current - EPSILON && next[handle] >= current - EPSILON {
480        pieces
481            .iter()
482            .map(|p| p.1)
483            .filter(|upper| *upper < current - EPSILON)
484            .reduce(f32::max)
485    } else {
486        None
487    };
488    if let Some(first) = jump {
489        let total = sizes[handle] + sizes[handle + 1];
490        next[handle] = first;
491        next[handle + 1] = total - first;
492    }
493    next
494}
495
496/// Enter on handle `handle`: collapse the panel before it (or, when only
497/// that one is collapsible, the panel after it), or restore it to its size
498/// before it collapsed (its minimum when it started collapsed). `None` when
499/// neither panel is collapsible.
500fn toggle_collapse(
501    sizes: &[f32],
502    handle: usize,
503    limits: &[Limit],
504    restore: &[Option<f32>],
505) -> Option<Vec<f32>> {
506    let panel = if limits[handle].collapsed.is_some() {
507        handle
508    } else if limits[handle + 1].collapsed.is_some() {
509        handle + 1
510    } else {
511        return None;
512    };
513    let limit = limits[panel];
514    let collapsed = limit.collapsed?;
515    let size = if limit.is_collapsed(sizes[panel]) {
516        restore
517            .get(panel)
518            .copied()
519            .flatten()
520            .unwrap_or(limit.min)
521            .max(limit.min)
522    } else {
523        collapsed
524    };
525    let total = sizes[handle] + sizes[handle + 1];
526    let first = if panel == handle { size } else { total - size };
527    Some(resize_pair(sizes, handle, first, limits))
528}
529
530/// A drag in progress: which handle, the pointer's axis coordinate at the
531/// press, and the sizes then. Every move resizes from the press, so the
532/// result does not drift with the number of move events. `last` is what the
533/// drag last committed (the press-time sizes before the first move): several
534/// moves can arrive between two frames, so a controlled group compares each
535/// move with it rather than with the sizes the frame rendered.
536#[derive(Clone)]
537struct Drag {
538    handle: usize,
539    origin: f32,
540    start: Vec<f32>,
541    last: Vec<f32>,
542}
543
544/// What the event closures share.
545#[derive(Clone)]
546struct GroupState {
547    store: gpui::Entity<Vec<f32>>,
548    controlled: bool,
549    limits: Rc<Vec<Limit>>,
550    /// Each panel's size before it last collapsed, for Enter to restore.
551    restore: gpui::Entity<Vec<Option<f32>>>,
552    on_resize: Option<ResizeCallback>,
553    on_resize_end: Option<ResizeCallback>,
554}
555
556impl GroupState {
557    /// Commits `next` when it differs from `now`: the uncontrolled store
558    /// takes it, and the caller hears about it either way. Returns whether
559    /// it committed.
560    fn apply(&self, now: &[f32], next: Vec<f32>, window: &mut Window, cx: &mut App) -> bool {
561        if next.len() != now.len()
562            || next
563                .iter()
564                .zip(now)
565                .all(|(a, b)| (a - b).abs() <= f32::EPSILON)
566        {
567            return false;
568        }
569        // Remember what a panel measured before it collapsed.
570        let collapsing: Vec<(usize, f32)> = now
571            .iter()
572            .zip(&next)
573            .zip(self.limits.iter())
574            .enumerate()
575            .filter(|(_, ((was, is), limit))| {
576                !limit.is_collapsed(**was) && limit.is_collapsed(**is)
577            })
578            .map(|(ix, ((was, _), _))| (ix, *was))
579            .collect();
580        if !collapsing.is_empty() {
581            self.restore.update(cx, |restore, _| {
582                for (ix, size) in collapsing {
583                    if let Some(slot) = restore.get_mut(ix) {
584                        *slot = Some(size);
585                    }
586                }
587            });
588        }
589        if !self.controlled {
590            self.store.update(cx, |sizes, cx| {
591                sizes.clone_from(&next);
592                cx.notify();
593            });
594        }
595        if let Some(cb) = &self.on_resize {
596            cb(&next, window, cx);
597        }
598        true
599    }
600
601    fn resize_ended(&self, sizes: &[f32], window: &mut Window, cx: &mut App) {
602        if let Some(cb) = &self.on_resize_end {
603            cb(sizes, window, cx);
604        }
605    }
606}
607
608/// The debug-selector spelling of an id: its name when it has one.
609fn selector_base(id: &ElementId) -> String {
610    match id {
611        ElementId::Name(name) => name.to_string(),
612        other => format!("{other:?}"),
613    }
614}
615
616impl RenderOnce for ResizablePanelGroup {
617    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
618        let base = self.id.clone();
619        let selector = selector_base(&base);
620        let count = self.panels.len();
621        let horizontal = self.orientation == Orientation::Horizontal;
622        let group_bounds = window
623            .use_keyed_state(element_id::scoped(&base, "bounds"), cx, |_, _| {
624                Rc::new(Cell::new(None::<Bounds<Pixels>>))
625            })
626            .read(cx)
627            .clone();
628        let handle_total = f32::from(RESIZABLE_HANDLE_SIZE) * count.saturating_sub(1) as f32;
629        // Pixel limits need the space the panels share, which the last frame
630        // measured (none before the first layout).
631        let available = group_bounds.get().map(|bounds| {
632            f32::from(if horizontal {
633                bounds.size.width
634            } else {
635                bounds.size.height
636            }) - handle_total
637        });
638        let has_pixel_limits = self.panels.iter().any(ResizablePanel::has_pixel_limits);
639        let limits: Rc<Vec<Limit>> = Rc::new(
640            self.panels
641                .iter()
642                .map(|panel| panel.limits(available))
643                .collect(),
644        );
645        let defaults = default_sizes(&self.panels, &limits);
646
647        let store = window.use_keyed_state(element_id::scoped(&base, "sizes"), cx, {
648            let defaults = defaults.clone();
649            move |_, _| defaults
650        });
651        if store.read(cx).len() != count {
652            store.update(cx, |sizes, _| sizes.clone_from(&defaults));
653        }
654        let controlled = self.sizes.as_ref().is_some_and(|s| s.len() == count);
655        let sizes: Vec<f32> = match &self.sizes {
656            Some(sizes) if controlled => normalize(sizes, &limits),
657            _ => {
658                // Limits can change between renders; keep the store inside
659                // them so the handles' ranges always contain their values.
660                let stored = store.read(cx).clone();
661                let normal = normalize(&stored, &limits);
662                if normal != stored {
663                    store.update(cx, |sizes, _| sizes.clone_from(&normal));
664                }
665                normal
666            }
667        };
668        let drag =
669            window.use_keyed_state(element_id::scoped(&base, "drag"), cx, |_, _| None::<Drag>);
670        // Panels removed mid-drag can take the dragged handle with them.
671        if drag
672            .read(cx)
673            .as_ref()
674            .is_some_and(|d| d.handle + 1 >= count || d.start.len() != count)
675        {
676            drag.update(cx, |value, _| *value = None);
677        }
678        let restore = window.use_keyed_state(element_id::scoped(&base, "restore"), cx, |_, _| {
679            Vec::<Option<f32>>::new()
680        });
681        if restore.read(cx).len() != count {
682            restore.update(cx, |restore, _| *restore = vec![None; count]);
683        }
684        let handles: Vec<gpui::FocusHandle> = (0..count.saturating_sub(1))
685            .map(|ix| {
686                window
687                    .use_keyed_state(
688                        element_id::indexed(&base, "handle-focus", ix),
689                        cx,
690                        |_, cx| cx.focus_handle(),
691                    )
692                    .read(cx)
693                    .clone()
694                    .tab_stop(!self.is_disabled)
695            })
696            .collect();
697
698        let state = GroupState {
699            store,
700            controlled,
701            limits: limits.clone(),
702            restore,
703            on_resize: self.on_resize.clone(),
704            on_resize_end: self.on_resize_end.clone(),
705        };
706        let dragging = drag.read(cx).as_ref().map(|d| d.handle);
707
708        let colors = cx.colors().clone();
709        let focus_visible = crate::util::focus_visible(cx);
710        let cursor_style = if horizontal {
711            gpui::CursorStyle::ResizeColumn
712        } else {
713            gpui::CursorStyle::ResizeRow
714        };
715        // The line a handle draws runs across the group's axis: a horizontal
716        // group's handles are vertical lines.
717        let separator_orientation = if horizontal {
718            Orientation::Vertical
719        } else {
720            Orientation::Horizontal
721        };
722        let axis = move |point: Point<Pixels>| -> f32 {
723            f32::from(if horizontal { point.x } else { point.y })
724        };
725
726        let mut root = div()
727            .id(base.clone())
728            .relative()
729            .flex()
730            .when(horizontal, |el| el.flex_row())
731            .when(!horizontal, |el| el.flex_col())
732            .size_full()
733            .overflow_hidden()
734            .debug_selector({
735                let name = format!("{selector}-group");
736                move || name
737            });
738
739        let panels = self.panels;
740        for (ix, panel) in panels.into_iter().enumerate() {
741            let size = sizes.get(ix).copied().unwrap_or(0.);
742            root = root.child(
743                div()
744                    .flex()
745                    .flex_col()
746                    .flex_basis(px(0.))
747                    .flex_grow(size)
748                    .flex_shrink(1.)
749                    .min_w_0()
750                    .min_h_0()
751                    .overflow_hidden()
752                    .debug_selector({
753                        let name = format!("{selector}-panel-{ix}");
754                        move || name
755                    })
756                    .children(panel.children),
757            );
758            if ix + 1 >= count {
759                continue;
760            }
761
762            let handle = handles[ix].clone();
763            let focused = handle.is_focused(window);
764            let active = dragging == Some(ix) || (focused && focus_visible);
765            let (lower, upper) = pair_range(&sizes, ix, &limits).unwrap_or((size, size));
766            let group_name = SharedString::from(format!("{selector}-handle-{ix}"));
767            let mut handle_el = div()
768                .id(element_id::indexed(&base, "handle", ix))
769                // The WAI-ARIA window splitter: a focusable `separator` whose
770                // value is the size of the pane before it. AccessKit spells
771                // that role `Splitter` (its `Separator` would be the rule a
772                // `Separator` component draws, which 0.24 does not define).
773                .a11y(a11y::Role::Splitter)
774                .a11y_orientation(separator_orientation)
775                .a11y_range(
776                    &a11y::Range::new(lower as f64, upper as f64, size as f64)
777                        .step(self.keyboard_step as f64),
778                )
779                .group(group_name.clone())
780                .relative()
781                .flex()
782                .items_center()
783                .justify_center()
784                .flex_shrink_0()
785                .when(horizontal, |el| el.w(RESIZABLE_HANDLE_SIZE).h_full())
786                .when(!horizontal, |el| el.h(RESIZABLE_HANDLE_SIZE).w_full())
787                .debug_selector({
788                    let name = group_name.to_string();
789                    move || name
790                })
791                .child(
792                    div()
793                        .when(horizontal, |el| el.w(px(1.)).h_full())
794                        .when(!horizontal, |el| el.h(px(1.)).w_full())
795                        .bg(if active {
796                            colors.accent.color
797                        } else {
798                            colors.separator
799                        })
800                        .when(!self.is_disabled, |line| {
801                            line.group_hover(group_name.clone(), |s| s.bg(colors.muted))
802                        }),
803                );
804            if !self.is_disabled {
805                handle_el = handle_el
806                    .track_focus(&handle)
807                    .cursor(cursor_style)
808                    .when(focused && focus_visible, |el| {
809                        el.child(crate::util::focus_ring_overlay(px(2.), false, cx))
810                    });
811
812                // Pointer capture: the handle's own hitbox, re-captured on
813                // every frame of the drag because a hitbox id names one frame's
814                // hitbox only. The captured hitbox counts as hovered wherever
815                // the pointer is, so the resize cursor stays with the drag.
816                let is_dragging = dragging == Some(ix);
817                handle_el = handle_el.child(
818                    gpui::canvas(
819                        |bounds, window, _| {
820                            window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
821                        },
822                        move |_, hitbox, window, _| {
823                            if is_dragging {
824                                window.capture_pointer(hitbox.id);
825                                window.set_cursor_style(cursor_style, &hitbox);
826                            }
827                        },
828                    )
829                    .absolute()
830                    .inset_0(),
831                );
832
833                let press_drag = drag.clone();
834                let press_focus = handle.clone();
835                let press_sizes = sizes.clone();
836                handle_el = handle_el.on_mouse_down(MouseButton::Left, move |event, window, cx| {
837                    crate::util::set_focus_visible(false, cx);
838                    window.focus(&press_focus, cx);
839                    press_drag.update(cx, |value, cx| {
840                        *value = Some(Drag {
841                            handle: ix,
842                            origin: axis(event.position),
843                            start: press_sizes.clone(),
844                            last: press_sizes.clone(),
845                        });
846                        cx.notify();
847                    });
848                    cx.stop_propagation();
849                });
850
851                let keys = state.clone();
852                let key_focus = handle.clone();
853                let key_sizes = sizes.clone();
854                let step = self.keyboard_step;
855                handle_el = handle_el.on_key_down(move |event, window, cx| {
856                    if !key_focus.is_focused(window) {
857                        return;
858                    }
859                    let m = &event.keystroke.modifiers;
860                    if m.control || m.alt || m.platform || m.function {
861                        return;
862                    }
863                    let (decrease, increase) = if horizontal {
864                        ("left", "right")
865                    } else {
866                        ("up", "down")
867                    };
868                    let delta = if m.shift { step * 4. } else { step };
869                    let current = key_sizes[ix];
870                    let next = match event.keystroke.key.as_str() {
871                        k if k == decrease => {
872                            step_pair(&key_sizes, ix, current - delta, &keys.limits)
873                        }
874                        k if k == increase => {
875                            step_pair(&key_sizes, ix, current + delta, &keys.limits)
876                        }
877                        // The pair's extremes, a collapsed size included.
878                        "home" if !m.shift => resize_pair(&key_sizes, ix, -1., &keys.limits),
879                        "end" if !m.shift => resize_pair(&key_sizes, ix, 101., &keys.limits),
880                        "enter" if !m.shift => {
881                            let Some(next) = toggle_collapse(
882                                &key_sizes,
883                                ix,
884                                &keys.limits,
885                                keys.restore.read(cx),
886                            ) else {
887                                return;
888                            };
889                            next
890                        }
891                        _ => return,
892                    };
893                    crate::util::set_focus_visible(true, cx);
894                    if keys.apply(&key_sizes, next.clone(), window, cx) {
895                        keys.resize_ended(&next, window, cx);
896                    }
897                    cx.stop_propagation();
898                });
899            }
900            if !self.is_disabled {
901                handle_el = crate::util::record_focus_bounds(handle_el, &handle, window, cx);
902            }
903            root = root.child(handle_el);
904        }
905
906        // The group measures itself for the pixel limits and the drag. A
907        // length change re-renders once, so pixel limits follow a resize.
908        let probe = group_bounds.clone();
909        let measure = move |bounds: Bounds<Pixels>, window: &mut Window, _: &mut App| {
910            let changed = probe.get().map(|b| b.size) != Some(bounds.size);
911            probe.set(Some(bounds));
912            if changed && has_pixel_limits {
913                window.request_animation_frame();
914            }
915        };
916        // The drag outlives the handle's hitbox, so paint-time window
917        // listeners own the move and the release until it ends.
918        if self.is_disabled || count <= 1 {
919            root = root.child(gpui::canvas(measure, |_, _, _, _| {}).absolute().inset_0());
920        } else {
921            let move_drag = drag.clone();
922            let up_drag = drag;
923            let up_state = state.clone();
924            let move_state = state;
925            root = root.child(
926                gpui::canvas(measure, move |_, _, window, _| {
927                    let bounds = group_bounds.clone();
928                    let held = move_drag.clone();
929                    let state = move_state.clone();
930                    window.on_mouse_event(
931                        move |event: &gpui::MouseMoveEvent, phase, window, cx| {
932                            if phase != gpui::DispatchPhase::Capture {
933                                return;
934                            }
935                            let Some(drag) = held.read(cx).clone() else {
936                                return;
937                            };
938                            if event.pressed_button != Some(MouseButton::Left) {
939                                // The release happened where no listener
940                                // saw it (outside the window).
941                                held.update(cx, |value, cx| {
942                                    *value = None;
943                                    cx.notify();
944                                });
945                                return;
946                            }
947                            // The panels changed under the drag (see
948                            // render): a handle that no longer exists
949                            // ends it.
950                            if drag.handle + 1 >= state.limits.len()
951                                || drag.start.len() != state.limits.len()
952                            {
953                                held.update(cx, |value, cx| {
954                                    *value = None;
955                                    cx.notify();
956                                });
957                                return;
958                            }
959                            let Some(bounds) = bounds.get() else {
960                                return;
961                            };
962                            let length = f32::from(if horizontal {
963                                bounds.size.width
964                            } else {
965                                bounds.size.height
966                            });
967                            let available = length - handle_total;
968                            if available <= 0. {
969                                return;
970                            }
971                            let delta = (axis(event.position) - drag.origin) / available * 100.;
972                            let target = drag.start[drag.handle] + delta;
973                            let next = resize_pair(&drag.start, drag.handle, target, &state.limits);
974                            // Compare with what is current now, not with
975                            // what the last frame rendered: several moves
976                            // can land between two frames. The store is
977                            // current for an uncontrolled group; a
978                            // controlled one has only what it reported.
979                            let now = if state.controlled {
980                                drag.last
981                            } else {
982                                state.store.read(cx).clone()
983                            };
984                            if state.apply(&now, next.clone(), window, cx) {
985                                held.update(cx, |value, _| {
986                                    if let Some(value) = value {
987                                        value.last = next;
988                                    }
989                                });
990                            }
991                        },
992                    );
993                    let held = up_drag.clone();
994                    let state = up_state.clone();
995                    window.on_mouse_event(move |event: &gpui::MouseUpEvent, phase, window, cx| {
996                        if phase != gpui::DispatchPhase::Capture
997                            || event.button != MouseButton::Left
998                        {
999                            return;
1000                        }
1001                        let Some(drag) = held.read(cx).clone() else {
1002                            return;
1003                        };
1004                        held.update(cx, |value, cx| {
1005                            *value = None;
1006                            cx.notify();
1007                        });
1008                        if drag.last != drag.start {
1009                            state.resize_ended(&drag.last, window, cx);
1010                        }
1011                    });
1012                })
1013                .absolute()
1014                .inset_0(),
1015            );
1016        }
1017        root
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    fn panel(default: Option<f32>) -> ResizablePanel {
1026        let p = ResizablePanel::new();
1027        match default {
1028            Some(size) => p.default_size(size),
1029            None => p,
1030        }
1031    }
1032
1033    #[test]
1034    fn defaults_share_the_remainder_and_scale_to_100() {
1035        let defaults = |panels: &[ResizablePanel]| {
1036            let limits: Vec<Limit> = panels.iter().map(|p| p.limits(None)).collect();
1037            default_sizes(panels, &limits)
1038        };
1039        assert_eq!(
1040            defaults(&[panel(Some(20.)), panel(None), panel(None)]),
1041            vec![20., 40., 40.]
1042        );
1043        assert_eq!(
1044            defaults(&[panel(Some(30.)), panel(Some(10.))]),
1045            vec![75., 25.]
1046        );
1047        assert_eq!(defaults(&[panel(None), panel(None)]), vec![50., 50.]);
1048    }
1049
1050    #[test]
1051    fn a_pair_resize_respects_both_panels_limits() {
1052        let limits = [
1053            Limit::range(10., 60.),
1054            Limit::range(0., 100.),
1055            Limit::range(0., 100.),
1056        ];
1057        let sizes = [40., 40., 20.];
1058        // The first panel's own max.
1059        assert_eq!(resize_pair(&sizes, 0, 70., &limits), vec![60., 20., 20.]);
1060        // The second panel's min caps the first at 80 - 30 = 50, under its
1061        // own max.
1062        let tight = [
1063            Limit::range(10., 90.),
1064            Limit::range(30., 100.),
1065            Limit::range(0., 100.),
1066        ];
1067        assert_eq!(resize_pair(&sizes, 0, 70., &tight), vec![50., 30., 20.]);
1068        // The first panel's min.
1069        assert_eq!(resize_pair(&sizes, 0, -5., &limits), vec![10., 70., 20.]);
1070        // Only the pair moves.
1071        assert_eq!(resize_pair(&sizes, 1, 50., &limits), vec![40., 50., 10.]);
1072    }
1073
1074    #[test]
1075    fn normalize_scales_splits_and_clamps() {
1076        let free = [Limit::range(0., 100.), Limit::range(0., 100.)];
1077        assert_eq!(normalize(&[1., 3.], &free), vec![25., 75.]);
1078        assert_eq!(normalize(&[0., 0.], &free), vec![50., 50.]);
1079        assert_eq!(normalize(&[f32::NAN, -5.], &free), vec![50., 50.]);
1080        assert_eq!(normalize(&[f32::INFINITY, 1.], &free), vec![0., 100.]);
1081        // Below a minimum: raised to it, the others give up the difference
1082        // in proportion to their room.
1083        let limits = [
1084            Limit::range(20., 100.),
1085            Limit::range(0., 100.),
1086            Limit::range(0., 100.),
1087        ];
1088        assert_eq!(normalize(&[10., 45., 45.], &limits), vec![20., 40., 40.]);
1089        // Over a maximum: the others take the excess.
1090        let capped = [Limit::range(0., 50.), Limit::range(0., 100.)];
1091        assert_eq!(normalize(&[80., 20.], &capped), vec![50., 50.]);
1092        // Unsatisfiable limits leave the scaled sizes alone.
1093        let impossible = [Limit::range(70., 100.), Limit::range(70., 100.)];
1094        assert_eq!(normalize(&[50., 50.], &impossible), vec![50., 50.]);
1095    }
1096
1097    #[test]
1098    fn contradictory_limits_leave_the_sizes_alone() {
1099        let limits = [Limit::range(70., 100.), Limit::range(70., 100.)];
1100        assert_eq!(resize_pair(&[50., 50.], 0, 60., &limits), vec![50., 50.]);
1101        assert!(pair_range(&[50., 50.], 0, &limits).is_none());
1102    }
1103
1104    fn collapsible(min: f32) -> Limit {
1105        Limit {
1106            min,
1107            max: 100.,
1108            collapsed: Some(0.),
1109        }
1110    }
1111
1112    #[test]
1113    fn pixel_limits_convert_against_the_measured_length() {
1114        let panel = ResizablePanel::new()
1115            .min_size(10.)
1116            .min_size_px(px(100.))
1117            .max_size(90.)
1118            .max_size_px(px(300.));
1119        let percent = |limit: Limit| {
1120            assert_eq!(limit.collapsed, None);
1121            (
1122                (limit.min * 1000.).round() / 1000.,
1123                (limit.max * 1000.).round() / 1000.,
1124            )
1125        };
1126        // Unmeasured: the percentages alone.
1127        assert_eq!(percent(panel.limits(None)), (10., 90.));
1128        // 500px available: 100px is 20% (stricter than 10%), 300px is 60%.
1129        assert_eq!(percent(panel.limits(Some(500.))), (20., 60.));
1130        // 2000px available: the 10% minimum is stricter than 5%.
1131        assert_eq!(percent(panel.limits(Some(2000.))), (10., 15.));
1132        // A pixel maximum under the minimum yields to it.
1133        let squeezed = ResizablePanel::new().min_size(50.).max_size_px(px(10.));
1134        assert_eq!(percent(squeezed.limits(Some(100.))), (50., 50.));
1135    }
1136
1137    #[test]
1138    fn a_collapsible_pair_snaps_at_halfway_to_the_minimum() {
1139        let limits = [collapsible(20.), Limit::range(0., 100.)];
1140        let sizes = [30., 70.];
1141        // Between the collapsed size and the minimum: the nearer end.
1142        assert_eq!(resize_pair(&sizes, 0, 11., &limits), vec![20., 80.]);
1143        assert_eq!(resize_pair(&sizes, 0, 9., &limits), vec![0., 100.]);
1144        assert_eq!(pair_range(&sizes, 0, &limits), Some((0., 100.)));
1145        // The panel after the handle collapses the same way.
1146        let after = [Limit::range(0., 100.), collapsible(20.)];
1147        assert_eq!(resize_pair(&sizes, 0, 95., &after), vec![100., 0.]);
1148        assert_eq!(resize_pair(&sizes, 0, 88., &after), vec![80., 20.]);
1149    }
1150
1151    #[test]
1152    fn a_step_crosses_the_collapse_gap() {
1153        let limits = [collapsible(20.), Limit::range(0., 100.)];
1154        // From the minimum, a 5% step down would snap back: it collapses.
1155        assert_eq!(step_pair(&[20., 80.], 0, 15., &limits), vec![0., 100.]);
1156        // From collapsed, a 5% step up would snap back: it expands to the
1157        // minimum.
1158        assert_eq!(step_pair(&[0., 100.], 0, 5., &limits), vec![20., 80.]);
1159        // Ordinary steps are plain resizes.
1160        assert_eq!(step_pair(&[30., 70.], 0, 35., &limits), vec![35., 65.]);
1161    }
1162
1163    #[test]
1164    fn enter_collapses_and_restores() {
1165        let limits = [collapsible(20.), Limit::range(0., 100.)];
1166        let restore = [Some(35.), None];
1167        assert_eq!(
1168            toggle_collapse(&[40., 60.], 0, &limits, &restore),
1169            Some(vec![0., 100.])
1170        );
1171        assert_eq!(
1172            toggle_collapse(&[0., 100.], 0, &limits, &restore),
1173            Some(vec![35., 65.])
1174        );
1175        // Started collapsed with nothing to restore: the minimum.
1176        assert_eq!(
1177            toggle_collapse(&[0., 100.], 0, &limits, &[None, None]),
1178            Some(vec![20., 80.])
1179        );
1180        // Only the panel after is collapsible: Enter collapses that one.
1181        let after = [Limit::range(0., 100.), collapsible(20.)];
1182        assert_eq!(
1183            toggle_collapse(&[40., 60.], 0, &after, &[None, None]),
1184            Some(vec![100., 0.])
1185        );
1186        let neither = [Limit::range(0., 100.), Limit::range(0., 100.)];
1187        assert_eq!(
1188            toggle_collapse(&[40., 60.], 0, &neither, &[None, None]),
1189            None
1190        );
1191    }
1192
1193    #[test]
1194    fn normalize_keeps_a_collapsed_panel_collapsed() {
1195        let limits = [collapsible(20.), Limit::range(0., 100.)];
1196        assert_eq!(normalize(&[0., 100.], &limits), vec![0., 100.]);
1197        // Under halfway rounds down to collapsed, over it up to the minimum.
1198        assert_eq!(normalize(&[5., 95.], &limits), vec![0., 100.]);
1199        assert_eq!(normalize(&[15., 85.], &limits), vec![20., 80.]);
1200    }
1201}