From e3996dec769aa8ac4bb8177550dc508ee695aa68 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:57:24 +0100 Subject: [PATCH] feat(maestro): wire spawn overlay fields to the text-input widget --- crates/maestro-tui/src/app.rs | 12 +-- crates/maestro-tui/src/overlays.rs | 133 ++++++++++++++++++--------- crates/maestro-tui/src/terminal.rs | 18 +++- crates/maestro-tui/src/text_input.rs | 35 ++++--- 4 files changed, 130 insertions(+), 68 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 82a46f7..aa9574d 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -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); diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5cc5722..5bb49d2 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -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)); + } } diff --git a/crates/maestro-tui/src/terminal.rs b/crates/maestro-tui/src/terminal.rs index eeb5fb5..e21df3f 100644 --- a/crates/maestro-tui/src/terminal.rs +++ b/crates/maestro-tui/src/terminal.rs @@ -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 { 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(); } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs index 402b7ce..55a3a4a 100644 --- a/crates/maestro-tui/src/text_input.rs +++ b/crates/maestro-tui/src/text_input.rs @@ -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, } 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" );