From 95f0e42df528a59f8e8215df72eda15ea938476d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:16:43 +0100 Subject: [PATCH] feat(maestro): add draft-pane widget with its own field focus --- crates/maestro-tui/src/draft.rs | 272 ++++++++++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 1 + 2 files changed, 273 insertions(+) create mode 100644 crates/maestro-tui/src/draft.rs diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs new file mode 100644 index 0000000..2cb6afe --- /dev/null +++ b/crates/maestro-tui/src/draft.rs @@ -0,0 +1,272 @@ +//! Draft pane — the spawn form living in an ordinary pane (issue #63). +//! +//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands +//! the caller a built request and the pane is replaced with the new session, +//! matching the web client's inline draft panes (#57). Field editing goes +//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields. +//! +//! Scope note: presets, the project list, generated names, and the worktree +//! section land with issues #64/#65 — this is cwd + command only. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::input::InputKey; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum DraftField { + #[default] + Cwd, + Command, + Submit, +} + +#[derive(Debug, Default, Clone)] +pub struct DraftPane { + pub cwd: crate::text_input::TextInput, + pub command: crate::text_input::TextInput, + pub focused_field: DraftField, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftInputResult { + Consumed, + Submit, +} + +impl DraftPane { + pub fn next_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Command, + DraftField::Command => DraftField::Submit, + DraftField::Submit => DraftField::Cwd, + }; + } + + pub fn prev_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Submit, + DraftField::Command => DraftField::Cwd, + DraftField::Submit => DraftField::Command, + }; + } + + /// Handle one keystroke while this draft owns input (Insert mode, pane + /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; + /// the caller fires the POST and replaces the draft with the session. + pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult { + match key { + InputKey::Tab => { + self.next_field(); + DraftInputResult::Consumed + } + InputKey::BackTab => { + self.prev_field(); + DraftInputResult::Consumed + } + InputKey::Enter => match self.focused_field { + DraftField::Submit => DraftInputResult::Submit, + _ => { + self.next_field(); + DraftInputResult::Consumed + } + }, + // Everything else — characters, motion, editing, paste — goes to + // the focused field's text input. + other => { + match self.focused_field { + DraftField::Cwd => self.cwd.handle_key(other), + DraftField::Command => self.command.handle_key(other), + DraftField::Submit => {} + } + DraftInputResult::Consumed + } + } + } + + /// Draw into the pane area. `pane_focused` gates the field highlight and + /// cursor placement the same way a session pane hides its cursor when + /// unfocused. + pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) { + let block = Block::default().title(" draft ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(inner); + + let style_for = |field: DraftField| { + if pane_focused && self.focused_field == field { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + } + }; + + // Each text field renders through the shared `TextInput`, which + // handles horizontal scrolling and reports where the terminal cursor + // belongs. Label is fixed-width 8 chars + 1 space = column 9. + let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} ")))); + f.render_widget(label("cwd:"), rows[0]); + f.render_widget(label("command:"), rows[1]); + let draw_field = + |f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 { + let area = Rect { + x: row.x.saturating_add(9), + y: row.y, + width: row.width.saturating_sub(9), + height: 1, + }; + input.render(area, f.buffer_mut(), style) + }; + let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd)); + let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command)); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "[ Spawn ]", + style_for(DraftField::Submit), + )])), + rows[2], + ); + + if pane_focused { + match self.focused_field { + DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), + DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)), + DraftField::Submit => { + // The submit button is indicated by the REVERSED style; no + // cursor placement. + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn draft_pane_renders_field_labels_and_values() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("/x"), + command: crate::text_input::TextInput::with_value("bash"), + focused_field: DraftField::Cwd, + }; + let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true)); + for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + } + + #[test] + fn field_rotation_wraps_in_both_directions() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Command); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.prev_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + + draft.on_input(&InputKey::BackTab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Tab); + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Cwd); + } + + /// Editing keys must reach the focused field's text input, not a bare + /// String — this is what gives the operator cursor motion and word ops. + #[test] + fn editing_keys_reach_the_focused_text_input() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + for k in [ + InputKey::char('/'), + InputKey::char('t'), + InputKey::char('m'), + InputKey::char('p'), + ] { + draft.on_input(&k); + } + draft.on_input(&InputKey::Left); + draft.on_input(&InputKey::Backspace); + assert_eq!(draft.cwd.value(), "/tp"); + + // Tab to command, then a paste and ctrl-w land there, not in cwd. + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Paste("/tmp/foo".into())); + assert_eq!(draft.command.value(), "/tmp/foo"); + draft.on_input(&InputKey::ctrl_char('w')); + assert_eq!(draft.command.value(), ""); + assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused"); + } + + /// Enter walks forward until the submit field, where it submits. + #[test] + fn enter_walks_fields_then_submits_from_submit() { + let mut draft = DraftPane::default(); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Command); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Submit); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (label is 8 chars + 1 space, block border takes column 0). + #[test] + fn render_places_the_cursor_after_the_typed_text_when_focused() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("abc"), + ..DraftPane::default() + }; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1)); + + // Unfocused pane: no field highlight either. + let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false)); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), false)).unwrap(); + let buf = terminal.backend().buffer(); + assert!( + !buf.content() + .iter() + .any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)), + "unfocused draft must not highlight fields:\n{rendered_unfocused}" + ); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index b76bd84..5d805bb 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod catalog; pub mod cli; pub mod config; +pub mod draft; pub mod emulators; pub mod input; pub mod keymap;