Skip to main content

ravenrs_widgets/
links.rs

1//! Opening addresses and jumping to anchors, as a service the platform layer provides,
2//! and [`Link`], a piece of text that opens one.
3
4use std::cell::RefCell;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Color, CursorIcon, Insets, Role};
9use ravenrs_layout::{Align, Axis, LayoutStyle};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::{IconName, View, util};
14
15/// Opens an address outside the app. Platform layers install one with
16/// [`crate::Ui::set_links`]: the browser navigates, the desktop asks the system's browser.
17pub trait LinkOpener {
18    /// Opens `url`, absolute or relative to the page.
19    fn open(&self, url: &str);
20}
21
22/// Keeps the addresses it is asked to open, for tests and headless use.
23#[derive(Debug, Default)]
24struct Recorder;
25
26impl LinkOpener for Recorder {
27    fn open(&self, url: &str) {
28        tracing::info!(url, "no link opener installed");
29    }
30}
31
32/// A shared handle to the links service, from [`crate::BuildCx::links`] or
33/// [`crate::Ui::links`]. An address that starts with `#` scrolls to the anchor of that
34/// name ([`crate::BuildCx::anchor`]) instead of leaving the app.
35///
36/// ```
37/// use ravenrs_widgets::Links;
38///
39/// let links = Links::default();
40/// links.open("https://example.com/docs");
41/// assert_eq!(links.opened(), ["https://example.com/docs"]);
42/// ```
43#[derive(Clone, Default)]
44#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
45pub struct Links {
46    opener: Rc<RefCell<Option<Rc<dyn LinkOpener>>>>,
47    anchor: Rc<RefCell<Option<String>>>,
48    opened: Rc<RefCell<Vec<String>>>,
49}
50
51#[cfg_attr(feature = "live", ravenrs_live::expose)]
52impl Links {
53    /// Opens `url`. `#name` scrolls to that anchor on the next frame.
54    pub fn open(&self, url: &str) {
55        if let Some(name) = url.strip_prefix('#') {
56            *self.anchor.borrow_mut() = Some(name.to_owned());
57            return;
58        }
59        self.opened.borrow_mut().push(url.to_owned());
60        let opener = self.opener.borrow().clone().unwrap_or_else(|| Rc::new(Recorder));
61        opener.open(url);
62    }
63
64    /// Every address opened outside the app so far, oldest first.
65    pub fn opened(&self) -> Vec<String> {
66        self.opened.borrow().clone()
67    }
68
69    pub(crate) fn set_opener(&self, opener: impl LinkOpener + 'static) {
70        *self.opener.borrow_mut() = Some(Rc::new(opener));
71    }
72
73    /// The anchor a link asked for, once.
74    pub(crate) fn take_anchor(&self) -> Option<String> {
75        self.anchor.borrow_mut().take()
76    }
77}
78
79impl fmt::Debug for Links {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.debug_struct("Links").field("opened", &self.opened.borrow().len()).finish_non_exhaustive()
82    }
83}
84
85/// Text that opens an address when clicked or activated with the keyboard. `#name`
86/// scrolls to an anchor in the app.
87#[derive(Debug)]
88#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
89pub struct Link {
90    label: String,
91    url: String,
92    style: TextStyle,
93    icon: Option<IconName>,
94    muted: bool,
95}
96
97#[cfg_attr(feature = "live", ravenrs_live::expose)]
98impl Link {
99    /// A link showing `label` that opens `url`.
100    pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
101        Self { label: label.into(), url: url.into(), style: TextStyle::Body, icon: None, muted: false }
102    }
103
104    /// The text style (body by default).
105    pub fn style(mut self, style: TextStyle) -> Self {
106        self.style = style;
107        self
108    }
109
110    /// An icon after the text, such as an arrow.
111    pub fn icon(mut self, icon: IconName) -> Self {
112        self.icon = Some(icon);
113        self
114    }
115
116    /// Muted at rest, for links among other links, such as a navigation bar.
117    pub fn muted(mut self) -> Self {
118        self.muted = true;
119        self
120    }
121}
122
123impl View for Link {
124    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
125        let theme = *cx.theme();
126        let lit = cx.is_hovered() || cx.is_focus_visible();
127        let ink = theme.color(Semantic::Ink);
128        let rest = if self.muted {
129            theme.color(Semantic::InkMuted)
130        } else {
131            // The accent moved toward the ink, so the text stays readable on every canvas.
132            theme.color(Semantic::Accent).lerp(ink, 0.4)
133        };
134        let color = if lit { ink } else { rest };
135        let mut parts =
136            vec![util::label(cx, &self.label, self.style, Some(ravenrs_style::Weight::SEMIBOLD), color)];
137        if let Some(icon) = self.icon {
138            parts.push(util::icon(cx, icon, 16.0, color));
139        }
140        let fill = if lit { ink.with_alpha(0.06) } else { Color::TRANSPARENT };
141        let node = cx.container(
142            LayoutStyle {
143                padding: Insets::symmetric(4.0, 6.0),
144                spacing: 6.0,
145                align: Align::Center,
146                align_self: Some(Align::Start),
147                ..LayoutStyle::stack(Axis::Horizontal)
148            },
149            &parts,
150            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
151        );
152        let (links, url) = (cx.links(), self.url.clone());
153        cx.on_click(node, Rc::new(move || links.open(&url)));
154        cx.semantics(node, Semantics::new(Role::Link).name(self.label.clone()));
155        cx.focusable(node, theme.radii.sm);
156        cx.cursor(node, CursorIcon::Pointer);
157        node
158    }
159}