1#![allow(non_snake_case)]
7
8use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
9use crate::composable;
10use crate::layout::policies::EmptyMeasurePolicy;
11use crate::modifier::Modifier;
12use crate::text::{measure_text, AnnotatedString, TextStyle};
13use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
14use crate::text_field_modifier_node::{
15 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
16};
17use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
18use crate::widgets::{Layout, SelectionHandle, TextSelectionMenu};
19use cranpose_core::{mutableStateOf, remember, NodeId, SideEffect};
20use cranpose_foundation::modifier_element;
21use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
22use cranpose_ui_graphics::{Color, Point};
23use std::cell::Cell;
24use std::rc::Rc;
25
26const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
28
29fn handle_tip_window_pos(
32 text: &str,
33 style: &TextStyle,
34 metrics: &TextFieldHandleMetrics,
35 offset: usize,
36) -> Point {
37 let offset = offset.min(text.len());
38 let before = &text[..offset];
39 let line_index = before.matches('\n').count();
40 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
41 let caret_x = measure_text(&AnnotatedString::from(&before[line_start..]), style).width;
42 Point {
43 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
44 y: metrics.node_origin.y
45 + metrics.padding_top
46 + (line_index as f32 + 1.0) * metrics.line_height,
47 }
48}
49
50fn window_pos_to_offset(
53 text: &str,
54 style: &TextStyle,
55 metrics: &TextFieldHandleMetrics,
56 window_pos: Point,
57) -> usize {
58 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
59 + metrics.scroll_offset)
60 .max(0.0);
61 let local_y =
64 (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
65 crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
66}
67#[composable]
85pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
86 BasicTextFieldWithOptions(
87 state,
88 modifier,
89 BasicTextFieldOptions {
90 text_style: style,
91 ..BasicTextFieldOptions::default()
92 },
93 )
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct BasicTextFieldOptions {
99 pub text_style: TextStyle,
101 pub cursor_color: Color,
103 pub line_limits: TextFieldLineLimits,
105}
106
107impl Default for BasicTextFieldOptions {
108 fn default() -> Self {
109 Self {
110 text_style: TextStyle::default(),
111 cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
113 }
114 }
115}
116
117#[composable]
121pub fn BasicTextFieldWithOptions(
122 state: TextFieldState,
123 modifier: Modifier,
124 options: BasicTextFieldOptions,
125) -> NodeId {
126 let _text = state.text();
129 let _selection = state.selection();
130
131 let controller =
135 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
136
137 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
139 .with_cursor_color(options.cursor_color)
140 .with_line_limits(options.line_limits)
141 .with_handle_controller(controller.clone());
142
143 let text_field_modifier = modifier_element(text_field_element);
145 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
146 let combined_modifier = modifier.then(final_modifier);
147
148 let node = Layout(
151 combined_modifier,
152 EmptyMeasurePolicy,
153 || {}, );
155
156 SelectionHandles(state, options.text_style, controller);
162
163 node
164}
165
166#[composable]
169fn SelectionHandles(
170 state: TextFieldState,
171 style: TextStyle,
172 controller: TextFieldHandleController,
173) {
174 let selection = state.selection();
175 let current_range = (selection.min(), selection.max());
176
177 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
181 let previous_range: Rc<Cell<(usize, usize)>> =
182 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
183 {
184 let previous_range = Rc::clone(&previous_range);
185 SideEffect(move || {
186 if previous_range.get() != current_range {
187 previous_range.set(current_range);
188 menu_open.set(true);
189 }
190 });
191 }
192
193 let Some(metrics) = controller.metrics() else {
194 return;
195 };
196 if !metrics.focused || !metrics.touch {
197 return;
198 }
199
200 let text = state.text();
201
202 if selection.collapsed() {
203 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
205 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
206 SelectionHandle(
207 HandleKind::Cursor,
208 tip,
209 HANDLE_RADIUS,
210 HANDLE_COLOR,
211 move |pos| on_drag(pos),
212 || {},
213 );
214 } else {
215 let start = selection.min();
217 let end = selection.max();
218 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
219 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
220
221 let on_drag_start = drag_edge_closure(
222 HandleKind::SelectionStart,
223 state.clone(),
224 style.clone(),
225 controller.clone(),
226 );
227 SelectionHandle(
228 HandleKind::SelectionStart,
229 start_tip,
230 HANDLE_RADIUS,
231 HANDLE_COLOR,
232 move |pos| on_drag_start(pos),
233 || {},
234 );
235
236 let on_drag_end = drag_edge_closure(
237 HandleKind::SelectionEnd,
238 state.clone(),
239 style.clone(),
240 controller.clone(),
241 );
242 SelectionHandle(
243 HandleKind::SelectionEnd,
244 end_tip,
245 HANDLE_RADIUS,
246 HANDLE_COLOR,
247 move |pos| on_drag_end(pos),
248 || {},
249 );
250
251 if menu_open.value() {
254 let menu_anchor = Point {
256 x: (start_tip.x + end_tip.x) * 0.5,
257 y: start_tip.y - metrics.line_height,
258 };
259 let can_paste = clipboard_read_text().is_some();
260 TextSelectionMenu(
261 menu_anchor,
262 can_paste,
263 move || {
264 if let Some(text) = dispatch_copy() {
265 clipboard_write_text(&text);
266 }
267 menu_open.set(false);
268 },
269 move || {
270 if let Some(text) = dispatch_cut() {
271 clipboard_write_text(&text);
272 }
273 menu_open.set(false);
274 },
275 move || {
276 if let Some(text) = clipboard_read_text() {
277 dispatch_paste(&text);
278 }
279 menu_open.set(false);
280 },
281 move || {
282 dispatch_select_all();
283 menu_open.set(false);
284 },
285 );
286 }
287 }
288}
289
290fn drag_caret_closure(
293 state: TextFieldState,
294 style: TextStyle,
295 controller: TextFieldHandleController,
296) -> Rc<dyn Fn(Point)> {
297 Rc::new(move |window_pos: Point| {
298 let Some(metrics) = controller.metrics() else {
299 return;
300 };
301 let text = state.text();
302 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
303 state.set_selection(TextRange::new(offset, offset));
304 crate::request_render_invalidation();
305 })
306}
307
308fn drag_edge_closure(
312 dragged: HandleKind,
313 state: TextFieldState,
314 style: TextStyle,
315 controller: TextFieldHandleController,
316) -> Rc<dyn Fn(Point)> {
317 Rc::new(move |window_pos: Point| {
318 let Some(metrics) = controller.metrics() else {
319 return;
320 };
321 let text = state.text();
322 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
323 let selection = state.selection();
324 let fixed_edge = match dragged {
325 HandleKind::SelectionStart => selection.max(),
326 _ => selection.min(),
327 };
328 let (min, max) =
329 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
330 state.set_selection(TextRange::new(min, max));
331 crate::request_render_invalidation();
332 })
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
339 use std::sync::Arc;
340
341 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
343 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
344 f()
345 }
346
347 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
351 use crate::layout::LayoutEngine;
352 use crate::renderer::HeadlessRenderer;
353 use crate::widgets::PopupHost;
354 use cranpose_ui_graphics::Size;
355
356 let mut composition = Composition::new(MemoryApplier::new());
357 let key = location_key(file!(), line!(), column!());
358 let state = TextFieldState::new("hello world");
359
360 let mut content = {
361 let state = state.clone();
362 move || {
363 let state = state.clone();
364 PopupHost(move || {
365 let controller = TextFieldHandleController::new();
366 controller.publish(TextFieldHandleMetrics {
367 focused: true,
368 touch,
369 node_origin: Point { x: 0.0, y: 10.0 },
370 padding_left: 0.0,
371 padding_top: 0.0,
372 scroll_offset: 0.0,
373 line_height: 18.0,
374 });
375 SelectionHandles(state.clone(), TextStyle::default(), controller);
376 });
377 }
378 };
379
380 composition.render(key, &mut content).expect("render");
381 for _ in 0..16 {
382 if !composition.should_render() {
383 break;
384 }
385 composition.reconcile(key, &mut content).expect("reconcile");
386 }
387 let root = composition.root().expect("root");
388 let handle = composition.runtime_handle();
389 let mut applier = composition.applier_mut();
390 applier.set_runtime_handle(handle);
391 let layout = applier
392 .compute_layout(
393 root,
394 Size {
395 width: 400.0,
396 height: 400.0,
397 },
398 )
399 .expect("layout");
400 applier.clear_runtime_handle();
401 drop(applier);
402 HeadlessRenderer::new().render(&layout)
403 }
404
405 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
408 use crate::layout::LayoutEngine;
409 use crate::renderer::HeadlessRenderer;
410 use crate::widgets::PopupHost;
411 use cranpose_ui_graphics::Size;
412
413 let mut composition = Composition::new(MemoryApplier::new());
414 let key = location_key(file!(), line!(), column!());
415 let state = TextFieldState::new("hello world");
416
417 let mut content = {
418 let state = state.clone();
419 move || {
420 let state = state.clone();
421 PopupHost(move || {
422 let controller = TextFieldHandleController::new();
423 if state.selection() != TextRange::new(0, 5) {
424 state.set_selection(TextRange::new(0, 5));
425 }
426 controller.publish(TextFieldHandleMetrics {
427 focused: true,
428 touch,
429 node_origin: Point { x: 0.0, y: 40.0 },
430 padding_left: 0.0,
431 padding_top: 0.0,
432 scroll_offset: 0.0,
433 line_height: 18.0,
434 });
435 SelectionHandles(state.clone(), TextStyle::default(), controller);
436 });
437 }
438 };
439
440 composition.render(key, &mut content).expect("render");
441 for _ in 0..16 {
442 if !composition.should_render() {
443 break;
444 }
445 composition.reconcile(key, &mut content).expect("reconcile");
446 }
447 let root = composition.root().expect("root");
448 let handle = composition.runtime_handle();
449 let mut applier = composition.applier_mut();
450 applier.set_runtime_handle(handle);
451 let layout = applier
452 .compute_layout(
453 root,
454 Size {
455 width: 400.0,
456 height: 400.0,
457 },
458 )
459 .expect("layout");
460 applier.clear_runtime_handle();
461 drop(applier);
462 HeadlessRenderer::new().render(&layout)
463 }
464
465 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
466 use crate::renderer::RenderOp;
467 scene
468 .operations()
469 .iter()
470 .filter_map(|op| match op {
471 RenderOp::Text { value, .. } => Some(value.clone()),
472 _ => None,
473 })
474 .collect()
475 }
476
477 #[test]
478 fn context_menu_shows_for_touch_selection_only() {
479 let _app_context = crate::render_state::app_context_test_scope();
480
481 let touch = text_values(&render_range_menu(true));
482 assert!(
483 touch.iter().any(|t| t == "Copy"),
484 "touch selection should show the Copy menu item, got {touch:?}"
485 );
486 assert!(
487 touch.iter().any(|t| t == "Cut"),
488 "expected Cut, got {touch:?}"
489 );
490 assert!(
491 touch.iter().any(|t| t == "Select all"),
492 "expected Select all, got {touch:?}"
493 );
494
495 let mouse = text_values(&render_range_menu(false));
496 assert!(
497 !mouse.iter().any(|t| t == "Copy"),
498 "mouse selection must not show the finger contextual menu, got {mouse:?}"
499 );
500 }
501
502 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
503 use crate::renderer::RenderOp;
504 use cranpose_ui_graphics::DrawPrimitive;
505 scene
506 .operations()
507 .iter()
508 .filter(|op| {
509 matches!(
510 op,
511 RenderOp::Primitive {
512 primitive: DrawPrimitive::Image { .. },
513 ..
514 }
515 )
516 })
517 .count()
518 }
519
520 #[test]
521 fn cursor_handle_shows_for_touch_only() {
522 let _app_context = crate::render_state::app_context_test_scope();
523 assert_eq!(
524 image_count(&render_collapsed_handles(true)),
525 1,
526 "a touch caret should show one finger cursor handle in the overlay"
527 );
528 assert_eq!(
529 image_count(&render_collapsed_handles(false)),
530 0,
531 "a mouse caret should keep a clean caret with no finger handle"
532 );
533 }
534
535 #[test]
536 fn basic_text_field_creates_node() {
537 let _app_context = crate::render_state::app_context_test_scope();
538 let mut composition = Composition::new(MemoryApplier::new());
539 let state = TextFieldState::new("Test content");
540
541 let result = composition.render(location_key(file!(), line!(), column!()), {
542 let state = state.clone();
543 move || {
544 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
545 }
546 });
547
548 assert!(result.is_ok());
549 assert!(composition.root().is_some());
550 }
551
552 #[test]
553 fn basic_text_field_state_updates() {
554 let _app_context = crate::render_state::app_context_test_scope();
555 with_test_runtime(|| {
556 let state = TextFieldState::new("Hello");
557 assert_eq!(state.text(), "Hello");
558
559 state.edit(|buffer| {
560 buffer.place_cursor_at_end();
561 buffer.insert("!");
562 });
563
564 assert_eq!(state.text(), "Hello!");
565 });
566 }
567}