Merge pull request #2786 from iced-rs/customizable-markdown

Customizable Markdown Rendering and Image Support
This commit is contained in:
Héctor 2025-02-05 02:20:36 +01:00 committed by GitHub
commit 4bbb5cbc1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 881 additions and 237 deletions

11
Cargo.lock generated
View file

@ -770,9 +770,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.11"
version = "1.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf"
checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2"
dependencies = [
"jobserver",
"libc",
@ -877,9 +877,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.27"
version = "4.5.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796"
checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff"
dependencies = [
"clap_builder",
]
@ -3299,7 +3299,10 @@ name = "markdown"
version = "0.1.0"
dependencies = [
"iced",
"image",
"open",
"reqwest",
"tokio",
]
[[package]]

View file

@ -84,3 +84,12 @@ pub use vector::Vector;
pub use widget::Widget;
pub use smol_str::SmolStr;
/// A function that can _never_ be called.
///
/// This is useful to turn generic types into anything
/// you want by coercing them into a type with no possible
/// values.
pub fn never<T>(never: std::convert::Infallible) -> T {
match never {}
}

View file

@ -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)
}
}

View file

@ -79,3 +79,11 @@ impl std::ops::Div<f32> for Pixels {
Pixels(self.0 / rhs)
}
}
impl std::ops::Div<u32> for Pixels {
type Output = Pixels;
fn div(self, rhs: u32) -> Self {
Pixels(self.0 / rhs as f32)
}
}

View file

@ -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_click(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(description, self.theme())
.map(Message::UrlClicked);
let labels =
row(pull_request.labels.iter().map(|label| {
@ -348,11 +344,11 @@ impl Generator {
} else {
container(
scrollable(
markdown::view(
markdown(
preview,
markdown::Settings::with_text_size(12),
markdown::Style::from_palette(
self.theme().palette(),
markdown::Settings::with_text_size(
12,
self.theme(),
),
)
.map(Message::UrlClicked),

View file

@ -7,6 +7,17 @@ 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"
# Disabled to keep amount of build dependencies low
# This can be re-enabled on demand
# [build-dependencies]
# iced_fontello = "0.13"

View file

@ -0,0 +1,5 @@
pub fn main() {
// println!("cargo::rerun-if-changed=fonts/markdown-icons.toml");
// iced_fontello::build("fonts/markdown-icons.toml")
// .expect("Build icons font");
}

View file

@ -0,0 +1,4 @@
module = "icon"
[glyphs]
copy = "fontawesome-docs"

Binary file not shown.

View file

@ -0,0 +1,15 @@
// Generated automatically by iced_fontello at build time.
// Do not edit manually. Source: ../fonts/markdown-icons.toml
// dcd2f0c969d603e2ee9237a4b70fa86b1a6e84d86f4689046d8fdd10440b06b9
use iced::widget::{text, Text};
use iced::Font;
pub const FONT: &[u8] = include_bytes!("../fonts/markdown-icons.ttf");
pub fn copy<'a>() -> Text<'a> {
icon("\u{F0C5}")
}
fn icon(codepoint: &str) -> Text<'_> {
text(codepoint).font(Font::with_name("markdown-icons"))
}

View file

@ -1,50 +1,79 @@
mod icon;
use iced::animation;
use iced::clipboard;
use iced::highlighter;
use iced::time::{self, milliseconds};
use iced::task;
use iced::time::{self, milliseconds, Instant};
use iced::widget::{
self, hover, markdown, right, row, scrollable, text_editor, toggler,
self, button, center_x, container, horizontal_space, hover, image,
markdown, pop, right, row, scrollable, text_editor, toggler,
};
use iced::{Element, Fill, Font, Subscription, Task, Theme};
use iced::window;
use iced::{Animation, Element, Fill, Font, Subscription, Task, Theme};
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
pub fn main() -> iced::Result {
iced::application("Markdown - Iced", Markdown::update, Markdown::view)
.font(icon::FONT)
.subscription(Markdown::subscription)
.theme(Markdown::theme)
.run_with(Markdown::new)
}
struct Markdown {
content: text_editor::Content,
content: markdown::Content,
raw: text_editor::Content,
images: HashMap<markdown::Url, Image>,
mode: Mode,
theme: Theme,
now: Instant,
}
enum Mode {
Preview(Vec<markdown::Item>),
Stream {
pending: String,
parsed: markdown::Content,
Preview,
Stream { pending: String },
}
enum Image {
Loading {
_download: task::Handle,
},
Ready {
handle: image::Handle,
fade_in: Animation<bool>,
},
#[allow(dead_code)]
Errored(Error),
}
#[derive(Debug, Clone)]
enum Message {
Edit(text_editor::Action),
Copy(String),
LinkClicked(markdown::Url),
ImageShown(markdown::Url),
ImageDownloaded(markdown::Url, Result<image::Handle, Error>),
ToggleStream(bool),
NextToken,
Animate(Instant),
}
impl Markdown {
fn new() -> (Self, Task<Message>) {
const INITIAL_CONTENT: &str = include_str!("../overview.md");
let theme = Theme::TokyoNight;
(
Self {
content: text_editor::Content::with_text(INITIAL_CONTENT),
mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()),
theme,
content: markdown::Content::parse(INITIAL_CONTENT),
raw: text_editor::Content::with_text(INITIAL_CONTENT),
images: HashMap::new(),
mode: Mode::Preview,
theme: Theme::TokyoNight,
now: Instant::now(),
},
widget::focus_next(),
)
@ -55,26 +84,73 @@ impl Markdown {
Message::Edit(action) => {
let is_edit = action.is_edit();
self.content.perform(action);
self.raw.perform(action);
if is_edit {
self.mode = Mode::Preview(
markdown::parse(&self.content.text()).collect(),
);
self.content = markdown::Content::parse(&self.raw.text());
self.mode = Mode::Preview;
let images = self.content.images();
self.images.retain(|url, _image| images.contains(url));
}
Task::none()
}
Message::Copy(content) => clipboard::write(content),
Message::LinkClicked(link) => {
let _ = open::that_in_background(link.to_string());
Task::none()
}
Message::ImageShown(url) => {
if self.images.contains_key(&url) {
return Task::none();
}
let (download_image, handle) = Task::future({
let url = url.clone();
async move {
// Wait half a second for further editions before attempting download
tokio::time::sleep(milliseconds(500)).await;
download_image(url).await
}
})
.abortable();
let _ = self.images.insert(
url.clone(),
Image::Loading {
_download: handle.abort_on_drop(),
},
);
download_image.map(move |result| {
Message::ImageDownloaded(url.clone(), result)
})
}
Message::ImageDownloaded(url, result) => {
let _ = self.images.insert(
url,
result
.map(|handle| Image::Ready {
handle,
fade_in: Animation::new(false)
.quick()
.easing(animation::Easing::EaseInOut)
.go(true),
})
.unwrap_or_else(Image::Errored),
);
Task::none()
}
Message::ToggleStream(enable_stream) => {
if enable_stream {
self.content = markdown::Content::new();
self.mode = Mode::Stream {
pending: self.content.text(),
parsed: markdown::Content::new(),
pending: self.raw.text(),
};
scrollable::snap_to(
@ -82,24 +158,22 @@ impl Markdown {
scrollable::RelativeOffset::END,
)
} else {
self.mode = Mode::Preview(
markdown::parse(&self.content.text()).collect(),
);
self.mode = Mode::Preview;
Task::none()
}
}
Message::NextToken => {
match &mut self.mode {
Mode::Preview(_) => {}
Mode::Stream { pending, parsed } => {
Mode::Preview => {}
Mode::Stream { pending } => {
if pending.is_empty() {
self.mode = Mode::Preview(parsed.items().to_vec());
self.mode = Mode::Preview;
} else {
let mut tokens = pending.split(' ');
if let Some(token) = tokens.next() {
parsed.push_str(&format!("{token} "));
self.content.push_str(&format!("{token} "));
}
*pending = tokens.collect::<Vec<_>>().join(" ");
@ -107,13 +181,18 @@ impl Markdown {
}
}
Task::none()
}
Message::Animate(now) => {
self.now = now;
Task::none()
}
}
}
fn view(&self) -> Element<Message> {
let editor = text_editor(&self.content)
let editor = text_editor(&self.raw)
.placeholder("Type your Markdown here...")
.on_action(Message::Edit)
.height(Fill)
@ -121,17 +200,14 @@ impl Markdown {
.font(Font::MONOSPACE)
.highlight("markdown", highlighter::Theme::Base16Ocean);
let items = match &self.mode {
Mode::Preview(items) => items.as_slice(),
Mode::Stream { parsed, .. } => parsed.items(),
};
let preview = markdown(
items,
markdown::Settings::default(),
markdown::Style::from_palette(self.theme.palette()),
)
.map(Message::LinkClicked);
let preview = markdown::view_with(
self.content.items(),
&self.theme,
&CustomViewer {
images: &self.images,
now: self.now,
},
);
row![
editor,
@ -159,11 +235,146 @@ impl Markdown {
}
fn subscription(&self) -> Subscription<Message> {
match self.mode {
Mode::Preview(_) => Subscription::none(),
let listen_stream = match self.mode {
Mode::Preview => Subscription::none(),
Mode::Stream { .. } => {
time::every(milliseconds(10)).map(|_| Message::NextToken)
}
}
};
let animate = {
let is_animating = self.images.values().any(|image| match image {
Image::Ready { fade_in, .. } => fade_in.is_animating(self.now),
_ => false,
});
if is_animating {
window::frames().map(Message::Animate)
} else {
Subscription::none()
}
};
Subscription::batch([listen_stream, animate])
}
}
struct CustomViewer<'a> {
images: &'a HashMap<markdown::Url, Image>,
now: Instant,
}
impl<'a> markdown::Viewer<'a, Message> for CustomViewer<'a> {
fn on_link_click(url: markdown::Url) -> Message {
Message::LinkClicked(url)
}
fn image(
&self,
_settings: markdown::Settings,
url: &'a markdown::Url,
_title: &'a str,
_alt: &markdown::Text,
) -> Element<'a, Message> {
if let Some(Image::Ready { handle, fade_in }) = self.images.get(url) {
center_x(
image(handle)
.opacity(fade_in.interpolate(0.0, 1.0, self.now))
.scale(fade_in.interpolate(1.2, 1.0, self.now)),
)
.into()
} else {
pop(horizontal_space())
.key(url.as_str())
.on_show(|_size| Message::ImageShown(url.clone()))
.into()
}
}
fn code_block(
&self,
settings: markdown::Settings,
_language: Option<&'a str>,
code: &'a str,
lines: &'a [markdown::Text],
) -> Element<'a, Message> {
let code_block =
markdown::code_block(settings, lines, Message::LinkClicked);
let copy = button(icon::copy().size(12))
.padding(2)
.on_press_with(|| Message::Copy(code.to_owned()))
.style(button::text);
hover(
code_block,
right(container(copy).style(container::dark))
.padding(settings.spacing / 2),
)
}
}
async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> {
use std::io;
use tokio::task;
println!("Trying to download image: {url}");
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<tokio::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<tokio::task::JoinError> for Error {
fn from(error: tokio::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

@ -505,9 +505,9 @@ pub use crate::core::gradient;
pub use crate::core::padding;
pub use crate::core::theme;
pub use crate::core::{
Alignment, Animation, Background, Border, Color, ContentFit, Degrees,
Gradient, Length, Padding, Pixels, Point, Radians, Rectangle, Rotation,
Settings, Shadow, Size, Theme, Transformation, Vector,
never, Alignment, Animation, Background, Border, Color, ContentFit,
Degrees, Gradient, Length, Padding, Pixels, Point, Radians, Rectangle,
Rotation, Settings, Shadow, Size, Theme, Transformation, Vector,
};
pub use crate::runtime::exit;
pub use iced_futures::Subscription;

View file

@ -167,7 +167,7 @@ macro_rules! text {
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::font;
/// use iced::widget::{rich_text, span};
/// use iced::{color, Font};
/// use iced::{color, never, Font};
///
/// #[derive(Debug, Clone)]
/// enum Message {
@ -177,9 +177,10 @@ macro_rules! text {
/// fn view(state: &State) -> Element<'_, Message> {
/// rich_text![
/// span("I am red!").color(color!(0xff0000)),
/// " ",
/// span(" "),
/// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }),
/// ]
/// .on_link_click(never)
/// .size(20)
/// .into()
/// }
@ -187,7 +188,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)),+])
@ -1138,10 +1139,11 @@ where
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::font;
/// use iced::widget::{rich_text, span};
/// use iced::{color, Font};
/// use iced::{color, never, Font};
///
/// #[derive(Debug, Clone)]
/// enum Message {
/// LinkClicked(&'static str),
/// // ...
/// }
///
@ -1151,13 +1153,14 @@ where
/// span(" "),
/// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }),
/// ])
/// .on_link_click(never)
/// .size(20)
/// .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,
@ -1181,7 +1184,7 @@ where
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::font;
/// use iced::widget::{rich_text, span};
/// use iced::{color, Font};
/// use iced::{color, never, Font};
///
/// #[derive(Debug, Clone)]
/// enum Message {
@ -1194,6 +1197,7 @@ where
/// " ",
/// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }),
/// ]
/// .on_link_click(never)
/// .size(20)
/// .into()
/// }

View file

@ -29,13 +29,9 @@
//! }
//!
//! fn view(&self) -> Element<'_, Message> {
//! markdown::view(
//! &self.markdown,
//! markdown::Settings::default(),
//! markdown::Style::from_palette(Theme::TokyoNightStorm.palette()),
//! )
//! .map(Message::LinkClicked)
//! .into()
//! markdown::view(&self.markdown, Theme::TokyoNight)
//! .map(Message::LinkClicked)
//! .into()
//! }
//!
//! fn update(state: &mut State, message: Message) {
@ -59,6 +55,7 @@ use crate::{column, container, rich_text, row, scrollable, span, text};
use std::borrow::BorrowMut;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::mem;
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
@ -144,6 +141,7 @@ impl Content {
let mut state = State {
leftover: String::new(),
references: self.state.references.clone(),
images: HashSet::new(),
highlighter: None,
};
@ -153,6 +151,7 @@ impl Content {
self.items[*index] = item;
}
self.state.images.extend(state.images.drain());
drop(state);
}
@ -167,6 +166,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) -> &HashSet<Url> {
&self.state.images
}
}
/// A Markdown item.
@ -179,7 +183,14 @@ pub enum Item {
/// A code block.
///
/// You can enable the `highlighter` feature for syntax highlighting.
CodeBlock(Vec<Text>),
CodeBlock {
/// The language of the code block, if any.
language: Option<String>,
/// The raw code of the code block.
code: String,
/// The styled lines of text in the code block.
lines: Vec<Text>,
},
/// A list.
List {
/// The first number of the list, if it is ordered.
@ -187,6 +198,15 @@ pub enum Item {
/// The items of the list.
items: Vec<Vec<Item>>,
},
/// An image.
Image {
/// The destination URL of the image.
url: Url,
/// The title of the image.
title: String,
/// The alternative text of the image.
alt: Text,
},
}
/// A bunch of parsed Markdown text.
@ -319,13 +339,9 @@ impl Span {
/// }
///
/// fn view(&self) -> Element<'_, Message> {
/// markdown::view(
/// &self.markdown,
/// markdown::Settings::default(),
/// markdown::Style::from_palette(Theme::TokyoNightStorm.palette()),
/// )
/// .map(Message::LinkClicked)
/// .into()
/// markdown::view(&self.markdown, Theme::TokyoNight)
/// .map(Message::LinkClicked)
/// .into()
/// }
///
/// fn update(state: &mut State, message: Message) {
@ -346,6 +362,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>,
}
@ -367,7 +384,7 @@ impl Highlighter {
parser: iced_highlighter::Stream::new(
&iced_highlighter::Settings {
theme: iced_highlighter::Theme::Base16Ocean,
token: language.to_string(),
token: language.to_owned(),
},
),
language: language.to_owned(),
@ -436,6 +453,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>>,
@ -444,14 +465,17 @@ fn parse_with<'a>(
let broken_links = Rc::new(RefCell::new(HashSet::new()));
let mut spans = Vec::new();
let mut code = Vec::new();
let mut code = String::new();
let mut code_language = None;
let mut code_lines = Vec::new();
let mut strong = false;
let mut emphasis = false;
let mut strikethrough = false;
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;
@ -476,7 +500,7 @@ fn parse_with<'a>(
))
} else {
let _ = RefCell::borrow_mut(&broken_links)
.insert(broken_link.reference.to_string());
.insert(broken_link.reference.into_string());
None
}
@ -492,10 +516,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((
@ -503,16 +535,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
}
};
@ -549,35 +571,42 @@ fn parse_with<'a>(
None
}
pulldown_cmark::Tag::Image {
dest_url, title, ..
} if !metadata && !table => {
image = Url::parse(&dest_url)
.ok()
.map(|url| (url, title.into_string()));
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(
pulldown_cmark::CodeBlockKind::Fenced(_language),
pulldown_cmark::CodeBlockKind::Fenced(language),
) if !metadata && !table => {
#[cfg(feature = "highlighter")]
{
@ -587,9 +616,9 @@ fn parse_with<'a>(
.highlighter
.take()
.filter(|highlighter| {
highlighter.language == _language.as_ref()
highlighter.language == language.as_ref()
})
.unwrap_or_else(|| Highlighter::new(&_language));
.unwrap_or_else(|| Highlighter::new(&language));
highlighter.prepare();
@ -597,12 +626,15 @@ fn parse_with<'a>(
});
}
code_language =
(!language.is_empty()).then(|| language.into_string());
let prev = if spans.is_empty() {
None
} else {
produce(
state.borrow_mut(),
&mut lists,
&mut stack,
Item::Paragraph(Text::new(spans.drain(..).collect())),
source,
)
@ -624,7 +656,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,
)
@ -646,12 +678,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() {
@ -659,18 +695,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,
@ -678,6 +716,20 @@ fn parse_with<'a>(
source,
)
}
pulldown_cmark::TagEnd::Image if !metadata && !table => {
let (url, title) = image.take()?;
let alt = Text::new(spans.drain(..).collect());
let state = state.borrow_mut();
let _ = state.images.insert(url.clone());
produce(
state,
&mut stack,
Item::Image { url, title, alt },
source,
)
}
pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => {
#[cfg(feature = "highlighter")]
{
@ -686,8 +738,12 @@ fn parse_with<'a>(
produce(
state.borrow_mut(),
&mut lists,
Item::CodeBlock(code.drain(..).collect()),
&mut stack,
Item::CodeBlock {
language: code_language.take(),
code: mem::take(&mut code),
lines: code_lines.drain(..).collect(),
},
source,
)
}
@ -704,8 +760,10 @@ fn parse_with<'a>(
pulldown_cmark::Event::Text(text) if !metadata && !table => {
#[cfg(feature = "highlighter")]
if let Some(highlighter) = &mut highlighter {
code.push_str(&text);
for line in text.lines() {
code.push(Text::new(
code_lines.push(Text::new(
highlighter.highlight_line(line).to_vec(),
));
}
@ -786,15 +844,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 {
@ -807,13 +875,20 @@ 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))
}
}
impl From<Theme> for Settings {
fn from(theme: Theme) -> Self {
Self::with_style(Style::from(theme))
}
}
@ -845,6 +920,24 @@ 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())
}
}
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`].
@ -873,13 +966,9 @@ impl Style {
/// }
///
/// fn view(&self) -> Element<'_, Message> {
/// markdown::view(
/// &self.markdown,
/// markdown::Settings::default(),
/// markdown::Style::from_palette(Theme::TokyoNightStorm.palette()),
/// )
/// .map(Message::LinkClicked)
/// .into()
/// markdown::view(&self.markdown, Theme::TokyoNight)
/// .map(Message::LinkClicked)
/// .into()
/// }
///
/// fn update(state: &mut State, message: Message) {
@ -891,109 +980,345 @@ 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>(
items: impl IntoIterator<Item = &'a Item>,
settings: impl Into<Settings>,
) -> Element<'a, Url, Theme, Renderer>
where
Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
view_with(items, settings, &DefaultViewer)
}
/// Runs [`view`] but with a custom [`Viewer`] to turn an [`Item`] into
/// an [`Element`].
///
/// This is useful if you want to customize the look of certain Markdown
/// elements.
pub fn view_with<'a, Message, Theme, Renderer>(
items: impl IntoIterator<Item = &'a Item>,
settings: impl Into<Settings>,
viewer: &impl Viewer<'a, Message, Theme, Renderer>,
) -> 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(|(i, item_)| item(viewer, settings, item_, i));
Element::new(column(blocks).spacing(settings.spacing))
}
/// Displays an [`Item`] using the given [`Viewer`].
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 { url, title, alt } => {
viewer.image(settings, url, title, alt)
}
Item::Heading(level, text) => {
viewer.heading(settings, level, text, index)
}
Item::Paragraph(text) => viewer.paragraph(settings, text),
Item::CodeBlock {
language,
code,
lines,
} => viewer.code_block(settings, language.as_deref(), code, lines),
Item::List { start: None, items } => {
viewer.unordered_list(settings, items)
}
Item::List {
start: Some(start),
items,
} => viewer.ordered_list(settings, *start, items),
}
}
/// Displays a heading using the default look.
pub fn heading<'a, Message, Theme, Renderer>(
settings: Settings,
level: &'a HeadingLevel,
text: &'a Text,
index: usize,
on_link_click: 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 {
text_size,
h1_size,
h2_size,
h3_size,
h4_size,
h5_size,
h6_size,
code_size,
spacing,
text_size,
..
} = settings;
let blocks = items.into_iter().enumerate().map(|(i, item)| match item {
Item::Heading(level, heading) => {
container(rich_text(heading.spans(style)).size(match level {
container(
rich_text(text.spans(settings.style))
.on_link_click(on_link_click)
.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 i > 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(
items,
Settings {
spacing: settings.spacing * 0.6,
..settings
},
style
)
]
.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(
items,
Settings {
spacing: settings.spacing * 0.6,
..settings
},
style
)
]
.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(),
});
}),
)
.padding(padding::top(if index > 0 {
text_size / 2.0
} else {
Pixels::ZERO
}))
.into()
}
Element::new(column(blocks).spacing(spacing))
/// Displays a paragraph using the default look.
pub fn paragraph<'a, Message, Theme, Renderer>(
settings: Settings,
text: &'a Text,
on_link_click: 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_click(on_link_click)
.into()
}
/// Displays an unordered list using the default look and
/// calling the [`Viewer`] for each bullet point item.
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(
items,
Settings {
spacing: settings.spacing * 0.6,
..settings
},
viewer,
)
]
.spacing(settings.spacing)
.into()
}))
.spacing(settings.spacing * 0.75)
.padding([0.0, settings.spacing.0])
.into()
}
/// Displays an ordered list using the default look and
/// calling the [`Viewer`] for each numbered item.
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(
items,
Settings {
spacing: settings.spacing * 0.6,
..settings
},
viewer,
)
]
.spacing(settings.spacing)
.into()
}))
.spacing(settings.spacing * 0.75)
.padding([0.0, settings.spacing.0])
.into()
}
/// Displays a code block using the default look.
pub fn code_block<'a, Message, Theme, Renderer>(
settings: Settings,
lines: &'a [Text],
on_link_click: 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_click(on_link_click.clone())
.font(Font::MONOSPACE)
.size(settings.code_size)
.into()
})))
.padding(settings.code_size),
)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default()
.width(settings.code_size / 2)
.scroller_width(settings.code_size / 2),
)),
)
.width(Length::Fill)
.padding(settings.code_size / 4)
.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,
{
/// Produces a message when a link is clicked with the given [`Url`].
fn on_link_click(url: Url) -> Message;
/// Displays an image.
///
/// By default, it will show a container with the image title.
fn image(
&self,
settings: Settings,
url: &'a Url,
title: &'a str,
alt: &Text,
) -> Element<'a, Message, Theme, Renderer> {
let _url = url;
let _title = title;
container(
rich_text(alt.spans(settings.style))
.on_link_click(Self::on_link_click),
)
.padding(settings.spacing.0)
.class(Theme::code_block())
.into()
}
/// Displays a heading.
///
/// By default, it calls [`heading`].
fn heading(
&self,
settings: Settings,
level: &'a HeadingLevel,
text: &'a Text,
index: usize,
) -> Element<'a, Message, Theme, Renderer> {
heading(settings, level, text, index, Self::on_link_click)
}
/// Displays a paragraph.
///
/// By default, it calls [`paragraph`].
fn paragraph(
&self,
settings: Settings,
text: &'a Text,
) -> Element<'a, Message, Theme, Renderer> {
paragraph(settings, text, Self::on_link_click)
}
/// Displays a code block.
///
/// By default, it calls [`code_block`].
fn code_block(
&self,
settings: Settings,
language: Option<&'a str>,
code: &'a str,
lines: &'a [Text],
) -> Element<'a, Message, Theme, Renderer> {
let _language = language;
let _code = code;
code_block(settings, lines, Self::on_link_click)
}
/// Displays an unordered list.
///
/// By default, it calls [`unordered_list`].
fn unordered_list(
&self,
settings: Settings,
items: &'a [Vec<Item>],
) -> Element<'a, Message, Theme, Renderer> {
unordered_list(self, settings, items)
}
/// Displays an ordered list.
///
/// By default, it calls [`ordered_list`].
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 DefaultViewer;
impl<'a, Theme, Renderer> Viewer<'a, Url, Theme, Renderer> for DefaultViewer
where
Theme: Catalog + 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
fn on_link_click(url: Url) -> Url {
url
}
}
/// The theme catalog of Markdown items.

View file

@ -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

View file

@ -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_click: 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_click: 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_click(
mut self,
on_link_clicked: impl Fn(Link) -> Message + 'a,
) -> Self {
self.on_link_click = 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_click.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_click 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)
}
}