summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--examples/markdown/Cargo.toml2
-rw-r--r--examples/markdown/src/main.rs111
-rw-r--r--highlighter/src/lib.rs112
-rw-r--r--widget/Cargo.toml1
-rw-r--r--widget/src/container.rs14
-rw-r--r--widget/src/markdown.rs269
-rw-r--r--widget/src/scrollable.rs47
-rw-r--r--widget/src/stack.rs7
-rw-r--r--widget/src/text/rich.rs117
-rw-r--r--widget/src/text_editor.rs36
11 files changed, 548 insertions, 169 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 0ad8c0b3..40949423 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/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();
}
}
}