fix: retain alternate screen across executable restart
This commit is contained in:
+39
-15
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user