feat(maestro): wire spawn overlay fields to the text-input widget

This commit is contained in:
Miguel Palhas
2026-08-23 19:57:24 +01:00
parent dfc4ee91e1
commit e3996dec76
4 changed files with 130 additions and 68 deletions
+6 -6
View File
@@ -190,8 +190,8 @@ impl App {
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.clone()),
command: self.spawn.command.clone(),
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
@@ -267,8 +267,8 @@ impl App {
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: String::new(),
command: String::new(),
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
@@ -590,8 +590,8 @@ mod tests {
fn submitting_the_spawn_modal_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = "/srv/work".into();
app.spawn.command = "claude".into();
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
+89 -44
View File
@@ -20,8 +20,8 @@ pub enum SpawnField {
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub cwd: String,
pub command: String,
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
@@ -64,27 +64,16 @@ impl SpawnModal {
SpawnInputResult::Consumed
}
},
InputKey::Char { c, .. } => {
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => {
self.cwd.pop();
}
SpawnField::Command => {
self.command.pop();
}
_ => {}
}
SpawnInputResult::Consumed
}
_ => SpawnInputResult::Consumed,
}
}
@@ -107,20 +96,40 @@ impl SpawnModal {
Style::default()
}
};
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
// 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(ratatui::text::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,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
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)
};
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[0],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
@@ -129,20 +138,9 @@ impl SpawnModal {
rows[2],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[1].y))
}
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
@@ -267,8 +265,8 @@ mod tests {
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: "/x".into(),
command: "bash".into(),
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
@@ -338,4 +336,51 @@ mod tests {
assert_eq!(modal.focused_field, SpawnField::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 modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
}
}
+15 -3
View File
@@ -5,7 +5,9 @@
use std::io;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -26,7 +28,12 @@ impl TerminalGuard {
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
execute!(
out,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
Ok(TerminalGuard)
}
@@ -34,7 +41,12 @@ impl TerminalGuard {
/// call it without producing weird errors.
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = execute!(
out,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
+20 -15
View File
@@ -19,8 +19,10 @@ pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
cursor: usize,
/// Char offset of the leftmost visible cell.
scroll: usize,
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
/// re-clamp it without `&mut self` — widgets are drawn through shared
/// references in this codebase.
scroll: std::cell::Cell<usize>,
}
impl TextInput {
@@ -44,7 +46,7 @@ impl TextInput {
/// Char offset of the leftmost visible cell; only meaningful after
/// [`TextInput::render`] has clamped it to the field width.
pub fn scroll_offset(&self) -> usize {
self.scroll
self.scroll.get()
}
/// Insert `text` at the cursor as one operation. Used both for typed
@@ -110,21 +112,24 @@ impl TextInput {
/// Draw into `area` (expected one row tall), scrolling horizontally so the
/// cursor stays visible, and return the screen column where the caller
/// should place the terminal cursor.
pub fn render(&mut self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
let width = area.width as usize;
let len = self.value.chars().count();
self.scroll = self.scroll.min(len);
if self.cursor < self.scroll {
self.scroll = self.cursor;
} else if width > 0 && self.cursor.saturating_sub(self.scroll) >= width {
self.scroll = self.cursor + 1 - width;
}
for (col, c) in self.value.chars().skip(self.scroll).take(width).enumerate() {
let scroll = self.scroll.get().min(len);
let scroll = if self.cursor < scroll {
self.cursor
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
self.cursor + 1 - width
} else {
scroll
};
self.scroll.set(scroll);
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
buf[(area.x + col as u16, area.y)]
.set_symbol(&c.to_string())
.set_style(style);
}
area.x + self.cursor.saturating_sub(self.scroll) as u16
area.x + self.cursor.saturating_sub(scroll) as u16
}
fn byte_offset(&self, char_index: usize) -> usize {
@@ -378,7 +383,7 @@ mod tests {
#[test]
fn rendering_an_empty_field_points_at_its_first_column() {
let mut input = TextInput::default();
let input = TextInput::default();
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
@@ -402,7 +407,7 @@ mod tests {
#[test]
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
let value = "0123456789abcdefghij";
let mut input = TextInput::with_value(value);
let input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
@@ -435,7 +440,7 @@ mod tests {
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 5);
assert_eq!(
input.value().chars().nth(input.scroll),
input.value().chars().nth(input.scroll_offset()),
Some('5'),
"window scrolled back to the cursor"
);