Skip to main content

zng_wgt_markdown/
resolvers.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use zng_ext_l10n::l10n;
6use zng_wgt::{prelude::*, *};
7
8use zng_ext_clipboard::{CLIPBOARD, COPY_CMD};
9use zng_ext_image::ImageSource;
10use zng_ext_input::focus::WidgetInfoFocusExt as _;
11use zng_ext_input::{focus::FOCUS, gesture::ClickArgs};
12use zng_wgt_button::Button;
13use zng_wgt_container::Container;
14use zng_wgt_fill::*;
15use zng_wgt_filter::*;
16use zng_wgt_input::focus::on_focus_leave;
17use zng_wgt_layer::{AnchorMode, AnchorOffset, LAYERS, LayerIndex};
18use zng_wgt_scroll::cmd::ScrollToMode;
19use zng_wgt_size_offset::*;
20use zng_wgt_text::{self as text, Text};
21
22use super::Markdown;
23
24use path_absolutize::*;
25
26use http::Uri;
27
28context_var! {
29    /// Markdown image resolver.
30    pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
31
32    /// Markdown link resolver.
33    pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
34
35    /// Scroll mode used by anchor links.
36    pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
37}
38
39/// Markdown image resolver.
40///
41/// This can be used to override image source resolution, by default the image URL or URI is passed as parsed to the [`image_fn`].
42///
43/// Note that image downloads are blocked by default, you can enable this by using the [`image::img_limits`] property.
44///
45/// Sets the [`IMAGE_RESOLVER_VAR`].
46///
47/// [`image_fn`]: fn@crate::image_fn
48/// [`image::img_limits`]: fn@zng_wgt_image::img_limits
49#[property(CONTEXT, default(IMAGE_RESOLVER_VAR), widget_impl(Markdown))]
50pub fn image_resolver(child: impl IntoUiNode, resolver: impl IntoVar<ImageResolver>) -> UiNode {
51    with_context_var(child, IMAGE_RESOLVER_VAR, resolver)
52}
53
54/// Markdown link resolver.
55///
56/// This can be used to expand or replace links.
57///
58/// Sets the [`LINK_RESOLVER_VAR`].
59#[property(CONTEXT, default(LINK_RESOLVER_VAR), widget_impl(Markdown))]
60pub fn link_resolver(child: impl IntoUiNode, resolver: impl IntoVar<LinkResolver>) -> UiNode {
61    with_context_var(child, LINK_RESOLVER_VAR, resolver)
62}
63
64/// Scroll-to mode used by anchor links.
65#[property(CONTEXT, default(LINK_SCROLL_MODE_VAR), widget_impl(Markdown))]
66pub fn link_scroll_mode(child: impl IntoUiNode, mode: impl IntoVar<ScrollToMode>) -> UiNode {
67    with_context_var(child, LINK_SCROLL_MODE_VAR, mode)
68}
69
70/// Markdown image resolver.
71///
72/// See [`IMAGE_RESOLVER_VAR`] for more details.
73#[derive(Clone, Default)]
74pub enum ImageResolver {
75    /// No extra resolution, just convert into [`ImageSource`].
76    ///
77    /// [`ImageSource`]: zng_ext_image::ImageSource
78    #[default]
79    Default,
80    /// Custom resolution.
81    Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
82}
83impl ImageResolver {
84    /// Resolve the image.
85    pub fn resolve(&self, img: &str) -> ImageSource {
86        match self {
87            ImageResolver::Default => img.into(),
88            ImageResolver::Resolve(r) => r(img),
89        }
90    }
91
92    /// New [`Resolve`](Self::Resolve).
93    pub fn new(fn_: impl Fn(&str) -> ImageSource + Send + Sync + 'static) -> Self {
94        ImageResolver::Resolve(Arc::new(fn_))
95    }
96}
97impl fmt::Debug for ImageResolver {
98    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
99        if f.alternate() {
100            write!(f, "ImgSourceResolver::")?;
101        }
102        match self {
103            ImageResolver::Default => write!(f, "Default"),
104            ImageResolver::Resolve(_) => write!(f, "Resolve(_)"),
105        }
106    }
107}
108impl PartialEq for ImageResolver {
109    fn eq(&self, other: &Self) -> bool {
110        match (self, other) {
111            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
112            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
113        }
114    }
115}
116
117/// Markdown link resolver.
118///
119/// See [`LINK_RESOLVER_VAR`] for more details.
120#[derive(Clone, Default)]
121pub enum LinkResolver {
122    /// No extra resolution, just pass the link provided.
123    #[default]
124    Default,
125    /// Custom resolution.
126    Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
127}
128impl LinkResolver {
129    /// Resolve the link.
130    pub fn resolve(&self, url: &str) -> Txt {
131        match self {
132            Self::Default => url.to_txt(),
133            Self::Resolve(r) => r(url),
134        }
135    }
136
137    /// New [`Resolve`](Self::Resolve).
138    pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
139        Self::Resolve(Arc::new(fn_))
140    }
141
142    /// Resolve file links relative to `base`.
143    ///
144    /// The path is also absolutized, but not canonicalized.
145    pub fn base_dir(base: impl Into<PathBuf>) -> Self {
146        let base = base.into();
147        Self::new(move |url| {
148            if !url.starts_with('#') {
149                let is_not_uri = url.parse::<Uri>().is_err();
150
151                if is_not_uri {
152                    let path = Path::new(url);
153                    if let Ok(path) = base.join(path).absolutize() {
154                        return path.display().to_txt();
155                    }
156                }
157            }
158            url.to_txt()
159        })
160    }
161}
162impl fmt::Debug for LinkResolver {
163    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
164        if f.alternate() {
165            write!(f, "LinkResolver::")?;
166        }
167        match self {
168            Self::Default => write!(f, "Default"),
169            Self::Resolve(_) => write!(f, "Resolve(_)"),
170        }
171    }
172}
173impl PartialEq for LinkResolver {
174    fn eq(&self, other: &Self) -> bool {
175        match (self, other) {
176            // can only fail by returning `false` in some cases where the value pointer is actually equal.
177            // see: https://github.com/rust-lang/rust/issues/103763
178            //
179            // we are fine with this, worst case is just an extra var update
180            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
181            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
182        }
183    }
184}
185
186event! {
187    /// Event raised by markdown links when clicked.
188    pub static LINK_EVENT: LinkArgs;
189}
190
191event_property! {
192    /// Markdown link click.
193    #[property(EVENT)]
194    pub fn on_link<on_pre_link>(child: impl IntoUiNode, handler: Handler<LinkArgs>) -> UiNode {
195        const PRE: bool;
196        EventNodeBuilder::new(LINK_EVENT).build::<PRE>(child, handler)
197    }
198}
199
200event_args! {
201    /// Arguments for the [`LINK_EVENT`].
202    pub struct LinkArgs {
203        /// Raw URL.
204        pub url: Txt,
205
206        /// Link widget.
207        pub link: InteractionPath,
208
209        ..
210
211        fn is_in_target(&self, id: WidgetId) -> bool {
212            self.link.contains(id)
213        }
214    }
215}
216
217/// Default markdown link action.
218///
219/// Does [`try_scroll_link`] or [`try_open_link`].
220pub fn try_default_link_action(args: &LinkArgs) -> bool {
221    try_scroll_link(args) || try_open_link(args)
222}
223
224/// Handle `url` in the format `#anchor`, by scrolling and focusing the anchor.
225///
226/// If the anchor is found scrolls to it and moves focus to the `#anchor` widget,
227/// or the first focusable descendant of it, or the markdown widget or the first focusable ancestor of it.
228///
229/// Note that the request is handled even if the anchor is not found.
230pub fn try_scroll_link(args: &LinkArgs) -> bool {
231    if args.propagation.is_stopped() {
232        return false;
233    }
234    // Note: file names can start with #, but we are choosing to always interpret URLs with this prefix as an anchor.
235    if let Some(anchor) = args.url.strip_prefix('#') {
236        let tree = WINDOW.info();
237        if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown()))
238            && let Some(target) = md.find_anchor(anchor)
239        {
240            // scroll-to
241            zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
242
243            // focus if target if focusable
244            if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
245                FOCUS.focus_widget(focus.info().id(), false);
246            }
247        }
248        args.propagation.stop();
249        return true;
250    }
251
252    false
253}
254
255/// Try open link, only works if the `url` is a full HTTP(S) URL or a dir/file path that exists,
256/// returns if the confirm tooltip is visible.
257///
258/// The popup will offer "Open in Browser" for HTTP links and "Reveal in File Manager" for file paths. The
259/// popup also offers alternative to copy the full link.
260pub fn try_open_link(args: &LinkArgs) -> bool {
261    if args.propagation.is_stopped() {
262        return false;
263    }
264
265    #[derive(Clone)]
266    enum Link {
267        Url(Uri),
268        Path(PathBuf),
269    }
270
271    let link = if let Ok(url) = args.url.parse::<Uri>()
272        && let Some(sc) = url.scheme()
273        && (sc == &http::uri::Scheme::HTTP || sc == &http::uri::Scheme::HTTPS)
274    {
275        Link::Url(url)
276    } else {
277        let path = PathBuf::from(args.url.as_str());
278        if !path.exists() {
279            return false;
280        }
281        Link::Path(path)
282    };
283
284    let popup_id = WidgetId::new_unique();
285
286    #[derive(Clone, Debug, PartialEq)]
287    enum Status {
288        Pending,
289        Ok,
290        Err,
291        Cancel,
292    }
293    let status = var(Status::Pending);
294
295    let open_time = INSTANT.now();
296
297    let popup = Container! {
298        id = popup_id;
299
300        padding = (2, 4);
301        corner_radius = 2;
302        drop_shadow = (2, 2), 2, colors::BLACK.with_alpha(50.pct());
303        align = Align::TOP_LEFT;
304
305        #[easing(200.ms())]
306        opacity = 0.pct();
307        #[easing(200.ms())]
308        offset = (0, -10);
309
310        background_color = light_dark(colors::WHITE.with_alpha(90.pct()), colors::BLACK.with_alpha(90.pct()));
311
312        when *#{status.clone()} == Status::Pending {
313            opacity = 100.pct();
314            offset = (0, 0);
315        }
316        when *#{status.clone()} == Status::Err {
317            background_color = light_dark(
318                web_colors::PINK.with_alpha(90.pct()),
319                web_colors::DARK_RED.with_alpha(90.pct()),
320            );
321        }
322
323        on_focus_leave = async_hn_once!(status, |_| {
324            if status.get() != Status::Pending {
325                return;
326            }
327
328            status.set(Status::Cancel);
329            task::deadline(200.ms()).await;
330
331            LAYERS.remove(popup_id);
332        });
333
334        child = Button! {
335            style_fn = zng_wgt_button::LightStyle!();
336
337            focus_on_init = true;
338
339            child = Text!(match &link {
340                Link::Url(_) => l10n!("try_open_link.open-url", "Open in Browser"),
341                Link::Path(_) => match std::env::consts::OS {
342                    "windows" => l10n!("try_open_link.reveal-path-windows", "Reveal in File Explorer"),
343                    "macos" => l10n!("try_open_link.reveal-path-macos", "Reveal in Finder"),
344                    _ => l10n!("try_open_link.reveal-path", "Reveal in File Manager"),
345                },
346            });
347            child_spacing = 3;
348            child_end = ICONS.get_or("arrow-outward", || Text!("🡵"));
349
350            text::underline_skip = text::UnderlineSkip::SPACES;
351
352            on_click = async_hn_once!(status, link, |args: &ClickArgs| {
353                if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
354                    return;
355                }
356
357                args.propagation.stop();
358
359                match link {
360                    Link::Url(u) => {
361                        let u = u.to_string();
362                        #[cfg(not(target_arch = "wasm32"))]
363                        {
364                            let r = task::wait(|| open::that_detached(u)).await;
365                            if let Err(e) = &r {
366                                tracing::error!("error opening url, {e}");
367                            }
368
369                            status.set(if r.is_ok() { Status::Ok } else { Status::Err });
370                        }
371                        #[cfg(target_arch = "wasm32")]
372                        {
373                            match web_sys::window() {
374                                Some(w) => match w.open_with_url_and_target(u.as_str(), "_blank") {
375                                    Ok(w) => match w {
376                                        Some(w) => {
377                                            let _ = w.focus();
378                                            status.set(Status::Ok);
379                                        }
380                                        None => {
381                                            tracing::error!("error opening url, no new tab/window");
382                                            status.set(Status::Err);
383                                        }
384                                    },
385                                    Err(e) => {
386                                        tracing::error!("error opening url, {e:?}");
387                                        status.set(Status::Err);
388                                    }
389                                },
390                                None => {
391                                    tracing::error!("error opening url, no window");
392                                    status.set(Status::Err);
393                                }
394                            }
395                        }
396                    }
397                    Link::Path(p) => match dunce::canonicalize(&p) {
398                        Ok(p) => {
399                            #[cfg(windows)]
400                            {
401                                let p = p.display().to_string();
402                                let p = p.replace('/', "\\");
403                                let r = std::process::Command::new("explorer").arg("/select,").arg(p).spawn();
404                                if let Err(e) = r {
405                                    tracing::error!("cannot spawn explorer to reveal path, {e}");
406                                    status.set(Status::Err);
407                                }
408                            }
409                            #[cfg(target_os = "macos")]
410                            {
411                                let r = std::process::Command::new("open").arg("-R").arg(p).spawn();
412                                if let Err(e) = r {
413                                    tracing::error!("cannot spawn reveal in finder, {e}");
414                                    status.set(Status::Err);
415                                }
416                            }
417                            #[cfg(target_os = "linux")]
418                            if let Err(e) = reveal_in_file_manager(format!("file://{}", p.display())).await {
419                                tracing::error!("cannot reveal in file manager, {e}\nwill try open parent folder");
420
421                                let parent = p.parent().unwrap_or(&p);
422                                let r = std::process::Command::new("xdg-open").arg(parent).spawn();
423                                if let Err(e) = r {
424                                    tracing::error!("cannot spawn xdg-open to reveal path, {e}");
425                                    status.set(Status::Err);
426                                }
427                            }
428                            #[cfg(target_arch = "wasm32")]
429                            {
430                                tracing::error!("cannot reveal path in wasm");
431                                status.set(Status::Err);
432                            }
433
434                            #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
435                            {
436                                let _ = p;
437                            }
438                        }
439                        Err(e) => {
440                            tracing::error!("error canonicalizing \"{}\", {e}", p.display());
441                            status.set(Status::Err);
442                        }
443                    },
444                }
445
446                task::deadline(200.ms()).await;
447                LAYERS.remove(popup_id);
448            });
449        };
450        child_end = Button! {
451            style_fn = zng_wgt_button::LightStyle!();
452            padding = 3;
453            child_spacing = 3;
454            child = Text!(match &link {
455                Link::Url(_) => l10n!("try_open_link.copy-url", "Copy Url"),
456                Link::Path(_) => l10n!("try_open_link.copy-path", "Copy Path"),
457            });
458            child_end = COPY_CMD.icon().present_data(());
459            on_click = async_hn_once!(status, |args: &ClickArgs| {
460                if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
461                    return;
462                }
463
464                args.propagation.stop();
465
466                let txt = match link {
467                    Link::Url(u) => u.to_txt(),
468                    Link::Path(p) => p.display().to_txt(),
469                };
470
471                let r = CLIPBOARD.set_text(txt.clone()).wait_rsp().await;
472                if let Err(e) = &r {
473                    tracing::error!("error copying uri, {e}");
474                }
475
476                status.set(if r.is_ok() { Status::Ok } else { Status::Err });
477                task::deadline(200.ms()).await;
478
479                LAYERS.remove(popup_id);
480            });
481        };
482    };
483
484    LAYERS.insert_anchored(
485        LayerIndex::ADORNER,
486        args.link.widget_id(),
487        AnchorMode::popup(AnchorOffset::out_bottom()),
488        popup,
489    );
490
491    true
492}
493
494static_id! {
495    static ref ANCHOR_ID: StateId<Txt>;
496    pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
497}
498
499/// Set a label that identifies the widget in the context of the parent markdown.
500///
501/// The anchor can be retried in the widget info using [`WidgetInfoExt::anchor`]. It is mostly used
502/// by markdown links to find scroll targets.
503#[property(CONTEXT, default(""))]
504pub fn anchor(child: impl IntoUiNode, anchor: impl IntoVar<Txt>) -> UiNode {
505    let anchor = anchor.into_var();
506    match_node(child, move |_, op| match op {
507        UiNodeOp::Init => {
508            WIDGET.sub_var_info(&anchor);
509        }
510        UiNodeOp::Info { info } => {
511            info.set_meta(*ANCHOR_ID, anchor.get());
512        }
513        _ => {}
514    })
515}
516
517/// Markdown extension methods for widget info.
518pub trait WidgetInfoExt {
519    /// Gets the [`anchor`].
520    ///
521    /// [`anchor`]: fn@anchor
522    fn anchor(&self) -> Option<&Txt>;
523
524    /// If this widget is a [`Markdown!`].
525    ///
526    /// [`Markdown!`]: struct@crate::Markdown
527    fn is_markdown(&self) -> bool;
528
529    /// Find descendant tagged by the given anchor.
530    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
531}
532impl WidgetInfoExt for WidgetInfo {
533    fn anchor(&self) -> Option<&Txt> {
534        self.meta().get(*ANCHOR_ID)
535    }
536
537    fn is_markdown(&self) -> bool {
538        self.meta().contains(*MARKDOWN_INFO_ID)
539    }
540
541    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
542        self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
543    }
544}
545
546/// Generate an anchor label for a header.
547pub fn heading_anchor(header: &str) -> Txt {
548    header.chars().filter_map(slugify).collect::<String>().into()
549}
550fn slugify(c: char) -> Option<char> {
551    if c.is_alphanumeric() || c == '-' || c == '_' {
552        if c.is_ascii() { Some(c.to_ascii_lowercase()) } else { Some(c) }
553    } else if c.is_whitespace() && c.is_ascii() {
554        Some('-')
555    } else {
556        None
557    }
558}
559
560#[cfg(target_os = "linux")]
561#[zbus::proxy(
562    interface = "org.freedesktop.FileManager1",
563    default_service = "org.freedesktop.FileManager1",
564    default_path = "/org/freedesktop/FileManager1"
565)]
566trait FileManager {
567    fn show_items(&self, uris: Vec<String>, startup_id: &str) -> zbus::Result<()>;
568}
569#[cfg(target_os = "linux")]
570async fn reveal_in_file_manager(uri: String) -> zbus::Result<()> {
571    let conn = zbus::Connection::session().await?;
572    let proxy = FileManagerProxy::new(&conn).await?;
573    proxy.show_items(vec![uri], "").await
574}