Skip to main content

fui/controls/
nav_link.rs

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