feat(maestro): add draft-pane widget with its own field focus

This commit is contained in:
Miguel Palhas
2026-08-23 20:16:43 +01:00
parent 35bc5bfb40
commit 95f0e42df5
2 changed files with 273 additions and 0 deletions
+272
View File
@@ -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}"
);
}
}
+1
View File
@@ -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;