diff --git a/core/src/text.rs b/core/src/text.rs index e62ab730..674eccf9 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -278,16 +278,34 @@ impl Hash for LineHeight { #[derive(Debug, Clone, Copy, PartialEq)] pub enum Hit { /// The point was within the bounds of the returned character index. - CharOffset(usize), + CharOffset(usize, Affinity), } impl Hit { /// Computes the cursor position of the [`Hit`] . pub fn cursor(self) -> usize { match self { - Self::CharOffset(i) => i, + Self::CharOffset(i, _) => i, } } + + /// Returns the cursor [`Affinity`] of the [`Hit`]. + pub fn affinity(&self) -> Affinity { + match self { + Self::CharOffset(_, a) => *a, + } + } +} + +/// Cursor affinity for BiDi text. At the boundary between runs of different +/// directions, the same byte index can map to different visual positions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Affinity { + /// Associate with the run before the cursor index. + #[default] + Before, + /// Associate with the run after the cursor index. + After, } /// The difference detected in some text. diff --git a/core/src/text/paragraph.rs b/core/src/text/paragraph.rs index fe459047..685c3b37 100644 --- a/core/src/text/paragraph.rs +++ b/core/src/text/paragraph.rs @@ -1,7 +1,8 @@ //! Draw paragraphs. use crate::alignment; use crate::text::{ - Alignment, Difference, Hit, LineHeight, Shaping, Span, Text, Wrapping, + Affinity, Alignment, Difference, Hit, LineHeight, Shaping, Span, Text, + Wrapping, }; use crate::{Pixels, Point, Rectangle, Size}; @@ -71,6 +72,32 @@ pub trait Paragraph: Sized + Default { /// Returns the distance to the given grapheme index in the [`Paragraph`]. fn grapheme_position(&self, line: usize, index: usize) -> Option; + /// Returns the visual position of a cursor at the given byte index and [`Affinity`]. + fn cursor_position( + &self, + _line: usize, + _byte_index: usize, + _affinity: Affinity, + ) -> Option { + None + } + + /// Returns highlight rectangles for a text selection. For mixed BiDi text, + /// this may return multiple rectangles. + fn highlight( + &self, + _line: usize, + _start: (usize, Affinity), + _end: (usize, Affinity), + ) -> Vec { + Vec::new() + } + + /// Returns `true` if the line is RTL, `false` for LTR. + fn is_rtl(&self, _line: usize) -> Option { + None + } + /// Returns the minimum width that can fit the contents of the [`Paragraph`]. fn min_width(&self) -> f32 { self.min_bounds().width @@ -81,7 +108,6 @@ pub trait Paragraph: Sized + Default { self.min_bounds().height } - /// Returns the [`Ellipsize`] strategy of the [`Paragraph`]> fn ellipsize(&self) -> Ellipsize; } @@ -180,7 +206,7 @@ impl Plain

{ align_y: self.raw.align_y(), shaping: self.raw.shaping(), wrapping: self.raw.wrapping(), - ellipsize: self.raw.ellipsize() + ellipsize: self.raw.ellipsize(), } } } diff --git a/graphics/src/text/paragraph.rs b/graphics/src/text/paragraph.rs index 4fca4882..41596239 100644 --- a/graphics/src/text/paragraph.rs +++ b/graphics/src/text/paragraph.rs @@ -4,7 +4,7 @@ use iced_core::text::Ellipsize; use crate::core; use crate::core::alignment; use crate::core::text::{ - Alignment, Hit, LineHeight, Shaping, Span, Text, Wrapping, + Affinity, Alignment, Hit, LineHeight, Shaping, Span, Text, Wrapping, }; use crate::core::{Font, Pixels, Point, Rectangle, Size}; use crate::text; @@ -12,6 +12,13 @@ use crate::text; use std::fmt; use std::sync::{self, Arc}; +fn to_cosmic_affinity(affinity: Affinity) -> cosmic_text::Affinity { + match affinity { + Affinity::Before => cosmic_text::Affinity::Before, + Affinity::After => cosmic_text::Affinity::After, + } +} + /// A bunch of text. #[derive(Clone, PartialEq)] pub struct Paragraph(Arc); @@ -265,8 +272,12 @@ impl core::text::Paragraph for Paragraph { fn hit_test(&self, point: Point) -> Option { let cursor = self.internal().buffer.hit(point.x, point.y)?; + let affinity = match cursor.affinity { + cosmic_text::Affinity::Before => Affinity::Before, + cosmic_text::Affinity::After => Affinity::After, + }; - Some(Hit::CharOffset(cursor.index)) + Some(Hit::CharOffset(cursor.index, affinity)) } fn hit_span(&self, point: Point) -> Option { @@ -394,6 +405,66 @@ impl core::text::Paragraph for Paragraph { )) } + fn cursor_position( + &self, + line: usize, + byte_index: usize, + affinity: Affinity, + ) -> Option { + let internal = self.internal(); + let cursor = cosmic_text::Cursor::new_with_affinity( + line, + byte_index, + to_cosmic_affinity(affinity), + ); + internal + .buffer + .cursor_position(&cursor) + .map(|(x, y)| Point::new(x, y)) + } + + fn highlight( + &self, + line: usize, + start: (usize, Affinity), + end: (usize, Affinity), + ) -> Vec { + let internal = self.internal(); + let start_cursor = cosmic_text::Cursor::new_with_affinity( + line, + start.0, + to_cosmic_affinity(start.1), + ); + let end_cursor = cosmic_text::Cursor::new_with_affinity( + line, + end.0, + to_cosmic_affinity(end.1), + ); + + internal + .buffer + .layout_runs() + .filter(|run| run.line_i == line) + .flat_map(|run| { + let line_top = run.line_top; + let line_height = run.line_height; + run.highlight(start_cursor, end_cursor) + .filter(|(_, width)| *width > 0.0) + .map(move |(x, w)| Rectangle { + x, + y: line_top, + width: w, + height: line_height, + }) + .collect::>() + }) + .collect() + } + + fn is_rtl(&self, line: usize) -> Option { + self.internal().buffer.is_rtl(line) + } + fn ellipsize(&self) -> Ellipsize { self.0.ellipsize } diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 8efa74dd..0aea71f6 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -112,7 +112,7 @@ pub struct TextInput< padding: Padding, size: Option, line_height: text::LineHeight, - alignment: alignment::Horizontal, + alignment: Option, on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, on_submit: Option, @@ -143,7 +143,7 @@ where padding: DEFAULT_PADDING, size: None, line_height: text::LineHeight::default(), - alignment: alignment::Horizontal::Left, + alignment: None, on_input: None, on_paste: None, on_submit: None, @@ -269,7 +269,7 @@ where mut self, alignment: impl Into, ) -> Self { - self.alignment = alignment.into(); + self.alignment = Some(alignment.into()); self } @@ -417,16 +417,24 @@ where }; let text = state.value.raw(); - let (cursor_x, scroll_offset) = - measure_cursor_and_scroll_offset(text, text_bounds, caret_index); + let effective_alignment = effective_alignment(self.alignment, text); + + let (cursor_x, _) = measure_cursor_and_scroll_offset( + text, + text_bounds, + caret_index, + value, + state.cursor.affinity(), + state.scroll_offset, + ); let alignment_offset = alignment_offset( text_bounds.width, text.min_width(), - self.alignment, + effective_alignment, ); - let x = (text_bounds.x + cursor_x).floor() - scroll_offset + let x = (text_bounds.x + cursor_x).floor() - state.scroll_offset + alignment_offset; InputMethod::Enabled { @@ -500,18 +508,21 @@ where let text = value.to_string(); - let (cursor, offset, is_selecting) = if let Some(focus) = state + let (cursors, offset, is_selecting) = if let Some(focus) = state .is_focused .as_ref() .filter(|focus| focus.is_window_focused) { match state.cursor.state(value) { cursor::State::Index(position) => { - let (text_value_width, offset) = + let (text_value_width, _) = measure_cursor_and_scroll_offset( state.value.raw(), text_bounds, position, + value, + state.cursor.affinity(), + state.scroll_offset, ); let is_cursor_visible = !is_disabled @@ -519,8 +530,8 @@ where / CURSOR_BLINK_INTERVAL_MILLIS) .is_multiple_of(2); - let cursor = if is_cursor_visible { - Some(( + let cursors = if is_cursor_visible { + vec![( renderer::Quad { bounds: Rectangle { x: (text_bounds.x + text_value_width) @@ -532,57 +543,58 @@ where ..renderer::Quad::default() }, style.value, - )) + )] } else { - None + vec![] }; - (cursor, offset, false) + (cursors, state.scroll_offset, false) } cursor::State::Selection { start, end } => { let left = start.min(end); let right = end.max(start); - let (left_position, left_offset) = - measure_cursor_and_scroll_offset( - state.value.raw(), - text_bounds, - left, - ); + let lo_byte = value.byte_index_at_grapheme(left); + let hi_byte = value.byte_index_at_grapheme(right); - let (right_position, right_offset) = - measure_cursor_and_scroll_offset( - state.value.raw(), - text_bounds, - right, - ); + let rects = state.value.raw().highlight( + 0, + (lo_byte, text::Affinity::After), + (hi_byte, text::Affinity::Before), + ); - let width = right_position - left_position; - - ( - Some(( - renderer::Quad { - bounds: Rectangle { - x: text_bounds.x + left_position, - y: text_bounds.y, - width, - height: text_bounds.height, + let cursors: Vec<(renderer::Quad, Color)> = rects + .into_iter() + .map(|r| { + ( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + r.x, + y: text_bounds.y, + width: r.width, + height: text_bounds.height, + }, + ..renderer::Quad::default() }, - ..renderer::Quad::default() - }, - style.selection, - )), - if end == right { - right_offset - } else { - left_offset - }, - true, - ) + style.selection, + ) + }) + .collect(); + + (cursors, state.scroll_offset, true) } } } else { - (None, 0.0, false) + let unfocused_offset = { + match effective_alignment(self.alignment, state.value.raw()) { + alignment::Horizontal::Right => { + (state.value.raw().min_width() - text_bounds.width) + .max(0.0) + } + _ => 0.0, + } + }; + (vec![], unfocused_offset, false) }; let draw = |renderer: &mut Renderer, viewport| { @@ -601,14 +613,16 @@ where let alignment_offset = alignment_offset( text_bounds.width, paragraph.min_width(), - self.alignment, + effective_alignment(self.alignment, paragraph), ); - if let Some((cursor, color)) = cursor { + if !cursors.is_empty() { renderer.with_translation( Vector::new(alignment_offset - offset, 0.0), |renderer| { - renderer.fill_quad(cursor, color); + for (quad, color) in &cursors { + renderer.fill_quad(*quad, *color); + } }, ); } else { @@ -632,10 +646,9 @@ where }; if is_selecting { - renderer - .with_layer(text_bounds, |renderer| draw(renderer, *viewport)); + renderer.with_layer(bounds, |renderer| draw(renderer, *viewport)); } else { - draw(renderer, text_bounds); + draw(renderer, bounds); } } } @@ -745,7 +758,10 @@ where let alignment_offset = alignment_offset( text_bounds.width, state.value.raw().min_width(), - self.alignment, + effective_alignment( + self.alignment, + state.value.raw(), + ), ); cursor_position.x - text_bounds.x - alignment_offset @@ -759,23 +775,21 @@ where match click.kind() { click::Kind::Single => { - let position = if target > 0.0 { - let value = if self.is_secure { - self.value.secure() - } else { - self.value.clone() - }; - - find_cursor_position( - text_layout.bounds(), - &value, - state, - target, - ) + let value = if self.is_secure { + self.value.secure() } else { - None - } - .unwrap_or(0); + self.value.clone() + }; + + let (position, affinity) = find_cursor_position( + text_layout.bounds(), + &value, + state, + target, + ) + .unwrap_or((0, text::Affinity::Before)); + + state.cursor.set_affinity(affinity); if state.keyboard_modifiers.shift() { state.cursor.select_range( @@ -794,13 +808,16 @@ where state.is_dragging = None; } else { - let position = find_cursor_position( - text_layout.bounds(), - &self.value, - state, - target, - ) - .unwrap_or(0); + let (position, affinity) = + find_cursor_position( + text_layout.bounds(), + &self.value, + state, + target, + ) + .unwrap_or((0, text::Affinity::Before)); + + state.cursor.set_affinity(affinity); state.cursor.select_range( self.value.previous_start_of_word(position), @@ -845,7 +862,10 @@ where let alignment_offset = alignment_offset( text_bounds.width, state.value.raw().min_width(), - self.alignment, + effective_alignment( + self.alignment, + state.value.raw(), + ), ); position.x - text_bounds.x - alignment_offset @@ -857,13 +877,15 @@ where self.value.clone() }; - let position = find_cursor_position( + let (position, affinity) = find_cursor_position( text_layout.bounds(), &value, state, target, ) - .unwrap_or(0); + .unwrap_or((0, text::Affinity::Before)); + + state.cursor.set_affinity(affinity); let selection_before = state.cursor.selection(&value); @@ -1160,6 +1182,8 @@ where } keyboard::Key::Named(key::Named::ArrowLeft) => { let cursor_before = state.cursor; + let rtl = + state.value.raw().is_rtl(0).unwrap_or(false); if (self.is_secure && modifiers.jump()) || modifiers.macos_command() @@ -1172,20 +1196,23 @@ where } else { state.cursor.move_to(0); } - } else if modifiers.jump() { - if modifiers.shift() { - state - .cursor - .select_left_by_words(&self.value); - } else { - state - .cursor - .move_left_by_words(&self.value); - } - } else if modifiers.shift() { - state.cursor.select_left(&self.value); } else { - state.cursor.move_left(&self.value); + let by_words = modifiers.jump(); + if modifiers.shift() { + state.cursor.select_visual( + false, + by_words, + rtl, + &self.value, + ); + } else { + state.cursor.move_visual( + false, + by_words, + rtl, + &self.value, + ); + } } if cursor_before != state.cursor { @@ -1198,6 +1225,8 @@ where } keyboard::Key::Named(key::Named::ArrowRight) => { let cursor_before = state.cursor; + let rtl = + state.value.raw().is_rtl(0).unwrap_or(false); if (self.is_secure && modifiers.jump()) || modifiers.macos_command() @@ -1210,20 +1239,23 @@ where } else { state.cursor.move_to(self.value.len()); } - } else if modifiers.jump() { - if modifiers.shift() { - state - .cursor - .select_right_by_words(&self.value); - } else { - state - .cursor - .move_right_by_words(&self.value); - } - } else if modifiers.shift() { - state.cursor.select_right(&self.value); } else { - state.cursor.move_right(&self.value); + let by_words = modifiers.jump(); + if modifiers.shift() { + state.cursor.select_visual( + true, + by_words, + rtl, + &self.value, + ); + } else { + state.cursor.move_visual( + true, + by_words, + rtl, + &self.value, + ); + } } if cursor_before != state.cursor { @@ -1363,6 +1395,20 @@ where } let state = state::(tree); + + // Cache scroll offset for consistent hit-testing in the next event. + // This prevents feedback loops where cursor changes alter the offset, + // which shifts the text, which causes the next hit-test to overshoot. + { + let text_layout = layout.children().next().unwrap(); + state.scroll_offset = offset( + text_layout.bounds(), + &self.value, + state, + self.alignment, + ); + } + let is_disabled = self.on_input.is_none(); let status = if is_disabled { @@ -1471,7 +1517,7 @@ pub struct State { last_click: Option, cursor: Cursor, keyboard_modifiers: keyboard::Modifiers, - // TODO: Add stateful horizontal scrolling offset + scroll_offset: f32, } fn state( @@ -1601,6 +1647,7 @@ fn offset( text_bounds: Rectangle, value: &Value, state: &State

, + alignment: Option, ) -> f32 { if state.is_focused() { let cursor = state.cursor(); @@ -1614,11 +1661,19 @@ fn offset( state.value.raw(), text_bounds, focus_position, + value, + state.cursor().affinity(), + state.scroll_offset, ); offset } else { - 0.0 + match effective_alignment(alignment, state.value.raw()) { + alignment::Horizontal::Right => { + (state.value.raw().min_width() - text_bounds.width).max(0.0) + } + _ => 0.0, + } } } @@ -1626,14 +1681,33 @@ fn measure_cursor_and_scroll_offset( paragraph: &impl text::Paragraph, text_bounds: Rectangle, cursor_index: usize, + value: &Value, + affinity: text::Affinity, + current_offset: f32, ) -> (f32, f32) { - let grapheme_position = paragraph - .grapheme_position(0, cursor_index) + let byte_index = value.byte_index_at_grapheme(cursor_index); + let position = paragraph + .cursor_position(0, byte_index, affinity) .unwrap_or(Point::ORIGIN); - let offset = ((grapheme_position.x + 5.0) - text_bounds.width).max(0.0); + // The visible window in paragraph coordinates is: + // [current_offset, current_offset + text_bounds.width] + // Keep the cursor visible with a 5px margin on each side. + let offset = if position.x > current_offset + text_bounds.width - 5.0 { + // Cursor past right edge of visible window → scroll left + (position.x + 5.0) - text_bounds.width + } else if position.x < current_offset + 5.0 { + // Cursor past left edge of visible window → scroll right + position.x - 5.0 + } else { + // Cursor is within visible window → keep current scroll + current_offset + }; - (grapheme_position.x, offset) + let max_offset = (paragraph.min_width() - text_bounds.width).max(0.0); + let offset = offset.clamp(0.0, max_offset); + + (position.x, offset) } /// Computes the position of the text cursor at the given X coordinate of @@ -1643,23 +1717,23 @@ fn find_cursor_position( value: &Value, state: &State

, x: f32, -) -> Option { - let offset = offset(text_bounds, value, state); - let value = value.to_string(); +) -> Option<(usize, text::Affinity)> { + let value_str = value.to_string(); - let char_offset = state - .value - .raw() - .hit_test(Point::new(x + offset, text_bounds.height / 2.0)) - .map(text::Hit::cursor)?; + let hit = state.value.raw().hit_test(Point::new( + x + state.scroll_offset, + text_bounds.height / 2.0, + ))?; + let char_offset = hit.cursor(); + let affinity = hit.affinity(); - Some( - unicode_segmentation::UnicodeSegmentation::graphemes( - &value[..char_offset.min(value.len())], - true, - ) - .count(), + let grapheme_count = unicode_segmentation::UnicodeSegmentation::graphemes( + &value_str[..char_offset.min(value_str.len())], + true, ) + .count(); + + Some((grapheme_count, affinity)) } fn replace_paragraph( @@ -1816,3 +1890,20 @@ fn alignment_offset( } } } + +/// Returns the effective horizontal alignment for the given paragraph, +/// defaulting to [`alignment::Horizontal::Right`] for RTL text and +/// [`alignment::Horizontal::Left`] for LTR text when no explicit alignment is +/// set. +fn effective_alignment( + alignment: Option, + paragraph: &impl text::Paragraph, +) -> alignment::Horizontal { + alignment.unwrap_or_else(|| { + if paragraph.is_rtl(0).unwrap_or(false) { + alignment::Horizontal::Right + } else { + alignment::Horizontal::Left + } + }) +} diff --git a/widget/src/text_input/cursor.rs b/widget/src/text_input/cursor.rs index a326fc8f..4785e973 100644 --- a/widget/src/text_input/cursor.rs +++ b/widget/src/text_input/cursor.rs @@ -1,10 +1,12 @@ //! Track the cursor of a text input. +use crate::core::text::Affinity; use crate::text_input::Value; /// The cursor of a text input. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct Cursor { state: State, + affinity: Affinity, } /// The state of a [`Cursor`]. @@ -26,6 +28,7 @@ impl Default for Cursor { fn default() -> Self { Cursor { state: State::Index(0), + affinity: Affinity::Before, } } } @@ -186,4 +189,52 @@ impl Cursor { State::Selection { start, end } => start.max(end), } } + + /// Returns the current cursor [`Affinity`]. + pub fn affinity(&self) -> Affinity { + self.affinity + } + + /// Sets the cursor [`Affinity`]. + pub fn set_affinity(&mut self, affinity: Affinity) { + self.affinity = affinity; + } + + /// Moves the cursor in a visual direction, accounting for RTL text. + /// + /// `forward` = `true` is visually rightward + /// RTL text flips the logical direction so that pressing the right-arrow key + /// still moves the caret forward visually. + pub fn move_visual( + &mut self, + forward: bool, + by_words: bool, + rtl: bool, + value: &Value, + ) { + match (forward ^ rtl, by_words) { + (true, false) => self.move_right(value), + (true, true) => self.move_right_by_words(value), + (false, false) => self.move_left(value), + (false, true) => self.move_left_by_words(value), + } + } + + /// Extends the selection in a visual direction, accounting for RTL text. + /// + /// See [`Cursor::move_visual`] for the `forward` / `rtl` semantics. + pub fn select_visual( + &mut self, + forward: bool, + by_words: bool, + rtl: bool, + value: &Value, + ) { + match (forward ^ rtl, by_words) { + (true, false) => self.select_right(value), + (true, true) => self.select_right_by_words(value), + (false, false) => self.select_left(value), + (false, true) => self.select_left_by_words(value), + } + } } diff --git a/widget/src/text_input/value.rs b/widget/src/text_input/value.rs index 7456ce63..1c88f7d1 100644 --- a/widget/src/text_input/value.rs +++ b/widget/src/text_input/value.rs @@ -127,6 +127,26 @@ impl Value { .collect(), } } + + /// Converts a grapheme index to a byte index in the underlying string. + pub fn byte_index_at_grapheme(&self, grapheme_index: usize) -> usize { + self.graphemes[..grapheme_index.min(self.graphemes.len())] + .iter() + .map(|g| g.len()) + .sum() + } + + /// Converts a byte index to a grapheme index. + pub fn grapheme_index_at_byte(&self, byte_index: usize) -> usize { + let mut bytes = 0; + for (i, g) in self.graphemes.iter().enumerate() { + if bytes >= byte_index { + return i; + } + bytes += g.len(); + } + self.graphemes.len() + } } impl std::fmt::Display for Value {