From 798e77777200f5907b92571ce1a62dd5af9545b1 Mon Sep 17 00:00:00 2001 From: pml68 Date: Thu, 24 Jul 2025 21:35:06 +0200 Subject: [PATCH 1/5] feat: add support for *non-interactive* checkmarks in `markdown` --- widget/src/markdown.rs | 91 +++++++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 7694bb5a..50882a77 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -51,7 +51,9 @@ use crate::core::theme; use crate::core::{ self, Color, Element, Length, Padding, Pixels, Theme, color, }; -use crate::{column, container, rich_text, row, rule, scrollable, span, text}; +use crate::{ + checkbox, column, container, rich_text, row, rule, scrollable, span, text, +}; use std::borrow::BorrowMut; use std::cell::{Cell, RefCell}; @@ -208,7 +210,7 @@ pub enum Item { /// The first number of the list, if it is ordered. start: Option, /// The items of the list. - items: Vec>, + items: Vec, }, /// An image. Image { @@ -350,6 +352,19 @@ impl Span { } } +/// The item of a list. +#[derive(Debug, Clone)] +pub struct ListItem { + checked: Option, + items: Vec, +} + +impl ListItem { + fn push(&mut self, item: Item) { + self.items.push(item); + } +} + /// Parse the given Markdown content. /// /// # Example @@ -503,7 +518,7 @@ fn parse_with<'a>( struct List { start: Option, - items: Vec>, + items: Vec, } let broken_links = Rc::new(RefCell::new(HashSet::new())); @@ -529,7 +544,8 @@ fn parse_with<'a>( pulldown_cmark::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | pulldown_cmark::Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS | pulldown_cmark::Options::ENABLE_TABLES - | pulldown_cmark::Options::ENABLE_STRIKETHROUGH, + | pulldown_cmark::Options::ENABLE_STRIKETHROUGH + | pulldown_cmark::Options::ENABLE_TASKLISTS, { let references = state.borrow().references.clone(); let broken_links = broken_links.clone(); @@ -637,7 +653,10 @@ fn parse_with<'a>( } pulldown_cmark::Tag::Item => { if let Some(Scope::List(list)) = stack.last_mut() { - list.items.push(Vec::new()); + list.items.push(ListItem { + checked: None, + items: Vec::new(), + }); } None @@ -970,6 +989,15 @@ fn parse_with<'a>( pulldown_cmark::Event::Rule => { produce(state.borrow_mut(), &mut stack, Item::Rule, source) } + pulldown_cmark::Event::TaskListMarker(checked) => { + if let Some(Scope::List(list)) = stack.last_mut() + && let Some(item) = list.items.last_mut() + { + item.checked = Some(checked); + } + + None + } _ => None, }) } @@ -1280,18 +1308,28 @@ where pub fn unordered_list<'a, Message, Theme, Renderer>( viewer: &impl Viewer<'a, Message, Theme, Renderer>, settings: Settings, - items: &'a [Vec], + items: &'a [ListItem], ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { - column(items.iter().map(|items| { + column(items.iter().map(|list_item| { row![ - text("•").size(settings.text_size), + list_item + .checked + .map(|is_checked| Element::from( + checkbox(is_checked).text_size(settings.text_size) + )) + .unwrap_or_else(|| Element::from( + text("•") + .width(settings.text_size) + .center() + .size(settings.text_size) + )), view_with( - items, + &list_item.items, Settings { spacing: settings.spacing * 0.6, ..settings @@ -1313,7 +1351,7 @@ pub fn ordered_list<'a, Message, Theme, Renderer>( viewer: &impl Viewer<'a, Message, Theme, Renderer>, settings: Settings, start: u64, - items: &'a [Vec], + items: &'a [ListItem], ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, @@ -1322,14 +1360,30 @@ where { let digits = ((start + items.len() as u64).max(1) as f32).log10().ceil(); - column(items.iter().enumerate().map(|(i, items)| { + column(items.iter().enumerate().map(|(i, list_item)| { row![ - text!("{}.", i as u64 + start) - .size(settings.text_size) - .align_x(alignment::Horizontal::Right) - .width(settings.text_size * ((digits / 2.0).ceil() + 1.0)), + list_item + .checked + .map(|is_checked| { + Element::from( + container( + checkbox(is_checked).text_size(settings.text_size), + ) + .align_right( + settings.text_size * ((digits / 2.0).ceil() + 1.0), + ), + ) + }) + .unwrap_or_else(|| Element::from( + text!("{}.", i as u64 + start) + .size(settings.text_size) + .align_x(alignment::Horizontal::Right) + .width( + settings.text_size * ((digits / 2.0).ceil() + 1.0) + ), + )), view_with( - items, + &list_item.items, Settings { spacing: settings.spacing * 0.6, ..settings @@ -1568,7 +1622,7 @@ where fn unordered_list( &self, settings: Settings, - items: &'a [Vec], + items: &'a [ListItem], ) -> Element<'a, Message, Theme, Renderer> { unordered_list(self, settings, items) } @@ -1580,7 +1634,7 @@ where &self, settings: Settings, start: u64, - items: &'a [Vec], + items: &'a [ListItem], ) -> Element<'a, Message, Theme, Renderer> { ordered_list(self, settings, start, items) } @@ -1638,6 +1692,7 @@ pub trait Catalog: + scrollable::Catalog + text::Catalog + crate::rule::Catalog + + checkbox::Catalog + crate::table::Catalog { /// The styling class of a Markdown code block. From 6c271541b0aa0190b9d0170ecaa4b68aad8f03f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 29 Nov 2025 13:22:28 +0100 Subject: [PATCH 2/5] Refactor `ListItem` into `Bullet` in `markdown` widget --- widget/src/markdown.rs | 127 ++++++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 50882a77..de154c0a 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -210,7 +210,7 @@ pub enum Item { /// The first number of the list, if it is ordered. start: Option, /// The items of the list. - items: Vec, + bullets: Vec, }, /// An image. Image { @@ -354,14 +354,32 @@ impl Span { /// The item of a list. #[derive(Debug, Clone)] -pub struct ListItem { - checked: Option, - items: Vec, +pub enum Bullet { + /// A simple bullet point. + Point { + /// The contents of the bullet point. + items: Vec, + }, + /// A task. + Task { + /// The contents of the task. + items: Vec, + /// Whether the task is done or not. + done: bool, + }, } -impl ListItem { +impl Bullet { + fn items(&self) -> &[Item] { + match self { + Bullet::Point { items } | Bullet::Task { items, .. } => items, + } + } + fn push(&mut self, item: Item) { - self.items.push(item); + let (Bullet::Point { items } | Bullet::Task { items, .. }) = self; + + items.push(item); } } @@ -518,7 +536,7 @@ fn parse_with<'a>( struct List { start: Option, - items: Vec, + bullets: Vec, } let broken_links = Rc::new(RefCell::new(HashSet::new())); @@ -582,7 +600,7 @@ fn parse_with<'a>( if let Some(scope) = stack.last_mut() { match scope { Scope::List(list) => { - list.items.last_mut().expect("item context").push(item); + list.bullets.last_mut().expect("item context").push(item); } Scope::Quote(items) => { items.push(item); @@ -646,17 +664,14 @@ fn parse_with<'a>( stack.push(Scope::List(List { start: first_item, - items: Vec::new(), + bullets: Vec::new(), })); prev } pulldown_cmark::Tag::Item => { if let Some(Scope::List(list)) = stack.last_mut() { - list.items.push(ListItem { - checked: None, - items: Vec::new(), - }); + list.bullets.push(Bullet::Point { items: Vec::new() }); } None @@ -800,7 +815,7 @@ fn parse_with<'a>( &mut stack, Item::List { start: list.start, - items: list.items, + bullets: list.bullets, }, source, ) @@ -989,11 +1004,15 @@ fn parse_with<'a>( pulldown_cmark::Event::Rule => { produce(state.borrow_mut(), &mut stack, Item::Rule, source) } - pulldown_cmark::Event::TaskListMarker(checked) => { + pulldown_cmark::Event::TaskListMarker(done) => { if let Some(Scope::List(list)) = stack.last_mut() - && let Some(item) = list.items.last_mut() + && let Some(item) = list.bullets.last_mut() + && let Bullet::Point { items } = item { - item.checked = Some(checked); + *item = Bullet::Task { + items: std::mem::take(items), + done, + }; } None @@ -1229,13 +1248,14 @@ where code, lines, } => viewer.code_block(settings, language.as_deref(), code, lines), - Item::List { start: None, items } => { - viewer.unordered_list(settings, items) - } + Item::List { + start: None, + bullets, + } => viewer.unordered_list(settings, bullets), Item::List { start: Some(start), - items, - } => viewer.ordered_list(settings, *start, items), + bullets, + } => viewer.ordered_list(settings, *start, bullets), Item::Quote(quote) => viewer.quote(settings, quote), Item::Rule => viewer.rule(settings), Item::Table { columns, rows } => viewer.table(settings, columns, rows), @@ -1308,28 +1328,29 @@ where pub fn unordered_list<'a, Message, Theme, Renderer>( viewer: &impl Viewer<'a, Message, Theme, Renderer>, settings: Settings, - items: &'a [ListItem], + bullets: &'a [Bullet], ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { - column(items.iter().map(|list_item| { + column(bullets.iter().map(|bullet| { row![ - list_item - .checked - .map(|is_checked| Element::from( - checkbox(is_checked).text_size(settings.text_size) - )) - .unwrap_or_else(|| Element::from( + match bullet { + Bullet::Point { .. } => { text("•") .width(settings.text_size) .center() .size(settings.text_size) - )), + .into() + } + Bullet::Task { done, .. } => { + Element::from(checkbox(*done).size(settings.text_size)) + } + }, view_with( - &list_item.items, + bullet.items(), Settings { spacing: settings.spacing * 0.6, ..settings @@ -1351,39 +1372,25 @@ pub fn ordered_list<'a, Message, Theme, Renderer>( viewer: &impl Viewer<'a, Message, Theme, Renderer>, settings: Settings, start: u64, - items: &'a [ListItem], + bullets: &'a [Bullet], ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { - let digits = ((start + items.len() as u64).max(1) as f32).log10().ceil(); + let digits = ((start + bullets.len() as u64).max(1) as f32) + .log10() + .ceil(); - column(items.iter().enumerate().map(|(i, list_item)| { + column(bullets.iter().enumerate().map(|(i, bullet)| { row![ - list_item - .checked - .map(|is_checked| { - Element::from( - container( - checkbox(is_checked).text_size(settings.text_size), - ) - .align_right( - settings.text_size * ((digits / 2.0).ceil() + 1.0), - ), - ) - }) - .unwrap_or_else(|| Element::from( - text!("{}.", i as u64 + start) - .size(settings.text_size) - .align_x(alignment::Horizontal::Right) - .width( - settings.text_size * ((digits / 2.0).ceil() + 1.0) - ), - )), + text!("{}.", i as u64 + start) + .size(settings.text_size) + .align_x(alignment::Horizontal::Right) + .width(settings.text_size * ((digits / 2.0).ceil() + 1.0),), view_with( - &list_item.items, + bullet.items(), Settings { spacing: settings.spacing * 0.6, ..settings @@ -1622,9 +1629,9 @@ where fn unordered_list( &self, settings: Settings, - items: &'a [ListItem], + bullets: &'a [Bullet], ) -> Element<'a, Message, Theme, Renderer> { - unordered_list(self, settings, items) + unordered_list(self, settings, bullets) } /// Displays an ordered list. @@ -1634,9 +1641,9 @@ where &self, settings: Settings, start: u64, - items: &'a [ListItem], + bullets: &'a [Bullet], ) -> Element<'a, Message, Theme, Renderer> { - ordered_list(self, settings, start, items) + ordered_list(self, settings, start, bullets) } /// Displays a quote. From e27b8cba8e91c022b2b6d22f871bb5c65badd004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 29 Nov 2025 13:22:47 +0100 Subject: [PATCH 3/5] Center task checkboxes vertically in `markdown` widget --- widget/src/markdown.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index de154c0a..e3275347 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -1346,7 +1346,13 @@ where .into() } Bullet::Task { done, .. } => { - Element::from(checkbox(*done).size(settings.text_size)) + Element::from( + container(checkbox(*done).size(settings.text_size)) + .center_y( + text::LineHeight::default() + .to_absolute(settings.text_size), + ), + ) } }, view_with( From 696f912d3da5c3c11bdf940dcb5a4070f50185ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 29 Nov 2025 13:29:24 +0100 Subject: [PATCH 4/5] Remove `width` and `center` from `Bullet::Point` Mixing bullet points and tasks is rare. --- widget/src/markdown.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index e3275347..bb660fe4 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -1339,11 +1339,7 @@ where row![ match bullet { Bullet::Point { .. } => { - text("•") - .width(settings.text_size) - .center() - .size(settings.text_size) - .into() + text("•").size(settings.text_size).into() } Bullet::Task { done, .. } => { Element::from( From 8c29cbc3d9517b74097aab29333baf9d24c32f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 29 Nov 2025 13:30:35 +0100 Subject: [PATCH 5/5] Remove superfluous comma in `markdown` --- widget/src/markdown.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index bb660fe4..ab3000e7 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -1390,7 +1390,7 @@ where text!("{}.", i as u64 + start) .size(settings.text_size) .align_x(alignment::Horizontal::Right) - .width(settings.text_size * ((digits / 2.0).ceil() + 1.0),), + .width(settings.text_size * ((digits / 2.0).ceil() + 1.0)), view_with( bullet.items(), Settings {