Draft Viewer trait for markdown

This commit is contained in:
Héctor Ramón Jiménez 2025-02-04 07:53:56 +01:00
parent c02ae0c4a4
commit 5655998761
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
9 changed files with 588 additions and 234 deletions

3
Cargo.lock generated
View file

@ -3299,7 +3299,10 @@ name = "markdown"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"iced", "iced",
"image",
"open", "open",
"reqwest",
"tokio",
] ]
[[package]] [[package]]

View file

@ -202,3 +202,9 @@ impl From<Padding> for Size {
Self::new(padding.horizontal(), padding.vertical()) Self::new(padding.horizontal(), padding.vertical())
} }
} }
impl From<Pixels> for Padding {
fn from(pixels: Pixels) -> Self {
Self::from(pixels.0)
}
}

View file

@ -267,25 +267,21 @@ impl Generator {
} => { } => {
let details = { let details = {
let title = rich_text![ let title = rich_text![
span(&pull_request.title).size(24).link( span(&pull_request.title)
Message::OpenPullRequest(pull_request.id) .size(24)
), .link(pull_request.id),
span(format!(" by {}", pull_request.author)) span(format!(" by {}", pull_request.author))
.font(Font { .font(Font {
style: font::Style::Italic, style: font::Style::Italic,
..Font::default() ..Font::default()
}), }),
] ]
.on_link_clicked(Message::OpenPullRequest)
.font(Font::MONOSPACE); .font(Font::MONOSPACE);
let description = markdown::view( let description =
description, markdown::view(&self.theme(), description)
markdown::Settings::default(), .map(Message::UrlClicked);
markdown::Style::from_palette(
self.theme().palette(),
),
)
.map(Message::UrlClicked);
let labels = let labels =
row(pull_request.labels.iter().map(|label| { row(pull_request.labels.iter().map(|label| {
@ -349,11 +345,11 @@ impl Generator {
container( container(
scrollable( scrollable(
markdown::view( markdown::view(
preview, markdown::Settings::with_text_size(
markdown::Settings::with_text_size(12), 12,
markdown::Style::from_palette( &self.theme(),
self.theme().palette(),
), ),
preview,
) )
.map(Message::UrlClicked), .map(Message::UrlClicked),
) )

View file

@ -7,6 +7,12 @@ publish = false
[dependencies] [dependencies]
iced.workspace = true 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" open = "5.3"

View file

@ -1,10 +1,17 @@
use iced::highlighter; use iced::highlighter;
use iced::time::{self, milliseconds}; use iced::time::{self, milliseconds};
use iced::widget::{ 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 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 { pub fn main() -> iced::Result {
iced::application("Markdown - Iced", Markdown::update, Markdown::view) iced::application("Markdown - Iced", Markdown::update, Markdown::view)
.subscription(Markdown::subscription) .subscription(Markdown::subscription)
@ -14,6 +21,7 @@ pub fn main() -> iced::Result {
struct Markdown { struct Markdown {
content: text_editor::Content, content: text_editor::Content,
images: HashMap<markdown::Url, Image>,
mode: Mode, mode: Mode,
theme: Theme, theme: Theme,
} }
@ -26,10 +34,19 @@ enum Mode {
}, },
} }
enum Image {
Loading,
Ready(image::Handle),
#[allow(dead_code)]
Errored(Error),
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Message { enum Message {
Edit(text_editor::Action), Edit(text_editor::Action),
LinkClicked(markdown::Url), LinkClicked(markdown::Url),
ImageShown(markdown::Url),
ImageDownloaded(markdown::Url, Result<image::Handle, Error>),
ToggleStream(bool), ToggleStream(bool),
NextToken, NextToken,
} }
@ -43,6 +60,7 @@ impl Markdown {
( (
Self { Self {
content: text_editor::Content::with_text(INITIAL_CONTENT), content: text_editor::Content::with_text(INITIAL_CONTENT),
images: HashMap::new(),
mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()),
theme, theme,
}, },
@ -70,6 +88,25 @@ impl Markdown {
Task::none() 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) => { Message::ToggleStream(enable_stream) => {
if enable_stream { if enable_stream {
self.mode = Mode::Stream { self.mode = Mode::Stream {
@ -126,12 +163,13 @@ impl Markdown {
Mode::Stream { parsed, .. } => parsed.items(), Mode::Stream { parsed, .. } => parsed.items(),
}; };
let preview = markdown( let preview = markdown::view_with(
&MarkdownViewer {
images: &self.images,
},
&self.theme,
items, items,
markdown::Settings::default(), );
markdown::Style::from_palette(self.theme.palette()),
)
.map(Message::LinkClicked);
row![ row![
editor, 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))
}
}

View file

@ -187,7 +187,7 @@ macro_rules! text {
#[macro_export] #[macro_export]
macro_rules! rich_text { macro_rules! rich_text {
() => ( () => (
$crate::Column::new() $crate::text::Rich::new()
); );
($($x:expr),+ $(,)?) => ( ($($x:expr),+ $(,)?) => (
$crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+]) $crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+])
@ -1155,9 +1155,9 @@ where
/// .into() /// .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, spans: impl AsRef<[text::Span<'a, Link, Renderer::Font>]> + 'a,
) -> text::Rich<'a, Link, Theme, Renderer> ) -> text::Rich<'a, Link, Message, Theme, Renderer>
where where
Link: Clone + 'static, Link: Clone + 'static,
Theme: text::Catalog + 'a, Theme: text::Catalog + 'a,

View file

@ -47,6 +47,7 @@
//! } //! }
//! } //! }
//! ``` //! ```
#![allow(missing_docs)]
use crate::core::border; use crate::core::border;
use crate::core::font::{self, Font}; use crate::core::font::{self, Font};
use crate::core::padding; use crate::core::padding;
@ -144,6 +145,7 @@ impl Content {
let mut state = State { let mut state = State {
leftover: String::new(), leftover: String::new(),
references: self.state.references.clone(), references: self.state.references.clone(),
images: HashSet::new(),
highlighter: None, highlighter: None,
}; };
@ -153,6 +155,7 @@ impl Content {
self.items[*index] = item; self.items[*index] = item;
} }
self.state.images.extend(state.images.drain());
drop(state); drop(state);
} }
@ -167,6 +170,11 @@ impl Content {
pub fn items(&self) -> &[Item] { pub fn items(&self) -> &[Item] {
&self.items &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. /// A Markdown item.
@ -187,130 +195,13 @@ pub enum Item {
/// The items of the list. /// The items of the list.
items: Vec<Vec<Item>>, items: Vec<Vec<Item>>,
}, },
} /// An image.
Image {
impl Item { /// The destination URL of the image.
/// Displays a Markdown [`Item`] using the default, built-in look for its children. url: Url,
pub fn view<'a, 'b, Theme, Renderer>( /// The title of the image.
&'b self, title: Text,
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(),
}
}
} }
/// A bunch of parsed Markdown text. /// A bunch of parsed Markdown text.
@ -470,6 +361,7 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {
struct State { struct State {
leftover: String, leftover: String,
references: HashMap<String, String>, references: HashMap<String, String>,
images: HashSet<Url>,
#[cfg(feature = "highlighter")] #[cfg(feature = "highlighter")]
highlighter: Option<Highlighter>, highlighter: Option<Highlighter>,
} }
@ -560,6 +452,10 @@ fn parse_with<'a>(
mut state: impl BorrowMut<State> + 'a, mut state: impl BorrowMut<State> + 'a,
markdown: &'a str, markdown: &'a str,
) -> impl Iterator<Item = (Item, &'a str, HashSet<String>)> + 'a { ) -> impl Iterator<Item = (Item, &'a str, HashSet<String>)> + 'a {
enum Scope {
List(List),
}
struct List { struct List {
start: Option<u64>, start: Option<u64>,
items: Vec<Vec<Item>>, items: Vec<Vec<Item>>,
@ -575,7 +471,8 @@ fn parse_with<'a>(
let mut metadata = false; let mut metadata = false;
let mut table = false; let mut table = false;
let mut link = None; let mut link = None;
let mut lists = Vec::new(); let mut image = None;
let mut stack = Vec::new();
#[cfg(feature = "highlighter")] #[cfg(feature = "highlighter")]
let mut highlighter = None; let mut highlighter = None;
@ -616,10 +513,18 @@ fn parse_with<'a>(
} }
let produce = move |state: &mut State, let produce = move |state: &mut State,
lists: &mut Vec<List>, stack: &mut Vec<Scope>,
item, item,
source: Range<usize>| { 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(); state.leftover = markdown[source.start..].to_owned();
Some(( Some((
@ -627,16 +532,6 @@ fn parse_with<'a>(
&markdown[source.start..source.end], &markdown[source.start..source.end],
broken_links.take(), 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 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 => { pulldown_cmark::Tag::List(first_item) if !metadata && !table => {
let prev = if spans.is_empty() { let prev = if spans.is_empty() {
None None
} else { } else {
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())), Item::Paragraph(Text::new(spans.drain(..).collect())),
source, source,
) )
}; };
lists.push(List { stack.push(Scope::List(List {
start: first_item, start: first_item,
items: Vec::new(), items: Vec::new(),
}); }));
prev prev
} }
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
lists if let Some(Scope::List(list)) = stack.last_mut() {
.last_mut() list.items.push(Vec::new());
.expect("list context") }
.items
.push(Vec::new());
None None
} }
pulldown_cmark::Tag::CodeBlock( pulldown_cmark::Tag::CodeBlock(
@ -726,7 +626,7 @@ fn parse_with<'a>(
} else { } else {
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())), Item::Paragraph(Text::new(spans.drain(..).collect())),
source, source,
) )
@ -748,7 +648,7 @@ fn parse_with<'a>(
pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => {
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::Heading(level, Text::new(spans.drain(..).collect())), Item::Heading(level, Text::new(spans.drain(..).collect())),
source, source,
) )
@ -770,12 +670,16 @@ fn parse_with<'a>(
None None
} }
pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { pulldown_cmark::TagEnd::Paragraph if !metadata && !table => {
produce( if spans.is_empty() {
state.borrow_mut(), None
&mut lists, } else {
Item::Paragraph(Text::new(spans.drain(..).collect())), produce(
source, state.borrow_mut(),
) &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())),
source,
)
}
} }
pulldown_cmark::TagEnd::Item if !metadata && !table => { pulldown_cmark::TagEnd::Item if !metadata && !table => {
if spans.is_empty() { if spans.is_empty() {
@ -783,18 +687,20 @@ fn parse_with<'a>(
} else { } else {
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())), Item::Paragraph(Text::new(spans.drain(..).collect())),
source, source,
) )
} }
} }
pulldown_cmark::TagEnd::List(_) if !metadata && !table => { pulldown_cmark::TagEnd::List(_) if !metadata && !table => {
let list = lists.pop().expect("list context"); let scope = stack.pop()?;
let Scope::List(list) = scope;
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::List { Item::List {
start: list.start, start: list.start,
items: list.items, items: list.items,
@ -802,6 +708,15 @@ fn parse_with<'a>(
source, 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 => { pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => {
#[cfg(feature = "highlighter")] #[cfg(feature = "highlighter")]
{ {
@ -810,7 +725,7 @@ fn parse_with<'a>(
produce( produce(
state.borrow_mut(), state.borrow_mut(),
&mut lists, &mut stack,
Item::CodeBlock(code.drain(..).collect()), Item::CodeBlock(code.drain(..).collect()),
source, source,
) )
@ -910,15 +825,25 @@ pub struct Settings {
pub code_size: Pixels, pub code_size: Pixels,
/// The spacing to be used between elements. /// The spacing to be used between elements.
pub spacing: Pixels, pub spacing: Pixels,
/// The styling of the Markdown.
pub style: Style,
} }
impl Settings { 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`]. /// Creates new [`Settings`] with the given base text size in [`Pixels`].
/// ///
/// Heading levels will be adjusted automatically. Specifically, /// Heading levels will be adjusted automatically. Specifically,
/// the first level will be twice the base size, and then every level /// the first level will be twice the base size, and then every level
/// after that will be 25% smaller. /// 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(); let text_size = text_size.into();
Self { Self {
@ -931,13 +856,14 @@ impl Settings {
h6_size: text_size, h6_size: text_size,
code_size: text_size * 0.75, code_size: text_size * 0.75,
spacing: text_size * 0.875, spacing: text_size * 0.875,
style: style.into(),
} }
} }
} }
impl Default for Settings { impl From<&Theme> for Settings {
fn default() -> Self { fn from(theme: &Theme) -> Self {
Self::with_text_size(16) 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. /// Display a bunch of Markdown items.
/// ///
/// You can obtain the items with [`parse`]. /// You can obtain the items with [`parse`].
@ -1015,16 +953,15 @@ impl Style {
/// } /// }
/// } /// }
/// ``` /// ```
pub fn view<'a, 'b, Theme, Renderer>( pub fn view<'a, Theme, Renderer>(
items: impl IntoIterator<Item = &'b Item>, settings: impl Into<Settings>,
settings: Settings, items: impl IntoIterator<Item = &'a Item>,
style: Style,
) -> Element<'a, Url, Theme, Renderer> ) -> Element<'a, Url, Theme, Renderer>
where where
Theme: Catalog + 'a, Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + '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 /// 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 /// You can use [`Item::view`] and [`Item::view_with`] for the default
/// look. /// look.
pub fn view_with<'a, 'b, Message, Theme, Renderer>( pub fn view_with<'a, Message, Theme, Renderer>(
items: impl IntoIterator<Item = &'b Item>, viewer: &impl Viewer<'a, Message, Theme, Renderer>,
settings: Settings, settings: impl Into<Settings>,
style: Style, items: impl IntoIterator<Item = &'a Item>,
view: &dyn View<'a, 'b, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> ) -> Element<'a, Message, Theme, Renderer>
where where
Message: 'a, Message: 'a,
Theme: Catalog + 'a, Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a, Renderer: core::text::Renderer<Font = Font> + 'a,
{ {
let settings = settings.into();
let blocks = items let blocks = items
.into_iter() .into_iter()
.enumerate() .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)) Element::new(column(blocks).spacing(settings.spacing))
} }
/// A view strategy to display a Markdown [`Item`]. pub fn item<'a, Message, Theme, Renderer>(
pub trait View<'a, 'b, Message, Theme, Renderer> { viewer: &impl Viewer<'a, Message, Theme, Renderer>,
/// Displays a Markdown [`Item`] by projecting it into an [`Element`]. settings: Settings,
/// item: &'a Item,
/// You can use [`Item::view`] and [`Item::view_with`] for the default index: usize,
/// look. ) -> Element<'a, Message, Theme, Renderer>
fn view( 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, &self,
settings: Settings, settings: Settings,
style: Style,
item: &'b Item,
index: usize, 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)] #[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 where
Theme: Catalog + 'a, Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a, Renderer: core::text::Renderer<Font = Font> + 'a,
{ {
fn view( fn on_link_clicked(url: Url) -> Url {
&self, url
settings: Settings,
style: Style,
item: &'b Item,
index: usize,
) -> Element<'a, Url, Theme, Renderer> {
item.view(settings, style, index)
} }
} }

View file

@ -3,6 +3,7 @@ use crate::core::layout;
use crate::core::mouse; use crate::core::mouse;
use crate::core::overlay; use crate::core::overlay;
use crate::core::renderer; use crate::core::renderer;
use crate::core::text;
use crate::core::widget; use crate::core::widget;
use crate::core::widget::tree::{self, Tree}; use crate::core::widget::tree::{self, Tree};
use crate::core::window; use crate::core::window;
@ -17,6 +18,7 @@ use crate::core::{
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
pub struct Pop<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { pub struct Pop<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> {
content: Element<'a, Message, Theme, Renderer>, content: Element<'a, Message, Theme, Renderer>,
key: Option<text::Fragment<'a>>,
on_show: Option<Box<dyn Fn(Size) -> Message + 'a>>, on_show: Option<Box<dyn Fn(Size) -> Message + 'a>>,
on_resize: Option<Box<dyn Fn(Size) -> Message + 'a>>, on_resize: Option<Box<dyn Fn(Size) -> Message + 'a>>,
on_hide: Option<Message>, on_hide: Option<Message>,
@ -34,6 +36,7 @@ where
) -> Self { ) -> Self {
Self { Self {
content: content.into(), content: content.into(),
key: None,
on_show: None, on_show: None,
on_resize: None, on_resize: None,
on_hide: None, on_hide: None,
@ -66,6 +69,14 @@ where
self 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 /// Sets the distance in [`Pixels`] to use in anticipation of the
/// content popping into view. /// content popping into view.
/// ///
@ -77,10 +88,11 @@ where
} }
} }
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Default)]
struct State { struct State {
has_popped_in: bool, has_popped_in: bool,
last_size: Option<Size>, last_size: Option<Size>,
last_key: Option<String>,
} }
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
@ -118,8 +130,16 @@ where
) { ) {
if let Event::Window(window::Event::RedrawRequested(_)) = &event { if let Event::Window(window::Event::RedrawRequested(_)) = &event {
let state = tree.state.downcast_mut::<State>(); 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 top_left_distance = viewport.distance(bounds.position());
let bottom_right_distance = viewport let bottom_right_distance = viewport

View file

@ -14,8 +14,13 @@ use crate::core::{
/// A bunch of [`Rich`] text. /// A bunch of [`Rich`] text.
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
pub struct Rich<'a, Link, Theme = crate::Theme, Renderer = crate::Renderer> pub struct Rich<
where 'a,
Link,
Message,
Theme = crate::Theme,
Renderer = crate::Renderer,
> where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
Renderer: core::text::Renderer, Renderer: core::text::Renderer,
@ -31,9 +36,11 @@ where
wrapping: Wrapping, wrapping: Wrapping,
class: Theme::Class<'a>, class: Theme::Class<'a>,
hovered_link: Option<usize>, 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 where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
@ -54,6 +61,7 @@ where
wrapping: Wrapping::default(), wrapping: Wrapping::default(),
class: Theme::default(), class: Theme::default(),
hovered_link: None, hovered_link: None,
on_link_clicked: None,
} }
} }
@ -127,6 +135,16 @@ where
self 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. /// Sets the default style of the [`Rich`] text.
#[must_use] #[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self 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 where
Link: Clone + 'a, Link: Clone + 'a,
Theme: Catalog, Theme: Catalog,
@ -182,8 +201,8 @@ struct State<Link, P: Paragraph> {
paragraph: P, paragraph: P,
} }
impl<Link, Theme, Renderer> Widget<Link, Theme, Renderer> impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Rich<'_, Link, Theme, Renderer> for Rich<'_, Link, Message, Theme, Renderer>
where where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
@ -252,7 +271,8 @@ where
let style = theme.style(&self.class); let style = theme.style(&self.class);
for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { 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() if span.highlight.is_some()
|| span.underline || span.underline
@ -363,9 +383,13 @@ where
cursor: mouse::Cursor, cursor: mouse::Cursor,
_renderer: &Renderer, _renderer: &Renderer,
_clipboard: &mut dyn Clipboard, _clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Link>, shell: &mut Shell<'_, Message>,
_viewport: &Rectangle, _viewport: &Rectangle,
) { ) {
let Some(on_link_clicked) = &self.on_link_clicked else {
return;
};
let was_hovered = self.hovered_link.is_some(); let was_hovered = self.hovered_link.is_some();
if let Some(position) = cursor.position_in(layout.bounds()) { if let Some(position) = cursor.position_in(layout.bounds()) {
@ -414,7 +438,7 @@ where
.get(span) .get(span)
.and_then(|span| span.link.clone()) .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>> impl<'a, Link, Message, Theme, Renderer>
for Rich<'a, Link, Theme, Renderer> FromIterator<Span<'a, Link, Renderer::Font>>
for Rich<'a, Link, Message, Theme, Renderer>
where where
Link: Clone + 'a, Link: Clone + 'a,
Theme: Catalog, Theme: Catalog,
@ -524,16 +549,18 @@ where
} }
} }
impl<'a, Link, Theme, Renderer> From<Rich<'a, Link, Theme, Renderer>> impl<'a, Link, Message, Theme, Renderer>
for Element<'a, Link, Theme, Renderer> From<Rich<'a, Link, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where where
Message: 'a,
Link: Clone + 'a, Link: Clone + 'a,
Theme: Catalog + 'a, Theme: Catalog + 'a,
Renderer: core::text::Renderer + 'a, Renderer: core::text::Renderer + 'a,
{ {
fn from( fn from(
text: Rich<'a, Link, Theme, Renderer>, text: Rich<'a, Link, Message, Theme, Renderer>,
) -> Element<'a, Link, Theme, Renderer> { ) -> Element<'a, Message, Theme, Renderer> {
Element::new(text) Element::new(text)
} }
} }