From 820da2cfae38e6ffb87f9ea7829d1f3f729e93cc Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 24 Mar 2026 10:24:55 +0000 Subject: [PATCH 01/11] Make kas::widgets::edit::editor a pub module --- crates/kas-widgets/src/edit/edit_field.rs | 1 + crates/kas-widgets/src/edit/editor.rs | 34 ++++++++++++++++++----- crates/kas-widgets/src/edit/mod.rs | 22 ++------------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index a3c022c05..cf5b02748 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -5,6 +5,7 @@ //! The [`EditBoxCore`] widget +use super::editor::{Component, EventAction}; use super::*; use crate::edit::highlight::{Highlighter, Plain}; use kas::event::CursorIcon; diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 0123c73aa..8f08de33f 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -3,7 +3,12 @@ // You may obtain a copy of the License in the LICENSE-APACHE file or at: // https://www.apache.org/licenses/LICENSE-2.0 -//! Text editor component +//! Text editor components +//! +//! The struct [`Editor`] provides a public API for text-editing actions. +//! +//! [`Component`] is a lower-level type for integrating a text editor into a +//! widget (this is used, for example, in [`EditBoxCore`]. use super::highlight::{self, Highlighter, SchemeColors}; use super::*; @@ -24,13 +29,28 @@ use std::num::NonZeroUsize; use std::ops::{Deref, DerefMut}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; -/// Inner editor component +/// Result type of [`Component::handle_event`] +pub enum EventAction { + /// Key not used, no action + Unused, + /// Key used, no action + Used, + /// Focus has been gained + FocusGained, + /// Focus has been lost + FocusLost, + /// Cursor and/or selection changed + Cursor, + /// Enter key in single-line editor + Activate(Option), + /// Text was edited by key command + Edit, +} + +/// Inner editor interface /// -/// This type is made public for use as the associated `Target` type of the -/// [`Deref`](std::ops::Deref) impl on `EditBoxCore` and `EditBox`. It will no -/// longer be needed once `impl trait` is stabilised for associated types. -/// (Alternatively, [`Editor`] could be re-implemented on the above widgets; -/// this is preferable in theory but requires a lot of tedious code.) +/// This type provides an API usable by [`EditGuard`] and (read-only) via +/// [`Deref`] from [`EditBoxCore`] and [`EditBox`]. #[autoimpl(Debug)] pub struct Editor { // TODO(opt): id is duplicated here since macros don't let us put the core here diff --git a/crates/kas-widgets/src/edit/mod.rs b/crates/kas-widgets/src/edit/mod.rs index a385fa834..57a3eba0b 100644 --- a/crates/kas-widgets/src/edit/mod.rs +++ b/crates/kas-widgets/src/edit/mod.rs @@ -7,13 +7,13 @@ mod edit_box; mod edit_field; -mod editor; +pub mod editor; mod guard; pub mod highlight; pub use edit_box::EditBox; pub use edit_field::EditBoxCore; -pub use editor::{Component, Editor}; +pub use editor::Editor; pub use guard::*; use kas::event::PhysicalKey; @@ -53,24 +53,6 @@ impl EditOp { } } -/// Result type of [`Component::handle_event`] -pub enum EventAction { - /// Key not used, no action - Unused, - /// Key used, no action - Used, - /// Focus has been gained - FocusGained, - /// Focus has been lost - FocusLost, - /// Cursor and/or selection changed - Cursor, - /// Enter key in single-line editor - Activate(Option), - /// Text was edited by key command - Edit, -} - /// Used to track ongoing incompatible actions #[derive(Clone, Debug, Default, PartialEq, Eq)] enum CurrentAction { From a7e324bb1b72d3012c5f9d223adf3370775fa179 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Fri, 20 Mar 2026 15:35:01 +0000 Subject: [PATCH 02/11] Add struct editor::Common --- crates/kas-widgets/src/edit/editor.rs | 39 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 8f08de33f..aa9ad34d7 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -105,6 +105,25 @@ impl From for Editor { } } +/// Editor state common to all parts +#[derive(Debug, Default)] +pub struct Common { + highlighter: H, +} + +impl Common { + /// Replace the highlighter + #[inline] + pub fn with_highlighter(self, highlighter: H2) -> Common

{ + Common { highlighter } + } + + /// Set a new highlighter of the same type + pub fn set_highlighter(&mut self, highlighter: H) { + self.highlighter = highlighter; + } +} + /// Editor component /// /// This is a component used to implement an editor widget. It is used, for @@ -122,7 +141,7 @@ impl From for Editor { /// cannot implement [`Viewport`] directly, but it does provide the following /// methods: [`Self::content_size`], [`Self::draw_with_offset`]. #[derive(Debug, Default)] -pub struct Component(pub Editor, H); +pub struct Component(pub Editor, pub Common); impl Deref for Component { type Target = ConfiguredDisplay; @@ -166,7 +185,10 @@ impl Layout for Component { impl From for Component { #[inline] fn from(text: S) -> Self { - Component(Editor::from(text), H::default()) + let common = Common { + highlighter: H::default(), + }; + Component(Editor::from(text), common) } } @@ -174,12 +196,13 @@ impl Component { /// Replace the highlighter #[inline] pub fn with_highlighter(self, highlighter: H2) -> Component

{ - Component(self.0, highlighter) + let common = Common { highlighter }; + Component(self.0, common) } /// Set a new highlighter of the same type pub fn set_highlighter(&mut self, highlighter: H) { - self.1 = highlighter; + self.1.highlighter = highlighter; } /// Get the background color @@ -213,10 +236,10 @@ impl Component { #[inline] pub fn configure(&mut self, cx: &mut ConfigCx, id: Id) { self.0.id = id; - if self.1.configure(cx) { + if self.1.highlighter.configure(cx) { self.0.display.set_max_status(Status::New); } - self.0.colors = self.1.scheme_colors(); + self.0.colors = self.1.highlighter.scheme_colors(); self.0.display.configure(&mut cx.size_cx()); self.prepare(cx); @@ -225,7 +248,9 @@ impl Component { #[inline] fn prepare_runs(&mut self) { fn inner(this: &mut Component) { - this.0.highlight.highlight(&this.0.text, &mut this.1); + this.0 + .highlight + .highlight(&this.0.text, &mut this.1.highlighter); let (dpem, font) = (this.0.display.font_size(), this.0.display.font()); this.0.display.prepare_runs( this.0.text.as_str(), From dce3e497a3fbcb14e3da4b2bd353cfb3819244c3 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sat, 21 Mar 2026 11:20:15 +0000 Subject: [PATCH 03/11] Add EventAction::Preedit; revise repreparation after handle_event --- crates/kas-widgets/src/edit/edit_field.rs | 2 +- crates/kas-widgets/src/edit/editor.rs | 92 ++++++++++++----------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index cf5b02748..6e3b07778 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -180,7 +180,7 @@ mod EditBoxCore { fn handle_event(&mut self, cx: &mut EventCx, data: &G::Data, event: Event) -> IsUsed { match self.editor.handle_event(cx, event) { EventAction::Unused => Unused, - EventAction::Used | EventAction::Cursor => Used, + EventAction::Used | EventAction::Cursor | EventAction::Preedit => Used, EventAction::FocusGained => { self.guard.focus_gained(&mut self.editor.0, cx, data); self.editor.prepare(cx); diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index aa9ad34d7..eb58d8583 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -43,6 +43,8 @@ pub enum EventAction { Cursor, /// Enter key in single-line editor Activate(Option), + /// Transient (uncommitted) edit by IME + Preedit, /// Text was edited by key command Edit, } @@ -263,6 +265,26 @@ impl Component { } } + /// Perform line wrapping and alignment + /// + /// This represents a high-level step of preparation required before + /// displaying text. After [run-breaking](Self::prepare_runs), this method + /// should be called before displaying the text. This will advance + /// the [status](ConfiguredDisplay::status) to [`Status::Ready`]. + /// This method must be called again after [`Self::prepare_runs`] and after + /// changes to alignment or the wrap-width. + /// + /// Returns `true` when the size of the bounding-box changes. + fn prepare_wrap(&mut self) -> bool { + if self.rect().size.0 == 0 { + return false; + }; + let bb = self.0.display.bounding_box(); + self.0.display.prepare_wrap(); + self.0.display.ensure_no_left_overhang(); + bb != self.0.display.bounding_box() + } + /// Prepare text for display, as necessary /// /// Requests a resize when required. @@ -279,43 +301,14 @@ impl Component { this.prepare_runs(); debug_assert!(this.0.display.status() >= Status::LevelRuns); - if this.rect().size.0 != 0 { - let bb = this.0.display.bounding_box(); - this.0.display.prepare_wrap(); - if bb != this.0.display.bounding_box() { - cx.resize(); - } + if this.prepare_wrap() { + cx.resize(); } } inner(self, cx); true } - /// Prepare text - /// - /// Updates the view offset (scroll position) if the content size changes or - /// `force_set_offset`. Requests redraw and resize as appropriate. - fn prepare_and_scroll(&mut self, cx: &mut EventCx, force_set_offset: bool) { - let mut set_offset = force_set_offset; - if !self.0.display.is_prepared() { - let bb = self.0.display.bounding_box(); - - self.prepare_runs(); - self.0.display.prepare_wrap(); - self.0.display.ensure_no_left_overhang(); - - cx.redraw(); - if bb != self.0.display.bounding_box() { - cx.resize(); - set_offset = true; - } - } - - if set_offset { - self.0.set_view_offset_from_cursor(cx); - } - } - /// Measure required vertical height, wrapping as configured /// /// Stops after `max_lines`, if provided. @@ -463,6 +456,26 @@ impl Component { /// Handle an event pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + let result = self.handle_event_impl(cx, event); + match &result { + &EventAction::Cursor => self.0.set_view_offset_from_cursor(cx), + &EventAction::Preedit | &EventAction::Edit => { + if !self.0.display.is_prepared() { + self.prepare_runs(); + + cx.redraw(); + if self.prepare_wrap() { + cx.resize(); + self.0.set_view_offset_from_cursor(cx); + } + } + } + _ => (), + } + result + } + + fn handle_event_impl(&mut self, cx: &mut EventCx, event: Event) -> EventAction { match event { Event::NavFocus(source) if source == FocusSource::Key => { if !self.0.input_handler.is_selecting() { @@ -516,17 +529,13 @@ impl Component { EventAction::Used } Event::Command(cmd, code) => match self.0.cmd_action(cx, cmd, code) { - Ok(action) => { - self.prepare_and_scroll(cx, true); - action - } + Ok(action) => action, Err(NotReady) => EventAction::Used, }, Event::Key(event, false) if event.state == ElementState::Pressed => { if let Some(text) = &event.text { self.0.save_undo_state(Some(EditOp::KeyInput)); if self.0.received_text(cx, text) == Used { - self.prepare_and_scroll(cx, false); EventAction::Edit } else { EventAction::Unused @@ -538,10 +547,7 @@ impl Component { .try_match_event(cx.modifiers(), event); if let Some(cmd) = opt_cmd { match self.0.cmd_action(cx, cmd, Some(event.physical_key)) { - Ok(action) => { - self.prepare_and_scroll(cx, true); - action - } + Ok(action) => action, Err(NotReady) => EventAction::Used, } } else { @@ -602,8 +608,7 @@ impl Component { edit_range: edit_range.cast(), }; self.0.edit_x_coord = None; - self.prepare_and_scroll(cx, false); - EventAction::Used + EventAction::Preedit } Ime::Commit { text } => { self.0.save_undo_state(Some(EditOp::Ime)); @@ -620,7 +625,6 @@ impl Component { edit_range: self.0.selection.range().cast(), }; self.0.edit_x_coord = None; - self.prepare_and_scroll(cx, false); EventAction::Edit } Ime::DeleteSurrounding { @@ -682,8 +686,6 @@ impl Component { self.0.replace_range(index..index, &content[range.clone()]); self.0.selection.set_cursor(index + range.len()); self.0.edit_x_coord = None; - self.prepare_and_scroll(cx, false); - EventAction::Edit } else { EventAction::Used From cfc3927c361fab57d0b9bd779171778b8bf256fc Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sat, 21 Mar 2026 09:56:25 +0000 Subject: [PATCH 04/11] Add struct editor::Part --- crates/kas-widgets/src/edit/editor.rs | 426 ++++++++++++++------------ 1 file changed, 229 insertions(+), 197 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index eb58d8583..368906f56 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -9,6 +9,10 @@ //! //! [`Component`] is a lower-level type for integrating a text editor into a //! widget (this is used, for example, in [`EditBoxCore`]. +//! +//! [`Common`] and [`Part`] are lower-level components of [`Component`]: a +//! single-paragraph editor should have one of each while a multi-paragraph +//! editor might use multiple [`Part`]s. use super::highlight::{self, Highlighter, SchemeColors}; use super::*; @@ -49,12 +53,9 @@ pub enum EventAction { Edit, } -/// Inner editor interface -/// -/// This type provides an API usable by [`EditGuard`] and (read-only) via -/// [`Deref`] from [`EditBoxCore`] and [`EditBox`]. +/// A text part for usage by an editor #[autoimpl(Debug)] -pub struct Editor { +pub struct Part { // TODO(opt): id is duplicated here since macros don't let us put the core here id: Id, read_only: bool, @@ -72,10 +73,10 @@ pub struct Editor { input_handler: TextInput, } -impl Default for Editor { +impl Default for Part { #[inline] fn default() -> Self { - Editor { + Part { id: Id::default(), read_only: false, display: ConfiguredDisplay::new(TextClass::Editor, false), @@ -94,12 +95,12 @@ impl Default for Editor { } } -impl From for Editor { +impl From for Part { #[inline] fn from(text: S) -> Self { let text = text.to_string(); let len = text.len(); - Editor { + Part { text, selection: SelectionHelper::from(len), ..Self::default() @@ -107,6 +108,24 @@ impl From for Editor { } } +/// Inner editor interface +/// +/// This type provides an API usable by [`EditGuard`] and (read-only) via +/// [`Deref`] from [`EditBoxCore`] and [`EditBox`]. +#[autoimpl(Debug, Default)] +pub struct Editor { + part: Part, +} + +impl From for Editor { + #[inline] + fn from(text: S) -> Self { + Editor { + part: Part::from(text), + } + } +} + /// Editor state common to all parts #[derive(Debug, Default)] pub struct Common { @@ -148,33 +167,34 @@ pub struct Component(pub Editor, pub Common); impl Deref for Component { type Target = ConfiguredDisplay; fn deref(&self) -> &Self::Target { - &self.0.display + &self.0.part.display } } impl DerefMut for Component { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0.display + &mut self.0.part.display } } impl Layout for Component { #[inline] fn rect(&self) -> Rect { - self.0.display.rect() + self.0.part.display.rect() } #[inline] fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { self.prepare_runs(); - self.0.display.size_rules(cx, axis) + self.0.part.display.size_rules(cx, axis) } fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect, hints: AlignHints) { - self.0.display.set_rect(cx, rect, hints); - self.0.display.ensure_no_left_overhang(); - if self.0.current.is_ime_enabled() { - self.0.set_ime_cursor_area(cx); + let part: &mut Part = &mut self.0.part; + part.display.set_rect(cx, rect, hints); + part.display.ensure_no_left_overhang(); + if part.current.is_ime_enabled() { + part.set_ime_cursor_area(cx); } } @@ -209,9 +229,9 @@ impl Component { /// Get the background color pub fn background_color(&self) -> Background { - if self.0.error_state.is_some() { + if self.0.part.error_state.is_some() { Background::Error - } else if let Some(c) = self.0.colors.background.as_rgba() { + } else if let Some(c) = self.0.part.colors.background.as_rgba() { Background::Rgb(c.as_rgb()) } else { Background::Default @@ -224,44 +244,41 @@ impl Component { #[inline] #[must_use] pub fn with_text(mut self, text: impl ToString) -> Self { - debug_assert!( - self.0.current == CurrentAction::None && !self.0.input_handler.is_selecting() - ); + let part: &mut Part = &mut self.0.part; + debug_assert!(part.current == CurrentAction::None && !part.input_handler.is_selecting()); let text = text.to_string(); let len = text.len(); - self.0.text = text; - self.0.selection.set_cursor(len); + part.text = text; + part.selection.set_cursor(len); self } /// Configure component #[inline] pub fn configure(&mut self, cx: &mut ConfigCx, id: Id) { - self.0.id = id; + let part: &mut Part = &mut self.0.part; + part.id = id; if self.1.highlighter.configure(cx) { - self.0.display.set_max_status(Status::New); + part.display.set_max_status(Status::New); } - self.0.colors = self.1.highlighter.scheme_colors(); - self.0.display.configure(&mut cx.size_cx()); + part.colors = self.1.highlighter.scheme_colors(); + part.display.configure(&mut cx.size_cx()); self.prepare(cx); } #[inline] fn prepare_runs(&mut self) { - fn inner(this: &mut Component) { - this.0 - .highlight - .highlight(&this.0.text, &mut this.1.highlighter); - let (dpem, font) = (this.0.display.font_size(), this.0.display.font()); - this.0.display.prepare_runs( - this.0.text.as_str(), - this.0.highlight.font_tokens(dpem, font), - ); + fn inner(common: &mut Common, part: &mut Part) { + part.highlight + .highlight(&part.text, &mut common.highlighter); + let (dpem, font) = (part.display.font_size(), part.display.font()); + part.display + .prepare_runs(part.text.as_str(), part.highlight.font_tokens(dpem, font)); } - if self.0.display.status() < Status::LevelRuns { - inner(self) + if self.0.part.display.status() < Status::LevelRuns { + inner(&mut self.1, &mut self.0.part) } } @@ -279,10 +296,10 @@ impl Component { if self.rect().size.0 == 0 { return false; }; - let bb = self.0.display.bounding_box(); - self.0.display.prepare_wrap(); - self.0.display.ensure_no_left_overhang(); - bb != self.0.display.bounding_box() + let bb = self.0.part.display.bounding_box(); + self.0.part.display.prepare_wrap(); + self.0.part.display.ensure_no_left_overhang(); + bb != self.0.part.display.bounding_box() } /// Prepare text for display, as necessary @@ -293,13 +310,12 @@ impl Component { /// when the text is already prepared. #[inline] pub fn prepare(&mut self, cx: &mut ConfigCx) -> bool { - if self.0.display.is_prepared() { + if self.0.part.display.is_prepared() { return false; } fn inner(this: &mut Component, cx: &mut ConfigCx) { this.prepare_runs(); - debug_assert!(this.0.display.status() >= Status::LevelRuns); if this.prepare_wrap() { cx.resize(); @@ -318,6 +334,7 @@ impl Component { pub fn measure_height(&mut self, wrap_width: f32, max_lines: Option) -> f32 { self.prepare_runs(); self.0 + .part .display .unchecked_display() .measure_height(wrap_width, max_lines) @@ -325,7 +342,7 @@ impl Component { /// Implementation of [`Viewport::content_size`] pub fn content_size(&self) -> Size { - if let Ok((tl, br)) = self.0.display.bounding_box() { + if let Ok((tl, br)) = self.0.part.display.bounding_box() { (br - tl).cast_ceil() } else { Size::ZERO @@ -334,16 +351,17 @@ impl Component { /// Implementation of [`Viewport::draw_with_offset`] pub fn draw_with_offset(&self, mut draw: DrawCx, rect: Rect, offset: Offset) { - let Ok(display) = self.0.display.display() else { + let part = &self.0.part; + let Ok(display) = part.display.display() else { return; }; let pos = self.rect().pos - offset; - let range: Range = self.0.selection.range().cast(); + let range: Range = part.selection.range().cast(); - let color_tokens = self.0.highlight.color_tokens(); + let color_tokens = part.highlight.color_tokens(); let default_colors = format::Colors { - foreground: self.0.colors.foreground, + foreground: part.colors.foreground, background: None, }; let mut buf = [(0, default_colors); 3]; @@ -356,17 +374,17 @@ impl Component { } } else if color_tokens.is_empty() { buf[1].0 = range.start; - buf[1].1.foreground = self.0.colors.selection_foreground; - buf[1].1.background = Some(self.0.colors.selection_background); + buf[1].1.foreground = part.colors.selection_foreground; + buf[1].1.background = Some(part.colors.selection_background); buf[2].0 = range.end; let r0 = if range.start > 0 { 0 } else { 1 }; &buf[r0..] } else { let set_selection_colors = |colors: &mut format::Colors| { - if colors.foreground == self.0.colors.foreground { - colors.foreground = self.0.colors.selection_foreground; + if colors.foreground == part.colors.foreground { + colors.foreground = part.colors.selection_foreground; } - colors.background = Some(self.0.colors.selection_background); + colors.background = Some(part.colors.selection_background); }; vec.reserve(color_tokens.len() + 2); @@ -425,12 +443,12 @@ impl Component { }; draw.text(pos, rect, display, tokens); - let decorations = self.0.highlight.decorations(); + let decorations = part.highlight.decorations(); if !decorations.is_empty() { draw.decorate_text(pos, rect, display, decorations); } - if let CurrentAction::ImePreedit { edit_range } = self.0.current.clone() { + if let CurrentAction::ImePreedit { edit_range } = part.current.clone() { let tokens = [ Default::default(), (edit_range.start, format::Decoration { @@ -443,13 +461,13 @@ impl Component { draw.decorate_text(pos, rect, display, &tokens[r0..]); } - if !self.0.read_only && draw.ev_state().has_input_focus(self.0.id_ref()) == Some(true) { + if !part.read_only && draw.ev_state().has_input_focus(self.0.id_ref()) == Some(true) { draw.text_cursor( pos, rect, display, - self.0.selection.edit_index(), - Some(self.0.colors.cursor), + part.selection.edit_index(), + Some(part.colors.cursor), ); } } @@ -458,15 +476,15 @@ impl Component { pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { let result = self.handle_event_impl(cx, event); match &result { - &EventAction::Cursor => self.0.set_view_offset_from_cursor(cx), + &EventAction::Cursor => self.0.part.set_view_offset_from_cursor(cx), &EventAction::Preedit | &EventAction::Edit => { - if !self.0.display.is_prepared() { + if !self.0.part.display.is_prepared() { self.prepare_runs(); cx.redraw(); if self.prepare_wrap() { cx.resize(); - self.0.set_view_offset_from_cursor(cx); + self.0.part.set_view_offset_from_cursor(cx); } } } @@ -476,10 +494,11 @@ impl Component { } fn handle_event_impl(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + let part: &mut Part = &mut self.0.part; match event { Event::NavFocus(source) if source == FocusSource::Key => { - if !self.0.input_handler.is_selecting() { - self.0.request_key_focus(cx, source); + if !part.input_handler.is_selecting() { + part.request_key_focus(cx, source); } EventAction::Used } @@ -488,31 +507,31 @@ impl Component { Event::SelFocus(source) => { // NOTE: sel focus implies key focus since we only request // the latter. We must set before calling self.set_primary. - self.0.has_key_focus = true; + part.has_key_focus = true; if source == FocusSource::Pointer { - self.0.set_primary(cx); + part.set_primary(cx); } EventAction::Used } Event::KeyFocus => { - self.0.has_key_focus = true; - self.0.set_view_offset_from_cursor(cx); + part.has_key_focus = true; + part.set_view_offset_from_cursor(cx); - if self.0.current.is_none() { + if part.current.is_none() { let hint = Default::default(); let purpose = ImePurpose::Normal; - let surrounding_text = self.0.ime_surrounding_text(); - cx.replace_ime_focus(self.0.id.clone(), hint, purpose, surrounding_text); + let surrounding_text = part.ime_surrounding_text(); + cx.replace_ime_focus(part.id.clone(), hint, purpose, surrounding_text); EventAction::FocusGained } else { EventAction::Used } } Event::LostKeyFocus => { - self.0.has_key_focus = false; + part.has_key_focus = false; cx.redraw(); - if !self.0.current.is_ime_enabled() { + if !part.current.is_ime_enabled() { EventAction::FocusLost } else { EventAction::Used @@ -520,22 +539,22 @@ impl Component { } Event::LostSelFocus => { // NOTE: we can assume that we will receive Ime::Disabled if IME is active - if !self.0.selection.is_empty() { - self.0.save_undo_state(None); - self.0.selection.set_empty(); + if !part.selection.is_empty() { + part.save_undo_state(None); + part.selection.set_empty(); } - self.0.input_handler.stop_selecting(); + part.input_handler.stop_selecting(); cx.redraw(); EventAction::Used } - Event::Command(cmd, code) => match self.0.cmd_action(cx, cmd, code) { + Event::Command(cmd, code) => match part.cmd_action(cx, cmd, code) { Ok(action) => action, Err(NotReady) => EventAction::Used, }, Event::Key(event, false) if event.state == ElementState::Pressed => { if let Some(text) = &event.text { - self.0.save_undo_state(Some(EditOp::KeyInput)); - if self.0.received_text(cx, text) == Used { + part.save_undo_state(Some(EditOp::KeyInput)); + if part.received_text(cx, text) == Used { EventAction::Edit } else { EventAction::Unused @@ -546,7 +565,7 @@ impl Component { .shortcuts() .try_match_event(cx.modifiers(), event); if let Some(cmd) = opt_cmd { - match self.0.cmd_action(cx, cmd, Some(event.physical_key)) { + match part.cmd_action(cx, cmd, Some(event.physical_key)) { Ok(action) => action, Err(NotReady) => EventAction::Used, } @@ -557,83 +576,81 @@ impl Component { } Event::Ime(ime) => match ime { Ime::Enabled => { - match self.0.current { + match part.current { CurrentAction::None => { - self.0.current = CurrentAction::ImeStart; - self.0.set_ime_cursor_area(cx); + part.current = CurrentAction::ImeStart; + part.set_ime_cursor_area(cx); } CurrentAction::ImeStart | CurrentAction::ImePreedit { .. } => { // already enabled } CurrentAction::Selection => { // Do not interrupt selection - cx.cancel_ime_focus(self.0.id_ref()); + cx.cancel_ime_focus(&part.id); } } - if !self.0.has_key_focus { + if !part.has_key_focus { EventAction::FocusGained } else { EventAction::Used } } Ime::Disabled => { - self.0.clear_ime(); - if !self.0.has_key_focus { + part.clear_ime(); + if !part.has_key_focus { EventAction::FocusLost } else { EventAction::Used } } Ime::Preedit { text, cursor } => { - self.0.save_undo_state(None); - let mut edit_range = match self.0.current.clone() { - CurrentAction::ImeStart if cursor.is_some() => self.0.selection.range(), + part.save_undo_state(None); + let mut edit_range = match part.current.clone() { + CurrentAction::ImeStart if cursor.is_some() => part.selection.range(), CurrentAction::ImeStart => return EventAction::Used, CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; - self.0.replace_range(edit_range.clone(), text); + part.replace_range(edit_range.clone(), text); edit_range.end = edit_range.start + text.len(); if let Some((start, end)) = cursor { - self.0 - .selection - .set_sel_index_only(edit_range.start + start); - self.0.selection.set_edit_index(edit_range.start + end); + part.selection.set_sel_index_only(edit_range.start + start); + part.selection.set_edit_index(edit_range.start + end); } else { - self.0.selection.set_cursor(edit_range.start + text.len()); + part.selection.set_cursor(edit_range.start + text.len()); } - self.0.current = CurrentAction::ImePreedit { + part.current = CurrentAction::ImePreedit { edit_range: edit_range.cast(), }; - self.0.edit_x_coord = None; + part.edit_x_coord = None; EventAction::Preedit } Ime::Commit { text } => { - self.0.save_undo_state(Some(EditOp::Ime)); - let edit_range = match self.0.current.clone() { - CurrentAction::ImeStart => self.0.selection.range(), + part.save_undo_state(Some(EditOp::Ime)); + let edit_range = match part.current.clone() { + CurrentAction::ImeStart => part.selection.range(), CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; - self.0.replace_range(edit_range.clone(), text); - self.0.selection.set_cursor(edit_range.start + text.len()); + part.replace_range(edit_range.clone(), text); + part.selection.set_cursor(edit_range.start + text.len()); - self.0.current = CurrentAction::ImePreedit { - edit_range: self.0.selection.range().cast(), + part.current = CurrentAction::ImePreedit { + edit_range: part.selection.range().cast(), }; - self.0.edit_x_coord = None; + part.edit_x_coord = None; EventAction::Edit } Ime::DeleteSurrounding { before_bytes, after_bytes, } => { - self.0.save_undo_state(None); - let edit_range = match self.0.current.clone() { - CurrentAction::ImeStart => self.0.selection.range(), + part.save_undo_state(None); + let edit_range = match part.current.clone() { + CurrentAction::ImeStart => part.selection.range(), CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; @@ -641,9 +658,9 @@ impl Component { if before_bytes > 0 { let end = edit_range.start; let start = end - before_bytes; - if self.0.as_str().is_char_boundary(start) { - self.0.replace_range(start..end, ""); - self.0.selection.delete_range(start..end); + if part.as_str().is_char_boundary(start) { + part.replace_range(start..end, ""); + part.selection.delete_range(start..end); } else { log::warn!("buggy IME tried to delete range not at char boundary"); } @@ -652,46 +669,46 @@ impl Component { if after_bytes > 0 { let start = edit_range.end; let end = start + after_bytes; - if self.0.as_str().is_char_boundary(end) { - self.0.replace_range(start..end, ""); + if part.as_str().is_char_boundary(end) { + part.replace_range(start..end, ""); } else { log::warn!("buggy IME tried to delete range not at char boundary"); } } - if let Some(text) = self.0.ime_surrounding_text() { - cx.update_ime_surrounding_text(self.0.id_ref(), text); + if let Some(text) = part.ime_surrounding_text() { + cx.update_ime_surrounding_text(&part.id, text); } EventAction::Used } }, Event::PressStart(press) if press.is_tertiary() => { - match press.grab_click(self.0.id()).complete(cx) { + match press.grab_click(part.id.clone()).complete(cx) { Unused => EventAction::Unused, Used => EventAction::Used, } } Event::PressEnd { press, .. } if press.is_tertiary() => { - self.0.set_cursor_from_coord(cx, press.coord); - self.0.cancel_selection_and_ime(cx); - self.0.request_key_focus(cx, FocusSource::Pointer); + part.set_cursor_from_coord(cx, press.coord); + part.cancel_selection_and_ime(cx); + part.request_key_focus(cx, FocusSource::Pointer); if let Some(content) = cx.get_primary() { - self.0.save_undo_state(Some(EditOp::Clipboard)); + part.save_undo_state(Some(EditOp::Clipboard)); - let index = self.0.selection.edit_index(); - let range = self.0.trim_paste(&content); + let index = part.selection.edit_index(); + let range = part.trim_paste(&content); - self.0.replace_range(index..index, &content[range.clone()]); - self.0.selection.set_cursor(index + range.len()); - self.0.edit_x_coord = None; + part.replace_range(index..index, &content[range.clone()]); + part.selection.set_cursor(index + range.len()); + part.edit_x_coord = None; EventAction::Edit } else { EventAction::Used } } - event => match self.0.input_handler.handle(cx, self.0.id.clone(), event) { + event => match part.input_handler.handle(cx, part.id.clone(), event) { TextInputAction::Used => EventAction::Used, TextInputAction::Unused => EventAction::Unused, TextInputAction::PressStart { @@ -699,55 +716,49 @@ impl Component { clear, repeats, } => { - if self.0.current.is_ime_enabled() { - self.0.clear_ime(); - cx.cancel_ime_focus(self.0.id_ref()); + if part.current.is_ime_enabled() { + part.clear_ime(); + cx.cancel_ime_focus(&part.id); } - self.0.save_undo_state(Some(EditOp::Cursor)); - self.0.current = CurrentAction::Selection; + part.save_undo_state(Some(EditOp::Cursor)); + part.current = CurrentAction::Selection; - self.0.set_cursor_from_coord(cx, coord); - self.0.selection.set_anchor(clear); + part.set_cursor_from_coord(cx, coord); + part.selection.set_anchor(clear); if repeats > 1 { - self.0.selection.expand( - self.0.text.as_str(), - &self.0.display, - repeats >= 3, - ); + part.selection + .expand(part.text.as_str(), &part.display, repeats >= 3); } - self.0.request_key_focus(cx, FocusSource::Pointer); + part.request_key_focus(cx, FocusSource::Pointer); EventAction::Used } TextInputAction::PressMove { coord, repeats } => { - if self.0.current == CurrentAction::Selection { - self.0.set_cursor_from_coord(cx, coord); + if part.current == CurrentAction::Selection { + part.set_cursor_from_coord(cx, coord); if repeats > 1 { - self.0.selection.expand( - self.0.text.as_str(), - &self.0.display, - repeats >= 3, - ); + part.selection + .expand(part.text.as_str(), &part.display, repeats >= 3); } } EventAction::Used } TextInputAction::PressEnd { coord } => { - if self.0.current.is_ime_enabled() { - self.0.clear_ime(); - cx.cancel_ime_focus(self.0.id_ref()); + if part.current.is_ime_enabled() { + part.clear_ime(); + cx.cancel_ime_focus(&part.id); } - self.0.save_undo_state(Some(EditOp::Cursor)); - if self.0.current == CurrentAction::Selection { - self.0.set_primary(cx); + part.save_undo_state(Some(EditOp::Cursor)); + if part.current == CurrentAction::Selection { + part.set_primary(cx); } else { - self.0.set_cursor_from_coord(cx, coord); - self.0.selection.set_empty(); + part.set_cursor_from_coord(cx, coord); + part.selection.set_empty(); } - self.0.current = CurrentAction::None; + part.current = CurrentAction::None; - self.0.request_key_focus(cx, FocusSource::Pointer); + part.request_key_focus(cx, FocusSource::Pointer); EventAction::Used } }, @@ -757,11 +768,29 @@ impl Component { /// Clear the error state #[inline] pub fn clear_error(&mut self) { - self.0.error_state = None; + self.0.part.error_state = None; } } -impl Editor { +impl Part { + /// Get text contents + #[inline] + fn as_str(&self) -> &str { + self.text.as_str() + } + + /// True if the editor uses multi-line mode + #[inline] + fn multi_line(&self) -> bool { + self.display.wrap() + } + + /// Get the (horizontal) text direction + #[inline] + fn text_is_rtl(&self) -> bool { + self.display.text_is_rtl(self.as_str()) + } + /// Insert a `text` at the given position /// /// This may be used to edit the raw text instead of replacing it. @@ -923,7 +952,7 @@ impl Editor { self.last_edit = edit; self.undo_stack - .try_push((self.clone_string(), self.cursor_range())); + .try_push((self.as_str().to_string(), *self.selection)); } /// Insert `text` at the cursor position @@ -953,7 +982,7 @@ impl Editor { /// Request key focus, if we don't have it or IME fn request_key_focus(&self, cx: &mut EventCx, source: FocusSource) { if !self.has_key_focus && !self.current.is_ime_enabled() { - cx.request_key_focus(self.id(), source); + cx.request_key_focus(self.id.clone(), source); } } @@ -1315,19 +1344,19 @@ impl Editor { /// Get a reference to the widget's identifier #[inline] pub fn id_ref(&self) -> &Id { - &self.id + &self.part.id } /// Get the widget's identifier #[inline] pub fn id(&self) -> Id { - self.id.clone() + self.id_ref().clone() } /// Get text contents #[inline] pub fn as_str(&self) -> &str { - self.text.as_str() + self.part.text.as_str() } /// Get the text contents as a `String` @@ -1343,7 +1372,7 @@ impl Editor { /// TODO: support defaulting to RTL. #[inline] pub fn text_is_rtl(&self) -> bool { - self.display.text_is_rtl(self.as_str()) + self.part.display.text_is_rtl(self.as_str()) } /// Commit outstanding changes to the undo history @@ -1352,14 +1381,14 @@ impl Editor { /// [`Self::set_string`] to commit changes to the undo history. #[inline] pub fn pre_commit(&mut self) { - self.save_undo_state(Some(EditOp::Synthetic)); + self.part.save_undo_state(Some(EditOp::Synthetic)); } /// Clear text contents and undo history #[inline] pub fn clear(&mut self, cx: &mut EventState) { - self.last_edit = Some(EditOp::Initial); - self.undo_stack.clear(); + self.part.last_edit = Some(EditOp::Initial); + self.part.undo_stack.clear(); self.set_string(cx, String::new()); } @@ -1388,15 +1417,15 @@ impl Editor { return; // no change } - self.cancel_selection_and_ime(cx); + self.part.cancel_selection_and_ime(cx); - self.text = text; - self.display.set_max_status(Status::New); + self.part.text = text; + self.part.display.set_max_status(Status::New); let len = self.as_str().len(); - self.selection.set_max_len(len); - self.edit_x_coord = None; - self.error_state = None; + self.part.selection.set_max_len(len); + self.part.edit_x_coord = None; + self.part.error_state = None; } /// Replace selected text @@ -1405,26 +1434,26 @@ impl Editor { /// guard. #[inline] pub fn replace_selected_text(&mut self, cx: &mut EventState, text: &str) { - self.cancel_selection_and_ime(cx); + self.part.cancel_selection_and_ime(cx); - let index = self.selection.edit_index(); - let selection = self.selection.range(); + let index = self.part.selection.edit_index(); + let selection = self.part.selection.range(); let have_sel = selection.start < selection.end; if have_sel { - self.replace_range(selection.clone(), text); - self.selection.set_cursor(selection.start + text.len()); + self.part.replace_range(selection.clone(), text); + self.part.selection.set_cursor(selection.start + text.len()); } else { - self.insert_str(index, text); - self.selection.set_cursor(index + text.len()); + self.part.insert_str(index, text); + self.part.selection.set_cursor(index + text.len()); } - self.edit_x_coord = None; - self.error_state = None; + self.part.edit_x_coord = None; + self.part.error_state = None; } /// Access the cursor index / selection range #[inline] pub fn cursor_range(&self) -> CursorRange { - *self.selection + *self.part.selection } /// Set the cursor index / range @@ -1433,32 +1462,32 @@ impl Editor { /// guard. #[inline] pub fn set_cursor_range(&mut self, range: CursorRange) { - self.edit_x_coord = None; - self.selection = range.into(); + self.part.edit_x_coord = None; + self.part.selection = range.into(); } /// Get whether this text-edit widget is read-only #[inline] pub fn is_read_only(&self) -> bool { - self.read_only + self.part.read_only } /// Set whether this text-edit widget is editable #[inline] pub fn set_read_only(&mut self, read_only: bool) { - self.read_only = read_only; + self.part.read_only = read_only; } /// True if the editor uses multi-line mode #[inline] pub fn multi_line(&self) -> bool { - self.display.wrap() + self.part.display.wrap() } /// Get the text class used #[inline] pub fn class(&self) -> TextClass { - self.display.class() + self.part.display.class() } /// Get whether the widget has input focus @@ -1466,19 +1495,22 @@ impl Editor { /// This is true when the widget is has keyboard or IME focus. #[inline] pub fn has_input_focus(&self) -> bool { - self.has_key_focus || self.current.is_ime_enabled() + self.part.has_key_focus || self.part.current.is_ime_enabled() } /// Get whether the input state is erroneous #[inline] pub fn has_error(&self) -> bool { - self.error_state.is_some() + self.part.error_state.is_some() } /// Get the error message, if any #[inline] pub fn error_message(&self) -> Option<&str> { - self.error_state.as_ref().and_then(|state| state.as_deref()) + self.part + .error_state + .as_ref() + .and_then(|state| state.as_deref()) } /// Mark the input as erroneous with an optional message @@ -1490,7 +1522,7 @@ impl Editor { /// When set, the input field's background is drawn red. If a message is /// supplied, then a tooltip will be available on mouse-hover. pub fn set_error(&mut self, cx: &mut EventState, message: Option>) { - self.error_state = Some(message); - cx.redraw(&self.id); + self.part.error_state = Some(message); + cx.redraw(self.id_ref()); } } From 69c25fae7fcc1a95fe9b726b8c7da1cc6695d2c6 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sat, 21 Mar 2026 10:38:16 +0000 Subject: [PATCH 05/11] Move several methods from Component to Part --- crates/kas-widgets/src/edit/edit_field.rs | 4 +- crates/kas-widgets/src/edit/editor.rs | 416 +++++++++++++--------- 2 files changed, 246 insertions(+), 174 deletions(-) diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index 6e3b07778..e9dbe95cf 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -122,12 +122,12 @@ mod EditBoxCore { impl Viewport for Self { #[inline] fn content_size(&self) -> Size { - self.editor.content_size() + self.editor.part().content_size() } #[inline] fn draw_with_offset(&self, mut draw: DrawCx, rect: Rect, offset: Offset) { - self.editor.draw_with_offset(draw, rect, offset); + self.editor.part().draw_with_offset(draw, rect, offset); } } diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 368906f56..5e6fb1e20 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -54,6 +54,13 @@ pub enum EventAction { } /// A text part for usage by an editor +/// +/// ### Special behaviour +/// +/// The wrapping widget may (optionally) wish to implement [`Viewport`] to +/// support scrolling of text content. Since this component is not a widget it +/// cannot implement [`Viewport`] directly, but it does provide the following +/// methods: [`Self::content_size`], [`Self::draw_with_offset`]. #[autoimpl(Debug)] pub struct Part { // TODO(opt): id is duplicated here since macros don't let us put the core here @@ -157,10 +164,7 @@ impl Common { /// widget may wish to reserve extra space, use a higher stretch policy and /// potentially also set an alignment hint. /// -/// The wrapping widget may (optionally) wish to implement [`Viewport`] to -/// support scrolling of text content. Since this component is not a widget it -/// cannot implement [`Viewport`] directly, but it does provide the following -/// methods: [`Self::content_size`], [`Self::draw_with_offset`]. +/// See also [`Part`] (accessible through [`Self::part`]). #[derive(Debug, Default)] pub struct Component(pub Editor, pub Common); @@ -185,7 +189,7 @@ impl Layout for Component { #[inline] fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { - self.prepare_runs(); + self.0.part.prepare_runs(&mut self.1); self.0.part.display.size_rules(cx, axis) } @@ -200,7 +204,9 @@ impl Layout for Component { #[inline] fn draw(&self, draw: DrawCx) { - self.draw_with_offset(draw, self.rect(), Offset::ZERO); + self.0 + .part + .draw_with_offset(draw, self.rect(), Offset::ZERO); } } @@ -253,23 +259,104 @@ impl Component { self } + /// Access the text part + #[inline] + pub fn part(&self) -> &Part { + &self.0.part + } + /// Configure component #[inline] pub fn configure(&mut self, cx: &mut ConfigCx, id: Id) { - let part: &mut Part = &mut self.0.part; - part.id = id; if self.1.highlighter.configure(cx) { - part.display.set_max_status(Status::New); + self.0.part.display.set_max_status(Status::New); } - part.colors = self.1.highlighter.scheme_colors(); - part.display.configure(&mut cx.size_cx()); + self.0.part.colors = self.1.highlighter.scheme_colors(); + self.0.part.configure(cx, id); self.prepare(cx); } + /// Prepare text for display, as necessary + /// + /// This represents a high-level step of preparation required before + /// displaying text. After the `Part` is [configured](Self::configure), this + /// method should be called before displaying the text. This will advance + /// the [status](ConfiguredDisplay::status) to [`Status::Ready`]. + /// This method must be called again after edits or changes to alignment or + /// the wrap-width. + #[inline] + pub fn prepare(&mut self, cx: &mut ConfigCx) { + self.0.part.prepare(&mut self.1, cx); + } + + /// Measure required vertical height, wrapping as configured + /// + /// Stops after `max_lines`, if provided. + /// + /// May partially prepare the text for display, but does not otherwise + /// modify `self`. + #[inline] + pub fn measure_height(&mut self, wrap_width: f32, max_lines: Option) -> f32 { + self.0 + .part + .measure_height(&mut self.1, wrap_width, max_lines) + } + + /// Handle an event + #[inline] + pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + self.0.part.handle_event(&mut self.1, cx, event) + } + + /// Clear the error state + #[inline] + pub fn clear_error(&mut self) { + self.0.part.clear_error(); + } +} + +impl Part { + /// Get text contents + #[inline] + pub fn as_str(&self) -> &str { + self.text.as_str() + } + + /// True if the editor uses multi-line mode + #[inline] + fn multi_line(&self) -> bool { + self.display.wrap() + } + + /// Get the (horizontal) text direction #[inline] - fn prepare_runs(&mut self) { - fn inner(common: &mut Common, part: &mut Part) { + pub fn text_is_rtl(&self) -> bool { + self.display.text_is_rtl(self.as_str()) + } + + /// Access the cursor index / selection range + #[inline] + pub fn cursor_range(&self) -> CursorRange { + *self.selection + } + + /// Configure component + fn configure(&mut self, cx: &mut ConfigCx, id: Id) { + self.id = id; + self.display.configure(&mut cx.size_cx()); + } + + /// Perform run-breaking and shaping + /// + /// This represents a high-level step of preparation required before + /// displaying text. After the `Part` is [configured](Self::configure), this + /// method should be called before any sizing operations. This will advance + /// the [status](ConfiguredDisplay::status) to [`Status::LevelRuns`]. + /// This method must be called again after any edits to the `Part`'s text. + #[inline] + pub fn prepare_runs(&mut self, common: &mut Common) { + fn inner(part: &mut Part, common: &mut Common) { part.highlight .highlight(&part.text, &mut common.highlighter); let (dpem, font) = (part.display.font_size(), part.display.font()); @@ -277,8 +364,8 @@ impl Component { .prepare_runs(part.text.as_str(), part.highlight.font_tokens(dpem, font)); } - if self.0.part.display.status() < Status::LevelRuns { - inner(&mut self.1, &mut self.0.part) + if self.display.status() < Status::LevelRuns { + inner(self, common); } } @@ -293,36 +380,34 @@ impl Component { /// /// Returns `true` when the size of the bounding-box changes. fn prepare_wrap(&mut self) -> bool { - if self.rect().size.0 == 0 { + if self.display.rect().size.0 == 0 { return false; }; - let bb = self.0.part.display.bounding_box(); - self.0.part.display.prepare_wrap(); - self.0.part.display.ensure_no_left_overhang(); - bb != self.0.part.display.bounding_box() + + let bb = self.display.bounding_box(); + self.display.prepare_wrap(); + self.display.ensure_no_left_overhang(); + bb != self.display.bounding_box() } /// Prepare text for display, as necessary /// - /// Requests a resize when required. - /// - /// Returns `true` on success when some action is performed, `false` - /// when the text is already prepared. + /// This represents a high-level step of preparation required before + /// displaying text. After the `Part` is [configured](Self::configure), this + /// method should be called before displaying the text. This will advance + /// the [status](ConfiguredDisplay::status) to [`Status::Ready`]. + /// This method must be called again after edits or changes to alignment or + /// the wrap-width. #[inline] - pub fn prepare(&mut self, cx: &mut ConfigCx) -> bool { - if self.0.part.display.is_prepared() { - return false; + pub fn prepare(&mut self, common: &mut Common, cx: &mut ConfigCx) { + if self.display.is_prepared() { + return; } - fn inner(this: &mut Component, cx: &mut ConfigCx) { - this.prepare_runs(); - - if this.prepare_wrap() { - cx.resize(); - } + self.prepare_runs(common); + if self.prepare_wrap() { + cx.resize(); } - inner(self, cx); - true } /// Measure required vertical height, wrapping as configured @@ -331,18 +416,21 @@ impl Component { /// /// May partially prepare the text for display, but does not otherwise /// modify `self`. - pub fn measure_height(&mut self, wrap_width: f32, max_lines: Option) -> f32 { - self.prepare_runs(); - self.0 - .part - .display + pub fn measure_height( + &mut self, + common: &mut Common, + wrap_width: f32, + max_lines: Option, + ) -> f32 { + self.prepare_runs(common); + self.display .unchecked_display() .measure_height(wrap_width, max_lines) } /// Implementation of [`Viewport::content_size`] pub fn content_size(&self) -> Size { - if let Ok((tl, br)) = self.0.part.display.bounding_box() { + if let Ok((tl, br)) = self.display.bounding_box() { (br - tl).cast_ceil() } else { Size::ZERO @@ -351,17 +439,16 @@ impl Component { /// Implementation of [`Viewport::draw_with_offset`] pub fn draw_with_offset(&self, mut draw: DrawCx, rect: Rect, offset: Offset) { - let part = &self.0.part; - let Ok(display) = part.display.display() else { + let Ok(display) = self.display.display() else { return; }; - let pos = self.rect().pos - offset; - let range: Range = part.selection.range().cast(); + let pos = self.display.rect().pos - offset; + let range: Range = self.selection.range().cast(); - let color_tokens = part.highlight.color_tokens(); + let color_tokens = self.highlight.color_tokens(); let default_colors = format::Colors { - foreground: part.colors.foreground, + foreground: self.colors.foreground, background: None, }; let mut buf = [(0, default_colors); 3]; @@ -374,17 +461,17 @@ impl Component { } } else if color_tokens.is_empty() { buf[1].0 = range.start; - buf[1].1.foreground = part.colors.selection_foreground; - buf[1].1.background = Some(part.colors.selection_background); + buf[1].1.foreground = self.colors.selection_foreground; + buf[1].1.background = Some(self.colors.selection_background); buf[2].0 = range.end; let r0 = if range.start > 0 { 0 } else { 1 }; &buf[r0..] } else { let set_selection_colors = |colors: &mut format::Colors| { - if colors.foreground == part.colors.foreground { - colors.foreground = part.colors.selection_foreground; + if colors.foreground == self.colors.foreground { + colors.foreground = self.colors.selection_foreground; } - colors.background = Some(part.colors.selection_background); + colors.background = Some(self.colors.selection_background); }; vec.reserve(color_tokens.len() + 2); @@ -443,12 +530,12 @@ impl Component { }; draw.text(pos, rect, display, tokens); - let decorations = part.highlight.decorations(); + let decorations = self.highlight.decorations(); if !decorations.is_empty() { draw.decorate_text(pos, rect, display, decorations); } - if let CurrentAction::ImePreedit { edit_range } = part.current.clone() { + if let CurrentAction::ImePreedit { edit_range } = self.current.clone() { let tokens = [ Default::default(), (edit_range.start, format::Decoration { @@ -461,30 +548,36 @@ impl Component { draw.decorate_text(pos, rect, display, &tokens[r0..]); } - if !part.read_only && draw.ev_state().has_input_focus(self.0.id_ref()) == Some(true) { + if !self.read_only && draw.ev_state().has_input_focus(&self.id) == Some(true) { draw.text_cursor( pos, rect, display, - part.selection.edit_index(), - Some(part.colors.cursor), + self.selection.edit_index(), + Some(self.colors.cursor), ); } } /// Handle an event - pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + #[inline] + pub fn handle_event( + &mut self, + common: &mut Common, + cx: &mut EventCx, + event: Event, + ) -> EventAction { let result = self.handle_event_impl(cx, event); match &result { - &EventAction::Cursor => self.0.part.set_view_offset_from_cursor(cx), + &EventAction::Cursor => self.set_view_offset_from_cursor(cx), &EventAction::Preedit | &EventAction::Edit => { - if !self.0.part.display.is_prepared() { - self.prepare_runs(); + if !self.display.is_prepared() { + self.prepare_runs(common); cx.redraw(); if self.prepare_wrap() { cx.resize(); - self.0.part.set_view_offset_from_cursor(cx); + self.set_view_offset_from_cursor(cx); } } } @@ -494,11 +587,10 @@ impl Component { } fn handle_event_impl(&mut self, cx: &mut EventCx, event: Event) -> EventAction { - let part: &mut Part = &mut self.0.part; match event { Event::NavFocus(source) if source == FocusSource::Key => { - if !part.input_handler.is_selecting() { - part.request_key_focus(cx, source); + if !self.input_handler.is_selecting() { + self.request_key_focus(cx, source); } EventAction::Used } @@ -507,31 +599,31 @@ impl Component { Event::SelFocus(source) => { // NOTE: sel focus implies key focus since we only request // the latter. We must set before calling self.set_primary. - part.has_key_focus = true; + self.has_key_focus = true; if source == FocusSource::Pointer { - part.set_primary(cx); + self.set_primary(cx); } EventAction::Used } Event::KeyFocus => { - part.has_key_focus = true; - part.set_view_offset_from_cursor(cx); + self.has_key_focus = true; + self.set_view_offset_from_cursor(cx); - if part.current.is_none() { + if self.current.is_none() { let hint = Default::default(); let purpose = ImePurpose::Normal; - let surrounding_text = part.ime_surrounding_text(); - cx.replace_ime_focus(part.id.clone(), hint, purpose, surrounding_text); + let surrounding_text = self.ime_surrounding_text(); + cx.replace_ime_focus(self.id.clone(), hint, purpose, surrounding_text); EventAction::FocusGained } else { EventAction::Used } } Event::LostKeyFocus => { - part.has_key_focus = false; + self.has_key_focus = false; cx.redraw(); - if !part.current.is_ime_enabled() { + if !self.current.is_ime_enabled() { EventAction::FocusLost } else { EventAction::Used @@ -539,22 +631,22 @@ impl Component { } Event::LostSelFocus => { // NOTE: we can assume that we will receive Ime::Disabled if IME is active - if !part.selection.is_empty() { - part.save_undo_state(None); - part.selection.set_empty(); + if !self.selection.is_empty() { + self.save_undo_state(None); + self.selection.set_empty(); } - part.input_handler.stop_selecting(); + self.input_handler.stop_selecting(); cx.redraw(); EventAction::Used } - Event::Command(cmd, code) => match part.cmd_action(cx, cmd, code) { + Event::Command(cmd, code) => match self.cmd_action(cx, cmd, code) { Ok(action) => action, Err(NotReady) => EventAction::Used, }, Event::Key(event, false) if event.state == ElementState::Pressed => { if let Some(text) = &event.text { - part.save_undo_state(Some(EditOp::KeyInput)); - if part.received_text(cx, text) == Used { + self.save_undo_state(Some(EditOp::KeyInput)); + if self.received_text(cx, text) == Used { EventAction::Edit } else { EventAction::Unused @@ -565,7 +657,7 @@ impl Component { .shortcuts() .try_match_event(cx.modifiers(), event); if let Some(cmd) = opt_cmd { - match part.cmd_action(cx, cmd, Some(event.physical_key)) { + match self.cmd_action(cx, cmd, Some(event.physical_key)) { Ok(action) => action, Err(NotReady) => EventAction::Used, } @@ -576,81 +668,81 @@ impl Component { } Event::Ime(ime) => match ime { Ime::Enabled => { - match part.current { + match self.current { CurrentAction::None => { - part.current = CurrentAction::ImeStart; - part.set_ime_cursor_area(cx); + self.current = CurrentAction::ImeStart; + self.set_ime_cursor_area(cx); } CurrentAction::ImeStart | CurrentAction::ImePreedit { .. } => { // already enabled } CurrentAction::Selection => { // Do not interrupt selection - cx.cancel_ime_focus(&part.id); + cx.cancel_ime_focus(&self.id); } } - if !part.has_key_focus { + if !self.has_key_focus { EventAction::FocusGained } else { EventAction::Used } } Ime::Disabled => { - part.clear_ime(); - if !part.has_key_focus { + self.clear_ime(); + if !self.has_key_focus { EventAction::FocusLost } else { EventAction::Used } } Ime::Preedit { text, cursor } => { - part.save_undo_state(None); - let mut edit_range = match part.current.clone() { - CurrentAction::ImeStart if cursor.is_some() => part.selection.range(), + self.save_undo_state(None); + let mut edit_range = match self.current.clone() { + CurrentAction::ImeStart if cursor.is_some() => self.selection.range(), CurrentAction::ImeStart => return EventAction::Used, CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; - part.replace_range(edit_range.clone(), text); + self.replace_range(edit_range.clone(), text); edit_range.end = edit_range.start + text.len(); if let Some((start, end)) = cursor { - part.selection.set_sel_index_only(edit_range.start + start); - part.selection.set_edit_index(edit_range.start + end); + self.selection.set_sel_index_only(edit_range.start + start); + self.selection.set_edit_index(edit_range.start + end); } else { - part.selection.set_cursor(edit_range.start + text.len()); + self.selection.set_cursor(edit_range.start + text.len()); } - part.current = CurrentAction::ImePreedit { + self.current = CurrentAction::ImePreedit { edit_range: edit_range.cast(), }; - part.edit_x_coord = None; + self.edit_x_coord = None; EventAction::Preedit } Ime::Commit { text } => { - part.save_undo_state(Some(EditOp::Ime)); - let edit_range = match part.current.clone() { - CurrentAction::ImeStart => part.selection.range(), + self.save_undo_state(Some(EditOp::Ime)); + let edit_range = match self.current.clone() { + CurrentAction::ImeStart => self.selection.range(), CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; - part.replace_range(edit_range.clone(), text); - part.selection.set_cursor(edit_range.start + text.len()); + self.replace_range(edit_range.clone(), text); + self.selection.set_cursor(edit_range.start + text.len()); - part.current = CurrentAction::ImePreedit { - edit_range: part.selection.range().cast(), + self.current = CurrentAction::ImePreedit { + edit_range: self.selection.range().cast(), }; - part.edit_x_coord = None; + self.edit_x_coord = None; EventAction::Edit } Ime::DeleteSurrounding { before_bytes, after_bytes, } => { - part.save_undo_state(None); - let edit_range = match part.current.clone() { - CurrentAction::ImeStart => part.selection.range(), + self.save_undo_state(None); + let edit_range = match self.current.clone() { + CurrentAction::ImeStart => self.selection.range(), CurrentAction::ImePreedit { edit_range } => edit_range.cast(), _ => return EventAction::Used, }; @@ -658,9 +750,9 @@ impl Component { if before_bytes > 0 { let end = edit_range.start; let start = end - before_bytes; - if part.as_str().is_char_boundary(start) { - part.replace_range(start..end, ""); - part.selection.delete_range(start..end); + if self.as_str().is_char_boundary(start) { + self.replace_range(start..end, ""); + self.selection.delete_range(start..end); } else { log::warn!("buggy IME tried to delete range not at char boundary"); } @@ -669,46 +761,46 @@ impl Component { if after_bytes > 0 { let start = edit_range.end; let end = start + after_bytes; - if part.as_str().is_char_boundary(end) { - part.replace_range(start..end, ""); + if self.as_str().is_char_boundary(end) { + self.replace_range(start..end, ""); } else { log::warn!("buggy IME tried to delete range not at char boundary"); } } - if let Some(text) = part.ime_surrounding_text() { - cx.update_ime_surrounding_text(&part.id, text); + if let Some(text) = self.ime_surrounding_text() { + cx.update_ime_surrounding_text(&self.id, text); } EventAction::Used } }, Event::PressStart(press) if press.is_tertiary() => { - match press.grab_click(part.id.clone()).complete(cx) { + match press.grab_click(self.id.clone()).complete(cx) { Unused => EventAction::Unused, Used => EventAction::Used, } } Event::PressEnd { press, .. } if press.is_tertiary() => { - part.set_cursor_from_coord(cx, press.coord); - part.cancel_selection_and_ime(cx); - part.request_key_focus(cx, FocusSource::Pointer); + self.set_cursor_from_coord(cx, press.coord); + self.cancel_selection_and_ime(cx); + self.request_key_focus(cx, FocusSource::Pointer); if let Some(content) = cx.get_primary() { - part.save_undo_state(Some(EditOp::Clipboard)); + self.save_undo_state(Some(EditOp::Clipboard)); - let index = part.selection.edit_index(); - let range = part.trim_paste(&content); + let index = self.selection.edit_index(); + let range = self.trim_paste(&content); - part.replace_range(index..index, &content[range.clone()]); - part.selection.set_cursor(index + range.len()); - part.edit_x_coord = None; + self.replace_range(index..index, &content[range.clone()]); + self.selection.set_cursor(index + range.len()); + self.edit_x_coord = None; EventAction::Edit } else { EventAction::Used } } - event => match part.input_handler.handle(cx, part.id.clone(), event) { + event => match self.input_handler.handle(cx, self.id.clone(), event) { TextInputAction::Used => EventAction::Used, TextInputAction::Unused => EventAction::Unused, TextInputAction::PressStart { @@ -716,49 +808,49 @@ impl Component { clear, repeats, } => { - if part.current.is_ime_enabled() { - part.clear_ime(); - cx.cancel_ime_focus(&part.id); + if self.current.is_ime_enabled() { + self.clear_ime(); + cx.cancel_ime_focus(&self.id); } - part.save_undo_state(Some(EditOp::Cursor)); - part.current = CurrentAction::Selection; + self.save_undo_state(Some(EditOp::Cursor)); + self.current = CurrentAction::Selection; - part.set_cursor_from_coord(cx, coord); - part.selection.set_anchor(clear); + self.set_cursor_from_coord(cx, coord); + self.selection.set_anchor(clear); if repeats > 1 { - part.selection - .expand(part.text.as_str(), &part.display, repeats >= 3); + self.selection + .expand(self.text.as_str(), &self.display, repeats >= 3); } - part.request_key_focus(cx, FocusSource::Pointer); + self.request_key_focus(cx, FocusSource::Pointer); EventAction::Used } TextInputAction::PressMove { coord, repeats } => { - if part.current == CurrentAction::Selection { - part.set_cursor_from_coord(cx, coord); + if self.current == CurrentAction::Selection { + self.set_cursor_from_coord(cx, coord); if repeats > 1 { - part.selection - .expand(part.text.as_str(), &part.display, repeats >= 3); + self.selection + .expand(self.text.as_str(), &self.display, repeats >= 3); } } EventAction::Used } TextInputAction::PressEnd { coord } => { - if part.current.is_ime_enabled() { - part.clear_ime(); - cx.cancel_ime_focus(&part.id); + if self.current.is_ime_enabled() { + self.clear_ime(); + cx.cancel_ime_focus(&self.id); } - part.save_undo_state(Some(EditOp::Cursor)); - if part.current == CurrentAction::Selection { - part.set_primary(cx); + self.save_undo_state(Some(EditOp::Cursor)); + if self.current == CurrentAction::Selection { + self.set_primary(cx); } else { - part.set_cursor_from_coord(cx, coord); - part.selection.set_empty(); + self.set_cursor_from_coord(cx, coord); + self.selection.set_empty(); } - part.current = CurrentAction::None; + self.current = CurrentAction::None; - part.request_key_focus(cx, FocusSource::Pointer); + self.request_key_focus(cx, FocusSource::Pointer); EventAction::Used } }, @@ -768,27 +860,7 @@ impl Component { /// Clear the error state #[inline] pub fn clear_error(&mut self) { - self.0.part.error_state = None; - } -} - -impl Part { - /// Get text contents - #[inline] - fn as_str(&self) -> &str { - self.text.as_str() - } - - /// True if the editor uses multi-line mode - #[inline] - fn multi_line(&self) -> bool { - self.display.wrap() - } - - /// Get the (horizontal) text direction - #[inline] - fn text_is_rtl(&self) -> bool { - self.display.text_is_rtl(self.as_str()) + self.error_state = None; } /// Insert a `text` at the given position From 9c07c042ea732419df0c95791332ff32d1817450 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sun, 22 Mar 2026 12:28:27 +0000 Subject: [PATCH 06/11] Prepare runs in editor::Component::configure, not size_rules --- crates/kas-widgets/src/edit/editor.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 5e6fb1e20..ce947068a 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -189,7 +189,6 @@ impl Layout for Component { #[inline] fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { - self.0.part.prepare_runs(&mut self.1); self.0.part.display.size_rules(cx, axis) } @@ -271,10 +270,10 @@ impl Component { if self.1.highlighter.configure(cx) { self.0.part.display.set_max_status(Status::New); } + self.0.part.prepare_runs(&mut self.1); self.0.part.colors = self.1.highlighter.scheme_colors(); self.0.part.configure(cx, id); - self.prepare(cx); } /// Prepare text for display, as necessary From bc441bddd99df5e39d03be3b404c1f86b063e3fd Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Mon, 23 Mar 2026 11:42:57 +0000 Subject: [PATCH 07/11] Add fn configure for Common, Part --- crates/kas-widgets/src/edit/editor.rs | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index ce947068a..863bb820c 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -33,6 +33,11 @@ use std::num::NonZeroUsize; use std::ops::{Deref, DerefMut}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; +/// Action: text parts should have their status reset to [`Status::New`] and be re-prepared +#[must_use] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct ActionResetStatus; + /// Result type of [`Component::handle_event`] pub enum EventAction { /// Key not used, no action @@ -150,6 +155,17 @@ impl Common { pub fn set_highlighter(&mut self, highlighter: H) { self.highlighter = highlighter; } + + /// Configure `Common` data + #[inline] + #[must_use] + pub fn configure(&mut self, cx: &mut ConfigCx) -> Option { + if self.highlighter.configure(cx) { + Some(ActionResetStatus) + } else { + None + } + } } /// Editor component @@ -267,13 +283,10 @@ impl Component { /// Configure component #[inline] pub fn configure(&mut self, cx: &mut ConfigCx, id: Id) { - if self.1.highlighter.configure(cx) { + if let Some(ActionResetStatus) = self.1.configure(cx) { self.0.part.display.set_max_status(Status::New); } - self.0.part.prepare_runs(&mut self.1); - - self.0.part.colors = self.1.highlighter.scheme_colors(); - self.0.part.configure(cx, id); + self.0.part.configure(&mut self.1, cx, id); } /// Prepare text for display, as necessary @@ -341,9 +354,13 @@ impl Part { } /// Configure component - fn configure(&mut self, cx: &mut ConfigCx, id: Id) { + /// + /// [`Common::configure`] must be called before this method. + pub fn configure(&mut self, common: &mut Common, cx: &mut ConfigCx, id: Id) { self.id = id; + self.colors = common.highlighter.scheme_colors(); self.display.configure(&mut cx.size_cx()); + self.prepare_runs(common); } /// Perform run-breaking and shaping From 770d3d4b7d96405cc5d7590f0a9bde5077bf1e3c Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Mon, 23 Mar 2026 11:44:23 +0000 Subject: [PATCH 08/11] Make fn Part::measure_height fallible --- crates/kas-widgets/src/edit/editor.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 863bb820c..cf91a31ec 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -310,9 +310,8 @@ impl Component { /// modify `self`. #[inline] pub fn measure_height(&mut self, wrap_width: f32, max_lines: Option) -> f32 { - self.0 - .part - .measure_height(&mut self.1, wrap_width, max_lines) + self.0.part.prepare_runs(&mut self.1); + self.0.part.measure_height(wrap_width, max_lines).unwrap() } /// Handle an event @@ -430,18 +429,13 @@ impl Part { /// /// Stops after `max_lines`, if provided. /// - /// May partially prepare the text for display, but does not otherwise - /// modify `self`. - pub fn measure_height( + /// [`Self::prepare_runs`] should be called before this. + pub fn measure_height( &mut self, - common: &mut Common, wrap_width: f32, max_lines: Option, - ) -> f32 { - self.prepare_runs(common); - self.display - .unchecked_display() - .measure_height(wrap_width, max_lines) + ) -> Result { + self.display.measure_height(wrap_width, max_lines) } /// Implementation of [`Viewport::content_size`] From 3658aff251938af25e1e5a74760e9362f5a5f4c5 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 24 Mar 2026 09:56:16 +0000 Subject: [PATCH 09/11] editor: rename fn prepare -> prepare_and_scroll --- crates/kas-widgets/src/edit/edit_field.rs | 24 +++++------ crates/kas-widgets/src/edit/editor.rs | 49 +++++++++++++++-------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index e9dbe95cf..0cab478c6 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -174,34 +174,32 @@ mod EditBoxCore { self.guard.update(&mut self.editor.0, cx, data); } - self.editor.prepare(cx); + self.editor.prepare(); } fn handle_event(&mut self, cx: &mut EventCx, data: &G::Data, event: Event) -> IsUsed { + let mut result = Used; match self.editor.handle_event(cx, event) { - EventAction::Unused => Unused, - EventAction::Used | EventAction::Cursor | EventAction::Preedit => Used, + EventAction::Unused => return Unused, + EventAction::Used | EventAction::Cursor | EventAction::Preedit => return Used, EventAction::FocusGained => { self.guard.focus_gained(&mut self.editor.0, cx, data); - self.editor.prepare(cx); - Used } EventAction::FocusLost => { self.guard.focus_lost(&mut self.editor.0, cx, data); - self.editor.prepare(cx); - Used } EventAction::Activate(code) => { cx.depress_with_key(&self, code); - let result = self.guard.activate(&mut self.editor.0, cx, data); - self.editor.prepare(cx); - result + result = self.guard.activate(&mut self.editor.0, cx, data); } EventAction::Edit => { self.call_guard_edit(cx, data); - Used + return Used; } } + + self.editor.prepare_and_scroll(cx); + result } fn handle_messages(&mut self, cx: &mut EventCx, data: &G::Data) { @@ -278,7 +276,7 @@ mod EditBoxCore { #[inline] pub fn call_guard_activate(&mut self, cx: &mut EventCx, data: &G::Data) { self.guard.activate(&mut self.editor.0, cx, data); - self.editor.prepare(cx); + self.editor.prepare_and_scroll(cx); } /// Call the [`EditGuard`]'s `edit` method @@ -288,7 +286,7 @@ mod EditBoxCore { fn call_guard_edit(&mut self, cx: &mut EventCx, data: &G::Data) { self.editor.clear_error(); self.guard.edit(&mut self.editor.0, cx, data); - self.editor.prepare(cx); + self.editor.prepare_and_scroll(cx); } } } diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index cf91a31ec..c539e3e3d 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -289,17 +289,33 @@ impl Component { self.0.part.configure(&mut self.1, cx, id); } - /// Prepare text for display, as necessary + /// Fully prepare text for display /// - /// This represents a high-level step of preparation required before - /// displaying text. After the `Part` is [configured](Self::configure), this - /// method should be called before displaying the text. This will advance - /// the [status](ConfiguredDisplay::status) to [`Status::Ready`]. - /// This method must be called again after edits or changes to alignment or - /// the wrap-width. + /// This method performs all required steps of preparation according to the + /// [status](ConfiguredDisplay::status) (which is advanced to + /// [`Status::Ready`]). + /// + /// It is usually preferable to call [`Self::prepare_and_scroll`] after + /// edits to the text to trigger any required resizing and scrolling. #[inline] - pub fn prepare(&mut self, cx: &mut ConfigCx) { - self.0.part.prepare(&mut self.1, cx); + pub fn prepare(&mut self) { + if self.0.part.display.is_prepared() { + return; + } + + self.0.part.prepare_runs(&mut self.1); + self.0.part.prepare_wrap(); + } + + /// Fully prepare text for display, ensuring the cursor is within view + /// + /// This method performs all required steps of preparation according to the + /// [status](ConfiguredDisplay::status) (which is advanced to + /// [`Status::Ready`]). This method should be called after changes to the + /// text, alignment or wrap-width. + #[inline] + pub fn prepare_and_scroll(&mut self, cx: &mut EventCx) { + self.0.part.prepare_and_scroll(&mut self.1, cx); } /// Measure required vertical height, wrapping as configured @@ -405,16 +421,14 @@ impl Part { bb != self.display.bounding_box() } - /// Prepare text for display, as necessary + /// Fully prepare text for display, ensuring the cursor is within view /// - /// This represents a high-level step of preparation required before - /// displaying text. After the `Part` is [configured](Self::configure), this - /// method should be called before displaying the text. This will advance - /// the [status](ConfiguredDisplay::status) to [`Status::Ready`]. - /// This method must be called again after edits or changes to alignment or - /// the wrap-width. + /// This method performs all required steps of preparation according to the + /// [status](ConfiguredDisplay::status) (which is advanced to + /// [`Status::Ready`]). This method should be called after changes to the + /// text, alignment or wrap-width. #[inline] - pub fn prepare(&mut self, common: &mut Common, cx: &mut ConfigCx) { + pub fn prepare_and_scroll(&mut self, common: &mut Common, cx: &mut EventCx) { if self.display.is_prepared() { return; } @@ -422,6 +436,7 @@ impl Part { self.prepare_runs(common); if self.prepare_wrap() { cx.resize(); + self.set_view_offset_from_cursor(cx); } } From 82c3f55cb0bccb3a3b45b5f58e0c29de975a80a2 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Mon, 23 Mar 2026 12:12:20 +0000 Subject: [PATCH 10/11] editor::Part: do not re-prepare within fn handle_event Motivation: this allows calling without Common --- crates/kas-widgets/src/edit/editor.rs | 57 +++++++++++++-------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index c539e3e3d..f5da1b986 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -58,6 +58,13 @@ pub enum EventAction { Edit, } +impl EventAction { + /// If true, text has been edited and must be re-prepared. + pub fn requires_repreparation(&self) -> bool { + matches!(self, EventAction::Preedit | EventAction::Edit) + } +} + /// A text part for usage by an editor /// /// ### Special behaviour @@ -333,7 +340,11 @@ impl Component { /// Handle an event #[inline] pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { - self.0.part.handle_event(&mut self.1, cx, event) + let action = self.0.part.handle_event(cx, event); + if action.requires_repreparation() { + self.0.part.prepare_and_scroll(&mut self.1, cx); + } + action } /// Clear the error state @@ -585,33 +596,11 @@ impl Part { } /// Handle an event + /// + /// If [`EventAction::requires_repreparation`] then the caller **must** call + /// re-prepare the text by calling [`Self::prepare_and_scroll`]. #[inline] - pub fn handle_event( - &mut self, - common: &mut Common, - cx: &mut EventCx, - event: Event, - ) -> EventAction { - let result = self.handle_event_impl(cx, event); - match &result { - &EventAction::Cursor => self.set_view_offset_from_cursor(cx), - &EventAction::Preedit | &EventAction::Edit => { - if !self.display.is_prepared() { - self.prepare_runs(common); - - cx.redraw(); - if self.prepare_wrap() { - cx.resize(); - self.set_view_offset_from_cursor(cx); - } - } - } - _ => (), - } - result - } - - fn handle_event_impl(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { match event { Event::NavFocus(source) if source == FocusSource::Key => { if !self.input_handler.is_selecting() { @@ -665,7 +654,12 @@ impl Part { EventAction::Used } Event::Command(cmd, code) => match self.cmd_action(cx, cmd, code) { - Ok(action) => action, + Ok(action) => { + if matches!(action, EventAction::Cursor) { + self.set_view_offset_from_cursor(cx); + } + action + } Err(NotReady) => EventAction::Used, }, Event::Key(event, false) if event.state == ElementState::Pressed => { @@ -683,7 +677,12 @@ impl Part { .try_match_event(cx.modifiers(), event); if let Some(cmd) = opt_cmd { match self.cmd_action(cx, cmd, Some(event.physical_key)) { - Ok(action) => action, + Ok(action) => { + if matches!(action, EventAction::Cursor) { + self.set_view_offset_from_cursor(cx); + } + action + } Err(NotReady) => EventAction::Used, } } else { From 2697918189c6798d1029dde607721113db4db880 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 24 Mar 2026 11:28:31 +0000 Subject: [PATCH 11/11] Clippy --- crates/kas-core/src/event/cx/window.rs | 18 ++++++++---------- crates/kas-widgets/src/edit/highlight/cache.rs | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/kas-core/src/event/cx/window.rs b/crates/kas-core/src/event/cx/window.rs index 9123351c6..8e3f95252 100644 --- a/crates/kas-core/src/event/cx/window.rs +++ b/crates/kas-core/src/event/cx/window.rs @@ -420,16 +420,14 @@ impl<'a> EventCx<'a> { } _ => (), }, - PointerEntered { kind, .. } => { - if kind == PointerKind::Mouse { - self.handle_pointer_entered() - } - } - PointerLeft { kind, .. } => { - if kind == PointerKind::Mouse { - self.handle_pointer_left(win.as_node(data)) - } - } + PointerEntered { + kind: PointerKind::Mouse, + .. + } => self.handle_pointer_entered(), + PointerLeft { + kind: PointerKind::Mouse, + .. + } => self.handle_pointer_left(win.as_node(data)), MouseWheel { delta, .. } => self.handle_mouse_wheel(win.as_node(data), delta), PointerButton { state, diff --git a/crates/kas-widgets/src/edit/highlight/cache.rs b/crates/kas-widgets/src/edit/highlight/cache.rs index ef7cc2a6c..89cf93386 100644 --- a/crates/kas-widgets/src/edit/highlight/cache.rs +++ b/crates/kas-widgets/src/edit/highlight/cache.rs @@ -86,7 +86,7 @@ impl Cache { } pub fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { - self.fonts.iter().cloned().map(move |fmt| FontToken { + self.fonts.iter().map(move |fmt| FontToken { start: fmt.start, dpem, font: FontSelector {