diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | examples/markdown/Cargo.toml | 2 | ||||
| -rw-r--r-- | examples/markdown/src/main.rs | 111 | ||||
| -rw-r--r-- | highlighter/src/lib.rs | 112 | ||||
| -rw-r--r-- | widget/Cargo.toml | 1 | ||||
| -rw-r--r-- | widget/src/container.rs | 14 | ||||
| -rw-r--r-- | widget/src/markdown.rs | 269 | ||||
| -rw-r--r-- | widget/src/scrollable.rs | 47 | ||||
| -rw-r--r-- | widget/src/stack.rs | 7 | ||||
| -rw-r--r-- | widget/src/text/rich.rs | 117 | ||||
| -rw-r--r-- | widget/src/text_editor.rs | 36 | 
11 files changed, 548 insertions, 169 deletions
@@ -2640,6 +2640,7 @@ dependencies = [   "iced_highlighter",   "iced_renderer",   "iced_runtime", + "log",   "num-traits",   "ouroboros",   "pulldown-cmark", diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index cb74b954..fa6ced74 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 5605478f..5b9a3b4a 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<markdown::Item>, +    mode: Mode,      theme: Theme,  } +enum Mode { +    Preview(Vec<markdown::Item>), +    Stream { +        pending: String, +        parsed: markdown::Content, +    }, +} +  #[derive(Debug, Clone)]  enum Message {      Edit(text_editor::Action),      LinkClicked(markdown::Url), +    ToggleStream(bool), +    NextToken,  }  impl Markdown { @@ -29,14 +43,14 @@ impl Markdown {          (              Self {                  content: text_editor::Content::with_text(INITIAL_CONTENT), -                items: 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<Message> {          match message {              Message::Edit(action) => {                  let is_edit = action.is_edit(); @@ -44,12 +58,56 @@ impl Markdown {                  self.content.perform(action);                  if is_edit { -                    self.items = -                        markdown::parse(&self.content.text()).collect(); +                    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) => { +                if enable_stream { +                    self.mode = Mode::Stream { +                        pending: self.content.text(), +                        parsed: markdown::Content::new(), +                    }; + +                    scrollable::snap_to( +                        "preview", +                        scrollable::RelativeOffset::END, +                    ) +                } else { +                    self.mode = Mode::Preview( +                        markdown::parse(&self.content.text()).collect(), +                    ); + +                    Task::none() +                } +            } +            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::<Vec<_>>().join(" "); +                        } +                    } +                } + +                Task::none()              }          }      } @@ -63,20 +121,49 @@ 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( -            &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) +                    .id("preview"), +                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<Message> { +        match self.mode { +            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 d2abc6b1..2d0ac2e4 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<Item = (Range<usize>, 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<Item = (Range<usize>, 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/Cargo.toml b/widget/Cargo.toml index e19cad08..6d1f054e 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/container.rs b/widget/src/container.rs index a411a7d2..852481f1 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<Id>) -> Self { +        self.id = Some(id.into());          self      } @@ -480,9 +480,17 @@ impl From<Id> 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<Option<Rectangle>> { +pub fn visible_bounds(id: impl Into<Id>) -> Task<Option<Rectangle>> { +    let id = id.into(); +      struct VisibleBounds {          target: widget::Id,          depth: usize, diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index c0648e9e..bb818d19 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; @@ -56,13 +57,55 @@ 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;  pub use core::text::Highlight;  pub use pulldown_cmark::HeadingLevel;  pub use url::Url; +#[derive(Debug, Default)] +pub struct Content { +    items: Vec<Item>, +    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(); + +        Self { items, state } +    } + +    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); + +        // 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); +    } + +    pub fn items(&self) -> &[Item] { +        &self.items +    } +} +  /// A Markdown item.  #[derive(Debug, Clone)]  pub enum Item { @@ -73,7 +116,7 @@ pub enum Item {      /// A code block.      ///      /// You can enable the `highlighter` feature for syntax highlighting. -    CodeBlock(Text), +    CodeBlock(Vec<Text>),      /// A list.      List {          /// The first number of the list, if it is ordered. @@ -232,12 +275,109 @@ impl Span {  /// }  /// ```  pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ { +    parse_with(State::default(), markdown) +} + +#[derive(Debug, Default)] +struct State { +    leftover: String, +    #[cfg(feature = "highlighter")] +    highlighter: Option<Highlighter>, +} + +#[cfg(feature = "highlighter")] +#[derive(Debug)] +struct Highlighter { +    lines: Vec<(String, Vec<Span>)>, +    language: String, +    parser: iced_highlighter::Stream, +    current: usize, +} + +#[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(), +                }, +            ), +            language: language.to_owned(), +            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() { +                    log::debug!("Resetting highlighter..."); +                    self.parser.reset(); +                    self.lines.truncate(self.current); + +                    for line in &self.lines { +                        log::debug!( +                            "Refeeding {n} lines", +                            n = self.lines.len() +                        ); + +                        let _ = self.parser.highlight_line(&line.0); +                    } +                } + +                log::trace!("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 BorrowMut<State> + 'a, +    markdown: &'a str, +) -> impl Iterator<Item = Item> + 'a {      struct List {          start: Option<u64>,          items: Vec<Vec<Item>>,      }      let mut spans = Vec::new(); +    let mut code = Vec::new();      let mut strong = false;      let mut emphasis = false;      let mut strikethrough = false; @@ -255,10 +395,16 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {              | pulldown_cmark::Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS              | pulldown_cmark::Options::ENABLE_TABLES              | pulldown_cmark::Options::ENABLE_STRIKETHROUGH, -    ); +    ) +    .into_offset_iter(); -    let produce = |lists: &mut Vec<List>, item| { +    let produce = move |state: &mut State, +                        lists: &mut Vec<List>, +                        item, +                        source: Range<usize>| {          if lists.is_empty() { +            state.leftover = markdown[source.start..].to_owned(); +              Some(item)          } else {              lists @@ -275,7 +421,7 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {      // 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; @@ -309,8 +455,10 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {                      None                  } else {                      produce( +                        state.borrow_mut(),                          &mut lists,                          Item::Paragraph(Text::new(spans.drain(..).collect())), +                        source,                      )                  }; @@ -334,17 +482,34 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {              ) 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() +                            .filter(|highlighter| { +                                highlighter.language == _language.as_ref() +                            }) +                            .unwrap_or_else(|| Highlighter::new(&_language)); + +                        highlighter.prepare(); + +                        highlighter +                    });                  } -                None +                let prev = if spans.is_empty() { +                    None +                } else { +                    produce( +                        state.borrow_mut(), +                        &mut lists, +                        Item::Paragraph(Text::new(spans.drain(..).collect())), +                        source, +                    ) +                }; + +                prev              }              pulldown_cmark::Tag::MetadataBlock(_) => {                  metadata = true; @@ -359,8 +524,10 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {          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,                  )              }              pulldown_cmark::TagEnd::Strong if !metadata && !table => { @@ -381,8 +548,10 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {              }              pulldown_cmark::TagEnd::Paragraph if !metadata && !table => {                  produce( +                    state.borrow_mut(),                      &mut lists,                      Item::Paragraph(Text::new(spans.drain(..).collect())), +                    source,                  )              }              pulldown_cmark::TagEnd::Item if !metadata && !table => { @@ -390,8 +559,10 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {                      None                  } else {                      produce( +                        state.borrow_mut(),                          &mut lists,                          Item::Paragraph(Text::new(spans.drain(..).collect())), +                        source,                      )                  }              } @@ -399,22 +570,26 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {                  let list = lists.pop().expect("list context");                  produce( +                    state.borrow_mut(),                      &mut lists,                      Item::List {                          start: list.start,                          items: list.items,                      }, +                    source,                  )              }              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())), +                    Item::CodeBlock(code.drain(..).collect()), +                    source,                  )              }              pulldown_cmark::TagEnd::MetadataBlock(_) => { @@ -430,18 +605,10 @@ pub fn parse(markdown: &str) -> impl Iterator<Item = Item> + '_ {          pulldown_cmark::Event::Text(text) if !metadata && !table => {              #[cfg(feature = "highlighter")]              if let Some(highlighter) = &mut highlighter { -                use text::Highlighter as _; - -                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() { +                    code.push(Text::new( +                        highlighter.highlight_line(line).to_vec(), +                    ));                  }                  return None; @@ -518,6 +685,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 { @@ -538,6 +707,7 @@ impl Settings {              h5_size: text_size,              h6_size: text_size,              code_size: text_size * 0.75, +            spacing: text_size * 0.875,          }      }  } @@ -640,10 +810,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 { @@ -666,11 +835,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 { @@ -679,20 +858,28 @@ 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( +        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( @@ -707,7 +894,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. diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index b08d5d09..9ba8cdea 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<Id>) -> Self { +        self.id = Some(id.into());          self      } @@ -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) @@ -999,9 +993,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 +1097,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                  },              );          } @@ -1228,25 +1222,36 @@ impl From<Id> 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<T>(id: Id, offset: RelativeOffset) -> Task<T> { -    task::effect(Action::widget(operation::scrollable::snap_to(id.0, offset))) +pub fn snap_to<T>(id: impl Into<Id>, offset: RelativeOffset) -> Task<T> { +    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<T>(id: Id, offset: AbsoluteOffset) -> Task<T> { +pub fn scroll_to<T>(id: impl Into<Id>, offset: AbsoluteOffset) -> Task<T> {      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<T>(id: Id, offset: AbsoluteOffset) -> Task<T> { +pub fn scroll_by<T>(id: impl Into<Id>, offset: AbsoluteOffset) -> Task<T> {      task::effect(Action::widget(operation::scrollable::scroll_by( -        id.0, offset, +        id.into().0, +        offset,      )))  } diff --git a/widget/src/stack.rs b/widget/src/stack.rs index d2828c56..12ed941d 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,                  ); diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index a40f2b57..0b499ec6 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<usize>,  }  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,22 +238,21 @@ where          theme: &Theme,          defaults: &renderer::Style,          layout: Layout<'_>, -        cursor: mouse::Cursor, +        _cursor: mouse::Cursor,          viewport: &Rectangle,      ) { +        if !layout.bounds().intersects(viewport) { +            return; +        } +          let state = tree              .state              .downcast_ref::<State<Link, Renderer::Paragraph>>();          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 @@ -365,25 +366,38 @@ 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::<State<Link, Renderer::Paragraph>>(); + +            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::<State<Link, Renderer::Paragraph>>(); +                let state = tree +                    .state +                    .downcast_mut::<State<Link, Renderer::Paragraph>>(); -                    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(); -                        } -                    } +                if self.hovered_link.is_some() { +                    state.span_pressed = self.hovered_link; +                    shell.capture_event();                  }              }              Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { @@ -391,27 +405,22 @@ where                      .state                      .downcast_mut::<State<Link, Renderer::Paragraph>>(); -                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;              }              _ => {}          } @@ -419,29 +428,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::<State<Link, Renderer::Paragraph>>(); - -            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      }  } diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index a3470768..f1ec589b 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<Pixels>) -> Self { +        self.width = Length::from(width.into()); +        self +    } +      /// Sets the height of the [`TextEditor`].      pub fn height(mut self, height: impl Into<Length>) -> Self {          self.height = height.into();          self      } -    /// Sets the width of the [`TextEditor`]. -    pub fn width(mut self, width: impl Into<Pixels>) -> Self { -        self.width = Length::from(width.into()); +    /// Sets the minimum height of the [`TextEditor`]. +    pub fn min_height(mut self, min_height: impl Into<Pixels>) -> 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<Pixels>) -> 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(), @@ -768,6 +790,10 @@ where                          }                      } +                    if !matches!(binding, Binding::Unfocus) { +                        shell.capture_event(); +                    } +                      apply_binding(                          binding,                          self.content, @@ -780,8 +806,6 @@ where                      if let Some(focus) = &mut state.focus {                          focus.updated_at = Instant::now();                      } - -                    shell.capture_event();                  }              }          }  | 
