diff --git a/CHANGELOG.md b/CHANGELOG.md index 659ccdcd..a1ef5ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ This release has an [MSRV] of 1.75. - `Generation` on `PlainEditor` to help implement lazy drawing. ([#143] by [@xorgy]) +### Changed + +### Parley + +- Breaking change: `PlainEditor`'s semantics are no longer transactional ([#192][] by [@DJMcNab][]) ## [0.2.0] - 2024-10-10 @@ -76,6 +81,7 @@ This release has an [MSRV] of 1.70. [MSRV]: README.md#minimum-supported-rust-version-msrv [@dfrg]: https://github.com/dfrg +[@DJMcNab]: https://github.com/DJMcNab [@nicoburns]: https://github.com/nicoburns [@waywardmonkeys]: https://github.com/waywardmonkeys [@xorgy]: https://github.com/xorgy @@ -93,6 +99,7 @@ This release has an [MSRV] of 1.70. [#126]: https://github.com/linebender/parley/pull/126 [#129]: https://github.com/linebender/parley/pull/129 [#143]: https://github.com/linebender/parley/pull/143 +[#192]: https://github.com/linebender/parley/pull/192 [Unreleased]: https://github.com/linebender/parley/compare/v0.2.0...HEAD [0.2.0]: https://github.com/linebender/parley/releases/tag/v0.2.0 diff --git a/examples/vello_editor/src/main.rs b/examples/vello_editor/src/main.rs index 0422e769..13f33040 100644 --- a/examples/vello_editor/src/main.rs +++ b/examples/vello_editor/src/main.rs @@ -28,7 +28,6 @@ mod access_ids; use access_ids::{TEXT_INPUT_ID, WINDOW_ID}; mod text; -use parley::{GenericFamily, StyleProperty}; const WINDOW_TITLE: &str = "Vello Text Editor"; @@ -114,15 +113,10 @@ impl ApplicationHandler for SimpleVelloApp<'_> { let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(&window, self.event_loop_proxy.clone()); window.set_visible(true); + window.set_ime_allowed(true); let size = window.inner_size(); - self.editor.transact(|txn| { - txn.set_scale(1.0); - txn.set_width(Some(size.width as f32 - 2f32 * text::INSET)); - txn.set_text(text::LOREM); - }); - // Create a vello Surface let surface_future = { let surface = self @@ -236,15 +230,9 @@ impl ApplicationHandler for SimpleVelloApp<'_> { WindowEvent::Resized(size) => { self.context .resize_surface(&mut render_state.surface, size.width, size.height); - self.editor.transact(|txn| { - txn.set_scale(1.0); - txn.set_width(Some(size.width as f32 - 2f32 * text::INSET)); - txn.set_default_style(Arc::new([ - StyleProperty::FontSize(32.0), - StyleProperty::LineHeight(1.2), - GenericFamily::SystemUi.into(), - ])); - }); + let editor = self.editor.editor(); + editor.set_scale(1.0); + editor.set_width(Some(size.width as f32 - 2f32 * text::INSET)); render_state.window.request_redraw(); } @@ -352,7 +340,7 @@ fn main() -> Result<()> { renderers: vec![], state: RenderState::Suspended(None), scene: Scene::new(), - editor: text::Editor::default(), + editor: text::Editor::new(text::LOREM), last_drawn_generation: Default::default(), event_loop_proxy: event_loop.create_proxy(), }; @@ -361,7 +349,8 @@ fn main() -> Result<()> { event_loop .run_app(&mut app) .expect("Couldn't run event loop"); - print!("{}", app.editor.text()); + let text = app.editor.text(); + print!("{text}"); Ok(()) } diff --git a/examples/vello_editor/src/text.rs b/examples/vello_editor/src/text.rs index 2211c7e9..c3202933 100644 --- a/examples/vello_editor/src/text.rs +++ b/examples/vello_editor/src/text.rs @@ -3,23 +3,25 @@ use accesskit::{Node, TreeUpdate}; use core::default::Default; -use parley::layout::PositionedLayoutItem; +use parley::{editor::SplitString, layout::PositionedLayoutItem, GenericFamily, StyleProperty}; use peniko::{kurbo::Affine, Color, Fill}; use std::time::{Duration, Instant}; -use vello::Scene; +use vello::{ + kurbo::{Line, Stroke}, + Scene, +}; use winit::{ - event::{Modifiers, Touch, WindowEvent}, + event::{Ime, Modifiers, Touch, WindowEvent}, keyboard::{Key, NamedKey}, }; pub use parley::layout::editor::Generation; -use parley::{FontContext, LayoutContext, PlainEditor, PlainEditorTxn}; +use parley::{FontContext, LayoutContext, PlainEditor, PlainEditorDriver}; use crate::access_ids::next_node_id; pub const INSET: f32 = 32.0; -#[derive(Default)] pub struct Editor { font_cx: FontContext, layout_cx: LayoutContext, @@ -35,12 +37,38 @@ pub struct Editor { } impl Editor { - pub fn transact(&mut self, callback: impl FnOnce(&mut PlainEditorTxn<'_, Color>)) { - self.editor - .transact(&mut self.font_cx, &mut self.layout_cx, callback); + pub fn new(text: &str) -> Self { + let mut editor = PlainEditor::new(32.0); + editor.set_text(text); + editor.set_scale(1.0); + let styles = editor.edit_styles(); + styles.insert(StyleProperty::LineHeight(1.2)); + styles.insert(GenericFamily::SystemUi.into()); + styles.insert(StyleProperty::Brush(Color::WHITE)); + Self { + font_cx: Default::default(), + layout_cx: Default::default(), + editor, + last_click_time: Default::default(), + click_count: Default::default(), + pointer_down: Default::default(), + cursor_pos: Default::default(), + cursor_visible: Default::default(), + modifiers: Default::default(), + start_time: Default::default(), + blink_period: Default::default(), + } + } + + fn driver(&mut self) -> PlainEditorDriver<'_, Color> { + self.editor.driver(&mut self.font_cx, &mut self.layout_cx) } - pub fn text(&self) -> &str { + pub fn editor(&mut self) -> &mut PlainEditor { + &mut self.editor + } + + pub fn text(&self) -> SplitString<'_> { self.editor.text() } @@ -77,16 +105,18 @@ impl Editor { pub fn handle_event(&mut self, event: WindowEvent) { match event { WindowEvent::Resized(size) => { - self.transact(|txn| txn.set_width(Some(size.width as f32 - 2f32 * INSET))); + self.editor + .set_width(Some(size.width as f32 - 2f32 * INSET)); } WindowEvent::ModifiersChanged(modifiers) => { self.modifiers = Some(modifiers); } - WindowEvent::KeyboardInput { event, .. } => { + WindowEvent::KeyboardInput { event, .. } if !self.editor.is_composing() => { if !event.state.is_pressed() { return; } self.cursor_reset(); + let mut drv = self.editor.driver(&mut self.font_cx, &mut self.layout_cx); #[allow(unused)] let (shift, action_mod) = self .modifiers @@ -108,149 +138,147 @@ impl Editor { use clipboard_rs::{Clipboard, ClipboardContext}; match c.to_lowercase().as_str() { "c" => { - if let Some(text) = self.editor.selected_text() { + if let Some(text) = drv.editor.selected_text() { let cb = ClipboardContext::new().unwrap(); cb.set_text(text.to_owned()).ok(); } } "x" => { - if let Some(text) = self.editor.selected_text() { + if let Some(text) = drv.editor.selected_text() { let cb = ClipboardContext::new().unwrap(); cb.set_text(text.to_owned()).ok(); - self.transact(|txn| txn.delete_selection()); + drv.delete_selection(); } } "v" => { let cb = ClipboardContext::new().unwrap(); let text = cb.get_text().unwrap_or_default(); - self.transact(|txn| txn.insert_or_replace_selection(&text)); + drv.insert_or_replace_selection(&text); } _ => (), } } Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => { - self.transact(|txn| { - if shift { - txn.collapse_selection(); - } else { - txn.select_all(); - } - }); + if shift { + drv.collapse_selection(); + } else { + drv.select_all(); + }; } - Key::Named(NamedKey::ArrowLeft) => self.transact(|txn| { + Key::Named(NamedKey::ArrowLeft) => { if action_mod { if shift { - txn.select_word_left(); + drv.select_word_left(); } else { - txn.move_word_left(); + drv.move_word_left(); } } else if shift { - txn.select_left(); + drv.select_left(); } else { - txn.move_left(); + drv.move_left(); } - }), - Key::Named(NamedKey::ArrowRight) => self.transact(|txn| { + } + Key::Named(NamedKey::ArrowRight) => { if action_mod { if shift { - txn.select_word_right(); + drv.select_word_right(); } else { - txn.move_word_right(); + drv.move_word_right(); } } else if shift { - txn.select_right(); + drv.select_right(); } else { - txn.move_right(); + drv.move_right(); } - }), - Key::Named(NamedKey::ArrowUp) => self.transact(|txn| { + } + Key::Named(NamedKey::ArrowUp) => { if shift { - txn.select_up(); + drv.select_up(); } else { - txn.move_up(); + drv.move_up(); } - }), - Key::Named(NamedKey::ArrowDown) => self.transact(|txn| { + } + Key::Named(NamedKey::ArrowDown) => { if shift { - txn.select_down(); + drv.select_down(); } else { - txn.move_down(); + drv.move_down(); } - }), - Key::Named(NamedKey::Home) => self.transact(|txn| { + } + Key::Named(NamedKey::Home) => { if action_mod { if shift { - txn.select_to_text_start(); + drv.select_to_text_start(); } else { - txn.move_to_text_start(); + drv.move_to_text_start(); } } else if shift { - txn.select_to_line_start(); + drv.select_to_line_start(); } else { - txn.move_to_line_start(); + drv.move_to_line_start(); } - }), - Key::Named(NamedKey::End) => self.transact(|txn| { + } + Key::Named(NamedKey::End) => { + let this = &mut *self; + let mut drv = this.driver(); + if action_mod { if shift { - txn.select_to_text_end(); + drv.select_to_text_end(); } else { - txn.move_to_text_end(); + drv.move_to_text_end(); } } else if shift { - txn.select_to_line_end(); + drv.select_to_line_end(); } else { - txn.move_to_line_end(); + drv.move_to_line_end(); } - }), - Key::Named(NamedKey::Delete) => self.transact(|txn| { + } + Key::Named(NamedKey::Delete) => { if action_mod { - txn.delete_word(); + drv.delete_word(); } else { - txn.delete(); + drv.delete(); } - }), - Key::Named(NamedKey::Backspace) => self.transact(|txn| { + } + Key::Named(NamedKey::Backspace) => { if action_mod { - txn.backdelete_word(); + drv.backdelete_word(); } else { - txn.backdelete(); + drv.backdelete(); } - }), + } Key::Named(NamedKey::Enter) => { - self.transact(|txn| txn.insert_or_replace_selection("\n")); + drv.insert_or_replace_selection("\n"); } Key::Named(NamedKey::Space) => { - self.transact(|txn| txn.insert_or_replace_selection(" ")); + drv.insert_or_replace_selection(" "); } Key::Character(s) => { - self.transact(|txn| txn.insert_or_replace_selection(&s)); + drv.insert_or_replace_selection(&s); } _ => (), } } WindowEvent::Touch(Touch { phase, location, .. - }) => { + }) if !self.editor.is_composing() => { + let mut drv = self.editor.driver(&mut self.font_cx, &mut self.layout_cx); use winit::event::TouchPhase::*; match phase { Started => { // TODO: start a timer to convert to a SelectWordAtPoint - self.transact(|txn| { - txn.move_to_point(location.x as f32 - INSET, location.y as f32 - INSET); - }); + drv.move_to_point(location.x as f32 - INSET, location.y as f32 - INSET); } Cancelled => { - self.transact(|txn| txn.collapse_selection()); + drv.collapse_selection(); } Moved => { // TODO: cancel SelectWordAtPoint timer - self.transact(|txn| { - txn.extend_selection_to_point( - location.x as f32 - INSET, - location.y as f32 - INSET, - ); - }); + drv.extend_selection_to_point( + location.x as f32 - INSET, + location.y as f32 - INSET, + ); } Ended => (), } @@ -259,7 +287,7 @@ impl Editor { if button == winit::event::MouseButton::Left { self.pointer_down = state.is_pressed(); self.cursor_reset(); - if self.pointer_down { + if self.pointer_down && !self.editor.is_composing() { let now = Instant::now(); if let Some(last) = self.last_click_time.take() { if now.duration_since(last).as_secs_f64() < 0.25 { @@ -273,11 +301,12 @@ impl Editor { self.last_click_time = Some(now); let click_count = self.click_count; let cursor_pos = self.cursor_pos; - self.transact(|txn| match click_count { - 2 => txn.select_word_at_point(cursor_pos.0, cursor_pos.1), - 3 => txn.select_line_at_point(cursor_pos.0, cursor_pos.1), - _ => txn.move_to_point(cursor_pos.0, cursor_pos.1), - }); + let mut drv = self.editor.driver(&mut self.font_cx, &mut self.layout_cx); + match click_count { + 2 => drv.select_word_at_point(cursor_pos.0, cursor_pos.1), + 3 => drv.select_line_at_point(cursor_pos.0, cursor_pos.1), + _ => drv.move_to_point(cursor_pos.0, cursor_pos.1), + }; } } } @@ -285,10 +314,24 @@ impl Editor { let prev_pos = self.cursor_pos; self.cursor_pos = (position.x as f32 - INSET, position.y as f32 - INSET); // macOS seems to generate a spurious move after selecting word? - if self.pointer_down && prev_pos != self.cursor_pos { + if self.pointer_down && prev_pos != self.cursor_pos && !self.editor.is_composing() { self.cursor_reset(); let cursor_pos = self.cursor_pos; - self.transact(|txn| txn.extend_selection_to_point(cursor_pos.0, cursor_pos.1)); + self.driver() + .extend_selection_to_point(cursor_pos.0, cursor_pos.1); + } + } + WindowEvent::Ime(Ime::Disabled) => { + self.driver().clear_compose(); + } + WindowEvent::Ime(Ime::Commit(text)) => { + self.driver().insert_or_replace_selection(&text); + } + WindowEvent::Ime(Ime::Preedit(text, cursor)) => { + if text.is_empty() { + self.driver().clear_compose(); + } else { + self.driver().set_compose(&text, cursor); } } _ => {} @@ -298,9 +341,7 @@ impl Editor { pub fn handle_accesskit_action_request(&mut self, req: &accesskit::ActionRequest) { if req.action == accesskit::Action::SetTextSelection { if let Some(accesskit::ActionData::SetTextSelection(selection)) = &req.data { - self.transact(|txn| { - txn.select_from_accesskit(selection); - }); + self.driver().select_from_accesskit(selection); } } } @@ -313,7 +354,7 @@ impl Editor { /// Draw into scene. /// /// Returns drawn `Generation`. - pub fn draw(&self, scene: &mut Scene) -> Generation { + pub fn draw(&mut self, scene: &mut Scene) -> Generation { let transform = Affine::translate((INSET as f64, INSET as f64)); for rect in self.editor.selection_geometry().iter() { scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect); @@ -323,11 +364,45 @@ impl Editor { scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor); }; } - for line in self.editor.lines() { + let layout = self.editor.layout(&mut self.font_cx, &mut self.layout_cx); + for line in layout.lines() { for item in line.items() { let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; + let style = glyph_run.style(); + // We draw underlines under the text, then the strikethrough on top, following: + // https://drafts.csswg.org/css-text-decor/#painting-order + if let Some(underline) = &style.underline { + let underline_brush = &style.brush; + let run_metrics = glyph_run.run().metrics(); + let offset = match underline.offset { + Some(offset) => offset, + None => run_metrics.underline_offset, + }; + let width = match underline.size { + Some(size) => size, + None => run_metrics.underline_size, + }; + // The `offset` is the distance from the baseline to the top of the underline + // so we move the line down by half the width + // Remember that we are using a y-down coordinate system + // If there's a custom width, because this is an underline, we want the custom + // width to go down from the default expectation + let y = glyph_run.baseline() - offset + width / 2.; + + let line = Line::new( + (glyph_run.offset() as f64, y as f64), + ((glyph_run.offset() + glyph_run.advance()) as f64, y as f64), + ); + scene.stroke( + &Stroke::new(width.into()), + transform, + underline_brush, + None, + &line, + ); + } let mut x = glyph_run.offset(); let y = glyph_run.baseline(); let run = glyph_run.run(); @@ -344,7 +419,7 @@ impl Editor { .collect::>(); scene .draw_glyphs(font) - .brush(Color::WHITE) + .brush(style.brush) .hint(true) .transform(transform) .glyph_transform(glyph_xform) @@ -363,14 +438,43 @@ impl Editor { } }), ); + if let Some(strikethrough) = &style.strikethrough { + let strikethrough_brush = &style.brush; + let run_metrics = glyph_run.run().metrics(); + let offset = match strikethrough.offset { + Some(offset) => offset, + None => run_metrics.strikethrough_offset, + }; + let width = match strikethrough.size { + Some(size) => size, + None => run_metrics.strikethrough_size, + }; + // The `offset` is the distance from the baseline to the *top* of the strikethrough + // so we calculate the middle y-position of the strikethrough based on the font's + // standard strikethrough width. + // Remember that we are using a y-down coordinate system + let y = glyph_run.baseline() - offset + run_metrics.strikethrough_size / 2.; + + let line = Line::new( + (glyph_run.offset() as f64, y as f64), + ((glyph_run.offset() + glyph_run.advance()) as f64, y as f64), + ); + scene.stroke( + &Stroke::new(width.into()), + transform, + strikethrough_brush, + None, + &line, + ); + } } } self.editor.generation() } pub fn accessibility(&mut self, update: &mut TreeUpdate, node: &mut Node) { - self.editor - .accessibility(update, node, next_node_id, INSET.into(), INSET.into()); + let mut drv = self.editor.driver(&mut self.font_cx, &mut self.layout_cx); + drv.accessibility(update, node, next_node_id, INSET.into(), INSET.into()); } } diff --git a/parley/Cargo.toml b/parley/Cargo.toml index a6bc492c..7d89a993 100644 --- a/parley/Cargo.toml +++ b/parley/Cargo.toml @@ -22,7 +22,7 @@ std = ["fontique/std", "skrifa/std", "peniko/std"] libm = ["fontique/libm", "skrifa/libm", "peniko/libm", "dep:core_maths"] # Enables support for system font backends system = ["std", "fontique/system"] -accesskit = ["dep:accesskit", "dep:hashbrown"] +accesskit = ["dep:accesskit"] [dependencies] swash = { workspace = true } @@ -31,7 +31,7 @@ peniko = { workspace = true } fontique = { workspace = true } core_maths = { version = "0.1.0", optional = true } accesskit = { workspace = true, optional = true } -hashbrown = { workspace = true, optional = true } +hashbrown = { workspace = true } [dev-dependencies] tiny-skia = "0.11.4" diff --git a/parley/src/layout/editor.rs b/parley/src/layout/editor.rs index 05eb0138..afac08db 100644 --- a/parley/src/layout/editor.rs +++ b/parley/src/layout/editor.rs @@ -1,21 +1,28 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use core::{cmp::PartialEq, default::Default, fmt::Debug}; +//! A simple plain text editor and related types. -#[cfg(feature = "accesskit")] -use crate::layout::LayoutAccessibility; use crate::{ layout::{ cursor::{Cursor, Selection}, - Affinity, Alignment, Layout, Line, + Affinity, Alignment, Layout, }, - style::{Brush, StyleProperty}, - FontContext, LayoutContext, Rect, + style::Brush, + FontContext, LayoutContext, Rect, StyleProperty, StyleSet, +}; +use alloc::{borrow::ToOwned, string::String, vec::Vec}; +use core::{ + cmp::PartialEq, + default::Default, + fmt::{Debug, Display}, + ops::Range, }; + +#[cfg(feature = "accesskit")] +use crate::layout::LayoutAccessibility; #[cfg(feature = "accesskit")] use accesskit::{Node, NodeId, TreeUpdate}; -use alloc::{borrow::ToOwned, string::String, sync::Arc, vec::Vec}; /// Opaque representation of a generation. /// @@ -34,18 +41,74 @@ impl Generation { } } -/// Basic plain text editor with a single default style. +/// A string which is potentially discontiguous in memory. +/// +/// This is returned by [`PlainEditor::text`], as the IME preedit +/// area needs to be efficiently excluded from its return value. +#[derive(Debug, Clone, Copy)] +pub struct SplitString<'source>([&'source str; 2]); + +impl<'source> SplitString<'source> { + /// Get the characters of this string. + pub fn chars(self) -> impl Iterator + 'source { + self.into_iter().flat_map(str::chars) + } +} + +impl PartialEq<&'_ str> for SplitString<'_> { + fn eq(&self, other: &&'_ str) -> bool { + let [a, b] = self.0; + let mid = a.len(); + // When our MSRV is 1.80 or above, use split_at_checked instead. + // is_char_boundary checks bounds + let (a_1, b_1) = if other.is_char_boundary(mid) { + other.split_at(mid) + } else { + return false; + }; + + a_1 == a && b_1 == b + } +} +// We intentionally choose not to: +// impl PartialEq for SplitString<'_> {} +// for simplicity, as the impl wouldn't be useful and is non-trivial + +impl Display for SplitString<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let [a, b] = self.0; + write!(f, "{a}{b}") + } +} + +/// Iterate through the source strings. +impl<'source> IntoIterator for SplitString<'source> { + type Item = &'source str; + type IntoIter = <[&'source str; 2] as IntoIterator>::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// Basic plain text editor with a single style applied to the entire text. +/// +/// Internally, this is a wrapper around a string buffer and its corresponding [`Layout`], +/// which is kept up-to-date as needed. +/// This layout is invalidated by a number. #[derive(Clone)] pub struct PlainEditor where T: Brush + Clone + Debug + PartialEq + Default, { - default_style: Arc<[StyleProperty<'static, T>]>, - buffer: String, layout: Layout, + buffer: String, + default_style: StyleSet, #[cfg(feature = "accesskit")] layout_access: LayoutAccessibility, selection: Selection, + /// Byte offsets of IME composing preedit text in the text buffer. + /// `None` if the IME is not currently composing. + compose: Option>, width: Option, scale: f32, // Simple tracking of when the layout needs to be updated @@ -54,25 +117,32 @@ where // Not all operations on `PlainEditor` need to operate on a // clean layout, and not all operations trigger a layout. layout_dirty: bool, + // TODO: We could avoid redoing the full text layout if only + // linebreaking or alignment were changed. + // linebreak_dirty: bool, + // alignment_dirty: bool, + alignment: Alignment, generation: Generation, } -// TODO: When MSRV >= 1.80 we can remove this. Default was not implemented for Arc<[T]> where T: !Default until 1.80 -impl Default for PlainEditor +impl PlainEditor where - T: Brush + Clone + Debug + PartialEq + Default, + T: Brush, { - fn default() -> Self { + /// Create a new editor, with default font size `font_size`. + pub fn new(font_size: f32) -> Self { Self { - default_style: Arc::new([]), + default_style: StyleSet::new(font_size), buffer: Default::default(), layout: Default::default(), #[cfg(feature = "accesskit")] layout_access: Default::default(), selection: Default::default(), - width: Default::default(), + compose: None, + width: None, scale: 1.0, - layout_dirty: Default::default(), + layout_dirty: true, + alignment: Alignment::Start, // We don't use the `default` value to start with, as our consumers // will choose to use that as their initial value, but will probably need // to redraw if they haven't already. @@ -81,59 +151,43 @@ where } } -/// The argument passed to the callback of [`PlainEditor::transact`], -/// on which the caller performs operations. -pub struct PlainEditorTxn<'a, T> +/// A short-lived wrapper around [`PlainEditor`]. +/// +/// This can perform operations which require the editor's layout to +/// be up-to-date by refreshing it as necessary. +pub struct PlainEditorDriver<'a, T> where T: Brush + Clone + Debug + PartialEq + Default, { - editor: &'a mut PlainEditor, - font_cx: &'a mut FontContext, - layout_cx: &'a mut LayoutContext, + pub editor: &'a mut PlainEditor, + pub font_cx: &'a mut FontContext, + pub layout_cx: &'a mut LayoutContext, } -impl PlainEditorTxn<'_, T> +impl PlainEditorDriver<'_, T> where T: Brush + Clone + Debug + PartialEq + Default, { - /// Replace the whole text buffer. - pub fn set_text(&mut self, is: &str) { - self.editor.buffer.clear(); - self.editor.buffer.push_str(is); - self.editor.layout_dirty = true; - } - - /// Set the width of the layout. - pub fn set_width(&mut self, width: Option) { - self.editor.width = width; - self.editor.layout_dirty = true; - } - - /// Set the scale for the layout. - pub fn set_scale(&mut self, scale: f32) { - self.editor.scale = scale; - self.editor.layout_dirty = true; - } - - /// Set the default style for the layout. - pub fn set_default_style(&mut self, style: Arc<[StyleProperty<'static, T>]>) { - self.editor.default_style = style; - self.editor.layout_dirty = true; - } - + // --- MARK: Forced relayout --- /// Insert at cursor, or replace selection. pub fn insert_or_replace_selection(&mut self, s: &str) { + assert!(!self.editor.is_composing()); + self.editor .replace_selection(self.font_cx, self.layout_cx, s); } /// Delete the selection. pub fn delete_selection(&mut self) { + assert!(!self.editor.is_composing()); + self.insert_or_replace_selection(""); } /// Delete the selection or the next cluster (typical ‘delete’ behavior). pub fn delete(&mut self) { + assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { // Upstream cluster range if let Some(range) = self @@ -155,11 +209,13 @@ where /// Delete the selection or up to the next word boundary (typical ‘ctrl + delete’ behavior). pub fn delete_word(&mut self) { + assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { let focus = self.editor.selection.focus(); let start = focus.index(); let end = focus.next_logical_word(&self.editor.layout).index(); - if self.editor.text().get(start..end).is_some() { + if self.editor.buffer.get(start..end).is_some() { self.editor.buffer.replace_range(start..end, ""); self.update_layout(); self.editor.set_selection( @@ -174,6 +230,8 @@ where /// Delete the selection or the previous cluster (typical ‘backspace’ behavior). pub fn backdelete(&mut self) { + assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { // Upstream cluster if let Some(cluster) = self @@ -192,7 +250,7 @@ where // Otherwise, delete the previous character let Some((start, _)) = self .editor - .text() + .buffer .get(..end) .and_then(|str| str.char_indices().next_back()) else { @@ -214,11 +272,13 @@ where /// Delete the selection or back to the previous word boundary (typical ‘ctrl + backspace’ behavior). pub fn backdelete_word(&mut self) { + assert!(!self.editor.is_composing()); + if self.editor.selection.is_collapsed() { let focus = self.editor.selection.focus(); let end = focus.index(); let start = focus.previous_logical_word(&self.editor.layout).index(); - if self.editor.text().get(start..end).is_some() { + if self.editor.buffer.get(start..end).is_some() { self.editor.buffer.replace_range(start..end, ""); self.update_layout(); self.editor.set_selection( @@ -231,8 +291,75 @@ where } } + // --- MARK: IME --- + /// Set the IME preedit composing text. + /// + /// This starts composing. Composing is reset by calling [`clear_compose`](Self::clear_compose). + /// While composing, it is a logic error to call anything other than + /// [`Self::set_compose`] or [`Self::clear_compose`]. + /// + /// The preedit text replaces the current selection if this call starts composing. + /// + /// The selection is updated based on `cursor`, which contains the byte offsets relative to the + /// start of the preedit text. If `cursor` is `None`, the selection is collapsed to a caret in + /// front of the preedit text. + pub fn set_compose(&mut self, text: &str, cursor: Option<(usize, usize)>) { + debug_assert!(!text.is_empty()); + debug_assert!(cursor.map(|cursor| cursor.1 <= text.len()).unwrap_or(true)); + + let start = if let Some(preedit_range) = self.editor.compose.clone() { + self.editor + .buffer + .replace_range(preedit_range.clone(), text); + preedit_range.start + } else { + if self.editor.selection.is_collapsed() { + self.editor + .buffer + .insert_str(self.editor.selection.text_range().start, text); + } else { + self.editor + .buffer + .replace_range(self.editor.selection.text_range(), text); + } + self.editor.selection.text_range().start + }; + self.editor.compose = Some(start..start + text.len()); + self.update_layout(); + + if let Some(cursor) = cursor { + // Select the location indicated by the IME. + self.editor.set_selection(Selection::new( + self.editor.cursor_at(start + cursor.0), + self.editor.cursor_at(start + cursor.1), + )); + } else { + // IME indicates nothing is to be selected: collapse the selection to a + // caret just in front of the preedit. + self.editor + .set_selection(self.editor.cursor_at(start).into()); + } + } + + /// Stop IME composing. + /// + /// This removes the IME preedit text. + pub fn clear_compose(&mut self) { + if let Some(preedit_range) = self.editor.compose.clone() { + self.editor.buffer.replace_range(preedit_range.clone(), ""); + self.editor.compose = None; + self.update_layout(); + + self.editor + .set_selection(self.editor.cursor_at(preedit_range.start).into()); + } + } + + // --- MARK: Cursor Movement --- /// Move the cursor to the cluster boundary nearest this point in the layout. pub fn move_to_point(&mut self, x: f32, y: f32) { + assert!(!self.editor.is_composing()); + self.refresh_layout(); self.editor .set_selection(Selection::from_point(&self.editor.layout, x, y)); @@ -242,6 +369,8 @@ where /// /// No-op if index is not a char boundary. pub fn move_to_byte(&mut self, index: usize) { + assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(index) { self.refresh_layout(); self.editor @@ -251,6 +380,9 @@ where /// Move the cursor to the start of the buffer. pub fn move_to_text_start(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MIN, @@ -260,12 +392,18 @@ where /// Move the cursor to the start of the physical line. pub fn move_to_line_start(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.line_start(&self.editor.layout, false)); } /// Move the cursor to the end of the buffer. pub fn move_to_text_end(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MAX, @@ -275,12 +413,18 @@ where /// Move the cursor to the end of the physical line. pub fn move_to_line_end(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.line_end(&self.editor.layout, false)); } /// Move up to the closest physical cluster boundary on the previous line, preserving the horizontal position for repeated movements. pub fn move_up(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -290,12 +434,18 @@ where /// Move down to the closest physical cluster boundary on the next line, preserving the horizontal position for repeated movements. pub fn move_down(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.next_line(&self.editor.layout, false)); } /// Move to the next cluster left in visual order. pub fn move_left(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -305,6 +455,9 @@ where /// Move to the next cluster right in visual order. pub fn move_right(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -314,6 +467,9 @@ where /// Move to the next word boundary left. pub fn move_word_left(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -323,6 +479,9 @@ where /// Move to the next word boundary right. pub fn move_word_right(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -332,19 +491,27 @@ where /// Select the whole buffer. pub fn select_all(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( - Selection::from_byte_index(&self.editor.layout, 0usize, Affinity::default()) + Selection::from_byte_index(&self.editor.layout, 0_usize, Affinity::default()) .move_lines(&self.editor.layout, isize::MAX, true), ); } /// Collapse selection into caret. pub fn collapse_selection(&mut self) { + assert!(!self.editor.is_composing()); + self.editor.set_selection(self.editor.selection.collapse()); } /// Move the selection focus point to the start of the buffer. pub fn select_to_text_start(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MIN, @@ -354,12 +521,18 @@ where /// Move the selection focus point to the start of the physical line. pub fn select_to_line_start(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.line_start(&self.editor.layout, true)); } /// Move the selection focus point to the end of the buffer. pub fn select_to_text_end(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection(self.editor.selection.move_lines( &self.editor.layout, isize::MAX, @@ -369,12 +542,18 @@ where /// Move the selection focus point to the end of the physical line. pub fn select_to_line_end(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.line_end(&self.editor.layout, true)); } /// Move the selection focus point up to the nearest cluster boundary on the previous line, preserving the horizontal position for repeated movements. pub fn select_up(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -384,12 +563,18 @@ where /// Move the selection focus point down to the nearest cluster boundary on the next line, preserving the horizontal position for repeated movements. pub fn select_down(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.next_line(&self.editor.layout, true)); } /// Move the selection focus point to the next cluster left in visual order. pub fn select_left(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -399,12 +584,18 @@ where /// Move the selection focus point to the next cluster right in visual order. pub fn select_right(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor .set_selection(self.editor.selection.next_visual(&self.editor.layout, true)); } /// Move the selection focus point to the next word boundary left. pub fn select_word_left(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -414,6 +605,9 @@ where /// Move the selection focus point to the next word boundary right. pub fn select_word_right(&mut self) { + assert!(!self.editor.is_composing()); + + self.refresh_layout(); self.editor.set_selection( self.editor .selection @@ -423,6 +617,8 @@ where /// Select the word at the point. pub fn select_word_at_point(&mut self, x: f32, y: f32) { + assert!(!self.editor.is_composing()); + self.refresh_layout(); self.editor .set_selection(Selection::word_from_point(&self.editor.layout, x, y)); @@ -430,6 +626,8 @@ where /// Select the physical line at the point. pub fn select_line_at_point(&mut self, x: f32, y: f32) { + assert!(!self.editor.is_composing()); + self.refresh_layout(); let line = Selection::line_from_point(&self.editor.layout, x, y); self.editor.set_selection(line); @@ -437,6 +635,8 @@ where /// Move the selection focus point to the cluster boundary closest to point. pub fn extend_selection_to_point(&mut self, x: f32, y: f32) { + assert!(!self.editor.is_composing()); + self.refresh_layout(); // FIXME: This is usually the wrong way to handle selection extension for mouse moves, but not a regression. self.editor.set_selection( @@ -450,31 +650,35 @@ where /// /// No-op if index is not a char boundary. pub fn extend_selection_to_byte(&mut self, index: usize) { + assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(index) { self.refresh_layout(); - self.editor.set_selection( - self.editor - .selection - .maybe_extend(self.editor.cursor_at(index), true), - ); + self.editor + .set_selection(self.editor.selection.extend(self.editor.cursor_at(index))); } } - /// Select a range of byte indices + /// Select a range of byte indices. /// /// No-op if either index is not a char boundary. pub fn select_byte_range(&mut self, start: usize, end: usize) { + assert!(!self.editor.is_composing()); + if self.editor.buffer.is_char_boundary(start) && self.editor.buffer.is_char_boundary(end) { self.refresh_layout(); - self.editor.set_selection( - Selection::from(self.editor.cursor_at(start)) - .maybe_extend(self.editor.cursor_at(end), true), - ); + self.editor.set_selection(Selection::new( + self.editor.cursor_at(start), + self.editor.cursor_at(end), + )); } } #[cfg(feature = "accesskit")] + /// Select inside the editor based on the selection provided by accesskit. pub fn select_from_accesskit(&mut self, selection: &accesskit::TextSelection) { + assert!(!self.editor.is_composing()); + self.refresh_layout(); if let Some(selection) = Selection::from_access_selection( selection, @@ -485,38 +689,209 @@ where } } - fn update_layout(&mut self) { - self.editor.update_layout(self.font_cx, self.layout_cx); + /// --- MARK: Rendering --- + #[cfg(feature = "accesskit")] + /// Perform an accessibility update. + pub fn accessibility( + &mut self, + update: &mut TreeUpdate, + node: &mut Node, + next_node_id: impl FnMut() -> NodeId, + x_offset: f64, + y_offset: f64, + ) -> Option<()> { + self.refresh_layout(); + self.editor + .accessibility_unchecked(update, node, next_node_id, x_offset, y_offset); + Some(()) } - fn refresh_layout(&mut self) { + /// Get the up-to-date layout for this driver. + pub fn layout(&mut self) -> &Layout { + self.editor.layout(self.font_cx, self.layout_cx) + } + // --- MARK: Internal helpers--- + /// Update the layout if needed. + pub fn refresh_layout(&mut self) { self.editor.refresh_layout(self.font_cx, self.layout_cx); } + + /// Update the layout unconditionally. + fn update_layout(&mut self) { + self.editor.update_layout(self.font_cx, self.layout_cx); + } } impl PlainEditor where T: Brush + Clone + Debug + PartialEq + Default, { - /// Run a series of [`PlainEditorTxn`] methods, updating the layout - /// if necessary. - pub fn transact( - &mut self, - font_cx: &mut FontContext, - layout_cx: &mut LayoutContext, - callback: impl FnOnce(&mut PlainEditorTxn<'_, T>), - ) { - let mut txn = PlainEditorTxn { + /// Run a series of [`PlainEditorDriver`] methods. + /// + /// This type is only used to simplify methods which require both + /// the editor and the provided contexts. + pub fn driver<'drv>( + &'drv mut self, + font_cx: &'drv mut FontContext, + layout_cx: &'drv mut LayoutContext, + ) -> PlainEditorDriver<'drv, T> { + PlainEditorDriver { editor: self, font_cx, layout_cx, - }; - callback(&mut txn); - txn.refresh_layout(); + } + } + + /// If the current selection is not collapsed, returns the text content of + /// that selection. + pub fn selected_text(&self) -> Option<&str> { + if self.is_composing() { + return None; + } + if !self.selection.is_collapsed() { + self.buffer.get(self.selection.text_range()) + } else { + None + } + } + + /// Get rectangles representing the selected portions of text. + pub fn selection_geometry(&self) -> Vec { + self.selection.geometry(&self.layout) + } + + /// Get a rectangle representing the current caret cursor position. + pub fn cursor_geometry(&self, size: f32) -> Option { + Some(self.selection.focus().geometry(&self.layout, size)) + } + + /// Borrow the text content of the buffer. + /// + /// The return value is a `SplitString` because it + /// excludes the IME preedit region. + pub fn text(&self) -> SplitString<'_> { + if let Some(compose) = &self.compose { + SplitString([&self.buffer[..compose.start], &self.buffer[compose.end..]]) + } else { + SplitString([&self.buffer, ""]) + } } - /// Make a cursor at a given byte index + /// Get the current `Generation` of the layout, to decide whether to draw. + /// + /// You should store the generation the editor was at when you last drew it, and then redraw + /// when the generation is different (`Generation` is [`PartialEq`], so supports the equality `==` operation). + pub fn generation(&self) -> Generation { + self.generation + } + + /// Replace the whole text buffer. + pub fn set_text(&mut self, is: &str) { + assert!(!self.is_composing()); + + self.buffer.clear(); + self.buffer.push_str(is); + self.layout_dirty = true; + } + + /// Set the width of the layout. + pub fn set_width(&mut self, width: Option) { + self.width = width; + self.layout_dirty = true; + } + + /// Set the alignment of the layout. + pub fn set_alignment(&mut self, alignment: Alignment) { + self.alignment = alignment; + self.layout_dirty = true; + } + + /// Set the scale for the layout. + pub fn set_scale(&mut self, scale: f32) { + self.scale = scale; + self.layout_dirty = true; + } + + /// Modify the styles provided for this editor. + pub fn edit_styles(&mut self) -> &mut StyleSet { + self.layout_dirty = true; + &mut self.default_style + } + + /// Whether the editor is currently in IME composing mode. + pub fn is_composing(&self) -> bool { + self.compose.is_some() + } + + /// Get the full read-only details from the layout, which will be updated if necessary. + /// + /// If the required contexts are not available, then [`refresh_layout`](Self::refresh_layout) can + /// be called in a scope when they are available, and [`try_layout`](Self::try_layout) can + /// be used instead. + pub fn layout( + &mut self, + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext, + ) -> &Layout { + self.refresh_layout(font_cx, layout_cx); + &self.layout + } + + // --- MARK: Raw APIs --- + /// Get the full read-only details from the layout, if valid. + /// + /// Returns `None` if the layout is not up-to-date. + /// You can call [`refresh_layout`](Self::refresh_layout) before using this method, + /// to ensure that the layout is up-to-date. + /// + /// The [`layout`](Self::layout) method should generally be preferred. + pub fn try_layout(&self) -> Option<&Layout> { + if self.layout_dirty { + None + } else { + Some(&self.layout) + } + } + + #[cfg(feature = "accesskit")] + #[inline] + /// Perform an accessibility update if the layout is valid. + /// + /// Returns `None` if the layout is not up-to-date. + /// You can call [`refresh_layout`](Self::refresh_layout) before using this method, + /// to ensure that the layout is up-to-date. + /// The [`accessibility`](PlainEditorDriver::accessibility) method on the driver type + /// should be preferred if the contexts are available, which will do this automatically. + pub fn try_accessibility( + &mut self, + update: &mut TreeUpdate, + node: &mut Node, + next_node_id: impl FnMut() -> NodeId, + x_offset: f64, + y_offset: f64, + ) -> Option<()> { + if self.layout_dirty { + return None; + } + self.accessibility_unchecked(update, node, next_node_id, x_offset, y_offset); + Some(()) + } + + /// Update the layout if it is dirty. + /// + /// This should only be used alongside [`try_layout`](Self::try_layout) + /// or [`try_accessibility`](Self::try_accessibility), if those will be + /// called in a scope where the contexts are not available. + pub fn refresh_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext) { + if self.layout_dirty { + self.update_layout(font_cx, layout_cx); + } + } + + // --- MARK: Internal Helpers --- + /// Make a cursor at a given byte index. fn cursor_at(&self, index: usize) -> Cursor { + // TODO: Do we need to be non-dirty? // FIXME: `Selection` should make this easier if index >= self.buffer.len() { Cursor::from_byte_index(&self.layout, self.buffer.len(), Affinity::Upstream) @@ -555,106 +930,65 @@ where { self.generation.nudge(); } - // Keeping this commented debug code in for now because it's quite - // useful when diagnosing selection problems: - //---------------------------------------------------------------------- - // #[cfg(feature = "std")] - // { - // let focus = new_sel.focus(); - // let cluster = focus.logical_clusters(&self.layout); - // let dbg = ( - // cluster[0].as_ref().map(|c| &self.buffer[c.text_range()]), - // focus.index(), - // focus.affinity(), - // cluster[1].as_ref().map(|c| &self.buffer[c.text_range()]), - // ); - // print!("{dbg:?}"); - // let cluster = focus.visual_clusters(&self.layout); - // let dbg = ( - // cluster[0].as_ref().map(|c| &self.buffer[c.text_range()]), - // cluster[0] - // .as_ref() - // .map(|c| if c.is_word_boundary() { " W" } else { "" }) - // .unwrap_or_default(), - // focus.index(), - // focus.affinity(), - // cluster[1].as_ref().map(|c| &self.buffer[c.text_range()]), - // cluster[1] - // .as_ref() - // .map(|c| if c.is_word_boundary() { " W" } else { "" }) - // .unwrap_or_default(), - // ); - // println!(" | visual: {dbg:?}"); - // } - self.selection = new_sel; - } - - /// If the current selection is not collapsed, returns the text content of - /// that selection. - pub fn selected_text(&self) -> Option<&str> { - if !self.selection.is_collapsed() { - self.text().get(self.selection.text_range()) - } else { - None - } - } - - /// Get rectangles representing the selected portions of text. - pub fn selection_geometry(&self) -> Vec { - self.selection.geometry(&self.layout) - } - - /// Get a rectangle representing the current caret cursor position. - pub fn cursor_geometry(&self, size: f32) -> Option { - Some(self.selection.focus().geometry(&self.layout, size)) - } - - /// Returns the underlying `Layout`. - pub fn layout(&self) -> &Layout { - &self.layout - } - /// Get the lines from the `Layout`. - pub fn lines(&self) -> impl Iterator> + '_ + Clone { - self.layout.lines() - } - - /// Borrow the text content of the buffer. - pub fn text(&self) -> &str { - &self.buffer - } - - /// Get the current `Generation` of the layout, to decide whether to draw. - /// - /// You should store the generation the editor was at when you last drew it, and then redraw - /// when the generation is different (`Generation` is [`PartialEq`], so supports the equality `==` operation). - pub fn generation(&self) -> Generation { - self.generation - } - - /// Update the layout if it is dirty. - fn refresh_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext) { - if self.layout_dirty { - self.update_layout(font_cx, layout_cx); + // This debug code is quite useful when diagnosing selection problems. + #[cfg(feature = "std")] + #[allow(clippy::print_stderr)] // reason = "unreachable debug code" + if false { + let focus = new_sel.focus(); + let cluster = focus.logical_clusters(&self.layout); + let dbg = ( + cluster[0].as_ref().map(|c| &self.buffer[c.text_range()]), + focus.index(), + focus.affinity(), + cluster[1].as_ref().map(|c| &self.buffer[c.text_range()]), + ); + eprint!("{dbg:?}"); + let cluster = focus.visual_clusters(&self.layout); + let dbg = ( + cluster[0].as_ref().map(|c| &self.buffer[c.text_range()]), + cluster[0] + .as_ref() + .map(|c| if c.is_word_boundary() { " W" } else { "" }) + .unwrap_or_default(), + focus.index(), + focus.affinity(), + cluster[1].as_ref().map(|c| &self.buffer[c.text_range()]), + cluster[1] + .as_ref() + .map(|c| if c.is_word_boundary() { " W" } else { "" }) + .unwrap_or_default(), + ); + eprintln!(" | visual: {dbg:?}"); } + self.selection = new_sel; } - /// Update the layout. fn update_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext) { let mut builder = layout_cx.ranged_builder(font_cx, &self.buffer, self.scale); - for prop in self.default_style.iter() { + for prop in self.default_style.inner().values() { builder.push_default(prop.to_owned()); } - builder.build_into(&mut self.layout, &self.buffer); + if let Some(ref preedit_range) = self.compose { + builder.push(StyleProperty::Underline(true), preedit_range.clone()); + } + self.layout = builder.build(&self.buffer); self.layout.break_all_lines(self.width); - self.layout.align(self.width, Alignment::Start); + self.layout.align(self.width, self.alignment); self.selection = self.selection.refresh(&self.layout); self.layout_dirty = false; self.generation.nudge(); } #[cfg(feature = "accesskit")] - pub fn accessibility( + /// Perform an accessibility update, assuming that the layout is valid. + /// + /// The wrapper [`accessibility`](PlainEditorDriver::accessibility) on the driver type should + /// be preferred. + /// + /// You should always call [`refresh_layout`](Self::refresh_layout) before using this method, + /// with no other modifying method calls in between. + fn accessibility_unchecked( &mut self, update: &mut TreeUpdate, node: &mut Node, diff --git a/parley/src/lib.rs b/parley/src/lib.rs index 81c654d0..774aecd7 100644 --- a/parley/src/lib.rs +++ b/parley/src/lib.rs @@ -131,7 +131,7 @@ pub use inline_box::InlineBox; #[doc(inline)] pub use layout::Layout; -pub use layout::editor::{PlainEditor, PlainEditorTxn}; +pub use layout::editor::{PlainEditor, PlainEditorDriver}; pub use layout::*; pub use style::*; diff --git a/parley/src/style/mod.rs b/parley/src/style/mod.rs index c999f3c7..c99382aa 100644 --- a/parley/src/style/mod.rs +++ b/parley/src/style/mod.rs @@ -5,6 +5,7 @@ mod brush; mod font; +mod styleset; use alloc::borrow::Cow; @@ -13,6 +14,7 @@ pub use font::{ FontFamily, FontFeature, FontSettings, FontStack, FontStretch, FontStyle, FontVariation, FontWeight, GenericFamily, }; +pub use styleset::StyleSet; #[derive(Debug, Clone, Copy)] pub enum WhiteSpaceCollapse { diff --git a/parley/src/style/styleset.rs b/parley/src/style/styleset.rs new file mode 100644 index 00000000..f3dfd7ee --- /dev/null +++ b/parley/src/style/styleset.rs @@ -0,0 +1,76 @@ +// Copyright 2024 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::mem::Discriminant; +use hashbrown::HashMap; + +type StyleProperty = crate::StyleProperty<'static, Brush>; + +/// A long-lived collection of [`StyleProperties`](super::StyleProperty), containing at +/// most one of each property. +/// +/// This is used by [`PlainEditor`](crate::editor::PlainEditor) to provide a reasonably ergonomic +/// mutable API for styles applied to all text managed by it. +/// This can be accessed using [`PlainEditor::edit_styles`](crate::editor::PlainEditor::edit_styles). +/// +/// These styles do not have a corresponding range, and are generally unsuited for rich text. +#[derive(Clone, Debug)] +pub struct StyleSet( + HashMap>, StyleProperty>, +); + +impl StyleSet { + /// Create a new collection of styles. + /// + /// The font size will be `font_size`, and can be overwritten at runtime by + /// [inserting](Self::insert) a new [`FontSize`](crate::StyleProperty::FontSize). + pub fn new(font_size: f32) -> Self { + let mut this = Self(Default::default()); + this.insert(StyleProperty::FontSize(font_size)); + this + } + + /// Add `style` to this collection, returning any overwritten value. + /// + /// Note: Adding a [font stack](crate::StyleProperty::FontStack) to this collection is not + /// additive, and instead overwrites any previously added font stack. + pub fn insert(&mut self, style: StyleProperty) -> Option> { + let discriminant = core::mem::discriminant(&style); + self.0.insert(discriminant, style) + } + + /// [Retain](std::vec::Vec::retain) only the styles for which `f` returns true. + /// + /// Styles which are removed return to their default values. + /// + /// Removing the [font size](crate::StyleProperty::FontSize) is not recommended, as an unspecified + /// fallback font size will be used. + pub fn retain(&mut self, mut f: impl FnMut(&StyleProperty) -> bool) { + self.0.retain(|_, v| f(v)); + } + + /// Remove the style with the discriminant `property`. + /// + /// Styles which are removed return to their default values. + /// + /// To get the discriminant requires constructing a valid `StyleProperty` for the + /// the desired property and passing it to [`core::mem::discriminant`]. + /// Getting this discriminant is usually possible in a `const` context. + /// + /// Removing the [font size](crate::StyleProperty::FontSize) is not recommended, as an unspecified + /// fallback font size will be used. + pub fn remove( + &mut self, + property: Discriminant>, + ) -> Option> { + self.0.remove(&property) + } + + /// Read the raw underlying storage of this. + /// + /// Write access is not provided due to the invariant that keys + /// are the discriminant of their corresponding value. + pub fn inner(&self) -> &HashMap>, StyleProperty> { + &self.0 + } +} diff --git a/parley/src/tests/test_editor.rs b/parley/src/tests/test_editor.rs index b4ce97dd..0c1e5d80 100644 --- a/parley/src/tests/test_editor.rs +++ b/parley/src/tests/test_editor.rs @@ -7,27 +7,25 @@ use crate::testenv; fn editor_simple_move() { let mut env = testenv!(); let mut editor = env.editor("Hi, all!\nNext"); - env.check_editor_snapshot(&editor); - env.transact(&mut editor, |e| { - e.move_right(); - e.move_right(); - e.move_right(); - }); - env.check_editor_snapshot(&editor); - env.transact(&mut editor, |e| e.move_down()); - env.check_editor_snapshot(&editor); - env.transact(&mut editor, |e| e.move_left()); - env.check_editor_snapshot(&editor); - env.transact(&mut editor, |e| e.move_up()); - env.check_editor_snapshot(&editor); + env.check_editor_snapshot(&mut editor); + let mut drv = env.driver(&mut editor); + drv.move_right(); + drv.move_right(); + drv.move_right(); + + env.check_editor_snapshot(&mut editor); + env.driver(&mut editor).move_down(); + env.check_editor_snapshot(&mut editor); + env.driver(&mut editor).move_left(); + env.check_editor_snapshot(&mut editor); + env.driver(&mut editor).move_up(); + env.check_editor_snapshot(&mut editor); } #[test] fn editor_select_all() { let mut env = testenv!(); let mut editor = env.editor("Hi, all!\nNext"); - env.transact(&mut editor, |e| { - e.select_all(); - }); - env.check_editor_snapshot(&editor); + env.driver(&mut editor).select_all(); + env.check_editor_snapshot(&mut editor); } diff --git a/parley/src/tests/utils/env.rs b/parley/src/tests/utils/env.rs index 587b7525..18cd6eb6 100644 --- a/parley/src/tests/utils/env.rs +++ b/parley/src/tests/utils/env.rs @@ -3,13 +3,12 @@ use crate::tests::utils::renderer::{render_layout, RenderingConfig}; use crate::{ - FontContext, FontFamily, FontStack, Layout, LayoutContext, PlainEditor, PlainEditorTxn, + FontContext, FontFamily, FontStack, Layout, LayoutContext, PlainEditor, PlainEditorDriver, RangedBuilder, Rect, StyleProperty, }; use fontique::{Collection, CollectionOptions}; use peniko::Color; use std::path::{Path, PathBuf}; -use std::sync::Arc; use tiny_skia::Pixmap; // Creates a new instance of TestEnv and put current function name in constructor @@ -60,6 +59,7 @@ pub(crate) struct TestEnv { rendering_config: RenderingConfig, cursor_size: f32, tolerance: f32, + // TODO: Add core::panic::Location for case. errors: Vec<(PathBuf, String)>, next_test_case_name: String, } @@ -156,21 +156,19 @@ impl TestEnv { builder } - pub(crate) fn transact( - &mut self, - editor: &mut PlainEditor, - callback: impl FnOnce(&mut PlainEditorTxn<'_, Color>), - ) { - editor.transact(&mut self.font_cx, &mut self.layout_cx, callback); + pub(crate) fn driver<'a>( + &'a mut self, + editor: &'a mut PlainEditor, + ) -> PlainEditorDriver<'a, Color> { + editor.driver(&mut self.font_cx, &mut self.layout_cx) } pub(crate) fn editor(&mut self, text: &str) -> PlainEditor { - let default_style = Arc::new(self.default_style()); - let mut editor = PlainEditor::default(); - self.transact(&mut editor, |editor| { - editor.set_default_style(default_style); - editor.set_text(text); - }); + let mut editor = PlainEditor::new(16.); + for style in self.default_style() { + editor.edit_styles().insert(style); + } + editor.set_text(text); editor } @@ -229,9 +227,10 @@ impl TestEnv { self } - pub(crate) fn check_editor_snapshot(&mut self, editor: &PlainEditor) { + pub(crate) fn check_editor_snapshot(&mut self, editor: &mut PlainEditor) { + editor.refresh_layout(&mut self.font_cx, &mut self.layout_cx); self.render_and_check_snapshot( - editor.layout(), + editor.try_layout().unwrap(), editor.cursor_geometry(self.cursor_size), &editor.selection_geometry(), );