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)]
|
#[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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue