Skip to main content

hjkl_form/
field.rs

1//! Field types — the building blocks of a `Form`.
2
3use crate::host::FormFieldHost;
4use crate::validate::Validator;
5use hjkl_buffer::View;
6use hjkl_engine::{CoarseMode, Editor, Host, Input, Key, Options};
7use hjkl_vim::VimEditorExt;
8
9/// Metadata shared by every field variant. Holds the label,
10/// required-marker, the most recent validator error, and an optional
11/// placeholder shown when text fields are empty.
12pub struct FieldMeta {
13    pub label: String,
14    pub required: bool,
15    pub error: Option<String>,
16    pub placeholder: Option<String>,
17}
18
19impl FieldMeta {
20    /// Construct a field with just a label. Use the builder methods to
21    /// layer on `required`, `placeholder`, etc.
22    pub fn new(label: impl Into<String>) -> Self {
23        Self {
24            label: label.into(),
25            required: false,
26            error: None,
27            placeholder: None,
28        }
29    }
30
31    /// Mark the field as required (renderers prefix the label with `*`).
32    pub fn required(mut self, required: bool) -> Self {
33        self.required = required;
34        self
35    }
36
37    /// Set the placeholder text shown when the field is empty and not
38    /// being edited.
39    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
40        self.placeholder = Some(text.into());
41        self
42    }
43}
44
45/// A text input field — either single-line or multi-line. Owns its own
46/// `Editor<View, FormFieldHost>` so the full vim grammar applies.
47///
48/// Two construction paths:
49///
50/// - [`TextFieldEditor::with_meta`] — used by [`crate::Form`] to wire a
51///   field with label / validator / placeholder metadata.
52/// - [`TextFieldEditor::new`] / [`TextFieldEditor::with_text`] — the
53///   standalone primitive: a vim-grammar one-line (or N-line) prompt
54///   without a surrounding form. Used by hosts that need just the
55///   editing surface (`:` command palette, `/` `?` search prompt, etc.).
56///
57/// In standalone single-line mode, `Enter` is **swallowed** by
58/// [`TextFieldEditor::handle_input`]: there is no "next field" to jump
59/// to, and the surrounding host typically interprets `Enter` as
60/// "submit / commit" via its own dispatcher before the keystroke ever
61/// reaches the field. This keeps the buffer single-line by construction.
62pub struct TextFieldEditor {
63    pub meta: FieldMeta,
64    pub editor: Editor<View, FormFieldHost>,
65    pub validator: Option<Validator>,
66    /// Visible body height for multi-line fields. Single-line is 1.
67    pub rows: u16,
68    /// True when the field is single-line — gates Enter swallowing in
69    /// the standalone `handle_input` path and label rendering choices.
70    pub(crate) single_line: bool,
71    /// `dirty_gen` of the buffer at the moment the user entered Insert
72    /// on this field. Used to decide whether a `Changed` event fires
73    /// on Esc.
74    pub(crate) enter_gen: u64,
75}
76
77impl TextFieldEditor {
78    /// Build a standalone vim-grammar text field with empty buffer.
79    /// `single_line=true` suppresses Enter from inserting newlines and
80    /// sets `rows=1`; multi-line fields default to `rows=3`. Hosts that
81    /// want a different multi-line height should bump `rows` afterwards
82    /// or use [`TextFieldEditor::with_meta`].
83    pub fn new(single_line: bool) -> Self {
84        let buffer = View::new();
85        let host = FormFieldHost::new();
86        let editor = hjkl_vim::vim_editor(buffer, host, Options::default());
87        Self {
88            meta: FieldMeta::new(""),
89            editor,
90            validator: None,
91            rows: if single_line { 1 } else { 3 },
92            single_line,
93            enter_gen: 0,
94        }
95    }
96
97    /// Standalone variant pre-populated with `text`. Cursor lands at the
98    /// end of the inserted content; mode is `Normal`.
99    pub fn with_text(text: &str, single_line: bool) -> Self {
100        let mut me = Self::new(single_line);
101        me.set_text(text);
102        me
103    }
104
105    /// Form-style constructor: full metadata + render-height. The label
106    /// / placeholder / required marker render via
107    /// [`crate::Form`]'s ratatui adapter.
108    pub fn with_meta(meta: FieldMeta, rows: u16) -> Self {
109        let buffer = View::new();
110        let host = FormFieldHost::new();
111        let editor = hjkl_vim::vim_editor(buffer, host, Options::default());
112        Self {
113            meta,
114            editor,
115            validator: None,
116            single_line: rows <= 1,
117            rows,
118            enter_gen: 0,
119        }
120    }
121
122    /// Attach a validator that runs on field-blur and on submit.
123    pub fn with_validator(mut self, validator: Validator) -> Self {
124        self.validator = Some(validator);
125        self
126    }
127
128    /// Pre-fill the editor's buffer with `text`.
129    pub fn with_initial(mut self, text: &str) -> Self {
130        let buffer = View::from_str(text);
131        let host = FormFieldHost::new();
132        self.editor = hjkl_vim::vim_editor(buffer, host, Options::default());
133        self
134    }
135
136    /// Borrow the underlying [`View`] for span rendering, snapshots,
137    /// or other read-only consumers.
138    pub fn buffer(&self) -> &View {
139        self.editor.buffer()
140    }
141
142    /// Mutable buffer access. Rare — prefer [`TextFieldEditor::handle_input`]
143    /// which routes through the vim FSM and keeps the cursor consistent.
144    pub fn buffer_mut(&mut self) -> &mut View {
145        self.editor.buffer_mut()
146    }
147
148    /// Snapshot the buffer's current text. Multi-line buffers join with
149    /// `'\n'`. A single-line field returns only its first (visible) line, so a
150    /// newline slipped in via `o`/`O`/paste can't submit hidden multi-line data
151    /// that the renderer never showed.
152    pub fn text(&self) -> String {
153        let s = self.editor.buffer().as_string();
154        if self.single_line {
155            s.split('\n').next().unwrap_or("").to_string()
156        } else {
157            s
158        }
159    }
160
161    /// Replace contents wholesale, e.g. when opening a prompt with a
162    /// preset value. Drops the inner editor and rebuilds it; cursor
163    /// lands at the end of the new content and mode is `Normal`.
164    pub fn set_text(&mut self, text: &str) {
165        let buffer = View::from_str(text);
166        let host = FormFieldHost::new();
167        self.editor = hjkl_vim::vim_editor(buffer, host, Options::default());
168        // Land cursor at end-of-text so `enter_insert_at_end` puts the
169        // caret right after the last character.
170        let n = self.editor.buffer().row_count();
171        if n > 0 {
172            let row = n - 1;
173            let rope = self.editor.buffer().rope();
174            let col = hjkl_buffer::rope_line_str(&rope, row).chars().count();
175            self.editor
176                .buffer_mut()
177                .set_cursor(hjkl_buffer::Position::new(row, col));
178        }
179    }
180
181    /// Cursor position as `(row, col)` in chars. Use directly to place
182    /// the terminal cursor in the prompt's render rect.
183    pub fn cursor(&self) -> (usize, usize) {
184        self.editor.cursor()
185    }
186
187    /// Discipline-agnostic coarse mode of the inner editor (Normal / Insert /
188    /// Select / ...).
189    ///
190    /// A form field only ever needs "am I inserting text or not" — it has no
191    /// business naming vim-specific states. Reading [`CoarseMode`] instead of
192    /// `VimMode` keeps this widget usable under any keybinding discipline
193    /// (#265 / #267).
194    pub fn coarse_mode(&self) -> CoarseMode {
195        self.editor.coarse_mode()
196    }
197
198    /// Force the inner editor into Insert mode at the end of the
199    /// current line. Used by hosts that open a prompt and want the user
200    /// typing immediately.
201    pub fn enter_insert_at_end(&mut self) {
202        // Move cursor to end of last line.
203        let n = self.editor.buffer().row_count();
204        let row = n.saturating_sub(1);
205        let eol_rope = self.editor.buffer().rope();
206        let col = hjkl_buffer::rope_line_str(&eol_rope, row).chars().count();
207        self.editor
208            .buffer_mut()
209            .set_cursor(hjkl_buffer::Position::new(row, col));
210        // Normalise FSM to Normal first so `enter_insert_shift_a` cleanly
211        // transitions (it expects Normal mode as the entry point).
212        self.editor.force_normal();
213        // `A` (append at end of line) puts the editor in Insert at EOL —
214        // exactly the entry point we want for prompts.
215        self.editor.enter_insert_shift_a(1);
216    }
217
218    /// Force the inner editor back to Normal mode (Esc).
219    pub fn enter_normal(&mut self) {
220        self.editor.force_normal();
221    }
222
223    /// Forward a key event to the inner editor's vim FSM. Returns
224    /// `true` when the buffer's `dirty_gen` advanced — useful for
225    /// triggering incremental search on `/` `?` prompts.
226    ///
227    /// In single-line standalone mode, `Enter` while in Insert is
228    /// swallowed: there is no next field to jump to. Hosts intercept
229    /// `Enter` upstream as "submit", so the field never sees it in
230    /// practice; this guard is the belt-and-suspenders.
231    pub fn handle_input(&mut self, input: Input) -> bool {
232        // Single-line: drop newline-producing Enter in Insert mode.
233        if self.single_line
234            && input.key == Key::Enter
235            && self.editor.coarse_mode() == CoarseMode::Insert
236        {
237            return false;
238        }
239        let before = self.editor.buffer().dirty_gen();
240        hjkl_vim::dispatch_input(&mut self.editor, input);
241        self.editor.buffer().dirty_gen() != before
242    }
243
244    /// View dirty generation — bumps on every content edit.
245    pub fn dirty_gen(&self) -> u64 {
246        self.editor.buffer().dirty_gen()
247    }
248
249    /// Set the field's host viewport width. The renderer should call
250    /// this every frame so motions / scroll stay in-bounds.
251    pub fn set_viewport_width(&mut self, width: u16) {
252        self.editor.host_mut().viewport_mut().width = width;
253    }
254
255    /// Set the field's host viewport height. Single-line fields pass 1.
256    pub fn set_viewport_height(&mut self, height: u16) {
257        self.editor.host_mut().viewport_mut().height = height;
258    }
259}
260
261/// A checkbox field. `value` is the toggled state.
262pub struct CheckboxField {
263    pub meta: FieldMeta,
264    pub value: bool,
265}
266
267impl CheckboxField {
268    /// Construct an unchecked checkbox.
269    pub fn new(meta: FieldMeta) -> Self {
270        Self { meta, value: false }
271    }
272
273    /// Set the initial checked state.
274    pub fn with_value(mut self, value: bool) -> Self {
275        self.value = value;
276        self
277    }
278}
279
280/// A select field — the user cycles through `options` with `h` / `l`.
281pub struct SelectField {
282    pub meta: FieldMeta,
283    pub options: Vec<String>,
284    pub index: usize,
285}
286
287impl SelectField {
288    /// Construct a select field with a list of options. The first
289    /// option is selected by default.
290    pub fn new(meta: FieldMeta, options: Vec<String>) -> Self {
291        Self {
292            meta,
293            options,
294            index: 0,
295        }
296    }
297
298    /// Currently-selected option (`None` if `options` is empty).
299    pub fn selected(&self) -> Option<&str> {
300        self.options.get(self.index).map(String::as_str)
301    }
302}
303
304/// A submit "button" — an `Enter` while focused fires the form's submit
305/// handler.
306pub struct SubmitField {
307    pub meta: FieldMeta,
308}
309
310impl SubmitField {
311    /// Construct a submit "button" field.
312    pub fn new(meta: FieldMeta) -> Self {
313        Self { meta }
314    }
315}
316
317/// Sum-type for all field variants. The form holds a `Vec<Field>`.
318pub enum Field {
319    SingleLineText(TextFieldEditor),
320    MultiLineText(TextFieldEditor),
321    Select(SelectField),
322    Checkbox(CheckboxField),
323    Submit(SubmitField),
324}
325
326impl Field {
327    /// Borrow the field's metadata.
328    pub fn meta(&self) -> &FieldMeta {
329        match self {
330            Self::SingleLineText(f) | Self::MultiLineText(f) => &f.meta,
331            Self::Select(f) => &f.meta,
332            Self::Checkbox(f) => &f.meta,
333            Self::Submit(f) => &f.meta,
334        }
335    }
336
337    /// Mutably borrow the field's metadata.
338    pub fn meta_mut(&mut self) -> &mut FieldMeta {
339        match self {
340            Self::SingleLineText(f) | Self::MultiLineText(f) => &mut f.meta,
341            Self::Select(f) => &mut f.meta,
342            Self::Checkbox(f) => &mut f.meta,
343            Self::Submit(f) => &mut f.meta,
344        }
345    }
346
347    /// True for text fields (single- or multi-line).
348    pub fn is_text(&self) -> bool {
349        matches!(self, Self::SingleLineText(_) | Self::MultiLineText(_))
350    }
351
352    /// True for single-line text fields specifically.
353    pub fn is_single_line_text(&self) -> bool {
354        matches!(self, Self::SingleLineText(_))
355    }
356
357    /// True if the field can take focus. All variants are focusable in
358    /// v1; v2 may add static "presentational" rows.
359    pub fn is_focusable(&self) -> bool {
360        // TODO(v2): non-focusable presentational rows (headings, hints).
361        let _ = self;
362        true
363    }
364}
365
366#[cfg(test)]
367mod standalone_tests {
368    //! Tests for the standalone `TextFieldEditor` API used by host
369    //! prompts (`:` palette, `/` `?` search). The form-side path is
370    //! covered by `crate::fsm::tests`.
371
372    use super::*;
373
374    fn ki(c: char) -> Input {
375        Input {
376            key: Key::Char(c),
377            ..Input::default()
378        }
379    }
380
381    #[test]
382    fn text_round_trips_via_set_text() {
383        let mut f = TextFieldEditor::new(true);
384        f.set_text("hello");
385        assert_eq!(f.text(), "hello");
386    }
387
388    #[test]
389    fn with_text_constructor_pre_populates() {
390        let f = TextFieldEditor::with_text("abc", true);
391        assert_eq!(f.text(), "abc");
392    }
393
394    #[test]
395    fn single_line_text_strips_injected_newline() {
396        // set_text / o / O / paste can slip a newline into a single-line
397        // buffer; text() must surface only the visible first line, never the
398        // hidden remainder.
399        let f = TextFieldEditor::with_text("visible\nhidden", true);
400        assert_eq!(f.text(), "visible");
401        // A multi-line field is unchanged.
402        let g = TextFieldEditor::with_text("a\nb", false);
403        assert_eq!(g.text(), "a\nb");
404    }
405
406    #[test]
407    fn handle_input_i_enters_insert() {
408        let mut f = TextFieldEditor::new(true);
409        f.handle_input(ki('i'));
410        assert_eq!(f.coarse_mode(), CoarseMode::Insert);
411    }
412
413    #[test]
414    fn handle_input_types_and_esc_returns_to_normal() {
415        let mut f = TextFieldEditor::new(true);
416        f.handle_input(ki('i'));
417        f.handle_input(ki('h'));
418        f.handle_input(ki('i'));
419        f.handle_input(Input {
420            key: Key::Esc,
421            ..Input::default()
422        });
423        assert_eq!(f.text(), "hi");
424        assert_eq!(f.coarse_mode(), CoarseMode::Normal);
425    }
426
427    #[test]
428    fn dirty_gen_advances_after_insert() {
429        let mut f = TextFieldEditor::new(true);
430        let before = f.dirty_gen();
431        f.handle_input(ki('i'));
432        f.handle_input(ki('x'));
433        assert!(f.dirty_gen() > before);
434    }
435
436    #[test]
437    fn enter_insert_at_end_lands_cursor_at_eol() {
438        let mut f = TextFieldEditor::with_text("abc", true);
439        f.enter_insert_at_end();
440        let (row, col) = f.cursor();
441        assert_eq!(row, 0);
442        // After `A` + Insert mode, cursor is past the last char.
443        assert_eq!(col, 3);
444        assert_eq!(f.coarse_mode(), CoarseMode::Insert);
445    }
446
447    #[test]
448    fn single_line_swallows_enter_in_insert() {
449        let mut f = TextFieldEditor::new(true);
450        f.enter_insert_at_end();
451        let dirty = f.handle_input(Input {
452            key: Key::Enter,
453            ..Input::default()
454        });
455        assert!(!dirty, "Enter must not mutate buffer in single-line Insert");
456        assert_eq!(f.text(), "");
457    }
458
459    #[test]
460    fn multi_line_accepts_enter_in_insert() {
461        let mut f = TextFieldEditor::new(false);
462        f.enter_insert_at_end();
463        f.handle_input(ki('a'));
464        f.handle_input(Input {
465            key: Key::Enter,
466            ..Input::default()
467        });
468        f.handle_input(ki('b'));
469        assert_eq!(f.text(), "a\nb");
470    }
471}