From 8a656e862b438ee39024254601ac3aaac0d63924 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 3 Sep 2026 08:43:58 +0200 Subject: [PATCH] feat: repaint screen with Ctrl-L (#392) Interactive sessions had no way to recover when terminal contents were corrupted because unchanged frames produce no backend writes. Add a configurable Ctrl-L repaint action. It clears the backend directly to avoid Terminal::clear's cursor-position query racing the input thread, invalidates Ratatui's cached frame, and redraws through the normal event path. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- src/config.rs | 5 +++++ src/interactive/app/eventloop.rs | 9 ++++++++- .../app/tests/journeys_readonly.rs | 19 +++++++++++++++++++ src/interactive/widgets/help.rs | 10 ++++++++++ src/interactive/widgets/i18n.rs | 6 ++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 5b17b24e..567c173a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -433,6 +433,8 @@ pub struct KeysConfig { pub quit_immediately: KeyBindings, /// Suspend the process and return control to the shell on Unix. pub suspend: KeyBindings, + /// Clear and repaint the screen. + pub repaint: KeyBindings, /// Move focus to the next open pane. pub cycle_panes: KeyBindings, /// Show or hide help. @@ -525,6 +527,7 @@ impl Default for KeysConfig { quit: KeyBindings::defaults(&["q"]), quit_immediately: KeyBindings::defaults(&["ctrl+c"]), suspend: KeyBindings::defaults(&["ctrl+z"]), + repaint: KeyBindings::defaults(&["ctrl+l"]), cycle_panes: KeyBindings::defaults(&["tab"]), toggle_help: KeyBindings::defaults(&["?"]), open_search: KeyBindings::defaults(&["/"]), @@ -638,6 +641,7 @@ impl Config { "# quit = \"q\"\n", "# quit_immediately = \"ctrl+c\"\n", "# suspend = \"ctrl+z\" # Unix only.\n", + "# repaint = \"ctrl+l\"\n", "# cycle_panes = \"tab\"\n", "# toggle_help = \"?\"\n", "# open_search = \"/\"\n", @@ -723,6 +727,7 @@ mod tests { "quit", "quit_immediately", "suspend", + "repaint", "cycle_panes", "toggle_help", "open_search", diff --git a/src/interactive/app/eventloop.rs b/src/interactive/app/eventloop.rs index ee5ce06e..e583a310 100644 --- a/src/interactive/app/eventloop.rs +++ b/src/interactive/app/eventloop.rs @@ -580,7 +580,7 @@ impl AppState { return Ok(None); } Event::Key(key) if key.kind != KeyEventKind::Release => { - if key != refresh_key() { + if key != refresh_key() && !config.keys.repaint.matches(key) { self.received_events = true; } key @@ -612,6 +612,13 @@ impl AppState { _ if keys.suspend.matches(key) => { suspend_terminal(terminal, config.notifications.any_enabled())?; } + _ if keys.repaint.matches(key) => { + terminal + .backend_mut() + .clear() + .map_err(|err| anyhow::Error::msg(err.to_string()))?; + terminal.swap_buffers(); + } _ if keys.cycle_panes.matches(key) => { self.cycle_focus(window); } diff --git a/src/interactive/app/tests/journeys_readonly.rs b/src/interactive/app/tests/journeys_readonly.rs index 4fc5eaf8..361ed9de 100644 --- a/src/interactive/app/tests/journeys_readonly.rs +++ b/src/interactive/app/tests/journeys_readonly.rs @@ -2,6 +2,7 @@ use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use pretty_assertions::assert_eq; use std::{ffi::OsString, fs, time::Duration}; +use tui::backend::Backend; use crate::interactive::app::tests::utils::{into_codes, into_events}; use crate::interactive::widgets::Column; @@ -623,6 +624,24 @@ fn tracks_terminal_focus_events() -> Result<()> { Ok(()) } +#[test] +fn ctrl_l_repaints_the_screen() -> Result<()> { + let (mut terminal, mut app) = initialized_app_and_terminal_from_fixture(&["sample-01"])?; + let expected = terminal.backend().buffer().clone(); + terminal.backend_mut().clear()?; + + app.process_events( + &mut terminal, + into_events([Event::Key(KeyEvent::new( + KeyCode::Char('l'), + KeyModifiers::CONTROL, + ))]), + )?; + + assert_eq!(terminal.backend().buffer(), &expected); + Ok(()) +} + #[test] fn once_replays_user_events_after_traversal() -> Result<()> { let (mut terminal, mut app) = untraversed_app_and_terminal_from_fixture(&["sample-01"])?; diff --git a/src/interactive/widgets/help.rs b/src/interactive/widgets/help.rs index 3fe67f7e..fc31b12f 100644 --- a/src/interactive/widgets/help.rs +++ b/src/interactive/widgets/help.rs @@ -227,6 +227,9 @@ impl HelpPane { } title(t.app_title); { + #[cfg(unix)] + hotkey(keys.suspend.to_string(), t.app_suspend, None); + hotkey(keys.repaint.to_string(), t.app_repaint, None); hotkey(keys.quit_immediately.to_string(), t.app_quit, None); spacer(); } @@ -343,6 +346,8 @@ mod tests { r#" [keys] quit_immediately = ["alt+x"] + suspend = ["alt+z"] + repaint = ["ctrl+r"] descend = ["f"] delete_marked = [] "#, @@ -351,6 +356,11 @@ mod tests { let text = rendered_with_keys(Language::English, &config.keys); assert!(text.contains("Alt + x")); + #[cfg(unix)] + assert!( + text.contains("Alt + z => Suspend the application and return control to the shell.") + ); + assert!(text.contains("Ctrl + r => Clear and repaint the screen.")); assert!(text.contains("f => Descent")); assert!(text.contains(" => Permanently delete all marked entries")); assert!(!text.contains("Ctrl + c")); diff --git a/src/interactive/widgets/i18n.rs b/src/interactive/widgets/i18n.rs index db72886a..902be33a 100644 --- a/src/interactive/widgets/i18n.rs +++ b/src/interactive/widgets/i18n.rs @@ -136,6 +136,8 @@ pub struct HelpText { pub mark_trash_2: &'static str, pub app_title: &'static str, + pub app_suspend: &'static str, + pub app_repaint: &'static str, pub app_quit: &'static str, } @@ -198,6 +200,8 @@ const EN: HelpText = HelpText { mark_trash_2: "The entries can be restored from the trash bin.", app_title: "Application control", + app_suspend: "Suspend the application and return control to the shell.", + app_repaint: "Clear and repaint the screen.", app_quit: "Close the application. No questions asked!", }; @@ -260,6 +264,8 @@ const JA: HelpText = HelpText { mark_trash_2: "エントリはゴミ箱から復元できる。", app_title: "アプリ操作", + app_suspend: "アプリケーションを一時停止してシェルに戻る。", + app_repaint: "画面を消去して再描画する。", app_quit: "アプリケーションを終了する。確認なし!", };