use repose_core::*;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::ops::Range;
use std::rc::{Rc, Weak};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_segmentation::UnicodeSegmentation;
use web_time::Duration;
use web_time::Instant;
use crate::layout::mul_alpha_color;
use crate::text_selection::{
SELECTION_BACKGROUND_ALPHA, SelectionLine, push_selection, selection_brush, selection_rects,
};
static NEXT_TEXTFIELD_TRANSFORM_ID: AtomicU64 = AtomicU64::new(1);
thread_local! {
static TEXTFIELD_STATES: RefCell<HashMap<u64, Weak<RefCell<TextFieldState>>>> =
RefCell::new(HashMap::new());
static TEXTFIELD_TRANSFORMS: RefCell<HashMap<u64, TextFieldTransformEntry>> =
RefCell::new(HashMap::new());
static TEXTFIELD_TRANSFORM_MARKERS: RefCell<HashMap<u64, u64>> =
RefCell::new(HashMap::new());
}
#[derive(Clone)]
struct TextFieldTransforms {
visual: Option<Rc<dyn VisualTransformation>>,
input: Option<Rc<dyn InputTransformation>>,
output: Option<Rc<dyn OutputTransformation>>,
codepoint: Option<Rc<CodepointTransformation>>,
}
struct TextFieldTransformEntry {
transforms: TextFieldTransforms,
marker: Weak<TextFieldTransformMarker>,
}
#[derive(Debug)]
struct TextFieldTransformMarker(u64);
impl VisualTransformation for TextFieldTransformMarker {
fn filter(&self, text: &AnnotatedString) -> TransformedText {
let transforms = TEXTFIELD_TRANSFORMS.with(|all| {
all.borrow()
.get(&self.0)
.map(|entry| entry.transforms.clone())
});
let Some(transforms) = transforms else {
return TransformedText::new(text.clone(), Box::new(IdentityOffsetMapping));
};
let mut transformed = transforms.visual.as_ref().map_or_else(
|| TransformedText::new(text.clone(), Box::new(IdentityOffsetMapping)),
|visual| visual.filter(text),
);
if let Some(output) = &transforms.output {
let before = transformed.text.text.clone();
let mut buffer = TransformationBuffer::new(before.clone(), 0..before.len());
output.transform_output(&mut buffer);
let after = buffer.text;
let stage = CodepointOffsetMapping::new(&before, &after);
transformed.offset_mapping = Box::new(ComposedOffsetMapping {
first: transformed.offset_mapping,
second: Box::new(stage),
});
transformed.text.text = after;
}
if let Some(codepoint) = &transforms.codepoint {
let before = transformed.text.text.clone();
let after = before
.chars()
.enumerate()
.map(|(index, ch)| codepoint.transform(index, ch))
.collect::<String>();
let stage = CodepointOffsetMapping::new(&before, &after);
transformed.offset_mapping = Box::new(ComposedOffsetMapping {
first: transformed.offset_mapping,
second: Box::new(stage),
});
transformed.text.text = after;
}
transformed
}
}
pub fn set_textfield_state(key: u64, state: Rc<RefCell<TextFieldState>>) {
TEXTFIELD_STATES.with(|states| {
states.borrow_mut().insert(key, Rc::downgrade(&state));
});
}
pub fn get_textfield_state(key: u64) -> Option<Rc<RefCell<TextFieldState>>> {
TEXTFIELD_STATES.with(|states| states.borrow().get(&key).and_then(Weak::upgrade))
}
pub(crate) fn prune_textfield_registries() {
TEXTFIELD_STATES.with(|states| {
states
.borrow_mut()
.retain(|_, state| state.strong_count() != 0);
});
let mut live = std::collections::HashSet::new();
TEXTFIELD_TRANSFORMS.with(|transforms| {
transforms.borrow_mut().retain(|id, entry| {
let keep = entry.marker.strong_count() != 0;
if keep {
live.insert(*id);
}
keep
});
});
TEXTFIELD_TRANSFORM_MARKERS.with(|markers| {
markers.borrow_mut().retain(|_, id| live.contains(id));
});
}
pub fn ensure_caret_visible(state: &mut TextFieldState, multiline: bool) {
let metrics = TextFieldMetrics::default();
ensure_caret_visible_with_metrics(state, multiline, &metrics);
}
pub fn ensure_caret_visible_with_metrics(
state: &mut TextFieldState,
multiline: bool,
metrics: &TextFieldMetrics,
) {
let caret = state.caret_index();
let (display, display_caret) = if let Some(transformation) = &state.visual_transformation {
let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
let transformed = transformation.filter(&annotated);
let offset = transformed.offset_mapping.original_to_transformed(caret);
(transformed.text.text, offset)
} else {
(state.text.clone(), caret)
};
let wrap_width = state.inner_width.max(1.0);
if multiline {
let (cx, cy, _) =
caret_xy_for_byte_with_metrics(&display, wrap_width, display_caret, metrics);
state.ensure_caret_visible_xy(
cx,
cy,
state.inner_width,
state.inner_height,
Dp(2.0).to_px().0,
);
} else {
let measured = measure_text(&display, metrics.font_px, metrics.measure_config());
let caret_x = measured
.positions
.get(byte_to_char_index(&measured, display_caret))
.copied()
.unwrap_or(0.0);
state.ensure_caret_visible(caret_x, wrap_width, Dp(2.0).to_px().0);
}
}
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_SP: Sp = Sp(16.0);
#[derive(Clone, Copy, Debug)]
pub struct KeyboardOptions {
pub keyboard_type: repose_core::KeyboardType,
pub autocorrect: bool,
pub capitalization: repose_core::KeyboardCapitalization,
}
impl Default for KeyboardOptions {
fn default() -> Self {
Self {
keyboard_type: repose_core::KeyboardType::default(),
autocorrect: true,
capitalization: repose_core::KeyboardCapitalization::default(),
}
}
}
pub const TF_PADDING_X: Dp = Dp(8.0);
pub struct TextMetrics {
pub positions: Vec<f32>, pub byte_offsets: Vec<usize>,
}
#[derive(Clone)]
pub struct TextMeasureConfig {
pub font_family: Option<&'static str>,
pub font_weight: u16,
pub font_style: u8,
pub letter_spacing: f32,
pub font_variation_settings: Option<String>,
}
impl Default for TextMeasureConfig {
fn default() -> Self {
Self {
font_family: None,
font_weight: 400,
font_style: 0,
letter_spacing: 0.0,
font_variation_settings: None,
}
}
}
#[derive(Clone, Debug)]
pub struct TextFieldMetrics {
pub font_px: f32,
pub font_family: Option<&'static str>,
pub font_weight: u16,
pub font_style: u8,
pub letter_spacing_px: f32,
pub font_variation_settings: Option<String>,
pub line_height_px: f32,
}
impl Default for TextFieldMetrics {
fn default() -> Self {
let font_px = TF_FONT_SP.to_px().0;
Self {
font_px,
font_family: None,
font_weight: 400,
font_style: 0,
letter_spacing_px: 0.0,
font_variation_settings: None,
line_height_px: font_px,
}
}
}
impl TextFieldMetrics {
pub(crate) fn from_config(config: &TextInputConfig) -> Self {
let style = config.text_style.clone().unwrap_or_default();
let font_px = if style.font_size == Sp::ZERO {
TF_FONT_SP.to_px().0
} else {
style.font_size.to_px().0
};
Self {
font_px,
font_family: style.font_family,
font_weight: style.font_weight.unwrap_or(400),
font_style: style.font_style.unwrap_or(0),
letter_spacing_px: style.letter_spacing.to_px().0,
font_variation_settings: style.font_variation_settings.clone(),
line_height_px: if style.line_height == Sp::ZERO {
font_px
} else {
style.line_height.to_px().0
},
}
}
pub fn measure_config(&self) -> TextMeasureConfig {
TextMeasureConfig {
font_family: self.font_family,
font_weight: self.font_weight,
font_style: self.font_style,
letter_spacing: self.letter_spacing_px,
font_variation_settings: self.font_variation_settings.clone(),
}
}
}
struct TransformationBuffer {
text: String,
selection: Range<usize>,
original_text: String,
original_selection: Range<usize>,
}
impl TransformationBuffer {
fn new(text: String, selection: Range<usize>) -> Self {
let selection = clamp_range(&text, selection);
Self {
original_text: text.clone(),
original_selection: selection.clone(),
text,
selection,
}
}
fn normalized(&mut self) {
self.selection = clamp_range(&self.text, self.selection.clone());
}
}
impl TextFieldBuffer for TransformationBuffer {
fn text(&self) -> &str {
&self.text
}
fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.normalized();
}
fn selection(&self) -> TextRange {
TextRange::new(self.selection.start, self.selection.end)
}
fn set_selection(&mut self, selection: TextRange) {
self.selection = clamp_range(
&self.text,
selection.start.min(selection.end)..selection.start.max(selection.end),
);
}
fn length(&self) -> usize {
self.text.chars().count()
}
fn replace(&mut self, start: usize, end: usize, text: &str) {
let start = byte_for_char(&self.text, start);
let end = byte_for_char(&self.text, end);
self.text.replace_range(start..end.max(start), text);
self.selection = clamp_range(&self.text, start..start + text.len());
}
fn insert(&mut self, index: usize, text: &str) {
let start = byte_for_char(&self.text, index);
self.text.insert_str(start, text);
self.selection = clamp_range(&self.text, start..start + text.len());
}
fn delete(&mut self, start: usize, end: usize) {
let start = byte_for_char(&self.text, start);
let end = byte_for_char(&self.text, end);
self.text.replace_range(start..end.max(start), "");
self.selection = clamp_range(&self.text, start..start);
}
fn place_cursor_before_char_at(&mut self, index: usize) {
let start = byte_for_char(&self.text, index);
self.selection = clamp_range(&self.text, start..start);
}
fn place_cursor_at_end(&mut self) {
let end = self.text.len();
self.selection = end..end;
}
fn select_all(&mut self) {
self.selection = 0..self.text.len();
}
fn revert_all_changes(&mut self) {
self.text = self.original_text.clone();
self.selection = self.original_selection.clone();
}
fn original_text(&self) -> &str {
&self.original_text
}
fn original_selection(&self) -> TextRange {
TextRange::new(self.original_selection.start, self.original_selection.end)
}
fn has_selection(&self) -> bool {
self.selection.start != self.selection.end
}
}
fn byte_for_char(text: &str, index: usize) -> usize {
text.char_indices().nth(index).map_or(text.len(), |v| v.0)
}
fn clamp_range(text: &str, range: Range<usize>) -> Range<usize> {
let mut start = range.start.min(text.len());
let mut end = range.end.min(text.len());
while start > 0 && !text.is_char_boundary(start) {
start -= 1;
}
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
start..end
}
#[derive(Debug)]
struct CodepointOffsetMapping {
original: Vec<usize>,
transformed: Vec<usize>,
}
impl CodepointOffsetMapping {
fn new(original: &str, transformed: &str) -> Self {
let original_offsets: Vec<usize> = original
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(original.len()))
.collect();
let transformed_offsets: Vec<usize> = transformed
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(transformed.len()))
.collect();
if original_offsets.len() == transformed_offsets.len() {
Self {
original: original_offsets,
transformed: transformed_offsets,
}
} else {
Self {
original: vec![0, original.len()],
transformed: vec![0, transformed.len()],
}
}
}
fn map(offsets: &[usize], value: usize) -> usize {
let index = match offsets.binary_search(&value) {
Ok(index) => index,
Err(index) => index.saturating_sub(1),
};
offsets
.get(index)
.copied()
.or_else(|| offsets.last().copied())
.unwrap_or(0)
}
}
impl OffsetMapping for CodepointOffsetMapping {
fn original_to_transformed(&self, offset: usize) -> usize {
Self::map(&self.original, offset)
}
fn transformed_to_original(&self, offset: usize) -> usize {
Self::map(&self.transformed, offset)
}
fn clone_box(&self) -> Box<dyn OffsetMapping> {
Box::new(Self {
original: self.original.clone(),
transformed: self.transformed.clone(),
})
}
}
#[derive(Debug)]
struct ComposedOffsetMapping {
first: Box<dyn OffsetMapping>,
second: Box<dyn OffsetMapping>,
}
impl OffsetMapping for ComposedOffsetMapping {
fn original_to_transformed(&self, offset: usize) -> usize {
self.second
.original_to_transformed(self.first.original_to_transformed(offset))
}
fn transformed_to_original(&self, offset: usize) -> usize {
self.first
.transformed_to_original(self.second.transformed_to_original(offset))
}
fn clone_box(&self) -> Box<dyn OffsetMapping> {
Box::new(Self {
first: self.first.clone_box(),
second: self.second.clone_box(),
})
}
}
fn install_textfield_transforms(
visual: Option<Rc<dyn VisualTransformation>>,
input: Option<Rc<dyn InputTransformation>>,
output: Option<Rc<dyn OutputTransformation>>,
codepoint: Option<CodepointTransformation>,
) -> Option<Rc<dyn VisualTransformation>> {
if visual.is_none() && input.is_none() && output.is_none() && codepoint.is_none() {
return None;
}
let id = NEXT_TEXTFIELD_TRANSFORM_ID.fetch_add(1, Ordering::Relaxed);
let marker = Rc::new(TextFieldTransformMarker(id));
let pointer = Rc::as_ptr(&marker) as u64;
let visual_marker: Rc<dyn VisualTransformation> = marker.clone();
TEXTFIELD_TRANSFORMS.with(|all| {
all.borrow_mut().insert(
id,
TextFieldTransformEntry {
transforms: TextFieldTransforms {
visual,
input,
output,
codepoint: codepoint.map(Rc::new),
},
marker: Rc::downgrade(&marker),
},
);
});
TEXTFIELD_TRANSFORM_MARKERS.with(|markers| {
markers.borrow_mut().insert(pointer, id);
});
Some(visual_marker)
}
pub(crate) fn textfield_transform_id(transformation: &Rc<dyn VisualTransformation>) -> Option<u64> {
let pointer = Rc::as_ptr(transformation) as *const () as u64;
TEXTFIELD_TRANSFORM_MARKERS.with(|markers| markers.borrow().get(&pointer).copied())
}
fn apply_textfield_input_transformation_id(id: Option<u64>, state: &mut TextFieldState) {
let Some(id) = id else {
return;
};
let input = TEXTFIELD_TRANSFORMS.with(|all| {
all.borrow()
.get(&id)
.and_then(|entry| entry.transforms.input.clone())
});
let Some(input) = input else {
return;
};
let before = state.text.clone();
let composition = state.composition.clone();
let mut buffer = TransformationBuffer::new(state.text.clone(), state.selection.clone());
input.transform_input(&mut buffer);
state.text = buffer.text;
state.selection = buffer.selection;
if let Some(composition) = composition {
state.composition = Some(remap_text_range(&before, &state.text, composition));
}
}
fn remap_text_range(before: &str, after: &str, range: Range<usize>) -> Range<usize> {
if before == after {
return range;
}
let mut prefix = before
.as_bytes()
.iter()
.zip(after.as_bytes())
.take_while(|(left, right)| left == right)
.count();
while prefix > 0 && (!before.is_char_boundary(prefix) || !after.is_char_boundary(prefix)) {
prefix -= 1;
}
let max_suffix = (before.len() - prefix).min(after.len() - prefix);
let mut suffix = before[before.len() - max_suffix..]
.bytes()
.rev()
.zip(after[after.len() - max_suffix..].bytes().rev())
.take_while(|(left, right)| left == right)
.count();
while suffix > 0
&& (!before.is_char_boundary(before.len() - suffix)
|| !after.is_char_boundary(after.len() - suffix))
{
suffix -= 1;
}
let new_end = after.len() - suffix;
let old_start = prefix.min(range.start.min(before.len()));
let old_range_end = range.end.min(before.len()).max(old_start);
let map = |offset: usize| {
let offset = offset.clamp(old_start, old_range_end);
if offset == old_start {
prefix
} else if offset == old_range_end {
new_end
} else {
let relative = offset - old_start;
let changed_len = new_end.saturating_sub(prefix);
prefix + relative.min(changed_len)
}
};
let start = map(old_start);
let end = map(old_range_end);
clamp_range(after, start.min(end)..start.max(end))
}
pub fn apply_textfield_input_transformation(hit: &HitRegion, state: &mut TextFieldState) {
apply_textfield_input_transformation_id(crate::hit_testing::textfield_transform_id(hit), state);
}
pub fn dispatch_textfield_keyboard_action(
hit: &HitRegion,
action: repose_core::ImeAction,
default: &dyn Fn(),
) -> bool {
crate::hit_testing::dispatch_keyboard_action(hit, action, default)
}
pub fn textfield_metrics(hit: &HitRegion) -> TextFieldMetrics {
crate::hit_testing::textfield_metrics(hit)
}
pub fn insert_text_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
text: &str,
atomic: bool,
) -> bool {
perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
|state| {
if atomic {
state.insert_text_atomic(text);
} else {
state.insert_text(text);
}
},
)
.1
}
pub fn delete_backward_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
) -> bool {
perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
TextFieldState::delete_backward,
)
.1
}
pub fn delete_forward_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
) -> bool {
perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
TextFieldState::delete_forward,
)
.1
}
pub fn delete_surrounding_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
before_bytes: usize,
after_bytes: usize,
) -> bool {
perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
|state| {
state.delete_surrounding(before_bytes, after_bytes);
},
)
.1
}
pub fn cut_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
) -> Option<String> {
let selected = state.selected_text();
if selected.is_empty() {
return None;
}
let changed = perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
|state| state.insert_text_atomic(""),
)
.1;
changed.then_some(selected)
}
pub fn commit_composition_with_input_transformation(
hit: &HitRegion,
state: &mut TextFieldState,
text: String,
) {
perform_input_edit(
crate::hit_testing::textfield_transform_id(hit),
state,
|state| state.commit_composition(text),
);
}
pub fn undo_with_input_transformation(_hit: &HitRegion, state: &mut TextFieldState) -> bool {
state.undo()
}
pub fn redo_with_input_transformation(_hit: &HitRegion, state: &mut TextFieldState) -> bool {
state.redo()
}
pub(crate) fn insert_text_with_input_transformation_config(
config: &TextInputConfig,
state: &mut TextFieldState,
text: &str,
atomic: bool,
) -> bool {
let transform_id = config
.visual_transformation
.as_ref()
.and_then(textfield_transform_id);
perform_input_edit(transform_id, state, |state| {
if atomic {
state.insert_text_atomic(text);
} else {
state.insert_text(text);
}
})
.1
}
pub(crate) fn cut_with_input_transformation_config(
config: &TextInputConfig,
state: &mut TextFieldState,
) -> Option<String> {
let selected = state.selected_text();
if selected.is_empty() {
return None;
}
let transform_id = config
.visual_transformation
.as_ref()
.and_then(textfield_transform_id);
perform_input_edit(transform_id, state, |state| state.insert_text_atomic(""))
.1
.then_some(selected)
}
pub(crate) fn undo_with_input_transformation_config(
_config: &TextInputConfig,
state: &mut TextFieldState,
) -> bool {
state.undo()
}
pub(crate) fn redo_with_input_transformation_config(
_config: &TextInputConfig,
state: &mut TextFieldState,
) -> bool {
state.redo()
}
fn perform_input_edit<T>(
transform_id: Option<u64>,
state: &mut TextFieldState,
edit: impl FnOnce(&mut TextFieldState) -> T,
) -> (T, bool) {
let before = state.text.clone();
let before_selection = state.selection.clone();
let previous = state.staging_undo.take();
let redo = std::mem::take(&mut state.redo_stack);
let output = edit(state);
let Some(latest) = state.staging_undo.take() else {
state.staging_undo = previous;
state.redo_stack = redo;
return (output, false);
};
apply_textfield_input_transformation_id(transform_id, state);
let canonical = canonical_edit(
&before,
before_selection,
state,
latest.time,
latest.can_merge,
);
state.staging_undo = previous;
let Some(canonical) = canonical else {
state.redo_stack = redo;
return (output, false);
};
state.record_edit(canonical);
(output, true)
}
fn canonical_edit(
before: &str,
before_selection: Range<usize>,
state: &TextFieldState,
time: Instant,
can_merge: bool,
) -> Option<TextUndoOp> {
canonical_edit_between(
before,
before_selection,
&state.text,
state.selection.clone(),
time,
can_merge,
)
}
fn canonical_edit_between(
before: &str,
before_selection: Range<usize>,
after: &str,
after_selection: Range<usize>,
time: Instant,
can_merge: bool,
) -> Option<TextUndoOp> {
if before == after {
return None;
}
let mut prefix = before
.as_bytes()
.iter()
.zip(after.as_bytes())
.take_while(|(left, right)| left == right)
.count();
while prefix > 0 && (!before.is_char_boundary(prefix) || !after.is_char_boundary(prefix)) {
prefix -= 1;
}
let max_suffix = (before.len() - prefix).min(after.len() - prefix);
let mut suffix = before[before.len() - max_suffix..]
.bytes()
.rev()
.zip(after[after.len() - max_suffix..].bytes().rev())
.take_while(|(left, right)| left == right)
.count();
while suffix > 0
&& (!before.is_char_boundary(before.len() - suffix)
|| !after.is_char_boundary(after.len() - suffix))
{
suffix -= 1;
}
let pre_end = before.len() - suffix;
let post_end = after.len() - suffix;
Some(TextUndoOp {
index: prefix,
pre_text: before[prefix..pre_end].to_string(),
post_text: after[prefix..post_end].to_string(),
pre_selection: before_selection,
post_selection: after_selection,
time,
can_merge,
})
}
pub fn measure_text(text: &str, font_px: f32, config: TextMeasureConfig) -> TextMetrics {
let m = repose_text::metrics_for_textfield(
text,
font_px,
config.font_family,
config.font_weight,
config.font_style,
config.letter_spacing,
config.font_variation_settings.as_deref(),
);
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,
font_weight: u16,
font_style: u8,
) -> usize {
let m = measure_text(
text,
font_px,
TextMeasureConfig {
font_weight,
font_style,
..Default::default()
},
);
if m.positions.is_empty() {
return 0;
}
let mut best_i = 0usize;
let mut best_d = f32::INFINITY;
for (index, position) in m.positions.iter().enumerate() {
let distance = (position - x_px).abs();
if distance < best_d {
best_d = distance;
best_i = index;
}
}
m.byte_offsets[best_i]
}
pub(crate) 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
}
pub(crate) fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
for (i, _) in text.grapheme_indices(true) {
if i > byte {
return i;
}
}
text.len()
}
pub(crate) fn word_range(text: &str, byte: usize) -> (usize, usize) {
let byte = byte.min(text.len());
let mut start = byte;
while start > 0 {
let p = prev_grapheme_boundary(text, start);
if is_word_grapheme(&text[p..start]) {
start = p;
} else {
break;
}
}
let mut end = byte;
while end < text.len() {
let n = next_grapheme_boundary(text, end);
if is_word_grapheme(&text[end..n]) {
end = n;
} else {
break;
}
}
if start == end {
let s = if byte == 0 {
0
} else {
prev_grapheme_boundary(text, byte)
};
let e = next_grapheme_boundary(text, byte);
(s, e.max(s))
} else {
(start, end)
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_word_grapheme(grapheme: &str) -> bool {
!grapheme.is_empty() && grapheme.chars().all(is_word_char)
}
fn step_over(pos: &mut usize, text: &str, want_word: bool) {
while *pos < text.len() {
let next = next_grapheme_boundary(text, *pos);
if is_word_grapheme(&text[*pos..next]) != want_word {
return;
}
*pos = next;
}
}
fn step_back(pos: &mut usize, text: &str, want_word: bool) {
while *pos > 0 {
let prev = prev_grapheme_boundary(text, *pos);
if is_word_grapheme(&text[prev..*pos]) != want_word {
return;
}
*pos = prev;
}
}
pub fn prev_word_boundary(text: &str, byte: usize) -> usize {
let origin = byte.min(text.len());
let mut pos = origin;
step_back(&mut pos, text, true);
if pos == origin {
step_back(&mut pos, text, false);
step_back(&mut pos, text, true);
}
pos
}
pub fn next_word_boundary(text: &str, byte: usize) -> usize {
let origin = byte.min(text.len());
let mut pos = origin;
step_over(&mut pos, text, true);
if pos == origin {
step_over(&mut pos, text, false);
step_over(&mut pos, text, true);
}
pos
}
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(crate) last_tap_time: Option<Instant>,
pub(crate) last_tap_pos: Option<(f32, f32)>,
pub(crate) tap_count: u8,
pub blink_start: Instant, pub inner_width: f32, pub inner_height: f32, pub preferred_x_px: Option<f32>, pub offset_map: Option<Box<dyn OffsetMapping>>,
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(|_| "<offset_mapping>"),
)
.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 Clone for TextFieldState {
fn clone(&self) -> Self {
Self {
text: self.text.clone(),
selection: self.selection.clone(),
composition: self.composition.clone(),
scroll_offset: self.scroll_offset,
scroll_offset_y: self.scroll_offset_y,
drag_anchor: self.drag_anchor,
last_tap_time: self.last_tap_time,
last_tap_pos: self.last_tap_pos,
tap_count: self.tap_count,
blink_start: self.blink_start,
inner_width: self.inner_width,
inner_height: self.inner_height,
preferred_x_px: self.preferred_x_px,
offset_map: self.offset_map.as_ref().map(|m| m.clone_box()),
visual_transformation: self.visual_transformation.clone(),
scroll_target: self.scroll_target,
scroll_target_y: self.scroll_target_y,
scroll_vel: self.scroll_vel,
scroll_vel_y: self.scroll_vel_y,
last_scroll_tick: self.last_scroll_tick,
undo_stack: self.undo_stack.clone(),
redo_stack: self.redo_stack.clone(),
staging_undo: self.staging_undo.clone(),
}
}
}
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,
last_tap_time: None,
last_tap_pos: None,
tap_count: 0,
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 idx = clamp_to_char_boundary(&self.text, op.index.min(self.text.len()));
let end = clamp_to_char_boundary(
&self.text,
(op.index + op.post_text.len()).min(self.text.len()),
);
let end = end.max(idx);
self.text.replace_range(idx..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 idx = clamp_to_char_boundary(&self.text, op.index.min(self.text.len()));
let end = clamp_to_char_boundary(
&self.text,
(op.index + op.pre_text.len()).min(self.text.len()),
);
let end = end.max(idx);
self.text.replace_range(idx..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) {
self.redo_stack.clear();
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);
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 a = self.selection.start.min(self.text.len());
let b = self.selection.end.min(self.text.len());
let start = clamp_to_char_boundary(&self.text, a.min(b));
let end = clamp_to_char_boundary(&self.text, a.max(b));
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, by_word: bool) {
let origin = self.selection.end.min(self.text.len());
let pos = if !by_word {
let mut pos = origin;
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);
}
}
pos
} else if delta < 0 {
prev_word_boundary(&self.text, origin)
} else {
next_word_boundary(&self.text, origin)
};
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 {
let a = self.selection.start.min(self.text.len());
let b = self.selection.end.min(self.text.len());
let (lo, hi) = (a.min(b), a.max(b));
if lo == hi {
String::new()
} else {
let lo = clamp_to_char_boundary(&self.text, lo);
let hi = clamp_to_char_boundary(&self.text, hi);
self.text[lo..hi].to_string()
}
}
pub fn selection_range(&self) -> std::ops::Range<usize> {
let a = self.selection.start.min(self.text.len());
let b = self.selection.end.min(self.text.len());
let (mut lo, mut hi) = (a.min(b), a.max(b));
lo = clamp_to_char_boundary(&self.text, lo);
hi = clamp_to_char_boundary(&self.text, hi);
lo..hi
}
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 = clamp_cursor_in_str(&text, c0);
let b1 = clamp_cursor_in_str(&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 finish_composition(&mut self) {
self.composition = None;
self.preferred_x_px = None;
self.reset_caret_blink();
}
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();
}
}
fn clamp_cursor_in_str(s: &str, i: usize) -> usize {
let mut j = i.min(s.len());
while j > 0 && !s.is_char_boundary(j) {
j -= 1;
}
j
}
fn is_grapheme_boundary(text: &str, byte: usize) -> bool {
if byte == 0 || byte == text.len() {
return true;
}
if !text.is_char_boundary(byte) {
return false;
}
for (i, _) in text.grapheme_indices(true) {
if i == byte {
return true;
}
if i > byte {
return false;
}
}
false
}
fn snap_inward_start(text: &str, pos: usize) -> usize {
let pos = pos.min(text.len());
if is_grapheme_boundary(text, pos) {
return pos;
}
next_grapheme_boundary(text, pos)
}
fn snap_inward_end(text: &str, pos: usize) -> usize {
let pos = pos.min(text.len());
if is_grapheme_boundary(text, pos) {
return pos;
}
let mut b = pos;
while b > 0 && !text.is_char_boundary(b) {
b -= 1;
}
if is_grapheme_boundary(text, b) {
return b;
}
prev_grapheme_boundary(text, pos)
}
impl TextFieldState {
pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
let range = if self.selection.start != self.selection.end {
self.selection_range()
} else {
let caret = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
let start =
snap_inward_start(&self.text, caret.saturating_sub(before_bytes)).min(caret);
let end =
snap_inward_end(&self.text, (caret + after_bytes).min(self.text.len())).max(caret);
if start >= end {
self.preferred_x_px = None;
self.reset_caret_blink();
return;
}
start..end
};
let pre_text = self.text[range.clone()].to_string();
let pre_selection = self.selection.clone();
self.text.replace_range(range.clone(), "");
self.selection = range.start..range.start;
self.preferred_x_px = None;
self.reset_caret_blink();
self.record_edit(TextUndoOp {
index: range.start,
pre_text,
post_text: String::new(),
pre_selection,
post_selection: self.selection.clone(),
time: Instant::now(),
can_merge: true,
});
}
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();
if self.selection.start < self.selection.end {
repose_core::clipboard::set_primary_selection(
&self.text[self.selection.start..self.selection.end],
);
}
}
pub fn end_drag(&mut self) {
self.drag_anchor = None;
if self.selection.start < self.selection.end {
repose_core::clipboard::set_primary_selection(
&self.text[self.selection.start..self.selection.end],
);
}
}
pub fn handle_pointer_down(&mut self, idx_byte: usize, pos_px: (f32, f32), shift: bool) {
const DOUBLE_TAP_MS: u64 = 300;
const TAP_SLOP_PX: f32 = 12.0;
let now = Instant::now();
let mut count = self.tap_count;
if let (Some(t), Some(p)) = (self.last_tap_time, self.last_tap_pos) {
let dt = now.saturating_duration_since(t);
let dist = ((pos_px.0 - p.0).powi(2) + (pos_px.1 - p.1).powi(2)).sqrt();
if dt < Duration::from_millis(DOUBLE_TAP_MS) && dist < TAP_SLOP_PX {
count = count.saturating_add(1);
} else {
count = 1;
}
} else {
count = 1;
}
self.tap_count = count;
self.last_tap_time = Some(now);
self.last_tap_pos = Some(pos_px);
let idx = idx_byte.min(self.text.len());
if count > 3 {
count = (count - 1) % 3 + 1;
self.tap_count = count;
}
if count >= 3 {
self.selection = 0..self.text.len();
self.drag_anchor = None;
self.preferred_x_px = None;
self.reset_caret_blink();
if self.selection.end > 0 {
repose_core::clipboard::set_primary_selection(&self.text);
}
return;
}
if count == 2 {
let (s, e) = word_range(&self.text, idx);
self.selection = s..e;
self.drag_anchor = Some(s);
self.preferred_x_px = None;
self.reset_caret_blink();
if e > s {
repose_core::clipboard::set_primary_selection(&self.text[s..e]);
}
return;
}
self.begin_drag(idx, shift);
}
pub fn select_word_at(&mut self, byte: usize) {
let (s, e) = word_range(&self.text, byte.min(self.text.len()));
self.selection = s..e;
self.drag_anchor = Some(s);
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn select_all(&mut self) {
self.selection = 0..self.text.len();
self.drag_anchor = None;
self.preferred_x_px = None;
self.reset_caret_blink();
}
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 next_blink_deadline(&self) -> Option<Instant> {
if self.selection.start != self.selection.end {
return None;
}
const PERIOD_MS: u128 = 500;
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.blink_start).as_millis();
let next_tick = (elapsed / PERIOD_MS) + 1;
Some(self.blink_start + Duration::from_millis((next_tick * PERIOD_MS) as u64))
}
pub fn with_text(text: impl Into<String>) -> Self {
let mut s = Self::new();
s.text = text.into();
s.selection = 0..0;
s
}
pub fn place_cursor_at_end(&mut self) {
let len = self.text.len();
self.selection = len..len;
self.drag_anchor = None;
self.preferred_x_px = None;
self.reset_caret_blink();
}
pub fn apply_controlled_value(&mut self, value: &str) {
if self.text.as_str() == value {
return;
}
if self.composition.is_some() {
return;
}
self.text = value.to_string();
let len = self.text.len();
let ns = clamp_to_char_boundary(&self.text, self.selection.start.min(len));
let ne = clamp_to_char_boundary(&self.text, self.selection.end.min(len));
self.selection = ns..ne;
self.drag_anchor = None;
}
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;
}
}
}
}
}
#[derive(Clone)]
pub struct TextFieldConfig {
pub enabled: bool,
pub read_only: bool,
pub input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
pub text_style: repose_core::TextStyle,
pub keyboard_options: repose_core::KeyboardOptions,
pub on_keyboard_action: Option<Rc<dyn repose_core::KeyboardActionHandler>>,
pub line_limits: repose_core::TextFieldLineLimits,
pub on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
pub interaction_source: Option<repose_core::MutableInteractionSource>,
pub focus_tracker: Option<Rc<Cell<bool>>>,
pub cursor_brush: Option<repose_core::Brush>,
pub output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
pub decorator: Option<Rc<dyn repose_core::TextFieldDecorator>>,
pub codepoint_transformation: Option<repose_core::CodepointTransformation>,
pub text_obfuscation_mode: repose_core::TextObfuscationMode,
pub text_obfuscation_character: char,
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 decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
}
impl Default for TextFieldConfig {
fn default() -> Self {
Self {
enabled: true,
read_only: false,
input_transformation: None,
text_style: Default::default(),
keyboard_options: repose_core::KeyboardOptions::DEFAULT,
on_keyboard_action: None,
line_limits: repose_core::TextFieldLineLimits::MultiLine {
min_height_in_lines: 1,
max_height_in_lines: usize::MAX,
},
on_text_layout: None,
interaction_source: None,
focus_tracker: None,
cursor_brush: None,
output_transformation: None,
decorator: None,
codepoint_transformation: None,
text_obfuscation_mode: repose_core::TextObfuscationMode::System,
text_obfuscation_character: '\u{2022}',
on_change: None,
on_submit: None,
visual_transformation: None,
decoration_box: None,
}
}
}
pub fn BasicTextField(
state: Rc<RefCell<TextFieldState>>,
modifier: repose_core::Modifier,
hint: impl Into<String>,
config: TextFieldConfig,
) -> repose_core::View {
let (single_line, max_lines, min_lines) = match config.line_limits {
repose_core::TextFieldLineLimits::SingleLine => (true, 1, 1),
repose_core::TextFieldLineLimits::MultiLine {
min_height_in_lines,
max_height_in_lines,
} => (false, max_height_in_lines, min_height_in_lines),
};
let ka = if let Some(ref handler) = config.on_keyboard_action {
let default_submit = config.on_submit.clone();
let default_state = state.clone();
let make_action = move |handler: Rc<dyn repose_core::KeyboardActionHandler>| {
let default_submit = default_submit.clone();
let default_state = default_state.clone();
Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
handler.on_keyboard_action(&|| {
if let Some(callback) = &default_submit {
let text = default_state.borrow().text.clone();
callback(text);
}
});
}) as Rc<dyn Fn(&dyn repose_core::KeyboardActionScope)>
};
repose_core::KeyboardActions {
on_done: Some(make_action(handler.clone())),
on_go: Some(make_action(handler.clone())),
on_next: Some(make_action(handler.clone())),
on_previous: Some(make_action(handler.clone())),
on_search: Some(make_action(handler.clone())),
on_send: Some(make_action(handler.clone())),
}
} else {
repose_core::KeyboardActions::default()
};
let transform_cache_key = format!(
"textfield-transforms:{:x}:{:?}:{:x}:{:x}:{:x}:{}",
Rc::as_ptr(&state) as usize,
modifier.key,
config
.visual_transformation
.as_ref()
.map(|value| Rc::as_ptr(value) as *const () as usize)
.unwrap_or(0),
config
.input_transformation
.as_ref()
.map(|value| Rc::as_ptr(value) as *const () as usize)
.unwrap_or(0),
config
.output_transformation
.as_ref()
.map(|value| Rc::as_ptr(value) as *const () as usize)
.unwrap_or(0),
config.codepoint_transformation.is_some(),
);
let visual_transformation = remember_with_key(transform_cache_key, || {
install_textfield_transforms(
config.visual_transformation,
config.input_transformation,
config.output_transformation,
config.codepoint_transformation,
)
});
let visual_transformation = visual_transformation.as_ref().clone();
let decoration_box = config
.decorator
.map(|d| Rc::new(move |inner: repose_core::View| d.decorate(inner)) as Rc<dyn Fn(_) -> _>);
let cursor_color = config.cursor_brush.and_then(|b| match b {
repose_core::Brush::Solid(c) => Some(c),
_ => None,
});
let value = state.borrow().text.clone();
let key = state.as_ptr() as u64;
set_textfield_state(key, state.clone());
let state_on_change = {
let s = state.clone();
move |new_value: String| {
s.borrow_mut().text = new_value;
}
};
let merged_on_change: Option<Rc<dyn Fn(String)>> =
if let Some(ref cfg_on_change) = config.on_change {
let a = Rc::new(state_on_change) as Rc<dyn Fn(String)>;
let b = cfg_on_change.clone();
Some(Rc::new(move |v: String| {
a(v.clone());
b(v);
}) as Rc<dyn Fn(String)>)
} else {
Some(Rc::new(state_on_change) as Rc<dyn Fn(String)>)
};
text_field_view(
modifier,
hint.into(),
value,
!single_line,
merged_on_change,
config.on_submit,
visual_transformation,
config.keyboard_options.keyboard_type,
config.keyboard_options.capitalization,
config.keyboard_options.ime_action,
config.keyboard_options.auto_correct_enabled,
config.enabled,
config.read_only,
Some(max_lines),
min_lines,
cursor_color,
config.on_text_layout,
config.text_style,
ka,
config.interaction_source,
config.focus_tracker,
Some(config.line_limits),
decoration_box,
)
}
pub fn BasicSecureTextField(
state: Rc<RefCell<TextFieldState>>,
modifier: repose_core::Modifier,
hint: impl Into<String>,
config: TextFieldConfig,
) -> repose_core::View {
let mask = config.text_obfuscation_character;
let secure_config = TextFieldConfig {
line_limits: repose_core::TextFieldLineLimits::SingleLine,
keyboard_options: repose_core::KeyboardOptions::SECURE_TEXT_FIELD,
visual_transformation: match config.text_obfuscation_mode {
repose_core::TextObfuscationMode::Visible => None,
_ => Some(Rc::new(repose_core::PasswordVisualTransformation { mask })
as Rc<dyn repose_core::VisualTransformation>),
},
..config
};
BasicTextField(state, modifier, hint, secure_config)
}
#[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,
font_weight: u16,
font_style: u8,
letter_spacing: f32,
font_variation_settings: Option<&str>,
) -> TextAreaLayout {
layout_text_area_with_metrics(
text,
wrap_w_px,
&TextFieldMetrics {
font_px,
font_weight,
font_style,
letter_spacing_px: letter_spacing,
font_variation_settings: font_variation_settings.map(str::to_string),
line_height_px: font_px,
..TextFieldMetrics::default()
},
)
}
pub fn layout_text_area_with_metrics(
text: &str,
wrap_w_px: f32,
metrics: &TextFieldMetrics,
) -> TextAreaLayout {
let (ranges, _) = repose_text::wrap_line_ranges(
text,
metrics.font_px,
wrap_w_px.max(1.0),
None,
true,
metrics.font_weight,
metrics.font_style,
metrics.letter_spacing_px,
metrics.font_variation_settings.as_deref(),
);
TextAreaLayout {
ranges,
line_h_px: metrics.line_height_px,
}
}
fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
if ranges.is_empty() {
return (0, 0, b);
}
let next = ranges.partition_point(|(start, _)| *start <= b);
if next == 0 {
return (0, 0, b);
}
let index = next - 1;
let (start, end) = ranges[index];
let local = b.saturating_sub(start).min(end.saturating_sub(start));
(index, local, start + local)
}
pub fn caret_xy_for_byte(
text: &str,
font_px: f32,
wrap_w_px: f32,
byte: usize,
) -> (f32, f32, usize) {
caret_xy_for_byte_with_metrics(
text,
wrap_w_px,
byte,
&TextFieldMetrics {
font_px,
line_height_px: font_px,
..TextFieldMetrics::default()
},
)
}
pub fn caret_xy_for_byte_with_metrics(
text: &str,
wrap_w_px: f32,
byte: usize,
metrics: &TextFieldMetrics,
) -> (f32, f32, usize) {
let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
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 measured = measure_text(line, metrics.font_px, metrics.measure_config());
let ci = byte_to_char_index(&measured, local);
let x = measured.positions.get(ci).copied().unwrap_or(0.0);
(x, li as f32 * line_h, li)
}
pub fn index_for_x_bytes_with_config(
text: &str,
font_px: f32,
x_px: f32,
config: TextMeasureConfig,
) -> usize {
let measured = measure_text(text, font_px, config);
if measured.positions.is_empty() {
return 0;
}
let mut best_i = 0usize;
let mut best_d = f32::INFINITY;
for (index, position) in measured.positions.iter().enumerate() {
let distance = (position - x_px).abs();
if distance < best_d {
best_d = distance;
best_i = index;
}
}
measured.byte_offsets[best_i]
}
pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
index_for_xy_bytes_with_metrics(
text,
wrap_w_px,
x_px,
y_px,
&TextFieldMetrics {
font_px,
line_height_px: font_px,
..TextFieldMetrics::default()
},
)
}
pub fn index_for_xy_bytes_with_metrics(
text: &str,
wrap_w_px: f32,
x_px: f32,
y_px: f32,
metrics: &TextFieldMetrics,
) -> usize {
let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
let line = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
let line = line.min(layout.ranges.len().saturating_sub(1));
let (start, end) = layout.ranges.get(line).copied().unwrap_or((0, 0));
let local = index_for_x_bytes_with_config(
&text[start..end],
metrics.font_px,
x_px.max(0.0),
metrics.measure_config(),
);
(start + 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) {
move_caret_vertical_with_metrics(
text,
wrap_w_px,
cur_byte,
dir,
preferred_x,
&TextFieldMetrics {
font_px,
line_height_px: font_px,
..TextFieldMetrics::default()
},
)
}
pub fn move_caret_vertical_with_metrics(
text: &str,
wrap_w_px: f32,
cur_byte: usize,
dir: i32,
preferred_x: Option<f32>,
metrics: &TextFieldMetrics,
) -> (usize, f32) {
let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
if layout.ranges.is_empty() {
return (cur_byte, preferred_x.unwrap_or(0.0));
}
let (x, _, line) = caret_xy_for_byte_with_metrics(text, wrap_w_px, cur_byte, metrics);
let x = preferred_x.unwrap_or(x);
let line = (line as i32 + dir).clamp(0, layout.ranges.len().saturating_sub(1) as i32) as usize;
let (start, end) = layout.ranges[line];
let local = index_for_x_bytes_with_config(
&text[start..end],
metrics.font_px,
x.max(0.0),
metrics.measure_config(),
);
((start + local).min(text.len()), x)
}
pub fn line_home_end(
text: &str,
font_px: f32,
wrap_w_px: f32,
cur_byte: usize,
to_end: bool,
) -> usize {
line_home_end_with_metrics(
text,
wrap_w_px,
cur_byte,
to_end,
&TextFieldMetrics {
font_px,
line_height_px: font_px,
..TextFieldMetrics::default()
},
)
}
pub fn line_home_end_with_metrics(
text: &str,
wrap_w_px: f32,
cur_byte: usize,
to_end: bool,
metrics: &TextFieldMetrics,
) -> usize {
let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
let (line, _, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
let (start, end) = layout.ranges.get(line).copied().unwrap_or((0, 0));
if cur_byte < start || cur_byte > end {
return cur_byte;
}
if to_end { end } else { start }
}
fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
if i >= s.len() {
return s.len();
}
let mut j = i;
while j > 0 && !s.is_char_boundary(j) {
j -= 1;
}
if j == 0 || j == s.len() || is_grapheme_boundary(s, j) {
return j;
}
prev_grapheme_boundary(s, j)
}
fn hint_line(
text_input: &TextInputConfig,
metrics: &TextFieldMetrics,
font_px: f32,
max_width: f32,
) -> String {
repose_text::ellipsize_line_with_family(
&text_input.hint,
font_px,
max_width,
metrics.font_family,
metrics.font_weight,
metrics.font_style,
metrics.letter_spacing_px,
metrics.font_variation_settings.as_deref(),
)
}
pub(crate) fn paint_text_field(
scene: &mut Scene,
rect: repose_core::Rect,
text_input: &TextInputConfig,
state: Option<&Rc<RefCell<TextFieldState>>>,
is_focused: bool,
alpha_accum: f32,
) {
let ts = text_input.text_style.clone().unwrap_or_default();
let metrics = TextFieldMetrics::from_config(text_input);
let font_val = metrics.font_px;
let line_h = if text_input.multiline {
metrics.line_height_px
} else {
font_val
};
let text_off_y = (rect.h - line_h.max(font_val)) / 2.0;
scene.nodes.push(SceneNode::PushClip {
rect,
radius: [Px::ZERO; 4],
op: repose_core::ClipOp::Intersect,
});
let th = locals::theme();
let show_selection = text_input.enabled;
let show_cursor = text_input.enabled && !text_input.read_only;
let cursor_color = text_input.cursor_color.unwrap_or(th.on_surface);
let rendered_by_vt = |original: &str| -> String {
if let Some(ref vt) = text_input.visual_transformation {
let annotated = repose_core::AnnotatedString::new(original.to_string(), vec![]);
vt.filter(&annotated).text.text
} else {
original.to_string()
}
};
if let Some(state_rc) = state {
let st = state_rc.borrow();
if !text_input.multiline {
let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
rendered_by_vt(&st.text)
} else {
st.text.clone()
};
let has_vt = text_input.visual_transformation.is_some();
let m = measure_text(&measure_for, font_val, metrics.measure_config());
if show_selection && st.selection.start != st.selection.end {
let start_off = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&measure_for,
st.selection.start,
st.offset_map.as_deref(),
)
} else {
st.selection.start
};
let end_off = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&measure_for,
st.selection.end,
st.offset_map.as_deref(),
)
} else {
st.selection.end
};
let sx = m
.positions
.get(byte_to_char_index(&m, start_off))
.copied()
.unwrap_or(0.0)
- st.scroll_offset;
let ex = m
.positions
.get(byte_to_char_index(&m, end_off))
.copied()
.unwrap_or(sx)
- st.scroll_offset;
let selection = th.primary.with_alpha(SELECTION_BACKGROUND_ALPHA);
let vis_x = sx.max(0.0);
let vis_ex = ex.max(0.0);
scene.nodes.push(SceneNode::Rect {
rect: repose_core::Rect {
x: rect.x + vis_x,
y: rect.y + text_off_y,
w: (vis_ex - vis_x).max(0.0),
h: line_h.max(font_val),
},
brush: Brush::Solid(selection),
radius: [Px::ZERO; 4],
});
}
if let Some(comp) = st.composition.clone() {
let cs = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&measure_for,
comp.start,
st.offset_map.as_deref(),
)
} else {
comp.start
};
let ce = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&measure_for,
comp.end,
st.offset_map.as_deref(),
)
} else {
comp.end
};
let sx = m
.positions
.get(byte_to_char_index(&m, cs))
.copied()
.unwrap_or(0.0)
- st.scroll_offset;
let ex = m
.positions
.get(byte_to_char_index(&m, ce))
.copied()
.unwrap_or(sx)
- st.scroll_offset;
let y = rect.y + text_off_y + line_h.max(font_val) - Dp(2.0).to_px().0;
scene.nodes.push(SceneNode::Rect {
rect: repose_core::Rect {
x: rect.x + sx.max(0.0),
y,
w: (ex - sx).max(Dp(2.0).to_px().0),
h: Dp(2.0).to_px().0,
},
brush: Brush::Solid(th.focus),
radius: [Px::ZERO; 4],
});
}
let display_src = if st.text.is_empty() && !text_input.value.is_empty() {
text_input.value.as_str()
} else {
st.text.as_str()
};
let txt_col = if display_src.is_empty() {
ts.color.unwrap_or(th.on_surface_variant)
} else {
ts.color.unwrap_or(th.on_surface)
};
let render_txt = if display_src.is_empty() {
hint_line(text_input, &metrics, font_val, rect.w)
} else {
rendered_by_vt(display_src)
};
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x - st.scroll_offset,
y: rect.y + text_off_y,
w: rect.w,
h: line_h,
},
text: Arc::from(render_txt),
color: mul_alpha_color(txt_col, alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
text_align: ts.text_align,
font_weight: FontWeight(metrics.font_weight),
font_style: match metrics.font_style {
1 => FontStyle::Italic,
_ => FontStyle::Normal,
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
..Default::default()
},
});
if show_cursor
&& is_focused
&& st.selection.start == st.selection.end
&& st.caret_visible()
{
let caret_off = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&measure_for,
st.selection.end,
st.offset_map.as_deref(),
)
} else {
st.selection.end
};
let cx = m
.positions
.get(byte_to_char_index(&m, caret_off))
.copied()
.unwrap_or(0.0)
- st.scroll_offset;
let cursor_y = rect.y + text_off_y + (line_h.max(font_val) - font_val) / 2.0;
scene.nodes.push(SceneNode::Rect {
rect: repose_core::Rect {
x: rect.x + cx.max(0.0),
y: cursor_y,
w: Dp(1.0).to_px().0,
h: font_val,
},
brush: Brush::Solid(cursor_color),
radius: [Px::ZERO; 4],
});
}
} else {
let render_text = if st.text.is_empty() {
st.text.clone()
} else if let Some(ref vt) = text_input.visual_transformation {
let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
vt.filter(&annotated).text.text
} else {
st.text.clone()
};
let layout = layout_text_area_with_metrics(&render_text, rect.w.max(1.0), &metrics);
let lh = layout.line_h_px;
let max_line_count = text_input.max_lines.unwrap_or(usize::MAX);
let visible_line_count = layout.ranges.len().min(max_line_count);
let line_step = if lh.is_finite() && lh > 0.0 { lh } else { 1.0 };
let first_visible_line = ((((st.scroll_offset_y - 1.0) / line_step).floor().max(0.0)
as usize)
.saturating_sub(1))
.min(visible_line_count);
let last_visible_line = (((st.scroll_offset_y + rect.h + 1.0) / line_step)
.ceil()
.max(0.0) as usize)
.min(visible_line_count)
.max(first_visible_line);
if st.text.is_empty() {
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x,
y: rect.y,
w: rect.w,
h: line_h,
},
text: Arc::from(hint_line(text_input, &metrics, font_val, rect.w)),
color: mul_alpha_color(ts.color.unwrap_or(th.on_surface_variant), alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
text_align: ts.text_align,
font_weight: FontWeight(metrics.font_weight),
font_style: match metrics.font_style {
1 => FontStyle::Italic,
_ => FontStyle::Normal,
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics
.font_variation_settings
.clone()
.map(Arc::from),
..Default::default()
},
});
} else {
for i in first_visible_line..last_visible_line {
let (s, e) = layout.ranges[i];
let ln = render_text[s..e].to_string();
let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
continue;
}
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x,
y: draw_y,
w: rect.w,
h: lh,
},
text: Arc::<str>::from(ln),
color: mul_alpha_color(ts.color.unwrap_or(th.on_surface), alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
text_align: ts.text_align,
font_weight: FontWeight(metrics.font_weight),
font_style: match metrics.font_style {
1 => FontStyle::Italic,
_ => FontStyle::Normal,
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics
.font_variation_settings
.clone()
.map(Arc::from),
..Default::default()
},
});
}
}
if show_selection && st.selection.start != st.selection.end {
let sel_a_orig: usize = st.selection.start.min(st.selection.end);
let sel_b_orig: usize = st.selection.start.max(st.selection.end);
let has_vt = text_input.visual_transformation.is_some();
let sel_a = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&render_text,
sel_a_orig,
st.offset_map.as_deref(),
)
} else {
sel_a_orig
};
let sel_b = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&render_text,
sel_b_orig,
st.offset_map.as_deref(),
)
} else {
sel_b_orig
};
let measure_cfg = metrics.measure_config();
let x_for = |line: &SelectionLine, byte: usize| -> f32 {
let m = measure_text(
&render_text[line.start..line.end],
font_val,
measure_cfg.clone(),
);
m.positions
.get(byte_to_char_index(&m, byte.saturating_sub(line.start)))
.copied()
.unwrap_or(0.0)
};
let sel_lines: Vec<SelectionLine> = (first_visible_line..last_visible_line)
.filter_map(|i| {
let &(start, end) = layout.ranges.get(i)?;
let line_w =
measure_text(&render_text[start..end], font_val, measure_cfg.clone())
.positions
.last()
.copied()
.unwrap_or(0.0);
Some(SelectionLine {
start,
end,
left: 0.0,
right: line_w,
top: (i as f32) * lh - st.scroll_offset_y,
height: lh,
})
})
.collect();
let rects = selection_rects(&sel_lines, &(sel_a..sel_b), &x_for, (rect.x, rect.y));
push_selection(scene, rects, selection_brush(th.primary));
}
if let Some(comp) = st.composition.clone() {
let has_vt = text_input.visual_transformation.is_some();
let comp_a = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&render_text,
comp.start,
st.offset_map.as_deref(),
)
} else {
comp.start
};
let comp_b = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&render_text,
comp.end,
st.offset_map.as_deref(),
)
} else {
comp.end
};
for i in first_visible_line..last_visible_line {
let (s, e) = layout.ranges[i];
let os = comp_a.max(s);
let oe = comp_b.min(e);
if os >= oe {
continue;
}
let ln = &render_text[s..e];
let m = measure_text(ln, font_val, metrics.measure_config());
let ls = os - s;
let le = oe - s;
let sx = m
.positions
.get(byte_to_char_index(&m, ls))
.copied()
.unwrap_or(0.0);
let ex = m
.positions
.get(byte_to_char_index(&m, le))
.copied()
.unwrap_or(sx);
let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
scene.nodes.push(SceneNode::Rect {
rect: repose_core::Rect {
x: rect.x + sx,
y: draw_y + lh - Dp(2.0).to_px().0,
w: (ex - sx).max(Dp(2.0).to_px().0),
h: Dp(2.0).to_px().0,
},
brush: Brush::Solid(th.focus),
radius: [Px::ZERO; 4],
});
}
}
if show_cursor
&& is_focused
&& st.selection.start == st.selection.end
&& st.caret_visible()
{
let caret_orig = st.selection.end.min(st.text.len());
let has_vt = text_input.visual_transformation.is_some();
let caret = if has_vt {
original_offset_to_display_with_mapping(
&st.text,
&render_text,
caret_orig,
st.offset_map.as_deref(),
)
} else {
caret_orig
};
let (cx, cy, _li) =
caret_xy_for_byte_with_metrics(&render_text, rect.w.max(1.0), caret, &metrics);
let draw_x = rect.x + cx;
let draw_y = rect.y + cy - st.scroll_offset_y;
scene.nodes.push(SceneNode::Rect {
rect: repose_core::Rect {
x: draw_x,
y: draw_y + (lh - font_val) / 2.0,
w: Dp(1.0).to_px().0,
h: font_val,
},
brush: Brush::Solid(cursor_color),
radius: [Px::ZERO; 4],
});
}
}
} else {
if text_input.value.is_empty() {
let hint_y = if text_input.multiline {
rect.y
} else {
rect.y + text_off_y
};
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x,
y: hint_y,
w: rect.w,
h: line_h,
},
text: Arc::from(hint_line(text_input, &metrics, font_val, rect.w)),
color: mul_alpha_color(th.on_surface_variant, alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
font_weight: FontWeight(metrics.font_weight),
font_style: if metrics.font_style == 1 {
FontStyle::Italic
} else {
FontStyle::Normal
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
..Default::default()
},
});
} else if text_input.multiline {
let render_text = if text_input.value.is_empty() {
text_input.value.clone()
} else if let Some(ref vt) = text_input.visual_transformation {
let annotated = repose_core::AnnotatedString::new(text_input.value.clone(), vec![]);
vt.filter(&annotated).text.text
} else {
text_input.value.clone()
};
let layout = layout_text_area_with_metrics(&render_text, rect.w.max(1.0), &metrics);
let lh = layout.line_h_px;
let line_step = if lh.is_finite() && lh > 0.0 { lh } else { 1.0 };
let first_visible_line =
((-1.0 / line_step).floor().max(0.0) as usize).min(layout.ranges.len());
let last_visible_line =
((rect.h + 1.0) / line_step).ceil().max(0.0) as usize + first_visible_line;
for i in first_visible_line..last_visible_line.min(layout.ranges.len()) {
let (s, e) = layout.ranges[i];
let ln = render_text[s..e].to_string();
let draw_y = rect.y + (i as f32) * lh;
if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
continue;
}
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x,
y: draw_y,
w: rect.w,
h: lh,
},
text: Arc::<str>::from(ln),
color: mul_alpha_color(th.on_surface, alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
font_weight: FontWeight(metrics.font_weight),
font_style: if metrics.font_style == 1 {
FontStyle::Italic
} else {
FontStyle::Normal
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics
.font_variation_settings
.clone()
.map(Arc::from),
..Default::default()
},
});
}
} else {
scene.nodes.push(SceneNode::Text {
rect: repose_core::Rect {
x: rect.x,
y: rect.y + text_off_y,
w: rect.w,
h: line_h,
},
text: Arc::from(rendered_by_vt(&text_input.value)),
color: mul_alpha_color(th.on_surface, alpha_accum),
size: Px(font_val),
style: TextPaintStyle {
font_family: metrics.font_family,
font_weight: FontWeight(metrics.font_weight),
font_style: if metrics.font_style == 1 {
FontStyle::Italic
} else {
FontStyle::Normal
},
text_decoration: ts.text_decoration.unwrap_or_default(),
letter_spacing: Px(metrics.letter_spacing_px),
line_height: Px(metrics.line_height_px),
font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
..Default::default()
},
});
}
}
if let Some(ref cb) = text_input.on_text_layout {
let (
line_count,
content_w,
content_h,
first_baseline,
last_baseline,
did_overflow_w,
did_overflow_h,
lines,
) = if let Some(state_rc) = state {
let st = state_rc.borrow();
let display = if st.text.is_empty() {
hint_line(text_input, &metrics, font_val, rect.w)
} else if let Some(ref vt) = text_input.visual_transformation {
let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
vt.filter(&annotated).text.text
} else {
st.text.clone()
};
if text_input.multiline {
let l = layout_text_area_with_metrics(&display, rect.w.max(1.0), &metrics);
let lc = l.ranges.len();
let cw = rect.w.max(0.0);
let ch = (lc as f32 * l.line_h_px).max(0.0);
let line_infos: Vec<_> = l
.ranges
.iter()
.enumerate()
.map(|(i, &(s, e))| {
let top = i as f32 * l.line_h_px;
let bottom = top + l.line_h_px;
let line_text = &display[s..e];
let m = measure_text(line_text, font_val, metrics.measure_config());
let line_w = m.positions.last().copied().unwrap_or(0.0);
TextLineInfo {
start: s,
end: e,
top,
baseline: top + l.line_h_px * 0.8,
bottom,
left: 0.0,
right: line_w,
width: line_w,
}
})
.collect();
let fb = line_infos.first().map(|l| l.baseline).unwrap_or(0.0);
let lb = line_infos.last().map(|l| l.baseline).unwrap_or(0.0);
(lc, cw, ch, fb, lb, cw > rect.w, ch > rect.h, line_infos)
} else {
let m = measure_text(&display, font_val, metrics.measure_config());
let w = m.positions.last().copied().unwrap_or(0.0);
let top = 0.0;
let bottom = line_h.max(font_val);
let baseline = bottom * 0.8;
let line_info = TextLineInfo {
start: 0,
end: display.len(),
top,
baseline,
bottom,
left: 0.0,
right: w,
width: w,
};
(
1,
w.max(0.0),
bottom,
baseline,
baseline,
w > rect.w,
bottom > rect.h,
vec![line_info],
)
}
} else {
(0, 0.0, 0.0, 0.0, 0.0, false, false, vec![])
};
cb(&repose_core::TextLayoutResult {
line_count,
width_px: content_w,
height_px: content_h,
first_baseline,
last_baseline,
did_overflow_width: did_overflow_w,
did_overflow_height: did_overflow_h,
lines,
});
}
scene.nodes.push(SceneNode::PopClip);
}
fn text_field_view(
modifier: Modifier,
hint: String,
value: String,
multiline: bool,
on_change: Option<Rc<dyn Fn(String)>>,
on_submit: Option<Rc<dyn Fn(String)>>,
visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
keyboard_type: repose_core::KeyboardType,
capitalization: repose_core::KeyboardCapitalization,
ime_action: repose_core::ImeAction,
auto_correct_enabled: Option<bool>,
enabled: bool,
read_only: bool,
max_lines: Option<usize>,
min_lines: usize,
cursor_color: Option<Color>,
on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
text_style: repose_core::TextStyle,
keyboard_actions: repose_core::KeyboardActions,
interaction_source: Option<repose_core::MutableInteractionSource>,
focus_tracker: Option<Rc<Cell<bool>>>,
line_limits: Option<repose_core::TextFieldLineLimits>,
decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
) -> View {
let mut modif = modifier.text_input(TextInputConfig {
hint,
multiline,
on_change,
on_submit,
focus_tracker,
value,
visual_transformation,
keyboard_type,
capitalization,
ime_action,
auto_correct_enabled,
enabled,
read_only,
sensitive: matches!(
keyboard_type,
repose_core::KeyboardType::Password
| repose_core::KeyboardType::NumberPassword
| repose_core::KeyboardType::DecimalPassword
| repose_core::KeyboardType::NumberPasswordSigned
| repose_core::KeyboardType::DecimalPasswordSigned
),
max_lines,
min_lines,
cursor_color,
on_text_layout,
text_style: Some(text_style),
keyboard_actions: Some(keyboard_actions),
interaction_source: interaction_source.as_ref().map(|s| s.source()),
line_limits,
});
if !enabled {
modif = modif.focusable(false).enabled(false);
} else if read_only {
modif = modif.focusable(true);
}
let inner = View::new(0, ViewKind::Box)
.modifier(modif)
.semantics(Semantics {
role: Role::TextField,
enabled,
..Default::default()
});
if let Some(decorate) = decoration_box {
decorate(inner)
} else {
inner
}
}
pub fn tf_ensure_visible_in_rect(state: &mut TextFieldState, inner_rect: repose_core::Rect) {
use crate::textfield::{TF_PADDING_X, TextFieldMetrics};
let metrics = TextFieldMetrics::default();
let caret = state.caret_index();
let (display, display_caret) = if let Some(transformation) = &state.visual_transformation {
let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
let transformed = transformation.filter(&annotated);
(
transformed.text.text,
transformed.offset_mapping.original_to_transformed(caret),
)
} else {
(state.text.clone(), caret)
};
let measured = measure_text(&display, metrics.font_px, metrics.measure_config());
let caret_x_px = measured
.positions
.get(byte_to_char_index(&measured, display_caret))
.copied()
.unwrap_or(0.0);
state.ensure_caret_visible(
caret_x_px,
inner_rect.w - 2.0 * TF_PADDING_X.to_px().0,
Dp(2.0).to_px().0,
);
}
#[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, TextMeasureConfig::default());
for i in 0..m.byte_offsets.len() - 1 {
let b = m.byte_offsets[i];
let _ = &t[..b];
}
}
fn delete_op(
index: usize,
pre_text: &str,
pre_selection: Range<usize>,
post_selection: Range<usize>,
) -> TextUndoOp {
TextUndoOp {
index,
pre_text: pre_text.to_string(),
post_text: String::new(),
pre_selection,
post_selection,
time: Instant::now(),
can_merge: true,
}
}
#[test]
fn deletion_type_collapsed_post_selection_is_backspace() {
let op = delete_op(2, "c", 3..3, 2..2);
assert_eq!(op.deletion_type(), TextDeleteType::Start);
}
#[test]
fn deletion_type_collapsed_post_selection_is_delete_forward() {
let op = delete_op(3, "c", 3..3, 3..3);
assert_eq!(op.deletion_type(), TextDeleteType::End);
}
#[test]
fn deletion_type_range_post_selection_is_not_by_user() {
let op = delete_op(2, "de", 2..4, 3..5);
assert_eq!(op.deletion_type(), TextDeleteType::NotByUser);
}
#[test]
fn backspace_ops_merge() {
let a = delete_op(2, "c", 3..3, 2..2);
let b = delete_op(1, "b", 2..2, 1..1);
let merged = a
.try_merge(&b)
.expect("consecutive backspaces should merge");
assert_eq!(merged.index, 1);
assert_eq!(merged.pre_text, "bc");
}
#[test]
fn selection_delete_does_not_merge_with_backspace() {
let backspace = delete_op(2, "c", 3..3, 2..2);
let selection = delete_op(2, "de", 2..4, 2..2);
assert_eq!(selection.deletion_type(), TextDeleteType::Inner);
assert!(backspace.try_merge(&selection).is_none());
}
#[test]
fn multiline_tap_and_caret_round_trip() {
let m = TextFieldMetrics::default();
let lh = m.line_height_px;
for (text, wrap) in [
("hello world\nsecond line here\nthird", 1000.0),
("hello world\nsecond line here\nthird", 60.0),
("a\n\nb", 1000.0),
("one\n", 1000.0),
("日本語テスト二行目", 30.0),
("héllo wörld\nünicode", 1000.0),
] {
let layout = layout_text_area_with_metrics(text, wrap, &m);
for b in 0..=text.len() {
if !text.is_char_boundary(b) {
continue;
}
let (x, y, line) = caret_xy_for_byte_with_metrics(text, wrap, b, &m);
let Some(&(line_start, line_end)) = layout.ranges.get(line) else {
panic!("byte {b} of {text:?} reported absent line {line}");
};
assert!(
line_start <= b && b <= line_end,
"byte {b} of {text:?} reported line {line} [{line_start}..{line_end}]"
);
let got = index_for_xy_bytes_with_metrics(text, wrap, x + 0.5, y + lh * 0.5, &m);
assert_eq!(got, b, "byte {b} of {text:?} at ({x}, {y}) tapped to {got}");
}
}
}
#[test]
fn home_end_never_move_caret_against_the_user() {
let m = TextFieldMetrics::default();
for text in ["ab \ncd", "ab \ncd", " indented line\nnext", "one\n"] {
for wrap in [1000.0f32, 30.0] {
let ranges = layout_text_area_with_metrics(text, wrap, &m).ranges;
for b in 0..=text.len() {
let (line, _, _) = locate_byte_in_ranges(&ranges, b);
let (start, end) = ranges.get(line).copied().unwrap_or((b, b));
let to_end = line_home_end_with_metrics(text, wrap, b, true, &m);
let to_home = line_home_end_with_metrics(text, wrap, b, false, &m);
if start <= b && b <= end {
assert_eq!(to_end, end, "End from {b} in {text:?} wrap {wrap}");
assert_eq!(to_home, start, "Home from {b} in {text:?} wrap {wrap}");
} else {
assert_eq!(to_end, b, "End from gap byte {b} in {text:?} wrap {wrap}");
assert_eq!(to_home, b, "Home from gap byte {b} in {text:?} wrap {wrap}");
}
}
}
}
}
}