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