Skip to main content

gpui_component/
link.rs

1use gpui::{
2    AnyElement, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
3    RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, div,
4};
5
6use crate::{ActiveTheme as _, StyledExt};
7
8/// A Link element like a `<a>` tag in HTML.
9#[derive(IntoElement)]
10pub struct Link {
11    id: ElementId,
12    style: StyleRefinement,
13    href: Option<SharedString>,
14    disabled: bool,
15    on_click: Option<Box<dyn Fn(&ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
16    children: Vec<AnyElement>,
17}
18
19impl Link {
20    /// Create a new Link element.
21    pub fn new(id: impl Into<ElementId>) -> Self {
22        Self {
23            id: id.into(),
24            style: StyleRefinement::default(),
25            href: None,
26            on_click: None,
27            disabled: false,
28            children: Vec::new(),
29        }
30    }
31
32    /// Set the href of the link.
33    pub fn href(mut self, href: impl Into<SharedString>) -> Self {
34        self.href = Some(href.into());
35        self
36    }
37
38    /// Set the click handler of the link.
39    ///
40    /// If this set, the handler will be called when the link is clicked.
41    /// Otherwise, the link will only open the href if set.
42    pub fn on_click(
43        mut self,
44        handler: impl Fn(&ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
45    ) -> Self {
46        self.on_click = Some(Box::new(handler));
47        self
48    }
49
50    /// Set the disabled state, default false.
51    pub fn disabled(mut self, disabled: bool) -> Self {
52        self.disabled = disabled;
53        self
54    }
55}
56
57impl Styled for Link {
58    fn style(&mut self) -> &mut gpui::StyleRefinement {
59        &mut self.style
60    }
61}
62
63impl ParentElement for Link {
64    fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
65        self.children.extend(elements)
66    }
67}
68
69impl RenderOnce for Link {
70    fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
71        let href = self.href.clone();
72        let on_click = self.on_click;
73
74        div()
75            .id(self.id)
76            .text_color(cx.theme().link)
77            .text_decoration_1()
78            .text_decoration_color(cx.theme().link.opacity(0.5))
79            .hover(|this| {
80                this.text_color(cx.theme().link.opacity(0.8))
81                    .text_decoration_1()
82                    .text_decoration_color(cx.theme().link)
83            })
84            .active(|this| {
85                this.text_color(cx.theme().link.opacity(0.6))
86                    .text_decoration_1()
87                    .text_decoration_color(cx.theme().link)
88            })
89            .cursor_pointer()
90            .refine_style(&self.style)
91            .on_mouse_down(MouseButton::Left, |_, _, cx| {
92                cx.stop_propagation();
93            })
94            .on_click({
95                move |e, window, cx| {
96                    if let Some(href) = &href {
97                        cx.open_url(&href.clone());
98                    }
99                    if let Some(on_click) = &on_click {
100                        on_click(e, window, cx);
101                    }
102                }
103            })
104            .children(self.children)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use std::{cell::Cell, rc::Rc};
111
112    use gpui::{Context, Modifiers, Render, TestAppContext, div, point, px};
113
114    use super::*;
115
116    struct LinkHarness {
117        disabled: bool,
118        clicks: Rc<Cell<usize>>,
119    }
120
121    impl Render for LinkHarness {
122        fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
123            let clicks = self.clicks.clone();
124            Link::new("legacy-link")
125                .href("https://example.com")
126                .disabled(self.disabled)
127                .size(px(100.))
128                .child(
129                    div()
130                        .debug_selector(|| "legacy-link-child".into())
131                        .child("Visible link"),
132                )
133                .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
134        }
135    }
136
137    #[gpui::test]
138    fn legacy_link_prepaints_children(cx: &mut TestAppContext) {
139        let (cx, _) = harness(cx, false);
140        let bounds = cx
141            .debug_bounds("legacy-link-child")
142            .expect("legacy Link child must participate in layout and prepaint");
143
144        assert!(bounds.size.width > px(0.));
145        assert!(bounds.size.height > px(0.));
146    }
147
148    fn harness(
149        cx: &mut TestAppContext,
150        disabled: bool,
151    ) -> (&mut gpui::VisualTestContext, Rc<Cell<usize>>) {
152        cx.update(crate::init);
153        let clicks = Rc::new(Cell::new(0));
154        let (_, cx) = cx.add_window_view({
155            let clicks = clicks.clone();
156            move |_, _| LinkHarness { disabled, clicks }
157        });
158        cx.update(|window, cx| window.draw(cx).clear(cx));
159        (cx, clicks)
160    }
161
162    #[gpui::test]
163    fn legacy_link_preserves_pointer_open_and_callback(cx: &mut TestAppContext) {
164        let (cx, clicks) = harness(cx, false);
165        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
166
167        assert_eq!(cx.opened_url().as_deref(), Some("https://example.com"));
168        assert_eq!(clicks.get(), 1);
169    }
170
171    #[gpui::test]
172    fn legacy_link_preserves_disabled_behavior(cx: &mut TestAppContext) {
173        let (cx, clicks) = harness(cx, true);
174        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
175
176        // `disabled` was historically a stored but behaviorally inert field.
177        assert_eq!(cx.opened_url().as_deref(), Some("https://example.com"));
178        assert_eq!(clicks.get(), 1);
179    }
180
181    #[gpui::test]
182    fn legacy_link_remains_pointer_only(cx: &mut TestAppContext) {
183        let (cx, clicks) = harness(cx, false);
184        cx.simulate_keystrokes("enter space");
185
186        assert_eq!(cx.opened_url(), None);
187        assert_eq!(clicks.get(), 0);
188    }
189}