From fc05bf9711599f175da85e596d2bb158da737f78 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 3 Sep 2026 02:34:27 +0900 Subject: [PATCH] fix: restore cursor after inline terminal menus --- crates/tui/src/backend_worker_picker.rs | 74 ++++++---------- crates/tui/src/inline_terminal.rs | 109 ++++++++++++++++++++++++ crates/tui/src/keys.rs | 39 ++------- crates/tui/src/lib.rs | 1 + crates/tui/src/standalone_picker.rs | 66 +++++++------- crates/tui/src/standalone_spawn.rs | 26 +++--- 6 files changed, 182 insertions(+), 133 deletions(-) create mode 100644 crates/tui/src/inline_terminal.rs diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 46e3f28a..4ab49b8c 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -7,15 +7,15 @@ use client::{ list_backend_workers, restore_backend_worker, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::backend::CrosstermBackend; +use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; use crate::backend_workspace_picker::select_backend_workspace; use crate::console; +use crate::inline_terminal::with_inline_terminal; const MAX_ROWS: usize = 10; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; @@ -127,31 +127,32 @@ fn pick_worker( workers.truncate(MAX_ROWS); let mut state = BackendWorkerPickerState::new(target, workers); - let mut terminal = make_inline_terminal()?; - loop { - terminal.draw(|frame| draw(frame, &state))?; - match poll_event()? { - None => continue, - Some(Action::Up) => state.previous(), - Some(Action::Down) => state.next(), - Some(Action::Submit) => { - close_viewport(&mut terminal)?; - return Ok(WorkerPickerResult::Selected( - state.selected_worker().clone(), - )); + with_inline_terminal( + VIEWPORT_LINES, + |terminal| -> Result<_, Box> { + loop { + terminal.draw(|frame| draw(frame, &state))?; + match poll_event()? { + None => continue, + Some(Action::Up) => state.previous(), + Some(Action::Down) => state.next(), + Some(Action::Submit) => { + return Ok(WorkerPickerResult::Selected( + state.selected_worker().clone(), + )); + } + Some(Action::SwitchWorkspace) => { + return Ok(WorkerPickerResult::SwitchWorkspace); + } + Some(Action::Cancel) => { + return Err(Box::new(io::Error::other( + "Backend worker picker cancelled", + ))); + } + } } - Some(Action::SwitchWorkspace) => { - close_viewport(&mut terminal)?; - return Ok(WorkerPickerResult::SwitchWorkspace); - } - Some(Action::Cancel) => { - close_viewport(&mut terminal)?; - return Err(Box::new(io::Error::other( - "Backend worker picker cancelled", - ))); - } - } - } + }, + ) } struct BackendWorkerPickerState { @@ -184,27 +185,6 @@ impl BackendWorkerPickerState { } } -fn make_inline_terminal() -> io::Result>> { - let backend = CrosstermBackend::new(io::stdout()); - Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(VIEWPORT_LINES), - }, - ) -} - -fn close_viewport(terminal: &mut Terminal>) -> io::Result<()> { - let area = terminal.get_frame().area(); - let last_row = area.bottom().saturating_sub(1); - terminal.set_cursor_position((0, last_row))?; - use std::io::Write; - let mut out = io::stdout(); - out.write_all(b"\r\n")?; - out.flush()?; - Ok(()) -} - enum Action { Up, Down, diff --git a/crates/tui/src/inline_terminal.rs b/crates/tui/src/inline_terminal.rs new file mode 100644 index 00000000..86093d5d --- /dev/null +++ b/crates/tui/src/inline_terminal.rs @@ -0,0 +1,109 @@ +use std::io::{self, Stdout, Write}; + +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::{TerminalOptions, Viewport}; + +pub(crate) type InlineTerminal = Terminal>; + +struct InlineTerminalGuard { + terminal: InlineTerminal, + closed: bool, +} + +impl InlineTerminalGuard { + fn open(height: u16) -> io::Result { + let terminal = Terminal::with_options( + CrosstermBackend::new(io::stdout()), + TerminalOptions { + viewport: Viewport::Inline(height), + }, + )?; + Ok(Self { + terminal, + closed: false, + }) + } + + fn close(&mut self) -> io::Result<()> { + if self.closed { + return Ok(()); + } + self.closed = true; + + let area = self.terminal.get_frame().area(); + let last_row = area.bottom().saturating_sub(1); + let cursor_result = self.terminal.set_cursor_position((0, last_row)); + let output_result = write_viewport_terminator(&mut io::stdout()); + cursor_result?; + output_result + } +} + +impl Drop for InlineTerminalGuard { + fn drop(&mut self) { + let _ = self.close(); + } +} + +pub(crate) fn with_inline_terminal( + height: u16, + run: impl FnOnce(&mut InlineTerminal) -> Result, +) -> Result +where + E: From, +{ + let mut guard = InlineTerminalGuard::open(height).map_err(E::from)?; + let result = run(&mut guard.terminal); + let close_result = guard.close(); + match result { + Ok(value) => { + close_result.map_err(E::from)?; + Ok(value) + } + Err(error) => Err(error), + } +} + +fn write_viewport_terminator(output: &mut impl Write) -> io::Result<()> { + output.write_all(b"\r\n")?; + output.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn viewport_terminator_moves_following_output_to_a_fresh_line() { + let mut output = Vec::new(); + + write_viewport_terminator(&mut output).unwrap(); + + assert_eq!(output, b"\r\n"); + } + + #[test] + fn inline_viewport_construction_is_owned_by_this_module() { + fn assert_shared_owner(path: &std::path::Path) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + assert_shared_owner(&path); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") + && path.file_name().and_then(|value| value.to_str()) + != Some("inline_terminal.rs") + { + let source = std::fs::read_to_string(&path).unwrap(); + assert!( + !source.contains("Viewport::Inline"), + "{} constructs an inline viewport outside its shared owner", + path.display() + ); + } + } + } + + assert_shared_owner(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")); + } +} diff --git a/crates/tui/src/keys.rs b/crates/tui/src/keys.rs index 0b2c69f7..e9acc181 100644 --- a/crates/tui/src/keys.rs +++ b/crates/tui/src/keys.rs @@ -1,17 +1,17 @@ -use std::io::{self, Stdout, Write}; use std::process::ExitCode; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; -use ratatui::backend::CrosstermBackend; +use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; use secrets::{SecretStore, SecretValue}; +use crate::inline_terminal::{InlineTerminal, with_inline_terminal}; + #[derive(Debug, Clone, PartialEq, Eq)] enum Mode { Normal, @@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode { } type UiResult = Result>; -type InlineTerminal = Terminal>; const MAX_ROWS: usize = 10; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5; @@ -270,37 +269,9 @@ impl Drop for RawModeGuard { fn run(store: SecretStore) -> UiResult<()> { enable_raw_mode()?; let guard = RawModeGuard::new(); - let mut terminal = make_inline_terminal()?; - let result = run_loop(&mut terminal, store); - let close_result = close_viewport(&mut terminal); - drop(terminal); + let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store)); guard.restore(); - result?; - close_result?; - Ok(()) -} - -fn make_inline_terminal() -> io::Result { - let backend = CrosstermBackend::new(io::stdout()); - Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(VIEWPORT_LINES), - }, - ) -} - -/// Park the cursor at the very bottom of the inline viewport and emit one -/// newline before dropping the terminal. This matches the resume picker and -/// keeps the shell prompt (or a later inline viewport) from drawing over rows. -fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> { - let area = terminal.get_frame().area(); - let last_row = area.bottom().saturating_sub(1); - terminal.set_cursor_position((0, last_row))?; - let mut out = io::stdout(); - out.write_all(b"\r\n")?; - out.flush()?; - Ok(()) + result } fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 699da9a7..2cd9f031 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -10,6 +10,7 @@ mod composer_keys; mod console; #[cfg(feature = "e2e-test")] mod e2e_observer; +mod inline_terminal; mod input; pub mod keys; mod markdown; diff --git a/crates/tui/src/standalone_picker.rs b/crates/tui/src/standalone_picker.rs index 6ff76d76..9d3dfcf3 100644 --- a/crates/tui/src/standalone_picker.rs +++ b/crates/tui/src/standalone_picker.rs @@ -3,15 +3,14 @@ use std::time::Duration; use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::widgets::Paragraph; -use ratatui::{TerminalOptions, Viewport}; use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore}; use thiserror::Error; +use crate::inline_terminal::with_inline_terminal; + const LIMIT: usize = 100; pub(crate) fn pick( @@ -57,41 +56,36 @@ fn run_picker( records: Vec, ) -> Result, StandalonePickerError> { let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); - let mut terminal = Terminal::with_options( - CrosstermBackend::new(io::stdout()), - TerminalOptions { - viewport: Viewport::Inline(height), - }, - ) - .map_err(StandalonePickerError::Io)?; - let mut selected = 0usize; - loop { - terminal - .draw(|frame| draw(frame, &records, selected)) - .map_err(StandalonePickerError::Io)?; - if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? { - continue; - } - let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else { - continue; - }; - if key.kind == KeyEventKind::Release { - continue; - } - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { - KeyCode::Up | KeyCode::Char('k') if !ctrl => { - selected = selected.saturating_sub(1); + with_inline_terminal(height, |terminal| { + let mut selected = 0usize; + loop { + terminal + .draw(|frame| draw(frame, &records, selected)) + .map_err(StandalonePickerError::Io)?; + if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? { + continue; } - KeyCode::Down | KeyCode::Char('j') if !ctrl => { - selected = (selected + 1).min(records.len() - 1); + let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else { + continue; + }; + if key.kind == KeyEventKind::Release { + continue; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Up | KeyCode::Char('k') if !ctrl => { + selected = selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if !ctrl => { + selected = (selected + 1).min(records.len() - 1); + } + KeyCode::Enter => return Ok(Some(records[selected].clone())), + KeyCode::Esc => return Ok(None), + KeyCode::Char('c') if ctrl => return Ok(None), + _ => {} } - KeyCode::Enter => return Ok(Some(records[selected].clone())), - KeyCode::Esc => return Ok(None), - KeyCode::Char('c') if ctrl => return Ok(None), - _ => {} } - } + }) } fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) { @@ -149,7 +143,7 @@ pub(crate) enum StandalonePickerError { )] NoWorkers { include_all: bool }, #[error("standalone Worker picker I/O failed: {0}")] - Io(#[source] io::Error), + Io(#[from] io::Error), } #[cfg(test)] diff --git a/crates/tui/src/standalone_spawn.rs b/crates/tui/src/standalone_spawn.rs index 1880803d..b6b78fe1 100644 --- a/crates/tui/src/standalone_spawn.rs +++ b/crates/tui/src/standalone_spawn.rs @@ -1,17 +1,17 @@ -use std::io::{self, Stdout}; +use std::io; use std::path::Path; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use manifest::ProfileDiscovery; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use thiserror::Error; +use crate::inline_terminal::{InlineTerminal, with_inline_terminal}; + const VIEWPORT_HEIGHT: u16 = 6; const FALLBACK_WORKER_NAME: &str = "worker"; @@ -182,15 +182,16 @@ pub(crate) fn select( return Err(StandaloneSpawnError::NoProfiles); } - let terminal = open_inline_terminal()?; - run_picker( - terminal, - SpawnForm::new(worker_name, default_worker_name, choices), - ) + with_inline_terminal(VIEWPORT_HEIGHT, |terminal| { + run_picker( + terminal, + SpawnForm::new(worker_name, default_worker_name, choices), + ) + }) } fn run_picker( - mut terminal: Terminal>, + terminal: &mut InlineTerminal, mut form: SpawnForm, ) -> Result, StandaloneSpawnError> { loop { @@ -222,13 +223,6 @@ fn run_picker( } } -fn open_inline_terminal() -> io::Result>> { - let options = ratatui::TerminalOptions { - viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT), - }; - Terminal::with_options(CrosstermBackend::new(io::stdout()), options) -} - fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec { registry .entries()