Skip to main content

ui/widgets/
layout.rs

1//! Organisation chrome — disclosure, collapsible header, nav row, split
2//! divider, tabs.
3//!
4//! A catalog trait, like every widget group: import it to unlock
5//! `theme.collapsible_header(..)`, `theme.split_handle(..)`, `theme.tab(..)`.
6
7use crate::stack;
8use gpui::{CursorStyle, Div, SharedString, Svg, div, prelude::*, px};
9use icons::Icon;
10use motion::{self, Fade};
11use theme::{TextStyle, Theme, ThemeExt, Typeset};
12
13/// The drag payload of a [`Layout::split_handle`]. Shipped from here so every
14/// split speaks the same type: `on_drag_move::<SplitDrag>` on one container
15/// would otherwise fire for an unrelated split's gesture.
16pub struct SplitDrag;
17
18/// Width of the divider's grab strip: the 1px line plus zed's own 4px of slack
19/// each side (`workspace::HANDLE_HITBOX_SIZE`) — a 1px target is unhittable.
20pub const SPLIT_HANDLE_HIT: f32 = 9.0;
21/// The bar a [`Layout::grip_handle`] holds, along the edge and across it.
22const GRIP_LENGTH: f32 = 36.0;
23const GRIP_THICKNESS: f32 = 4.0;
24
25/// What a [`Layout::split_handle`] paints. `Ghost` is for a pane that already
26/// draws the edge itself; the strip still takes the drag.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SplitStyle {
29    /// A hairline of its own, lit while grabbed.
30    Line {
31        dragging: bool,
32    },
33    Ghost,
34}
35
36pub trait Layout: ThemeExt {
37    /// The disclosure chevron: right when collapsed, down when expanded.
38    ///
39    /// Two assets rather than one rotated: gpui has no transform for `div`s at
40    /// the pinned rev, and an SVG rotation would need a transform on the
41    /// element.
42    fn disclosure(&self, expanded: bool) -> Svg {
43        let theme = self.theme();
44        crate::icons::icon(if expanded {
45            crate::icons::glyph::ChevronDown
46        } else {
47            crate::icons::glyph::ChevronRight
48        })
49        .size(px(14.0))
50        .text_color(theme.text_muted)
51    }
52
53    /// Header row of a collapsible section: chevron plus title. The caller owns
54    /// `expanded` and renders the body itself — a container that swallowed its
55    /// children would have to re-implement layout for them. Hover is
56    /// caller-owned (gpui panics on a second hover); the default wash is
57    /// [`Theme::element_hover`].
58    fn collapsible_header(&self, label: impl Into<SharedString>, expanded: bool) -> Div {
59        let theme = self.theme();
60        div()
61            .self_start()
62            .flex()
63            .flex_row()
64            .items_center()
65            .gap(px(6.0))
66            .px(px(4.0))
67            .py(px(5.0))
68            .rounded(px(Theme::control_radius()))
69            .cursor_pointer()
70            .child(Layout::disclosure(self.theme(), expanded))
71            .child(
72                div()
73                    .text_style(TextStyle::Callout)
74                    .font_weight(gpui::FontWeight::MEDIUM)
75                    .text_color(theme.text)
76                    .child(label.into()),
77            )
78    }
79
80    /// A navigation row: leading icon, truncating label, and whatever the
81    /// caller appends — a count, a chevron, a control that shows on hover.
82    /// shadcn calls it `SidebarMenuButton`, MUI `ListItemButton`.
83    ///
84    /// The label is a parameter rather than a child because it carries the
85    /// truncation, and a caller that has to remember `min_w_0().flex_1()`
86    /// forgets it on the first long project name — which pushes the trailing
87    /// content off the row instead of shortening the label.
88    ///
89    /// `fade` must be stable across frames. It is also how a trailing control
90    /// reveals itself on row hover: paint it with
91    /// [`motion::hover_blend`] on this same fade, rather than adding an
92    /// `on_hover` of its own — gpui allows only one per element, and this row
93    /// has claimed it.
94    fn nav_row(
95        &self,
96        icon: Option<Icon>,
97        label: impl Into<SharedString>,
98        selected: bool,
99        fade: Fade,
100    ) -> Div {
101        let theme = self.theme();
102        // Selected is a flat wash; unselected fades. The tone is
103        // `popover::menu_row`'s and `tree::tree_row`'s, so a sidebar, a menu
104        // and a tree never show three different ideas of "this one".
105        let tint = if selected {
106            theme.text
107        } else {
108            motion::hover_blend(&fade, theme.text_muted, theme.text)
109        };
110        let mut row = stack::row()
111            .w_full()
112            .px(px(8.0))
113            .py(px(6.0))
114            .rounded(px(Theme::control_radius()))
115            .text_style(TextStyle::Body)
116            .text_color(tint)
117            .cursor_pointer();
118        if selected {
119            row = row.bg(theme.card_selected_bg());
120        } else {
121            row = row.bg(motion::hover_blend(
122                &fade,
123                theme.ink(0.0),
124                theme.element_hover,
125            ));
126            row.interactivity().on_hover(motion::hover_listener(fade));
127        }
128        row.when_some(icon, |row, icon| {
129            // The tint is set on the svg itself: gpui reads an svg's colour off
130            // that element's own style and paints nothing when it is unset.
131            row.child(
132                crate::icons::icon(icon)
133                    .size(px(16.0))
134                    .flex_none()
135                    .text_color(tint),
136            )
137        })
138        .child(div().min_w_0().flex_1().truncate().child(label.into()))
139    }
140
141    /// The divider between two panes: a hairline centred in a grab strip, lit
142    /// while dragged.
143    ///
144    /// The gesture stays with the caller, which owns the fraction — the handle
145    /// is dragged with gpui's null-preview drag, and the container reads the
146    /// pointer:
147    ///
148    /// ```ignore
149    /// div()
150    ///     .id("split")
151    ///     .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SplitDrag>, _, cx| {
152    ///         view.fraction = axis_fraction(event.event.position, event.bounds, Axis::Horizontal, 0.15);
153    ///         cx.notify();
154    ///     }))
155    ///     .child(div().w(relative(self.fraction)).child(left))
156    ///     .child(
157    ///         theme.split_handle(Axis::Horizontal, SplitStyle::Line { dragging: self.dragging })
158    ///             .id("split-handle")
159    ///             .on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| gpui::Empty)),
160    ///     )
161    ///     .child(div().flex_1().child(right))
162    /// ```
163    fn split_handle(&self, axis: gpui::Axis, style: SplitStyle) -> Div {
164        let theme = self.theme();
165        let line = match style {
166            SplitStyle::Line { dragging } => {
167                Some(if dragging { theme.caret } else { theme.border })
168            }
169            SplitStyle::Ghost => None,
170        };
171        let handle = div().flex_none().flex().items_center().justify_center();
172        match axis {
173            gpui::Axis::Horizontal => handle
174                .w(px(SPLIT_HANDLE_HIT))
175                .h_full()
176                .cursor_col_resize()
177                .when_some(line, |el, line| {
178                    el.child(div().w(px(1.0)).h_full().bg(line))
179                }),
180            gpui::Axis::Vertical => handle
181                .h(px(SPLIT_HANDLE_HIT))
182                .w_full()
183                .cursor_row_resize()
184                .when_some(line, |el, line| {
185                    el.child(div().h(px(1.0)).w_full().bg(line))
186                }),
187        }
188    }
189
190    /// The grip along an edge that a box is dragged taller or shorter by: a
191    /// [`Self::split_handle`]'s strip across the edge, holding a short bar in
192    /// its middle that says the edge can be pulled.
193    fn grip_handle(&self) -> Div {
194        let theme = self.theme();
195        div()
196            .flex_none()
197            .flex()
198            .items_center()
199            .justify_center()
200            .h(px(SPLIT_HANDLE_HIT))
201            .w_full()
202            .cursor(CursorStyle::ResizeUpDown)
203            .child(
204                div()
205                    .w(px(GRIP_LENGTH))
206                    .h(px(GRIP_THICKNESS))
207                    .rounded_full()
208                    .bg(theme.border_strong),
209            )
210    }
211
212    /// Tab strip: a hairline-underlined row that tabs sit on.
213    ///
214    /// Switches between sections of one page, over a set fixed at compile
215    /// time. Tabs that open and close are [`crate::tabs`].
216    ///
217    /// Keys are the caller's, through [`crate::focus`]: `focusable` puts each
218    /// tab in the tab order and gives it `enter`/`space`, and `Decrement` /
219    /// `Increment` arrive on ← / → for whichever tab holds focus.
220    fn tab_bar(&self) -> Div {
221        let theme = self.theme();
222        div()
223            .flex()
224            .flex_row()
225            .items_center()
226            .gap(px(2.0))
227            .border_b_1()
228            .border_color(theme.border)
229    }
230
231    /// One tab. The active tab is marked by the text tone plus a 2px underline
232    /// that overlaps the bar's hairline, so switching tabs never changes row
233    /// height.
234    fn tab(&self, label: impl Into<SharedString>, active: bool) -> Div {
235        let theme = self.theme();
236        div()
237            .relative()
238            .px(px(10.0))
239            .pb(px(7.0))
240            .pt(px(6.0))
241            .rounded_t(px(Theme::control_radius()))
242            .border_1()
243            .border_color(crate::widgets::RING_SLOT)
244            .text_style(TextStyle::Body)
245            .font_weight(if active {
246                gpui::FontWeight::MEDIUM
247            } else {
248                gpui::FontWeight::NORMAL
249            })
250            .text_color(if active { theme.text } else { theme.text_muted })
251            .cursor_pointer()
252            .child(label.into())
253            .when(active, |t| {
254                t.child(
255                    // Insets resolve against the padding box, so each one carries
256                    // the ring slot's pixel: the underline still spans the tab's
257                    // full width and still overlaps the bar's hairline.
258                    div()
259                        .absolute()
260                        .bottom(px(-2.0))
261                        .left(px(-1.0))
262                        .right(px(-1.0))
263                        .h(px(2.0))
264                        .bg(theme.solid),
265                )
266            })
267    }
268}
269
270impl Layout for Theme {}