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#[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#[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 pub fn href(mut self, href: impl Into<SharedString>) -> Self {
74 self.href = Some(href.into());
75 self
76 }
77
78 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 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 pub fn disabled(mut self, disabled: bool) -> Self {
100 self.disabled = disabled;
101 self
102 }
103
104 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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
119 self.accessibility_label = Some(label.into());
120 self
121 }
122
123 pub fn tab_index(mut self, tab_index: isize) -> Self {
125 self.tab_index = tab_index;
126 self
127 }
128
129 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}