Skip to main content

cranpose_ui/widgets/
basic_text_field.rs

1//! BasicTextField widget for editable text input.
2//!
3//! This module provides the `BasicTextField` composable following Jetpack Compose's
4//! `BasicTextField` pattern from `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt`.
5
6#![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
26/// Fill color of the finger selection/cursor handles (Android accent blue).
27const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
28
29/// Window-space position where a handle's tip should sit for the caret/selection
30/// endpoint at byte `offset`: the bottom of that offset's visual line.
31fn 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
50/// Maps a window-space drag position (finger on the handle bulb) back to the
51/// nearest text byte offset in the field.
52fn 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    // The bulb hangs one line below its tip, so bias the sampled y up by a line
62    // to select the line the tip points at rather than the one below it.
63    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///
68/// # When to use
69/// Use this when you need an editable text input but want full control over the
70/// styling (no built-in borders or labels).
71///
72/// # Arguments
73///
74/// * `state` - The observable text field state that holds text content and cursor position.
75/// * `modifier` - Modifiers for styling and layout.
76/// * `style` - Text styling (color, font size).
77///
78/// # Example
79///
80/// ```rust,ignore
81/// let text = remember_text_field_state("Initial text");
82/// BasicTextField(text, Modifier::padding(8.0), TextStyle::default());
83/// ```
84#[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/// Options for customizing BasicTextField appearance and behavior.
97#[derive(Debug, Clone, PartialEq)]
98pub struct BasicTextFieldOptions {
99    /// Text style
100    pub text_style: TextStyle,
101    /// Cursor color
102    pub cursor_color: Color,
103    /// Line limits: SingleLine or MultiLine with optional min/max
104    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), // Black
112            line_limits: TextFieldLineLimits::default(),
113        }
114    }
115}
116
117/// Creates an editable text field with custom options.
118///
119/// This is the full version of `BasicTextField` with all configuration options.
120#[composable]
121pub fn BasicTextFieldWithOptions(
122    state: TextFieldState,
123    modifier: Modifier,
124    options: BasicTextFieldOptions,
125) -> NodeId {
126    // Read text + selection to create composition dependencies: the field (and
127    // its finger handles) recompose when either changes.
128    let _text = state.text();
129    let _selection = state.selection();
130
131    // Shared channel through which the field node publishes live handle geometry
132    // (focus, touch, on-screen origin, metrics). Remembered so it is stable
133    // across recompositions.
134    let controller =
135        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
136
137    // Build the text field element with line limits + the handle controller.
138    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    // Wrap it in a modifier
144    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    // Use EmptyMeasurePolicy - TextFieldModifierNode handles all measurement
149    // This matches Jetpack Compose's BasicTextField architecture
150    let node = Layout(
151        combined_modifier,
152        EmptyMeasurePolicy,
153        || {}, // No children
154    );
155
156    // Finger selection handles (touch only): a caret handle for a collapsed
157    // selection, start/end teardrops for a range. Rendered in the top-level
158    // overlay via `Popup` so they escape the field's clip and hang below the
159    // last line. A `PopupHost` at the app root (installed by the shell) is
160    // required for them to appear.
161    SelectionHandles(state, options.text_style, controller);
162
163    node
164}
165
166/// Emits the finger selection/cursor handles for the field when it is focused
167/// and was last touched (never for mouse input, which keeps a clean caret).
168#[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    // Whether the contextual menu is open. Reopens whenever the selection range
178    // changes (a fresh selection), so tapping an action dismisses it until the
179    // next selection.
180    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        // Collapsed caret: a single symmetric cursor handle.
204        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        // Range selection: start (leftmost) and end (rightmost) teardrops.
216        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        // Contextual menu (Copy / Cut / Paste / Select all) floating above the
252        // selection. Actions run against the focused field and dismiss the menu.
253        if menu_open.value() {
254            // Top-center of the selection's first line.
255            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
290/// Builds the drag handler for the collapsed cursor handle: moves the caret to
291/// the dragged position.
292fn 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
308/// Builds the drag handler for a selection start/end teardrop: extends the
309/// selection to the dragged position while keeping the opposite edge fixed and
310/// never letting the edges cross.
311fn 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    /// Sets up a test runtime and keeps it alive for the duration of the test.
342    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
343        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
344        f()
345    }
346
347    /// Composes just the finger handles for a collapsed caret with the given
348    /// published metrics, and returns the rendered scene. The teardrop
349    /// rasterizes to an image primitive, so counting images counts handles.
350    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    /// Composes the handles + contextual menu for a range selection with the
406    /// given metrics, returning the rendered scene.
407    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}