use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use zng_wgt::{prelude::*, *};
use zng_app::widget::info::TransformChangedArgs;
use zng_ext_clipboard::{CLIPBOARD, COPY_CMD};
use zng_ext_image::ImageSource;
use zng_ext_input::focus::WidgetInfoFocusExt as _;
use zng_ext_input::{focus::FOCUS, gesture::ClickArgs};
use zng_wgt_button::Button;
use zng_wgt_container::Container;
use zng_wgt_fill::*;
use zng_wgt_filter::*;
use zng_wgt_input::focus::on_focus_leave;
use zng_wgt_layer::{AnchorMode, AnchorOffset, LayerIndex, LAYERS};
use zng_wgt_scroll::cmd::ScrollToMode;
use zng_wgt_size_offset::*;
use zng_wgt_text::{self as text, Text};
use super::Markdown;
use path_absolutize::*;
use http::Uri;
context_var! {
pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
}
#[property(CONTEXT, default(IMAGE_RESOLVER_VAR), widget_impl(Markdown))]
pub fn image_resolver(child: impl UiNode, resolver: impl IntoVar<ImageResolver>) -> impl UiNode {
with_context_var(child, IMAGE_RESOLVER_VAR, resolver)
}
#[property(CONTEXT, default(LINK_RESOLVER_VAR), widget_impl(Markdown))]
pub fn link_resolver(child: impl UiNode, resolver: impl IntoVar<LinkResolver>) -> impl UiNode {
with_context_var(child, LINK_RESOLVER_VAR, resolver)
}
#[property(CONTEXT, default(LINK_SCROLL_MODE_VAR), widget_impl(Markdown))]
pub fn link_scroll_mode(child: impl UiNode, mode: impl IntoVar<ScrollToMode>) -> impl UiNode {
with_context_var(child, LINK_SCROLL_MODE_VAR, mode)
}
#[derive(Clone)]
pub enum ImageResolver {
Default,
Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
}
impl ImageResolver {
pub fn resolve(&self, img: &str) -> ImageSource {
match self {
ImageResolver::Default => img.into(),
ImageResolver::Resolve(r) => r(img),
}
}
pub fn new(fn_: impl Fn(&str) -> ImageSource + Send + Sync + 'static) -> Self {
ImageResolver::Resolve(Arc::new(fn_))
}
}
impl Default for ImageResolver {
fn default() -> Self {
Self::Default
}
}
impl fmt::Debug for ImageResolver {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if f.alternate() {
write!(f, "ImgSourceResolver::")?;
}
match self {
ImageResolver::Default => write!(f, "Default"),
ImageResolver::Resolve(_) => write!(f, "Resolve(_)"),
}
}
}
impl PartialEq for ImageResolver {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
_ => core::mem::discriminant(self) == core::mem::discriminant(other),
}
}
}
#[derive(Clone)]
pub enum LinkResolver {
Default,
Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
}
impl LinkResolver {
pub fn resolve(&self, url: &str) -> Txt {
match self {
Self::Default => url.to_txt(),
Self::Resolve(r) => r(url),
}
}
pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
Self::Resolve(Arc::new(fn_))
}
pub fn base_dir(base: impl Into<PathBuf>) -> Self {
let base = base.into();
Self::new(move |url| {
if !url.starts_with('#') {
let is_not_uri = url.parse::<Uri>().is_err();
if is_not_uri {
let path = Path::new(url);
if let Ok(path) = base.join(path).absolutize() {
return path.display().to_txt();
}
}
}
url.to_txt()
})
}
}
impl Default for LinkResolver {
fn default() -> Self {
Self::Default
}
}
impl fmt::Debug for LinkResolver {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if f.alternate() {
write!(f, "LinkResolver::")?;
}
match self {
Self::Default => write!(f, "Default"),
Self::Resolve(_) => write!(f, "Resolve(_)"),
}
}
}
impl PartialEq for LinkResolver {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
_ => core::mem::discriminant(self) == core::mem::discriminant(other),
}
}
}
event! {
pub static LINK_EVENT: LinkArgs;
}
event_property! {
pub fn link {
event: LINK_EVENT,
args: LinkArgs,
}
}
event_args! {
pub struct LinkArgs {
pub url: Txt,
pub link: InteractionPath,
..
fn delivery_list(&self, delivery_list: &mut UpdateDeliveryList) {
delivery_list.insert_wgt(self.link.as_path())
}
}
}
pub fn try_default_link_action(args: &LinkArgs) -> bool {
try_scroll_link(args) || try_open_link(args)
}
pub fn try_scroll_link(args: &LinkArgs) -> bool {
if args.propagation().is_stopped() {
return false;
}
if let Some(anchor) = args.url.strip_prefix('#') {
let tree = WINDOW.info();
if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown())) {
if let Some(target) = md.find_anchor(anchor) {
zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
FOCUS.focus_widget(focus.info().id(), false);
}
}
}
args.propagation().stop();
return true;
}
false
}
pub fn try_open_link(args: &LinkArgs) -> bool {
if args.propagation().is_stopped() {
return false;
}
#[derive(Clone)]
enum Link {
Url(Uri),
Path(PathBuf),
}
let link = if let Ok(url) = args.url.parse() {
Link::Url(url)
} else {
Link::Path(PathBuf::from(args.url.as_str()))
};
let popup_id = WidgetId::new_unique();
let url = args.url.clone();
#[derive(Clone, Debug, PartialEq)]
enum Status {
Pending,
Ok,
Err,
Cancel,
}
let status = var(Status::Pending);
let open_time = INSTANT.now();
let popup = Container! {
id = popup_id;
padding = (2, 4);
corner_radius = 2;
drop_shadow = (2, 2), 2, colors::BLACK.with_alpha(50.pct());
align = Align::TOP_LEFT;
#[easing(200.ms())]
opacity = 0.pct();
#[easing(200.ms())]
offset = (0, -10);
background_color = light_dark(colors::WHITE.with_alpha(90.pct()), colors::BLACK.with_alpha(90.pct()));
when *#{status.clone()} == Status::Pending {
opacity = 100.pct();
offset = (0, 0);
}
when *#{status.clone()} == Status::Err {
background_color = light_dark(web_colors::PINK.with_alpha(90.pct()), web_colors::DARK_RED.with_alpha(90.pct()));
}
on_focus_leave = async_hn_once!(status, |_| {
if status.get() != Status::Pending {
return;
}
status.set(Status::Cancel);
task::deadline(200.ms()).await;
LAYERS.remove(popup_id);
});
on_move = async_hn!(status, |args: TransformChangedArgs| {
if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
return;
}
status.set(Status::Cancel);
task::deadline(200.ms()).await;
LAYERS.remove(popup_id);
});
child = Button! {
style_fn = zng_wgt_button::LinkStyle!();
focus_on_init = true;
child = Text!(url);
child_end = ICONS.get_or("arrow-outward", || Text!("🡵")), 2;
text::underline_skip = text::UnderlineSkip::SPACES;
on_click = async_hn_once!(status, link, |args: ClickArgs| {
if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
return;
}
args.propagation().stop();
let (uri, kind) = match link {
Link::Url(u) => (u.to_string(), "url"),
Link::Path(p) => {
match dunce::canonicalize(&p) {
Ok(p) => {
let p = p.display().to_string();
#[cfg(windows)]
let p = p.replace('/', "\\");
#[cfg(target_arch = "wasm32")]
let p = format!("file:///{p}");
(p, "path")
},
Err(e) => {
tracing::error!("error canonicalizing \"{}\", {e}", p.display());
return;
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
let r = task::wait( || open::that_detached(uri)).await;
if let Err(e) = &r {
tracing::error!("error opening {kind}, {e}");
}
status.set(if r.is_ok() { Status::Ok } else { Status::Err });
}
#[cfg(target_arch = "wasm32")]
{
match web_sys::window() {
Some(w) => {
match w.open_with_url_and_target(uri.as_str(), "_blank") {
Ok(w) => match w {
Some(w) => {
let _ = w.focus();
status.set(Status::Ok);
},
None => {
tracing::error!("error opening {kind}, no new tab/window");
status.set(Status::Err);
}
},
Err(e) => {
tracing::error!("error opening {kind}, {e:?}");
status.set(Status::Err);
}
}
},
None => {
tracing::error!("error opening {kind}, no window");
status.set(Status::Err);
}
}
}
task::deadline(200.ms()).await;
LAYERS.remove(popup_id);
});
};
child_end = Button! {
style_fn = zng_wgt_button::LightStyle!();
padding = 3;
child = presenter((), COPY_CMD.icon());
on_click = async_hn_once!(status, |args: ClickArgs| {
if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
return;
}
args.propagation().stop();
let txt = match link {
Link::Url(u) => u.to_txt(),
Link::Path(p) => p.display().to_txt(),
};
let r = CLIPBOARD.set_text(txt.clone()).wait_into_rsp().await;
if let Err(e) = &r {
tracing::error!("error copying uri, {e}");
}
status.set(if r.is_ok() { Status::Ok } else { Status::Err });
task::deadline(200.ms()).await;
LAYERS.remove(popup_id);
});
}, 0;
};
LAYERS.insert_anchored(
LayerIndex::ADORNER,
args.link.widget_id(),
AnchorMode::popup(AnchorOffset::out_bottom()),
popup,
);
true
}
static_id! {
static ref ANCHOR_ID: StateId<Txt>;
pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
}
#[property(CONTEXT, default(""))]
pub fn anchor(child: impl UiNode, anchor: impl IntoVar<Txt>) -> impl UiNode {
let anchor = anchor.into_var();
match_node(child, move |_, op| match op {
UiNodeOp::Init => {
WIDGET.sub_var_info(&anchor);
}
UiNodeOp::Info { info } => {
info.set_meta(*ANCHOR_ID, anchor.get());
}
_ => {}
})
}
pub trait WidgetInfoExt {
fn anchor(&self) -> Option<&Txt>;
fn is_markdown(&self) -> bool;
fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
}
impl WidgetInfoExt for WidgetInfo {
fn anchor(&self) -> Option<&Txt> {
self.meta().get(*ANCHOR_ID)
}
fn is_markdown(&self) -> bool {
self.meta().contains(*MARKDOWN_INFO_ID)
}
fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
}
}
pub fn heading_anchor(header: &str) -> Txt {
header.chars().filter_map(slugify).collect::<String>().into()
}
fn slugify(c: char) -> Option<char> {
if c.is_alphanumeric() || c == '-' || c == '_' {
if c.is_ascii() {
Some(c.to_ascii_lowercase())
} else {
Some(c)
}
} else if c.is_whitespace() && c.is_ascii() {
Some('-')
} else {
None
}
}