From cfbeb05e32914ed951b7ce4afd131ef75b3cfb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 01:46:04 +0100 Subject: [PATCH 01/19] Fix code block merging with previous spans in `markdown` widget --- widget/src/markdown.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index c0648e9e10..fe61d631a4 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -344,7 +344,16 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { })); } - None + let prev = if spans.is_empty() { + None + } else { + produce( + &mut lists, + Item::Paragraph(Text::new(spans.drain(..).collect())), + ) + }; + + prev } pulldown_cmark::Tag::MetadataBlock(_) => { metadata = true; From d49d4dc3fa58482ab0269ac678134fa6f360396a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 01:46:52 +0100 Subject: [PATCH 02/19] Make `spacing` configurable in `markdown::Settings` --- widget/src/markdown.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index fe61d631a4..252a3e1aa5 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -527,6 +527,8 @@ pub struct Settings { pub h6_size: Pixels, /// The text size used in code blocks. pub code_size: Pixels, + /// The spacing to be used between elements. + pub spacing: Pixels, } impl Settings { @@ -547,6 +549,7 @@ impl Settings { h5_size: text_size, h6_size: text_size, code_size: text_size * 0.75, + spacing: text_size * 0.875, } } } @@ -649,10 +652,9 @@ where h5_size, h6_size, code_size, + spacing, } = settings; - let spacing = text_size * 0.625; - let blocks = items.into_iter().enumerate().map(|(i, item)| match item { Item::Heading(level, heading) => { container(rich_text(heading.spans(style)).size(match level { @@ -675,11 +677,21 @@ where } Item::List { start: None, items } => { column(items.iter().map(|items| { - row![text("•").size(text_size), view(items, settings, style)] - .spacing(spacing) - .into() + row![ + text("•").size(text_size), + view( + items, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + style + ) + ] + .spacing(spacing) + .into() })) - .spacing(spacing) + .spacing(spacing * 0.75) .into() } Item::List { @@ -688,12 +700,19 @@ where } => column(items.iter().enumerate().map(|(i, items)| { row![ text!("{}.", i as u64 + *start).size(text_size), - view(items, settings, style) + view( + items, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + style + ) ] .spacing(spacing) .into() })) - .spacing(spacing) + .spacing(spacing * 0.75) .into(), Item::CodeBlock(code) => container( scrollable( From aa0f0e73aa06242b8228fd81df8acaac6f377b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 01:47:10 +0100 Subject: [PATCH 03/19] Let `markdown::view` be `Shrink` when no code blocks exist --- 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 252a3e1aa5..2b7bc0fc7f 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -735,7 +735,7 @@ where .into(), }); - Element::new(column(blocks).width(Length::Fill).spacing(text_size)) + Element::new(column(blocks).spacing(spacing)) } /// The theme catalog of Markdown items. From ea8696eac2049fc19ea6ce5849922a002123ac37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 02:47:14 +0100 Subject: [PATCH 04/19] Use `Into` for `container::Id` arguments --- widget/src/container.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/widget/src/container.rs b/widget/src/container.rs index a411a7d222..852481f1d7 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -107,8 +107,8 @@ where } /// Sets the [`Id`] of the [`Container`]. - pub fn id(mut self, id: Id) -> Self { - self.id = Some(id); + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); self } @@ -480,9 +480,17 @@ impl From for widget::Id { } } +impl From<&'static str> for Id { + fn from(value: &'static str) -> Self { + Id::new(value) + } +} + /// Produces a [`Task`] that queries the visible screen bounds of the /// [`Container`] with the given [`Id`]. -pub fn visible_bounds(id: Id) -> Task> { +pub fn visible_bounds(id: impl Into) -> Task> { + let id = id.into(); + struct VisibleBounds { target: widget::Id, depth: usize, From a7bc1e7da4a456b6fa53a7f9f86a6f1f0a172029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 02:47:59 +0100 Subject: [PATCH 05/19] Avoid capturing mouse press when `text_editor` is unfocused --- widget/src/text_editor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index a347076861..502b5de949 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -768,6 +768,10 @@ where } } + if !matches!(binding, Binding::Unfocus) { + shell.capture_event(); + } + apply_binding( binding, self.content, @@ -780,8 +784,6 @@ where if let Some(focus) = &mut state.focus { focus.updated_at = Instant::now(); } - - shell.capture_event(); } } } From fb87c971591042228c6ba5286e9d2efaf9852517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 02:52:24 +0100 Subject: [PATCH 06/19] Use `Into` for `scrollable::Id` arguments --- widget/src/scrollable.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index b08d5d09eb..8ac70da87c 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -144,8 +144,8 @@ where } /// Sets the [`Id`] of the [`Scrollable`]. - pub fn id(mut self, id: Id) -> Self { - self.id = Some(id); + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); self } @@ -1228,25 +1228,36 @@ impl From for widget::Id { } } +impl From<&'static str> for Id { + fn from(id: &'static str) -> Self { + Self::new(id) + } +} + /// Produces a [`Task`] that snaps the [`Scrollable`] with the given [`Id`] /// to the provided [`RelativeOffset`]. -pub fn snap_to(id: Id, offset: RelativeOffset) -> Task { - task::effect(Action::widget(operation::scrollable::snap_to(id.0, offset))) +pub fn snap_to(id: impl Into, offset: RelativeOffset) -> Task { + task::effect(Action::widget(operation::scrollable::snap_to( + id.into().0, + offset, + ))) } /// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] /// to the provided [`AbsoluteOffset`]. -pub fn scroll_to(id: Id, offset: AbsoluteOffset) -> Task { +pub fn scroll_to(id: impl Into, offset: AbsoluteOffset) -> Task { task::effect(Action::widget(operation::scrollable::scroll_to( - id.0, offset, + id.into().0, + offset, ))) } /// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] /// by the provided [`AbsoluteOffset`]. -pub fn scroll_by(id: Id, offset: AbsoluteOffset) -> Task { +pub fn scroll_by(id: impl Into, offset: AbsoluteOffset) -> Task { task::effect(Action::widget(operation::scrollable::scroll_by( - id.0, offset, + id.into().0, + offset, ))) } From 6aab76e3a0ce219a950f7214cd2ab68171c5df00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 30 Jan 2025 03:45:14 +0100 Subject: [PATCH 07/19] Add `min_height` and `max_height` to `text_editor` --- widget/src/text_editor.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index 502b5de949..f1ec589b04 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -110,6 +110,8 @@ pub struct TextEditor< line_height: LineHeight, width: Length, height: Length, + min_height: f32, + max_height: f32, padding: Padding, wrapping: Wrapping, class: Theme::Class<'a>, @@ -139,6 +141,8 @@ where line_height: LineHeight::default(), width: Length::Fill, height: Length::Shrink, + min_height: 0.0, + max_height: f32::INFINITY, padding: Padding::new(5.0), wrapping: Wrapping::default(), class: Theme::default(), @@ -169,15 +173,27 @@ where self } + /// Sets the width of the [`TextEditor`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = Length::from(width.into()); + self + } + /// Sets the height of the [`TextEditor`]. pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } - /// Sets the width of the [`TextEditor`]. - pub fn width(mut self, width: impl Into) -> Self { - self.width = Length::from(width.into()); + /// Sets the minimum height of the [`TextEditor`]. + pub fn min_height(mut self, min_height: impl Into) -> Self { + self.min_height = min_height.into().0; + self + } + + /// Sets the maximum height of the [`TextEditor`]. + pub fn max_height(mut self, max_height: impl Into) -> Self { + self.max_height = max_height.into().0; self } @@ -265,6 +281,8 @@ where line_height: self.line_height, width: self.width, height: self.height, + min_height: self.min_height, + max_height: self.max_height, padding: self.padding, wrapping: self.wrapping, class: self.class, @@ -549,7 +567,11 @@ where state.highlighter_settings = self.highlighter_settings.clone(); } - let limits = limits.width(self.width).height(self.height); + let limits = limits + .width(self.width) + .height(self.height) + .min_height(self.min_height) + .max_height(self.max_height); internal.editor.update( limits.shrink(self.padding).max(), From 128058ea948909c21a9cfd0b58cbd3a13e238e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 31 Jan 2025 17:35:38 +0100 Subject: [PATCH 08/19] Draft incremental `markdown` parsing Specially useful when dealing with long Markdown streams, like LLMs. --- examples/markdown/Cargo.toml | 2 +- examples/markdown/src/main.rs | 96 +++++++++++++++++++++++++++++---- widget/src/markdown.rs | 99 +++++++++++++++++++++++++++++------ 3 files changed, 168 insertions(+), 29 deletions(-) diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index cb74b954b0..fa6ced741e 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -7,6 +7,6 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["markdown", "highlighter", "debug"] +iced.features = ["markdown", "highlighter", "tokio", "debug"] open = "5.3" diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 5605478fb4..a55e91d2ab 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1,23 +1,37 @@ use iced::highlighter; -use iced::widget::{self, markdown, row, scrollable, text_editor}; -use iced::{Element, Fill, Font, Task, Theme}; +use iced::time::{self, milliseconds}; +use iced::widget::{ + self, hover, markdown, right, row, scrollable, text_editor, toggler, +}; +use iced::{Element, Fill, Font, Subscription, Task, Theme}; pub fn main() -> iced::Result { iced::application("Markdown - Iced", Markdown::update, Markdown::view) + .subscription(Markdown::subscription) .theme(Markdown::theme) .run_with(Markdown::new) } struct Markdown { content: text_editor::Content, - items: Vec, + mode: Mode, theme: Theme, } +enum Mode { + Oneshot(Vec), + Stream { + pending: String, + parsed: markdown::Content, + }, +} + #[derive(Debug, Clone)] enum Message { Edit(text_editor::Action), LinkClicked(markdown::Url), + ToggleStream(bool), + NextToken, } impl Markdown { @@ -29,7 +43,7 @@ impl Markdown { ( Self { content: text_editor::Content::with_text(INITIAL_CONTENT), - items: markdown::parse(INITIAL_CONTENT).collect(), + mode: Mode::Oneshot(markdown::parse(INITIAL_CONTENT).collect()), theme, }, widget::focus_next(), @@ -44,13 +58,48 @@ impl Markdown { self.content.perform(action); if is_edit { - self.items = - markdown::parse(&self.content.text()).collect(); + self.mode = match self.mode { + Mode::Oneshot(_) => Mode::Oneshot( + markdown::parse(&self.content.text()).collect(), + ), + Mode::Stream { .. } => Mode::Stream { + pending: self.content.text(), + parsed: markdown::Content::parse(""), + }, + } } } Message::LinkClicked(link) => { let _ = open::that_in_background(link.to_string()); } + Message::ToggleStream(enable_stream) => { + self.mode = if enable_stream { + Mode::Stream { + pending: self.content.text(), + parsed: markdown::Content::parse(""), + } + } else { + Mode::Oneshot( + markdown::parse(&self.content.text()).collect(), + ) + }; + } + Message::NextToken => match &mut self.mode { + Mode::Oneshot(_) => {} + Mode::Stream { pending, parsed } => { + if pending.is_empty() { + self.mode = Mode::Oneshot(parsed.items().to_vec()); + } else { + let mut tokens = pending.split(' '); + + if let Some(token) = tokens.next() { + parsed.push_str(&format!("{token} ")); + } + + *pending = tokens.collect::>().join(" "); + } + } + }, } } @@ -63,20 +112,45 @@ impl Markdown { .font(Font::MONOSPACE) .highlight("markdown", highlighter::Theme::Base16Ocean); + let items = match &self.mode { + Mode::Oneshot(items) => items.as_slice(), + Mode::Stream { parsed, .. } => parsed.items(), + }; + let preview = markdown( - &self.items, + items, markdown::Settings::default(), markdown::Style::from_palette(self.theme.palette()), ) .map(Message::LinkClicked); - row![editor, scrollable(preview).spacing(10).height(Fill)] - .spacing(10) - .padding(10) - .into() + row![ + editor, + hover( + scrollable(preview).spacing(10).width(Fill).height(Fill), + right( + toggler(matches!(self.mode, Mode::Stream { .. })) + .label("Stream") + .on_toggle(Message::ToggleStream) + ) + .padding([0, 20]) + ) + ] + .spacing(10) + .padding(10) + .into() } fn theme(&self) -> Theme { self.theme.clone() } + + fn subscription(&self) -> Subscription { + match self.mode { + Mode::Oneshot(_) => Subscription::none(), + Mode::Stream { .. } => { + time::every(milliseconds(20)).map(|_| Message::NextToken) + } + } + } } diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 2b7bc0fc7f..0365dee8a0 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; @@ -57,12 +58,47 @@ use crate::core::{ use crate::{column, container, rich_text, row, scrollable, span, text}; use std::cell::{Cell, RefCell}; +use std::ops::Range; use std::sync::Arc; pub use core::text::Highlight; pub use pulldown_cmark::HeadingLevel; pub use url::Url; +#[derive(Debug, Clone)] +pub struct Content { + items: Vec, + state: State, +} + +impl Content { + pub fn parse(markdown: &str) -> Self { + let mut state = State::default(); + let items = parse_with(&mut state, markdown).collect(); + + Self { items, state } + } + + pub fn push_str(&mut self, markdown: &str) { + // Append to last leftover text + let mut leftover = std::mem::take(&mut self.state.leftover); + leftover.push_str(markdown); + + // Pop the last item + let _ = self.items.pop(); + + // Re-parse last item and new text + let new_items = parse_with(&mut self.state, &leftover); + self.items.extend(new_items); + + dbg!(&self.state); + } + + pub fn items(&self) -> &[Item] { + &self.items + } +} + /// A Markdown item. #[derive(Debug, Clone)] pub enum Item { @@ -232,6 +268,24 @@ impl Span { /// } /// ``` pub fn parse(markdown: &str) -> impl Iterator + '_ { + parse_with(State::default(), markdown) +} + +#[derive(Debug, Clone, Default)] +pub struct State { + leftover: String, +} + +impl AsMut for State { + fn as_mut(&mut self) -> &mut Self { + self + } +} + +fn parse_with<'a>( + mut state: impl AsMut + 'a, + markdown: &'a str, +) -> impl Iterator + 'a { struct List { start: Option, items: Vec>, @@ -255,27 +309,31 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { | pulldown_cmark::Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS | pulldown_cmark::Options::ENABLE_TABLES | pulldown_cmark::Options::ENABLE_STRIKETHROUGH, - ); - - let produce = |lists: &mut Vec, item| { - if lists.is_empty() { - Some(item) - } else { - lists - .last_mut() - .expect("list context") - .items - .last_mut() - .expect("item context") - .push(item); + ) + .into_offset_iter(); - None - } - }; + let mut produce = + move |lists: &mut Vec, item, source: Range| { + if lists.is_empty() { + state.as_mut().leftover = markdown[source.start..].to_owned(); + + Some(item) + } else { + lists + .last_mut() + .expect("list context") + .items + .last_mut() + .expect("item context") + .push(item); + + None + } + }; // We want to keep the `spans` capacity #[allow(clippy::drain_collect)] - parser.filter_map(move |event| match event { + parser.filter_map(move |(event, source)| match event { pulldown_cmark::Event::Start(tag) => match tag { pulldown_cmark::Tag::Strong if !metadata && !table => { strong = true; @@ -311,6 +369,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), + source, ) }; @@ -350,6 +409,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), + source, ) }; @@ -370,6 +430,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::Heading(level, Text::new(spans.drain(..).collect())), + source, ) } pulldown_cmark::TagEnd::Strong if !metadata && !table => { @@ -392,6 +453,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), + source, ) } pulldown_cmark::TagEnd::Item if !metadata && !table => { @@ -401,6 +463,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), + source, ) } } @@ -413,6 +476,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { start: list.start, items: list.items, }, + source, ) } pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { @@ -424,6 +488,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { produce( &mut lists, Item::CodeBlock(Text::new(spans.drain(..).collect())), + source, ) } pulldown_cmark::TagEnd::MetadataBlock(_) => { From 4b8fc23840e52a81f1c62c48e4e83d04b700b392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 31 Jan 2025 20:37:07 +0100 Subject: [PATCH 09/19] Implement `markdown` incremental code highlighting --- examples/markdown/src/main.rs | 81 +++++++++------- highlighter/src/lib.rs | 112 +++++++++++++++++----- widget/src/markdown.rs | 175 +++++++++++++++++++++++++--------- 3 files changed, 264 insertions(+), 104 deletions(-) diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index a55e91d2ab..2361b7b770 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -19,7 +19,7 @@ struct Markdown { } enum Mode { - Oneshot(Vec), + Preview(Vec), Stream { pending: String, parsed: markdown::Content, @@ -43,14 +43,14 @@ impl Markdown { ( Self { content: text_editor::Content::with_text(INITIAL_CONTENT), - mode: Mode::Oneshot(markdown::parse(INITIAL_CONTENT).collect()), + mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), theme, }, widget::focus_next(), ) } - fn update(&mut self, message: Message) { + fn update(&mut self, message: Message) -> Task { match message { Message::Edit(action) => { let is_edit = action.is_edit(); @@ -58,48 +58,57 @@ impl Markdown { self.content.perform(action); if is_edit { - self.mode = match self.mode { - Mode::Oneshot(_) => Mode::Oneshot( - markdown::parse(&self.content.text()).collect(), - ), - Mode::Stream { .. } => Mode::Stream { - pending: self.content.text(), - parsed: markdown::Content::parse(""), - }, - } + self.mode = Mode::Preview( + markdown::parse(&self.content.text()).collect(), + ); } + + Task::none() } Message::LinkClicked(link) => { let _ = open::that_in_background(link.to_string()); + + Task::none() } Message::ToggleStream(enable_stream) => { - self.mode = if enable_stream { - Mode::Stream { + if enable_stream { + self.mode = Mode::Stream { pending: self.content.text(), parsed: markdown::Content::parse(""), - } + }; + + scrollable::snap_to( + "preview", + scrollable::RelativeOffset::END, + ) } else { - Mode::Oneshot( + self.mode = Mode::Preview( markdown::parse(&self.content.text()).collect(), - ) - }; + ); + + Task::none() + } } - Message::NextToken => match &mut self.mode { - Mode::Oneshot(_) => {} - Mode::Stream { pending, parsed } => { - if pending.is_empty() { - self.mode = Mode::Oneshot(parsed.items().to_vec()); - } else { - let mut tokens = pending.split(' '); - - if let Some(token) = tokens.next() { - parsed.push_str(&format!("{token} ")); + Message::NextToken => { + match &mut self.mode { + Mode::Preview(_) => {} + Mode::Stream { pending, parsed } => { + if pending.is_empty() { + self.mode = Mode::Preview(parsed.items().to_vec()); + } else { + let mut tokens = pending.split(' '); + + if let Some(token) = tokens.next() { + parsed.push_str(&format!("{token} ")); + } + + *pending = tokens.collect::>().join(" "); } - - *pending = tokens.collect::>().join(" "); } } - }, + + Task::none() + } } } @@ -113,7 +122,7 @@ impl Markdown { .highlight("markdown", highlighter::Theme::Base16Ocean); let items = match &self.mode { - Mode::Oneshot(items) => items.as_slice(), + Mode::Preview(items) => items.as_slice(), Mode::Stream { parsed, .. } => parsed.items(), }; @@ -127,7 +136,11 @@ impl Markdown { row![ editor, hover( - scrollable(preview).spacing(10).width(Fill).height(Fill), + scrollable(preview) + .spacing(10) + .width(Fill) + .height(Fill) + .id("preview"), right( toggler(matches!(self.mode, Mode::Stream { .. })) .label("Stream") @@ -147,7 +160,7 @@ impl Markdown { fn subscription(&self) -> Subscription { match self.mode { - Mode::Oneshot(_) => Subscription::none(), + Mode::Preview(_) => Subscription::none(), Mode::Stream { .. } => { time::every(milliseconds(20)).map(|_| Message::NextToken) } diff --git a/highlighter/src/lib.rs b/highlighter/src/lib.rs index d2abc6b12a..2d0ac2e434 100644 --- a/highlighter/src/lib.rs +++ b/highlighter/src/lib.rs @@ -7,6 +7,7 @@ use crate::core::Color; use std::ops::Range; use std::sync::LazyLock; + use syntect::highlighting; use syntect::parsing; @@ -104,30 +105,7 @@ impl highlighter::Highlighter for Highlighter { let ops = parser.parse_line(line, &SYNTAXES).unwrap_or_default(); - let highlighter = &self.highlighter; - - Box::new( - ScopeRangeIterator { - ops, - line_length: line.len(), - index: 0, - last_str_index: 0, - } - .filter_map(move |(range, scope)| { - let _ = stack.apply(&scope); - - if range.is_empty() { - None - } else { - Some(( - range, - Highlight( - highlighter.style_mod_for_stack(&stack.scopes), - ), - )) - } - }), - ) + Box::new(scope_iterator(ops, line, stack, &self.highlighter)) } fn current_line(&self) -> usize { @@ -135,6 +113,92 @@ impl highlighter::Highlighter for Highlighter { } } +fn scope_iterator<'a>( + ops: Vec<(usize, parsing::ScopeStackOp)>, + line: &str, + stack: &'a mut parsing::ScopeStack, + highlighter: &'a highlighting::Highlighter<'static>, +) -> impl Iterator, Highlight)> + 'a { + ScopeRangeIterator { + ops, + line_length: line.len(), + index: 0, + last_str_index: 0, + } + .filter_map(move |(range, scope)| { + let _ = stack.apply(&scope); + + if range.is_empty() { + None + } else { + Some(( + range, + Highlight(highlighter.style_mod_for_stack(&stack.scopes)), + )) + } + }) +} + +/// A streaming syntax highlighter. +/// +/// It can efficiently highlight an immutable stream of tokens. +#[derive(Debug)] +pub struct Stream { + syntax: &'static parsing::SyntaxReference, + highlighter: highlighting::Highlighter<'static>, + commit: (parsing::ParseState, parsing::ScopeStack), + state: parsing::ParseState, + stack: parsing::ScopeStack, +} + +impl Stream { + /// Creates a new [`Stream`] highlighter. + pub fn new(settings: &Settings) -> Self { + let syntax = SYNTAXES + .find_syntax_by_token(&settings.token) + .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); + + let highlighter = highlighting::Highlighter::new( + &THEMES.themes[settings.theme.key()], + ); + + let state = parsing::ParseState::new(syntax); + let stack = parsing::ScopeStack::new(); + + Self { + syntax, + highlighter, + commit: (state.clone(), stack.clone()), + state, + stack, + } + } + + /// Highlights the given line from the last commit. + pub fn highlight_line( + &mut self, + line: &str, + ) -> impl Iterator, Highlight)> + '_ { + self.state = self.commit.0.clone(); + self.stack = self.commit.1.clone(); + + let ops = self.state.parse_line(line, &SYNTAXES).unwrap_or_default(); + scope_iterator(ops, line, &mut self.stack, &self.highlighter) + } + + /// Commits the last highlighted line. + pub fn commit(&mut self) { + self.commit = (self.state.clone(), self.stack.clone()); + } + + /// Resets the [`Stream`] highlighter. + pub fn reset(&mut self) { + self.state = parsing::ParseState::new(self.syntax); + self.stack = parsing::ScopeStack::new(); + self.commit = (self.state.clone(), self.stack.clone()); + } +} + /// The settings of a [`Highlighter`]. #[derive(Debug, Clone, PartialEq)] pub struct Settings { diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 0365dee8a0..7f6965e59c 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -57,6 +57,7 @@ use crate::core::{ }; use crate::{column, container, rich_text, row, scrollable, span, text}; +use std::borrow::BorrowMut; use std::cell::{Cell, RefCell}; use std::ops::Range; use std::sync::Arc; @@ -65,7 +66,7 @@ pub use core::text::Highlight; pub use pulldown_cmark::HeadingLevel; pub use url::Url; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct Content { items: Vec, state: State, @@ -80,6 +81,10 @@ impl Content { } pub fn push_str(&mut self, markdown: &str) { + if markdown.is_empty() { + return; + } + // Append to last leftover text let mut leftover = std::mem::take(&mut self.state.leftover); leftover.push_str(markdown); @@ -90,8 +95,6 @@ impl Content { // Re-parse last item and new text let new_items = parse_with(&mut self.state, &leftover); self.items.extend(new_items); - - dbg!(&self.state); } pub fn items(&self) -> &[Item] { @@ -271,19 +274,91 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { parse_with(State::default(), markdown) } -#[derive(Debug, Clone, Default)] -pub struct State { +#[derive(Debug, Default)] +struct State { leftover: String, + #[cfg(feature = "highlighter")] + highlighter: Option, +} + +#[cfg(feature = "highlighter")] +#[derive(Debug)] +struct Highlighter { + lines: Vec<(String, Vec)>, + parser: iced_highlighter::Stream, + current: usize, } -impl AsMut for State { - fn as_mut(&mut self) -> &mut Self { - self +#[cfg(feature = "highlighter")] +impl Highlighter { + pub fn new(language: &str) -> Self { + Self { + lines: Vec::new(), + parser: iced_highlighter::Stream::new( + &iced_highlighter::Settings { + theme: iced_highlighter::Theme::Base16Ocean, + token: language.to_string(), + }, + ), + current: 0, + } + } + + pub fn prepare(&mut self) { + self.current = 0; + } + + pub fn highlight_line(&mut self, text: &str) -> &[Span] { + match self.lines.get(self.current) { + Some(line) if line.0 == text => {} + _ => { + if self.current + 1 < self.lines.len() { + println!("Resetting..."); + self.parser.reset(); + self.lines.truncate(self.current); + + for line in &self.lines { + println!("Refeeding {n} lines", n = self.lines.len()); + + let _ = self.parser.highlight_line(&line.0); + } + } + + println!("Parsing: {text}", text = text.trim_end()); + if self.current + 1 < self.lines.len() { + self.parser.commit(); + } + + let mut spans = Vec::new(); + + for (range, highlight) in self.parser.highlight_line(text) { + spans.push(Span::Highlight { + text: text[range].to_owned(), + color: highlight.color(), + font: highlight.font(), + }); + } + + if self.current + 1 == self.lines.len() { + let _ = self.lines.pop(); + } + + self.lines.push((text.to_owned(), spans)); + } + } + + self.current += 1; + + &self + .lines + .get(self.current - 1) + .expect("Line must be parsed") + .1 } } fn parse_with<'a>( - mut state: impl AsMut + 'a, + mut state: impl BorrowMut + 'a, markdown: &'a str, ) -> impl Iterator + 'a { struct List { @@ -312,24 +387,26 @@ fn parse_with<'a>( ) .into_offset_iter(); - let mut produce = - move |lists: &mut Vec, item, source: Range| { - if lists.is_empty() { - state.as_mut().leftover = markdown[source.start..].to_owned(); - - Some(item) - } else { - lists - .last_mut() - .expect("list context") - .items - .last_mut() - .expect("item context") - .push(item); + let produce = move |state: &mut State, + lists: &mut Vec, + item, + source: Range| { + if lists.is_empty() { + state.leftover = markdown[source.start..].to_owned(); + + Some(item) + } else { + lists + .last_mut() + .expect("list context") + .items + .last_mut() + .expect("item context") + .push(item); - None - } - }; + None + } + }; // We want to keep the `spans` capacity #[allow(clippy::drain_collect)] @@ -367,6 +444,7 @@ fn parse_with<'a>( None } else { produce( + state.borrow_mut(), &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), source, @@ -393,20 +471,24 @@ fn parse_with<'a>( ) if !metadata && !table => { #[cfg(feature = "highlighter")] { - use iced_highlighter::Highlighter; - use text::Highlighter as _; - - highlighter = - Some(Highlighter::new(&iced_highlighter::Settings { - theme: iced_highlighter::Theme::Base16Ocean, - token: _language.to_string(), - })); + highlighter = Some({ + let mut highlighter = state + .borrow_mut() + .highlighter + .take() + .unwrap_or_else(|| Highlighter::new(&_language)); + + highlighter.prepare(); + + highlighter + }); } let prev = if spans.is_empty() { None } else { produce( + state.borrow_mut(), &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), source, @@ -428,6 +510,7 @@ fn parse_with<'a>( pulldown_cmark::Event::End(tag) => match tag { pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { produce( + state.borrow_mut(), &mut lists, Item::Heading(level, Text::new(spans.drain(..).collect())), source, @@ -451,6 +534,7 @@ fn parse_with<'a>( } pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { produce( + state.borrow_mut(), &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), source, @@ -461,6 +545,7 @@ fn parse_with<'a>( None } else { produce( + state.borrow_mut(), &mut lists, Item::Paragraph(Text::new(spans.drain(..).collect())), source, @@ -471,6 +556,7 @@ fn parse_with<'a>( let list = lists.pop().expect("list context"); produce( + state.borrow_mut(), &mut lists, Item::List { start: list.start, @@ -482,10 +568,11 @@ fn parse_with<'a>( pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { #[cfg(feature = "highlighter")] { - highlighter = None; + state.borrow_mut().highlighter = highlighter.take(); } produce( + state.borrow_mut(), &mut lists, Item::CodeBlock(Text::new(spans.drain(..).collect())), source, @@ -504,20 +591,16 @@ fn parse_with<'a>( pulldown_cmark::Event::Text(text) if !metadata && !table => { #[cfg(feature = "highlighter")] if let Some(highlighter) = &mut highlighter { - use text::Highlighter as _; + let start = std::time::Instant::now(); - for (range, highlight) in - highlighter.highlight_line(text.as_ref()) - { - let span = Span::Highlight { - text: text[range].to_owned(), - color: highlight.color(), - font: highlight.font(), - }; - - spans.push(span); + for line in text.lines() { + spans.extend_from_slice( + highlighter.highlight_line(&format!("{line}\n")), + ); } + dbg!(start.elapsed()); + return None; } From bc2d662af7fd9b527dc6b49f31627780e58d79c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 31 Jan 2025 20:42:53 +0100 Subject: [PATCH 10/19] Replace `println` with `log` calls in `markdown` module --- Cargo.lock | 1 + widget/Cargo.toml | 1 + widget/src/markdown.rs | 10 +++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ad8c0b354..409494238f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2640,6 +2640,7 @@ dependencies = [ "iced_highlighter", "iced_renderer", "iced_runtime", + "log", "num-traits", "ouroboros", "pulldown-cmark", diff --git a/widget/Cargo.toml b/widget/Cargo.toml index e19cad0843..6d1f054e56 100644 --- a/widget/Cargo.toml +++ b/widget/Cargo.toml @@ -33,6 +33,7 @@ iced_renderer.workspace = true iced_runtime.workspace = true num-traits.workspace = true +log.workspace = true rustc-hash.workspace = true thiserror.workspace = true unicode-segmentation.workspace = true diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 7f6965e59c..77a560ecb1 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -313,18 +313,22 @@ impl Highlighter { Some(line) if line.0 == text => {} _ => { if self.current + 1 < self.lines.len() { - println!("Resetting..."); + log::debug!("Resetting highlighter..."); self.parser.reset(); self.lines.truncate(self.current); for line in &self.lines { - println!("Refeeding {n} lines", n = self.lines.len()); + log::debug!( + "Refeeding {n} lines", + n = self.lines.len() + ); let _ = self.parser.highlight_line(&line.0); } } - println!("Parsing: {text}", text = text.trim_end()); + log::trace!("Parsing: {text}", text = text.trim_end()); + if self.current + 1 < self.lines.len() { self.parser.commit(); } From 095859ed57e573d91ebe36dceb888ec95427b6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 31 Jan 2025 20:49:25 +0100 Subject: [PATCH 11/19] Add `new` constructor for `markdown::Content` --- examples/markdown/src/main.rs | 2 +- widget/src/markdown.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 2361b7b770..5b9a3b4a54 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -74,7 +74,7 @@ impl Markdown { if enable_stream { self.mode = Mode::Stream { pending: self.content.text(), - parsed: markdown::Content::parse(""), + parsed: markdown::Content::new(), }; scrollable::snap_to( diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 77a560ecb1..b4b890958a 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -66,13 +66,17 @@ pub use core::text::Highlight; pub use pulldown_cmark::HeadingLevel; pub use url::Url; -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Content { items: Vec, state: State, } impl Content { + pub fn new() -> Self { + Self::default() + } + pub fn parse(markdown: &str) -> Self { let mut state = State::default(); let items = parse_with(&mut state, markdown).collect(); @@ -595,16 +599,12 @@ fn parse_with<'a>( pulldown_cmark::Event::Text(text) if !metadata && !table => { #[cfg(feature = "highlighter")] if let Some(highlighter) = &mut highlighter { - let start = std::time::Instant::now(); - for line in text.lines() { spans.extend_from_slice( highlighter.highlight_line(&format!("{line}\n")), ); } - dbg!(start.elapsed()); - return None; } From 447f5ae494da7ef93ac073600f4e5a2559c4e71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 00:33:05 +0100 Subject: [PATCH 12/19] Discard `markdown::Highlighter` if language changes --- widget/src/markdown.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index b4b890958a..658166ec48 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -289,6 +289,7 @@ struct State { #[derive(Debug)] struct Highlighter { lines: Vec<(String, Vec)>, + language: String, parser: iced_highlighter::Stream, current: usize, } @@ -304,6 +305,7 @@ impl Highlighter { token: language.to_string(), }, ), + language: language.to_owned(), current: 0, } } @@ -484,6 +486,9 @@ fn parse_with<'a>( .borrow_mut() .highlighter .take() + .filter(|highlighter| { + highlighter.language == _language.as_ref() + }) .unwrap_or_else(|| Highlighter::new(&_language)); highlighter.prepare(); From 7336a18443ea88ef04ea842a07ba02e89400bbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 01:04:36 +0100 Subject: [PATCH 13/19] Fix `viewport` when using nested `scrollable`s --- widget/src/scrollable.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 8ac70da87c..966e4ac7bb 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -999,9 +999,9 @@ where content_layout, cursor, &Rectangle { - y: bounds.y + translation.y, - x: bounds.x + translation.x, - ..bounds + y: visible_bounds.y + translation.y, + x: visible_bounds.x + translation.x, + ..visible_bounds }, ); }, @@ -1103,9 +1103,9 @@ where content_layout, cursor, &Rectangle { - x: bounds.x + translation.x, - y: bounds.y + translation.y, - ..bounds + x: visible_bounds.x + translation.x, + y: visible_bounds.y + translation.y, + ..visible_bounds }, ); } From c2155b82b35200585991a09945fb93903a61fccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 01:07:03 +0100 Subject: [PATCH 14/19] Cull out of bounds `rich_text` during `draw` --- widget/src/text/rich.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index a40f2b57cb..69a3393a4e 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -239,6 +239,10 @@ where cursor: mouse::Cursor, viewport: &Rectangle, ) { + if !layout.bounds().intersects(viewport) { + return; + } + let state = tree .state .downcast_ref::>(); From eb81679e604e2d1d45590c236fb5b2644c38f3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 01:07:35 +0100 Subject: [PATCH 15/19] Split code blocks into multiple `rich_text` lines This improves layout diffing considerably! --- widget/src/markdown.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 658166ec48..bb818d194b 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -116,7 +116,7 @@ pub enum Item { /// A code block. /// /// You can enable the `highlighter` feature for syntax highlighting. - CodeBlock(Text), + CodeBlock(Vec), /// A list. List { /// The first number of the list, if it is ordered. @@ -377,6 +377,7 @@ fn parse_with<'a>( } let mut spans = Vec::new(); + let mut code = Vec::new(); let mut strong = false; let mut emphasis = false; let mut strikethrough = false; @@ -587,7 +588,7 @@ fn parse_with<'a>( produce( state.borrow_mut(), &mut lists, - Item::CodeBlock(Text::new(spans.drain(..).collect())), + Item::CodeBlock(code.drain(..).collect()), source, ) } @@ -605,9 +606,9 @@ fn parse_with<'a>( #[cfg(feature = "highlighter")] if let Some(highlighter) = &mut highlighter { for line in text.lines() { - spans.extend_from_slice( - highlighter.highlight_line(&format!("{line}\n")), - ); + code.push(Text::new( + highlighter.highlight_line(line).to_vec(), + )); } return None; @@ -871,13 +872,14 @@ where })) .spacing(spacing * 0.75) .into(), - Item::CodeBlock(code) => container( + Item::CodeBlock(lines) => container( scrollable( - container( - rich_text(code.spans(style)) + container(column(lines.iter().map(|line| { + rich_text(line.spans(style)) .font(Font::MONOSPACE) - .size(code_size), - ) + .size(code_size) + .into() + }))) .padding(spacing.0 / 2.0), ) .direction(scrollable::Direction::Horizontal( From 7493b83031a9fe4bcaf6041cf64d6cbd3b9698e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 01:50:55 +0100 Subject: [PATCH 16/19] Fix `rich_text` reactive rendering when hovering links --- widget/src/text/rich.rs | 113 ++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 61 deletions(-) diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index 69a3393a4e..7c67ab807d 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -30,6 +30,7 @@ where align_y: alignment::Vertical, wrapping: Wrapping, class: Theme::Class<'a>, + hovered_link: Option, } impl<'a, Link, Theme, Renderer> Rich<'a, Link, Theme, Renderer> @@ -52,6 +53,7 @@ where align_y: alignment::Vertical::Top, wrapping: Wrapping::default(), class: Theme::default(), + hovered_link: None, } } @@ -236,7 +238,7 @@ where theme: &Theme, defaults: &renderer::Style, layout: Layout<'_>, - cursor: mouse::Cursor, + _cursor: mouse::Cursor, viewport: &Rectangle, ) { if !layout.bounds().intersects(viewport) { @@ -249,13 +251,8 @@ where let style = theme.style(&self.class); - let hovered_span = cursor - .position_in(layout.bounds()) - .and_then(|position| state.paragraph.hit_span(position)); - for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { - let is_hovered_link = - span.link.is_some() && Some(index) == hovered_span; + let is_hovered_link = Some(index) == self.hovered_link; if span.highlight.is_some() || span.underline @@ -369,53 +366,59 @@ where shell: &mut Shell<'_, Link>, _viewport: &Rectangle, ) { + let was_hovered = self.hovered_link.is_some(); + + if let Some(position) = cursor.position_in(layout.bounds()) { + let state = tree + .state + .downcast_ref::>(); + + self.hovered_link = + state.paragraph.hit_span(position).and_then(|span| { + if self.spans.as_ref().as_ref().get(span)?.link.is_some() { + Some(span) + } else { + None + } + }); + } else { + self.hovered_link = None; + } + + if was_hovered != self.hovered_link.is_some() { + shell.request_redraw(); + } + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { - if let Some(position) = cursor.position_in(layout.bounds()) { - let state = tree - .state - .downcast_mut::>(); + let state = tree + .state + .downcast_mut::>(); - if let Some(span) = state.paragraph.hit_span(position) { - if self - .spans - .as_ref() - .as_ref() - .get(span) - .is_some_and(|span| span.link.is_some()) - { - state.span_pressed = Some(span); - shell.capture_event(); - } - } - } + state.span_pressed = self.hovered_link; + shell.capture_event(); } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let state = tree .state .downcast_mut::>(); - if let Some(span_pressed) = state.span_pressed { - state.span_pressed = None; - - if let Some(position) = cursor.position_in(layout.bounds()) - { - match state.paragraph.hit_span(position) { - Some(span) if span == span_pressed => { - if let Some(link) = self - .spans - .as_ref() - .as_ref() - .get(span) - .and_then(|span| span.link.clone()) - { - shell.publish(link); - } - } - _ => {} + match state.span_pressed { + Some(span) if Some(span) == self.hovered_link => { + if let Some(link) = self + .spans + .as_ref() + .as_ref() + .get(span) + .and_then(|span| span.link.clone()) + { + shell.publish(link); } } + _ => {} } + + state.span_pressed = None; } _ => {} } @@ -423,29 +426,17 @@ where fn mouse_interaction( &self, - tree: &Tree, - layout: Layout<'_>, - cursor: mouse::Cursor, + _tree: &Tree, + _layout: Layout<'_>, + _cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - if let Some(position) = cursor.position_in(layout.bounds()) { - let state = tree - .state - .downcast_ref::>(); - - if let Some(span) = state - .paragraph - .hit_span(position) - .and_then(|span| self.spans.as_ref().as_ref().get(span)) - { - if span.link.is_some() { - return mouse::Interaction::Pointer; - } - } + if self.hovered_link.is_some() { + mouse::Interaction::Pointer + } else { + mouse::Interaction::None } - - mouse::Interaction::None } } From 2fc94d9f443ba497f269b25659dfd16616ef8d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 01:57:11 +0100 Subject: [PATCH 17/19] Fix event capturing in `rich_text` --- widget/src/text/rich.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index 7c67ab807d..0b499ec6c0 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -395,8 +395,10 @@ where .state .downcast_mut::>(); - state.span_pressed = self.hovered_link; - shell.capture_event(); + if self.hovered_link.is_some() { + state.span_pressed = self.hovered_link; + shell.capture_event(); + } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let state = tree From 7a6d4d580e6fe08a9062cfe3a9f92087fc270ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 02:13:45 +0100 Subject: [PATCH 18/19] Propagate mouse cursor movements in `stack` --- widget/src/stack.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/widget/src/stack.rs b/widget/src/stack.rs index d2828c5658..12ed941dbb 100644 --- a/widget/src/stack.rs +++ b/widget/src/stack.rs @@ -216,6 +216,8 @@ where viewport: &Rectangle, ) { let is_over = cursor.is_over(layout.bounds()); + let is_mouse_movement = + matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })); for ((child, state), layout) in self .children @@ -235,7 +237,10 @@ where viewport, ); - if is_over && cursor != mouse::Cursor::Unavailable { + if is_over + && !is_mouse_movement + && cursor != mouse::Cursor::Unavailable + { let interaction = child.as_widget().mouse_interaction( state, layout, cursor, viewport, renderer, ); From ed0ffb59634424bb58540bdfdc4994d6665028ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 1 Feb 2025 02:16:29 +0100 Subject: [PATCH 19/19] Revert automatic horizontal scroll in `scrollable` --- widget/src/scrollable.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 966e4ac7bb..9ba8cdeac1 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -788,13 +788,7 @@ where (x, y) }; - let is_vertical = match self.direction { - Direction::Vertical(_) => true, - Direction::Horizontal(_) => false, - Direction::Both { .. } => !is_shift_pressed, - }; - - let movement = if is_vertical { + let movement = if !is_shift_pressed { Vector::new(x, y) } else { Vector::new(y, x)