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.row_text(true, false)
107 } else {
108 motion::hover_blend(&fade, theme.row_text(false, false), 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 /// A vertical rule between clusters of controls in a bar.
142 fn toolbar_separator(&self) -> Div {
143 div()
144 .w(px(1.0))
145 .h(px(16.0))
146 .mx(px(2.0))
147 .flex_none()
148 .bg(self.theme().border)
149 }
150
151 /// The divider between two panes: a hairline centred in a grab strip, lit
152 /// while dragged.
153 ///
154 /// The gesture stays with the caller, which owns the fraction — the handle
155 /// is dragged with gpui's null-preview drag, and the container reads the
156 /// pointer:
157 ///
158 /// ```ignore
159 /// div()
160 /// .id("split")
161 /// .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SplitDrag>, _, cx| {
162 /// view.fraction = axis_fraction(event.event.position, event.bounds, Axis::Horizontal, 0.15);
163 /// cx.notify();
164 /// }))
165 /// .child(div().w(relative(self.fraction)).child(left))
166 /// .child(
167 /// theme.split_handle(Axis::Horizontal, SplitStyle::Line { dragging: self.dragging })
168 /// .id("split-handle")
169 /// .on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| gpui::Empty)),
170 /// )
171 /// .child(div().flex_1().child(right))
172 /// ```
173 fn split_handle(&self, axis: gpui::Axis, style: SplitStyle) -> Div {
174 let theme = self.theme();
175 let line = match style {
176 SplitStyle::Line { dragging } => {
177 Some(if dragging { theme.caret } else { theme.border })
178 }
179 SplitStyle::Ghost => None,
180 };
181 let handle = div().flex_none().flex().items_center().justify_center();
182 match axis {
183 gpui::Axis::Horizontal => handle
184 .w(px(SPLIT_HANDLE_HIT))
185 .h_full()
186 .cursor_col_resize()
187 .when_some(line, |el, line| {
188 el.child(div().w(px(1.0)).h_full().bg(line))
189 }),
190 gpui::Axis::Vertical => handle
191 .h(px(SPLIT_HANDLE_HIT))
192 .w_full()
193 .cursor_row_resize()
194 .when_some(line, |el, line| {
195 el.child(div().h(px(1.0)).w_full().bg(line))
196 }),
197 }
198 }
199
200 /// The grip along an edge that a box is dragged taller or shorter by: a
201 /// [`Self::split_handle`]'s strip across the edge, holding a short bar in
202 /// its middle that says the edge can be pulled.
203 fn grip_handle(&self) -> Div {
204 let theme = self.theme();
205 div()
206 .flex_none()
207 .flex()
208 .items_center()
209 .justify_center()
210 .h(px(SPLIT_HANDLE_HIT))
211 .w_full()
212 .cursor(CursorStyle::ResizeUpDown)
213 .child(
214 div()
215 .w(px(GRIP_LENGTH))
216 .h(px(GRIP_THICKNESS))
217 .rounded_full()
218 .bg(theme.border_strong),
219 )
220 }
221
222 /// Tab strip: a hairline-underlined row that tabs sit on.
223 ///
224 /// Switches between sections of one page, over a set fixed at compile
225 /// time. Tabs that open and close are [`crate::tabs`].
226 ///
227 /// Keys are the caller's, through [`crate::focus`]: `focusable` puts each
228 /// tab in the tab order and gives it `enter`/`space`, and `Decrement` /
229 /// `Increment` arrive on ← / → for whichever tab holds focus.
230 fn tab_bar(&self) -> Div {
231 let theme = self.theme();
232 div()
233 .flex()
234 .flex_row()
235 .items_center()
236 .gap(px(2.0))
237 .border_b_1()
238 .border_color(theme.border)
239 }
240
241 /// One tab. The active tab is marked by the text tone plus a 2px underline
242 /// that overlaps the bar's hairline, so switching tabs never changes row
243 /// height.
244 fn tab(&self, label: impl Into<SharedString>, active: bool) -> Div {
245 let theme = self.theme();
246 div()
247 .relative()
248 .px(px(10.0))
249 .pb(px(7.0))
250 .pt(px(6.0))
251 .rounded_t(px(Theme::control_radius()))
252 .border_1()
253 .border_color(crate::widgets::RING_SLOT)
254 .text_style(TextStyle::Body)
255 .font_weight(if active {
256 gpui::FontWeight::MEDIUM
257 } else {
258 gpui::FontWeight::NORMAL
259 })
260 .text_color(if active { theme.text } else { theme.text_muted })
261 .cursor_pointer()
262 .child(label.into())
263 .when(active, |t| {
264 t.child(
265 // Insets resolve against the padding box, so each one carries
266 // the ring slot's pixel: the underline still spans the tab's
267 // full width and still overlaps the bar's hairline.
268 div()
269 .absolute()
270 .bottom(px(-2.0))
271 .left(px(-1.0))
272 .right(px(-1.0))
273 .h(px(2.0))
274 .bg(theme.solid),
275 )
276 })
277 }
278}
279
280impl Layout for Theme {}