1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
/// Direction for cursor movement in text input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextDirection {
Forward,
Backward,
Up,
Down,
LineStart,
LineEnd,
WordForward,
WordBackward,
}
/// Text input state managed by the renderer.
///
/// Components don't store this directly -- the renderer maintains it
/// and components query/modify it through the Renderer trait methods.
#[derive(Debug, Clone, Default)]
pub struct TextInputState {
/// The full text content.
pub text: String,
/// Cursor position as byte offset into the text.
pub cursor_pos: usize,
/// Selection anchor. If Some, the selection is from anchor to cursor.
/// If None, there is no selection.
pub selection_anchor: Option<usize>,
/// Whether the input is focused (shows cursor, accepts keyboard).
pub focused: bool,
/// Whether the caret is currently visible (for blinking).
pub caret_visible: bool,
/// Last edit timestamp for undo coalescing.
pub last_edit_time: f32,
}
impl TextInputState {
/// Create a new TextInputState with the given initial text.
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
let cursor_pos = text.len();
Self {
text,
cursor_pos,
selection_anchor: None,
focused: false,
caret_visible: true,
last_edit_time: 0.0,
}
}
/// Get the selection range as (start, end) byte offsets.
/// Returns None if there is no selection.
pub fn selection_range(&self) -> Option<(usize, usize)> {
self.selection_anchor.map(|anchor| {
if anchor <= self.cursor_pos {
(anchor, self.cursor_pos)
} else {
(self.cursor_pos, anchor)
}
})
}
/// Get the selected text, or empty string if no selection.
pub fn selected_text(&self) -> String {
self.selection_range()
.map(|(start, end)| self.text[start..end].to_string())
.unwrap_or_default()
}
/// Insert text at the current cursor position, replacing any selection.
pub fn insert(&mut self, new_text: &str) {
if let Some((start, end)) = self.selection_range() {
self.text.replace_range(start..end, new_text);
self.cursor_pos = start + new_text.len();
} else {
self.text.insert_str(self.cursor_pos, new_text);
self.cursor_pos += new_text.len();
}
self.selection_anchor = None;
}
/// Delete characters. If there's a selection, delete it.
/// Otherwise delete `count` characters backward (backspace) or forward (delete).
pub fn delete(&mut self, backward: bool, count: usize) -> String {
if let Some((start, end)) = self.selection_range() {
let deleted = self.text[start..end].to_string();
self.text.replace_range(start..end, "");
self.cursor_pos = start;
self.selection_anchor = None;
return deleted;
}
if backward && self.cursor_pos > 0 {
let start = self.cursor_pos.saturating_sub(count);
let deleted = self.text[start..self.cursor_pos].to_string();
self.text.replace_range(start..self.cursor_pos, "");
self.cursor_pos = start;
deleted
} else if !backward && self.cursor_pos < self.text.len() {
let end = (self.cursor_pos + count).min(self.text.len());
let deleted = self.text[self.cursor_pos..end].to_string();
self.text.replace_range(self.cursor_pos..end, "");
deleted
} else {
String::new()
}
}
/// Move the cursor in the given direction.
pub fn move_cursor(&mut self, direction: TextDirection, extend_selection: bool) {
if !extend_selection {
self.selection_anchor = None;
} else if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor_pos);
}
match direction {
TextDirection::Forward if self.cursor_pos < self.text.len() => {
// Move to next character boundary (UTF-8 safe)
let next = self.text[self.cursor_pos..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor_pos + i)
.unwrap_or(self.text.len());
self.cursor_pos = next;
}
TextDirection::Backward if self.cursor_pos > 0 => {
let prev = self.text[..self.cursor_pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.cursor_pos = prev;
}
TextDirection::LineStart => {
self.cursor_pos = 0;
}
TextDirection::LineEnd => {
self.cursor_pos = self.text.len();
}
TextDirection::WordForward => {
// Find next word boundary
let rest = &self.text[self.cursor_pos..];
// Skip current word chars
let after_word = rest
.char_indices()
.find(|(_, c)| !c.is_alphanumeric())
.map(|(i, _)| i)
.unwrap_or(rest.len());
// Skip whitespace
let after_space = rest[after_word..]
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map(|(i, _)| after_word + i)
.unwrap_or(rest.len());
self.cursor_pos = (self.cursor_pos + after_space).min(self.text.len());
}
TextDirection::WordBackward => {
let before = &self.text[..self.cursor_pos];
// Skip whitespace going backward
let before_word = before
.char_indices()
.rev()
.find(|(_, c)| !c.is_whitespace())
.map(|(i, _)| i)
.unwrap_or(0);
// Skip word chars going backward
let word_start = before[..before_word]
.char_indices()
.rev()
.find(|(_, c)| !c.is_alphanumeric())
.map(|(i, _)| i)
.unwrap_or(0);
self.cursor_pos = word_start;
}
_ => {} // Up/Down handled by multi-line components
}
if !extend_selection {
self.selection_anchor = None;
}
}
/// Select all text.
pub fn select_all(&mut self) {
self.cursor_pos = self.text.len();
self.selection_anchor = Some(0);
}
/// Get the byte offset of the cursor.
pub fn cursor_byte_pos(&self) -> usize {
self.cursor_pos
}
}