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#[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 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 pub fn href(mut self, href: impl Into<SharedString>) -> Self {
34 self.href = Some(href.into());
35 self
36 }
37
38 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 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 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}