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}