Another project
0

Configure Feed

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

feat(ui): text input pointer drag, scroll

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

author
Lewis
date (May 11, 2026, 8:23 PM +0300) commit 1ff40b37 parent 0ebff415 change-id nmxoqznn
+546 -28
+546 -28
crates/bone-ui/src/widgets/text_input.rs
··· 1 - use bone_text::SourceByteIndex; 1 + use bone_text::{ShapedLine, ShapedText, SourceByteIndex}; 2 2 3 3 use crate::a11y::{AccessNode, Role}; 4 4 use crate::frame::{FrameCtx, InteractDeclaration}; 5 5 use crate::hit_test::{Interaction, Sense}; 6 - use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 7 - use crate::layout::LayoutRect; 6 + use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton}; 7 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 8 8 use crate::strings::StringKey; 9 - use crate::text::{Selection, SelectionAction}; 9 + use crate::text::{Selection, SelectionAction, request_for}; 10 10 use crate::theme::{Border, Step12, StrokeWidth}; 11 11 use crate::widget_id::WidgetId; 12 12 13 - use super::paint::{LabelText, SelectionByteRange, WidgetPaint}; 13 + use super::paint::{LabelText, WidgetPaint}; 14 14 use super::visuals::{FieldVisuals, SurfaceVisuals, TextVisuals, push_focus_ring}; 15 + 16 + const CARET_WIDTH_PX: f32 = 1.0; 17 + const CARET_VPAD_PX: f32 = 3.0; 15 18 16 19 pub trait Clipboard { 17 20 fn read(&self) -> Option<String>; ··· 30 33 } 31 34 } 32 35 33 - #[derive(Clone, Debug, PartialEq, Eq)] 36 + #[derive(Clone, Debug, PartialEq)] 34 37 pub struct TextInputState { 35 38 pub text: String, 36 39 pub selection: Selection, 37 40 pub was_focused: bool, 41 + pub drag_anchor: Option<SourceByteIndex>, 42 + pub scroll_x: f32, 38 43 } 39 44 40 45 impl Default for TextInputState { ··· 43 48 text: String::new(), 44 49 selection: Selection::caret_at(SourceByteIndex::new(0)), 45 50 was_focused: false, 51 + drag_anchor: None, 52 + scroll_x: 0.0, 46 53 } 47 54 } 48 55 } ··· 56 63 selection: Selection::caret_at(SourceByteIndex::new(len)), 57 64 text, 58 65 was_focused: false, 66 + drag_anchor: None, 67 + scroll_x: 0.0, 59 68 } 60 69 } 61 70 } ··· 130 139 clamp_selection_to_text(state); 131 140 let interactive = !disabled; 132 141 let interaction = ctx.interact( 133 - InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 142 + InteractDeclaration::new(id, rect, Sense::DRAGGABLE) 134 143 .focusable(interactive) 135 144 .disabled(!interactive) 136 145 .a11y( ··· 140 149 ), 141 150 ); 142 151 let live_focused = ctx.is_focused(id); 152 + let role = ctx.theme().typography.body; 143 153 let mut edits = Vec::new(); 144 154 if interactive && live_focused { 145 155 edits.extend(drain_edits(ctx, state, clipboard)); 156 + } 157 + let shaped = ctx.shaper.shape(&state.text, request_for(role, None)); 158 + if interactive { 159 + let pointer = ctx.input.pointer.as_ref().map(|p| p.position); 160 + apply_pointer_selection(state, rect, &shaped, pointer, interaction); 146 161 } 147 162 let error = validator.validate(&state.text).err(); 163 + update_scroll_x(state, rect, &shaped); 148 164 let paint = build_paint( 149 165 ctx, 150 166 PaintInputs { ··· 152 168 placeholder, 153 169 state, 154 170 disabled, 171 + shaped: &shaped, 155 172 }, 156 173 interaction, 157 174 live_focused, ··· 166 183 } 167 184 } 168 185 186 + fn apply_pointer_selection( 187 + state: &mut TextInputState, 188 + rect: LayoutRect, 189 + shaped: &ShapedText, 190 + pointer: Option<LayoutPos>, 191 + interaction: Interaction, 192 + ) { 193 + let primary_held = 194 + interaction.pressed() && interaction.pressed_buttons.contains(PointerButton::Primary); 195 + if !primary_held { 196 + state.drag_anchor = None; 197 + return; 198 + } 199 + let Some(byte) = pointer_byte(pointer, rect, shaped, &state.text, state.scroll_x) else { 200 + return; 201 + }; 202 + let idx = SourceByteIndex::new(byte); 203 + match state.drag_anchor { 204 + None => { 205 + state.drag_anchor = Some(idx); 206 + state.selection = Selection::caret_at(idx); 207 + } 208 + Some(anchor) => { 209 + state.selection = Selection::ranged(anchor, idx); 210 + } 211 + } 212 + } 213 + 214 + fn pointer_byte( 215 + pointer: Option<LayoutPos>, 216 + rect: LayoutRect, 217 + shaped: &ShapedText, 218 + text: &str, 219 + scroll_x: f32, 220 + ) -> Option<usize> { 221 + let pointer = pointer?; 222 + let line = shaped.lines.first()?; 223 + let visible = line.visible_advance_px(); 224 + let start_x = effective_start_x(rect, visible, scroll_x); 225 + let local_x = pointer.x.value() - start_x; 226 + Some(byte_at_x(line, text.len(), local_x)) 227 + } 228 + 229 + fn effective_start_x(rect: LayoutRect, visible: f32, scroll_x: f32) -> f32 { 230 + let rw = rect.size.width.value(); 231 + let rx = rect.origin.x.value(); 232 + if visible > rw { 233 + rx - scroll_x 234 + } else { 235 + rx + (rw - visible) * 0.5 236 + } 237 + } 238 + 239 + fn update_scroll_x(state: &mut TextInputState, rect: LayoutRect, shaped: &ShapedText) { 240 + let Some(line) = shaped.lines.first() else { 241 + state.scroll_x = 0.0; 242 + return; 243 + }; 244 + let visible = line.visible_advance_px(); 245 + let rw = rect.size.width.value(); 246 + if visible <= rw { 247 + state.scroll_x = 0.0; 248 + return; 249 + } 250 + let max_scroll = (visible - rw).max(0.0); 251 + let prev = state.scroll_x.clamp(0.0, max_scroll); 252 + let caret_local = caret_x_at_byte(line, state.text.len(), state.selection.caret().value()); 253 + state.scroll_x = if caret_local < prev { 254 + caret_local.max(0.0) 255 + } else if caret_local > prev + rw { 256 + (caret_local - rw).clamp(0.0, max_scroll) 257 + } else { 258 + prev 259 + }; 260 + } 261 + 262 + fn byte_at_x(line: &ShapedLine, text_len: usize, target_x: f32) -> usize { 263 + if target_x <= 0.0 { 264 + return 0; 265 + } 266 + line.runs 267 + .iter() 268 + .flat_map(|run| { 269 + run.glyphs 270 + .iter() 271 + .map(move |g| (g.cluster, run.origin_x_px + g.x_px, g.advance_px)) 272 + }) 273 + .find_map(|(cluster, x, adv)| (target_x < x + adv * 0.5).then_some(cluster.value())) 274 + .unwrap_or(text_len) 275 + } 276 + 277 + fn caret_x_at_byte(line: &ShapedLine, text_len: usize, byte: usize) -> f32 { 278 + if byte == 0 { 279 + return 0.0; 280 + } 281 + if byte >= text_len { 282 + return line.visible_advance_px(); 283 + } 284 + line.runs 285 + .iter() 286 + .flat_map(|run| { 287 + run.glyphs 288 + .iter() 289 + .map(move |g| (g.cluster.value(), run.origin_x_px + g.x_px)) 290 + }) 291 + .find(|(cluster, _)| *cluster >= byte) 292 + .map_or_else(|| line.visible_advance_px(), |(_, x)| x) 293 + } 294 + 295 + fn caret_rect( 296 + rect: LayoutRect, 297 + shaped: &ShapedText, 298 + text_len: usize, 299 + caret_byte: usize, 300 + scroll_x: f32, 301 + ) -> LayoutRect { 302 + let visible = shaped 303 + .lines 304 + .first() 305 + .map_or(0.0, ShapedLine::visible_advance_px); 306 + let start_x = effective_start_x(rect, visible, scroll_x); 307 + let caret_local = shaped 308 + .lines 309 + .first() 310 + .map_or(0.0, |line| caret_x_at_byte(line, text_len, caret_byte)); 311 + let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0); 312 + LayoutRect::new( 313 + LayoutPos::new( 314 + LayoutPx::saturating(start_x + caret_local), 315 + LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX), 316 + ), 317 + LayoutSize::new(LayoutPx::new(CARET_WIDTH_PX), LayoutPx::new(h)), 318 + ) 319 + } 320 + 321 + fn selection_rect( 322 + rect: LayoutRect, 323 + shaped: &ShapedText, 324 + text_len: usize, 325 + min_byte: usize, 326 + max_byte: usize, 327 + scroll_x: f32, 328 + ) -> Option<LayoutRect> { 329 + if max_byte <= min_byte { 330 + return None; 331 + } 332 + let line = shaped.lines.first()?; 333 + let visible = line.visible_advance_px(); 334 + let start_x = effective_start_x(rect, visible, scroll_x); 335 + let min_x = start_x + caret_x_at_byte(line, text_len, min_byte); 336 + let max_x = start_x + caret_x_at_byte(line, text_len, max_byte); 337 + let width = (max_x - min_x).max(0.0); 338 + if width <= 0.0 { 339 + return None; 340 + } 341 + let visible_left = rect.origin.x.value(); 342 + let visible_right = visible_left + rect.size.width.value(); 343 + let clipped_min = min_x.max(visible_left); 344 + let clipped_max = max_x.min(visible_right); 345 + let clipped_width = clipped_max - clipped_min; 346 + if clipped_width <= 0.0 { 347 + return None; 348 + } 349 + let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0); 350 + Some(LayoutRect::new( 351 + LayoutPos::new( 352 + LayoutPx::saturating(clipped_min), 353 + LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX), 354 + ), 355 + LayoutSize::new(LayoutPx::new(clipped_width), LayoutPx::new(h)), 356 + )) 357 + } 358 + 169 359 fn drain_edits<C: Clipboard + ?Sized>( 170 360 ctx: &mut FrameCtx<'_>, 171 361 state: &mut TextInputState, ··· 379 569 placeholder: StringKey, 380 570 state: &'a TextInputState, 381 571 disabled: bool, 572 + shaped: &'a ShapedText, 382 573 } 383 574 384 575 fn build_paint( ··· 393 584 placeholder, 394 585 state, 395 586 disabled, 587 + shaped, 396 588 } = inputs; 397 589 let visuals = field_visuals(ctx, disabled, interaction, has_error); 398 590 let (label, label_color) = if state.text.is_empty() { ··· 400 592 } else { 401 593 (LabelText::Owned(state.text.clone()), visuals.text.color) 402 594 }; 595 + let visible = shaped 596 + .lines 597 + .first() 598 + .map_or(0.0, ShapedLine::visible_advance_px); 599 + let label_rect = if visible > rect.size.width.value() { 600 + LayoutRect::new( 601 + LayoutPos::new( 602 + LayoutPx::saturating(rect.origin.x.value() - state.scroll_x), 603 + rect.origin.y, 604 + ), 605 + rect.size, 606 + ) 607 + } else { 608 + rect 609 + }; 403 610 let mut paint = vec![ 404 611 WidgetPaint::Surface { 405 612 rect, ··· 409 616 elevation: None, 410 617 }, 411 618 WidgetPaint::Label { 412 - rect, 619 + rect: label_rect, 413 620 text: label, 414 621 color: label_color, 415 622 role: visuals.text.role, 416 623 }, 417 624 ]; 418 - if !state.selection.is_empty() { 625 + let text_len = state.text.len(); 626 + if let Some(sel) = selection_rect( 627 + rect, 628 + shaped, 629 + text_len, 630 + state.selection.min().value(), 631 + state.selection.max().value(), 632 + state.scroll_x, 633 + ) { 419 634 paint.push(WidgetPaint::SelectionHighlight { 420 - rect, 421 - range: SelectionByteRange::new(state.selection.min(), state.selection.max()), 635 + rect: sel, 422 636 color: visuals.selection, 423 637 }); 424 638 } 425 639 if live_focused && !disabled { 640 + let caret = caret_rect( 641 + rect, 642 + shaped, 643 + text_len, 644 + state.selection.caret().value(), 645 + state.scroll_x, 646 + ); 426 647 paint.push(WidgetPaint::Caret { 427 - rect, 428 - byte_offset: state.selection.caret(), 648 + rect: caret, 429 649 color: visuals.caret, 430 650 }); 431 651 } ··· 555 775 validator, 556 776 }; 557 777 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 778 + let mut shaper = bone_text::Shaper::new(); 558 779 let mut ctx = FrameCtx::new( 559 780 theme, 560 781 input, ··· 564 785 &mut hits, 565 786 &prev, 566 787 &mut a11y, 788 + &mut shaper, 567 789 ); 568 790 show_text_input(&mut ctx, widget, clipboard) 569 791 } ··· 927 1149 assert_eq!(key, Some(PLACEHOLDER)); 928 1150 } 929 1151 1152 + fn caret_paint_for(text: &str, byte: usize) -> super::WidgetPaint { 1153 + let (mut state, mut focus, mut input) = focused_with(text, vec![]); 1154 + state.selection = Selection::caret_at(SourceByteIndex::new(byte)); 1155 + let mut clipboard = MemoryClipboard::default(); 1156 + let response = run( 1157 + &mut state, 1158 + &mut focus, 1159 + &mut input, 1160 + &mut clipboard, 1161 + AlwaysValid, 1162 + ); 1163 + let Some(caret) = response 1164 + .paint 1165 + .iter() 1166 + .find(|p| matches!(p, super::WidgetPaint::Caret { .. })) 1167 + .cloned() 1168 + else { 1169 + panic!("focused input must paint a caret"); 1170 + }; 1171 + caret 1172 + } 1173 + 930 1174 #[test] 931 - fn caret_paint_carries_byte_offset() { 932 - let (mut state, mut focus, mut input) = focused_with("abc", vec![]); 933 - state.selection = Selection::caret_at(SourceByteIndex::new(2)); 1175 + fn caret_rect_advances_with_byte_offset() { 1176 + let super::WidgetPaint::Caret { rect: at_zero, .. } = caret_paint_for("abc", 0) else { 1177 + panic!("caret variant expected"); 1178 + }; 1179 + let super::WidgetPaint::Caret { rect: at_two, .. } = caret_paint_for("abc", 2) else { 1180 + panic!("caret variant expected"); 1181 + }; 1182 + let super::WidgetPaint::Caret { rect: at_end, .. } = caret_paint_for("abc", 3) else { 1183 + panic!("caret variant expected"); 1184 + }; 1185 + assert!( 1186 + at_zero.origin.x.value() < at_two.origin.x.value(), 1187 + "caret at 0 must sit left of caret at 2", 1188 + ); 1189 + assert!( 1190 + at_two.origin.x.value() < at_end.origin.x.value(), 1191 + "caret at 2 must sit left of caret at end", 1192 + ); 1193 + assert!( 1194 + (at_zero.size.width.value() - super::CARET_WIDTH_PX).abs() < f32::EPSILON, 1195 + "caret bar is one pixel wide", 1196 + ); 1197 + } 1198 + 1199 + #[test] 1200 + fn selection_rect_spans_only_selected_glyphs() { 1201 + let (mut state, mut focus, mut input) = focused_with("hello", vec![]); 1202 + state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4)); 934 1203 let mut clipboard = MemoryClipboard::default(); 935 1204 let response = run( 936 1205 &mut state, ··· 939 1208 &mut clipboard, 940 1209 AlwaysValid, 941 1210 ); 942 - let offset = response.paint.iter().find_map(|p| match p { 943 - super::WidgetPaint::Caret { byte_offset, .. } => Some(*byte_offset), 1211 + let Some(selection) = response.paint.iter().find_map(|p| match p { 1212 + super::WidgetPaint::SelectionHighlight { rect, .. } => Some(*rect), 944 1213 _ => None, 945 - }); 946 - assert_eq!(offset, Some(SourceByteIndex::new(2))); 1214 + }) else { 1215 + panic!("non-empty selection must emit a highlight"); 1216 + }; 1217 + let widget = rect(); 1218 + assert!( 1219 + selection.size.width.value() > 0.0, 1220 + "selection rect must have positive width", 1221 + ); 1222 + assert!( 1223 + selection.size.width.value() < widget.size.width.value(), 1224 + "selection rect cannot exceed input width for substring", 1225 + ); 1226 + assert!( 1227 + selection.origin.x.value() >= widget.origin.x.value(), 1228 + "selection cannot start before input rect", 1229 + ); 947 1230 } 948 1231 949 1232 #[test] 950 - fn selection_paint_carries_byte_range() { 1233 + fn empty_selection_emits_no_highlight() { 951 1234 let (mut state, mut focus, mut input) = focused_with("hello", vec![]); 952 - state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4)); 1235 + state.selection = Selection::caret_at(SourceByteIndex::new(2)); 953 1236 let mut clipboard = MemoryClipboard::default(); 954 1237 let response = run( 955 1238 &mut state, ··· 958 1241 &mut clipboard, 959 1242 AlwaysValid, 960 1243 ); 961 - let range = response.paint.iter().find_map(|p| match p { 962 - super::WidgetPaint::SelectionHighlight { range, .. } => Some(*range), 1244 + let any_selection = response 1245 + .paint 1246 + .iter() 1247 + .any(|p| matches!(p, super::WidgetPaint::SelectionHighlight { .. })); 1248 + assert!( 1249 + !any_selection, 1250 + "caret-only selection must not paint a highlight" 1251 + ); 1252 + } 1253 + 1254 + fn run_pointer_drag( 1255 + state: &mut TextInputState, 1256 + widget_rect: LayoutRect, 1257 + positions: &[(f32, bool)], 1258 + ) { 1259 + use crate::hit_test::{HitFrame, HitState, Sense, resolve}; 1260 + use crate::input::{PointerButton, PointerButtonMask, PointerSample}; 1261 + let mut focus = FocusManager::new(); 1262 + focus.register_focusable(id_widget()); 1263 + focus.end_frame(); 1264 + let theme = Arc::new(Theme::light()); 1265 + let table = HotkeyTable::new(); 1266 + let mut shaper = bone_text::Shaper::new(); 1267 + let mut hit_state = HitState::new(); 1268 + for (x, primary_pressed) in positions { 1269 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1270 + snap.pointer = Some(PointerSample::new(LayoutPos::new( 1271 + LayoutPx::new(*x), 1272 + LayoutPx::new(widget_rect.origin.y.value() + widget_rect.size.height.value() * 0.5), 1273 + ))); 1274 + if *primary_pressed { 1275 + snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 1276 + } else { 1277 + snap.buttons_released = PointerButtonMask::just(PointerButton::Primary); 1278 + } 1279 + let mut hits = HitFrame::new(); 1280 + hits.push(crate::hit_test::HitItem { 1281 + id: id_widget(), 1282 + rect: widget_rect, 1283 + sense: Sense::DRAGGABLE, 1284 + z: crate::hit_test::ZLayer::BASE, 1285 + disabled: false, 1286 + active: false, 1287 + }); 1288 + let new_state = resolve(&hit_state, &hits, &snap, focus.focused()); 1289 + let mut clipboard = MemoryClipboard::default(); 1290 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1291 + let mut frame_hits = HitFrame::new(); 1292 + let widget = TextInput { 1293 + id: id_widget(), 1294 + rect: widget_rect, 1295 + placeholder: PLACEHOLDER, 1296 + state: &mut *state, 1297 + disabled: false, 1298 + validator: AlwaysValid, 1299 + }; 1300 + let mut snap_for_ctx = snap; 1301 + { 1302 + let mut ctx = FrameCtx::new( 1303 + Arc::clone(&theme), 1304 + &mut snap_for_ctx, 1305 + &mut focus, 1306 + &table, 1307 + StringTable::empty(), 1308 + &mut frame_hits, 1309 + &new_state, 1310 + &mut a11y, 1311 + &mut shaper, 1312 + ); 1313 + let _ = show_text_input(&mut ctx, widget, &mut clipboard); 1314 + } 1315 + hit_state = new_state; 1316 + } 1317 + } 1318 + 1319 + #[test] 1320 + fn press_far_left_places_caret_at_start() { 1321 + let widget_rect = LayoutRect::new( 1322 + LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(0.0)), 1323 + LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(24.0)), 1324 + ); 1325 + let mut state = TextInputState::from_text("hello"); 1326 + state.selection = Selection::caret_at(SourceByteIndex::new(5)); 1327 + run_pointer_drag(&mut state, widget_rect, &[(22.0, true)]); 1328 + assert_eq!( 1329 + state.selection.caret().value(), 1330 + 0, 1331 + "press far-left of text origin must place caret at byte 0", 1332 + ); 1333 + } 1334 + 1335 + #[test] 1336 + fn press_then_drag_extends_selection() { 1337 + let widget_rect = LayoutRect::new( 1338 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 1339 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)), 1340 + ); 1341 + let mut state = TextInputState::from_text("hello world"); 1342 + state.selection = Selection::caret_at(SourceByteIndex::new(0)); 1343 + run_pointer_drag( 1344 + &mut state, 1345 + widget_rect, 1346 + &[(0.0, true), (200.0, true), (200.0, false)], 1347 + ); 1348 + assert_eq!(state.selection.min().value(), 0); 1349 + assert_eq!(state.selection.max().value(), state.text.len()); 1350 + assert!(state.drag_anchor.is_none(), "release clears drag anchor"); 1351 + } 1352 + 1353 + fn run_with_state( 1354 + state: &mut TextInputState, 1355 + widget_rect: LayoutRect, 1356 + ) -> super::TextInputResponse<core::convert::Infallible> { 1357 + use crate::hit_test::{HitFrame, HitState}; 1358 + let mut focus = FocusManager::new(); 1359 + focus.register_focusable(id_widget()); 1360 + focus.request_focus(id_widget()); 1361 + focus.end_frame(); 1362 + let theme = Arc::new(Theme::light()); 1363 + let table = HotkeyTable::new(); 1364 + let mut hits = HitFrame::new(); 1365 + let prev = HitState::new(); 1366 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 1367 + let mut clipboard = MemoryClipboard::default(); 1368 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1369 + let mut shaper = bone_text::Shaper::new(); 1370 + let widget = TextInput { 1371 + id: id_widget(), 1372 + rect: widget_rect, 1373 + placeholder: PLACEHOLDER, 1374 + state: &mut *state, 1375 + disabled: false, 1376 + validator: AlwaysValid, 1377 + }; 1378 + let mut ctx = FrameCtx::new( 1379 + theme, 1380 + &mut input, 1381 + &mut focus, 1382 + &table, 1383 + StringTable::empty(), 1384 + &mut hits, 1385 + &prev, 1386 + &mut a11y, 1387 + &mut shaper, 1388 + ); 1389 + show_text_input(&mut ctx, widget, &mut clipboard) 1390 + } 1391 + 1392 + #[test] 1393 + fn scroll_x_stays_zero_when_text_fits() { 1394 + let widget_rect = LayoutRect::new( 1395 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1396 + LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)), 1397 + ); 1398 + let mut state = TextInputState::from_text("hi"); 1399 + state.selection = Selection::caret_at(SourceByteIndex::new(2)); 1400 + let _ = run_with_state(&mut state, widget_rect); 1401 + assert!( 1402 + state.scroll_x.abs() < f32::EPSILON, 1403 + "short text in wide rect must not scroll, got {}", 1404 + state.scroll_x, 1405 + ); 1406 + } 1407 + 1408 + #[test] 1409 + fn scroll_x_advances_to_keep_caret_visible_at_end_of_long_text() { 1410 + let widget_rect = LayoutRect::new( 1411 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1412 + LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)), 1413 + ); 1414 + let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog"); 1415 + state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len())); 1416 + let _ = run_with_state(&mut state, widget_rect); 1417 + assert!( 1418 + state.scroll_x > 0.0, 1419 + "long text with caret at end must scroll, got {}", 1420 + state.scroll_x, 1421 + ); 1422 + } 1423 + 1424 + #[test] 1425 + fn scroll_x_resets_when_caret_returns_to_start() { 1426 + let widget_rect = LayoutRect::new( 1427 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1428 + LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)), 1429 + ); 1430 + let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog"); 1431 + state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len())); 1432 + let _ = run_with_state(&mut state, widget_rect); 1433 + let scrolled = state.scroll_x; 1434 + assert!(scrolled > 0.0); 1435 + state.selection = Selection::caret_at(SourceByteIndex::new(0)); 1436 + let _ = run_with_state(&mut state, widget_rect); 1437 + assert!( 1438 + state.scroll_x < scrolled, 1439 + "caret at start must scroll back, was {} now {}", 1440 + scrolled, 1441 + state.scroll_x, 1442 + ); 1443 + } 1444 + 1445 + #[test] 1446 + fn caret_rect_stays_inside_widget_rect_when_text_overflows() { 1447 + let widget_rect = LayoutRect::new( 1448 + LayoutPos::new(LayoutPx::new(10.0), LayoutPx::ZERO), 1449 + LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)), 1450 + ); 1451 + let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog"); 1452 + state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len())); 1453 + let response = run_with_state(&mut state, widget_rect); 1454 + let Some(caret) = response.paint.iter().find_map(|p| match p { 1455 + super::WidgetPaint::Caret { rect, .. } => Some(*rect), 963 1456 _ => None, 964 - }); 965 - let expected = 966 - super::SelectionByteRange::new(SourceByteIndex::new(1), SourceByteIndex::new(4)); 967 - assert_eq!(range, Some(expected)); 1457 + }) else { 1458 + panic!("focused input must paint a caret"); 1459 + }; 1460 + let left = widget_rect.origin.x.value(); 1461 + let right = left + widget_rect.size.width.value(); 1462 + assert!( 1463 + caret.origin.x.value() >= left - 0.5 && caret.origin.x.value() <= right + 0.5, 1464 + "caret x {} must lie in [{}, {}]", 1465 + caret.origin.x.value(), 1466 + left, 1467 + right, 1468 + ); 1469 + } 1470 + 1471 + #[test] 1472 + fn drag_after_release_does_not_extend_selection() { 1473 + let widget_rect = LayoutRect::new( 1474 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 1475 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)), 1476 + ); 1477 + let mut state = TextInputState::from_text("hello world"); 1478 + state.selection = Selection::caret_at(SourceByteIndex::new(0)); 1479 + run_pointer_drag(&mut state, widget_rect, &[(20.0, true), (60.0, false)]); 1480 + let after_release = state.selection; 1481 + run_pointer_drag(&mut state, widget_rect, &[(180.0, false)]); 1482 + assert_eq!( 1483 + state.selection, after_release, 1484 + "pointer movement after release does not change selection", 1485 + ); 968 1486 } 969 1487 970 1488 #[test]