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::composable;
9use crate::layout::policies::EmptyMeasurePolicy;
10use crate::modifier::Modifier;
11use crate::text_field_modifier_node::TextFieldElement;
12use crate::widgets::Layout;
13use cranpose_core::NodeId;
14use cranpose_foundation::modifier_element;
15use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState};
16use cranpose_ui_graphics::Color;
17
18/// Creates an editable text field.
19///
20/// `BasicTextField` provides an interactive box that accepts text input through
21/// software or hardware keyboard, but provides no decorations like hint or placeholder.
22/// Use a wrapper component to add decorations.
23///
24/// # Arguments
25///
26/// * `state` - The observable text field state that holds the text content
27/// * `modifier` - Optional modifiers for styling and layout
28///
29/// # Architecture
30///
31/// Following Jetpack Compose's `BasicTextField` pattern, this implementation uses:
32/// - **TextFieldElement**: Adds text field content as a modifier node
33/// - **EmptyMeasurePolicy**: Delegates all measurement to modifier nodes
34///
35/// Text content lives in the modifier node (`TextFieldModifierNode`), not in the measure policy.
36///
37/// # Example
38///
39/// ```text
40/// use cranpose_foundation::text::TextFieldState;
41/// use cranpose_ui::{BasicTextField, Modifier};
42///
43/// let state = TextFieldState::new("Hello");
44/// BasicTextField(state.clone(), Modifier::empty());
45///
46/// // Edit the text programmatically
47/// state.edit(|buffer| {
48///     buffer.place_cursor_at_end();
49///     buffer.insert(", World!");
50/// });
51/// ```
52#[composable]
53pub fn BasicTextField(state: TextFieldState, modifier: Modifier) -> NodeId {
54    BasicTextFieldWithOptions(state, modifier, BasicTextFieldOptions::default())
55}
56
57/// Options for customizing BasicTextField appearance and behavior.
58#[derive(Debug, Clone, PartialEq)]
59pub struct BasicTextFieldOptions {
60    /// Cursor color
61    pub cursor_color: Color,
62    /// Line limits: SingleLine or MultiLine with optional min/max
63    pub line_limits: TextFieldLineLimits,
64}
65
66impl Default for BasicTextFieldOptions {
67    fn default() -> Self {
68        Self {
69            cursor_color: Color(0.0, 0.0, 0.0, 1.0), // Black
70            line_limits: TextFieldLineLimits::default(),
71        }
72    }
73}
74
75/// Creates an editable text field with custom options.
76///
77/// This is the full version of `BasicTextField` with all configuration options.
78#[composable]
79pub fn BasicTextFieldWithOptions(
80    state: TextFieldState,
81    modifier: Modifier,
82    options: BasicTextFieldOptions,
83) -> NodeId {
84    // Read text to create composition dependency.
85    // TextFieldState now uses mutableStateOf internally, so this read
86    // automatically creates composition dependency via the snapshot system.
87    let _text = state.text();
88
89    // Build the text field element with line limits
90    let text_field_element = TextFieldElement::new(state)
91        .with_cursor_color(options.cursor_color)
92        .with_line_limits(options.line_limits);
93
94    // Wrap it in a modifier
95    let text_field_modifier = modifier_element(text_field_element);
96    let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
97    let combined_modifier = modifier.then(final_modifier);
98
99    // Use EmptyMeasurePolicy - TextFieldModifierNode handles all measurement
100    // This matches Jetpack Compose's BasicTextField architecture
101    Layout(
102        combined_modifier,
103        EmptyMeasurePolicy,
104        || {}, // No children
105    )
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
112    use std::sync::Arc;
113
114    /// Sets up a test runtime and keeps it alive for the duration of the test.
115    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
116        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
117        f()
118    }
119
120    #[test]
121    fn basic_text_field_creates_node() {
122        let mut composition = Composition::new(MemoryApplier::new());
123        let state = TextFieldState::new("Test content");
124
125        let result = composition.render(location_key(file!(), line!(), column!()), {
126            let state = state.clone();
127            move || {
128                BasicTextField(state.clone(), Modifier::empty());
129            }
130        });
131
132        assert!(result.is_ok());
133        assert!(composition.root().is_some());
134    }
135
136    #[test]
137    fn basic_text_field_state_updates() {
138        with_test_runtime(|| {
139            let state = TextFieldState::new("Hello");
140            assert_eq!(state.text(), "Hello");
141
142            state.edit(|buffer| {
143                buffer.place_cursor_at_end();
144                buffer.insert("!");
145            });
146
147            assert_eq!(state.text(), "Hello!");
148        });
149    }
150}