use repose_core::*;
use std::ops::Range;
use std::rc::Rc;
use web_time::Duration;
use web_time::Instant;
use unicode_segmentation::UnicodeSegmentation;
const TEXT_UNDO_CAPACITY: usize = 100;
const SNAPSHOTS_INTERVAL_MILLIS: u128 = 5000;
#[derive(Clone, Copy, Debug, PartialEq)]
enum TextEditType {
Insert,
Delete,
Replace,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum TextDeleteType {
Start, End, Inner, NotByUser,
}
#[derive(Clone, Debug)]
pub struct TextUndoOp {
pub index: usize,
pub pre_text: String,
pub post_text: String,
pub pre_selection: Range<usize>,
pub post_selection: Range<usize>,
pub time: Instant,
pub can_merge: bool,
}
impl TextUndoOp {
fn edit_type(&self) -> TextEditType {
match (self.pre_text.is_empty(), self.post_text.is_empty()) {
(true, true) => unreachable!("Both pre and post text cannot be empty"),
(true, false) => TextEditType::Insert,
(false, true) => TextEditType::Delete,
(false, false) => TextEditType::Replace,
}
}
fn is_newline(&self) -> bool {
self.post_text == "\n" || self.post_text == "\r\n"
}
fn try_merge(&self, next: &TextUndoOp) -> Option<TextUndoOp> {
if !self.can_merge || !next.can_merge {
return None;
}
let elapsed = next.time.saturating_duration_since(self.time);
if elapsed.as_millis() >= SNAPSHOTS_INTERVAL_MILLIS {
return None;
}
if self.is_newline() || next.is_newline() {
return None;
}
let self_type = self.edit_type();
if self_type != next.edit_type() {
return None;
}
match self_type {
TextEditType::Insert => {
if self.index + self.post_text.len() == next.index {
Some(TextUndoOp {
index: self.index,
pre_text: String::new(),
post_text: format!("{}{}", self.post_text, next.post_text),
pre_selection: self.pre_selection.clone(),
post_selection: next.post_selection.clone(),
time: self.time,
can_merge: true,
})
} else {
None
}
}
TextEditType::Delete => {
let self_del = self.deletion_type();
let next_del = next.deletion_type();
if self_del == next_del && (self_del == TextDeleteType::Start || self_del == TextDeleteType::End) {
if self.index == next.index + next.pre_text.len() {
Some(TextUndoOp {
index: next.index,
pre_text: format!("{}{}", next.pre_text, self.pre_text),
post_text: String::new(),
pre_selection: self.pre_selection.clone(),
post_selection: next.post_selection.clone(),
time: self.time,
can_merge: true,
})
} else if self.index == next.index {
Some(TextUndoOp {
index: self.index,
pre_text: format!("{}{}", self.pre_text, next.pre_text),
post_text: String::new(),
pre_selection: self.pre_selection.clone(),
post_selection: next.post_selection.clone(),
time: self.time,
can_merge: true,
})
} else {
None
}
} else {
None
}
}
TextEditType::Replace => None,
}
}
fn deletion_type(&self) -> TextDeleteType {
if self.edit_type() != TextEditType::Delete {
return TextDeleteType::NotByUser;
}
if !self.post_selection.start == self.post_selection.end {
return TextDeleteType::NotByUser;
}
if self.pre_selection.start == self.pre_selection.end {
if self.pre_selection.start > self.post_selection.start {
TextDeleteType::Start } else {
TextDeleteType::End }
} else if self.pre_selection.start == self.post_selection.start
&& self.pre_selection.start == self.index
{
TextDeleteType::Inner
} else {
TextDeleteType::NotByUser
}
}
}
const SCROLL_STIFFNESS: f32 = 300.0;
const SCROLL_DAMPING: f32 = 30.0;
pub const TF_FONT_DP: f32 = 16.0;
#[derive(Clone, Copy, Debug)]
pub struct KeyboardOptions {
pub keyboard_type: repose_core::KeyboardType,
pub autocorrect: bool,
}
impl Default for KeyboardOptions {
fn default() -> Self {
Self {
keyboard_type: repose_core::KeyboardType::default(),
autocorrect: true,
}
}
}
pub const TF_PADDING_X_DP: f32 = 8.0;
pub struct TextMetrics {
pub positions: Vec<f32>, pub byte_offsets: Vec<usize>,
}
pub fn measure_text(text: &str, font_px: f32, font_family: Option<&'static str>) -> TextMetrics {
let m = repose_text::metrics_for_textfield(text, font_px, font_family);
TextMetrics {
positions: m.positions,
byte_offsets: m.byte_offsets,
}
}
pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
match m.byte_offsets.binary_search(&byte) {
Ok(i) | Err(i) => i,
}
}
pub fn index_for_x_bytes(text: &str, font_px: f32, x_px: f32) -> usize {
let m = measure_text(text, font_px, None);
let mut best_i = 0usize;
let mut best_d = f32::INFINITY;
for i in 0..m.positions.len() {
let d = (m.positions[i] - x_px).abs();
if d < best_d {
best_d = d;
best_i = i;
}
}
m.byte_offsets[best_i]
}
fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
let mut last = 0usize;
for (i, _) in text.grapheme_indices(true) {
if i >= byte {
break;
}
last = i;
}
last
}
fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
for (i, _) in text.grapheme_indices(true) {
if i > byte {
return i;
}
}
text.len()
}
#[derive(Clone)]
pub struct TextFieldState {
pub text: String,
pub selection: Range<usize>,
pub composition: Option<Range<usize>>, pub scroll_offset: f32, pub scroll_offset_y: f32, pub drag_anchor: Option<usize>, pub blink_start: Instant, pub inner_width: f32, pub inner_height: f32, pub preferred_x_px: Option<f32>, pub offset_map: Option<Rc<dyn Fn(usize) -> usize>>,
pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
pub(crate) scroll_target: f32,
pub(crate) scroll_target_y: f32,
scroll_vel: f32,
scroll_vel_y: f32,
last_scroll_tick: Option<Instant>,
undo_stack: Vec<TextUndoOp>,
redo_stack: Vec<TextUndoOp>,
staging_undo: Option<TextUndoOp>,
}
impl std::fmt::Debug for TextFieldState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextFieldState")
.field("text", &self.text)
.field("selection", &self.selection)
.field("composition", &self.composition)
.field("scroll_offset", &self.scroll_offset)
.field("scroll_offset_y", &self.scroll_offset_y)
.field("drag_anchor", &self.drag_anchor)
.field("blink_start", &self.blink_start)
.field("inner_width", &self.inner_width)
.field("inner_height", &self.inner_height)
.field("preferred_x_px", &self.preferred_x_px)
.field("offset_map", &self.offset_map.as_ref().map(|_| "<fn>"))
.field(
"visual_transformation",
&self.visual_transformation.as_ref().map(|_| "<vt>"),
)
.field("scroll_target", &self.scroll_target)
.field("scroll_target_y", &self.scroll_target_y)
.field("can_undo", &self.can_undo())
.field("can_redo", &self.can_redo())
.field("undo_count", &self.undo_stack.len())
.field("redo_count", &self.redo_stack.len())
.finish()
}
}
impl Default for TextFieldState {
fn default() -> Self {
Self::new()
}
}
impl TextFieldState {
pub fn new() -> Self {
Self {
text: String::new(),
selection: 0..0,
composition: None,
scroll_offset: 0.0,
scroll_offset_y: 0.0,
drag_anchor: None,
blink_start: Instant::now(),
inner_width: 0.0,
inner_height: 0.0,
preferred_x_px: None,
offset_map: None,
visual_transformation: None,
scroll_target: 0.0,
scroll_target_y: 0.0,
scroll_vel: 0.0,
scroll_vel_y: 0.0,
last_scroll_tick: None,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
staging_undo: None,
}
}
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty() || self.staging_undo.is_some()
}
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn undo(&mut self) -> bool {
self.flush_undo();
if let Some(op) = self.undo_stack.pop() {
let end = (op.index + op.post_text.len()).min(self.text.len());
self.text.replace_range(op.index..end, &op.pre_text);
self.selection = op.pre_selection.clone();
self.redo_stack.push(op);
self.preferred_x_px = None;
self.reset_caret_blink();
true
} else {
false
}
}
pub fn redo(&mut self) -> bool {
if let Some(op) = self.redo_stack.pop() {
let end = (op.index + op.pre_text.len()).min(self.text.len());
self.text.replace_range(op.index..end, &op.post_text);
self.selection = op.post_selection.clone();
self.undo_stack.push(op);
self.preferred_x_px = None;
self.reset_caret_blink();
true
} else {
false
}
}
pub fn clear_undo_history(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
self.staging_undo = None;
}
fn record_edit(&mut self, op: TextUndoOp) {
if let Some(staging) = self.staging_undo.take() {
if let Some(merged) = staging.try_merge(&op) {
self.staging_undo = Some(merged);
return;
}
self.undo_stack.push(staging);
self.redo_stack.clear();
while self.undo_stack.len() + 1 > TEXT_UNDO_CAPACITY {
self.undo_stack.remove(0);
}
}
self.staging_undo = Some(op);
}
fn flush_undo(&mut self) {
if let Some(op) = self.staging_undo.take() {
self.undo_stack.push(op);
self.redo_stack.clear();
while self.undo_stack.len() > TEXT_UNDO_CAPACITY {
self.undo_stack.remove(0);
}
}
}
fn insert_text_impl(&mut self, text: &str, can_merge: bool) {
let start = self.selection.start.min(self.text.len());
let end = self.selection.end.min(self.text.len());
let pre_text = self.text[start..end].to_string();
let pre_selection = self.selection.clone();
self.text.replace_range(start..end, text);
let new_pos = start + text.len();
self.selection = new_pos..new_pos;
self.preferred_x_px = None;
self.reset_caret_blink();
if !pre_text.is_empty() || !text.is_empty() {
self.record_edit(TextUndoOp {
index: start,
pre_text,
post_text: text.to_string(),
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge,
});
}
}
pub fn insert_text(&mut self, text: &str) {
self.insert_text_impl(text, true);
}
pub fn insert_text_atomic(&mut self, text: &str) {
self.insert_text_impl(text, false);
}
pub fn delete_backward(&mut self) {
if self.selection.start == self.selection.end {
let pos = self.selection.start.min(self.text.len());
if pos > 0 {
let prev = prev_grapheme_boundary(&self.text, pos);
let pre_text = self.text[prev..pos].to_string();
let pre_selection = self.selection.clone();
self.text.replace_range(prev..pos, "");
self.selection = prev..prev;
self.preferred_x_px = None;
self.reset_caret_blink();
self.record_edit(TextUndoOp {
index: prev,
pre_text,
post_text: String::new(),
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge: true,
});
}
} else {
self.insert_text_impl("", true);
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn delete_forward(&mut self) {
if self.selection.start == self.selection.end {
let pos = self.selection.start.min(self.text.len());
if pos < self.text.len() {
let next = next_grapheme_boundary(&self.text, pos);
let pre_text = self.text[pos..next].to_string();
let pre_selection = self.selection.clone();
self.text.replace_range(pos..next, "");
self.preferred_x_px = None;
self.reset_caret_blink();
self.record_edit(TextUndoOp {
index: pos,
pre_text,
post_text: String::new(),
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge: true,
});
}
} else {
self.insert_text_impl("", true);
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
let mut pos = self.selection.end.min(self.text.len());
if delta < 0 {
for _ in 0..delta.unsigned_abs() {
pos = prev_grapheme_boundary(&self.text, pos);
}
} else if delta > 0 {
for _ in 0..(delta as usize) {
pos = next_grapheme_boundary(&self.text, pos);
}
}
if extend_selection {
self.selection.end = pos;
} else {
self.selection = pos..pos;
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn selected_text(&self) -> String {
if self.selection.start == self.selection.end {
String::new()
} else {
self.text[self.selection.clone()].to_string()
}
}
pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
if text.is_empty() {
if let Some(range) = self.composition.take() {
let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
if s <= e {
self.text.replace_range(s..e, "");
self.selection = s..s;
}
}
self.preferred_x_px = None;
self.reset_caret_blink();
return;
}
let anchor_start;
if let Some(r) = self.composition.take() {
let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
if e < s {
std::mem::swap(&mut s, &mut e);
}
self.text.replace_range(s..e, &text);
anchor_start = s;
} else {
let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
self.text.insert_str(pos, &text);
anchor_start = pos;
}
self.composition = Some(anchor_start..(anchor_start + text.len()));
if let Some((c0, c1)) = cursor {
let b0 = char_to_byte(&text, c0);
let b1 = char_to_byte(&text, c1);
self.selection = (anchor_start + b0)..(anchor_start + b1);
} else {
let end = anchor_start + text.len();
self.selection = end..end;
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn commit_composition(&mut self, text: String) {
let pre_selection = self.selection.clone();
if let Some(r) = self.composition.take() {
let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
let pre_text = self.text[s..e].to_string();
self.text.replace_range(s..e, &text);
let new_pos = s + text.len();
self.selection = new_pos..new_pos;
self.preferred_x_px = None;
self.reset_caret_blink();
if !pre_text.is_empty() || !text.is_empty() {
self.record_edit(TextUndoOp {
index: s,
pre_text,
post_text: text,
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge: true,
});
}
} else {
let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
self.text.insert_str(pos, &text);
let new_pos = pos + text.len();
self.selection = new_pos..new_pos;
self.preferred_x_px = None;
self.reset_caret_blink();
if !text.is_empty() {
self.record_edit(TextUndoOp {
index: pos,
pre_text: String::new(),
post_text: text,
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge: true,
});
}
}
}
pub fn cancel_composition(&mut self) {
if let Some(r) = self.composition.take() {
let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
if s <= e {
self.text.replace_range(s..e, "");
self.selection = s..s;
}
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
if self.selection.start != self.selection.end {
let start = self.selection.start.min(self.text.len());
let end = self.selection.end.min(self.text.len());
self.text.replace_range(start..end, "");
self.selection = start..start;
self.preferred_x_px = None;
self.reset_caret_blink();
return;
}
let caret = self.selection.end.min(self.text.len());
let start_raw = caret.saturating_sub(before_bytes);
let end_raw = (caret + after_bytes).min(self.text.len());
let start = prev_grapheme_boundary(&self.text, start_raw);
let end = next_grapheme_boundary(&self.text, end_raw);
if start < end {
self.text.replace_range(start..end, "");
self.selection = start..start;
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
let idx = idx_byte.min(self.text.len());
if extend {
let anchor = self.selection.start;
self.selection = anchor.min(idx)..anchor.max(idx);
self.drag_anchor = Some(anchor);
} else {
self.selection = idx..idx;
self.drag_anchor = Some(idx);
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn drag_to(&mut self, idx_byte: usize) {
if let Some(anchor) = self.drag_anchor {
let i = idx_byte.min(self.text.len());
self.selection = anchor.min(i)..anchor.max(i);
}
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn end_drag(&mut self) {
self.drag_anchor = None;
}
pub fn caret_index(&self) -> usize {
self.selection.end
}
pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
}
pub fn ensure_caret_visible_xy(
&mut self,
caret_x_px: f32,
caret_y_px: f32,
inner_w_px: f32,
inner_h_px: f32,
inset_px: f32,
) {
let inset_px = inset_px.max(0.0);
let left_px = self.scroll_offset + inset_px;
let right_px = self.scroll_offset + inner_w_px - inset_px;
if caret_x_px < left_px {
self.scroll_target = (caret_x_px - inset_px).max(0.0);
} else if caret_x_px > right_px {
self.scroll_target = (caret_x_px - inner_w_px + inset_px).max(0.0);
}
let top_px = self.scroll_offset_y + inset_px;
let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
if caret_y_px < top_px {
self.scroll_target_y = (caret_y_px - inset_px).max(0.0);
} else if caret_y_px > bot_px {
self.scroll_target_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
}
}
pub fn clamp_scroll(&mut self, content_h_px: f32) {
let max_y = (content_h_px - self.inner_height).max(0.0);
self.scroll_target_y = self.scroll_target_y.clamp(0.0, max_y);
if self.scroll_target_y.is_nan() {
self.scroll_target_y = 0.0;
}
}
pub fn reset_caret_blink(&mut self) {
self.blink_start = Instant::now();
}
pub fn caret_visible(&self) -> bool {
const PERIOD: Duration = Duration::from_millis(500);
((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
}
pub fn set_inner_width(&mut self, w_px: f32) {
self.inner_width = w_px.max(0.0);
if self.scroll_offset.is_nan() {
self.scroll_offset = 0.0;
}
if self.scroll_target.is_nan() {
self.scroll_target = 0.0;
}
}
pub fn set_inner_height(&mut self, h_px: f32) {
self.inner_height = h_px.max(0.0);
if self.scroll_offset_y.is_nan() {
self.scroll_offset_y = 0.0;
}
if self.scroll_target_y.is_nan() {
self.scroll_target_y = 0.0;
}
}
pub fn tick_scroll_animation(&mut self) {
let now = Instant::now();
let dt = match self.last_scroll_tick {
Some(prev) => {
let d = now.saturating_duration_since(prev).as_secs_f32();
d.min(0.05) }
None => {
self.last_scroll_tick = Some(now);
self.scroll_offset = self.scroll_target;
self.scroll_vel = 0.0;
self.scroll_offset_y = self.scroll_target_y;
self.scroll_vel_y = 0.0;
return;
}
};
self.last_scroll_tick = Some(now);
if dt > 0.0 {
let dx = self.scroll_target - self.scroll_offset;
let near_x = dx.abs() < 0.5 && self.scroll_vel.abs() < 0.5;
if near_x {
self.scroll_offset = self.scroll_target;
self.scroll_vel = 0.0;
} else {
let force_x = SCROLL_STIFFNESS * dx - SCROLL_DAMPING * self.scroll_vel;
self.scroll_vel += force_x * dt;
self.scroll_offset += self.scroll_vel * dt;
if (self.scroll_target - self.scroll_offset).signum() != dx.signum() && dx != 0.0 {
self.scroll_offset = self.scroll_target;
self.scroll_vel = 0.0;
}
}
}
if dt > 0.0 {
let dy = self.scroll_target_y - self.scroll_offset_y;
let near_y = dy.abs() < 0.5 && self.scroll_vel_y.abs() < 0.5;
if near_y {
self.scroll_offset_y = self.scroll_target_y;
self.scroll_vel_y = 0.0;
} else {
let force_y = SCROLL_STIFFNESS * dy - SCROLL_DAMPING * self.scroll_vel_y;
self.scroll_vel_y += force_y * dt;
self.scroll_offset_y += self.scroll_vel_y * dt;
if (self.scroll_target_y - self.scroll_offset_y).signum() != dy.signum()
&& dy != 0.0
{
self.scroll_offset_y = self.scroll_target_y;
self.scroll_vel_y = 0.0;
}
}
}
}
}
pub fn TextField(
hint: impl Into<String>,
value: String,
modifier: repose_core::Modifier,
on_change: Option<impl Fn(String) + 'static>,
on_submit: Option<impl Fn(String) + 'static>,
) -> repose_core::View {
TextFieldEx {
hint: hint.into(),
value,
modifier,
on_change: on_change.map(|f| Rc::new(f) as _),
on_submit: on_submit.map(|f| Rc::new(f) as _),
visual_transformation: None,
keyboard_options: None,
ime_action: None,
}
.build()
}
pub struct TextFieldEx {
pub hint: String,
pub value: String,
pub modifier: repose_core::Modifier,
pub on_change: Option<Rc<dyn Fn(String)>>,
pub on_submit: Option<Rc<dyn Fn(String)>>,
pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
pub keyboard_options: Option<KeyboardOptions>,
pub ime_action: Option<repose_core::ImeAction>,
}
impl TextFieldEx {
pub fn new(hint: impl Into<String>, value: String, modifier: repose_core::Modifier) -> Self {
Self {
hint: hint.into(),
value,
modifier,
on_change: None,
on_submit: None,
visual_transformation: None,
keyboard_options: None,
ime_action: None,
}
}
pub fn on_change(mut self, f: impl Fn(String) + 'static) -> Self {
self.on_change = Some(Rc::new(f));
self
}
pub fn on_submit(mut self, f: impl Fn(String) + 'static) -> Self {
self.on_submit = Some(Rc::new(f));
self
}
pub fn visual_transformation(mut self, vt: impl repose_core::VisualTransformation) -> Self {
self.visual_transformation = Some(Rc::new(vt));
self
}
pub fn keyboard_options(mut self, opts: KeyboardOptions) -> Self {
self.keyboard_options = Some(opts);
self
}
pub fn ime_action(mut self, action: repose_core::ImeAction) -> Self {
self.ime_action = Some(action);
self
}
pub fn password(mut self) -> Self {
self.visual_transformation =
Some(Rc::new(repose_core::PasswordVisualTransformation::default()));
self
}
pub fn build(self) -> repose_core::View {
repose_core::View::new(
0,
repose_core::ViewKind::TextField {
state_key: 0,
hint: self.hint,
on_change: self.on_change,
on_submit: self.on_submit,
multiline: false,
focus_tracker: None,
value: self.value,
visual_transformation: self.visual_transformation,
keyboard_type: self.keyboard_options.map(|o| o.keyboard_type),
ime_action: self.ime_action,
},
)
.modifier(self.modifier)
.semantics(repose_core::Semantics {
role: repose_core::Role::TextField,
label: None,
focused: false,
enabled: true,
})
}
}
pub fn TextArea(
hint: impl Into<String>,
value: String,
modifier: repose_core::Modifier,
on_change: Option<impl Fn(String) + 'static>,
on_submit: Option<impl Fn(String) + 'static>,
) -> repose_core::View {
TextAreaEx {
hint: hint.into(),
value,
modifier,
on_change: on_change.map(|f| Rc::new(f) as _),
on_submit: on_submit.map(|f| Rc::new(f) as _),
visual_transformation: None,
keyboard_options: None,
ime_action: None,
}
.build()
}
pub struct TextAreaEx {
pub hint: String,
pub value: String,
pub modifier: repose_core::Modifier,
pub on_change: Option<Rc<dyn Fn(String)>>,
pub on_submit: Option<Rc<dyn Fn(String)>>,
pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
pub keyboard_options: Option<KeyboardOptions>,
pub ime_action: Option<repose_core::ImeAction>,
}
impl TextAreaEx {
pub fn new(hint: impl Into<String>, value: String, modifier: repose_core::Modifier) -> Self {
Self {
hint: hint.into(),
value,
modifier,
on_change: None,
on_submit: None,
visual_transformation: None,
keyboard_options: None,
ime_action: None,
}
}
pub fn on_change(mut self, f: impl Fn(String) + 'static) -> Self {
self.on_change = Some(Rc::new(f));
self
}
pub fn on_submit(mut self, f: impl Fn(String) + 'static) -> Self {
self.on_submit = Some(Rc::new(f));
self
}
pub fn visual_transformation(mut self, vt: impl repose_core::VisualTransformation) -> Self {
self.visual_transformation = Some(Rc::new(vt));
self
}
pub fn keyboard_options(mut self, opts: KeyboardOptions) -> Self {
self.keyboard_options = Some(opts);
self
}
pub fn ime_action(mut self, action: repose_core::ImeAction) -> Self {
self.ime_action = Some(action);
self
}
pub fn build(self) -> repose_core::View {
repose_core::View::new(
0,
repose_core::ViewKind::TextField {
state_key: 0,
hint: self.hint,
on_change: self.on_change,
on_submit: self.on_submit,
multiline: true,
focus_tracker: None,
value: self.value,
visual_transformation: self.visual_transformation,
keyboard_type: self.keyboard_options.map(|o| o.keyboard_type),
ime_action: self.ime_action,
},
)
.modifier(self.modifier)
.semantics(repose_core::Semantics {
role: repose_core::Role::TextField,
label: None,
focused: false,
enabled: true,
})
}
}
#[derive(Clone, Debug)]
pub struct TextAreaLayout {
pub ranges: Vec<(usize, usize)>,
pub line_h_px: f32,
}
pub fn layout_text_area(text: &str, font_px: f32, wrap_w_px: f32) -> TextAreaLayout {
let line_h = font_px * 1.3;
let (ranges, _) = repose_text::wrap_line_ranges(text, font_px, wrap_w_px.max(1.0), None, true);
TextAreaLayout {
ranges,
line_h_px: line_h,
}
}
fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
if ranges.is_empty() {
return (0, 0, b);
}
for (i, (s, e)) in ranges.iter().enumerate() {
if b < *s {
if i == 0 {
return (0, 0, b);
}
let (ps, pe) = ranges[i - 1];
let local = pe.saturating_sub(ps);
return (i - 1, local, ps + local);
}
if b < *e {
let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
return (i, local, *s + local);
}
if b == *e {
if let Some((ns, _ne)) = ranges.get(i + 1)
&& *ns == b
{
return (i + 1, 0, b);
}
let local = e.saturating_sub(*s);
return (i, local, *s + local);
}
}
let (ls, le) = ranges[ranges.len() - 1];
let local = le.saturating_sub(ls);
(ranges.len() - 1, local, ls + local)
}
pub fn caret_xy_for_byte(
text: &str,
font_px: f32,
wrap_w_px: f32,
byte: usize,
) -> (f32, f32, usize) {
let layout = layout_text_area(text, font_px, wrap_w_px);
let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
let (li, local, _) = locate_byte_in_ranges(ranges, byte);
let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
let line = &text[s..e];
let m = measure_text(line, font_px, None);
let ci = byte_to_char_index(&m, local);
let x = m.positions.get(ci).copied().unwrap_or(0.0);
let y = (li as f32) * line_h;
(x, y, li)
}
pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
let layout = layout_text_area(text, font_px, wrap_w_px);
let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
let li = li.min(layout.ranges.len().saturating_sub(1));
let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
let line = &text[s..e];
let local = index_for_x_bytes(line, font_px, x_px.max(0.0));
(s + local).min(text.len())
}
pub fn move_caret_vertical(
text: &str,
font_px: f32,
wrap_w_px: f32,
cur_byte: usize,
dir: i32, preferred_x: Option<f32>,
) -> (usize, f32) {
let layout = layout_text_area(text, font_px, wrap_w_px);
if layout.ranges.is_empty() {
return (cur_byte, preferred_x.unwrap_or(0.0));
}
let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
let px = preferred_x.unwrap_or(x);
let mut nli = li as i32 + dir;
nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
let nli = nli as usize;
let (s, e) = layout.ranges[nli];
let line = &text[s..e];
let local = index_for_x_bytes(line, font_px, px.max(0.0));
((s + local).min(text.len()), px)
}
pub fn line_home_end(
text: &str,
font_px: f32,
wrap_w_px: f32,
cur_byte: usize,
to_end: bool,
) -> usize {
let layout = layout_text_area(text, font_px, wrap_w_px);
let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
if to_end { e } else { s }
}
fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
if i >= s.len() {
return s.len();
}
if s.is_char_boundary(i) {
return i;
}
let mut j = i;
while j > 0 && !s.is_char_boundary(j) {
j -= 1;
}
j
}
fn char_to_byte(s: &str, ci: usize) -> usize {
if ci == 0 {
0
} else {
s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_index_for_x_bytes_grapheme() {
let t = "A👍🏽B";
let font_px = 16.0; let m = measure_text(t, font_px, None);
for i in 0..m.byte_offsets.len() - 1 {
let b = m.byte_offsets[i];
let _ = &t[..b];
}
}
}