1use std::rc::Rc;
8
9use gpui::{
10 App, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
11 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
12};
13use gpui_kit_semantics::{NodeSpec, Role, Semantic};
14use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
15
16use crate::foundation::direction::{ActiveDirection, DirectionalExt};
17use crate::foundation::stepping::bounded_step;
18use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, text};
19use crate::overlay::{Menu, MenuItem};
20use crate::strings::{ActiveStrings, StringKey};
21
22type NavigateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Anchor {
27 id: SharedString,
28 label: SharedString,
29 disabled: bool,
30}
31
32impl Anchor {
33 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
34 Self {
35 id: id.into(),
36 label: label.into(),
37 disabled: false,
38 }
39 }
40
41 pub fn disabled(mut self, disabled: bool) -> Self {
42 self.disabled = disabled;
43 self
44 }
45
46 pub fn id(&self) -> &SharedString {
47 &self.id
48 }
49
50 pub fn label(&self) -> &SharedString {
51 &self.label
52 }
53
54 pub fn is_disabled(&self) -> bool {
55 self.disabled
56 }
57
58 fn menu_row(&self, active: bool) -> MenuItem {
59 if active {
60 MenuItem::check(self.id.clone(), self.label.clone(), true).disabled(self.disabled)
61 } else {
62 MenuItem::command(self.id.clone(), self.label.clone()).disabled(self.disabled)
63 }
64 }
65}
66
67#[derive(IntoElement)]
69pub struct AnchorList {
70 ident: Ident,
71 anchors: Vec<Anchor>,
72 active: Option<SharedString>,
73 disabled: bool,
74 size: ControlSize,
75 on_navigate: Option<NavigateHandler>,
76 overflow_after: Option<usize>,
77 overflow_menu: Option<Entity<Menu>>,
78}
79
80impl std::fmt::Debug for AnchorList {
81 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 formatter
83 .debug_struct("AnchorList")
84 .field("ident", &self.ident)
85 .field("anchors", &self.anchors.len())
86 .field("active", &self.active)
87 .field("disabled", &self.disabled)
88 .field("overflow_after", &self.overflow_after)
89 .finish()
90 }
91}
92
93impl AnchorList {
94 pub fn new(ident: impl Into<Ident>) -> Self {
95 Self {
96 ident: ident.into(),
97 anchors: Vec::new(),
98 active: None,
99 disabled: false,
100 size: ControlSize::Md,
101 on_navigate: None,
102 overflow_after: None,
103 overflow_menu: None,
104 }
105 }
106
107 pub fn anchor(mut self, anchor: Anchor) -> Self {
108 self.anchors.push(anchor);
109 self
110 }
111
112 pub fn anchors(mut self, anchors: impl IntoIterator<Item = Anchor>) -> Self {
113 self.anchors.extend(anchors);
114 self
115 }
116
117 pub fn active(mut self, id: impl Into<SharedString>) -> Self {
118 self.active = Some(id.into());
119 self
120 }
121
122 pub fn on_navigate(
123 mut self,
124 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
125 ) -> Self {
126 self.on_navigate = Some(Rc::new(handler));
127 self
128 }
129
130 pub fn overflow_after(mut self, count: usize) -> Self {
133 self.overflow_after = Some(count);
134 self
135 }
136
137 pub fn overflow_menu(mut self, menu: Entity<Menu>) -> Self {
142 self.overflow_menu = Some(menu);
143 self
144 }
145
146 fn cut(&self) -> usize {
147 match (
148 self.disabled,
149 self.overflow_after,
150 self.overflow_menu.is_some(),
151 ) {
152 (false, Some(cut), true) => cut,
153 _ => usize::MAX,
154 }
155 }
156
157 fn anchor_element(&self, anchor: &Anchor, cx: &mut App) -> impl IntoElement {
158 let theme = cx.theme().clone();
159 let metrics = theme.control.get(self.size);
160 let ident = self.ident.child(anchor.id.as_ref());
161 let active = self.active.as_ref() == Some(&anchor.id);
162 let disabled = self.disabled || anchor.disabled;
163 let actionable = !disabled && self.on_navigate.is_some();
164 let color = if disabled {
165 theme.colors.text_faint
166 } else if active {
167 theme.colors.text
168 } else {
169 theme.colors.text_muted
170 };
171
172 let mut element = div()
173 .id(ident.element_id())
174 .flex_none()
175 .h(px(metrics.height))
176 .px(px(metrics.padding_x))
177 .flex()
178 .items_center()
179 .rounded(px(theme.radii.control))
180 .child(
181 text(&theme, TypeScale::Label, anchor.label.clone())
182 .text_size(px(metrics.font_size))
183 .text_color(color),
184 )
185 .when(active, |element| element.bg(theme.colors.selected))
186 .when(disabled, |element| element.opacity(theme.opacity.disabled))
187 .when(actionable, |element| {
188 element
189 .cursor_pointer()
190 .tab_index(0)
191 .pressable(cx)
192 .hover(|style| style.bg(theme.colors.hover))
193 .focus_ring(&theme)
194 });
195
196 if let (true, Some(handler)) = (actionable, self.on_navigate.clone()) {
197 let id = anchor.id.clone();
198 let clicked = id.clone();
199 let click = Rc::clone(&handler);
200 element = element
201 .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
202 .on_key_down(move |event, window, cx| {
203 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
204 handler(id.clone(), window, cx);
205 cx.stop_propagation();
206 }
207 });
208 }
209
210 element.semantic_in(
211 cx,
212 NodeSpec::new(ident.semantic_id(), Role::Link)
213 .parent(self.ident.semantic_id())
214 .selected(active)
215 .disabled(disabled)
216 .text(anchor.label.clone()),
217 )
218 }
219}
220
221impl Disableable for AnchorList {
222 fn disabled(mut self, disabled: bool) -> Self {
223 self.disabled = disabled;
224 self
225 }
226}
227
228impl Sizable for AnchorList {
229 fn control_size(mut self, size: ControlSize) -> Self {
230 self.size = size;
231 self
232 }
233}
234
235impl RenderOnce for AnchorList {
236 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
237 let theme = cx.theme().clone();
238 let direction = cx.layout_direction();
239 if self.disabled
240 && let Some(menu) = self.overflow_menu.as_ref()
241 && menu.read(cx).is_open()
242 {
243 menu.update(cx, |menu, cx| menu.close(window, cx));
244 }
245 let mut strip = div()
246 .id(self.ident.element_id())
247 .row_reading(direction)
248 .items_center()
249 .flex_wrap()
250 .gap(px(theme.space(Space::Xs)));
251
252 if let (false, Some(handler)) = (self.disabled, self.on_navigate.clone()) {
253 let anchors = self.anchors.clone();
254 let active = self.active.clone();
255 strip = strip.on_key_down(move |event, window, cx| {
256 let key = event.keystroke.key.as_str();
257 let next = match direction.arrow_step(key) {
258 Some(delta) => step(&anchors, active.as_ref(), delta as isize),
259 None => match key {
260 "home" => edge(&anchors, -1),
261 "end" => edge(&anchors, 1),
262 _ => return,
263 },
264 };
265 let Some(next) = next.filter(|next| Some(next) != active.as_ref()) else {
266 return;
267 };
268 handler(next, window, cx);
269 cx.stop_propagation();
270 });
271 }
272
273 let cut = self.cut();
274 let mut hidden = Vec::new();
275 for (index, anchor) in self.anchors.iter().enumerate() {
276 if index >= cut {
277 hidden.push(anchor.menu_row(self.active.as_ref() == Some(&anchor.id)));
278 } else {
279 strip = strip.child(self.anchor_element(anchor, cx));
280 }
281 }
282
283 let hidden_count = hidden.len();
284 let overflow = self
285 .overflow_menu
286 .clone()
287 .filter(|_| hidden_count > 0)
288 .map(|menu| {
289 if menu.read(cx).offered() != hidden.as_slice() {
290 menu.update(cx, |menu, cx| menu.set_items(hidden, cx));
291 }
292 let ident = self.ident.child("overflow");
293 div().flex().flex_none().child(menu).semantic_in(
294 cx,
295 NodeSpec::new(ident.semantic_id(), Role::Group)
296 .parent(self.ident.semantic_id())
297 .text(cx.strings().text(StringKey::AnchorMoreSections))
298 .value(hidden_count.to_string()),
299 )
300 });
301
302 strip.children(overflow).semantic_in(
303 cx,
304 NodeSpec::new(self.ident.semantic_id(), Role::List)
305 .disabled(self.disabled)
306 .value(self.anchors.len().to_string()),
307 )
308 }
309}
310
311fn step(anchors: &[Anchor], active: Option<&SharedString>, delta: isize) -> Option<SharedString> {
312 let from = active.and_then(|id| anchors.iter().position(|anchor| &anchor.id == id));
313 bounded_step(anchors.len(), from, delta, |index| anchors[index].disabled)
314 .map(|index| anchors[index].id.clone())
315}
316
317fn edge(anchors: &[Anchor], delta: isize) -> Option<SharedString> {
318 step(anchors, None, -delta)
319}