Skip to main content

fui/controls/
nav_link.rs

1use super::*;
2use crate::bindings::ui;
3use crate::event::{PointerButton, PointerType};
4use crate::ffi::{CursorStyle, HandleValue, SemanticRole};
5use crate::navigation;
6use crate::node::{text, NodeHandle, NodeRef, WeakFlexBox};
7use crate::platform;
8use crate::theme::{current_theme, subscribe};
9use crate::{focus_adorner, focus_visibility};
10use std::cell::{Cell, RefCell};
11use std::rc::Rc;
12
13thread_local! {
14    static ACTIVE_PREVIEW_OWNER: Cell<u64> = const { Cell::new(HandleValue::Invalid as u64) };
15}
16
17fn is_middle_mouse_button(event: &PointerEventArgs) -> bool {
18    event.pointer_type == PointerType::Mouse && event.button == PointerButton::Auxiliary
19}
20
21fn is_activation_pointer(event: &PointerEventArgs) -> bool {
22    event.button == PointerButton::Primary
23        || is_middle_mouse_button(event)
24        || event.pointer_type == PointerType::Touch
25        || event.pointer_type == PointerType::Pen
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct NavigateEventArgs {
30    pub path: String,
31}
32
33type NavigateCallback = Rc<dyn Fn(NavigateEventArgs)>;
34
35#[derive(Clone)]
36pub struct NavLink {
37    root: FlexBox,
38    label: TextNode,
39    href: Rc<RefCell<String>>,
40    open_in_new_tab: Rc<Cell<bool>>,
41    navigate: Rc<RefCell<Option<NavigateCallback>>>,
42    hovered: Rc<Cell<bool>>,
43    focused: Rc<Cell<bool>>,
44    pointer_pressed: Rc<Cell<bool>>,
45    pointer_pressed_open_in_new_tab: Rc<Cell<bool>>,
46    enter_pressed: Rc<Cell<bool>>,
47    enter_pressed_open_in_new_tab: Rc<Cell<bool>>,
48    preview_pinned_for_context_menu: Rc<Cell<bool>>,
49    text_color_override: Rc<Cell<Option<u32>>>,
50}
51
52impl NavLink {
53    pub fn new(href: impl Into<String>) -> Self {
54        let href = href.into();
55        Self::with_label(href.clone(), href)
56    }
57
58    pub fn with_label(href: impl Into<String>, label: impl Into<String>) -> Self {
59        let href = href.into();
60        let label = label.into();
61        let root = row();
62        let label_node = text(&label);
63        let theme = current_theme();
64        label_node
65            .font_family(theme.fonts.body_family.clone())
66            .font_size(15.0)
67            .text_color(theme.colors.accent)
68            .selectable(false)
69            .cursor(CursorStyle::Pointer);
70        root.justify_content(JustifyContent::Start)
71            .align_items(AlignItems::Center)
72            .interactive(true)
73            .focusable(true, 0)
74            .cursor(CursorStyle::Pointer)
75            .semantic_role(SemanticRole::Link)
76            .semantic_label(label)
77            .reflect_semantic_disabled_from_enabled()
78            .child(&label_node);
79        root.retained_node_ref()
80            .set_link_url_for_routing(Some(href.clone()));
81
82        let link = Self {
83            root,
84            label: label_node,
85            href: Rc::new(RefCell::new(href)),
86            open_in_new_tab: Rc::new(Cell::new(false)),
87            navigate: Rc::new(RefCell::new(None)),
88            hovered: Rc::new(Cell::new(false)),
89            focused: Rc::new(Cell::new(false)),
90            pointer_pressed: Rc::new(Cell::new(false)),
91            pointer_pressed_open_in_new_tab: Rc::new(Cell::new(false)),
92            enter_pressed: Rc::new(Cell::new(false)),
93            enter_pressed_open_in_new_tab: Rc::new(Cell::new(false)),
94            preview_pinned_for_context_menu: Rc::new(Cell::new(false)),
95            text_color_override: Rc::new(Cell::new(None)),
96        };
97        let node_ref = link.root.retained_node_ref();
98        let pin_target = link.event_target();
99        let release_target = link.event_target();
100        node_ref.set_link_preview_handlers_for_routing(
101            Some(Rc::new(move || {
102                pin_target.pin_preview_for_context_menu();
103            })),
104            Some(Rc::new(move || {
105                release_target.release_preview_for_context_menu();
106            })),
107        );
108        link.install_subscriptions();
109        link.bind_events();
110        link.sync_visual_state();
111        link.sync_focus_chrome();
112        link
113    }
114
115    fn bind_events(&self) {
116        let target = self.event_target();
117        self.root.on_pointer_enter(move |_event| {
118            target.hovered.set(true);
119            target.sync_visual_state();
120            target.show_preview();
121        });
122        let target = self.event_target();
123        self.root.on_pointer_leave(move |_event| {
124            target.hovered.set(false);
125            target.pointer_pressed.set(false);
126            target.pointer_pressed_open_in_new_tab.set(false);
127            target.sync_visual_state();
128            if !target.focused.get() && !target.preview_pinned_for_context_menu.get() {
129                target.hide_preview();
130            }
131        });
132        let target = self.event_target();
133        self.root.on_pointer_down(move |event| {
134            if !is_activation_pointer(event) {
135                target.pointer_pressed.set(false);
136                target.pointer_pressed_open_in_new_tab.set(false);
137                return;
138            }
139            target.pointer_pressed.set(true);
140            target.pointer_pressed_open_in_new_tab.set(
141                is_middle_mouse_button(event) || target.should_open_in_new_tab(event.modifiers),
142            );
143            event.handled = true;
144        });
145        let target = self.event_target();
146        self.root.on_pointer_up(move |event| {
147            if target.pointer_pressed.replace(false) && is_activation_pointer(event) {
148                let open_in_new_tab = target.pointer_pressed_open_in_new_tab.get()
149                    || is_middle_mouse_button(event)
150                    || target.should_open_in_new_tab(event.modifiers);
151                target.activate(open_in_new_tab);
152                event.handled = true;
153            }
154            target.pointer_pressed_open_in_new_tab.set(false);
155        });
156        let target = self.event_target();
157        self.root.on_key_down(move |event| {
158            if !target.should_handle_enter_key(event.modifiers) || event.key != "Enter" {
159                return;
160            }
161            if target.enter_pressed.get() {
162                event.handled = true;
163                return;
164            }
165            target.enter_pressed.set(true);
166            target
167                .enter_pressed_open_in_new_tab
168                .set(target.should_open_in_new_tab(event.modifiers));
169            event.handled = true;
170        });
171        let target = self.event_target();
172        self.root.on_key_up(move |event| {
173            if !target.should_handle_enter_key(event.modifiers) || event.key != "Enter" {
174                return;
175            }
176            if target.enter_pressed.replace(false) {
177                let open_in_new_tab = target.enter_pressed_open_in_new_tab.get()
178                    || target.should_open_in_new_tab(event.modifiers);
179                target.activate(open_in_new_tab);
180                event.handled = true;
181            }
182            target.enter_pressed_open_in_new_tab.set(false);
183        });
184        let target = self.event_target();
185        self.root.on_focus_changed(move |event| {
186            target.focused.set(event.focused);
187            if !event.focused {
188                target.enter_pressed.set(false);
189                target.enter_pressed_open_in_new_tab.set(false);
190            }
191            target.sync_focus_chrome();
192            if event.focused {
193                target.show_preview();
194            } else if !target.hovered.get() && !target.preview_pinned_for_context_menu.get() {
195                target.hide_preview();
196            }
197        });
198    }
199
200    fn install_subscriptions(&self) {
201        let target = self.event_target();
202        let theme_guard = subscribe(move |_theme| {
203            target.sync_visual_state();
204            target.sync_focus_chrome();
205        });
206        self.root
207            .retained_node_ref()
208            .retain_attachment(Rc::new(theme_guard));
209        let target = self.event_target();
210        let focus_guard = focus_visibility::subscribe(move |_visible| {
211            target.sync_focus_chrome();
212        });
213        self.root
214            .retained_node_ref()
215            .retain_attachment(Rc::new(focus_guard));
216    }
217
218    fn event_target(&self) -> NavLinkEventTarget {
219        NavLinkEventTarget {
220            weak_root: self.root.downgrade(),
221            label: self.label.clone(),
222            href: self.href.clone(),
223            open_in_new_tab: self.open_in_new_tab.clone(),
224            navigate: self.navigate.clone(),
225            hovered: self.hovered.clone(),
226            focused: self.focused.clone(),
227            pointer_pressed: self.pointer_pressed.clone(),
228            pointer_pressed_open_in_new_tab: self.pointer_pressed_open_in_new_tab.clone(),
229            enter_pressed: self.enter_pressed.clone(),
230            enter_pressed_open_in_new_tab: self.enter_pressed_open_in_new_tab.clone(),
231            preview_pinned_for_context_menu: self.preview_pinned_for_context_menu.clone(),
232            text_color_override: self.text_color_override.clone(),
233        }
234    }
235
236    fn set_explicit_font_family(&self, family: crate::FontFamily) {
237        self.label.font_family(family);
238    }
239
240    fn set_explicit_font_size(&self, size: f32) {
241        self.label.font_size(size);
242    }
243
244    fn set_explicit_text_color(&self, color: u32) {
245        self.text_color_override.set(Some(color));
246        self.label.text_color(color);
247        self.sync_visual_state();
248    }
249
250    pub fn href(&self) -> String {
251        self.href.borrow().clone()
252    }
253
254    pub fn href_to(&self, href: impl Into<String>) -> &Self {
255        let href = href.into();
256        *self.href.borrow_mut() = href.clone();
257        self.root
258            .retained_node_ref()
259            .set_link_url_for_routing(Some(href));
260        if self.focused.get() || self.hovered.get() || self.preview_pinned_for_context_menu.get() {
261            self.event_target().show_preview();
262        }
263        self
264    }
265
266    pub fn text(&self, value: impl Into<String>) -> &Self {
267        let value = value.into();
268        self.label.text(value.clone());
269        self.root.semantic_label(value);
270        self
271    }
272
273    pub fn label_node(&self) -> TextNode {
274        self.label.clone()
275    }
276
277    pub fn open_in_new_tab(&self, open: bool) -> &Self {
278        self.open_in_new_tab.set(open);
279        self
280    }
281
282    pub fn on_navigate(&self, handler: impl Fn(NavigateEventArgs) + 'static) -> &Self {
283        *self.navigate.borrow_mut() = Some(Rc::new(handler));
284        self
285    }
286
287    fn sync_visual_state(&self) {
288        self.event_target().sync_visual_state();
289    }
290
291    fn sync_focus_chrome(&self) {
292        self.event_target().sync_focus_chrome();
293    }
294}
295
296impl Node for NavLink {
297    fn retained_node_ref(&self) -> NodeRef {
298        self.root.retained_node_ref()
299    }
300
301    fn build_self(&self) {
302        self.root.build_self();
303        self.sync_visual_state();
304    }
305}
306
307impl HasFlexBoxRoot for NavLink {
308    fn flex_box_root(&self) -> &FlexBox {
309        &self.root
310    }
311}
312
313impl ThemeBindable for NavLink {
314    fn theme_binding_node(&self) -> NodeRef {
315        self.root.retained_node_ref()
316    }
317
318    fn weak_theme_target(&self) -> Box<dyn Fn() -> Option<Self>> {
319        let target = self.event_target();
320        Box::new(move || target.upgrade())
321    }
322}
323
324impl LabeledControlTextStyle for NavLink {
325    fn set_label_font_family(&self, family: crate::FontFamily) {
326        self.set_explicit_font_family(family);
327    }
328
329    fn set_label_font_size(&self, size: f32) {
330        self.set_explicit_font_size(size);
331    }
332
333    fn set_label_text_color(&self, color: u32) {
334        self.set_explicit_text_color(color);
335    }
336}
337
338#[derive(Clone)]
339struct NavLinkEventTarget {
340    weak_root: WeakFlexBox,
341    label: TextNode,
342    href: Rc<RefCell<String>>,
343    open_in_new_tab: Rc<Cell<bool>>,
344    navigate: Rc<RefCell<Option<NavigateCallback>>>,
345    hovered: Rc<Cell<bool>>,
346    focused: Rc<Cell<bool>>,
347    pointer_pressed: Rc<Cell<bool>>,
348    pointer_pressed_open_in_new_tab: Rc<Cell<bool>>,
349    enter_pressed: Rc<Cell<bool>>,
350    enter_pressed_open_in_new_tab: Rc<Cell<bool>>,
351    preview_pinned_for_context_menu: Rc<Cell<bool>>,
352    text_color_override: Rc<Cell<Option<u32>>>,
353}
354
355impl NavLinkEventTarget {
356    fn upgrade(&self) -> Option<NavLink> {
357        Some(NavLink {
358            root: self.weak_root.upgrade()?,
359            label: self.label.clone(),
360            href: self.href.clone(),
361            open_in_new_tab: self.open_in_new_tab.clone(),
362            navigate: self.navigate.clone(),
363            hovered: self.hovered.clone(),
364            focused: self.focused.clone(),
365            pointer_pressed: self.pointer_pressed.clone(),
366            pointer_pressed_open_in_new_tab: self.pointer_pressed_open_in_new_tab.clone(),
367            enter_pressed: self.enter_pressed.clone(),
368            enter_pressed_open_in_new_tab: self.enter_pressed_open_in_new_tab.clone(),
369            preview_pinned_for_context_menu: self.preview_pinned_for_context_menu.clone(),
370            text_color_override: self.text_color_override.clone(),
371        })
372    }
373
374    fn is_enabled(&self) -> bool {
375        self.weak_root
376            .upgrade()
377            .map(|root| root.retained_node_ref().is_enabled_for_routing())
378            .unwrap_or(false)
379    }
380
381    fn activate(&self, open_in_new_tab: bool) {
382        let href = self.href.borrow().clone();
383        navigation::navigate_to(&href, open_in_new_tab);
384        if let Some(callback) = self.navigate.borrow().clone() {
385            callback(NavigateEventArgs { path: href });
386        }
387    }
388
389    fn should_open_in_new_tab(&self, modifiers: u32) -> bool {
390        self.open_in_new_tab.get() || platform::has_primary_shortcut_modifier(modifiers)
391    }
392
393    fn should_handle_enter_key(&self, modifiers: u32) -> bool {
394        self.is_enabled() && (modifiers == 0 || platform::has_primary_shortcut_modifier(modifiers))
395    }
396
397    fn show_preview(&self) {
398        let Some(root) = self.weak_root.upgrade() else {
399            return;
400        };
401        let handle = root.handle().raw();
402        if handle == HandleValue::Invalid as u64 {
403            return;
404        }
405        let href = self.href.borrow().clone();
406        let bytes = href.as_bytes();
407        unsafe {
408            crate::ffi::fui_show_url_preview(
409                if bytes.is_empty() {
410                    0
411                } else {
412                    bytes.as_ptr() as usize
413                },
414                bytes.len() as u32,
415            )
416        };
417        ACTIVE_PREVIEW_OWNER.with(|owner| owner.set(handle));
418    }
419
420    fn hide_preview(&self) {
421        let Some(root) = self.weak_root.upgrade() else {
422            return;
423        };
424        let handle = root.handle().raw();
425        if !ACTIVE_PREVIEW_OWNER.with(|owner| owner.get() == handle) {
426            return;
427        }
428        ACTIVE_PREVIEW_OWNER.with(|owner| owner.set(HandleValue::Invalid as u64));
429        unsafe { crate::ffi::fui_hide_url_preview() };
430    }
431
432    fn pin_preview_for_context_menu(&self) {
433        self.preview_pinned_for_context_menu.set(true);
434        self.show_preview();
435    }
436
437    fn release_preview_for_context_menu(&self) {
438        if !self.preview_pinned_for_context_menu.replace(false) {
439            return;
440        }
441        if !self.hovered.get() && !self.focused.get() {
442            self.hide_preview();
443        }
444    }
445
446    fn sync_visual_state(&self) {
447        let theme = current_theme();
448        let color = if self.hovered.get() {
449            theme.colors.accent_hovered
450        } else {
451            self.text_color_override
452                .get()
453                .unwrap_or(theme.colors.accent)
454        };
455        if self.label.handle() != NodeHandle::INVALID {
456            ui::set_text_color(self.label.handle().raw(), color);
457        }
458    }
459
460    fn sync_focus_chrome(&self) {
461        let Some(root) = self.weak_root.upgrade() else {
462            return;
463        };
464        if self.focused.get() && self.is_enabled() && focus_visibility::keyboard_focus_visible() {
465            focus_adorner::show_standard(&root, current_theme().spacing.sm);
466            return;
467        }
468        focus_adorner::hide_owner(&root);
469    }
470}