From 58bcd76bfdb76ecbb8cfee868dabe60b43f16cc6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 25 May 2026 18:39:44 +0300 Subject: [PATCH] feat: make focus-gain redraw configurable refs #282 --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/configuration.mdx | 3 + src/app/mod.rs | 38 +++++++++- src/app/runtime.rs | 4 +- src/app/state.rs | 6 +- src/client/mod.rs | 49 +++++++++++-- src/config/model.rs | 16 +++++ src/main.rs | 5 ++ src/protocol/wire.rs | 2 +- src/raw_input.rs | 17 +++-- src/server/headless.rs | 70 ++++++++++++++++--- 11 files changed, 182 insertions(+), 29 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index a58d4f61..a6b08e93 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157) - Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11. +- Added `ui.redraw_on_focus_gained` to keep the existing full redraw on outer-terminal focus gain by default while allowing users to opt out of the visible refresh. (#282) ## [0.6.2] - 2026-05-23 diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 470e2709..1e30fbf3 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -222,6 +222,7 @@ sidebar_width = 32 sidebar_min_width = 18 sidebar_max_width = 36 mouse_capture = true +redraw_on_focus_gained = true mouse_scroll_lines = 3 confirm_close = true prompt_new_tab_name = true @@ -238,6 +239,8 @@ accent = "cyan" Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs. +Set `redraw_on_focus_gained = false` to avoid the visible full-screen refresh when switching back to Herdr. The default is `true` because a full redraw recovers from rare stale or dirty host terminal surfaces. + Set `mouse_scroll_lines` to change how many pane scrollback lines each mouse wheel notch scrolls. The default is 3. Pane apps that request mouse reporting still receive wheel events directly. Set `show_agent_labels_on_pane_borders = true` if you want detected agent labels in split pane borders when no manual pane label is set. diff --git a/src/app/mod.rs b/src/app/mod.rs index bcbd40cf..0b4c792c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -386,7 +386,7 @@ impl App { request_submit_worktree_open: false, request_submit_worktree_remove: false, request_reload_config: false, - request_client_sound_config_reload: false, + request_client_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -458,6 +458,7 @@ impl App { sidebar_section_split, agent_panel_scope, mouse_capture: config.ui.mouse_capture, + redraw_on_focus_gained: config.ui.redraw_on_focus_gained, mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, prompt_new_tab_name: config.ui.prompt_new_tab_name, @@ -1055,6 +1056,10 @@ impl App { .sidebar_width .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); self.state.mouse_capture = config.ui.mouse_capture; + if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained { + self.state.request_client_config_reload = true; + } + self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); self.state.confirm_close = config.ui.confirm_close; self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; @@ -1065,7 +1070,7 @@ impl App { self.state.agent_panel_scroll = 0; self.state.accent = crate::config::parse_color(&config.ui.accent); if !self.state.local_sound_playback && self.state.sound != config.ui.sound { - self.state.request_client_sound_config_reload = true; + self.state.request_client_config_reload = true; } self.state.sound = config.ui.sound.clone(); self.state.toast_config = config.ui.toast.clone(); @@ -1500,6 +1505,17 @@ mod tests { ); } + #[test] + fn startup_uses_redraw_on_focus_gained_config() { + let mut config = Config::default(); + config.ui.redraw_on_focus_gained = false; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + + let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + assert!(!app.state.redraw_on_focus_gained); + } + #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); @@ -1610,7 +1626,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1634,6 +1650,8 @@ mod tests { app.state.agent_panel_scope, state::AgentPanelScope::CurrentWorkspace ); + assert!(!app.state.redraw_on_focus_gained); + assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( app.state.new_terminal_cwd, @@ -2073,6 +2091,20 @@ mod tests { assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen); } + #[tokio::test] + async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() { + let mut app = test_app(); + app.state.redraw_on_focus_gained = false; + + let handled = app + .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) + .await; + + assert!(handled); + assert_eq!(app.state.outer_terminal_focus, Some(true)); + assert!(!app.full_redraw_pending); + } + #[tokio::test] async fn repeat_key_events_are_ignored_outside_terminal_mode() { let mut app = test_app(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 284fb882..85bdb8dd 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -106,7 +106,9 @@ impl App { true } crate::raw_input::RawInputEvent::OuterFocusGained => { - self.request_full_redraw(); + if self.state.redraw_on_focus_gained { + self.request_full_redraw(); + } self.state.outer_terminal_focus = Some(true); self.state.mark_active_tab_seen(); true diff --git a/src/app/state.rs b/src/app/state.rs index 422664ff..b22dc6a3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1057,7 +1057,7 @@ pub struct AppState { pub request_reload_config: bool, /// Set when the headless server should ask attached clients to reload /// their client-local sound config from disk. - pub request_client_sound_config_reload: bool, + pub request_client_config_reload: bool, /// Set when UI interaction requested a clipboard write that must be /// handled by the outer App/event loop instead of directly from AppState. pub request_clipboard_write: Option>, @@ -1115,6 +1115,7 @@ pub struct AppState { /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only /// captures mouse while the focused pane app requests mouse reporting. pub mouse_capture: bool, + pub redraw_on_focus_gained: bool, pub mouse_scroll_lines: usize, pub confirm_close: bool, pub prompt_new_tab_name: bool, @@ -1351,7 +1352,7 @@ impl AppState { request_submit_worktree_open: false, request_submit_worktree_remove: false, request_reload_config: false, - request_client_sound_config_reload: false, + request_client_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -1414,6 +1415,7 @@ impl AppState { sidebar_section_split: 0.5, agent_panel_scope: AgentPanelScope::AllWorkspaces, mouse_capture: true, + redraw_on_focus_gained: true, mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, prompt_new_tab_name: true, diff --git a/src/client/mod.rs b/src/client/mod.rs index 5dd2652f..3e146f47 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -59,6 +59,8 @@ struct ClientState { attach_escape: Option, /// Rows scrolled for one direct-attach wheel notch. mouse_scroll_lines: usize, + /// Whether outer focus gain should force a full host-terminal redraw. + redraw_on_focus_gained: bool, } #[derive(Debug, Default)] @@ -514,6 +516,7 @@ fn run_client_with_mode( let loaded_config = crate::config::Config::load(); let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); + let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; let sound_config = loaded_config.config.ui.sound; let direct_attach_requested = attach_request.is_some(); let kitty_graphics_enabled = @@ -608,6 +611,7 @@ fn run_client_with_mode( should_quit, sound_config, mouse_scroll_lines, + redraw_on_focus_gained, kitty_graphics_enabled, false, negotiated_encoding, @@ -655,6 +659,7 @@ async fn run_client_loop( should_quit: Arc, sound_config: crate::config::SoundConfig, mouse_scroll_lines: usize, + redraw_on_focus_gained: bool, kitty_graphics_enabled: bool, mouse_capture_active: bool, negotiated_encoding: RenderEncoding, @@ -668,6 +673,7 @@ async fn run_client_loop( kitty_graphics_enabled, attach_escape, mouse_scroll_lines, + redraw_on_focus_gained, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -763,7 +769,10 @@ async fn run_client_loop( } } else { let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - if crate::raw_input::events_require_host_surface_redraw(&events) { + if crate::raw_input::events_require_host_surface_redraw( + &events, + state.redraw_on_focus_gained, + ) { state.request_full_redraw(); } data @@ -854,7 +863,10 @@ async fn run_client_loop( let _ = io::stdout().flush(); } ServerMessage::ReloadSoundConfig => { - reload_local_sound_config(&mut state.sound_config); + reload_local_client_config( + &mut state.sound_config, + &mut state.redraw_on_focus_gained, + ); } ServerMessage::MouseCapture { enabled } => { let desired = enabled; @@ -955,17 +967,21 @@ fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<( // Notifications // --------------------------------------------------------------------------- -fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) { +fn reload_local_client_config( + sound_config: &mut crate::config::SoundConfig, + redraw_on_focus_gained: &mut bool, +) { match crate::config::load_live_config() { Ok(loaded) => { for diagnostic in loaded.config.ui.sound.diagnostics() { warn!(diagnostic = %diagnostic, "local sound config diagnostic"); } *sound_config = loaded.config.ui.sound; - debug!("reloaded local sound config"); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + debug!("reloaded local client config"); } Err(diagnostics) => { - warn!(diagnostics = ?diagnostics, "failed to reload local sound config; keeping current sound config"); + warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); } } } @@ -1631,6 +1647,29 @@ mod tests { assert_eq!(sound_from_notify_message("toast"), None); } + #[test] + fn reload_local_client_config_refreshes_redraw_on_focus_gained() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-config-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "[ui]\nredraw_on_focus_gained = false\n").unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = true; + + reload_local_client_config(&mut sound_config, &mut redraw_on_focus_gained); + + assert!(!redraw_on_focus_gained); + let _ = std::fs::remove_file(path); + } + #[test] fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { let sound_config = crate::config::SoundConfig::default(); diff --git a/src/config/model.rs b/src/config/model.rs index 35b08582..7434c924 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -259,6 +259,8 @@ pub struct UiConfig { pub sidebar_max_width: u16, /// Capture mouse input for Herdr's mouse UI. Default: true. pub mouse_capture: bool, + /// Force a full host-terminal redraw when the outer terminal regains focus. Default: true. + pub redraw_on_focus_gained: bool, /// Lines to scroll per mouse wheel notch. Default: 3. pub mouse_scroll_lines: Option, /// Ask for confirmation before closing a workspace. Default: true. @@ -416,6 +418,7 @@ impl Default for UiConfig { sidebar_min_width: 18, sidebar_max_width: 36, mouse_capture: true, + redraw_on_focus_gained: true, mouse_scroll_lines: None, confirm_close: true, prompt_new_tab_name: true, @@ -669,6 +672,19 @@ mouse_capture = false assert!(!config.ui.mouse_capture); } + #[test] + fn redraw_on_focus_gained_default_on_and_parse() { + let default_config = Config::default(); + assert!(default_config.ui.redraw_on_focus_gained); + + let toml = r#" +[ui] +redraw_on_focus_gained = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.ui.redraw_on_focus_gained); + } + #[test] fn mouse_scroll_lines_defaults_to_three_and_parses() { let default_config = Config::default(); diff --git a/src/main.rs b/src/main.rs index 725aed45..9a33555e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,6 +182,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Pane apps like lazygit and btop can still receive mouse when they request it. # mouse_capture = true +# Force a full redraw when the outer terminal regains focus. +# Set false to reduce visible flashing when switching back to Herdr. +# Trade-off: rare host terminal surface corruption may persist until the next full redraw. +# redraw_on_focus_gained = true + # Pane scrollback lines to scroll per mouse wheel notch. # mouse_scroll_lines = 3 diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index bd79c900..e6c5624c 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -370,7 +370,7 @@ pub enum ServerMessage { data: String, }, - /// Client-local sound config changed on disk; refresh it without reconnecting. + /// Client-local runtime config changed on disk; refresh it without reconnecting. ReloadSoundConfig, /// Whether the client should currently capture host mouse input. diff --git a/src/raw_input.rs b/src/raw_input.rs index d75c6c49..fb9a1a3d 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -128,10 +128,14 @@ pub enum RawInputEvent { Unsupported, } -pub(crate) fn events_require_host_surface_redraw(events: &[RawInputEvent]) -> bool { - events - .iter() - .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) +pub(crate) fn events_require_host_surface_redraw( + events: &[RawInputEvent], + redraw_on_focus_gained: bool, +) -> bool { + redraw_on_focus_gained + && events + .iter() + .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) } pub fn spawn_input_reader() -> mpsc::Receiver { @@ -667,10 +671,11 @@ mod tests { #[test] fn outer_focus_gained_requests_host_surface_redraw() { let events = parse_raw_input_bytes_sync(b"\x1b[I"); - assert!(events_require_host_surface_redraw(&events)); + assert!(events_require_host_surface_redraw(&events, true)); + assert!(!events_require_host_surface_redraw(&events, false)); let events = parse_raw_input_bytes_sync(b"\x1b[O"); - assert!(!events_require_host_surface_redraw(&events)); + assert!(!events_require_host_surface_redraw(&events, true)); } #[test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 4dafb6d7..bde4d362 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -406,7 +406,7 @@ impl HeadlessServer { needs_render = true; } - self.drain_client_sound_config_reload_request(); + self.drain_client_config_reload_request(); self.stream_host_mouse_capture_mode(); self.app.sync_headless_animation_timer(now); @@ -1423,11 +1423,11 @@ impl HeadlessServer { (had_event, changed) } - fn drain_client_sound_config_reload_request(&mut self) { - if !self.app.state.request_client_sound_config_reload { + fn drain_client_config_reload_request(&mut self) { + if !self.app.state.request_client_config_reload { return; } - self.app.state.request_client_sound_config_reload = false; + self.app.state.request_client_config_reload = false; self.send_to_all_clients(ServerMessage::ReloadSoundConfig); } @@ -1726,8 +1726,10 @@ impl HeadlessServer { return true; } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - let host_surface_redraw = - crate::raw_input::events_require_host_surface_redraw(&events); + let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( + &events, + self.app.state.redraw_on_focus_gained, + ); if let Some(client) = self.clients.get_mut(&client_id) { if host_surface_redraw { client.request_full_redraw(); @@ -4286,6 +4288,52 @@ next_tab = "" } } + #[test] + fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() { + let mut server = test_headless_server(); + server.app.state.redraw_on_focus_gained = false; + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::TerminalAnsi, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial terminal frame"); + + server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[I".to_vec(), + }); + server.render_and_stream(); + + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!( + server + .clients + .get(&1) + .unwrap() + .render_state + .terminal_seq() + .unwrap(), + 1 + ); + } + #[test] fn full_render_queue_does_not_advance_terminal_ansi_baseline() { let mut server = test_headless_server(); @@ -4420,7 +4468,7 @@ next_tab = "" } #[test] - fn client_sound_reload_request_refreshes_attached_clients() { + fn client_config_reload_request_refreshes_attached_clients() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); @@ -4436,19 +4484,19 @@ next_tab = "" Some(client_tx), ), ); - server.app.state.request_client_sound_config_reload = true; + server.app.state.request_client_config_reload = true; - server.drain_client_sound_config_reload_request(); + server.drain_client_config_reload_request(); match read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) - .expect("client sound reload message"), + .expect("client config reload message"), ) { ServerMessage::ReloadSoundConfig => {} other => panic!("expected ReloadSoundConfig, got {other:?}"), } - assert!(!server.app.state.request_client_sound_config_reload); + assert!(!server.app.state.request_client_config_reload); } #[test]