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