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)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum Hit { pub enum Hit {
/// The point was within the bounds of the returned character index. /// The point was within the bounds of the returned character index.
CharOffset(usize), CharOffset(usize, Affinity),
} }
impl Hit { impl Hit {
/// Computes the cursor position of the [`Hit`] . /// Computes the cursor position of the [`Hit`] .
pub fn cursor(self) -> usize { pub fn cursor(self) -> usize {
match self { 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. /// The difference detected in some text.

View file

@ -1,7 +1,8 @@
//! Draw paragraphs. //! Draw paragraphs.
use crate::alignment; use crate::alignment;
use crate::text::{ 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}; 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`]. /// Returns the distance to the given grapheme index in the [`Paragraph`].
fn grapheme_position(&self, line: usize, index: usize) -> Option<Point>; 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`]. /// Returns the minimum width that can fit the contents of the [`Paragraph`].
fn min_width(&self) -> f32 { fn min_width(&self) -> f32 {
self.min_bounds().width self.min_bounds().width
@ -81,7 +108,6 @@ pub trait Paragraph: Sized + Default {
self.min_bounds().height self.min_bounds().height
} }
/// Returns the [`Ellipsize`] strategy of the [`Paragraph`]> /// Returns the [`Ellipsize`] strategy of the [`Paragraph`]>
fn ellipsize(&self) -> Ellipsize; fn ellipsize(&self) -> Ellipsize;
} }
@ -180,7 +206,7 @@ impl<P: Paragraph> Plain<P> {
align_y: self.raw.align_y(), align_y: self.raw.align_y(),
shaping: self.raw.shaping(), shaping: self.raw.shaping(),
wrapping: self.raw.wrapping(), 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;
use crate::core::alignment; use crate::core::alignment;
use crate::core::text::{ 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::core::{Font, Pixels, Point, Rectangle, Size};
use crate::text; use crate::text;
@ -12,6 +12,13 @@ use crate::text;
use std::fmt; use std::fmt;
use std::sync::{self, Arc}; 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. /// A bunch of text.
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct Paragraph(Arc<Internal>); pub struct Paragraph(Arc<Internal>);
@ -265,8 +272,12 @@ impl core::text::Paragraph for Paragraph {
fn hit_test(&self, point: Point) -> Option<Hit> { fn hit_test(&self, point: Point) -> Option<Hit> {
let cursor = self.internal().buffer.hit(point.x, point.y)?; 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> { 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 { fn ellipsize(&self) -> Ellipsize {
self.0.ellipsize self.0.ellipsize
} }

View file

@ -112,7 +112,7 @@ pub struct TextInput<
padding: Padding, padding: Padding,
size: Option<Pixels>, size: Option<Pixels>,
line_height: text::LineHeight, line_height: text::LineHeight,
alignment: alignment::Horizontal, alignment: Option<alignment::Horizontal>,
on_input: Option<Box<dyn Fn(String) -> Message + 'a>>, on_input: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>, on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_submit: Option<Message>, on_submit: Option<Message>,
@ -143,7 +143,7 @@ where
padding: DEFAULT_PADDING, padding: DEFAULT_PADDING,
size: None, size: None,
line_height: text::LineHeight::default(), line_height: text::LineHeight::default(),
alignment: alignment::Horizontal::Left, alignment: None,
on_input: None, on_input: None,
on_paste: None, on_paste: None,
on_submit: None, on_submit: None,
@ -269,7 +269,7 @@ where
mut self, mut self,
alignment: impl Into<alignment::Horizontal>, alignment: impl Into<alignment::Horizontal>,
) -> Self { ) -> Self {
self.alignment = alignment.into(); self.alignment = Some(alignment.into());
self self
} }
@ -417,16 +417,24 @@ where
}; };
let text = state.value.raw(); let text = state.value.raw();
let (cursor_x, scroll_offset) = let effective_alignment = effective_alignment(self.alignment, text);
measure_cursor_and_scroll_offset(text, text_bounds, caret_index);
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( let alignment_offset = alignment_offset(
text_bounds.width, text_bounds.width,
text.min_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; + alignment_offset;
InputMethod::Enabled { InputMethod::Enabled {
@ -500,18 +508,21 @@ where
let text = value.to_string(); 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 .is_focused
.as_ref() .as_ref()
.filter(|focus| focus.is_window_focused) .filter(|focus| focus.is_window_focused)
{ {
match state.cursor.state(value) { match state.cursor.state(value) {
cursor::State::Index(position) => { cursor::State::Index(position) => {
let (text_value_width, offset) = let (text_value_width, _) =
measure_cursor_and_scroll_offset( measure_cursor_and_scroll_offset(
state.value.raw(), state.value.raw(),
text_bounds, text_bounds,
position, position,
value,
state.cursor.affinity(),
state.scroll_offset,
); );
let is_cursor_visible = !is_disabled let is_cursor_visible = !is_disabled
@ -519,8 +530,8 @@ where
/ CURSOR_BLINK_INTERVAL_MILLIS) / CURSOR_BLINK_INTERVAL_MILLIS)
.is_multiple_of(2); .is_multiple_of(2);
let cursor = if is_cursor_visible { let cursors = if is_cursor_visible {
Some(( vec![(
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle {
x: (text_bounds.x + text_value_width) x: (text_bounds.x + text_value_width)
@ -532,57 +543,58 @@ where
..renderer::Quad::default() ..renderer::Quad::default()
}, },
style.value, style.value,
)) )]
} else { } else {
None vec![]
}; };
(cursor, offset, false) (cursors, state.scroll_offset, false)
} }
cursor::State::Selection { start, end } => { cursor::State::Selection { start, end } => {
let left = start.min(end); let left = start.min(end);
let right = end.max(start); let right = end.max(start);
let (left_position, left_offset) = let lo_byte = value.byte_index_at_grapheme(left);
measure_cursor_and_scroll_offset( let hi_byte = value.byte_index_at_grapheme(right);
state.value.raw(),
text_bounds, let rects = state.value.raw().highlight(
left, 0,
(lo_byte, text::Affinity::After),
(hi_byte, text::Affinity::Before),
); );
let (right_position, right_offset) = let cursors: Vec<(renderer::Quad, Color)> = rects
measure_cursor_and_scroll_offset( .into_iter()
state.value.raw(), .map(|r| {
text_bounds,
right,
);
let width = right_position - left_position;
( (
Some((
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle {
x: text_bounds.x + left_position, x: text_bounds.x + r.x,
y: text_bounds.y, y: text_bounds.y,
width, width: r.width,
height: text_bounds.height, height: text_bounds.height,
}, },
..renderer::Quad::default() ..renderer::Quad::default()
}, },
style.selection, style.selection,
)),
if end == right {
right_offset
} else {
left_offset
},
true,
) )
})
.collect();
(cursors, state.scroll_offset, true)
} }
} }
} else { } 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| { let draw = |renderer: &mut Renderer, viewport| {
@ -601,14 +613,16 @@ where
let alignment_offset = alignment_offset( let alignment_offset = alignment_offset(
text_bounds.width, text_bounds.width,
paragraph.min_width(), paragraph.min_width(),
self.alignment, effective_alignment(self.alignment, paragraph),
); );
if let Some((cursor, color)) = cursor { if !cursors.is_empty() {
renderer.with_translation( renderer.with_translation(
Vector::new(alignment_offset - offset, 0.0), Vector::new(alignment_offset - offset, 0.0),
|renderer| { |renderer| {
renderer.fill_quad(cursor, color); for (quad, color) in &cursors {
renderer.fill_quad(*quad, *color);
}
}, },
); );
} else { } else {
@ -632,10 +646,9 @@ where
}; };
if is_selecting { if is_selecting {
renderer renderer.with_layer(bounds, |renderer| draw(renderer, *viewport));
.with_layer(text_bounds, |renderer| draw(renderer, *viewport));
} else { } else {
draw(renderer, text_bounds); draw(renderer, bounds);
} }
} }
} }
@ -745,7 +758,10 @@ where
let alignment_offset = alignment_offset( let alignment_offset = alignment_offset(
text_bounds.width, text_bounds.width,
state.value.raw().min_width(), state.value.raw().min_width(),
effective_alignment(
self.alignment, self.alignment,
state.value.raw(),
),
); );
cursor_position.x - text_bounds.x - alignment_offset cursor_position.x - text_bounds.x - alignment_offset
@ -759,23 +775,21 @@ where
match click.kind() { match click.kind() {
click::Kind::Single => { click::Kind::Single => {
let position = if target > 0.0 {
let value = if self.is_secure { let value = if self.is_secure {
self.value.secure() self.value.secure()
} else { } else {
self.value.clone() self.value.clone()
}; };
find_cursor_position( let (position, affinity) = find_cursor_position(
text_layout.bounds(), text_layout.bounds(),
&value, &value,
state, state,
target, target,
) )
} else { .unwrap_or((0, text::Affinity::Before));
None
} state.cursor.set_affinity(affinity);
.unwrap_or(0);
if state.keyboard_modifiers.shift() { if state.keyboard_modifiers.shift() {
state.cursor.select_range( state.cursor.select_range(
@ -794,13 +808,16 @@ where
state.is_dragging = None; state.is_dragging = None;
} else { } else {
let position = find_cursor_position( let (position, affinity) =
find_cursor_position(
text_layout.bounds(), text_layout.bounds(),
&self.value, &self.value,
state, state,
target, target,
) )
.unwrap_or(0); .unwrap_or((0, text::Affinity::Before));
state.cursor.set_affinity(affinity);
state.cursor.select_range( state.cursor.select_range(
self.value.previous_start_of_word(position), self.value.previous_start_of_word(position),
@ -845,7 +862,10 @@ where
let alignment_offset = alignment_offset( let alignment_offset = alignment_offset(
text_bounds.width, text_bounds.width,
state.value.raw().min_width(), state.value.raw().min_width(),
effective_alignment(
self.alignment, self.alignment,
state.value.raw(),
),
); );
position.x - text_bounds.x - alignment_offset position.x - text_bounds.x - alignment_offset
@ -857,13 +877,15 @@ where
self.value.clone() self.value.clone()
}; };
let position = find_cursor_position( let (position, affinity) = find_cursor_position(
text_layout.bounds(), text_layout.bounds(),
&value, &value,
state, state,
target, target,
) )
.unwrap_or(0); .unwrap_or((0, text::Affinity::Before));
state.cursor.set_affinity(affinity);
let selection_before = state.cursor.selection(&value); let selection_before = state.cursor.selection(&value);
@ -1160,6 +1182,8 @@ where
} }
keyboard::Key::Named(key::Named::ArrowLeft) => { keyboard::Key::Named(key::Named::ArrowLeft) => {
let cursor_before = state.cursor; let cursor_before = state.cursor;
let rtl =
state.value.raw().is_rtl(0).unwrap_or(false);
if (self.is_secure && modifiers.jump()) if (self.is_secure && modifiers.jump())
|| modifiers.macos_command() || modifiers.macos_command()
@ -1172,20 +1196,23 @@ where
} else { } else {
state.cursor.move_to(0); state.cursor.move_to(0);
} }
} else if modifiers.jump() { } else {
let by_words = modifiers.jump();
if modifiers.shift() { if modifiers.shift() {
state state.cursor.select_visual(
.cursor false,
.select_left_by_words(&self.value); by_words,
rtl,
&self.value,
);
} else { } else {
state state.cursor.move_visual(
.cursor false,
.move_left_by_words(&self.value); by_words,
rtl,
&self.value,
);
} }
} else if modifiers.shift() {
state.cursor.select_left(&self.value);
} else {
state.cursor.move_left(&self.value);
} }
if cursor_before != state.cursor { if cursor_before != state.cursor {
@ -1198,6 +1225,8 @@ where
} }
keyboard::Key::Named(key::Named::ArrowRight) => { keyboard::Key::Named(key::Named::ArrowRight) => {
let cursor_before = state.cursor; let cursor_before = state.cursor;
let rtl =
state.value.raw().is_rtl(0).unwrap_or(false);
if (self.is_secure && modifiers.jump()) if (self.is_secure && modifiers.jump())
|| modifiers.macos_command() || modifiers.macos_command()
@ -1210,20 +1239,23 @@ where
} else { } else {
state.cursor.move_to(self.value.len()); state.cursor.move_to(self.value.len());
} }
} else if modifiers.jump() { } else {
let by_words = modifiers.jump();
if modifiers.shift() { if modifiers.shift() {
state state.cursor.select_visual(
.cursor true,
.select_right_by_words(&self.value); by_words,
rtl,
&self.value,
);
} else { } else {
state state.cursor.move_visual(
.cursor true,
.move_right_by_words(&self.value); by_words,
rtl,
&self.value,
);
} }
} else if modifiers.shift() {
state.cursor.select_right(&self.value);
} else {
state.cursor.move_right(&self.value);
} }
if cursor_before != state.cursor { if cursor_before != state.cursor {
@ -1363,6 +1395,20 @@ where
} }
let state = state::<Renderer>(tree); 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 is_disabled = self.on_input.is_none();
let status = if is_disabled { let status = if is_disabled {
@ -1471,7 +1517,7 @@ pub struct State<P: text::Paragraph> {
last_click: Option<mouse::Click>, last_click: Option<mouse::Click>,
cursor: Cursor, cursor: Cursor,
keyboard_modifiers: keyboard::Modifiers, keyboard_modifiers: keyboard::Modifiers,
// TODO: Add stateful horizontal scrolling offset scroll_offset: f32,
} }
fn state<Renderer: text::Renderer>( fn state<Renderer: text::Renderer>(
@ -1601,6 +1647,7 @@ fn offset<P: text::Paragraph>(
text_bounds: Rectangle, text_bounds: Rectangle,
value: &Value, value: &Value,
state: &State<P>, state: &State<P>,
alignment: Option<alignment::Horizontal>,
) -> f32 { ) -> f32 {
if state.is_focused() { if state.is_focused() {
let cursor = state.cursor(); let cursor = state.cursor();
@ -1614,11 +1661,19 @@ fn offset<P: text::Paragraph>(
state.value.raw(), state.value.raw(),
text_bounds, text_bounds,
focus_position, focus_position,
value,
state.cursor().affinity(),
state.scroll_offset,
); );
offset offset
} else { } 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, paragraph: &impl text::Paragraph,
text_bounds: Rectangle, text_bounds: Rectangle,
cursor_index: usize, cursor_index: usize,
value: &Value,
affinity: text::Affinity,
current_offset: f32,
) -> (f32, f32) { ) -> (f32, f32) {
let grapheme_position = paragraph let byte_index = value.byte_index_at_grapheme(cursor_index);
.grapheme_position(0, cursor_index) let position = paragraph
.cursor_position(0, byte_index, affinity)
.unwrap_or(Point::ORIGIN); .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 /// 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, value: &Value,
state: &State<P>, state: &State<P>,
x: f32, x: f32,
) -> Option<usize> { ) -> Option<(usize, text::Affinity)> {
let offset = offset(text_bounds, value, state); let value_str = value.to_string();
let value = value.to_string();
let char_offset = state let hit = state.value.raw().hit_test(Point::new(
.value x + state.scroll_offset,
.raw() text_bounds.height / 2.0,
.hit_test(Point::new(x + offset, text_bounds.height / 2.0)) ))?;
.map(text::Hit::cursor)?; let char_offset = hit.cursor();
let affinity = hit.affinity();
Some( let grapheme_count = unicode_segmentation::UnicodeSegmentation::graphemes(
unicode_segmentation::UnicodeSegmentation::graphemes( &value_str[..char_offset.min(value_str.len())],
&value[..char_offset.min(value.len())],
true, true,
) )
.count(), .count();
)
Some((grapheme_count, affinity))
} }
fn replace_paragraph<Renderer>( 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. //! Track the cursor of a text input.
use crate::core::text::Affinity;
use crate::text_input::Value; use crate::text_input::Value;
/// The cursor of a text input. /// The cursor of a text input.
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Cursor { pub struct Cursor {
state: State, state: State,
affinity: Affinity,
} }
/// The state of a [`Cursor`]. /// The state of a [`Cursor`].
@ -26,6 +28,7 @@ impl Default for Cursor {
fn default() -> Self { fn default() -> Self {
Cursor { Cursor {
state: State::Index(0), state: State::Index(0),
affinity: Affinity::Before,
} }
} }
} }
@ -186,4 +189,52 @@ impl Cursor {
State::Selection { start, end } => start.max(end), 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(), .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 { impl std::fmt::Display for Value {