From 6448429103c9c82b90040ac5a5a097bdded23f82 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 12 Sep 2023 14:51:00 +0200 Subject: Draft `Editor` API and `TextEditor` widget --- core/src/layout/limits.rs | 2 +- core/src/lib.rs | 2 +- core/src/renderer/null.rs | 38 ++++ core/src/text.rs | 123 ++++-------- core/src/text/editor.rs | 68 +++++++ core/src/text/paragraph.rs | 59 ++++++ examples/editor/Cargo.toml | 10 + examples/editor/src/main.rs | 49 +++++ graphics/src/damage.rs | 7 + graphics/src/primitive.rs | 10 + graphics/src/renderer.rs | 14 ++ graphics/src/text.rs | 2 + graphics/src/text/editor.rs | 327 +++++++++++++++++++++++++++++++ renderer/src/lib.rs | 19 +- style/src/lib.rs | 1 + style/src/text_editor.rs | 47 +++++ style/src/theme.rs | 113 +++++++++++ tiny_skia/src/backend.rs | 25 +++ tiny_skia/src/text.rs | 32 ++++ wgpu/src/layer.rs | 15 +- wgpu/src/layer/text.rs | 8 +- wgpu/src/text.rs | 28 ++- widget/src/helpers.rs | 15 ++ widget/src/lib.rs | 5 +- widget/src/text_editor.rs | 457 ++++++++++++++++++++++++++++++++++++++++++++ 25 files changed, 1384 insertions(+), 92 deletions(-) create mode 100644 core/src/text/editor.rs create mode 100644 core/src/text/paragraph.rs create mode 100644 examples/editor/Cargo.toml create mode 100644 examples/editor/src/main.rs create mode 100644 graphics/src/text/editor.rs create mode 100644 style/src/text_editor.rs create mode 100644 widget/src/text_editor.rs diff --git a/core/src/layout/limits.rs b/core/src/layout/limits.rs index 5d3c1556..39a3d98b 100644 --- a/core/src/layout/limits.rs +++ b/core/src/layout/limits.rs @@ -2,7 +2,7 @@ use crate::{Length, Padding, Size}; /// A set of size constraints for layouting. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Limits { min: Size, max: Size, diff --git a/core/src/lib.rs b/core/src/lib.rs index 1bfba7bd..13a9f06b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -12,7 +12,7 @@ #![forbid(unsafe_code, rust_2018_idioms)] #![deny( missing_debug_implementations, - missing_docs, + // missing_docs, unused_results, clippy::extra_unused_lifetimes, clippy::from_over_into, diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 0ffd3649..adf75969 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -43,6 +43,7 @@ impl Renderer for Null { impl text::Renderer for Null { type Font = Font; type Paragraph = (); + type Editor = (); const ICON_FONT: Font = Font::DEFAULT; const CHECKMARK_ICON: char = '0'; @@ -66,6 +67,14 @@ impl text::Renderer for Null { ) { } + fn fill_editor( + &mut self, + _editor: &Self::Editor, + _position: Point, + _color: Color, + ) { + } + fn fill_text( &mut self, _paragraph: Text<'_, Self::Font>, @@ -106,3 +115,32 @@ impl text::Paragraph for () { None } } + +impl text::Editor for () { + type Font = Font; + + fn with_text(_text: &str) -> Self {} + + fn cursor(&self) -> text::editor::Cursor { + text::editor::Cursor::Caret(Point::ORIGIN) + } + + fn perform(&mut self, _action: text::editor::Action) {} + + fn bounds(&self) -> Size { + Size::ZERO + } + + fn min_bounds(&self) -> Size { + Size::ZERO + } + + fn update( + &mut self, + _new_bounds: Size, + _new_font: Self::Font, + _new_size: Pixels, + _new_line_height: text::LineHeight, + ) { + } +} diff --git a/core/src/text.rs b/core/src/text.rs index ff85696e..5aacbcc5 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -1,4 +1,11 @@ //! Draw and interact with text. +mod paragraph; + +pub mod editor; + +pub use editor::Editor; +pub use paragraph::Paragraph; + use crate::alignment; use crate::{Color, Pixels, Point, Size}; @@ -126,6 +133,31 @@ impl Hit { } } +/// The difference detected in some text. +/// +/// You will obtain a [`Difference`] when you [`compare`] a [`Paragraph`] with some +/// [`Text`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Difference { + /// No difference. + /// + /// The text can be reused as it is! + None, + + /// A bounds difference. + /// + /// This normally means a relayout is necessary, but the shape of the text can + /// be reused. + Bounds, + + /// A shape difference. + /// + /// The contents, alignment, sizes, fonts, or any other essential attributes + /// of the shape of the text have changed. A complete reshape and relayout of + /// the text is necessary. + Shape, +} + /// A renderer capable of measuring and drawing [`Text`]. pub trait Renderer: crate::Renderer { /// The font type used. @@ -134,6 +166,9 @@ pub trait Renderer: crate::Renderer { /// The [`Paragraph`] of this [`Renderer`]. type Paragraph: Paragraph + 'static; + /// The [`Editor`] of this [`Renderer`]. + type Editor: Editor + 'static; + /// The icon font of the backend. const ICON_FONT: Self::Font; @@ -165,6 +200,13 @@ pub trait Renderer: crate::Renderer { color: Color, ); + fn fill_editor( + &mut self, + editor: &Self::Editor, + position: Point, + color: Color, + ); + /// Draws the given [`Text`] at the given position and with the given /// [`Color`]. fn fill_text( @@ -174,84 +216,3 @@ pub trait Renderer: crate::Renderer { color: Color, ); } - -/// A text paragraph. -pub trait Paragraph: Sized + Default { - /// The font of this [`Paragraph`]. - type Font: Copy + PartialEq; - - /// Creates a new [`Paragraph`] laid out with the given [`Text`]. - fn with_text(text: Text<'_, Self::Font>) -> Self; - - /// Lays out the [`Paragraph`] with some new boundaries. - fn resize(&mut self, new_bounds: Size); - - /// Compares the [`Paragraph`] with some desired [`Text`] and returns the - /// [`Difference`]. - fn compare(&self, text: Text<'_, Self::Font>) -> Difference; - - /// Returns the horizontal alignment of the [`Paragraph`]. - fn horizontal_alignment(&self) -> alignment::Horizontal; - - /// Returns the vertical alignment of the [`Paragraph`]. - fn vertical_alignment(&self) -> alignment::Vertical; - - /// Returns the minimum boundaries that can fit the contents of the - /// [`Paragraph`]. - fn min_bounds(&self) -> Size; - - /// Tests whether the provided point is within the boundaries of the - /// [`Paragraph`], returning information about the nearest character. - fn hit_test(&self, point: Point) -> Option; - - /// Returns the distance to the given grapheme index in the [`Paragraph`]. - fn grapheme_position(&self, line: usize, index: usize) -> Option; - - /// Updates the [`Paragraph`] to match the given [`Text`], if needed. - fn update(&mut self, text: Text<'_, Self::Font>) { - match self.compare(text) { - Difference::None => {} - Difference::Bounds => { - self.resize(text.bounds); - } - Difference::Shape => { - *self = Self::with_text(text); - } - } - } - - /// Returns the minimum width that can fit the contents of the [`Paragraph`]. - fn min_width(&self) -> f32 { - self.min_bounds().width - } - - /// Returns the minimum height that can fit the contents of the [`Paragraph`]. - fn min_height(&self) -> f32 { - self.min_bounds().height - } -} - -/// The difference detected in some text. -/// -/// You will obtain a [`Difference`] when you [`compare`] a [`Paragraph`] with some -/// [`Text`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Difference { - /// No difference. - /// - /// The text can be reused as it is! - None, - - /// A bounds difference. - /// - /// This normally means a relayout is necessary, but the shape of the text can - /// be reused. - Bounds, - - /// A shape difference. - /// - /// The contents, alignment, sizes, fonts, or any other essential attributes - /// of the shape of the text have changed. A complete reshape and relayout of - /// the text is necessary. - Shape, -} diff --git a/core/src/text/editor.rs b/core/src/text/editor.rs new file mode 100644 index 00000000..a4fd0ec1 --- /dev/null +++ b/core/src/text/editor.rs @@ -0,0 +1,68 @@ +use crate::text::LineHeight; +use crate::{Pixels, Point, Rectangle, Size}; + +pub trait Editor: Sized + Default { + type Font: Copy + PartialEq + Default; + + /// Creates a new [`Editor`] laid out with the given text. + fn with_text(text: &str) -> Self; + + fn cursor(&self) -> Cursor; + + fn perform(&mut self, action: Action); + + /// Returns the current boundaries of the [`Editor`]. + fn bounds(&self) -> Size; + + /// Returns the minimum boundaries that can fit the contents of the + /// [`Editor`]. + fn min_bounds(&self) -> Size; + + /// Updates the [`Editor`] with some new attributes. + fn update( + &mut self, + new_bounds: Size, + new_font: Self::Font, + new_size: Pixels, + new_line_height: LineHeight, + ); + + /// Returns the minimum width that can fit the contents of the [`Editor`]. + fn min_width(&self) -> f32 { + self.min_bounds().width + } + + /// Returns the minimum height that can fit the contents of the [`Editor`]. + fn min_height(&self) -> f32 { + self.min_bounds().height + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Action { + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveLeftWord, + MoveRightWord, + MoveHome, + MoveEnd, + SelectWord, + SelectLine, + Insert(char), + Backspace, + Delete, + Click(Point), + Drag(Point), +} + +/// The cursor of an [`Editor`]. +#[derive(Debug, Clone)] +pub enum Cursor { + /// Cursor without a selection + Caret(Point), + + /// Cursor selecting a range of text + Selection(Vec), +} diff --git a/core/src/text/paragraph.rs b/core/src/text/paragraph.rs new file mode 100644 index 00000000..de1fb74d --- /dev/null +++ b/core/src/text/paragraph.rs @@ -0,0 +1,59 @@ +use crate::alignment; +use crate::text::{Difference, Hit, Text}; +use crate::{Point, Size}; + +/// A text paragraph. +pub trait Paragraph: Sized + Default { + /// The font of this [`Paragraph`]. + type Font: Copy + PartialEq; + + /// Creates a new [`Paragraph`] laid out with the given [`Text`]. + fn with_text(text: Text<'_, Self::Font>) -> Self; + + /// Lays out the [`Paragraph`] with some new boundaries. + fn resize(&mut self, new_bounds: Size); + + /// Compares the [`Paragraph`] with some desired [`Text`] and returns the + /// [`Difference`]. + fn compare(&self, text: Text<'_, Self::Font>) -> Difference; + + /// Returns the horizontal alignment of the [`Paragraph`]. + fn horizontal_alignment(&self) -> alignment::Horizontal; + + /// Returns the vertical alignment of the [`Paragraph`]. + fn vertical_alignment(&self) -> alignment::Vertical; + + /// Returns the minimum boundaries that can fit the contents of the + /// [`Paragraph`]. + fn min_bounds(&self) -> Size; + + /// Tests whether the provided point is within the boundaries of the + /// [`Paragraph`], returning information about the nearest character. + fn hit_test(&self, point: Point) -> Option; + + /// Returns the distance to the given grapheme index in the [`Paragraph`]. + fn grapheme_position(&self, line: usize, index: usize) -> Option; + + /// Updates the [`Paragraph`] to match the given [`Text`], if needed. + fn update(&mut self, text: Text<'_, Self::Font>) { + match self.compare(text) { + Difference::None => {} + Difference::Bounds => { + self.resize(text.bounds); + } + Difference::Shape => { + *self = Self::with_text(text); + } + } + } + + /// Returns the minimum width that can fit the contents of the [`Paragraph`]. + fn min_width(&self) -> f32 { + self.min_bounds().width + } + + /// Returns the minimum height that can fit the contents of the [`Paragraph`]. + fn min_height(&self) -> f32 { + self.min_bounds().height + } +} diff --git a/examples/editor/Cargo.toml b/examples/editor/Cargo.toml new file mode 100644 index 00000000..528cf23c --- /dev/null +++ b/examples/editor/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "editor" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez "] +edition = "2021" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["debug"] \ No newline at end of file diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs new file mode 100644 index 00000000..50989ac5 --- /dev/null +++ b/examples/editor/src/main.rs @@ -0,0 +1,49 @@ +use iced::widget::{container, text_editor}; +use iced::{Element, Font, Sandbox, Settings}; + +pub fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + content: text_editor::Content, +} + +#[derive(Debug, Clone, Copy)] +enum Message { + Edit(text_editor::Action), +} + +impl Sandbox for Editor { + type Message = Message; + + fn new() -> Self { + Self { + content: text_editor::Content::with(include_str!( + "../../../README.md" + )), + } + } + + fn title(&self) -> String { + String::from("Editor - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::Edit(action) => { + self.content.edit(action); + } + } + } + + fn view(&self) -> Element { + container( + text_editor(&self.content) + .on_edit(Message::Edit) + .font(Font::with_name("Hasklug Nerd Font Mono")), + ) + .padding(20) + .into() + } +} diff --git a/graphics/src/damage.rs b/graphics/src/damage.rs index 3276c2d4..595cc274 100644 --- a/graphics/src/damage.rs +++ b/graphics/src/damage.rs @@ -66,6 +66,13 @@ impl Damage for Primitive { bounds.expand(1.5) } + Self::Editor { + editor, position, .. + } => { + let bounds = Rectangle::new(*position, editor.bounds); + + bounds.expand(1.5) + } Self::Quad { bounds, .. } | Self::Image { bounds, .. } | Self::Svg { bounds, .. } => bounds.expand(1.0), diff --git a/graphics/src/primitive.rs b/graphics/src/primitive.rs index 8a97e6e7..ce0b734b 100644 --- a/graphics/src/primitive.rs +++ b/graphics/src/primitive.rs @@ -4,6 +4,7 @@ use crate::core::image; use crate::core::svg; use crate::core::text; use crate::core::{Background, Color, Font, Pixels, Point, Rectangle, Vector}; +use crate::text::editor; use crate::text::paragraph; use std::sync::Arc; @@ -41,6 +42,15 @@ pub enum Primitive { /// The color of the paragraph. color: Color, }, + /// An editor primitive + Editor { + /// The [`editor::Weak`] reference. + editor: editor::Weak, + /// The position of the paragraph. + position: Point, + /// The color of the paragraph. + color: Color, + }, /// A quad primitive Quad { /// The bounds of the quad diff --git a/graphics/src/renderer.rs b/graphics/src/renderer.rs index c5033d36..9b699183 100644 --- a/graphics/src/renderer.rs +++ b/graphics/src/renderer.rs @@ -141,6 +141,7 @@ where { type Font = Font; type Paragraph = text::Paragraph; + type Editor = text::Editor; const ICON_FONT: Font = Font::with_name("Iced-Icons"); const CHECKMARK_ICON: char = '\u{f00c}'; @@ -171,6 +172,19 @@ where }); } + fn fill_editor( + &mut self, + editor: &Self::Editor, + position: Point, + color: Color, + ) { + self.primitives.push(Primitive::Editor { + editor: editor.downgrade(), + position, + color, + }); + } + fn fill_text( &mut self, text: Text<'_, Self::Font>, diff --git a/graphics/src/text.rs b/graphics/src/text.rs index f5ccaf52..280e4f01 100644 --- a/graphics/src/text.rs +++ b/graphics/src/text.rs @@ -1,7 +1,9 @@ pub mod cache; +pub mod editor; pub mod paragraph; pub use cache::Cache; +pub use editor::Editor; pub use paragraph::Paragraph; pub use cosmic_text; diff --git a/graphics/src/text/editor.rs b/graphics/src/text/editor.rs new file mode 100644 index 00000000..53f63fea --- /dev/null +++ b/graphics/src/text/editor.rs @@ -0,0 +1,327 @@ +use crate::core::text::editor::{self, Action, Cursor}; +use crate::core::text::LineHeight; +use crate::core::{Font, Pixels, Point, Size}; +use crate::text; + +use cosmic_text::Edit; + +use std::fmt; +use std::sync::{self, Arc}; + +#[derive(Debug, PartialEq)] +pub struct Editor(Option>); + +struct Internal { + editor: cosmic_text::Editor, + font: Font, + bounds: Size, + min_bounds: Size, + version: text::Version, +} + +impl Editor { + pub fn new() -> Self { + Self::default() + } + + pub fn buffer(&self) -> &cosmic_text::Buffer { + &self.internal().editor.buffer() + } + + pub fn downgrade(&self) -> Weak { + let editor = self.internal(); + + Weak { + raw: Arc::downgrade(editor), + bounds: editor.bounds, + } + } + + fn internal(&self) -> &Arc { + self.0 + .as_ref() + .expect("editor should always be initialized") + } +} + +impl editor::Editor for Editor { + type Font = Font; + + fn with_text(text: &str) -> Self { + let mut buffer = cosmic_text::Buffer::new_empty(cosmic_text::Metrics { + font_size: 1.0, + line_height: 1.0, + }); + + buffer.set_text( + text::font_system() + .write() + .expect("Write font system") + .raw(), + text, + cosmic_text::Attrs::new(), + cosmic_text::Shaping::Advanced, + ); + + Editor(Some(Arc::new(Internal { + editor: cosmic_text::Editor::new(buffer), + ..Default::default() + }))) + } + + fn cursor(&self) -> editor::Cursor { + let internal = self.internal(); + + match internal.editor.select_opt() { + Some(selection) => { + // TODO + Cursor::Selection(vec![]) + } + None => { + let cursor = internal.editor.cursor(); + let buffer = internal.editor.buffer(); + + let lines_before_cursor: usize = buffer + .lines + .iter() + .take(cursor.line) + .map(|line| { + line.layout_opt() + .as_ref() + .expect("Line layout should be cached") + .len() + }) + .sum(); + + let line = buffer + .lines + .get(cursor.line) + .expect("Cursor line should be present"); + + let layout = line + .layout_opt() + .as_ref() + .expect("Line layout should be cached"); + + let mut lines = layout.iter().enumerate(); + + let (subline, offset) = lines + .find_map(|(i, line)| { + let start = line + .glyphs + .first() + .map(|glyph| glyph.start) + .unwrap_or(0); + let end = line + .glyphs + .last() + .map(|glyph| glyph.end) + .unwrap_or(0); + + let is_cursor_after_start = start <= cursor.index; + + let is_cursor_before_end = match cursor.affinity { + cosmic_text::Affinity::Before => { + cursor.index <= end + } + cosmic_text::Affinity::After => cursor.index < end, + }; + + if is_cursor_after_start && is_cursor_before_end { + let offset = line + .glyphs + .iter() + .take_while(|glyph| cursor.index > glyph.start) + .map(|glyph| glyph.w) + .sum(); + + Some((i, offset)) + } else { + None + } + }) + .unwrap_or((0, 0.0)); + + let line_height = buffer.metrics().line_height; + + let scroll_offset = buffer.scroll() as f32 * line_height; + + Cursor::Caret(Point::new( + offset, + (lines_before_cursor + subline) as f32 * line_height + - scroll_offset, + )) + } + } + } + + fn perform(&mut self, action: Action) { + let mut font_system = + text::font_system().write().expect("Write font system"); + + let editor = + self.0.take().expect("Editor should always be initialized"); + + // TODO: Handle multiple strong references somehow + let mut internal = Arc::try_unwrap(editor) + .expect("Editor cannot have multiple strong references"); + + let editor = &mut internal.editor; + + let mut act = |action| editor.action(font_system.raw(), action); + + match action { + Action::MoveLeft => act(cosmic_text::Action::Left), + Action::MoveRight => act(cosmic_text::Action::Right), + Action::MoveUp => act(cosmic_text::Action::Up), + Action::MoveDown => act(cosmic_text::Action::Down), + Action::Insert(c) => act(cosmic_text::Action::Insert(c)), + Action::Backspace => act(cosmic_text::Action::Backspace), + Action::Delete => act(cosmic_text::Action::Delete), + Action::Click(position) => act(cosmic_text::Action::Click { + x: position.x as i32, + y: position.y as i32, + }), + Action::Drag(position) => act(cosmic_text::Action::Drag { + x: position.x as i32, + y: position.y as i32, + }), + _ => todo!(), + } + + editor.shape_as_needed(font_system.raw()); + + self.0 = Some(Arc::new(internal)); + } + + fn bounds(&self) -> Size { + self.internal().bounds + } + + fn min_bounds(&self) -> Size { + self.internal().min_bounds + } + + fn update( + &mut self, + new_bounds: Size, + new_font: Font, + new_size: Pixels, + new_line_height: LineHeight, + ) { + let editor = + self.0.take().expect("editor should always be initialized"); + + let mut internal = Arc::try_unwrap(editor) + .expect("Editor cannot have multiple strong references"); + + let mut font_system = + text::font_system().write().expect("Write font system"); + + let mut changed = false; + + if new_font != internal.font { + for line in internal.editor.buffer_mut().lines.iter_mut() { + let _ = line.set_attrs_list(cosmic_text::AttrsList::new( + text::to_attributes(new_font), + )); + } + + changed = true; + } + + let metrics = internal.editor.buffer().metrics(); + let new_line_height = new_line_height.to_absolute(new_size); + + if new_size.0 != metrics.font_size + || new_line_height.0 != metrics.line_height + { + internal.editor.buffer_mut().set_metrics( + font_system.raw(), + cosmic_text::Metrics::new(new_size.0, new_line_height.0), + ); + + changed = true; + } + + if new_bounds != internal.bounds { + internal.editor.buffer_mut().set_size( + font_system.raw(), + new_bounds.width, + new_bounds.height, + ); + + internal.bounds = new_bounds; + changed = true; + } + + if changed { + internal.min_bounds = text::measure(&internal.editor.buffer()); + } + + self.0 = Some(Arc::new(internal)); + } +} + +impl Default for Editor { + fn default() -> Self { + Self(Some(Arc::new(Internal::default()))) + } +} + +impl PartialEq for Internal { + fn eq(&self, other: &Self) -> bool { + self.font == other.font + && self.bounds == other.bounds + && self.min_bounds == other.min_bounds + && self.editor.buffer().metrics() == other.editor.buffer().metrics() + } +} + +impl Default for Internal { + fn default() -> Self { + Self { + editor: cosmic_text::Editor::new(cosmic_text::Buffer::new_empty( + cosmic_text::Metrics { + font_size: 1.0, + line_height: 1.0, + }, + )), + font: Font::default(), + bounds: Size::ZERO, + min_bounds: Size::ZERO, + version: text::Version::default(), + } + } +} + +impl fmt::Debug for Internal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Internal") + .field("font", &self.font) + .field("bounds", &self.bounds) + .field("min_bounds", &self.min_bounds) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct Weak { + raw: sync::Weak, + pub bounds: Size, +} + +impl Weak { + pub fn upgrade(&self) -> Option { + self.raw.upgrade().map(Some).map(Editor) + } +} + +impl PartialEq for Weak { + fn eq(&self, other: &Self) -> bool { + match (self.raw.upgrade(), other.raw.upgrade()) { + (Some(p1), Some(p2)) => p1 == p2, + _ => false, + } + } +} diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 73e56890..6f044af6 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -32,6 +32,7 @@ use crate::core::text::{self, Text}; use crate::core::{ Background, Color, Font, Pixels, Point, Rectangle, Size, Vector, }; +use crate::graphics::text::Editor; use crate::graphics::text::Paragraph; use crate::graphics::Mesh; @@ -159,6 +160,7 @@ impl core::Renderer for Renderer { impl text::Renderer for Renderer { type Font = Font; type Paragraph = Paragraph; + type Editor = Editor; const ICON_FONT: Font = iced_tiny_skia::Renderer::::ICON_FONT; const CHECKMARK_ICON: char = iced_tiny_skia::Renderer::::CHECKMARK_ICON; @@ -179,14 +181,27 @@ impl text::Renderer for Renderer { fn fill_paragraph( &mut self, - text: &Self::Paragraph, + paragraph: &Self::Paragraph, position: Point, color: Color, ) { delegate!( self, renderer, - renderer.fill_paragraph(text, position, color) + renderer.fill_paragraph(paragraph, position, color) + ); + } + + fn fill_editor( + &mut self, + editor: &Self::Editor, + position: Point, + color: Color, + ) { + delegate!( + self, + renderer, + renderer.fill_editor(editor, position, color) ); } diff --git a/style/src/lib.rs b/style/src/lib.rs index 0c555ed8..7a97ac77 100644 --- a/style/src/lib.rs +++ b/style/src/lib.rs @@ -35,6 +35,7 @@ pub mod rule; pub mod scrollable; pub mod slider; pub mod svg; +pub mod text_editor; pub mod text_input; pub mod theme; pub mod toggler; diff --git a/style/src/text_editor.rs b/style/src/text_editor.rs new file mode 100644 index 00000000..45c9bad8 --- /dev/null +++ b/style/src/text_editor.rs @@ -0,0 +1,47 @@ +//! Change the appearance of a text editor. +use iced_core::{Background, BorderRadius, Color}; + +/// The appearance of a text input. +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The [`Background`] of the text input. + pub background: Background, + /// The border radius of the text input. + pub border_radius: BorderRadius, + /// The border width of the text input. + pub border_width: f32, + /// The border [`Color`] of the text input. + pub border_color: Color, +} + +/// A set of rules that dictate the style of a text input. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the style of an active text input. + fn active(&self, style: &Self::Style) -> Appearance; + + /// Produces the style of a focused text input. + fn focused(&self, style: &Self::Style) -> Appearance; + + /// Produces the [`Color`] of the placeholder of a text input. + fn placeholder_color(&self, style: &Self::Style) -> Color; + + /// Produces the [`Color`] of the value of a text input. + fn value_color(&self, style: &Self::Style) -> Color; + + /// Produces the [`Color`] of the value of a disabled text input. + fn disabled_color(&self, style: &Self::Style) -> Color; + + /// Produces the [`Color`] of the selection of a text input. + fn selection_color(&self, style: &Self::Style) -> Color; + + /// Produces the style of an hovered text input. + fn hovered(&self, style: &Self::Style) -> Appearance { + self.focused(style) + } + + /// Produces the style of a disabled text input. + fn disabled(&self, style: &Self::Style) -> Appearance; +} diff --git a/style/src/theme.rs b/style/src/theme.rs index 893d7202..a1501c01 100644 --- a/style/src/theme.rs +++ b/style/src/theme.rs @@ -17,6 +17,7 @@ use crate::rule; use crate::scrollable; use crate::slider; use crate::svg; +use crate::text_editor; use crate::text_input; use crate::toggler; @@ -1174,3 +1175,115 @@ impl text_input::StyleSheet for Theme { self.placeholder_color(style) } } + +/// The style of a text input. +#[derive(Default)] +pub enum TextEditor { + /// The default style. + #[default] + Default, + /// A custom style. + Custom(Box>), +} + +impl text_editor::StyleSheet for Theme { + type Style = TextEditor; + + fn active(&self, style: &Self::Style) -> text_editor::Appearance { + if let TextEditor::Custom(custom) = style { + return custom.active(self); + } + + let palette = self.extended_palette(); + + text_editor::Appearance { + background: palette.background.base.color.into(), + border_radius: 2.0.into(), + border_width: 1.0, + border_color: palette.background.strong.color, + } + } + + fn hovered(&self, style: &Self::Style) -> text_editor::Appearance { + if let TextEditor::Custom(custom) = style { + return custom.hovered(self); + } + + let palette = self.extended_palette(); + + text_editor::Appearance { + background: palette.background.base.color.into(), + border_radius: 2.0.into(), + border_width: 1.0, + border_color: palette.background.base.text, + } + } + + fn focused(&self, style: &Self::Style) -> text_editor::Appearance { + if let TextEditor::Custom(custom) = style { + return custom.focused(self); + } + + let palette = self.extended_palette(); + + text_editor::Appearance { + background: palette.background.base.color.into(), + border_radius: 2.0.into(), + border_width: 1.0, + border_color: palette.primary.strong.color, + } + } + + fn placeholder_color(&self, style: &Self::Style) -> Color { + if let TextEditor::Custom(custom) = style { + return custom.placeholder_color(self); + } + + let palette = self.extended_palette(); + + palette.background.strong.color + } + + fn value_color(&self, style: &Self::Style) -> Color { + if let TextEditor::Custom(custom) = style { + return custom.value_color(self); + } + + let palette = self.extended_palette(); + + palette.background.base.text + } + + fn selection_color(&self, style: &Self::Style) -> Color { + if let TextEditor::Custom(custom) = style { + return custom.selection_color(self); + } + + let palette = self.extended_palette(); + + palette.primary.weak.color + } + + fn disabled(&self, style: &Self::Style) -> text_editor::Appearance { + if let TextEditor::Custom(custom) = style { + return custom.disabled(self); + } + + let palette = self.extended_palette(); + + text_editor::Appearance { + background: palette.background.weak.color.into(), + border_radius: 2.0.into(), + border_width: 1.0, + border_color: palette.background.strong.color, + } + } + + fn disabled_color(&self, style: &Self::Style) -> Color { + if let TextEditor::Custom(custom) = style { + return custom.disabled_color(self); + } + + self.placeholder_color(style) + } +} diff --git a/tiny_skia/src/backend.rs b/tiny_skia/src/backend.rs index 72184c8a..5f66dff2 100644 --- a/tiny_skia/src/backend.rs +++ b/tiny_skia/src/backend.rs @@ -383,6 +383,31 @@ impl Backend { clip_mask, ); } + Primitive::Editor { + editor, + position, + color, + } => { + let physical_bounds = + (Rectangle::new(*position, editor.bounds) + translation) + * scale_factor; + + if !clip_bounds.intersects(&physical_bounds) { + return; + } + + let clip_mask = (!physical_bounds.is_within(&clip_bounds)) + .then_some(clip_mask as &_); + + self.text_pipeline.draw_editor( + editor, + *position + translation, + *color, + scale_factor, + pixels, + clip_mask, + ); + } Primitive::Text { content, bounds, diff --git a/tiny_skia/src/text.rs b/tiny_skia/src/text.rs index 4f6e3941..d055c749 100644 --- a/tiny_skia/src/text.rs +++ b/tiny_skia/src/text.rs @@ -2,6 +2,7 @@ use crate::core::alignment; use crate::core::text::{LineHeight, Shaping}; use crate::core::{Color, Font, Pixels, Point, Rectangle}; use crate::graphics::text::cache::{self, Cache}; +use crate::graphics::text::editor; use crate::graphics::text::font_system; use crate::graphics::text::paragraph; @@ -64,6 +65,37 @@ impl Pipeline { ); } + pub fn draw_editor( + &mut self, + editor: &editor::Weak, + position: Point, + color: Color, + scale_factor: f32, + pixels: &mut tiny_skia::PixmapMut<'_>, + clip_mask: Option<&tiny_skia::Mask>, + ) { + use crate::core::text::Editor as _; + + let Some(editor) = editor.upgrade() else { + return; + }; + + let mut font_system = font_system().write().expect("Write font system"); + + draw( + font_system.raw(), + &mut self.glyph_cache, + editor.buffer(), + Rectangle::new(position, editor.min_bounds()), + color, + alignment::Horizontal::Left, + alignment::Vertical::Top, + scale_factor, + pixels, + clip_mask, + ); + } + pub fn draw_cached( &mut self, content: &str, diff --git a/wgpu/src/layer.rs b/wgpu/src/layer.rs index 7a5a0f7c..10b3332d 100644 --- a/wgpu/src/layer.rs +++ b/wgpu/src/layer.rs @@ -120,12 +120,25 @@ impl<'a> Layer<'a> { } => { let layer = &mut layers[current_layer]; - layer.text.push(Text::Managed { + layer.text.push(Text::Paragraph { paragraph: paragraph.clone(), position: *position + translation, color: *color, }); } + Primitive::Editor { + editor, + position, + color, + } => { + let layer = &mut layers[current_layer]; + + layer.text.push(Text::Editor { + editor: editor.clone(), + position: *position + translation, + color: *color, + }); + } Primitive::Text { content, bounds, diff --git a/wgpu/src/layer/text.rs b/wgpu/src/layer/text.rs index b61615d6..d46b39da 100644 --- a/wgpu/src/layer/text.rs +++ b/wgpu/src/layer/text.rs @@ -1,16 +1,22 @@ use crate::core::alignment; use crate::core::text; use crate::core::{Color, Font, Pixels, Point, Rectangle}; +use crate::graphics::text::editor; use crate::graphics::text::paragraph; /// A paragraph of text. #[derive(Debug, Clone)] pub enum Text<'a> { - Managed { + Paragraph { paragraph: paragraph::Weak, position: Point, color: Color, }, + Editor { + editor: editor::Weak, + position: Point, + color: Color, + }, Cached(Cached<'a>), } diff --git a/wgpu/src/text.rs b/wgpu/src/text.rs index 5c9f4d7e..397c38dd 100644 --- a/wgpu/src/text.rs +++ b/wgpu/src/text.rs @@ -2,7 +2,7 @@ use crate::core::alignment; use crate::core::{Rectangle, Size}; use crate::graphics::color; use crate::graphics::text::cache::{self, Cache}; -use crate::graphics::text::{font_system, Paragraph}; +use crate::graphics::text::{font_system, Editor, Paragraph}; use crate::layer::Text; use std::borrow::Cow; @@ -74,15 +74,19 @@ impl Pipeline { enum Allocation { Paragraph(Paragraph), + Editor(Editor), Cache(cache::KeyHash), } let allocations: Vec<_> = sections .iter() .map(|section| match section { - Text::Managed { paragraph, .. } => { + Text::Paragraph { paragraph, .. } => { paragraph.upgrade().map(Allocation::Paragraph) } + Text::Editor { editor, .. } => { + editor.upgrade().map(Allocation::Editor) + } Text::Cached(text) => { let (key, _) = cache.allocate( font_system, @@ -117,7 +121,7 @@ impl Pipeline { vertical_alignment, color, ) = match section { - Text::Managed { + Text::Paragraph { position, color, .. } => { use crate::core::text::Paragraph as _; @@ -135,6 +139,24 @@ impl Pipeline { *color, ) } + Text::Editor { + position, color, .. + } => { + use crate::core::text::Editor as _; + + let Some(Allocation::Editor(editor)) = allocation + else { + return None; + }; + + ( + editor.buffer(), + Rectangle::new(*position, editor.min_bounds()), + alignment::Horizontal::Left, + alignment::Vertical::Top, + *color, + ) + } Text::Cached(text) => { let Some(Allocation::Cache(key)) = allocation else { return None; diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 3c9c2b29..61541eac 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -16,6 +16,7 @@ use crate::runtime::Command; use crate::scrollable::{self, Scrollable}; use crate::slider::{self, Slider}; use crate::text::{self, Text}; +use crate::text_editor::{self, TextEditor}; use crate::text_input::{self, TextInput}; use crate::toggler::{self, Toggler}; use crate::tooltip::{self, Tooltip}; @@ -206,6 +207,20 @@ where TextInput::new(placeholder, value) } +/// Creates a new [`TextEditor`]. +/// +/// [`TextEditor`]: crate::TextEditor +pub fn text_editor<'a, Message, Renderer>( + content: &'a text_editor::Content, +) -> TextEditor<'a, Message, Renderer> +where + Message: Clone, + Renderer: core::text::Renderer, + Renderer::Theme: text_editor::StyleSheet, +{ + TextEditor::new(content) +} + /// Creates a new [`Slider`]. /// /// [`Slider`]: crate::Slider diff --git a/widget/src/lib.rs b/widget/src/lib.rs index 7e204171..f8e5e865 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -4,8 +4,8 @@ )] #![forbid(unsafe_code, rust_2018_idioms)] #![deny( - missing_debug_implementations, - missing_docs, + // missing_debug_implementations, + // missing_docs, unused_results, clippy::extra_unused_lifetimes, clippy::from_over_into, @@ -41,6 +41,7 @@ pub mod scrollable; pub mod slider; pub mod space; pub mod text; +pub mod text_editor; pub mod text_input; pub mod toggler; pub mod tooltip; diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs new file mode 100644 index 00000000..d09f2c3e --- /dev/null +++ b/widget/src/text_editor.rs @@ -0,0 +1,457 @@ +use crate::core::event::{self, Event}; +use crate::core::keyboard; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text::editor::{Cursor, Editor as _}; +use crate::core::text::{self, LineHeight}; +use crate::core::widget::{self, Widget}; +use crate::core::{ + Clipboard, Color, Element, Length, Padding, Pixels, Point, Rectangle, + Shell, Vector, +}; + +use std::cell::RefCell; + +pub use crate::style::text_editor::{Appearance, StyleSheet}; +pub use text::editor::Action; + +pub struct TextEditor<'a, Message, Renderer = crate::Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + content: &'a Content, + font: Option, + text_size: Option, + line_height: LineHeight, + width: Length, + height: Length, + padding: Padding, + style: ::Style, + on_edit: Option Message + 'a>>, +} + +impl<'a, Message, Renderer> TextEditor<'a, Message, Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + pub fn new(content: &'a Content) -> Self { + Self { + content, + font: None, + text_size: None, + line_height: LineHeight::default(), + width: Length::Fill, + height: Length::Fill, + padding: Padding::new(5.0), + style: Default::default(), + on_edit: None, + } + } + + pub fn on_edit(mut self, on_edit: impl Fn(Action) -> Message + 'a) -> Self { + self.on_edit = Some(Box::new(on_edit)); + self + } + + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self + } +} + +pub struct Content(RefCell>) +where + R: text::Renderer; + +struct Internal +where + R: text::Renderer, +{ + editor: R::Editor, + is_dirty: bool, +} + +impl Content +where + R: text::Renderer, +{ + pub fn new() -> Self { + Self::with("") + } + + pub fn with(text: &str) -> Self { + Self(RefCell::new(Internal { + editor: R::Editor::with_text(text), + is_dirty: true, + })) + } + + pub fn edit(&mut self, action: Action) { + let internal = self.0.get_mut(); + + internal.editor.perform(action); + internal.is_dirty = true; + } +} + +impl Default for Content +where + Renderer: text::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +struct State { + is_focused: bool, + is_dragging: bool, + last_click: Option, +} + +impl<'a, Message, Renderer> Widget + for TextEditor<'a, Message, Renderer> +where + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + is_focused: false, + is_dragging: false, + last_click: None, + }) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + _tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> iced_renderer::core::layout::Node { + let mut internal = self.content.0.borrow_mut(); + + internal.editor.update( + limits.pad(self.padding).max(), + self.font.unwrap_or_else(|| renderer.default_font()), + self.text_size.unwrap_or_else(|| renderer.default_size()), + self.line_height, + ); + + layout::Node::new(limits.max()) + } + + fn on_event( + &mut self, + tree: &mut widget::Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let Some(on_edit) = self.on_edit.as_ref() else { + return event::Status::Ignored; + }; + + let state = tree.state.downcast_mut::(); + + let Some(update) = Update::from_event( + event, + state, + layout.bounds(), + self.padding, + cursor, + ) else { + return event::Status::Ignored; + }; + + match update { + Update::Focus { click, action } => { + state.is_focused = true; + state.last_click = Some(click); + shell.publish(on_edit(action)); + } + Update::Unfocus => { + state.is_focused = false; + state.is_dragging = false; + } + Update::Click { click, action } => { + state.last_click = Some(click); + state.is_dragging = true; + shell.publish(on_edit(action)); + } + Update::StopDragging => { + state.is_dragging = false; + } + Update::Edit(action) => { + shell.publish(on_edit(action)); + } + Update::Copy => {} + Update::Paste => if let Some(_contents) = clipboard.read() {}, + } + + event::Status::Captured + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + let internal = self.content.0.borrow(); + let state = tree.state.downcast_ref::(); + + let is_disabled = self.on_edit.is_none(); + let is_mouse_over = cursor.is_over(bounds); + + let appearance = if is_disabled { + theme.disabled(&self.style) + } else if state.is_focused { + theme.focused(&self.style) + } else if is_mouse_over { + theme.hovered(&self.style) + } else { + theme.active(&self.style) + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: appearance.border_radius, + border_width: appearance.border_width, + border_color: appearance.border_color, + }, + appearance.background, + ); + + renderer.fill_editor( + &internal.editor, + bounds.position() + + Vector::new(self.padding.left, self.padding.top), + style.text_color, + ); + + if state.is_focused { + match internal.editor.cursor() { + Cursor::Caret(position) => { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: position.x + bounds.x + self.padding.left, + y: position.y + bounds.y + self.padding.top, + width: 1.0, + height: self + .line_height + .to_absolute(self.text_size.unwrap_or_else( + || renderer.default_size(), + )) + .into(), + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.value_color(&self.style), + ); + } + Cursor::Selection(ranges) => { + for range in ranges { + renderer.fill_quad( + renderer::Quad { + bounds: range + Vector::new(bounds.x, bounds.y), + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.selection_color(&self.style), + ); + } + } + } + } + } + + fn mouse_interaction( + &self, + _state: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let is_disabled = self.on_edit.is_none(); + + if cursor.is_over(layout.bounds()) { + if is_disabled { + mouse::Interaction::NotAllowed + } else { + mouse::Interaction::Text + } + } else { + mouse::Interaction::default() + } + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Message: 'a, + Renderer: text::Renderer, + Renderer::Theme: StyleSheet, +{ + fn from(text_editor: TextEditor<'a, Message, Renderer>) -> Self { + Self::new(text_editor) + } +} + +enum Update { + Focus { click: mouse::Click, action: Action }, + Unfocus, + Click { click: mouse::Click, action: Action }, + StopDragging, + Edit(Action), + Copy, + Paste, +} + +impl Update { + fn from_event( + event: Event, + state: &State, + bounds: Rectangle, + padding: Padding, + cursor: mouse::Cursor, + ) -> Option { + match event { + Event::Mouse(event) => match event { + mouse::Event::ButtonPressed(mouse::Button::Left) => { + if let Some(cursor_position) = cursor.position_in(bounds) { + let cursor_position = cursor_position + - Vector::new(padding.top, padding.left); + + if state.is_focused { + let click = mouse::Click::new( + cursor_position, + state.last_click, + ); + + let action = match click.kind() { + mouse::click::Kind::Single => { + Action::Click(cursor_position) + } + mouse::click::Kind::Double => { + Action::SelectWord + } + mouse::click::Kind::Triple => { + Action::SelectLine + } + }; + + Some(Update::Click { click, action }) + } else { + Some(Update::Focus { + click: mouse::Click::new(cursor_position, None), + action: Action::Click(cursor_position), + }) + } + } else if state.is_focused { + Some(Update::Unfocus) + } else { + None + } + } + mouse::Event::ButtonReleased(mouse::Button::Left) => { + Some(Update::StopDragging) + } + mouse::Event::CursorMoved { .. } if state.is_dragging => { + let cursor_position = cursor.position_in(bounds)? + - Vector::new(padding.top, padding.left); + + Some(Self::Edit(Action::Drag(cursor_position))) + } + _ => None, + }, + Event::Keyboard(event) => match event { + keyboard::Event::KeyPressed { + key_code, + modifiers, + } if state.is_focused => match key_code { + keyboard::KeyCode::Left => { + if platform::is_jump_modifier_pressed(modifiers) { + Some(Self::Edit(Action::MoveLeftWord)) + } else { + Some(Self::Edit(Action::MoveLeft)) + } + } + keyboard::KeyCode::Right => { + if platform::is_jump_modifier_pressed(modifiers) { + Some(Self::Edit(Action::MoveRightWord)) + } else { + Some(Self::Edit(Action::MoveRight)) + } + } + keyboard::KeyCode::Up => Some(Self::Edit(Action::MoveUp)), + keyboard::KeyCode::Down => { + Some(Self::Edit(Action::MoveDown)) + } + keyboard::KeyCode::Backspace => { + Some(Self::Edit(Action::Backspace)) + } + keyboard::KeyCode::Delete => { + Some(Self::Edit(Action::Delete)) + } + keyboard::KeyCode::Escape => Some(Self::Unfocus), + _ => None, + }, + keyboard::Event::CharacterReceived(c) if state.is_focused => { + Some(Self::Edit(Action::Insert(c))) + } + _ => None, + }, + _ => None, + } + } +} + +mod platform { + use crate::core::keyboard; + + pub fn is_jump_modifier_pressed(modifiers: keyboard::Modifiers) -> bool { + if cfg!(target_os = "macos") { + modifiers.alt() + } else { + modifiers.control() + } + } +} -- cgit