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