Skip to main content

gpui_kit/navigation/
anchor_list.rs

1//! In-page navigation over caller-owned document anchors.
2//!
3//! `AnchorList` reports navigation intent; it never changes the active anchor.
4//! Overflow is declarative and truthful: anchors move only when both a cut and
5//! a caller-owned menu are supplied.
6
7use 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/// One caller-owned document destination, identified independently of order.
25#[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/// A horizontal reading-order list of links to sections in the current page.
68#[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    /// Keeps `count` anchors inline and moves the remainder into the supplied
131    /// menu. Without a menu this declaration hides nothing.
132    pub fn overflow_after(mut self, count: usize) -> Self {
133        self.overflow_after = Some(count);
134        self
135    }
136
137    /// Supplies the caller-owned menu that can hold overflowed anchors.
138    /// The menu reports an overflowed anchor through
139    /// [`crate::overlay::MenuEvent::Invoked`] with the same stable id that
140    /// `on_navigate` reports for inline and keyboard navigation.
141    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}