From d8b724701dde52a17daf9874e8bbcf2a64ac7d7a Mon Sep 17 00:00:00 2001 From: Polesznyák Márk Date: Sat, 18 Oct 2025 01:43:15 +0200 Subject: feat: initial commit --- .builds/ci.yml | 24 ++ .builds/doc.yml | 23 ++ .cargo/config.toml | 3 + .gitattributes | 6 + .gitignore | 2 + Cargo.toml | 48 ++++ LICENSE | 21 ++ docs/redirect.html | 13 + examples/name.rs | 39 +++ rustfmt.toml | 3 + src/lib.rs | 29 +++ src/selection.rs | 436 ++++++++++++++++++++++++++++++++ src/text.rs | 715 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/text/rich.rs | 0 14 files changed, 1362 insertions(+) create mode 100644 .builds/ci.yml create mode 100644 .builds/doc.yml create mode 100644 .cargo/config.toml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 docs/redirect.html create mode 100644 examples/name.rs create mode 100644 rustfmt.toml create mode 100644 src/lib.rs create mode 100644 src/selection.rs create mode 100644 src/text.rs create mode 100644 src/text/rich.rs diff --git a/.builds/ci.yml b/.builds/ci.yml new file mode 100644 index 0000000..aed0e26 --- /dev/null +++ b/.builds/ci.yml @@ -0,0 +1,24 @@ +image: ubuntu/noble +packages: + - rustup + - pkg-config + - libxkbcommon-dev + - libssl-dev +triggers: + - action: email + condition: failure + to: "<~pml68/iced-crates@lists.sr.ht>" +tasks: + - rust-setup: | + rustup toolchain install stable --profile default -c clippy + rustup default stable + - lint: | + cd iced_selection + cargo lint + - test: | + cd iced_selection + cargo test --verbose --doc + cargo test --verbose --all-targets + - build-example: | + cd iced_selection + cargo build --example name diff --git a/.builds/doc.yml b/.builds/doc.yml new file mode 100644 index 0000000..c70f1d4 --- /dev/null +++ b/.builds/doc.yml @@ -0,0 +1,23 @@ +image: alpine/edge +oauth: pages.sr.ht/PAGES:RW +packages: + - cargo + - hut +environment: + site: iced-selection.pml68.dev +tasks: + - build-docs: | + cd iced_selection + cargo doc --verbose --no-deps + - copy-redirect: | + cd iced_selection + cp docs/redirect.html target/doc/index.html + - package: | + cd iced_selection + tar -C target/doc -cvz . > site.tar.gz + - upload: | + cd iced_selection + [ "$BUILD_SUBMITTER" = "git.sr.ht" ] || complete-build + [ "$GIT_REF" = "refs/heads/master" ] || complete-build + [ "$(git remote get-url origin)" = "https://git.sr.ht/~pml68/iced_selection" ] || complete-build + hut pages publish -d $site site.tar.gz diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..feb6aad --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +lint = "clippy --no-deps -- -D warnings" +lint-all = "clippy --no-deps -- -D clippy::pedantic" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2742e4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf + +# Older git versions try to fix line endings on images, this prevents it. +*.png binary +*.jpg binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ea35440 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "iced_selection" +description = "Text selection for `iced`" +authors = ["pml68 "] +version = "0.0.0" +edition = "2024" +license = "MIT" +readme = "README.md" +homepage = "https://sr.ht/~pml68/iced_selection" +repository = "https://git.sr.ht/~pml68/iced_selection" +documentation = "https://iced-selection.pml68.dev" +categories = ["gui"] +keywords = ["gui", "ui", "graphics", "interface", "widgets"] +rust-version = "1.88" + +[dependencies] +iced_widget = { git = "https://github.com/iced-rs/iced" } +unicode-segmentation = "1.0" + +[dev-dependencies] +iced = { git = "https://github.com/iced-rs/iced" } + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + +[lints.rust] +missing_docs = "deny" +unsafe_code = "deny" +unused_results = "deny" + +[lints.clippy] +type-complexity = "allow" +semicolon_if_nothing_returned = "deny" +trivially-copy-pass-by-ref = "deny" +default_trait_access = "deny" +match-wildcard-for-single-variants = "deny" +redundant-closure-for-method-calls = "deny" +filter_map_next = "deny" +manual_let_else = "deny" +unused_async = "deny" +from_over_into = "deny" +needless_borrow = "deny" +new_without_default = "deny" +useless_conversion = "deny" + +[lints.rustdoc] +broken_intra_doc_links = "forbid" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb9185f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Polesznyák Márk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/redirect.html b/docs/redirect.html new file mode 100644 index 0000000..e483747 --- /dev/null +++ b/docs/redirect.html @@ -0,0 +1,13 @@ + + + + + + + Redirecting... + + + +

If you are not redirected automatically, follow this link.

+ + diff --git a/examples/name.rs b/examples/name.rs new file mode 100644 index 0000000..6d0edec --- /dev/null +++ b/examples/name.rs @@ -0,0 +1,39 @@ +#![allow(missing_docs)] +use iced::widget::{center, column, text_input}; +use iced::{Center, Element}; +use iced_selection::text; + +fn main() -> iced::Result { + iced::run(State::update, State::view) +} + +#[derive(Default)] +struct State { + name: String, +} + +#[derive(Debug, Clone)] +enum Message { + UpdateText(String), +} + +impl State { + fn update(&mut self, message: Message) { + match message { + Message::UpdateText(name) => self.name = name, + }; + } + + fn view(&self) -> Element<'_, Message> { + center( + column![ + text!("Hello {}", &self.name), + text_input("Type your name here...", &self.name) + .on_input(Message::UpdateText) + ] + .spacing(10) + .align_x(Center), + ) + .into() + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e029395 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +group_imports = "StdExternalCrate" +max_width = 80 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d103b63 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,29 @@ +//! A text selection API built around `iced`'s [`Paragraph`]. +//! +//! [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + +pub mod selection; + +pub mod text; + +use iced_widget::core; +pub use text::Text; + +/// Creates a new [`Text`] widget with the provided content. +#[macro_export] +macro_rules! text { + ($($arg:tt)*) => { + $crate::Text::new(format!($($arg)*)) + }; +} + +/// Creates a new [`Text`] widget with the provided content. +pub fn text<'a, Theme, Renderer>( + text: impl core::text::IntoFragment<'a>, +) -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: core::text::Renderer, +{ + Text::new(text) +} diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 0000000..32c8807 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,436 @@ +//! Provides a [`Selection`] type for working with text selections in [`Paragraph`]. +//! +//! [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + +use std::cmp::Ordering; + +use iced_widget::{graphics::text::Paragraph, text_input::Value}; + +/// The direction of a selection. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +#[allow(missing_docs)] +pub enum Direction { + Left, + #[default] + Right, +} + +/// A text selection. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Selection { + /// The start of the selection. + pub start: SelectionEnd, + /// The end of the selection. + pub end: SelectionEnd, + /// The last direction of the selection. + pub direction: Direction, + moving_line_index: Option, +} + +/// One of the ends of a [`Selection`]. +/// +/// Note that the index refers to [`graphemes`], not glyphs or bytes. +/// +/// [`graphemes`]: https://docs.rs/unicode-segmentation/latest/unicode_segmentation/trait.UnicodeSegmentation.html#tymethod.graphemes +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[allow(missing_docs)] +pub struct SelectionEnd { + pub line: usize, + pub index: usize, +} + +impl SelectionEnd { + /// Creates a new [`SelectionEnd`]. + pub fn new(line: usize, index: usize) -> Self { + Self { line, index } + } +} + +impl PartialOrd for SelectionEnd { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SelectionEnd { + fn cmp(&self, other: &Self) -> Ordering { + self.line + .cmp(&other.line) + .then(self.index.cmp(&other.index)) + } +} + +impl Selection { + /// Creates a new empty [`Selection`]. + pub fn new() -> Self { + Self::default() + } + + /// A selection is empty when the start and end are the same. + pub fn is_empty(&self) -> bool { + self.start == self.end + } + + /// Returns the selected text from the given [`Paragraph`]. + /// + /// [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + pub fn text(&self, paragraph: &Paragraph) -> String { + let Selection { start, end, .. } = *self; + + let mut value = String::new(); + let buffer_lines = ¶graph.buffer().lines; + let lines_total = end.line - start.line + 1; + + for (idx, line) in buffer_lines.iter().enumerate().take(lines_total) { + let text = line.text(); + let length = text.len(); + + if idx == 0 { + if lines_total == 1 { + value.push_str( + &text[start.index.min(length)..end.index.min(length)], + ); + } else { + value.push_str(&dbg!(text)[start.index.min(length)..]); + value.push_str(line.ending().as_str()); + } + } else if idx == lines_total - 1 { + value.push_str(&text[..end.index.min(length)]); + } else { + value.push_str(text); + value.push_str(line.ending().as_str()); + } + } + + value + } + + /// Returns the currently active [`SelectionEnd`]. + /// + /// `self.end` if `self.direction` is [`Right`], `self.start` otherwise. + /// + /// [`Right`]: Direction::Right + pub fn active_end(&self) -> SelectionEnd { + if self.direction == Direction::Right { + self.end + } else { + self.start + } + } + + /// Select a new range. + /// + /// `self.start` will be set to the smaller value, `self.end` to the larger. + /// + /// # Example + /// + /// ``` + /// use iced_selection::selection::{Selection, SelectionEnd}; + /// + /// let mut selection = Selection::default(); + /// + /// let start = SelectionEnd::new(5, 17); + /// let end = SelectionEnd::new(2, 8); + /// + /// selection.select_range(start, end); + /// + /// assert_eq!(selection.start, end); + /// assert_eq!(selection.end, start); + /// ``` + pub fn select_range(&mut self, start: SelectionEnd, end: SelectionEnd) { + self.start = start.min(end); + self.end = end.max(start); + } + + /// Updates the current selection by setting a new end point. + /// + /// This method adjusts the selection range based on the provided `new_end` position. The + /// current [`Direction`] is used to determine the new values: + /// + /// - If the current direction is [`Right`] (i.e., the selection goes from `start` to `end`), the + /// range becomes `(start, new_end)`. If `new_end` is before `start`, the direction is flipped to [`Left`]. + /// + /// - If it's [`Left`], the range becomes `(new_end, end)`. If `new_end` is after `end`, the + /// direction is flipped to [`Right`]. + /// + /// # Example + /// + /// ``` + /// use iced_selection::selection::{Direction, Selection, SelectionEnd}; + /// + /// let mut selection = Selection::default(); + /// + /// let start = SelectionEnd::new(5, 17); + /// let end = SelectionEnd::new(2, 8); + /// + /// selection.select_range(start, end); + /// + /// assert_eq!(selection.start, end); + /// assert_eq!(selection.end, start); + /// assert_eq!(selection.direction, Direction::Right); + /// + /// let new_end = SelectionEnd::new(2, 2); + /// + /// selection.change_selection(new_end); + /// + /// assert_eq!(selection.start, new_end); + /// assert_eq!(selection.end, end); + /// assert_eq!(selection.direction, Direction::Left); + /// ``` + /// + /// [`Left`]: Direction::Left + /// [`Right`]: Direction::Right + pub fn change_selection(&mut self, new_end: SelectionEnd) { + let (start, end) = if self.direction == Direction::Right { + if new_end < self.start { + self.direction = Direction::Left; + } + + (self.start, new_end) + } else { + if new_end > self.end { + self.direction = Direction::Right; + } + + (new_end, self.end) + }; + + self.moving_line_index = None; + self.select_range(start, end); + } + + /// Selects the word around the given grapheme position. + pub fn select_word( + &mut self, + line: usize, + index: usize, + paragraph: &Paragraph, + ) { + let value = Value::new(paragraph.buffer().lines[line].text()); + + let start = + SelectionEnd::new(line, value.previous_start_of_word(index)); + let end = SelectionEnd::new(line, value.next_end_of_word(index)); + + self.select_range(start, end); + } + + /// Moves the active [`SelectionEnd`] to the left by one, wrapping to the previous line if + /// possible and required. + pub fn select_left(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + if active_end.index > 0 { + active_end.index -= 1; + + self.change_selection(active_end); + } else if active_end.line > 0 { + active_end.line -= 1; + + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + active_end.index = value.len(); + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] to the right by one, wrapping to the next line if + /// possible and required. + pub fn select_right(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + let lines = ¶graph.buffer().lines; + let value = Value::new(lines[active_end.line].text()); + + if active_end.index < value.len() { + active_end.index += 1; + + self.change_selection(active_end); + } else if active_end.line < lines.len() - 1 { + active_end.line += 1; + active_end.index = 0; + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] up by one, keeping track of the original grapheme index. + pub fn select_up(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + if active_end.line == 0 { + active_end.index = 0; + + self.change_selection(active_end); + } else { + active_end.line -= 1; + + let mut moving_line_index = None; + + if let Some(index) = self.moving_line_index.take() { + active_end.index = index; + } + + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + if active_end.index > value.len() { + moving_line_index = Some(active_end.index); + active_end.index = value.len(); + } + + self.change_selection(active_end); + self.moving_line_index = moving_line_index; + } + } + + /// Moves the active [`SelectionEnd`] down by one, keeping track of the original grapheme index. + pub fn select_down(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + let lines = ¶graph.buffer().lines; + let value = Value::new(lines[active_end.line].text()); + + if active_end.line == lines.len() - 1 { + active_end.index = value.len(); + + self.change_selection(active_end); + } else { + active_end.line += 1; + + let mut moving_line_index = None; + + if let Some(index) = self.moving_line_index.take() { + active_end.index = index; + } + + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + if active_end.index > value.len() { + moving_line_index = Some(active_end.index); + active_end.index = value.len(); + } + + self.change_selection(active_end); + self.moving_line_index = moving_line_index; + } + } + + /// Moves the active [`SelectionEnd`] to the previous start of a word on its current line, or + /// the previous line if it exists and `index == 0`. + pub fn select_left_by_words(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + if active_end.index == 1 { + active_end.index = 0; + + self.change_selection(active_end); + } else if active_end.index > 1 { + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + active_end.index = value.previous_start_of_word(active_end.index); + + self.change_selection(active_end); + } else if active_end.line > 0 { + active_end.line -= 1; + + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + active_end.index = value.previous_start_of_word(value.len()); + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] to the next end of a word on its current line, or + /// the next line if it exists and `index == line.len()`. + pub fn select_right_by_words(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + let lines = ¶graph.buffer().lines; + let value = Value::new(lines[active_end.line].text()); + + if value.len() - active_end.index == 1 { + active_end.index = value.len(); + + self.change_selection(active_end); + } else if active_end.index < value.len() { + active_end.index = value.next_end_of_word(active_end.index); + + self.change_selection(active_end); + } else if active_end.line < lines.len() - 1 { + active_end.line += 1; + + let value = Value::new(lines[active_end.line].text()); + active_end.index = value.next_end_of_word(0); + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] to the beginning of its current line. + pub fn select_line_beginning(&mut self) { + let mut active_end = self.active_end(); + + if active_end.index > 0 { + active_end.index = 0; + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] to the end of its current line. + pub fn select_line_end(&mut self, paragraph: &Paragraph) { + let mut active_end = self.active_end(); + + let value = + Value::new(paragraph.buffer().lines[active_end.line].text()); + + if active_end.index < value.len() { + active_end.index = value.len(); + + self.change_selection(active_end); + } + } + + /// Moves the active [`SelectionEnd`] to the beginning of the [`Paragraph`]. + /// + /// [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + pub fn select_beginning(&mut self) { + self.change_selection(SelectionEnd::new(0, 0)); + } + + /// Moves the active [`SelectionEnd`] to the end of the [`Paragraph`]. + /// + /// [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + pub fn select_end(&mut self, paragraph: &Paragraph) { + let lines = ¶graph.buffer().lines; + let value = Value::new(lines[lines.len() - 1].text()); + + let new_end = SelectionEnd::new(lines.len() - 1, value.len()); + + self.change_selection(new_end); + } + + /// Selects an entire line. + pub fn select_line(&mut self, line: usize, paragraph: &Paragraph) { + let value = Value::new(paragraph.buffer().lines[line].text()); + + let start = SelectionEnd::new(line, 0); + let end = SelectionEnd::new(line, value.len()); + + self.select_range(start, end); + } + + /// Selects the entire [`Paragraph`]. + /// + /// [`Paragraph`]: https://docs.iced.rs/iced_graphics/text/paragraph/struct.Paragraph.html + pub fn select_all(&mut self, paragraph: &Paragraph) { + let line = paragraph.buffer().lines.len() - 1; + let index = Value::new(paragraph.buffer().lines[line].text()).len(); + + let end = SelectionEnd::new(line, index); + + self.select_range(SelectionEnd::new(0, 0), end); + } +} diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..96d4f1b --- /dev/null +++ b/src/text.rs @@ -0,0 +1,715 @@ +//! 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, + Theme, Widget, alignment, clipboard, + keyboard::{self, key}, + layout, + mouse::{self, click}, + renderer, + text::{self, Paragraph as _}, + touch, + widget::{ + Operation, + text::Format, + tree::{self, Tree}, + }, + }, + graphics::text::Paragraph, +}; +use text::{Alignment, LineHeight, Shaping, Wrapping}; + +use crate::selection::{Selection, SelectionEnd}; + +/// A bunch of text. +pub struct Text< + 'a, + Theme = iced_widget::Theme, + Renderer = iced_widget::Renderer, +> where + Theme: Catalog, + Renderer: text::Renderer, +{ + fragment: text::Fragment<'a>, + format: Format, + class: Theme::Class<'a>, +} + +impl<'a, Theme, Renderer> Text<'a, Theme, Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, +{ + /// Create a new fragment of [`Text`] with the given contents. + pub fn new(fragment: impl text::IntoFragment<'a>) -> Self { + Self { + fragment: fragment.into_fragment(), + format: Format::default(), + class: Theme::default(), + } + } + + /// Sets the size of the [`Text`]. + pub fn size(mut self, size: impl Into) -> Self { + self.format.size = Some(size.into()); + self + } + + /// Sets the [`LineHeight`] of the [`Text`]. + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.format.line_height = line_height.into(); + self + } + + /// Sets the [`Font`] of the [`Text`]. + pub fn font(mut self, font: impl Into) -> Self { + self.format.font = Some(font.into()); + self + } + + /// Sets the width of the [`Text`] boundaries. + pub fn width(mut self, width: impl Into) -> Self { + self.format.width = width.into(); + self + } + + /// Sets the height of the [`Text`] boundaries. + pub fn height(mut self, height: impl Into) -> Self { + self.format.height = height.into(); + self + } + + /// Centers the [`Text`], both horizontally and vertically. + pub fn center(mut self) -> Self { + self.format.align_x = Alignment::Center; + self.format.align_y = alignment::Vertical::Center; + self + } + + /// Sets the [`alignment::Horizontal`] of the [`Text`]. + pub fn align_x(mut self, alignment: impl Into) -> Self { + self.format.align_x = alignment.into(); + self + } + + /// Sets the [`alignment::Vertical`] of the [`Text`]. + pub fn align_y( + mut self, + alignment: impl Into, + ) -> Self { + self.format.align_y = alignment.into(); + self + } + + /// Sets the [`Shaping`] strategy of the [`Text`]. + pub fn shaping(mut self, shaping: Shaping) -> Self { + self.format.shaping = shaping; + self + } + + /// Sets the [`Wrapping`] strategy of the [`Text`]. + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.format.wrapping = wrapping; + self + } + + /// Sets the style of the [`Text`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`Text`]. + #[must_use] + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } +} + +/// The internal state of a [`Text`] widget. +#[derive(Debug, Default, Clone)] +pub struct State { + paragraph: Paragraph, + content: String, + is_hovered: bool, + selection: Selection, + is_dragging: bool, + last_click: Option, + keyboard_modifiers: keyboard::Modifiers, +} + +impl State { + 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] + } + + fn update(&mut self, text: text::Text<&str, Font>) { + if self.content != text.content { + text.content.clone_into(&mut self.content); + self.paragraph = Paragraph::with_text(text); + return; + } + + match self.paragraph.compare(text.with_content(())) { + text::Difference::None => {} + text::Difference::Bounds => self.paragraph.resize(text.bounds), + text::Difference::Shape => { + self.paragraph = Paragraph::with_text(text); + } + } + } +} + +impl Widget + for Text<'_, Theme, Renderer> +where + Theme: Catalog, + Renderer: text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size { + width: self.format.width, + height: self.format.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + tree.state.downcast_mut::(), + renderer, + limits, + &self.fragment, + self.format, + ) + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + clipboard: &mut dyn core::Clipboard, + shell: &mut core::Shell<'_, Message>, + viewport: &core::Rectangle, + ) { + let state = tree.state.downcast_mut::(); + + let bounds = layout.bounds(); + let click_position = cursor.position_over(bounds); + + if viewport.intersection(&bounds).is_none() + && state.selection == Selection::default() + && !state.is_dragging + { + return; + } + + let was_hovered = state.is_hovered; + let selection_before = state.selection; + state.is_hovered = click_position.is_some(); + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(cursor_position) = click_position { + let target = + cursor_position - core::Vector::new(bounds.x, bounds.y); + + let click = mouse::Click::new( + cursor_position, + mouse::Button::Left, + state.last_click, + ); + + match click.kind() { + click::Kind::Single => { + let (line, index) = if target != Point::ORIGIN { + state.grapheme_line_and_index(target) + } else { + None + } + .unwrap_or((0, 0)); + + let new_end = SelectionEnd { line, index }; + + if state.keyboard_modifiers.shift() { + state.selection.change_selection(new_end); + } else { + state.selection.select_range(new_end, new_end); + } + + state.is_dragging = true; + } + click::Kind::Double => { + let (line, index) = state + .grapheme_line_and_index(target) + .unwrap_or((0, 0)); + + state.selection.select_word( + line, + index, + &state.paragraph, + ); + state.is_dragging = false; + } + click::Kind::Triple => { + let (line, _) = state + .grapheme_line_and_index(target) + .unwrap_or((0, 0)); + + state.selection.select_line(line, &state.paragraph); + state.is_dragging = false; + } + } + + state.last_click = Some(click); + + shell.capture_event(); + } 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; + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + if let Some(cursor_position) = click_position + && state.is_dragging + { + let target = + cursor_position - core::Vector::new(bounds.x, bounds.y); + let (line, index) = + state.grapheme_line_and_index(target).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 + { + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + _cursor_position: mouse::Cursor, + viewport: &core::Rectangle, + ) { + if !layout.bounds().intersects(viewport) { + return; + } + + let state = tree.state.downcast_ref::(); + let style = theme.style(&self.class); + + if !state.selection.is_empty() { + let bounds = layout.bounds(); + + let [start, end] = state + .selection_end_points() + .map(|pos| pos + core::Vector::new(bounds.x, bounds.y)); + + let line_height = self + .format + .line_height + .to_absolute( + self.format.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: core::Rectangle { + x, + y, + width, + height: line_height, + }, + snap: true, + ..Default::default() + }, + style.selection, + ); + } + } + + draw( + renderer, + defaults, + layout.bounds(), + &state.paragraph, + style, + viewport, + ); + } + + fn operate( + &mut self, + _state: &mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.text(None, layout.bounds(), &self.fragment); + } + + fn mouse_interaction( + &self, + tree: &Tree, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &core::Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let state = tree.state.downcast_ref::(); + + if state.is_hovered { + mouse::Interaction::Text + } else { + mouse::Interaction::default() + } + } +} + +/// Produces the [`layout::Node`] of a [`Text`] widget. +pub fn layout( + state: &mut State, + renderer: &Renderer, + limits: &layout::Limits, + content: &str, + format: Format, +) -> layout::Node +where + Renderer: text::Renderer, +{ + layout::sized(limits, format.width, format.height, |limits| { + let bounds = limits.max(); + + let size = format.size.unwrap_or_else(|| renderer.default_size()); + let font = format.font.unwrap_or_else(|| renderer.default_font()); + + state.update(text::Text { + content, + bounds, + size, + line_height: format.line_height, + font, + align_x: format.align_x, + align_y: format.align_y, + shaping: format.shaping, + wrapping: format.wrapping, + }); + + state.paragraph.min_bounds() + }) +} + +/// Draws text using the same logic as the [`Text`] widget. +pub fn draw( + renderer: &mut Renderer, + style: &renderer::Style, + bounds: core::Rectangle, + paragraph: &Paragraph, + appearance: Style, + viewport: &core::Rectangle, +) where + Renderer: text::Renderer, +{ + let anchor = bounds.anchor( + paragraph.min_bounds(), + paragraph.align_x(), + paragraph.align_y(), + ); + + renderer.fill_paragraph( + paragraph, + anchor, + appearance.color.unwrap_or(style.text_color), + *viewport, + ); +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Theme: Catalog + 'a, + Renderer: text::Renderer + 'a, +{ + fn from( + text: Text<'a, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(text) + } +} + +impl<'a, Theme, Renderer> From<&'a str> for Text<'a, Theme, Renderer> +where + Theme: Catalog + 'a, + Renderer: text::Renderer + 'a, +{ + fn from(content: &'a str) -> Self { + Self::new(content) + } +} + +/// The appearance of some text. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Style { + /// The [`Color`] of the text. + /// + /// The default, `None`, means using the inherited color. + pub color: Option, + /// The [`Color`] of text selections. + pub selection: Color, +} + +/// The theme catalog of a [`Text`]. +pub trait Catalog: Sized { + /// The item class of this [`Catalog`]. + type Class<'a>; + + /// The default class produced by this [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, item: &Self::Class<'_>) -> Style; +} + +/// A styling function for a [`Text`]. +/// +/// This is just a boxed closure: `Fn(&Theme, Status) -> Style`. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +/// The default text styling; color is inherited. +pub fn default(theme: &Theme) -> Style { + Style { + color: None, + selection: theme.extended_palette().primary.weak.color, + } +} diff --git a/src/text/rich.rs b/src/text/rich.rs new file mode 100644 index 0000000..e69de29 -- cgit v1.2.3