Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): text selection, caret, word jump, home&end

Lewis: May this revision serve well! <lu5a@proton.me>

+453
+453
crates/bone-ui/src/text/selection.rs
··· 1 + use bone_text::SourceByteIndex; 2 + use unicode_segmentation::UnicodeSegmentation; 3 + 4 + use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 5 + 6 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 7 + pub enum CaretMove { 8 + PrevGrapheme, 9 + NextGrapheme, 10 + PrevWord, 11 + NextWord, 12 + LineStart, 13 + LineEnd, 14 + } 15 + 16 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 17 + pub enum SelectionAction { 18 + Collapse(CaretMove), 19 + Extend(CaretMove), 20 + } 21 + 22 + impl SelectionAction { 23 + #[must_use] 24 + pub fn from_key(event: KeyEvent) -> Option<Self> { 25 + let shift = event.modifiers.contains(ModifierMask::SHIFT); 26 + let ctrl = event.modifiers.contains(ModifierMask::CTRL); 27 + let blocked = event.modifiers.contains(ModifierMask::ALT) 28 + || event.modifiers.contains(ModifierMask::META); 29 + if blocked { 30 + return None; 31 + } 32 + let m = match (event.code, ctrl) { 33 + (KeyCode::Named(NamedKey::ArrowLeft), false) => CaretMove::PrevGrapheme, 34 + (KeyCode::Named(NamedKey::ArrowLeft), true) => CaretMove::PrevWord, 35 + (KeyCode::Named(NamedKey::ArrowRight), false) => CaretMove::NextGrapheme, 36 + (KeyCode::Named(NamedKey::ArrowRight), true) => CaretMove::NextWord, 37 + (KeyCode::Named(NamedKey::Home), false) => CaretMove::LineStart, 38 + (KeyCode::Named(NamedKey::End), false) => CaretMove::LineEnd, 39 + _ => return None, 40 + }; 41 + Some(if shift { 42 + Self::Extend(m) 43 + } else { 44 + Self::Collapse(m) 45 + }) 46 + } 47 + } 48 + 49 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 50 + pub struct Selection { 51 + anchor: SourceByteIndex, 52 + caret: SourceByteIndex, 53 + } 54 + 55 + impl Selection { 56 + #[must_use] 57 + pub const fn caret_at(caret: SourceByteIndex) -> Self { 58 + Self { 59 + anchor: caret, 60 + caret, 61 + } 62 + } 63 + 64 + #[must_use] 65 + pub const fn ranged(anchor: SourceByteIndex, caret: SourceByteIndex) -> Self { 66 + Self { anchor, caret } 67 + } 68 + 69 + #[must_use] 70 + pub const fn anchor(self) -> SourceByteIndex { 71 + self.anchor 72 + } 73 + 74 + #[must_use] 75 + pub const fn caret(self) -> SourceByteIndex { 76 + self.caret 77 + } 78 + 79 + #[must_use] 80 + pub fn is_empty(self) -> bool { 81 + self.anchor == self.caret 82 + } 83 + 84 + #[must_use] 85 + pub fn min(self) -> SourceByteIndex { 86 + SourceByteIndex::new(self.anchor.value().min(self.caret.value())) 87 + } 88 + 89 + #[must_use] 90 + pub fn max(self) -> SourceByteIndex { 91 + SourceByteIndex::new(self.anchor.value().max(self.caret.value())) 92 + } 93 + 94 + #[must_use] 95 + pub fn apply(self, text: &str, action: SelectionAction) -> Self { 96 + match action { 97 + SelectionAction::Collapse(m) => Self::caret_at(self.collapse_target(text, m)), 98 + SelectionAction::Extend(m) => Self { 99 + anchor: self.anchor, 100 + caret: step(text, self.caret, m), 101 + }, 102 + } 103 + } 104 + 105 + fn collapse_target(self, text: &str, m: CaretMove) -> SourceByteIndex { 106 + if self.is_empty() { 107 + return step(text, self.caret, m); 108 + } 109 + match m { 110 + CaretMove::PrevGrapheme | CaretMove::PrevWord => self.min(), 111 + CaretMove::NextGrapheme | CaretMove::NextWord => self.max(), 112 + CaretMove::LineStart | CaretMove::LineEnd => step(text, self.caret, m), 113 + } 114 + } 115 + 116 + #[must_use] 117 + pub fn apply_key(self, text: &str, event: KeyEvent) -> Option<Self> { 118 + SelectionAction::from_key(event).map(|action| self.apply(text, action)) 119 + } 120 + } 121 + 122 + fn step(text: &str, from: SourceByteIndex, m: CaretMove) -> SourceByteIndex { 123 + let next = match m { 124 + CaretMove::PrevGrapheme => prev_grapheme(text, from.value()), 125 + CaretMove::NextGrapheme => next_grapheme(text, from.value()), 126 + CaretMove::PrevWord => prev_word(text, from.value()), 127 + CaretMove::NextWord => next_word(text, from.value()), 128 + CaretMove::LineStart => 0, 129 + CaretMove::LineEnd => text.len(), 130 + }; 131 + SourceByteIndex::new(next) 132 + } 133 + 134 + fn next_grapheme(text: &str, offset: usize) -> usize { 135 + text.grapheme_indices(true) 136 + .find(|&(i, _)| i > offset) 137 + .map_or(text.len(), |(i, _)| i) 138 + } 139 + 140 + fn prev_grapheme(text: &str, offset: usize) -> usize { 141 + text.grapheme_indices(true) 142 + .rev() 143 + .find(|&(i, _)| i < offset) 144 + .map_or(0, |(i, _)| i) 145 + } 146 + 147 + fn is_word_segment(segment: &str) -> bool { 148 + segment.chars().next().is_some_and(char::is_alphanumeric) 149 + } 150 + 151 + fn next_word(text: &str, offset: usize) -> usize { 152 + text.split_word_bound_indices() 153 + .find(|&(start, segment)| start > offset && is_word_segment(segment)) 154 + .map_or(text.len(), |(start, _)| start) 155 + } 156 + 157 + fn prev_word(text: &str, offset: usize) -> usize { 158 + text.split_word_bound_indices() 159 + .rev() 160 + .find(|&(start, segment)| start < offset && is_word_segment(segment)) 161 + .map_or(0, |(start, _)| start) 162 + } 163 + 164 + #[cfg(test)] 165 + mod tests { 166 + use super::{CaretMove, Selection, SelectionAction}; 167 + use crate::input::{KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey}; 168 + use bone_text::SourceByteIndex; 169 + 170 + fn b(n: usize) -> SourceByteIndex { 171 + SourceByteIndex::new(n) 172 + } 173 + 174 + fn key(named: NamedKey, modifiers: ModifierMask) -> KeyEvent { 175 + KeyEvent::new(KeyCode::Named(named), modifiers) 176 + } 177 + 178 + #[test] 179 + fn caret_at_starts_empty_selection() { 180 + let s = Selection::caret_at(b(0)); 181 + assert!(s.is_empty()); 182 + assert_eq!(s.caret(), b(0)); 183 + assert_eq!(s.anchor(), b(0)); 184 + } 185 + 186 + #[test] 187 + fn ranged_min_max_independent_of_direction() { 188 + let forward = Selection::ranged(b(2), b(7)); 189 + let backward = Selection::ranged(b(7), b(2)); 190 + assert_eq!(forward.min(), b(2)); 191 + assert_eq!(forward.max(), b(7)); 192 + assert_eq!(backward.min(), b(2)); 193 + assert_eq!(backward.max(), b(7)); 194 + assert!(!forward.is_empty()); 195 + assert!(!backward.is_empty()); 196 + } 197 + 198 + #[test] 199 + fn collapse_next_grapheme_advances_one_codepoint_in_ascii() { 200 + let s = Selection::caret_at(b(0)) 201 + .apply("abc", SelectionAction::Collapse(CaretMove::NextGrapheme)); 202 + assert_eq!(s.caret(), b(1)); 203 + assert!(s.is_empty()); 204 + } 205 + 206 + #[test] 207 + fn collapse_prev_grapheme_retreats_one_codepoint_in_ascii() { 208 + let s = Selection::caret_at(b(2)) 209 + .apply("abc", SelectionAction::Collapse(CaretMove::PrevGrapheme)); 210 + assert_eq!(s.caret(), b(1)); 211 + } 212 + 213 + #[test] 214 + fn collapse_clamps_at_text_bounds() { 215 + let end = Selection::caret_at(b(3)) 216 + .apply("abc", SelectionAction::Collapse(CaretMove::NextGrapheme)); 217 + assert_eq!(end.caret(), b(3)); 218 + let start = Selection::caret_at(b(0)) 219 + .apply("abc", SelectionAction::Collapse(CaretMove::PrevGrapheme)); 220 + assert_eq!(start.caret(), b(0)); 221 + } 222 + 223 + #[test] 224 + fn grapheme_step_keeps_combining_mark_with_base() { 225 + let text = "e\u{0301}f"; 226 + let combined = Selection::caret_at(b(0)) 227 + .apply(text, SelectionAction::Collapse(CaretMove::NextGrapheme)); 228 + assert_eq!(combined.caret(), b(3), "right arrow must clear e + acute"); 229 + let back = Selection::caret_at(b(3)) 230 + .apply(text, SelectionAction::Collapse(CaretMove::PrevGrapheme)); 231 + assert_eq!(back.caret(), b(0), "left arrow must restore base position"); 232 + } 233 + 234 + #[test] 235 + fn grapheme_step_snaps_mid_cluster_offset_to_boundary() { 236 + let text = "e\u{0301}"; 237 + let snap = Selection::caret_at(b(1)) 238 + .apply(text, SelectionAction::Collapse(CaretMove::NextGrapheme)); 239 + assert_eq!(snap.caret(), b(3)); 240 + let back = Selection::caret_at(b(2)) 241 + .apply(text, SelectionAction::Collapse(CaretMove::PrevGrapheme)); 242 + assert_eq!(back.caret(), b(0)); 243 + } 244 + 245 + #[test] 246 + fn collapse_arrow_on_non_empty_selection_collapses_to_edge() { 247 + let sel = Selection::ranged(b(2), b(5)); 248 + let right = sel.apply( 249 + "abcdefg", 250 + SelectionAction::Collapse(CaretMove::NextGrapheme), 251 + ); 252 + assert_eq!(right.caret(), b(5)); 253 + assert!(right.is_empty()); 254 + let left = sel.apply( 255 + "abcdefg", 256 + SelectionAction::Collapse(CaretMove::PrevGrapheme), 257 + ); 258 + assert_eq!(left.caret(), b(2)); 259 + assert!(left.is_empty()); 260 + } 261 + 262 + #[test] 263 + fn collapse_word_on_non_empty_selection_collapses_to_edge() { 264 + let sel = Selection::ranged(b(2), b(5)); 265 + let right = sel.apply( 266 + "abc def ghi", 267 + SelectionAction::Collapse(CaretMove::NextWord), 268 + ); 269 + assert_eq!(right.caret(), b(5)); 270 + assert!(right.is_empty()); 271 + let left = sel.apply( 272 + "abc def ghi", 273 + SelectionAction::Collapse(CaretMove::PrevWord), 274 + ); 275 + assert_eq!(left.caret(), b(2)); 276 + assert!(left.is_empty()); 277 + } 278 + 279 + #[test] 280 + fn collapse_line_bound_on_non_empty_selection_jumps_to_bound() { 281 + let sel = Selection::ranged(b(2), b(5)); 282 + let home = sel.apply( 283 + "hello world", 284 + SelectionAction::Collapse(CaretMove::LineStart), 285 + ); 286 + assert_eq!(home.caret(), b(0)); 287 + assert!(home.is_empty()); 288 + let end = sel.apply("hello world", SelectionAction::Collapse(CaretMove::LineEnd)); 289 + assert_eq!(end.caret(), b(11)); 290 + assert!(end.is_empty()); 291 + } 292 + 293 + #[test] 294 + fn extend_keeps_anchor_and_moves_caret() { 295 + let s = Selection::caret_at(b(2)) 296 + .apply("abcdef", SelectionAction::Extend(CaretMove::NextGrapheme)); 297 + assert_eq!(s.anchor(), b(2)); 298 + assert_eq!(s.caret(), b(3)); 299 + assert!(!s.is_empty()); 300 + } 301 + 302 + #[test] 303 + fn extend_supports_inverse_directions() { 304 + let forward = Selection::caret_at(b(3)) 305 + .apply("abcdef", SelectionAction::Extend(CaretMove::NextGrapheme)); 306 + let back = forward.apply("abcdef", SelectionAction::Extend(CaretMove::PrevGrapheme)); 307 + assert_eq!(back.anchor(), b(3)); 308 + assert_eq!(back.caret(), b(3)); 309 + assert!(back.is_empty()); 310 + } 311 + 312 + #[test] 313 + fn next_word_jumps_to_start_of_following_word() { 314 + let text = "abc def ghi"; 315 + let from0 = 316 + Selection::caret_at(b(0)).apply(text, SelectionAction::Collapse(CaretMove::NextWord)); 317 + assert_eq!(from0.caret(), b(4)); 318 + let from4 = 319 + Selection::caret_at(b(4)).apply(text, SelectionAction::Collapse(CaretMove::NextWord)); 320 + assert_eq!(from4.caret(), b(8)); 321 + let from8 = 322 + Selection::caret_at(b(8)).apply(text, SelectionAction::Collapse(CaretMove::NextWord)); 323 + assert_eq!(from8.caret(), b(11)); 324 + } 325 + 326 + #[test] 327 + fn prev_word_jumps_to_start_of_current_or_previous_word() { 328 + let text = "abc def ghi"; 329 + let from11 = 330 + Selection::caret_at(b(11)).apply(text, SelectionAction::Collapse(CaretMove::PrevWord)); 331 + assert_eq!(from11.caret(), b(8)); 332 + let from5 = 333 + Selection::caret_at(b(5)).apply(text, SelectionAction::Collapse(CaretMove::PrevWord)); 334 + assert_eq!(from5.caret(), b(4)); 335 + let from4 = 336 + Selection::caret_at(b(4)).apply(text, SelectionAction::Collapse(CaretMove::PrevWord)); 337 + assert_eq!(from4.caret(), b(0)); 338 + } 339 + 340 + #[test] 341 + fn word_jump_skips_runs_of_punctuation_and_whitespace() { 342 + let text = "abc !!! def"; 343 + let next = 344 + Selection::caret_at(b(3)).apply(text, SelectionAction::Collapse(CaretMove::NextWord)); 345 + assert_eq!(next.caret(), b(8)); 346 + let prev = 347 + Selection::caret_at(b(8)).apply(text, SelectionAction::Collapse(CaretMove::PrevWord)); 348 + assert_eq!(prev.caret(), b(0)); 349 + } 350 + 351 + #[test] 352 + fn word_jump_treats_decomposed_combining_mark_as_part_of_word() { 353 + let text = "abc\u{0301}def ghi"; 354 + let cluster_end = 8; 355 + let prev_from_cluster_end = Selection::caret_at(b(cluster_end)) 356 + .apply(text, SelectionAction::Collapse(CaretMove::PrevWord)); 357 + assert_eq!( 358 + prev_from_cluster_end.caret(), 359 + b(0), 360 + "combining mark inside cluster must not split the word", 361 + ); 362 + let next_from_start = 363 + Selection::caret_at(b(0)).apply(text, SelectionAction::Collapse(CaretMove::NextWord)); 364 + assert_eq!(next_from_start.caret(), b(cluster_end + 1)); 365 + } 366 + 367 + #[test] 368 + fn line_start_and_end_clamp_to_text_bounds() { 369 + let text = "hello"; 370 + let home = 371 + Selection::caret_at(b(3)).apply(text, SelectionAction::Collapse(CaretMove::LineStart)); 372 + assert_eq!(home.caret(), b(0)); 373 + let end = 374 + Selection::caret_at(b(0)).apply(text, SelectionAction::Collapse(CaretMove::LineEnd)); 375 + assert_eq!(end.caret(), b(text.len())); 376 + } 377 + 378 + #[test] 379 + fn shift_arrow_dispatches_extend_action() { 380 + let action = SelectionAction::from_key(key(NamedKey::ArrowRight, ModifierMask::SHIFT)); 381 + assert_eq!( 382 + action, 383 + Some(SelectionAction::Extend(CaretMove::NextGrapheme)) 384 + ); 385 + } 386 + 387 + #[test] 388 + fn ctrl_arrow_dispatches_word_jump() { 389 + let right = SelectionAction::from_key(key(NamedKey::ArrowRight, ModifierMask::CTRL)); 390 + assert_eq!(right, Some(SelectionAction::Collapse(CaretMove::NextWord))); 391 + let left = SelectionAction::from_key(key(NamedKey::ArrowLeft, ModifierMask::CTRL)); 392 + assert_eq!(left, Some(SelectionAction::Collapse(CaretMove::PrevWord))); 393 + } 394 + 395 + #[test] 396 + fn ctrl_shift_arrow_dispatches_extend_word() { 397 + let mods = ModifierMask::CTRL | ModifierMask::SHIFT; 398 + let action = SelectionAction::from_key(key(NamedKey::ArrowRight, mods)); 399 + assert_eq!(action, Some(SelectionAction::Extend(CaretMove::NextWord))); 400 + } 401 + 402 + #[test] 403 + fn home_end_dispatch_to_line_bounds() { 404 + let home = SelectionAction::from_key(key(NamedKey::Home, ModifierMask::NONE)); 405 + assert_eq!(home, Some(SelectionAction::Collapse(CaretMove::LineStart))); 406 + let shift_end = SelectionAction::from_key(key(NamedKey::End, ModifierMask::SHIFT)); 407 + assert_eq!(shift_end, Some(SelectionAction::Extend(CaretMove::LineEnd))); 408 + } 409 + 410 + #[test] 411 + fn alt_or_meta_modifier_blocks_dispatch() { 412 + let alt = SelectionAction::from_key(key(NamedKey::ArrowRight, ModifierMask::ALT)); 413 + assert!(alt.is_none()); 414 + let meta = SelectionAction::from_key(key(NamedKey::ArrowRight, ModifierMask::META)); 415 + assert!(meta.is_none()); 416 + } 417 + 418 + #[test] 419 + fn unrelated_keys_yield_none() { 420 + let tab = SelectionAction::from_key(key(NamedKey::Tab, ModifierMask::NONE)); 421 + assert!(tab.is_none()); 422 + let printable = SelectionAction::from_key(KeyEvent::new( 423 + KeyCode::Char(KeyChar::from_char('x')), 424 + ModifierMask::NONE, 425 + )); 426 + assert!(printable.is_none()); 427 + } 428 + 429 + #[test] 430 + fn apply_key_returns_none_for_non_selection_event() { 431 + let s = Selection::caret_at(b(2)); 432 + let out = s.apply_key("hello", key(NamedKey::Tab, ModifierMask::NONE)); 433 + assert!(out.is_none()); 434 + } 435 + 436 + #[test] 437 + fn apply_key_round_trip_extends_with_shift_and_collapses_without() { 438 + let text = "hello"; 439 + let extended = Selection::caret_at(b(0)) 440 + .apply_key(text, key(NamedKey::ArrowRight, ModifierMask::SHIFT)); 441 + let Some(extended) = extended else { 442 + panic!("shift+right must dispatch") 443 + }; 444 + assert_eq!(extended.anchor(), b(0)); 445 + assert_eq!(extended.caret(), b(1)); 446 + let collapsed = extended.apply_key(text, key(NamedKey::ArrowRight, ModifierMask::NONE)); 447 + let Some(collapsed) = collapsed else { 448 + panic!("right must dispatch") 449 + }; 450 + assert_eq!(collapsed.caret(), b(1)); 451 + assert!(collapsed.is_empty()); 452 + } 453 + }