diff options
Diffstat (limited to '')
| -rw-r--r-- | src/lib.rs | 44 | ||||
| -rw-r--r-- | src/text.rs | 7 | ||||
| -rw-r--r-- | src/text/rich.rs | 841 |
3 files changed, 887 insertions, 5 deletions
@@ -3,10 +3,11 @@ //! [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html pub mod selection; - pub mod text; use iced_widget::core; +use iced_widget::graphics::text::Paragraph; +#[doc(no_inline)] pub use text::Text; /// Creates a new [`Text`] widget with the provided content. @@ -17,9 +18,22 @@ macro_rules! text { }; } +/// Creates some [`Rich`] text with the given spans. +/// +/// [`Rich`]: crate::text::Rich +#[macro_export] +macro_rules! rich_text { + () => ( + $crate::text::Rich::new() + ); + ($($x:expr),+ $(,)?) => ( + $crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+]) + ); +} + /// Creates a new [`Text`] widget with the provided content. pub fn text<'a, Theme, Renderer>( - text: impl core::text::IntoFragment<'a>, + text: impl text::IntoFragment<'a>, ) -> Text<'a, Theme, Renderer> where Theme: text::Catalog + 'a, @@ -27,3 +41,29 @@ where { Text::new(text) } + +/// Creates some [`Rich`] text with the given spans. +/// +/// [`Rich`]: crate::text::Rich +pub fn rich_text<'a, Link, Message, Theme, Renderer>( + spans: impl AsRef<[text::Span<'a, Link, core::Font>]> + 'a, +) -> text::Rich<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: text::Catalog + 'a, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = core::Font>, +{ + text::Rich::with_spans(spans) +} + +/// Creates a new [`Span`] of text with the provided content. +/// +/// A [`Span`] is a fragment of some [`Rich`] text. +/// +/// [`Rich`]: crate::text::Rich +/// [`Span`]: crate::text::Span +pub fn span<'a, Link>( + text: impl text::IntoFragment<'a>, +) -> text::Span<'a, Link, core::Font> { + text::Span::new(text) +} diff --git a/src/text.rs b/src/text.rs index 96d4f1b..07ced03 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,7 +1,6 @@ //! Text widgets display information through writing. mod rich; -pub use iced_widget::text::Rich; use iced_widget::{ core::{ self, Color, Element, Event, Font, Layout, Length, Pixels, Point, Size, @@ -20,7 +19,9 @@ use iced_widget::{ }, graphics::text::Paragraph, }; +pub use rich::Rich; use text::{Alignment, LineHeight, Shaping, Wrapping}; +pub use text::{Fragment, Highlighter, IntoFragment, Span}; use crate::selection::{Selection, SelectionEnd}; @@ -33,7 +34,7 @@ pub struct Text< Theme: Catalog, Renderer: text::Renderer, { - fragment: text::Fragment<'a>, + fragment: Fragment<'a>, format: Format<Renderer::Font>, class: Theme::Class<'a>, } @@ -44,7 +45,7 @@ where Renderer: text::Renderer, { /// Create a new fragment of [`Text`] with the given contents. - pub fn new(fragment: impl text::IntoFragment<'a>) -> Self { + pub fn new(fragment: impl IntoFragment<'a>) -> Self { Self { fragment: fragment.into_fragment(), format: Format::default(), diff --git a/src/text/rich.rs b/src/text/rich.rs index e69de29..ae8bbab 100644 --- a/src/text/rich.rs +++ b/src/text/rich.rs @@ -0,0 +1,841 @@ +use iced_widget::graphics::text::Paragraph; + +use crate::core::alignment; +use crate::core::clipboard; +use crate::core::keyboard; +use crate::core::keyboard::key; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text::{Paragraph as _, Span}; +use crate::core::touch; +use crate::core::widget::text::{Alignment, LineHeight, Shaping, Wrapping}; +use crate::core::widget::tree::{self, Tree}; +use crate::core::{ + self, Clipboard, Element, Event, Font, Layout, Length, Pixels, Point, + Rectangle, Shell, Size, Vector, Widget, +}; +use crate::selection::{Selection, SelectionEnd}; +use crate::text::{Catalog, Style, StyleFn}; + +/// A bunch of [`Rich`] text. +pub struct Rich< + 'a, + Link, + Message, + Theme = iced_widget::Theme, + Renderer = iced_widget::Renderer, +> where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer, +{ + spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>, + size: Option<Pixels>, + line_height: LineHeight, + width: Length, + height: Length, + font: Option<Renderer::Font>, + align_x: Alignment, + align_y: alignment::Vertical, + wrapping: Wrapping, + class: Theme::Class<'a>, + hovered_link: Option<usize>, + on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>, +} + +impl<'a, Link, Message, Theme, Renderer> + Rich<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer, + Renderer::Font: 'a, +{ + /// Creates a new empty [`Rich`] text. + pub fn new() -> Self { + Self { + spans: Box::new([]), + size: None, + line_height: LineHeight::default(), + width: Length::Shrink, + height: Length::Shrink, + font: None, + align_x: Alignment::Default, + align_y: alignment::Vertical::Top, + wrapping: Wrapping::default(), + class: Theme::default(), + hovered_link: None, + on_link_click: None, + } + } + + /// Creates a new [`Rich`] text with the given text spans. + pub fn with_spans( + spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a, + ) -> Self { + Self { + spans: Box::new(spans), + ..Self::new() + } + } + + /// Sets the default size of the [`Rich`] text. + pub fn size(mut self, size: impl Into<Pixels>) -> Self { + self.size = Some(size.into()); + self + } + + /// Sets the default [`LineHeight`] of the [`Rich`] text. + pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self { + self.line_height = line_height.into(); + self + } + + /// Sets the default font of the [`Rich`] text. + pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the width of the [`Rich`] text boundaries. + pub fn width(mut self, width: impl Into<Length>) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Rich`] text boundaries. + pub fn height(mut self, height: impl Into<Length>) -> Self { + self.height = height.into(); + self + } + + /// Centers the [`Rich`] text, both horizontally and vertically. + pub fn center(self) -> Self { + self.align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + } + + /// Sets the [`alignment::Horizontal`] of the [`Rich`] text. + pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self { + self.align_x = alignment.into(); + self + } + + /// Sets the [`alignment::Vertical`] of the [`Rich`] text. + pub fn align_y( + mut self, + alignment: impl Into<alignment::Vertical>, + ) -> Self { + self.align_y = alignment.into(); + self + } + + /// Sets the [`Wrapping`] strategy of the [`Rich`] text. + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.wrapping = wrapping; + self + } + + /// Sets the message that will be produced when a link of the [`Rich`] text + /// is clicked. + pub fn on_link_click( + mut self, + on_link_clicked: impl Fn(Link) -> Message + 'a, + ) -> Self { + self.on_link_click = Some(Box::new(on_link_clicked)); + self + } + + /// Sets the default style of the [`Rich`] text. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self + where + Theme::Class<'a>: From<StyleFn<'a, Theme>>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the default style class of the [`Rich`] text. + #[must_use] + pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self { + self.class = class.into(); + self + } +} + +impl<'a, Link, Message, Theme, Renderer> Default + for Rich<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'a, + Theme: Catalog, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = Font>, + Renderer::Font: 'a, +{ + fn default() -> Self { + Self::new() + } +} + +struct State<Link> { + spans: Vec<Span<'static, Link, Font>>, + span_pressed: Option<usize>, + paragraph: Paragraph, + is_hovered: bool, + selection: Selection, + is_dragging: bool, + last_click: Option<mouse::Click>, + keyboard_modifiers: keyboard::Modifiers, +} + +impl<Link> State<Link> { + fn grapheme_line_and_index(&self, point: Point) -> Option<(usize, usize)> { + let cursor = self.paragraph.buffer().hit(point.x, point.y)?; + let value = self.paragraph.buffer().lines[cursor.line].text(); + + Some(( + cursor.line, + unicode_segmentation::UnicodeSegmentation::graphemes( + &value[..cursor.index.min(value.len())], + true, + ) + .count(), + )) + } + + fn selection_end_points(&self) -> [Point; 2] { + let Selection { start, end, .. } = self.selection; + + let start_position = self + .paragraph + .grapheme_position(start.line, start.index) + .unwrap_or(Point::ORIGIN); + + let end_position = self + .paragraph + .grapheme_position(end.line, end.index) + .unwrap_or(Point::ORIGIN); + + [start_position, end_position] + } +} + +impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer> + for Rich<'_, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = Font>, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<State<Link>>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::<Link> { + spans: Vec::new(), + span_pressed: None, + paragraph: Paragraph::default(), + is_hovered: false, + selection: Selection::default(), + is_dragging: false, + last_click: None, + keyboard_modifiers: keyboard::Modifiers::default(), + }) + } + + fn size(&self) -> Size<Length> { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + tree.state.downcast_mut::<State<Link>>(), + renderer, + limits, + self.width, + self.height, + self.spans.as_ref().as_ref(), + self.line_height, + self.size, + self.font, + self.align_x, + self.align_y, + self.wrapping, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if !layout.bounds().intersects(viewport) { + return; + } + + let state = tree.state.downcast_ref::<State<Link>>(); + + let style = theme.style(&self.class); + + for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { + let is_hovered_link = self.on_link_click.is_some() + && Some(index) == self.hovered_link; + + if span.highlight.is_some() + || span.underline + || span.strikethrough + || is_hovered_link + { + let translation = layout.position() - Point::ORIGIN; + let regions = state.paragraph.span_bounds(index); + + if let Some(highlight) = span.highlight { + for bounds in ®ions { + let bounds = Rectangle::new( + bounds.position() + - Vector::new( + span.padding.left, + span.padding.top, + ), + bounds.size() + + Size::new( + span.padding.horizontal(), + span.padding.vertical(), + ), + ); + + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + border: highlight.border, + ..Default::default() + }, + highlight.background, + ); + } + } + + if span.underline || span.strikethrough || is_hovered_link { + let size = span + .size + .or(self.size) + .unwrap_or(renderer.default_size()); + + let line_height = span + .line_height + .unwrap_or(self.line_height) + .to_absolute(size); + + let color = span + .color + .or(style.color) + .unwrap_or(defaults.text_color); + + let baseline = translation + + Vector::new( + 0.0, + size.0 + (line_height.0 - size.0) / 2.0, + ); + + if span.underline || is_hovered_link { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 * 0.08), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + + if span.strikethrough { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 / 2.0), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + } + } + } + + if !state.selection.is_empty() { + let bounds = layout.bounds(); + + let [start, end] = state + .selection_end_points() + .map(|pos| pos + Vector::new(bounds.x, bounds.y)); + + let line_height = self + .line_height + .to_absolute( + self.size.unwrap_or_else(|| renderer.default_size()), + ) + .0; + + let baseline_y = bounds.y + + ((start.y - bounds.y) / line_height).floor() * line_height; + + // The correct code, uncomment when glyphs report a correct `y` value. + // + // let height = end.y - baseline_y - 0.5; + // let rows = (height / line_height).ceil() as usize; + // + // Temporary solution + let rows = + state.selection.end.line - state.selection.start.line + 1; + + for row in 0..rows { + let (x, width) = if row == 0 { + ( + start.x, + if rows == 1 { + end.x.min(bounds.x + bounds.width) - start.x + } else { + bounds.x + bounds.width - start.x + }, + ) + } else if row == rows - 1 { + (bounds.x, end.x - bounds.x) + } else { + (bounds.x, bounds.width) + }; + + let y = baseline_y + row as f32 * line_height; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x, + y, + width, + height: line_height, + }, + snap: true, + ..Default::default() + }, + style.selection, + ); + } + } + + crate::text::draw( + renderer, + defaults, + layout.bounds(), + &state.paragraph, + style, + viewport, + ); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::<State<Link>>(); + + let bounds = layout.bounds(); + let click_position = cursor.position_in(bounds); + + if viewport.intersection(&bounds).is_none() + && state.selection == Selection::default() + && !state.is_dragging + { + return; + } + + let link_was_hovered = self.hovered_link.is_some(); + let was_hovered = state.is_hovered; + let selection_before = state.selection; + + state.is_hovered = click_position.is_some(); + + if let Some(position) = click_position { + 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; + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if self.hovered_link.is_some() { + state.span_pressed = self.hovered_link; + shell.capture_event(); + } + + if let Some(position) = click_position { + let click = mouse::Click::new( + position, + mouse::Button::Left, + state.last_click, + ); + + let (line, index) = state + .grapheme_line_and_index(position) + .unwrap_or((0, 0)); + + match click.kind() { + mouse::click::Kind::Single => { + let new_end = SelectionEnd { line, index }; + + if state.keyboard_modifiers.shift() { + state.selection.change_selection(new_end); + state.is_dragging = true; + } else if state.span_pressed.is_none() { + state.selection.select_range(new_end, new_end); + state.is_dragging = true; + } + } + mouse::click::Kind::Double => { + state.selection.select_word( + line, + index, + &state.paragraph, + ); + state.is_dragging = false; + } + mouse::click::Kind::Triple => { + state.selection.select_line(line, &state.paragraph); + state.is_dragging = false; + } + } + } else { + state.selection = Selection::default(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state.is_dragging = false; + if !matches!( + event, + Event::Touch(touch::Event::FingerLost { .. }) + ) && state.selection.is_empty() + { + match state.span_pressed { + Some(span) if Some(span) == self.hovered_link => { + if let Some((link, on_link_clicked)) = self + .spans + .as_ref() + .as_ref() + .get(span) + .and_then(|span| span.link.clone()) + .zip(self.on_link_click.as_deref()) + { + shell.publish(on_link_clicked(link)); + } + } + _ => {} + } + + state.span_pressed = None; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + if let Some(position) = click_position + && state.is_dragging + { + let (line, index) = state + .grapheme_line_and_index(position) + .unwrap_or((0, 0)); + + let new_end = SelectionEnd { line, index }; + + state.selection.change_selection(new_end); + } + } + Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { + match key.as_ref() { + keyboard::Key::Character("c") + if state.keyboard_modifiers.command() + && !state.selection.is_empty() => + { + clipboard.write( + clipboard::Kind::Standard, + state.selection.text(&state.paragraph), + ); + + shell.capture_event(); + } + keyboard::Key::Character("a") + if state.keyboard_modifiers.command() + && state.selection != Selection::default() => + { + state.selection.select_all(&state.paragraph); + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::Home) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.jump() { + state.selection.select_beginning(); + } else { + state.selection.select_line_beginning(); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::End) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.jump() { + state.selection.select_end(&state.paragraph); + } else { + state.selection.select_line_end(&state.paragraph); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::ArrowLeft) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.macos_command() { + state.selection.select_line_beginning(); + } else if state.keyboard_modifiers.jump() { + state + .selection + .select_left_by_words(&state.paragraph); + } else { + state.selection.select_left(&state.paragraph); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::ArrowRight) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.macos_command() { + state.selection.select_line_end(&state.paragraph); + } else if state.keyboard_modifiers.jump() { + state + .selection + .select_right_by_words(&state.paragraph); + } else { + state.selection.select_right(&state.paragraph); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::ArrowUp) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.macos_command() { + state.selection.select_beginning(); + } else if state.keyboard_modifiers.jump() { + state.selection.select_line_beginning(); + } else { + state.selection.select_up(&state.paragraph); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::ArrowDown) + if state.keyboard_modifiers.shift() + && state.selection != Selection::default() => + { + if state.keyboard_modifiers.macos_command() { + state.selection.select_end(&state.paragraph); + } else if state.keyboard_modifiers.jump() { + state.selection.select_line_end(&state.paragraph); + } else { + state.selection.select_down(&state.paragraph); + } + + shell.capture_event(); + } + keyboard::Key::Named(key::Named::Escape) => { + state.is_dragging = false; + state.selection = Selection::default(); + + state.keyboard_modifiers = + keyboard::Modifiers::default(); + + if state.selection != selection_before { + shell.capture_event(); + } + } + _ => {} + } + } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + state.keyboard_modifiers = *modifiers; + } + _ => {} + } + + if state.is_hovered != was_hovered + || state.selection != selection_before + || self.hovered_link.is_some() != link_was_hovered + { + shell.request_redraw(); + } + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if self.hovered_link.is_some() { + mouse::Interaction::Pointer + } else if cursor.is_over(layout.bounds()) { + mouse::Interaction::Text + } else { + mouse::Interaction::None + } + } +} + +#[allow(clippy::too_many_arguments)] +fn layout<Link, Renderer>( + state: &mut State<Link>, + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + spans: &[Span<'_, Link, Renderer::Font>], + line_height: LineHeight, + size: Option<Pixels>, + font: Option<Renderer::Font>, + align_x: Alignment, + align_y: alignment::Vertical, + wrapping: Wrapping, +) -> layout::Node +where + Link: Clone, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = Font>, +{ + layout::sized(limits, width, height, |limits| { + let bounds = limits.max(); + + let size = size.unwrap_or_else(|| renderer.default_size()); + let font = font.unwrap_or_else(|| renderer.default_font()); + + let text_with_spans = || core::Text { + content: spans, + bounds, + size, + line_height, + font, + align_x, + align_y, + shaping: Shaping::Advanced, + wrapping, + }; + + if state.spans != spans { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + state.spans = spans.iter().cloned().map(Span::to_static).collect(); + } else { + match state.paragraph.compare(core::Text { + content: (), + bounds, + size, + line_height, + font, + align_x, + align_y, + shaping: Shaping::Advanced, + wrapping, + }) { + core::text::Difference::None => {} + core::text::Difference::Bounds => { + state.paragraph.resize(bounds); + } + core::text::Difference::Shape => { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + } + } + } + + state.paragraph.min_bounds() + }) +} + +impl<'a, Link, Message, Theme, Renderer> + FromIterator<Span<'a, Link, Renderer::Font>> + for Rich<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'a, + Theme: Catalog, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = Font>, + Renderer::Font: 'a, +{ + fn from_iter<T: IntoIterator<Item = Span<'a, Link, Renderer::Font>>>( + spans: T, + ) -> Self { + Self::with_spans(spans.into_iter().collect::<Vec<_>>()) + } +} + +impl<'a, Link, Message, Theme, Renderer> + From<Rich<'a, Link, Message, Theme, Renderer>> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Link: Clone + 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Paragraph = Paragraph, Font = Font> + 'a, +{ + fn from( + text: Rich<'a, Link, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(text) + } +} |
