summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2025-02-04 07:53:56 +0100
committerLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2025-02-04 07:53:56 +0100
commit565599876172b3f56d86b119ae453b5bcd8949e1 (patch)
tree5353cb71aa595fc5ef3445e104b4776b8cc61ccb
parentc02ae0c4a430994247e6fbc4318ac344ab89123c (diff)
downloadiced-565599876172b3f56d86b119ae453b5bcd8949e1.tar.gz
iced-565599876172b3f56d86b119ae453b5bcd8949e1.tar.bz2
iced-565599876172b3f56d86b119ae453b5bcd8949e1.zip
Draft `Viewer` trait for `markdown`
-rw-r--r--Cargo.lock3
-rw-r--r--core/src/padding.rs6
-rw-r--r--examples/changelog/src/main.rs26
-rw-r--r--examples/markdown/Cargo.toml8
-rw-r--r--examples/markdown/src/main.rs139
-rw-r--r--widget/src/helpers.rs6
-rw-r--r--widget/src/markdown.rs553
-rw-r--r--widget/src/pop.rs24
-rw-r--r--widget/src/text/rich.rs57
9 files changed, 588 insertions, 234 deletions
diff --git a/Cargo.lock b/Cargo.lock
index c9c7ddc5..4175a188 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3299,7 +3299,10 @@ name = "markdown"
version = "0.1.0"
dependencies = [
"iced",
+ "image",
"open",
+ "reqwest",
+ "tokio",
]
[[package]]
diff --git a/core/src/padding.rs b/core/src/padding.rs
index e26cdd9b..9ec02e6d 100644
--- a/core/src/padding.rs
+++ b/core/src/padding.rs
@@ -202,3 +202,9 @@ impl From<Padding> for Size {
Self::new(padding.horizontal(), padding.vertical())
}
}
+
+impl From<Pixels> for Padding {
+ fn from(pixels: Pixels) -> Self {
+ Self::from(pixels.0)
+ }
+}
diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs
index f889e757..a6528ce9 100644
--- a/examples/changelog/src/main.rs
+++ b/examples/changelog/src/main.rs
@@ -267,25 +267,21 @@ impl Generator {
} => {
let details = {
let title = rich_text![
- span(&pull_request.title).size(24).link(
- Message::OpenPullRequest(pull_request.id)
- ),
+ span(&pull_request.title)
+ .size(24)
+ .link(pull_request.id),
span(format!(" by {}", pull_request.author))
.font(Font {
style: font::Style::Italic,
..Font::default()
}),
]
+ .on_link_clicked(Message::OpenPullRequest)
.font(Font::MONOSPACE);
- let description = markdown::view(
- description,
- markdown::Settings::default(),
- markdown::Style::from_palette(
- self.theme().palette(),
- ),
- )
- .map(Message::UrlClicked);
+ let description =
+ markdown::view(&self.theme(), description)
+ .map(Message::UrlClicked);
let labels =
row(pull_request.labels.iter().map(|label| {
@@ -349,11 +345,11 @@ impl Generator {
container(
scrollable(
markdown::view(
- preview,
- markdown::Settings::with_text_size(12),
- markdown::Style::from_palette(
- self.theme().palette(),
+ markdown::Settings::with_text_size(
+ 12,
+ &self.theme(),
),
+ preview,
)
.map(Message::UrlClicked),
)
diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml
index fa6ced74..4711b1c4 100644
--- a/examples/markdown/Cargo.toml
+++ b/examples/markdown/Cargo.toml
@@ -7,6 +7,12 @@ publish = false
[dependencies]
iced.workspace = true
-iced.features = ["markdown", "highlighter", "tokio", "debug"]
+iced.features = ["markdown", "highlighter", "image", "tokio", "debug"]
+
+reqwest.version = "0.12"
+reqwest.features = ["json"]
+
+image.workspace = true
+tokio.workspace = true
open = "5.3"
diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs
index ba93ee18..29625d79 100644
--- a/examples/markdown/src/main.rs
+++ b/examples/markdown/src/main.rs
@@ -1,10 +1,17 @@
use iced::highlighter;
use iced::time::{self, milliseconds};
use iced::widget::{
- self, hover, markdown, right, row, scrollable, text_editor, toggler,
+ self, center_x, horizontal_space, hover, image, markdown, pop, right, row,
+ scrollable, text_editor, toggler,
};
use iced::{Element, Fill, Font, Subscription, Task, Theme};
+use tokio::task;
+
+use std::collections::HashMap;
+use std::io;
+use std::sync::Arc;
+
pub fn main() -> iced::Result {
iced::application("Markdown - Iced", Markdown::update, Markdown::view)
.subscription(Markdown::subscription)
@@ -14,6 +21,7 @@ pub fn main() -> iced::Result {
struct Markdown {
content: text_editor::Content,
+ images: HashMap<markdown::Url, Image>,
mode: Mode,
theme: Theme,
}
@@ -26,10 +34,19 @@ enum Mode {
},
}
+enum Image {
+ Loading,
+ Ready(image::Handle),
+ #[allow(dead_code)]
+ Errored(Error),
+}
+
#[derive(Debug, Clone)]
enum Message {
Edit(text_editor::Action),
LinkClicked(markdown::Url),
+ ImageShown(markdown::Url),
+ ImageDownloaded(markdown::Url, Result<image::Handle, Error>),
ToggleStream(bool),
NextToken,
}
@@ -43,6 +60,7 @@ impl Markdown {
(
Self {
content: text_editor::Content::with_text(INITIAL_CONTENT),
+ images: HashMap::new(),
mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()),
theme,
},
@@ -70,6 +88,25 @@ impl Markdown {
Task::none()
}
+ Message::ImageShown(url) => {
+ if self.images.contains_key(&url) {
+ return Task::none();
+ }
+
+ let _ = self.images.insert(url.clone(), Image::Loading);
+
+ Task::perform(download_image(url.clone()), move |result| {
+ Message::ImageDownloaded(url.clone(), result)
+ })
+ }
+ Message::ImageDownloaded(url, result) => {
+ let _ = self.images.insert(
+ url,
+ result.map(Image::Ready).unwrap_or_else(Image::Errored),
+ );
+
+ Task::none()
+ }
Message::ToggleStream(enable_stream) => {
if enable_stream {
self.mode = Mode::Stream {
@@ -126,12 +163,13 @@ impl Markdown {
Mode::Stream { parsed, .. } => parsed.items(),
};
- let preview = markdown(
+ let preview = markdown::view_with(
+ &MarkdownViewer {
+ images: &self.images,
+ },
+ &self.theme,
items,
- markdown::Settings::default(),
- markdown::Style::from_palette(self.theme.palette()),
- )
- .map(Message::LinkClicked);
+ );
row![
editor,
@@ -167,3 +205,92 @@ impl Markdown {
}
}
}
+
+struct MarkdownViewer<'a> {
+ images: &'a HashMap<markdown::Url, Image>,
+}
+
+impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> {
+ fn on_link_clicked(url: markdown::Url) -> Message {
+ Message::LinkClicked(url)
+ }
+
+ fn image(
+ &self,
+ _settings: markdown::Settings,
+ _title: &markdown::Text,
+ url: &'a markdown::Url,
+ ) -> Element<'a, Message> {
+ if let Some(Image::Ready(handle)) = self.images.get(url) {
+ center_x(image(handle)).into()
+ } else {
+ pop(horizontal_space().width(0))
+ .key(url.as_str())
+ .on_show(|_size| Message::ImageShown(url.clone()))
+ .into()
+ }
+ }
+}
+
+async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> {
+ use std::io;
+ use tokio::task;
+
+ let client = reqwest::Client::new();
+
+ let bytes = client
+ .get(url)
+ .send()
+ .await?
+ .error_for_status()?
+ .bytes()
+ .await?;
+
+ let image = task::spawn_blocking(move || {
+ Ok::<_, Error>(
+ ::image::ImageReader::new(io::Cursor::new(bytes))
+ .with_guessed_format()?
+ .decode()?
+ .to_rgba8(),
+ )
+ })
+ .await??;
+
+ Ok(image::Handle::from_rgba(
+ image.width(),
+ image.height(),
+ image.into_raw(),
+ ))
+}
+
+#[derive(Debug, Clone)]
+pub enum Error {
+ RequestFailed(Arc<reqwest::Error>),
+ IOFailed(Arc<io::Error>),
+ JoinFailed(Arc<task::JoinError>),
+ ImageDecodingFailed(Arc<::image::ImageError>),
+}
+
+impl From<reqwest::Error> for Error {
+ fn from(error: reqwest::Error) -> Self {
+ Self::RequestFailed(Arc::new(error))
+ }
+}
+
+impl From<io::Error> for Error {
+ fn from(error: io::Error) -> Self {
+ Self::IOFailed(Arc::new(error))
+ }
+}
+
+impl From<task::JoinError> for Error {
+ fn from(error: task::JoinError) -> Self {
+ Self::JoinFailed(Arc::new(error))
+ }
+}
+
+impl From<::image::ImageError> for Error {
+ fn from(error: ::image::ImageError) -> Self {
+ Self::ImageDecodingFailed(Arc::new(error))
+ }
+}
diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs
index 4cba197d..2716d4c6 100644
--- a/widget/src/helpers.rs
+++ b/widget/src/helpers.rs
@@ -187,7 +187,7 @@ macro_rules! text {
#[macro_export]
macro_rules! rich_text {
() => (
- $crate::Column::new()
+ $crate::text::Rich::new()
);
($($x:expr),+ $(,)?) => (
$crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+])
@@ -1155,9 +1155,9 @@ where
/// .into()
/// }
/// ```
-pub fn rich_text<'a, Link, Theme, Renderer>(
+pub fn rich_text<'a, Link, Message, Theme, Renderer>(
spans: impl AsRef<[text::Span<'a, Link, Renderer::Font>]> + 'a,
-) -> text::Rich<'a, Link, Theme, Renderer>
+) -> text::Rich<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: text::Catalog + 'a,
diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs
index 628a10c6..d8d33763 100644
--- a/widget/src/markdown.rs
+++ b/widget/src/markdown.rs
@@ -47,6 +47,7 @@
//! }
//! }
//! ```
+#![allow(missing_docs)]
use crate::core::border;
use crate::core::font::{self, Font};
use crate::core::padding;
@@ -144,6 +145,7 @@ impl Content {
let mut state = State {
leftover: String::new(),
references: self.state.references.clone(),
+ images: HashSet::new(),
highlighter: None,
};
@@ -153,6 +155,7 @@ impl Content {
self.items[*index] = item;
}
+ self.state.images.extend(state.images.drain());
drop(state);
}
@@ -167,6 +170,11 @@ impl Content {
pub fn items(&self) -> &[Item] {
&self.items
}
+
+ /// Returns the URLs of the Markdown images present in the [`Content`].
+ pub fn images(&self) -> impl Iterator<Item = &Url> {
+ self.state.images.iter()
+ }
}
/// A Markdown item.
@@ -187,130 +195,13 @@ pub enum Item {
/// The items of the list.
items: Vec<Vec<Item>>,
},
-}
-
-impl Item {
- /// Displays a Markdown [`Item`] using the default, built-in look for its children.
- pub fn view<'a, 'b, Theme, Renderer>(
- &'b self,
- settings: Settings,
- style: Style,
- index: usize,
- ) -> Element<'a, Url, Theme, Renderer>
- where
- Theme: Catalog + 'a,
- Renderer: core::text::Renderer<Font = Font> + 'a,
- {
- self.view_with(index, settings, style, &DefaultView)
- }
-
- /// Displays a Markdown [`Item`] using the given [`View`] for its children.
- pub fn view_with<'a, 'b, Theme, Renderer>(
- &'b self,
- index: usize,
- settings: Settings,
- style: Style,
- view: &dyn View<'a, 'b, Url, Theme, Renderer>,
- ) -> Element<'a, Url, Theme, Renderer>
- where
- Theme: Catalog + 'a,
- Renderer: core::text::Renderer<Font = Font> + 'a,
- {
- let Settings {
- text_size,
- h1_size,
- h2_size,
- h3_size,
- h4_size,
- h5_size,
- h6_size,
- code_size,
- spacing,
- } = settings;
-
- match self {
- Item::Heading(level, heading) => {
- container(rich_text(heading.spans(style)).size(match level {
- pulldown_cmark::HeadingLevel::H1 => h1_size,
- pulldown_cmark::HeadingLevel::H2 => h2_size,
- pulldown_cmark::HeadingLevel::H3 => h3_size,
- pulldown_cmark::HeadingLevel::H4 => h4_size,
- pulldown_cmark::HeadingLevel::H5 => h5_size,
- pulldown_cmark::HeadingLevel::H6 => h6_size,
- }))
- .padding(padding::top(if index > 0 {
- text_size / 2.0
- } else {
- Pixels::ZERO
- }))
- .into()
- }
- Item::Paragraph(paragraph) => {
- rich_text(paragraph.spans(style)).size(text_size).into()
- }
- Item::List { start: None, items } => {
- column(items.iter().map(|items| {
- row![
- text("•").size(text_size),
- view_with(
- items,
- Settings {
- spacing: settings.spacing * 0.6,
- ..settings
- },
- style,
- view
- )
- ]
- .spacing(spacing)
- .into()
- }))
- .spacing(spacing * 0.75)
- .into()
- }
- Item::List {
- start: Some(start),
- items,
- } => column(items.iter().enumerate().map(|(i, items)| {
- row![
- text!("{}.", i as u64 + *start).size(text_size),
- view_with(
- items,
- Settings {
- spacing: settings.spacing * 0.6,
- ..settings
- },
- style,
- view
- )
- ]
- .spacing(spacing)
- .into()
- }))
- .spacing(spacing * 0.75)
- .into(),
- Item::CodeBlock(lines) => container(
- scrollable(
- container(column(lines.iter().map(|line| {
- rich_text(line.spans(style))
- .font(Font::MONOSPACE)
- .size(code_size)
- .into()
- })))
- .padding(spacing.0 / 2.0),
- )
- .direction(scrollable::Direction::Horizontal(
- scrollable::Scrollbar::default()
- .width(spacing.0 / 2.0)
- .scroller_width(spacing.0 / 2.0),
- )),
- )
- .width(Length::Fill)
- .padding(spacing.0 / 2.0)
- .class(Theme::code_block())
- .into(),
- }
- }
+ /// An image.
+ Image {
+ /// The destination URL of the image.
+ url: Url,
+ /// The title of the image.
+ title: Text,
+ },
}
/// A bunch of parsed Markdown text.
@@ -470,6 +361,7 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {
struct State {
leftover: String,
references: HashMap<String, String>,
+ images: HashSet<Url>,
#[cfg(feature = "highlighter")]
highlighter: Option<Highlighter>,
}
@@ -560,6 +452,10 @@ fn parse_with<'a>(
mut state: impl BorrowMut<State> + 'a,
markdown: &'a str,
) -> impl Iterator<Item = (Item, &'a str, HashSet<String>)> + 'a {
+ enum Scope {
+ List(List),
+ }
+
struct List {
start: Option<u64>,
items: Vec<Vec<Item>>,
@@ -575,7 +471,8 @@ fn parse_with<'a>(
let mut metadata = false;
let mut table = false;
let mut link = None;
- let mut lists = Vec::new();
+ let mut image = None;
+ let mut stack = Vec::new();
#[cfg(feature = "highlighter")]
let mut highlighter = None;
@@ -616,10 +513,18 @@ fn parse_with<'a>(
}
let produce = move |state: &mut State,
- lists: &mut Vec<List>,
+ stack: &mut Vec<Scope>,
item,
source: Range<usize>| {
- if lists.is_empty() {
+ if let Some(scope) = stack.last_mut() {
+ match scope {
+ Scope::List(list) => {
+ list.items.last_mut().expect("item context").push(item);
+ }
+ }
+
+ None
+ } else {
state.leftover = markdown[source.start..].to_owned();
Some((
@@ -627,16 +532,6 @@ fn parse_with<'a>(
&markdown[source.start..source.end],
broken_links.take(),
))
- } else {
- lists
- .last_mut()
- .expect("list context")
- .items
- .last_mut()
- .expect("item context")
- .push(item);
-
- None
}
};
@@ -673,31 +568,36 @@ fn parse_with<'a>(
None
}
+ pulldown_cmark::Tag::Image { dest_url, .. }
+ if !metadata && !table =>
+ {
+ image = Url::parse(&dest_url).ok();
+ None
+ }
pulldown_cmark::Tag::List(first_item) if !metadata && !table => {
let prev = if spans.is_empty() {
None
} else {
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())),
source,
)
};
- lists.push(List {
+ stack.push(Scope::List(List {
start: first_item,
items: Vec::new(),
- });
+ }));
prev
}
pulldown_cmark::Tag::Item => {
- lists
- .last_mut()
- .expect("list context")
- .items
- .push(Vec::new());
+ if let Some(Scope::List(list)) = stack.last_mut() {
+ list.items.push(Vec::new());
+ }
+
None
}
pulldown_cmark::Tag::CodeBlock(
@@ -726,7 +626,7 @@ fn parse_with<'a>(
} else {
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())),
source,
)
@@ -748,7 +648,7 @@ fn parse_with<'a>(
pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => {
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::Heading(level, Text::new(spans.drain(..).collect())),
source,
)
@@ -770,12 +670,16 @@ fn parse_with<'a>(
None
}
pulldown_cmark::TagEnd::Paragraph if !metadata && !table => {
- produce(
- state.borrow_mut(),
- &mut lists,
- Item::Paragraph(Text::new(spans.drain(..).collect())),
- source,
- )
+ if spans.is_empty() {
+ None
+ } else {
+ produce(
+ state.borrow_mut(),
+ &mut stack,
+ Item::Paragraph(Text::new(spans.drain(..).collect())),
+ source,
+ )
+ }
}
pulldown_cmark::TagEnd::Item if !metadata && !table => {
if spans.is_empty() {
@@ -783,18 +687,20 @@ fn parse_with<'a>(
} else {
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())),
source,
)
}
}
pulldown_cmark::TagEnd::List(_) if !metadata && !table => {
- let list = lists.pop().expect("list context");
+ let scope = stack.pop()?;
+
+ let Scope::List(list) = scope;
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::List {
start: list.start,
items: list.items,
@@ -802,6 +708,15 @@ fn parse_with<'a>(
source,
)
}
+ pulldown_cmark::TagEnd::Image if !metadata && !table => {
+ let url = image.take()?;
+ let title = Text::new(spans.drain(..).collect());
+
+ let state = state.borrow_mut();
+ let _ = state.images.insert(url.clone());
+
+ produce(state, &mut stack, Item::Image { url, title }, source)
+ }
pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => {
#[cfg(feature = "highlighter")]
{
@@ -810,7 +725,7 @@ fn parse_with<'a>(
produce(
state.borrow_mut(),
- &mut lists,
+ &mut stack,
Item::CodeBlock(code.drain(..).collect()),
source,
)
@@ -910,15 +825,25 @@ pub struct Settings {
pub code_size: Pixels,
/// The spacing to be used between elements.
pub spacing: Pixels,
+ /// The styling of the Markdown.
+ pub style: Style,
}
impl Settings {
+ /// Creates new [`Settings`] with default text size and the given [`Style`].
+ pub fn with_style(style: impl Into<Style>) -> Self {
+ Self::with_text_size(16, style)
+ }
+
/// Creates new [`Settings`] with the given base text size in [`Pixels`].
///
/// Heading levels will be adjusted automatically. Specifically,
/// the first level will be twice the base size, and then every level
/// after that will be 25% smaller.
- pub fn with_text_size(text_size: impl Into<Pixels>) -> Self {
+ pub fn with_text_size(
+ text_size: impl Into<Pixels>,
+ style: impl Into<Style>,
+ ) -> Self {
let text_size = text_size.into();
Self {
@@ -931,13 +856,14 @@ impl Settings {
h6_size: text_size,
code_size: text_size * 0.75,
spacing: text_size * 0.875,
+ style: style.into(),
}
}
}
-impl Default for Settings {
- fn default() -> Self {
- Self::with_text_size(16)
+impl From<&Theme> for Settings {
+ fn from(theme: &Theme) -> Self {
+ Self::with_style(Style::from(theme))
}
}
@@ -969,6 +895,18 @@ impl Style {
}
}
+impl From<theme::Palette> for Style {
+ fn from(palette: theme::Palette) -> Self {
+ Self::from_palette(palette)
+ }
+}
+
+impl From<&Theme> for Style {
+ fn from(theme: &Theme) -> Self {
+ Self::from_palette(theme.palette())
+ }
+}
+
/// Display a bunch of Markdown items.
///
/// You can obtain the items with [`parse`].
@@ -1015,16 +953,15 @@ impl Style {
/// }
/// }
/// ```
-pub fn view<'a, 'b, Theme, Renderer>(
- items: impl IntoIterator<Item = &'b Item>,
- settings: Settings,
- style: Style,
+pub fn view<'a, Theme, Renderer>(
+ settings: impl Into<Settings>,
+ items: impl IntoIterator<Item = &'a Item>,
) -> Element<'a, Url, Theme, Renderer>
where
Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
- view_with(items, settings, style, &DefaultView)
+ view_with(&DefaultViewer, settings, items)
}
/// Runs [`view`] but with a custom [`View`] to turn an [`Item`] into
@@ -1035,56 +972,288 @@ where
///
/// You can use [`Item::view`] and [`Item::view_with`] for the default
/// look.
-pub fn view_with<'a, 'b, Message, Theme, Renderer>(
- items: impl IntoIterator<Item = &'b Item>,
- settings: Settings,
- style: Style,
- view: &dyn View<'a, 'b, Message, Theme, Renderer>,
+pub fn view_with<'a, Message, Theme, Renderer>(
+ viewer: &impl Viewer<'a, Message, Theme, Renderer>,
+ settings: impl Into<Settings>,
+ items: impl IntoIterator<Item = &'a Item>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
+ let settings = settings.into();
+
let blocks = items
.into_iter()
.enumerate()
- .map(move |(i, item)| view.view(settings, style, item, i));
+ .map(|(i, item_)| item(viewer, settings, item_, i));
Element::new(column(blocks).spacing(settings.spacing))
}
-/// A view strategy to display a Markdown [`Item`].
-pub trait View<'a, 'b, Message, Theme, Renderer> {
- /// Displays a Markdown [`Item`] by projecting it into an [`Element`].
- ///
- /// You can use [`Item::view`] and [`Item::view_with`] for the default
- /// look.
- fn view(
+pub fn item<'a, Message, Theme, Renderer>(
+ viewer: &impl Viewer<'a, Message, Theme, Renderer>,
+ settings: Settings,
+ item: &'a Item,
+ index: usize,
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ match item {
+ Item::Image { title, url } => viewer.image(settings, title, url),
+ Item::Heading(level, text) => {
+ viewer.heading(settings, index, level, text)
+ }
+ Item::Paragraph(text) => viewer.paragraph(settings, text),
+ Item::CodeBlock(lines) => viewer.code_block(settings, lines),
+ Item::List { start: None, items } => {
+ viewer.unordered_list(settings, items)
+ }
+ Item::List {
+ start: Some(start),
+ items,
+ } => viewer.ordered_list(settings, *start, items),
+ }
+}
+
+pub fn heading<'a, Message, Theme, Renderer>(
+ settings: Settings,
+ index: usize,
+ level: &'a HeadingLevel,
+ text: &'a Text,
+ on_link_clicked: impl Fn(Url) -> Message + 'a,
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ let Settings {
+ h1_size,
+ h2_size,
+ h3_size,
+ h4_size,
+ h5_size,
+ h6_size,
+ text_size,
+ ..
+ } = settings;
+
+ container(
+ rich_text(text.spans(settings.style))
+ .on_link_clicked(on_link_clicked)
+ .size(match level {
+ pulldown_cmark::HeadingLevel::H1 => h1_size,
+ pulldown_cmark::HeadingLevel::H2 => h2_size,
+ pulldown_cmark::HeadingLevel::H3 => h3_size,
+ pulldown_cmark::HeadingLevel::H4 => h4_size,
+ pulldown_cmark::HeadingLevel::H5 => h5_size,
+ pulldown_cmark::HeadingLevel::H6 => h6_size,
+ }),
+ )
+ .padding(padding::top(if index > 0 {
+ text_size / 2.0
+ } else {
+ Pixels::ZERO
+ }))
+ .into()
+}
+
+pub fn paragraph<'a, Message, Theme, Renderer>(
+ settings: Settings,
+ text: &'a Text,
+ on_link_clicked: impl Fn(Url) -> Message + 'a,
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ rich_text(text.spans(settings.style))
+ .size(settings.text_size)
+ .on_link_clicked(on_link_clicked)
+ .into()
+}
+
+pub fn unordered_list<'a, Message, Theme, Renderer>(
+ viewer: &impl Viewer<'a, Message, Theme, Renderer>,
+ settings: Settings,
+ items: &'a [Vec<Item>],
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ column(items.iter().map(|items| {
+ row![
+ text("•").size(settings.text_size),
+ view_with(
+ viewer,
+ Settings {
+ spacing: settings.spacing * 0.6,
+ ..settings
+ },
+ items,
+ )
+ ]
+ .spacing(settings.spacing)
+ .into()
+ }))
+ .spacing(settings.spacing * 0.75)
+ .padding([0.0, settings.spacing.0])
+ .into()
+}
+
+pub fn ordered_list<'a, Message, Theme, Renderer>(
+ viewer: &impl Viewer<'a, Message, Theme, Renderer>,
+ settings: Settings,
+ start: u64,
+ items: &'a [Vec<Item>],
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ column(items.iter().enumerate().map(|(i, items)| {
+ row![
+ text!("{}.", i as u64 + start).size(settings.text_size),
+ view_with(
+ viewer,
+ Settings {
+ spacing: settings.spacing * 0.6,
+ ..settings
+ },
+ items,
+ )
+ ]
+ .spacing(settings.spacing)
+ .into()
+ }))
+ .spacing(settings.spacing * 0.75)
+ .padding([0.0, settings.spacing.0])
+ .into()
+}
+
+pub fn code_block<'a, Message, Theme, Renderer>(
+ settings: Settings,
+ lines: &'a [Text],
+ on_link_clicked: impl Fn(Url) -> Message + Clone + 'a,
+) -> Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ container(
+ scrollable(
+ container(column(lines.iter().map(|line| {
+ rich_text(line.spans(settings.style))
+ .on_link_clicked(on_link_clicked.clone())
+ .font(Font::MONOSPACE)
+ .size(settings.code_size)
+ .into()
+ })))
+ .padding(settings.spacing.0 / 2.0),
+ )
+ .direction(scrollable::Direction::Horizontal(
+ scrollable::Scrollbar::default()
+ .width(settings.spacing.0 / 2.0)
+ .scroller_width(settings.spacing.0 / 2.0),
+ )),
+ )
+ .width(Length::Fill)
+ .padding(settings.spacing.0 / 2.0)
+ .class(Theme::code_block())
+ .into()
+}
+
+/// A view strategy to display a Markdown [`Item`].j
+pub trait Viewer<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
+where
+ Self: Sized + 'a,
+ Message: 'a,
+ Theme: Catalog + 'a,
+ Renderer: core::text::Renderer<Font = Font> + 'a,
+{
+ fn on_link_clicked(url: Url) -> Message;
+
+ fn image(
+ &self,
+ settings: Settings,
+ title: &Text,
+ url: &'a Url,
+ ) -> Element<'a, Message, Theme, Renderer> {
+ let _url = url;
+
+ container(
+ rich_text(title.spans(settings.style))
+ .on_link_clicked(Self::on_link_clicked),
+ )
+ .padding(settings.spacing.0)
+ .class(Theme::code_block())
+ .into()
+ }
+
+ fn heading(
&self,
settings: Settings,
- style: Style,
- item: &'b Item,
index: usize,
- ) -> Element<'a, Message, Theme, Renderer>;
+ level: &'a HeadingLevel,
+ text: &'a Text,
+ ) -> Element<'a, Message, Theme, Renderer> {
+ heading(settings, index, level, text, Self::on_link_clicked)
+ }
+
+ fn paragraph(
+ &self,
+ settings: Settings,
+ text: &'a Text,
+ ) -> Element<'a, Message, Theme, Renderer> {
+ paragraph(settings, text, Self::on_link_clicked)
+ }
+
+ fn code_block(
+ &self,
+ settings: Settings,
+ lines: &'a [Text],
+ ) -> Element<'a, Message, Theme, Renderer> {
+ code_block(settings, lines, Self::on_link_clicked)
+ }
+
+ fn unordered_list(
+ &self,
+ settings: Settings,
+ items: &'a [Vec<Item>],
+ ) -> Element<'a, Message, Theme, Renderer> {
+ unordered_list(self, settings, items)
+ }
+
+ fn ordered_list(
+ &self,
+ settings: Settings,
+ start: u64,
+ items: &'a [Vec<Item>],
+ ) -> Element<'a, Message, Theme, Renderer> {
+ ordered_list(self, settings, start, items)
+ }
}
#[derive(Debug, Clone, Copy)]
-struct DefaultView;
+struct DefaultViewer;
-impl<'a, 'b, Theme, Renderer> View<'a, 'b, Url, Theme, Renderer> for DefaultView
+impl<'a, Theme, Renderer> Viewer<'a, Url, Theme, Renderer> for DefaultViewer
where
Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
- fn view(
- &self,
- settings: Settings,
- style: Style,
- item: &'b Item,
- index: usize,
- ) -> Element<'a, Url, Theme, Renderer> {
- item.view(settings, style, index)
+ fn on_link_clicked(url: Url) -> Url {
+ url
}
}
diff --git a/widget/src/pop.rs b/widget/src/pop.rs
index 6e9df4be..950371ea 100644
--- a/widget/src/pop.rs
+++ b/widget/src/pop.rs
@@ -3,6 +3,7 @@ use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
+use crate::core::text;
use crate::core::widget;
use crate::core::widget::tree::{self, Tree};
use crate::core::window;
@@ -17,6 +18,7 @@ use crate::core::{
#[allow(missing_debug_implementations)]
pub struct Pop<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> {
content: Element<'a, Message, Theme, Renderer>,
+ key: Option<text::Fragment<'a>>,
on_show: Option<Box<dyn Fn(Size) -> Message + 'a>>,
on_resize: Option<Box<dyn Fn(Size) -> Message + 'a>>,
on_hide: Option<Message>,
@@ -34,6 +36,7 @@ where
) -> Self {
Self {
content: content.into(),
+ key: None,
on_show: None,
on_resize: None,
on_hide: None,
@@ -66,6 +69,14 @@ where
self
}
+ /// Sets the key of the [`Pop`] widget, for continuity.
+ ///
+ /// If the key changes, the [`Pop`] widget will trigger again.
+ pub fn key(mut self, key: impl text::IntoFragment<'a>) -> Self {
+ self.key = Some(key.into_fragment());
+ self
+ }
+
/// Sets the distance in [`Pixels`] to use in anticipation of the
/// content popping into view.
///
@@ -77,10 +88,11 @@ where
}
}
-#[derive(Debug, Clone, Copy, Default)]
+#[derive(Debug, Clone, Default)]
struct State {
has_popped_in: bool,
last_size: Option<Size>,
+ last_key: Option<String>,
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
@@ -118,8 +130,16 @@ where
) {
if let Event::Window(window::Event::RedrawRequested(_)) = &event {
let state = tree.state.downcast_mut::<State>();
- let bounds = layout.bounds();
+ if state.has_popped_in
+ && state.last_key.as_deref() != self.key.as_deref()
+ {
+ state.has_popped_in = false;
+ state.last_key =
+ self.key.as_ref().cloned().map(text::Fragment::into_owned);
+ }
+
+ let bounds = layout.bounds();
let top_left_distance = viewport.distance(bounds.position());
let bottom_right_distance = viewport
diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs
index a9e544d1..02348b54 100644
--- a/widget/src/text/rich.rs
+++ b/widget/src/text/rich.rs
@@ -14,8 +14,13 @@ use crate::core::{
/// A bunch of [`Rich`] text.
#[allow(missing_debug_implementations)]
-pub struct Rich<'a, Link, Theme = crate::Theme, Renderer = crate::Renderer>
-where
+pub struct Rich<
+ 'a,
+ Link,
+ Message,
+ Theme = crate::Theme,
+ Renderer = crate::Renderer,
+> where
Link: Clone + 'static,
Theme: Catalog,
Renderer: core::text::Renderer,
@@ -31,9 +36,11 @@ where
wrapping: Wrapping,
class: Theme::Class<'a>,
hovered_link: Option<usize>,
+ on_link_clicked: Option<Box<dyn Fn(Link) -> Message + 'a>>,
}
-impl<'a, Link, Theme, Renderer> Rich<'a, Link, Theme, Renderer>
+impl<'a, Link, Message, Theme, Renderer>
+ Rich<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
@@ -54,6 +61,7 @@ where
wrapping: Wrapping::default(),
class: Theme::default(),
hovered_link: None,
+ on_link_clicked: None,
}
}
@@ -127,6 +135,16 @@ where
self
}
+ /// Sets the message that will be produced when a link of the [`Rich`] text
+ /// is clicked.
+ pub fn on_link_clicked(
+ mut self,
+ on_link_clicked: impl Fn(Link) -> Message + 'a,
+ ) -> Self {
+ self.on_link_clicked = Some(Box::new(on_link_clicked));
+ self
+ }
+
/// Sets the default style of the [`Rich`] text.
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
@@ -164,7 +182,8 @@ where
}
}
-impl<'a, Link, Theme, Renderer> Default for Rich<'a, Link, Theme, Renderer>
+impl<'a, Link, Message, Theme, Renderer> Default
+ for Rich<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'a,
Theme: Catalog,
@@ -182,8 +201,8 @@ struct State<Link, P: Paragraph> {
paragraph: P,
}
-impl<Link, Theme, Renderer> Widget<Link, Theme, Renderer>
- for Rich<'_, Link, Theme, Renderer>
+impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
+ for Rich<'_, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
@@ -252,7 +271,8 @@ where
let style = theme.style(&self.class);
for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() {
- let is_hovered_link = Some(index) == self.hovered_link;
+ let is_hovered_link = self.on_link_clicked.is_some()
+ && Some(index) == self.hovered_link;
if span.highlight.is_some()
|| span.underline
@@ -363,9 +383,13 @@ where
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
- shell: &mut Shell<'_, Link>,
+ shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
+ let Some(on_link_clicked) = &self.on_link_clicked else {
+ return;
+ };
+
let was_hovered = self.hovered_link.is_some();
if let Some(position) = cursor.position_in(layout.bounds()) {
@@ -414,7 +438,7 @@ where
.get(span)
.and_then(|span| span.link.clone())
{
- shell.publish(link);
+ shell.publish(on_link_clicked(link));
}
}
_ => {}
@@ -509,8 +533,9 @@ where
})
}
-impl<'a, Link, Theme, Renderer> FromIterator<Span<'a, Link, Renderer::Font>>
- for Rich<'a, Link, Theme, Renderer>
+impl<'a, Link, Message, Theme, Renderer>
+ FromIterator<Span<'a, Link, Renderer::Font>>
+ for Rich<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'a,
Theme: Catalog,
@@ -524,16 +549,18 @@ where
}
}
-impl<'a, Link, Theme, Renderer> From<Rich<'a, Link, Theme, Renderer>>
- for Element<'a, Link, Theme, Renderer>
+impl<'a, Link, Message, Theme, Renderer>
+ From<Rich<'a, Link, Message, Theme, Renderer>>
+ for Element<'a, Message, Theme, Renderer>
where
+ Message: 'a,
Link: Clone + 'a,
Theme: Catalog + 'a,
Renderer: core::text::Renderer + 'a,
{
fn from(
- text: Rich<'a, Link, Theme, Renderer>,
- ) -> Element<'a, Link, Theme, Renderer> {
+ text: Rich<'a, Link, Message, Theme, Renderer>,
+ ) -> Element<'a, Message, Theme, Renderer> {
Element::new(text)
}
}