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 pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
31
32 pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
34
35 pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
37}
38
39#[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#[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#[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#[derive(Clone, Default)]
74pub enum ImageResolver {
75 #[default]
79 Default,
80 Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
82}
83impl ImageResolver {
84 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 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#[derive(Clone, Default)]
121pub enum LinkResolver {
122 #[default]
124 Default,
125 Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
127}
128impl LinkResolver {
129 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 pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
139 Self::Resolve(Arc::new(fn_))
140 }
141
142 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 (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 pub static LINK_EVENT: LinkArgs;
189}
190
191event_property! {
192 #[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 pub struct LinkArgs {
203 pub url: Txt,
205
206 pub link: InteractionPath,
208
209 ..
210
211 fn is_in_target(&self, id: WidgetId) -> bool {
212 self.link.contains(id)
213 }
214 }
215}
216
217pub fn try_default_link_action(args: &LinkArgs) -> bool {
221 try_scroll_link(args) || try_open_link(args)
222}
223
224pub fn try_scroll_link(args: &LinkArgs) -> bool {
231 if args.propagation.is_stopped() {
232 return false;
233 }
234 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 zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
242
243 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
255pub 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#[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
517pub trait WidgetInfoExt {
519 fn anchor(&self) -> Option<&Txt>;
523
524 fn is_markdown(&self) -> bool;
528
529 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
546pub 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}