feat(maestro): add draft-pane widget with its own field focus
This commit is contained in:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user