fix: retain alternate screen across executable restart

This commit is contained in:
2026-10-12 23:26:38 +02:00
parent d85998e1cb
commit c35abe0f03
6 changed files with 273 additions and 103 deletions
+39 -15
View File
@@ -1,7 +1,10 @@
use std::{env, path::PathBuf};
use std::{env, io, path::PathBuf};
use vamp::{session::EditorSession, tui};
// Only the first argument can be this process-to-process terminal handoff marker.
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
@@ -86,8 +89,8 @@ struct Options {
config: Option<PathBuf>,
}
fn options() -> Result<Option<Options>, String> {
let mut args = env::args_os().skip(1);
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
let mut args = env::args_os().skip(1 + usize::from(inherited_screen));
let mut file = None;
let mut config = None;
let mut config_selected = false;
@@ -129,7 +132,25 @@ fn options() -> Result<Option<Options>, String> {
}
fn run() -> Result<(), String> {
let Some(options) = options()? else {
let inherited_screen = env::args_os()
.nth(1)
.is_some_and(|arg| arg == INHERITED_SCREEN);
if inherited_screen {
return tui::TerminalSession::run(true, || {
run_editor(options(true).map_err(io::Error::other)?, true)
})
.map_err(|error| error.to_string());
}
let options = options(false)?;
if options.is_none() {
return Ok(());
}
tui::TerminalSession::run(false, || run_editor(options, false))
.map_err(|error| error.to_string())
}
fn run_editor(options: Option<Options>, inherited_screen: bool) -> io::Result<()> {
let Some(options) = options else {
return Ok(());
};
let mut session = EditorSession::open(
@@ -137,34 +158,37 @@ fn run() -> Result<(), String> {
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
options.config.as_deref(),
)
.map_err(|error| format!("cannot open file: {error}"))?;
tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))?;
.map_err(|error| io::Error::other(format!("cannot open file: {error}")))?;
tui::run(&mut session, inherited_screen)?;
let restart = session.editor().should_restart();
drop(session);
if restart {
restart_editor()?;
tui::TerminalSession::prepare_restart()?;
restart_editor(inherited_screen)
.map_err(|error| io::Error::other(format!("Restart failed: {error}")))?;
}
Ok(())
}
fn restart_editor() -> Result<(), String> {
let executable = env::current_exe().map_err(|error| format!("Restart failed: {error}"))?;
fn restart_editor(inherited_screen: bool) -> io::Result<()> {
let executable = env::current_exe()?;
let mut command = std::process::Command::new(executable);
command.args(env::args_os().skip(1));
command.arg(INHERITED_SCREEN);
command.args(env::args_os().skip(1 + usize::from(inherited_screen)));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
Err(format!("Restart failed: {}", command.exec()))
Err(command.exec())
}
#[cfg(not(unix))]
{
let status = command
.status()
.map_err(|error| format!("Restart failed: {error}"))?;
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(format!("Restarted editor exited with {status}"))
Err(io::Error::other(format!(
"Restarted editor exited with {status}"
)))
}
}
}
+107 -84
View File
@@ -59,7 +59,8 @@ struct HookState {
active: bool,
}
struct TerminalSession {
/// Owns terminal modes and panic cleanup through application startup and shutdown.
pub struct TerminalSession {
hook: Arc<Mutex<HookState>>,
}
@@ -91,10 +92,10 @@ fn restore_terminal() {
}
impl TerminalSession {
fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
pub fn run(inherited_screen: bool, body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
let hook = Arc::new(Mutex::new(HookState {
previous: Some(panic::take_hook()),
active: false,
active: inherited_screen,
}));
let during_session = Arc::clone(&hook);
panic::set_hook(Box::new(move |info| {
@@ -119,9 +120,11 @@ impl TerminalSession {
.lock()
.unwrap_or_else(|error| error.into_inner())
.active = true;
if !inherited_screen {
execute!(io::stdout(), EnterAlternateScreen)?;
}
execute!(
io::stdout(),
EnterAlternateScreen,
EnableBracketedPaste,
EnableMouseCapture,
EnableFocusChange,
@@ -135,6 +138,20 @@ impl TerminalSession {
Err(payload) => panic::resume_unwind(payload),
}
}
/// Restores process-local raw/input state without exposing the normal screen.
/// The guard remains armed if launching the replacement process fails.
pub fn prepare_restart() -> io::Result<()> {
disable_raw_mode()?;
execute!(
io::stdout(),
EndSynchronizedUpdate,
DisableBracketedPaste,
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false)
)
}
}
impl Drop for TerminalSession {
@@ -737,95 +754,101 @@ fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
output: &mut W,
clear_screen: bool,
render: impl FnOnce(&mut Frame),
) -> io::Result<()> {
// Do not suspend the terminal's presentation while computing the next frame.
terminal.backend_mut().writer_mut().clear();
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
// Terminals without synchronized updates still hide the painting cursor.
let result = terminal
.hide_cursor()
.and_then(|()| terminal.draw(render).map(|_| ()));
let result = terminal.hide_cursor().and_then(|()| {
if clear_screen {
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
crossterm::queue!(
terminal.backend_mut(),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
)?;
}
terminal.draw(render).map(|_| ())
});
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
result.and(finish)?;
output.write_all(terminal.backend().writer())?;
output.flush()
}
/// Renders session state and translates terminal input; the session owns plugin
/// execution and retirement, so the adapter needs no lifecycle callback.
pub fn run(session: &mut EditorSession) -> io::Result<()> {
TerminalSession::run(|| {
let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut viewports = BufferViewports::default();
let mut colors = TerminalColors::default();
let mut query_theme = true;
let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() {
redraw |= session.poll();
colors.expire(Instant::now());
if viewports.theme != colors.colors {
viewports.set_theme(colors.colors);
redraw = true;
}
if redraw {
draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw_session(frame, session)
})?;
redraw = false;
}
// Start the reply deadline after painting, not before expensive syntax initialization.
if query_theme || viewports.diff_visible {
let now = Instant::now();
if query_theme || colors.refresh_due(now) {
colors.query(&mut output, now)?;
query_theme = false;
}
}
let input = if let Some(input) = pending.take() {
input
} else {
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))?
else {
continue;
};
input
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files();
pending = typing_ready(session, key, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw = true;
} else if let Some(input) = translate(key) {
viewports.panels.follow_files();
session.handle(input);
redraw = true;
}
}
Event::Paste(text) => {
viewports.panels.follow_files();
session.handle(Input::Paste(text));
redraw = true;
}
Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true,
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
_ => {}
/// Renders inside an active `TerminalSession`; inherited contents are cleared
/// inside the first synchronized frame rather than exposing a blank screen.
pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> {
let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut viewports = BufferViewports::default();
let mut colors = TerminalColors::default();
let mut query_theme = true;
let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() {
redraw |= session.poll();
colors.expire(Instant::now());
if viewports.theme != colors.colors {
viewports.set_theme(colors.colors);
redraw = true;
}
if redraw {
draw_frame(&mut terminal, &mut output, clear_screen, |frame| {
viewports.draw_session(frame, session)
})?;
redraw = false;
clear_screen = false;
}
// Start the reply deadline after painting, not before expensive syntax initialization.
if query_theme || viewports.diff_visible {
let now = Instant::now();
if query_theme || colors.refresh_due(now) {
colors.query(&mut output, now)?;
query_theme = false;
}
}
Ok(())
})
let input = if let Some(input) = pending.take() {
input
} else {
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))? else {
continue;
};
input
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files();
pending = typing_ready(session, key, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw = true;
} else if let Some(input) = translate(key) {
viewports.panels.follow_files();
session.handle(input);
redraw = true;
}
}
Event::Paste(text) => {
viewports.panels.follow_files();
session.handle(Input::Paste(text));
redraw = true;
}
Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true,
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
_ => {}
}
}
Ok(())
}
fn next_terminal_event(
@@ -13927,7 +13950,7 @@ end
)
.unwrap();
for text in ["first frame", "second frame", "third frame"] {
draw_frame(&mut terminal, &mut output, |frame| {
draw_frame(&mut terminal, &mut output, false, |frame| {
assert!(
!synchronized.get(),
"terminal presentation suspended during frame computation"
@@ -13958,7 +13981,7 @@ end
let mut viewports = BufferViewports::default();
for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) {
editor.handle(Input::Char(key));
draw_frame(&mut terminal, &mut output, |frame| {
draw_frame(&mut terminal, &mut output, false, |frame| {
viewports.draw(frame, &mut editor)
})
.unwrap();
@@ -14023,10 +14046,10 @@ end
}));
if mode == "entry" {
let mut editor = EditorSession::open(None, vec![], None).unwrap();
assert!(run(&mut editor).is_err());
assert!(TerminalSession::run(false, || run(&mut editor, false)).is_err());
} else {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
TerminalSession::run(|| {
TerminalSession::run(false, || {
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
match mode.as_str() {
"normal" => Ok(()),
@@ -14038,7 +14061,7 @@ end
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)?;
draw_frame(&mut terminal, &mut io::stdout(), |_| {
draw_frame(&mut terminal, &mut io::stdout(), false, |_| {
panic::panic_any(73_u64)
})
}