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 ///
189 /// Switches between sections of one page, over a set fixed at compile
190 /// time. Tabs that open and close are [`crate::tabs`].
191 ///
192 /// Keys are the caller's, through [`crate::focus`]: `focusable` puts each
193 /// tab in the tab order and gives it `enter`/`space`, and `Decrement` /
194 /// `Increment` arrive on ← / → for whichever tab holds focus.
195 fn tab_bar(&self) -> Div {
196 let theme = self.theme();
197 div()
198 .flex()
199 .flex_row()
200 .items_center()
201 .gap(px(2.0))
202 .border_b_1()
203 .border_color(theme.border)
204 }
205
206 /// One tab. The active tab is marked by the text tone plus a 2px underline
207 /// that overlaps the bar's hairline, so switching tabs never changes row
208 /// height.
209 fn tab(&self, label: impl Into<SharedString>, active: bool) -> Div {
210 let theme = self.theme();
211 div()
212 .relative()
213 .px(px(10.0))
214 .pb(px(7.0))
215 .pt(px(6.0))
216 .rounded_t(px(Theme::control_radius()))
217 .border_1()
218 .border_color(crate::widgets::RING_SLOT)
219 .text_style(TextStyle::Body)
220 .font_weight(if active {
221 gpui::FontWeight::MEDIUM
222 } else {
223 gpui::FontWeight::NORMAL
224 })
225 .text_color(if active { theme.text } else { theme.text_muted })
226 .cursor_pointer()
227 .child(label.into())
228 .when(active, |t| {
229 t.child(
230 // Insets resolve against the padding box, so each one carries
231 // the ring slot's pixel: the underline still spans the tab's
232 // full width and still overlaps the bar's hairline.
233 div()
234 .absolute()
235 .bottom(px(-2.0))
236 .left(px(-1.0))
237 .right(px(-1.0))
238 .h(px(2.0))
239 .bg(theme.text),
240 )
241 })
242 }
243}
244
245impl Layout for Theme {}