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