diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index f9d1a5b0c7..7aa3aafb95 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -77,8 +77,8 @@ impl text::Paragraph for () { fn with_text(_text: Text<&str>) -> Self {} - fn with_spans( - _text: Text<&[text::Span<'_, Self::Font>], Self::Font>, + fn with_spans( + _text: Text<&[text::Span<'_, Link, Self::Font>], Self::Font>, ) -> Self { } @@ -107,6 +107,10 @@ impl text::Paragraph for () { fn hit_test(&self, _point: Point) -> Option { None } + + fn hit_span(&self, _point: Point) -> Option { + None + } } impl text::Editor for () { diff --git a/core/src/text.rs b/core/src/text.rs index 22cfce13f7..c22734f809 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -223,8 +223,8 @@ pub trait Renderer: crate::Renderer { } /// A span of text. -#[derive(Debug, Clone, PartialEq)] -pub struct Span<'a, Font = crate::Font> { +#[derive(Debug, Clone)] +pub struct Span<'a, Link = (), Font = crate::Font> { /// The [`Fragment`] of text. pub text: Fragment<'a>, /// The size of the [`Span`] in [`Pixels`]. @@ -235,9 +235,11 @@ pub struct Span<'a, Font = crate::Font> { pub font: Option, /// The [`Color`] of the [`Span`]. pub color: Option, + /// The link of the [`Span`]. + pub link: Option, } -impl<'a, Font> Span<'a, Font> { +impl<'a, Link, Font> Span<'a, Link, Font> { /// Creates a new [`Span`] of text with the given text fragment. pub fn new(fragment: impl IntoFragment<'a>) -> Self { Self { @@ -246,6 +248,7 @@ impl<'a, Font> Span<'a, Font> { line_height: None, font: None, color: None, + link: None, } } @@ -285,14 +288,27 @@ impl<'a, Font> Span<'a, Font> { self } + /// Sets the link of the [`Span`]. + pub fn link(mut self, link: impl Into) -> Self { + self.link = Some(link.into()); + self + } + + /// Sets the link of the [`Span`], if any. + pub fn link_maybe(mut self, link: Option>) -> Self { + self.link = link.map(Into::into); + self + } + /// Turns the [`Span`] into a static one. - pub fn to_static(self) -> Span<'static, Font> { + pub fn to_static(self) -> Span<'static, Link, Font> { Span { text: Cow::Owned(self.text.into_owned()), size: self.size, line_height: self.line_height, font: self.font, color: self.color, + link: self.link, } } } @@ -303,6 +319,16 @@ impl<'a, Font> From<&'a str> for Span<'a, Font> { } } +impl<'a, Link, Font: PartialEq> PartialEq for Span<'a, Link, Font> { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + && self.size == other.size + && self.line_height == other.line_height + && self.font == other.font + && self.color == other.color + } +} + /// A fragment of [`Text`]. /// /// This is just an alias to a string that may be either diff --git a/core/src/text/paragraph.rs b/core/src/text/paragraph.rs index 4ee83798e5..26650793a9 100644 --- a/core/src/text/paragraph.rs +++ b/core/src/text/paragraph.rs @@ -12,7 +12,9 @@ pub trait Paragraph: Sized + Default { fn with_text(text: Text<&str, Self::Font>) -> Self; /// Creates a new [`Paragraph`] laid out with the given [`Text`]. - fn with_spans(text: Text<&[Span<'_, Self::Font>], Self::Font>) -> Self; + fn with_spans( + text: Text<&[Span<'_, Link, Self::Font>], Self::Font>, + ) -> Self; /// Lays out the [`Paragraph`] with some new boundaries. fn resize(&mut self, new_bounds: Size); @@ -35,6 +37,11 @@ pub trait Paragraph: Sized + Default { /// [`Paragraph`], returning information about the nearest character. fn hit_test(&self, point: Point) -> Option; + /// Tests whether the provided point is within the boundaries of a + /// [`Span`] in the [`Paragraph`], returning the index of the [`Span`] + /// that was hit. + fn hit_span(&self, point: Point) -> Option; + /// Returns the distance to the given grapheme index in the [`Paragraph`]. fn grapheme_position(&self, line: usize, index: usize) -> Option; diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index 9404d5d2c0..cb74b954b0 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -8,3 +8,5 @@ publish = false [dependencies] iced.workspace = true iced.features = ["markdown", "highlighter", "debug"] + +open = "5.3" diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 6b7adc12c5..ee5b5aab91 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -16,6 +16,7 @@ struct Markdown { #[derive(Debug, Clone)] enum Message { Edit(text_editor::Action), + LinkClicked(String), } impl Markdown { @@ -50,6 +51,9 @@ impl Markdown { .collect(); } } + Message::LinkClicked(link) => { + let _ = open::that_in_background(link); + } } } @@ -60,7 +64,7 @@ impl Markdown { .padding(10) .font(Font::MONOSPACE); - let preview = markdown(&self.items); + let preview = markdown(&self.items, Message::LinkClicked); row![editor, scrollable(preview).spacing(10).height(Fill)] .spacing(10) diff --git a/graphics/src/text/paragraph.rs b/graphics/src/text/paragraph.rs index 37fa97f2d7..da703cebeb 100644 --- a/graphics/src/text/paragraph.rs +++ b/graphics/src/text/paragraph.rs @@ -100,8 +100,8 @@ impl core::text::Paragraph for Paragraph { })) } - fn with_spans(text: Text<&[Span<'_>]>) -> Self { - log::trace!("Allocating rich paragraph: {:?}", text.content); + fn with_spans(text: Text<&[Span<'_, Link>]>) -> Self { + log::trace!("Allocating rich paragraph: {} spans", text.content.len()); let mut font_system = text::font_system().write().expect("Write font system"); @@ -122,18 +122,8 @@ impl core::text::Paragraph for Paragraph { buffer.set_rich_text( font_system.raw(), - text.content.iter().map(|span| { - let attrs = cosmic_text::Attrs::new(); - - let attrs = if let Some(font) = span.font { - attrs - .family(text::to_family(font.family)) - .weight(text::to_weight(font.weight)) - .stretch(text::to_stretch(font.stretch)) - .style(text::to_style(font.style)) - } else { - text::to_attributes(text.font) - }; + text.content.iter().enumerate().map(|(i, span)| { + let attrs = text::to_attributes(span.font.unwrap_or(text.font)); let attrs = match (span.size, span.line_height) { (None, None) => attrs, @@ -156,7 +146,7 @@ impl core::text::Paragraph for Paragraph { attrs }; - (span.text.as_ref(), attrs) + (span.text.as_ref(), attrs.metadata(i)) }), text::to_attributes(text.font), text::to_shaping(text.shaping), @@ -231,6 +221,36 @@ impl core::text::Paragraph for Paragraph { Some(Hit::CharOffset(cursor.index)) } + fn hit_span(&self, point: Point) -> Option { + let internal = self.internal(); + + let cursor = internal.buffer.hit(point.x, point.y)?; + let line = internal.buffer.lines.get(cursor.line)?; + + let mut last_glyph = None; + let mut glyphs = line + .layout_opt() + .as_ref()? + .iter() + .flat_map(|line| line.glyphs.iter()) + .peekable(); + + while let Some(glyph) = glyphs.peek() { + if glyph.start <= cursor.index && cursor.index < glyph.end { + break; + } + + last_glyph = glyphs.next(); + } + + let glyph = match cursor.affinity { + cosmic_text::Affinity::Before => last_glyph, + cosmic_text::Affinity::After => glyphs.next(), + }?; + + Some(glyph.metadata) + } + fn grapheme_position(&self, line: usize, index: usize) -> Option { use unicode_segmentation::UnicodeSegmentation; diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 6def61d538..5b1cb5bce6 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -683,10 +683,11 @@ where /// Creates a new [`Rich`] text widget with the provided spans. /// /// [`Rich`]: text::Rich -pub fn rich_text<'a, Theme, Renderer>( - spans: impl Into]>>, -) -> text::Rich<'a, Theme, Renderer> +pub fn rich_text<'a, Message, Link, Theme, Renderer>( + spans: impl Into]>>, +) -> text::Rich<'a, Message, Link, Theme, Renderer> where + Link: Clone, Theme: text::Catalog + 'a, Renderer: core::text::Renderer, { diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index de691a4d4d..ae4020bce0 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -14,13 +14,13 @@ use crate::{column, container, rich_text, row, span, text}; #[derive(Debug, Clone)] pub enum Item { /// A heading. - Heading(Vec>), + Heading(Vec>), /// A paragraph. - Paragraph(Vec>), + Paragraph(Vec>), /// A code block. /// /// You can enable the `highlighter` feature for syntax highligting. - CodeBlock(Vec>), + CodeBlock(Vec>), /// A list. List { /// The first number of the list, if it is ordered. @@ -46,7 +46,7 @@ pub fn parse( let mut emphasis = false; let mut metadata = false; let mut table = false; - let mut link = false; + let mut link = None; let mut lists = Vec::new(); #[cfg(feature = "highlighter")] @@ -93,8 +93,10 @@ pub fn parse( emphasis = true; None } - pulldown_cmark::Tag::Link { .. } if !metadata && !table => { - link = true; + pulldown_cmark::Tag::Link { dest_url, .. } + if !metadata && !table => + { + link = Some(dest_url); None } pulldown_cmark::Tag::List(first_item) if !metadata && !table => { @@ -150,7 +152,7 @@ pub fn parse( None } pulldown_cmark::TagEnd::Link if !metadata && !table => { - link = false; + link = None; None } pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { @@ -245,7 +247,11 @@ pub fn parse( span }; - let span = span.color_maybe(link.then_some(palette.primary)); + let span = if let Some(link) = link.as_ref() { + span.color(palette.primary).link(link.to_string()) + } else { + span + }; spans.push(span); @@ -272,40 +278,48 @@ pub fn parse( /// You can obtain the items with [`parse`]. pub fn view<'a, Message, Renderer>( items: impl IntoIterator, + on_link: impl Fn(String) -> Message + Copy + 'a, ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, Renderer: core::text::Renderer + 'a, { let blocks = items.into_iter().enumerate().map(|(i, item)| match item { - Item::Heading(heading) => container(rich_text(heading)) - .padding(padding::top(if i > 0 { 8 } else { 0 })) - .into(), - Item::Paragraph(paragraph) => rich_text(paragraph).into(), - Item::List { start: None, items } => column( - items - .iter() - .map(|items| row!["•", view(items)].spacing(10).into()), - ) - .spacing(10) - .into(), + Item::Heading(heading) => { + container(rich_text(heading).on_link(on_link)) + .padding(padding::top(if i > 0 { 8 } else { 0 })) + .into() + } + Item::Paragraph(paragraph) => { + rich_text(paragraph).on_link(on_link).into() + } + Item::List { start: None, items } => { + column(items.iter().map(|items| { + row!["•", view(items, on_link)].spacing(10).into() + })) + .spacing(10) + .into() + } Item::List { start: Some(start), items, } => column(items.iter().enumerate().map(|(i, items)| { - row![text!("{}.", i as u64 + *start), view(items)] + row![text!("{}.", i as u64 + *start), view(items, on_link)] .spacing(10) .into() })) .spacing(10) .into(), - Item::CodeBlock(code) => { - container(rich_text(code).font(Font::MONOSPACE).size(12)) - .width(Length::Fill) - .padding(10) - .style(container::rounded_box) - .into() - } + Item::CodeBlock(code) => container( + rich_text(code) + .font(Font::MONOSPACE) + .size(12) + .on_link(on_link), + ) + .width(Length::Fill) + .padding(10) + .style(container::rounded_box) + .into(), }); Element::new(column(blocks).width(Length::Fill).spacing(16)) diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index 5c44ed9e06..625ea089dd 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -1,5 +1,6 @@ use crate::core::alignment; -use crate::core::layout::{self, Layout}; +use crate::core::event; +use crate::core::layout; use crate::core::mouse; use crate::core::renderer; use crate::core::text::{Paragraph, Span}; @@ -8,19 +9,26 @@ use crate::core::widget::text::{ }; use crate::core::widget::tree::{self, Tree}; use crate::core::{ - self, Color, Element, Length, Pixels, Rectangle, Size, Widget, + self, Clipboard, Color, Element, Event, Layout, Length, Pixels, Rectangle, + Shell, Size, Widget, }; use std::borrow::Cow; /// A bunch of [`Rich`] text. -#[derive(Debug)] -pub struct Rich<'a, Theme = crate::Theme, Renderer = crate::Renderer> -where +#[allow(missing_debug_implementations)] +pub struct Rich< + 'a, + Message, + Link = (), + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, { - spans: Cow<'a, [Span<'a, Renderer::Font>]>, + spans: Cow<'a, [Span<'a, Link, Renderer::Font>]>, size: Option, line_height: LineHeight, width: Length, @@ -29,10 +37,13 @@ where align_x: alignment::Horizontal, align_y: alignment::Vertical, class: Theme::Class<'a>, + on_link: Option Message + 'a>>, } -impl<'a, Theme, Renderer> Rich<'a, Theme, Renderer> +impl<'a, Message, Link, Theme, Renderer> + Rich<'a, Message, Link, Theme, Renderer> where + Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, { @@ -48,12 +59,13 @@ where align_x: alignment::Horizontal::Left, align_y: alignment::Vertical::Top, class: Theme::default(), + on_link: None, } } /// Creates a new [`Rich`] text with the given text spans. pub fn with_spans( - spans: impl Into]>>, + spans: impl Into]>>, ) -> Self { Self { spans: spans.into(), @@ -143,6 +155,12 @@ where self.style(move |_theme| Style { color }) } + /// Sets the message handler for link clicks on the [`Rich`] text. + pub fn on_link(mut self, on_link: impl Fn(Link) -> Message + 'a) -> Self { + self.on_link = Some(Box::new(on_link)); + self + } + /// Sets the default style class of the [`Rich`] text. #[cfg(feature = "advanced")] #[must_use] @@ -152,14 +170,19 @@ where } /// Adds a new text [`Span`] to the [`Rich`] text. - pub fn push(mut self, span: impl Into>) -> Self { + pub fn push( + mut self, + span: impl Into>, + ) -> Self { self.spans.to_mut().push(span.into()); self } } -impl<'a, Theme, Renderer> Default for Rich<'a, Theme, Renderer> +impl<'a, Message, Link, Theme, Renderer> Default + for Rich<'a, Message, Link, Theme, Renderer> where + Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, { @@ -168,24 +191,27 @@ where } } -struct State { - spans: Vec>, +struct State { + spans: Vec>, + span_pressed: Option, paragraph: P, } -impl<'a, Message, Theme, Renderer> Widget - for Rich<'a, Theme, Renderer> +impl<'a, Message, Link, Theme, Renderer> Widget + for Rich<'a, Message, Link, Theme, Renderer> where + Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, { fn tag(&self) -> tree::Tag { - tree::Tag::of::>() + tree::Tag::of::>() } fn state(&self) -> tree::State { - tree::State::new(State { + tree::State::new(State:: { spans: Vec::new(), + span_pressed: None, paragraph: Renderer::Paragraph::default(), }) } @@ -204,7 +230,8 @@ where limits: &layout::Limits, ) -> layout::Node { layout( - tree.state.downcast_mut::>(), + tree.state + .downcast_mut::>(), renderer, limits, self.width, @@ -228,7 +255,10 @@ where _cursor_position: mouse::Cursor, viewport: &Rectangle, ) { - let state = tree.state.downcast_ref::>(); + let state = tree + .state + .downcast_ref::>(); + let style = theme.style(&self.class); text::draw( @@ -240,15 +270,106 @@ where viewport, ); } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let Some(on_link_click) = self.on_link.as_ref() else { + return event::Status::Ignored; + }; + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(position) = cursor.position_in(layout.bounds()) { + let state = tree + .state + .downcast_mut::>(); + + if let Some(span) = state.paragraph.hit_span(position) { + state.span_pressed = Some(span); + + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let state = tree + .state + .downcast_mut::>(); + + if let Some(span_pressed) = state.span_pressed { + state.span_pressed = None; + + if let Some(position) = cursor.position_in(layout.bounds()) + { + match state.paragraph.hit_span(position) { + Some(span) if span == span_pressed => { + if let Some(link) = self + .spans + .get(span) + .and_then(|span| span.link.clone()) + { + shell.publish(on_link_click(link)); + } + } + _ => {} + } + } + } + } + _ => {} + } + + event::Status::Ignored + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if self.on_link.is_none() { + return mouse::Interaction::None; + } + + if let Some(position) = cursor.position_in(layout.bounds()) { + let state = tree + .state + .downcast_ref::>(); + + if let Some(span) = state + .paragraph + .hit_span(position) + .and_then(|span| self.spans.get(span)) + { + if span.link.is_some() { + return mouse::Interaction::Pointer; + } + } + } + + mouse::Interaction::None + } } -fn layout( - state: &mut State, +fn layout( + state: &mut State, renderer: &Renderer, limits: &layout::Limits, width: Length, height: Length, - spans: &[Span<'_, Renderer::Font>], + spans: &[Span<'_, Link, Renderer::Font>], line_height: LineHeight, size: Option, font: Option, @@ -256,6 +377,7 @@ fn layout( vertical_alignment: alignment::Vertical, ) -> layout::Node where + Link: Clone, Renderer: core::text::Renderer, { layout::sized(limits, width, height, |limits| { @@ -305,13 +427,15 @@ where }) } -impl<'a, Theme, Renderer> FromIterator> - for Rich<'a, Theme, Renderer> +impl<'a, Message, Link, Theme, Renderer> + FromIterator> + for Rich<'a, Message, Link, Theme, Renderer> where + Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, { - fn from_iter>>( + fn from_iter>>( spans: T, ) -> Self { Self { @@ -321,14 +445,17 @@ where } } -impl<'a, Message, Theme, Renderer> From> +impl<'a, Message, Link, Theme, Renderer> + From> for Element<'a, Message, Theme, Renderer> where + Message: 'a, + Link: Clone + 'static, Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { fn from( - text: Rich<'a, Theme, Renderer>, + text: Rich<'a, Message, Link, Theme, Renderer>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(text) }