From 938a698590356a2192a88c0ce6605dd073f31f19 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 09:23:25 +0000 Subject: [PATCH 1/8] Let fn SelectionHelper::expand take a dyn Fn --- crates/kas-core/src/text/display.rs | 1 + crates/kas-core/src/text/selection.rs | 21 +++++++++++---------- crates/kas-widgets/src/edit/editor.rs | 14 ++++++++++---- crates/kas-widgets/src/scroll_label.rs | 14 ++++++++++---- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/kas-core/src/text/display.rs b/crates/kas-core/src/text/display.rs index 48940921a..e43225fdd 100644 --- a/crates/kas-core/src/text/display.rs +++ b/crates/kas-core/src/text/display.rs @@ -514,6 +514,7 @@ impl ConfiguredDisplay { /// Find the line containing text `index` /// + /// Returns the line number and the text-range of the line. /// See [`TextDisplay::find_line`]. #[inline] pub fn find_line( diff --git a/crates/kas-core/src/text/selection.rs b/crates/kas-core/src/text/selection.rs index 18770f660..3890284ae 100644 --- a/crates/kas-core/src/text/selection.rs +++ b/crates/kas-core/src/text/selection.rs @@ -5,7 +5,6 @@ //! Tools for text selection -use super::ConfiguredDisplay; use kas_macros::autoimpl; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; @@ -182,7 +181,15 @@ impl SelectionHelper { /// /// The selection is expanded by words or lines (if `lines`). Line expansion /// requires that text has been prepared (see [`Text::prepare`][super::Text::prepare]). - pub fn expand(&mut self, text: &str, display: &ConfiguredDisplay, lines: bool) { + /// + /// Input `line_range` should map a text index to the range of the enclosing + /// line (if available). + pub fn expand( + &mut self, + text: &str, + line_range: &dyn Fn(usize) -> Option>, + lines: bool, + ) { let mut range = self.edit..self.anchor; if range.start > range.end { std::mem::swap(&mut range.start, &mut range.end); @@ -207,14 +214,8 @@ impl SelectionHelper { }) .unwrap_or(text.len()); } else { - start = match display.find_line(range.start) { - Ok(Some(r)) => r.1.start, - _ => 0, - }; - end = match display.find_line(range.end) { - Ok(Some(r)) => r.1.end, - _ => text.len(), - }; + start = line_range(range.start).map(|r| r.start).unwrap_or(0); + end = line_range(range.end).map(|r| r.end).unwrap_or(text.len()); } if self.edit < self.sel { diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 451a289af..871217766 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -870,8 +870,11 @@ impl Part { self.set_cursor_from_coord(cx, coord); self.selection.set_anchor(clear); if repeats > 1 { - self.selection - .expand(self.text.as_str(), &self.display, repeats >= 3); + self.selection.expand( + self.text.as_str(), + &|index| self.display.find_line(index).ok().flatten().map(|r| r.1), + repeats >= 3, + ); } self.request_key_focus(cx, FocusSource::Pointer); @@ -881,8 +884,11 @@ impl Part { if self.current == CurrentAction::Selection { self.set_cursor_from_coord(cx, coord); if repeats > 1 { - self.selection - .expand(self.text.as_str(), &self.display, repeats >= 3); + self.selection.expand( + self.text.as_str(), + &|index| self.display.find_line(index).ok().flatten().map(|r| r.1), + repeats >= 3, + ); } } diff --git a/crates/kas-widgets/src/scroll_label.rs b/crates/kas-widgets/src/scroll_label.rs index a38db0a36..8b23f972e 100644 --- a/crates/kas-widgets/src/scroll_label.rs +++ b/crates/kas-widgets/src/scroll_label.rs @@ -300,8 +300,11 @@ mod ScrollTextCore { self.set_cursor_from_coord(cx, coord); self.selection.set_anchor(clear); if repeats > 1 { - self.selection - .expand(self.text.as_str(), &self.text, repeats >= 3); + self.selection.expand( + self.text.as_str(), + &|index| self.text.find_line(index).ok().flatten().map(|r| r.1), + repeats >= 3, + ); } if !self.has_sel_focus { @@ -312,8 +315,11 @@ mod ScrollTextCore { TextInputAction::PressMove { coord, repeats } => { self.set_cursor_from_coord(cx, coord); if repeats > 1 { - self.selection - .expand(self.text.as_str(), &self.text, repeats >= 3); + self.selection.expand( + self.text.as_str(), + &|index| self.text.find_line(index).ok().flatten().map(|r| r.1), + repeats >= 3, + ); } Used } From 69c832c01746927fc4790c2e66a2474cec14b51f Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 10:22:25 +0000 Subject: [PATCH 2/8] Fix TextClass for editors --- crates/kas-widgets/src/edit/edit_box.rs | 10 +--------- crates/kas-widgets/src/edit/edit_field.rs | 10 +--------- crates/kas-widgets/src/edit/editor.rs | 6 ------ crates/kas-widgets/src/spin_box.rs | 16 ---------------- 4 files changed, 2 insertions(+), 40 deletions(-) diff --git a/crates/kas-widgets/src/edit/edit_box.rs b/crates/kas-widgets/src/edit/edit_box.rs index b13c7d01e..369e00ea7 100644 --- a/crates/kas-widgets/src/edit/edit_box.rs +++ b/crates/kas-widgets/src/edit/edit_box.rs @@ -11,7 +11,7 @@ use crate::{ScrollBar, ScrollBarMsg}; use kas::event::Scroll; use kas::event::components::ScrollComponent; use kas::prelude::*; -use kas::theme::{FrameStyle, TextClass}; +use kas::theme::FrameStyle; use std::fmt::{Debug, Display}; use std::str::FromStr; @@ -420,14 +420,6 @@ impl EditBox { self } - /// Set the text class used - #[inline] - #[must_use] - pub fn with_class(mut self, class: TextClass) -> Self { - self.inner = self.inner.with_class(class); - self - } - /// Adjust the height allocation #[inline] pub fn set_lines(&mut self, min_lines: f32, ideal_lines: f32) { diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index 19a7462a2..4059b5956 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -83,7 +83,7 @@ mod EditBoxCore { fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { let (min, mut ideal): (i32, i32); if axis.is_horizontal() { - let dpem = cx.dpem(self.class()); + let dpem = cx.dpem(TextClass::Editor); min = (self.width.0 * dpem).cast_ceil(); ideal = (self.width.1 * dpem).cast_ceil(); } else if let Some(width) = axis.other() { @@ -336,14 +336,6 @@ impl EditBoxCore { self } - /// Set the text class used - #[inline] - #[must_use] - pub fn with_class(mut self, class: TextClass) -> Self { - self.editor.set_class(class); - self - } - /// Adjust the height allocation #[inline] pub fn set_lines(&mut self, min_lines: f32, ideal_lines: f32) { diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 871217766..98836feab 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -1608,12 +1608,6 @@ impl Editor { self.part.display.wrap() } - /// Get the text class used - #[inline] - pub fn class(&self) -> TextClass { - self.part.display.class() - } - /// Get whether the widget has input focus /// /// This is true when the widget is has keyboard or IME focus. diff --git a/crates/kas-widgets/src/spin_box.rs b/crates/kas-widgets/src/spin_box.rs index e24337b39..4d9518d7f 100644 --- a/crates/kas-widgets/src/spin_box.rs +++ b/crates/kas-widgets/src/spin_box.rs @@ -298,22 +298,6 @@ mod SpinBox { self } - /// Set the text class used - /// - /// The default is: `TextClass::Edit(false)`. - #[inline] - #[must_use] - pub fn with_class(mut self, class: TextClass) -> Self { - self.edit = self.edit.with_class(class); - self - } - - /// Get the text class used - #[inline] - pub fn class(&self) -> TextClass { - self.edit.class() - } - /// Adjust the width allocation #[inline] pub fn set_width_em(&mut self, min_em: f32, ideal_em: f32) { From c28ff79525a8dac92a02bfa13015dd7b3267b802 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 11:52:26 +0000 Subject: [PATCH 3/8] Update kas-text and use ensure_non_negative_alignment --- Cargo.toml | 2 +- crates/kas-core/src/text/display.rs | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fa4fdda06..5f8df9a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,7 +175,7 @@ resolver = "2" [patch.crates-io.kas-text] git = "https://github.com/kas-gui/kas-text.git" -rev = "a087ae3c083fb8e7f73282514b556ecc9083c95a" +rev = "367ba7e0db5f1b890e3563773cf96c0dd49aac2c" [patch.crates-io.impl-tools-lib] git = "https://github.com/kas-gui/impl-tools.git" diff --git a/crates/kas-core/src/text/display.rs b/crates/kas-core/src/text/display.rs index e43225fdd..052a825ef 100644 --- a/crates/kas-core/src/text/display.rs +++ b/crates/kas-core/src/text/display.rs @@ -471,12 +471,9 @@ impl ConfiguredDisplay { /// using the size reported by [`Self::bounding_box`]. Note that while /// vertical alignment is untouched by this method, text is never aligned /// above the top (the first y-component is never negative). + #[inline] pub fn ensure_no_left_overhang(&mut self) { - if let Ok((tl, _)) = self.bounding_box() - && tl.0 < 0.0 - { - self.display.apply_offset(kas_text::Vec2(-tl.0, 0.0)); - } + self.display.ensure_non_negative_alignment(); } /// Get the size of the required bounding box From fdf38145c31e9ed4d47aebdb42009f667ab7a7bd Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 09:32:21 +0000 Subject: [PATCH 4/8] Integrate ConfiguredDisplay into editor::Part --- crates/kas-widgets/src/edit/editor.rs | 374 +++++++++++++++++--------- 1 file changed, 252 insertions(+), 122 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 98836feab..6faec45a1 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -24,13 +24,13 @@ use kas::event::{ use kas::geom::{Rect, Vec2}; use kas::layout::{AlignHints, AxisInfo, SizeRules}; use kas::prelude::*; -use kas::text::{ConfiguredDisplay, CursorRange, NotReady, SelectionHelper, Status, format}; +use kas::text::fonts::FontSelector; +use kas::text::{CursorRange, Direction, NotReady, SelectionHelper, Status, TextDisplay, format}; use kas::theme::{Background, DrawCx, SizeCx, TextClass}; use kas::util::UndoStack; use kas::{Layout, autoimpl}; use std::borrow::Cow; 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 @@ -128,12 +128,17 @@ impl Common { /// cannot implement [`Viewport`] directly, but it does provide the following /// methods: [`Self::content_size`], [`Self::draw_with_offset`]. #[autoimpl(Debug)] -#[autoimpl(Deref, DerefMut using self.display)] pub struct Part { // TODO(opt): id is duplicated here since macros don't let us put the core here id: Id, + font: FontSelector, + dpem: f32, + direction: Direction, + wrap: bool, read_only: bool, - display: ConfiguredDisplay, + rect: Rect, + status: Status, + display: TextDisplay, highlight: highlight::Cache, text: String, selection: SelectionHelper, @@ -171,37 +176,20 @@ pub struct Editor { #[derive(Debug)] pub struct Component(pub Editor, pub Common); -impl Deref for Component { - type Target = ConfiguredDisplay; - fn deref(&self) -> &Self::Target { - &self.0.part.display - } -} - -impl DerefMut for Component { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0.part.display - } -} - impl Layout for Component { #[inline] fn rect(&self) -> Rect { - self.0.part.display.rect() + self.0.part.rect } #[inline] fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { - self.0.part.display.size_rules(cx, axis) + self.0.part.size_rules(cx, axis) } - fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect, hints: AlignHints) { - 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); - } + #[inline] + fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect, _: AlignHints) { + self.0.part.set_rect(cx, rect); } #[inline] @@ -226,6 +214,13 @@ impl Component { Component(editor, Common::default()) } + /// Set whether long lines are automatically wrapped + #[inline] + pub fn set_wrap(&mut self, wrap: bool) { + self.0.part.wrap = wrap; + self.0.part.status = Status::New; + } + /// Replace the highlighter #[inline] pub fn with_highlighter(self, highlighter: H2) -> Component

{ @@ -273,7 +268,7 @@ impl Component { #[inline] pub fn configure(&mut self, cx: &mut ConfigCx, id: Id) { if let Some(ActionResetStatus) = self.1.configure(cx) { - self.0.part.display.require_reprepare(); + self.0.part.require_reprepare(); } self.0.part.configure(&mut self.1, cx, id); } @@ -288,7 +283,7 @@ impl Component { /// edits to the text to trigger any required resizing and scrolling. #[inline] pub fn prepare(&mut self) { - if self.0.part.display.is_prepared() { + if self.0.part.is_prepared() { return; } @@ -316,7 +311,7 @@ impl Component { #[inline] pub fn measure_height(&mut self, wrap_width: f32, max_lines: Option) -> f32 { self.0.part.prepare_runs(&mut self.1); - self.0.part.measure_height(wrap_width, max_lines).unwrap() + self.0.part.display.measure_height(wrap_width, max_lines) } /// Implementation of [`Viewport::draw_with_offset`] @@ -350,8 +345,14 @@ impl Part { pub fn new(wrap: bool) -> Self { Part { id: Id::default(), + font: FontSelector::default(), + dpem: 16.0, + direction: Direction::Auto, + wrap, read_only: false, - display: ConfiguredDisplay::new(TextClass::Editor, wrap), + rect: Rect::ZERO, + status: Status::New, + display: TextDisplay::default(), highlight: Default::default(), text: Default::default(), selection: Default::default(), @@ -384,16 +385,30 @@ impl Part { 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 + /// Get the base directionality of the text + /// + /// This does not require that the text is prepared. #[inline] pub fn text_is_rtl(&self) -> bool { - self.display.text_is_rtl(self.as_str()) + let mut cached_is_rtl = None; + if self.status >= Status::Wrapped { + cached_is_rtl = match self.display.line_is_rtl(0) { + None => Some(self.direction == Direction::Rtl), + Some(is_rtl) => Some(is_rtl), + }; + }; + + #[cfg(not(debug_assertions))] + if let Some(cached) = cached_is_rtl { + return cached; + } + + let text = self.as_str(); + let is_rtl = self.display.text_is_rtl(text, self.direction); + if let Some(cached) = cached_is_rtl { + debug_assert_eq!(cached, is_rtl); + } + is_rtl } /// Access the cursor index / selection range @@ -402,12 +417,36 @@ impl Part { *self.selection } + /// Check whether the text is fully prepared and ready for usage + #[inline] + pub fn is_prepared(&self) -> bool { + self.status == Status::Ready + } + + /// Force full repreparation of text + /// + /// This may be required after calling [`Text::text_mut`](super::Text::text_mut). + #[inline] + pub fn require_reprepare(&mut self) { + self.status = Status::New; + } + /// Configure component /// /// [`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.display.configure(&mut cx.size_cx()); + let cx = cx.size_cx(); + let font = cx.font(TextClass::Editor); + let dpem = cx.dpem(TextClass::Editor); + if font != self.font { + self.font = font; + self.dpem = dpem; + self.status = Status::New; + } else if dpem != self.dpem { + self.dpem = dpem; + self.status = self.status.min(Status::ResizeLevelRuns); + } self.prepare_runs(common); } @@ -423,16 +462,79 @@ impl Part { 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()); - part.display - .prepare_runs(part.text.as_str(), part.highlight.font_tokens(dpem, font)); + + let text = part.text.as_str(); + let font_tokens = part.highlight.font_tokens(part.dpem, part.font); + match part.status { + Status::New => part + .display + .prepare_runs(text, part.direction, font_tokens) + .expect("no suitable font found"), + Status::ResizeLevelRuns => part.display.resize_runs(text, font_tokens), + _ => return, + } + + part.status = Status::LevelRuns; } - if self.display.status() < Status::LevelRuns { + if self.status < Status::LevelRuns { inner(self, common); } } + /// Solve size rules + pub fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { + let rules = if axis.is_horizontal() { + let mut bound = 0i32; + if self.wrap { + let (min, ideal) = cx.wrapped_line_len(TextClass::Editor, self.dpem); + if self.status >= Status::LevelRuns { + bound = self.display.measure_width(ideal.cast()).cast_ceil(); + } + SizeRules::new(bound.min(min), bound.min(ideal), Stretch::Filler) + } else { + if self.status >= Status::LevelRuns { + bound = self.display.measure_width(f32::INFINITY).cast_ceil(); + } + SizeRules::new(bound, bound, Stretch::Filler) + } + } else { + let wrap_width = self + .wrap + .then(|| axis.other().map(|w| w.cast())) + .flatten() + .unwrap_or(f32::INFINITY); + let mut bound = 0i32; + if self.status >= Status::LevelRuns { + bound = self.display.measure_height(wrap_width, None).cast_ceil(); + } + SizeRules::new(bound, bound, Stretch::Filler) + }; + + rules.with_margins(cx.text_margins().extract(axis)) + } + + /// Set rect + /// + /// This `rect` is stored and available through [`Self::rect`]. + /// + /// Note that editors always use default alignment of content. + pub fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect) { + if rect.size != self.rect.size { + if rect.size.0 != self.rect.size.0 { + self.status = self.status.min(Status::LevelRuns); + } else { + self.status = self.status.min(Status::Wrapped); + } + } + self.rect = rect; + + self.prepare_wrap(); + if self.current.is_ime_enabled() { + self.set_ime_cursor_area(cx); + } + } + /// Perform line wrapping and alignment /// /// This represents a high-level step of preparation required before @@ -444,13 +546,26 @@ impl Part { /// /// Returns `true` when the size of the bounding-box changes. fn prepare_wrap(&mut self) -> bool { - if self.display.rect().size.0 == 0 { + if self.status < Status::LevelRuns || self.rect.size.0 == 0 { return false; }; let bb = self.display.bounding_box(); - self.display.prepare_wrap(); - self.display.ensure_no_left_overhang(); + + if self.status == Status::LevelRuns { + let align_width = self.rect.size.0.cast(); + let wrap_width = if !self.wrap { f32::INFINITY } else { align_width }; + self.display + .prepare_lines(wrap_width, align_width, Align::Default); + self.display.ensure_non_negative_alignment(); + } + + if self.status <= Status::Wrapped { + let h = self.rect.size.1.cast(); + self.display.vertically_align(h, Align::Default); + } + + self.status = Status::Ready; bb != self.display.bounding_box() } @@ -462,7 +577,7 @@ impl Part { /// text, alignment or wrap-width. #[inline] pub fn prepare_and_scroll(&mut self, common: &mut Common, cx: &mut EventCx) { - if self.display.is_prepared() { + if self.is_prepared() { return; } @@ -471,6 +586,7 @@ impl Part { cx.resize(); self.set_view_offset_from_cursor(cx); } + cx.redraw(); } /// Measure required vertical height, wrapping as configured @@ -483,16 +599,21 @@ impl Part { wrap_width: f32, max_lines: Option, ) -> Result { - self.display.measure_height(wrap_width, max_lines) + if self.status >= Status::LevelRuns { + Ok(self.display.measure_height(wrap_width, max_lines)) + } else { + Err(NotReady) + } } /// Implementation of [`Viewport::content_size`] pub fn content_size(&self) -> Size { - if let Ok((tl, br)) = self.display.bounding_box() { - (br - tl).cast_ceil() - } else { - Size::ZERO + if self.status < Status::Wrapped { + return Size::ZERO; } + + let (tl, br) = self.display.bounding_box(); + (Vec2::from(br) - Vec2::from(tl)).cast_ceil() } /// Implementation of [`Viewport::draw_with_offset`] @@ -503,11 +624,11 @@ impl Part { rect: Rect, offset: Offset, ) { - let Ok(display) = self.display.display() else { + if !self.is_prepared() { return; - }; + } - let pos = self.display.rect().pos - offset; + let pos = self.rect.pos - offset; let range: Range = self.selection.range().cast(); let color_tokens = self.highlight.color_tokens(); @@ -592,11 +713,11 @@ impl Part { } &vec }; - draw.text(pos, rect, display, tokens); + draw.text(pos, rect, &self.display, tokens); let decorations = self.highlight.decorations(); if !decorations.is_empty() { - draw.decorate_text(pos, rect, display, decorations); + draw.decorate_text(pos, rect, &self.display, decorations); } if let CurrentAction::ImePreedit { edit_range } = self.current.clone() { @@ -609,14 +730,14 @@ impl Part { (edit_range.end, Default::default()), ]; let r0 = if edit_range.start > 0 { 0 } else { 1 }; - draw.decorate_text(pos, rect, display, &tokens[r0..]); + draw.decorate_text(pos, rect, &self.display, &tokens[r0..]); } if !self.read_only && draw.ev_state().has_input_focus(&self.id) == Some(true) { draw.text_cursor( pos, rect, - display, + &self.display, self.selection.edit_index(), Some(colors.cursor), ); @@ -629,6 +750,11 @@ impl Part { /// re-prepare the text by calling [`Self::prepare_and_scroll`]. #[inline] pub fn handle_event(&mut self, cx: &mut EventCx, event: Event) -> EventAction { + if !self.is_prepared() { + debug_assert!(false); + return EventAction::Unused; + } + match event { Event::NavFocus(source) if source == FocusSource::Key => { if !self.input_handler.is_selecting() { @@ -872,7 +998,7 @@ impl Part { if repeats > 1 { self.selection.expand( self.text.as_str(), - &|index| self.display.find_line(index).ok().flatten().map(|r| r.1), + &|index| self.display.find_line(index).map(|r| r.1), repeats >= 3, ); } @@ -886,7 +1012,7 @@ impl Part { if repeats > 1 { self.selection.expand( self.text.as_str(), - &|index| self.display.find_line(index).ok().flatten().map(|r| r.1), + &|index| self.display.find_line(index).map(|r| r.1), repeats >= 3, ); } @@ -925,7 +1051,7 @@ impl Part { #[inline] fn insert_str(&mut self, index: usize, text: &str) { self.text.insert_str(index, text); - self.display.require_reprepare(); + self.require_reprepare(); } /// Replace a section of text @@ -940,7 +1066,7 @@ impl Part { #[inline] fn replace_range(&mut self, range: std::ops::Range, replace_with: &str) { self.text.replace_range(range, replace_with); - self.display.require_reprepare(); + self.require_reprepare(); } /// Cancel on-going selection and IME actions @@ -983,11 +1109,13 @@ impl Part { let initial_range = range.clone(); let edit_len = edit_range.clone().map(|r| r.len()).unwrap_or(0); - if let Ok(Some((_, line_range))) = self.display.find_line(range.start) { - range.start = line_range.start; - } - if let Ok(Some((_, line_range))) = self.display.find_line(range.end) { - range.end = line_range.end; + if self.status >= Status::Wrapped { + if let Some((_, line_range)) = self.display.find_line(range.start) { + range.start = line_range.start; + } + if let Some((_, line_range)) = self.display.find_line(range.end) { + range.end = line_range.end; + } } if range.len() - edit_len > MAX_TEXT_BYTES { @@ -1027,41 +1155,43 @@ impl Part { /// Call to set IME position only while IME is active fn set_ime_cursor_area(&self, cx: &mut EventState) { - if let Ok(text) = self.display.display() { - let range = match self.current.clone() { - CurrentAction::ImeStart => self.selection.range(), - CurrentAction::ImePreedit { edit_range } => edit_range.cast(), - _ => return, - }; - - let (m1, m2); - if range.is_empty() { - let mut iter = text.text_glyph_pos(range.start); - m1 = iter.next(); - m2 = iter.next(); - } else { - m1 = text.text_glyph_pos(range.start).next_back(); - m2 = text.text_glyph_pos(range.end).next(); - } + if !self.is_prepared() { + return; + } - let rect = if let Some((c1, c2)) = m1.zip(m2) { - let left = c1.pos.0.min(c2.pos.0); - let right = c1.pos.0.max(c2.pos.0); - let top = (c1.pos.1 - c1.ascent).min(c2.pos.1 - c2.ascent); - let bottom = (c1.pos.1 - c1.descent).max(c2.pos.1 - c2.ascent); - let p1 = Vec2(left, top).cast_floor(); - let p2 = Vec2(right, bottom).cast_ceil(); - Rect::from_coords(p1, p2) - } else if let Some(c) = m1.or(m2) { - let p1 = Vec2(c.pos.0, c.pos.1 - c.ascent).cast_floor(); - let p2 = Vec2(c.pos.0, c.pos.1 - c.descent).cast_ceil(); - Rect::from_coords(p1, p2) - } else { - return; - }; + let range = match self.current.clone() { + CurrentAction::ImeStart => self.selection.range(), + CurrentAction::ImePreedit { edit_range } => edit_range.cast(), + _ => return, + }; - cx.set_ime_cursor_area(&self.id, rect + Offset::conv(self.display.rect().pos)); + let (m1, m2); + if range.is_empty() { + let mut iter = self.display.text_glyph_pos(range.start); + m1 = iter.next(); + m2 = iter.next(); + } else { + m1 = self.display.text_glyph_pos(range.start).next_back(); + m2 = self.display.text_glyph_pos(range.end).next(); } + + let rect = if let Some((c1, c2)) = m1.zip(m2) { + let left = c1.pos.0.min(c2.pos.0); + let right = c1.pos.0.max(c2.pos.0); + let top = (c1.pos.1 - c1.ascent).min(c2.pos.1 - c2.ascent); + let bottom = (c1.pos.1 - c1.descent).max(c2.pos.1 - c2.ascent); + let p1 = Vec2(left, top).cast_floor(); + let p2 = Vec2(right, bottom).cast_ceil(); + Rect::from_coords(p1, p2) + } else if let Some(c) = m1.or(m2) { + let p1 = Vec2(c.pos.0, c.pos.1 - c.ascent).cast_floor(); + let p2 = Vec2(c.pos.0, c.pos.1 - c.descent).cast_ceil(); + Rect::from_coords(p1, p2) + } else { + return; + }; + + cx.set_ime_cursor_area(&self.id, rect + Offset::conv(self.rect.pos)); } /// Call before an edit to (potentially) commit current state based on last_edit @@ -1112,7 +1242,7 @@ impl Part { fn trim_paste(&self, text: &str) -> Range { let mut end = text.len(); - if !self.multi_line() { + if !self.wrap { // We cut the content short on control characters and // ignore them (preventing line-breaks and ignoring any // actions such as recursive-paste). @@ -1133,12 +1263,14 @@ impl Part { mut cmd: Command, code: Option, ) -> Result { + debug_assert!(self.is_prepared()); + let editable = !self.read_only; let mut shift = cx.modifiers().shift_key(); let mut buf = [0u8; 4]; let cursor = self.selection.edit_index(); let len = self.as_str().len(); - let multi_line = self.multi_line(); + let multi_line = self.wrap; let selection = self.selection.range(); let have_sel = selection.end > selection.start; let string; @@ -1230,12 +1362,12 @@ impl Part { Some(x) => x, None => self .display - .text_glyph_pos(cursor)? + .text_glyph_pos(cursor) .next_back() .map(|r| r.pos.0) .unwrap_or(0.0), }; - let mut line = self.display.find_line(cursor)?.map(|r| r.0).unwrap_or(0); + let mut line = self.display.find_line(cursor).map(|r| r.0).unwrap_or(0); // We can tolerate invalid line numbers here! line = match cmd { Command::Up => line.wrapping_sub(1), @@ -1248,14 +1380,14 @@ impl Part { _ => 0, }; self.display - .line_index_nearest(line, x)? + .line_index_nearest(line, x) .map(|index| Action::Move(index, Some(x))) .unwrap_or(Action::Move(nearest_end, None)) } Command::Home if cursor > 0 => { let index = self .display - .find_line(cursor)? + .find_line(cursor) .map(|r| r.1.start) .unwrap_or(0); Action::Move(index, None) @@ -1263,7 +1395,7 @@ impl Part { Command::End if cursor < len => { let index = self .display - .find_line(cursor)? + .find_line(cursor) .map(|r| r.1.end) .unwrap_or(len); Action::Move(index, None) @@ -1275,7 +1407,7 @@ impl Part { Command::PageUp | Command::PageDown if multi_line => { let mut v = self .display - .text_glyph_pos(cursor)? + .text_glyph_pos(cursor) .next_back() .map(|r| r.pos.into()) .unwrap_or(Vec2::ZERO); @@ -1283,12 +1415,12 @@ impl Part { v.0 = x; } const FACTOR: f32 = 2.0 / 3.0; - let mut h_dist = f32::conv(self.display.rect().size.1) * FACTOR; + let mut h_dist = f32::conv(self.rect.size.1) * FACTOR; if cmd == Command::PageUp { h_dist *= -1.0; } v.1 += h_dist; - Action::Move(self.display.text_index_nearest(v)?, Some(v.0)) + Action::Move(self.display.text_index_nearest(v.into()), Some(v.0)) } Command::Delete | Command::DelBack if editable && have_sel => { Action::Delete(selection.clone(), EditOp::Delete) @@ -1405,7 +1537,7 @@ impl Part { if let Some((text, cursor)) = self.undo_stack.undo_or_redo(redo) { if self.text.as_str() != text { self.text = text.clone(); - self.display.require_reprepare(); + self.status = Status::New; self.edit_x_coord = None; } self.selection = (*cursor).into(); @@ -1423,8 +1555,9 @@ impl Part { /// /// Committing undo state is the responsibility of the caller. fn set_cursor_from_coord(&mut self, cx: &mut EventCx, coord: Coord) { - let rel_pos = (coord - self.display.rect().pos).cast(); - if let Ok(index) = self.display.text_index_nearest(rel_pos) { + let rel_pos: Vec2 = (coord - self.rect.pos).cast(); + if self.is_prepared() { + let index = self.display.text_index_nearest(rel_pos.into()); if index != self.selection.edit_index() { self.selection.set_edit_index(index); self.set_view_offset_from_cursor(cx); @@ -1449,14 +1582,11 @@ impl Part { /// A redraw is assumed since the cursor moved. fn set_view_offset_from_cursor(&mut self, cx: &mut EventCx) { let cursor = self.selection.edit_index(); - if let Some(marker) = self - .display - .text_glyph_pos(cursor) - .ok() - .and_then(|mut m| m.next_back()) + if self.is_prepared() + && let Some(marker) = self.display.text_glyph_pos(cursor).next_back() { let y0 = (marker.pos.1 - marker.ascent).cast_floor(); - let pos = self.display.rect().pos + Offset(marker.pos.0.cast_nearest(), y0); + let pos = self.rect.pos + Offset(marker.pos.0.cast_nearest(), y0); let size = Size(0, i32::conv_ceil(marker.pos.1 - marker.descent) - y0); cx.set_scroll(Scroll::Rect(Rect { pos, size })); } @@ -1496,7 +1626,7 @@ impl Editor { /// TODO: support defaulting to RTL. #[inline] pub fn text_is_rtl(&self) -> bool { - self.part.display.text_is_rtl(self.as_str()) + self.part.text_is_rtl() } /// Commit outstanding changes to the undo history @@ -1544,7 +1674,7 @@ impl Editor { self.part.cancel_selection_and_ime(cx); self.part.text = text; - self.part.display.require_reprepare(); + self.part.require_reprepare(); let len = self.as_str().len(); self.part.selection.set_max_len(len); @@ -1605,7 +1735,7 @@ impl Editor { /// True if the editor uses multi-line mode #[inline] pub fn multi_line(&self) -> bool { - self.part.display.wrap() + self.part.wrap } /// Get whether the widget has input focus From d91f334583697460cbba542e4895d69601521613 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 14:03:58 +0000 Subject: [PATCH 5/8] Do not align editors vertically --- crates/kas-widgets/src/edit/editor.rs | 38 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 6faec45a1..e48bf29bf 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -482,6 +482,12 @@ impl Part { } } + /// Get the assigned [`Rect`] + #[inline] + pub fn rect(&self) -> Rect { + self.rect + } + /// Solve size rules pub fn size_rules(&mut self, cx: &mut SizeCx, axis: AxisInfo) -> SizeRules { let rules = if axis.is_horizontal() { @@ -518,14 +524,13 @@ impl Part { /// /// This `rect` is stored and available through [`Self::rect`]. /// + /// Changing the width requires re-wrapping lines; other changes to `rect` + /// should be very cheap. + /// /// Note that editors always use default alignment of content. pub fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect) { - if rect.size != self.rect.size { - if rect.size.0 != self.rect.size.0 { - self.status = self.status.min(Status::LevelRuns); - } else { - self.status = self.status.min(Status::Wrapped); - } + if rect.size.0 != self.rect.size.0 { + self.status = self.status.min(Status::LevelRuns); } self.rect = rect; @@ -535,6 +540,14 @@ impl Part { } } + /// Directly set the position + /// + /// This may be called instead of [`Self::set_rect`] if only `pos` changes. + #[inline] + pub fn set_pos(&mut self, pos: Coord) { + self.rect.pos = pos; + } + /// Perform line wrapping and alignment /// /// This represents a high-level step of preparation required before @@ -560,11 +573,6 @@ impl Part { self.display.ensure_non_negative_alignment(); } - if self.status <= Status::Wrapped { - let h = self.rect.size.1.cast(); - self.display.vertically_align(h, Align::Default); - } - self.status = Status::Ready; bb != self.display.bounding_box() } @@ -1414,8 +1422,12 @@ impl Part { if let Some(x) = self.edit_x_coord { v.0 = x; } - const FACTOR: f32 = 2.0 / 3.0; - let mut h_dist = f32::conv(self.rect.size.1) * FACTOR; + // TODO: page height should be an input? + let mut line_height = self.dpem; + if let Some(line) = self.display.lines().next() { + line_height = line.bottom() - line.top(); + } + let mut h_dist = line_height * 10.0; if cmd == Command::PageUp { h_dist *= -1.0; } From fab6f144746f4155780e3896b6e3cf2b9c3798b2 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 13:28:34 +0000 Subject: [PATCH 6/8] Support setting the base text direction in an editor --- crates/kas-widgets/src/edit/edit_box.rs | 8 ++++++++ crates/kas-widgets/src/edit/edit_field.rs | 7 +++++++ crates/kas-widgets/src/edit/editor.rs | 14 +++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/kas-widgets/src/edit/edit_box.rs b/crates/kas-widgets/src/edit/edit_box.rs index 369e00ea7..fed9c33d5 100644 --- a/crates/kas-widgets/src/edit/edit_box.rs +++ b/crates/kas-widgets/src/edit/edit_box.rs @@ -11,6 +11,7 @@ use crate::{ScrollBar, ScrollBarMsg}; use kas::event::Scroll; use kas::event::components::ScrollComponent; use kas::prelude::*; +use kas::text::Direction; use kas::theme::FrameStyle; use std::fmt::{Debug, Display}; use std::str::FromStr; @@ -391,6 +392,13 @@ impl EditBox> { } impl EditBox { + /// Set the base text direction (inline) + #[inline] + pub fn with_direction(mut self, direction: Direction) -> Self { + self.inner.set_direction(direction); + self + } + /// Set the initial text (inline) /// /// This method should only be used on a new `EditBox`. diff --git a/crates/kas-widgets/src/edit/edit_field.rs b/crates/kas-widgets/src/edit/edit_field.rs index 4059b5956..a2f9b9ee2 100644 --- a/crates/kas-widgets/src/edit/edit_field.rs +++ b/crates/kas-widgets/src/edit/edit_field.rs @@ -11,6 +11,7 @@ use crate::edit::highlight::{Highlighter, Plain}; use kas::event::CursorIcon; use kas::messages::{ReplaceSelectedText, SetValueText}; use kas::prelude::*; +use kas::text::Direction; use kas::theme::{Background, TextClass}; use std::ops::Deref; @@ -303,6 +304,12 @@ impl EditBoxCore> { } impl EditBoxCore { + /// Set the base text direction + #[inline] + pub fn set_direction(&mut self, direction: Direction) { + self.editor.set_direction(direction); + } + /// Set the initial text (inline) /// /// This method should only be used on a new `EditBoxCore`. diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index e48bf29bf..ea2e4afd7 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -221,6 +221,12 @@ impl Component { self.0.part.status = Status::New; } + /// Set the base text direction + #[inline] + pub fn set_direction(&mut self, direction: Direction) { + self.0.part.set_direction(direction); + } + /// Replace the highlighter #[inline] pub fn with_highlighter(self, highlighter: H2) -> Component

{ @@ -365,6 +371,13 @@ impl Part { } } + /// Set the base text direction + #[inline] + pub fn set_direction(&mut self, direction: Direction) { + self.direction = direction; + self.status = Status::New; + } + /// Set the initial text (inline) /// /// This method should only be used on a new `Part`. @@ -1635,7 +1648,6 @@ impl Editor { /// /// This returns `true` if the text is inferred to have right-to-left; /// in other cases (including when the text is empty) it returns `false`. - /// TODO: support defaulting to RTL. #[inline] pub fn text_is_rtl(&self) -> bool { self.part.text_is_rtl() From e68e9fb70cefc640a1fbca98226869f7848b78e6 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 14:47:51 +0000 Subject: [PATCH 7/8] Remove fn insert_str --- crates/kas-widgets/src/edit/editor.rs | 35 +++------------------------ 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index ea2e4afd7..b0339c814 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -1062,19 +1062,6 @@ impl Part { } } - /// Insert a `text` at the given position - /// - /// This may be used to edit the raw text instead of replacing it. - /// One must call [`Text::prepare`] afterwards. - /// - /// Currently this is not significantly more efficient than - /// [`Text::set_text`]. This may change in the future (TODO). - #[inline] - fn insert_str(&mut self, index: usize, text: &str) { - self.text.insert_str(index, text); - self.require_reprepare(); - } - /// Replace a section of text /// /// This may be used to edit the raw text instead of replacing it. @@ -1239,16 +1226,9 @@ impl Part { } self.cancel_selection_and_ime(cx); - let index = self.selection.edit_index(); let selection = self.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()); - } else { - self.insert_str(index, text); - self.selection.set_cursor(index + text.len()); - } + self.replace_range(selection.clone(), text); + self.selection.set_cursor(selection.start + text.len()); self.edit_x_coord = None; Used @@ -1714,16 +1694,9 @@ impl Editor { pub fn replace_selected_text(&mut self, cx: &mut EventState, text: &str) { self.part.cancel_selection_and_ime(cx); - let index = self.part.selection.edit_index(); let selection = self.part.selection.range(); - let have_sel = selection.start < selection.end; - if have_sel { - self.part.replace_range(selection.clone(), text); - self.part.selection.set_cursor(selection.start + text.len()); - } else { - self.part.insert_str(index, text); - self.part.selection.set_cursor(index + text.len()); - } + self.part.replace_range(selection.clone(), text); + self.part.selection.set_cursor(selection.start + text.len()); self.part.edit_x_coord = None; self.error_state = None; } From 1b248c8d5ac10655bc29586036e19f94866f4903 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Wed, 25 Mar 2026 16:13:54 +0000 Subject: [PATCH 8/8] Doc fixes --- crates/kas-widgets/src/edit/editor.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index b0339c814..bfbe135fa 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -84,7 +84,7 @@ impl Common { /// Set a new highlighter of the same type /// - /// Also call part.[require_reprepare](ConfiguredDisplay::require_reprepare)() + /// Also call [`Part::require_reprepare`]() /// on each part to ensure the highlighting is updated. pub fn set_highlighter(&mut self, highlighter: H) { self.highlighter = highlighter; @@ -153,7 +153,7 @@ pub struct Part { /// Inner editor interface /// /// This type provides an API usable by [`EditGuard`] and (read-only) via -/// [`Deref`] from [`EditBoxCore`] and [`EditBox`]. +/// [`Deref`](std::ops::Deref) from [`EditBoxCore`] and [`EditBox`]. #[autoimpl(Debug)] pub struct Editor { part: Part, @@ -282,8 +282,7 @@ impl Component { /// Fully prepare text for display /// /// This method performs all required steps of preparation according to the - /// [status](ConfiguredDisplay::status) (which is advanced to - /// [`Status::Ready`]). + /// [`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. @@ -300,9 +299,8 @@ impl Component { /// 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. + /// [`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); @@ -437,8 +435,6 @@ impl Part { } /// Force full repreparation of text - /// - /// This may be required after calling [`Text::text_mut`](super::Text::text_mut). #[inline] pub fn require_reprepare(&mut self) { self.status = Status::New; @@ -468,7 +464,7 @@ impl Part { /// 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`]. + /// the [`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) { @@ -593,9 +589,8 @@ impl Part { /// 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. + /// [`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, common: &mut Common, cx: &mut EventCx) { if self.is_prepared() {