feat: Add rtl support to text input

This commit is contained in:
Hojjat 2026-03-31 14:24:39 -06:00 committed by Ashley Wulber
parent de2982b37e
commit 9b2857083e
6 changed files with 411 additions and 134 deletions

View file

@ -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.

View file

@ -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<Point>;
/// 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<Point> {
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<Rectangle> {
Vec::new()
}
/// Returns `true` if the line is RTL, `false` for LTR.
fn is_rtl(&self, _line: usize) -> Option<bool> {
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<P: Paragraph> Plain<P> {
align_y: self.raw.align_y(),
shaping: self.raw.shaping(),
wrapping: self.raw.wrapping(),
ellipsize: self.raw.ellipsize()
ellipsize: self.raw.ellipsize(),
}
}
}

View file

@ -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<Internal>);
@ -265,8 +272,12 @@ impl core::text::Paragraph for Paragraph {
fn hit_test(&self, point: Point) -> Option<Hit> {
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<usize> {
@ -394,6 +405,66 @@ impl core::text::Paragraph for Paragraph {
))
}
fn cursor_position(
&self,
line: usize,
byte_index: usize,
affinity: Affinity,
) -> Option<Point> {
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<Rectangle> {
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::<Vec<_>>()
})
.collect()
}
fn is_rtl(&self, line: usize) -> Option<bool> {
self.internal().buffer.is_rtl(line)
}
fn ellipsize(&self) -> Ellipsize {
self.0.ellipsize
}

View file

@ -112,7 +112,7 @@ pub struct TextInput<
padding: Padding,
size: Option<Pixels>,
line_height: text::LineHeight,
alignment: alignment::Horizontal,
alignment: Option<alignment::Horizontal>,
on_input: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_submit: Option<Message>,
@ -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<alignment::Horizontal>,
) -> 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::<Renderer>(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<P: text::Paragraph> {
last_click: Option<mouse::Click>,
cursor: Cursor,
keyboard_modifiers: keyboard::Modifiers,
// TODO: Add stateful horizontal scrolling offset
scroll_offset: f32,
}
fn state<Renderer: text::Renderer>(
@ -1601,6 +1647,7 @@ fn offset<P: text::Paragraph>(
text_bounds: Rectangle,
value: &Value,
state: &State<P>,
alignment: Option<alignment::Horizontal>,
) -> f32 {
if state.is_focused() {
let cursor = state.cursor();
@ -1614,11 +1661,19 @@ fn offset<P: text::Paragraph>(
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<P: text::Paragraph>(
value: &Value,
state: &State<P>,
x: f32,
) -> Option<usize> {
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<Renderer>(
@ -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<alignment::Horizontal>,
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
}
})
}

View file

@ -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),
}
}
}

View file

@ -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 {