Skip to main content

gpui_base/
link.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5    IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6    Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
7    prelude::FluentBuilder as _,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _, a11y_disabled::A11yDisabledExt as _};
12
13type ActivationHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
14type OpenHandler = Rc<dyn Fn(&str, &ClickEvent, &mut Window, &mut App)>;
15
16/// An unstyled link that owns focus, activation, and accessibility semantics.
17///
18/// `href` is target data, not an instruction to launch a browser. Applications
19/// inject navigation through [`Link::open_with`], allowing internal routes,
20/// embedded web views, and external browsers to share the same behavior module.
21/// `href` is also not rendered as text; applications provide visible content
22/// through the element's child slot.
23#[derive(IntoElement)]
24pub struct Link {
25    id: ElementId,
26    base: Stateful<Div>,
27    style: StyleRefinement,
28    semantic_styles: LinkStyles,
29    href: Option<SharedString>,
30    disabled: bool,
31    children: SmallVec<[AnyElement; 2]>,
32    on_activate: Option<ActivationHandler>,
33    open_with: Option<OpenHandler>,
34    accessibility_label: Option<SharedString>,
35    tab_index: isize,
36    tab_stop: bool,
37}
38
39/// Semantic root styles supported by [`Link`].
40#[derive(Default)]
41pub struct LinkStyles {
42    disabled: StyleRefinement,
43}
44
45impl LinkStyles {
46    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
47        self.disabled
48            .refine(&build(StateStyle::default()).into_refinement());
49        self
50    }
51}
52
53impl Link {
54    pub fn new(id: impl Into<ElementId>) -> Self {
55        let id = id.into();
56        Self {
57            base: div().id(id.clone()),
58            id,
59            style: StyleRefinement::default(),
60            semantic_styles: LinkStyles::default(),
61            href: None,
62            disabled: false,
63            children: SmallVec::new(),
64            on_activate: None,
65            open_with: None,
66            accessibility_label: None,
67            tab_index: 0,
68            tab_stop: true,
69        }
70    }
71
72    /// Sets the application-defined navigation target.
73    pub fn href(mut self, href: impl Into<SharedString>) -> Self {
74        self.href = Some(href.into());
75        self
76    }
77
78    /// Injects the strategy used to open `href` on activation.
79    ///
80    /// Base never calls [`App::open_url`] on its own.
81    pub fn open_with(
82        mut self,
83        open: impl Fn(&str, &ClickEvent, &mut Window, &mut App) + 'static,
84    ) -> Self {
85        self.open_with = Some(Rc::new(open));
86        self
87    }
88
89    /// Observes pointer or keyboard activation after the open strategy runs.
90    pub fn on_activate(
91        mut self,
92        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
93    ) -> Self {
94        self.on_activate = Some(Rc::new(handler));
95        self
96    }
97
98    /// Sets whether pointer and keyboard activation are ignored.
99    pub fn disabled(mut self, disabled: bool) -> Self {
100        self.disabled = disabled;
101        self
102    }
103
104    /// Configures application-owned styles for the link's semantic states.
105    pub fn styles(mut self, build: impl FnOnce(LinkStyles) -> LinkStyles) -> Self {
106        self.semantic_styles = build(self.semantic_styles);
107        self
108    }
109
110    fn resolved_style(&self) -> StyleRefinement {
111        crate::state_style::resolve_style(
112            &self.style,
113            self.disabled.then_some(&self.semantic_styles.disabled),
114        )
115    }
116
117    /// Sets the name exposed to accessibility clients.
118    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
119        self.accessibility_label = Some(label.into());
120        self
121    }
122
123    /// Sets the focus traversal index. Use this within a GPUI tab group.
124    pub fn tab_index(mut self, tab_index: isize) -> Self {
125        self.tab_index = tab_index;
126        self
127    }
128
129    /// Sets whether the link participates in keyboard focus traversal.
130    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
131        self.tab_stop = tab_stop;
132        self
133    }
134
135    fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
136        window
137            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
138            .read(cx)
139            .clone()
140    }
141}
142
143impl Styled for Link {
144    fn style(&mut self) -> &mut StyleRefinement {
145        &mut self.style
146    }
147}
148
149impl ParentElement for Link {
150    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
151        self.children.extend(elements);
152    }
153}
154
155impl InteractiveElement for Link {
156    fn interactivity(&mut self) -> &mut Interactivity {
157        self.base.interactivity()
158    }
159}
160
161impl StatefulInteractiveElement for Link {}
162
163impl RenderOnce for Link {
164    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
165        let focus_handle = self.focus_handle(window, cx);
166        let disabled = self.disabled;
167        let style = self.resolved_style();
168        let href = self.href;
169        let open_with = self.open_with;
170        let on_activate = self.on_activate;
171        let activates = on_activate.is_some() || href.is_some() && open_with.is_some();
172
173        self.base
174            .role(Role::Link)
175            .when_some(self.accessibility_label, |this, label| {
176                this.aria_label(label)
177            })
178            .when(!disabled, |this| {
179                this.track_focus(
180                    &focus_handle
181                        .tab_index(self.tab_index)
182                        .tab_stop(self.tab_stop),
183                )
184            })
185            .when(disabled, |this| {
186                this.on_mouse_down(MouseButton::Left, |_, _, cx| {
187                    cx.stop_propagation();
188                })
189            })
190            .when(!disabled && activates, |this| {
191                this.on_click(move |event, window, cx| {
192                    if let (Some(href), Some(open)) = (&href, &open_with) {
193                        open(href.as_ref(), event, window, cx);
194                    }
195                    if let Some(on_activate) = &on_activate {
196                        on_activate(event, window, cx);
197                    }
198                })
199            })
200            .children(self.children)
201            .refine_style(&style)
202            .a11y_disabled(disabled)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::{
210        cell::{Cell, RefCell},
211        rc::Rc,
212        sync::{Arc, Mutex},
213    };
214
215    use gpui::{
216        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
217        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
218    };
219
220    struct LinkHarness {
221        disabled: bool,
222        activations: Rc<Cell<usize>>,
223        keyboard_events: Rc<Cell<usize>>,
224        opened: Rc<RefCell<Vec<String>>>,
225        parent_clicks: Rc<Cell<usize>>,
226    }
227
228    impl Render for LinkHarness {
229        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
230            let activations = self.activations.clone();
231            let keyboard_events = self.keyboard_events.clone();
232            let opened = self.opened.clone();
233            let parent_clicks = self.parent_clicks.clone();
234
235            div()
236                .id("link-parent")
237                .tab_group()
238                .size(px(100.))
239                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
240                .child(
241                    Link::new("link")
242                        .href("app://settings")
243                        .disabled(self.disabled)
244                        .size_full()
245                        .open_with(move |href, _, _, _| {
246                            opened.borrow_mut().push(href.to_owned());
247                        })
248                        .on_activate(move |event, _, _| {
249                            activations.set(activations.get() + 1);
250                            if matches!(event, ClickEvent::Keyboard(_)) {
251                                keyboard_events.set(keyboard_events.get() + 1);
252                            }
253                        }),
254                )
255        }
256    }
257
258    fn harness(
259        cx: &mut TestAppContext,
260        disabled: bool,
261    ) -> (
262        &mut VisualTestContext,
263        Rc<Cell<usize>>,
264        Rc<Cell<usize>>,
265        Rc<RefCell<Vec<String>>>,
266        Rc<Cell<usize>>,
267    ) {
268        let activations = Rc::new(Cell::new(0));
269        let keyboard_events = Rc::new(Cell::new(0));
270        let opened = Rc::new(RefCell::new(Vec::new()));
271        let parent_clicks = Rc::new(Cell::new(0));
272        let (_, cx) = cx.add_window_view({
273            let activations = activations.clone();
274            let keyboard_events = keyboard_events.clone();
275            let opened = opened.clone();
276            let parent_clicks = parent_clicks.clone();
277            move |_, _| LinkHarness {
278                disabled,
279                activations,
280                keyboard_events,
281                opened,
282                parent_clicks,
283            }
284        });
285        cx.update(|window, cx| window.draw(cx).clear(cx));
286        (cx, activations, keyboard_events, opened, parent_clicks)
287    }
288
289    fn activate_key(cx: &mut VisualTestContext, key: &str) {
290        let keystroke =
291            cx.update(|_, cx| Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
292        cx.simulate_event(KeyDownEvent {
293            keystroke: keystroke.clone(),
294            is_held: false,
295            prefer_character_input: false,
296        });
297        cx.simulate_event(KeyUpEvent { keystroke });
298    }
299
300    #[gpui::test]
301    fn pointer_runs_injected_open_strategy_and_activation_once(cx: &mut TestAppContext) {
302        let (cx, activations, keyboard_events, opened, _) = harness(cx, false);
303
304        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
305
306        assert_eq!(activations.get(), 1);
307        assert_eq!(keyboard_events.get(), 0);
308        assert_eq!(&*opened.borrow(), &["app://settings"]);
309        assert_eq!(cx.opened_url(), None);
310    }
311
312    #[gpui::test]
313    fn open_strategy_runs_before_activation_callback(cx: &mut TestAppContext) {
314        struct OrderedLink(Rc<RefCell<Vec<&'static str>>>);
315
316        impl Render for OrderedLink {
317            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
318                let opened = self.0.clone();
319                let activated = self.0.clone();
320                Link::new("ordered-link")
321                    .href("app://ordered")
322                    .size(px(100.))
323                    .open_with(move |_, _, _, _| opened.borrow_mut().push("open"))
324                    .on_activate(move |_, _, _| activated.borrow_mut().push("activate"))
325            }
326        }
327
328        let order = Rc::new(RefCell::new(Vec::new()));
329        let result = order.clone();
330        let (_, cx) = cx.add_window_view(move |_, _| OrderedLink(order));
331        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
332
333        assert_eq!(result.borrow().as_slice(), &["open", "activate"]);
334    }
335
336    #[gpui::test]
337    fn enter_and_space_each_activate_once(cx: &mut TestAppContext) {
338        let (cx, activations, keyboard_events, opened, _) = harness(cx, false);
339        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
340        activations.set(0);
341        opened.borrow_mut().clear();
342        cx.update(|window, cx| {
343            assert!(window.focused(cx).is_some());
344            window.draw(cx).clear(cx);
345        });
346
347        activate_key(cx, "enter");
348        activate_key(cx, "space");
349
350        assert_eq!(activations.get(), 2);
351        assert_eq!(keyboard_events.get(), 2);
352        assert_eq!(&*opened.borrow(), &["app://settings", "app://settings"]);
353        assert_eq!(cx.opened_url(), None);
354    }
355
356    #[gpui::test]
357    fn href_without_strategy_never_opens_externally(cx: &mut TestAppContext) {
358        struct TargetOnly;
359
360        impl Render for TargetOnly {
361            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
362                Link::new("target-only")
363                    .href("https://example.com")
364                    .size(px(100.))
365            }
366        }
367
368        let (_, cx) = cx.add_window_view(|_, _| TargetOnly);
369        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
370        assert_eq!(cx.opened_url(), None);
371    }
372
373    #[gpui::test]
374    fn disabled_link_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
375        let (cx, activations, _, opened, parent_clicks) = harness(cx, true);
376
377        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
378        activate_key(cx, "enter");
379        activate_key(cx, "space");
380
381        assert_eq!(activations.get(), 0);
382        assert!(opened.borrow().is_empty());
383        assert_eq!(parent_clicks.get(), 0);
384    }
385
386    #[test]
387    fn visual_state_styles_remain_application_owned() {
388        let _ = Link::new("states")
389            .styles(|styles| styles.disabled(|style| style.opacity(0.5)))
390            .hover(|style| style.opacity(0.9))
391            .active(|style| style.opacity(0.8))
392            .focus_visible(|style| style.opacity(0.7));
393    }
394
395    #[test]
396    fn disabled_style_applies_only_while_disabled_and_then_wins() {
397        let enabled = Link::new("enabled")
398            .opacity(0.9)
399            .styles(|styles| styles.disabled(|style| style.opacity(0.5)));
400        assert_eq!(enabled.resolved_style().opacity, Some(0.9));
401
402        let disabled = Link::new("disabled")
403            .styles(|styles| styles.disabled(|style| style.opacity(0.5)))
404            .opacity(0.9)
405            .disabled(true);
406        assert_eq!(disabled.resolved_style().opacity, Some(0.5));
407
408        let semantic_only = Link::new("semantic-only")
409            .styles(|styles| styles.disabled(|style| style.opacity(0.5)))
410            .disabled(true);
411        assert_eq!(semantic_only.resolved_style().opacity, Some(0.5));
412    }
413
414    #[gpui::test]
415    fn accessibility_exposes_link_role_label_and_action_surface(cx: &mut TestAppContext) {
416        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
417
418        struct A11yProbe {
419            captured: Captured,
420        }
421
422        impl Render for A11yProbe {
423            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
424                let captured = self.captured.clone();
425                canvas(
426                    move |_, window, cx| {
427                        let mut info = |link: Link| {
428                            let mut node = accesskit::Node::new(Role::Link);
429                            link.render(window, cx)
430                                .into_element()
431                                .write_a11y_info(&mut node);
432                            node
433                        };
434                        let enabled = info(
435                            Link::new("enabled")
436                                .href("app://settings")
437                                .accessibility_label("Settings")
438                                .open_with(|_, _, _, _| {}),
439                        );
440                        let disabled = info(
441                            Link::new("disabled")
442                                .disabled(true)
443                                .accessibility_label("Settings")
444                                .on_activate(|_, _, _| {}),
445                        );
446                        *captured.lock().unwrap() = Some((enabled, disabled));
447                    },
448                    |_, _, _, _| {},
449                )
450            }
451        }
452
453        let captured: Captured = Arc::new(Mutex::new(None));
454        let result = captured.clone();
455        let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
456        cx.update(|window, cx| window.draw(cx).clear(cx));
457        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
458
459        assert_eq!(enabled.role(), Role::Link);
460        assert_eq!(enabled.label(), Some("Settings"));
461        assert!(enabled.supports_action(accesskit::Action::Click));
462        assert_eq!(enabled.url(), None);
463
464        assert_eq!(disabled.role(), Role::Link);
465        assert!(!disabled.supports_action(accesskit::Action::Click));
466        assert!(!enabled.is_disabled());
467        assert!(disabled.is_disabled());
468    }
469}