feat: Add rtl support to text input
This commit is contained in:
parent
de2982b37e
commit
9b2857083e
6 changed files with 411 additions and 134 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue