diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 30 | ||||
| -rw-r--r-- | src/client/mod.rs | 63 | ||||
| -rw-r--r-- | src/config.rs | 18 | ||||
| -rw-r--r-- | src/config/model.rs | 12 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/protocol/render_ansi.rs | 93 |
6 files changed, 210 insertions, 11 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 713e0b02..c9d54f46 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -122,6 +122,7 @@ pub struct App { pub(crate) next_agent_manifest_update_check: Option<Instant>, pub(crate) update_version_check_enabled: bool, pub(crate) update_manifest_check_enabled: bool, + pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option<Instant>, pub(crate) pending_agent_resume_deadline: Option<Instant>, pub(crate) selection_autoscroll_deadline: Option<Instant>, @@ -710,6 +711,7 @@ impl App { .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), update_version_check_enabled: config.update.version_check, update_manifest_check_enabled: config.update.manifest_check, + loaded_host_cursor: config.ui.host_cursor, agent_metadata_deadline: None, pending_agent_resume_deadline: None, session_save_deadline: None, @@ -1356,6 +1358,10 @@ impl App { self.state.request_client_config_reload = true; } self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; + if self.loaded_host_cursor != config.ui.host_cursor { + self.state.request_client_config_reload = true; + } + self.loaded_host_cursor = config.ui.host_cursor; self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); self.state.right_click_passthrough_modifiers = config.ui.right_click_passthrough_modifiers(); @@ -2474,6 +2480,30 @@ mod tests { } #[test] + fn reload_config_requests_client_reload_for_host_cursor_only_change() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-host-cursor"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + app.state.request_client_config_reload = false; + + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!( + app.loaded_host_cursor, + crate::config::HostCursorModeConfig::Native + ); + assert!(app.state.request_client_config_reload); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_updates_sidebar_width_only_when_config_owned() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-sidebar-width"); diff --git a/src/client/mod.rs b/src/client/mod.rs index 4e0a54ec..92edb900 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -55,6 +55,7 @@ struct ClientLoopConfig { sound_config: crate::config::SoundConfig, mouse_scroll_lines: usize, redraw_on_focus_gained: bool, + host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, mouse_capture_active: bool, #[cfg(unix)] @@ -83,6 +84,8 @@ struct ClientState { remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, /// Whether outer focus gain should force a full host-terminal redraw. redraw_on_focus_gained: bool, + /// Whether this client draws the cursor into frame cells instead of using the host cursor. + draw_host_cursor: bool, } #[derive(Debug, Default)] @@ -440,6 +443,14 @@ fn write_terminal_restore_postlude( writer.flush() } +fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { + match mode { + crate::config::HostCursorModeConfig::Auto => cfg!(windows), + crate::config::HostCursorModeConfig::Native => false, + crate::config::HostCursorModeConfig::Drawn => true, + } +} + #[cfg(windows)] #[derive(Default)] struct WindowsVirtualTerminalInputSetup { @@ -1092,6 +1103,7 @@ fn run_client_with_mode( let mouse_capture = loaded_config.config.ui.mouse_capture; 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 host_cursor = loaded_config.config.ui.host_cursor; let direct_attach_requested = attach_request.is_some(); #[cfg(unix)] let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); @@ -1101,6 +1113,7 @@ fn run_client_with_mode( sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, + host_cursor, kitty_graphics_enabled, mouse_capture_active: mouse_capture, #[cfg(unix)] @@ -1253,6 +1266,7 @@ async fn run_client_loop( ) -> Result<(), ClientError> { #[cfg(windows)] let _ = config.mouse_scroll_lines; + let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor); let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), @@ -1266,6 +1280,7 @@ async fn run_client_loop( #[cfg(unix)] remote_image_paste_key: config.remote_image_paste_key, redraw_on_focus_gained: config.redraw_on_focus_gained, + draw_host_cursor, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -1443,7 +1458,18 @@ async fn run_client_loop( } ClientLoopEvent::ServerMessage(msg) => match msg { ServerMessage::Frame(frame_data) => { - let encoded = state.blit_encoder.encode(&frame_data, false); + let frame_data = if state.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if state.draw_host_cursor { + state + .blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, false) + } else { + state.blit_encoder.encode(&frame_data, false) + }; let mut stdout = io::stdout(); let graphics = if state.kitty_graphics_enabled { frame_data.graphics.as_slice() @@ -1493,6 +1519,7 @@ async fn run_client_loop( reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, + &mut state.draw_host_cursor, #[cfg(unix)] &mut state.remote_image_paste_key, ); @@ -1618,6 +1645,7 @@ fn client_remote_image_paste_key( fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, + draw_host_cursor: &mut bool, #[cfg(unix)] remote_image_paste_key: &mut Option<( crossterm::event::KeyCode, crossterm::event::KeyModifiers, @@ -1632,6 +1660,7 @@ fn reload_local_client_config( let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); *sound_config = loaded.config.ui.sound; *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); #[cfg(unix)] { *remote_image_paste_key = loaded_remote_image_paste_key; @@ -2007,6 +2036,27 @@ mod tests { } } + #[test] + fn host_cursor_policy_auto_uses_drawn_cursor_on_windows() { + assert_eq!( + should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), + cfg!(windows) + ); + } + + #[test] + fn host_cursor_policy_native_and_drawn_override_auto_detection() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); + + assert!(!should_draw_host_cursor( + crate::config::HostCursorModeConfig::Native + )); + assert!(should_draw_host_cursor( + crate::config::HostCursorModeConfig::Drawn + )); + } + #[cfg(unix)] #[test] fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { @@ -2436,7 +2486,7 @@ mod tests { } #[test] - fn reload_local_client_config_refreshes_redraw_on_focus_gained() { + fn reload_local_client_config_refreshes_local_client_presentation_state() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); let path = std::env::temp_dir().join(format!( "herdr-client-config-reload-{}-{}.toml", @@ -2446,22 +2496,29 @@ mod tests { .unwrap() .as_nanos() )); - std::fs::write(&path, "[ui]\nredraw_on_focus_gained = false\n").unwrap(); + std::fs::write( + &path, + "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\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; + let mut draw_host_cursor = false; #[cfg(unix)] let mut remote_image_paste_key = None; reload_local_client_config( &mut sound_config, &mut redraw_on_focus_gained, + &mut draw_host_cursor, #[cfg(unix)] &mut remote_image_paste_key, ); assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); let _ = std::fs::remove_file(path); } diff --git a/src/config.rs b/src/config.rs index 5ddc4c60..f3dc9dbb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,9 +19,9 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, - ConfigReloadStatus, NewTerminalCwdConfig, ShellModeConfig, SidebarCollapsedModeConfig, - ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, - UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, + ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig, + SidebarCollapsedModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery, + ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, @@ -292,4 +292,16 @@ command = "echo one" let config: Config = toml::from_str("[keys]\nremote_image_paste = ''\n").unwrap(); assert_eq!(config.remote_image_paste_key().unwrap(), None); } + + #[test] + fn ui_host_cursor_defaults_to_auto_and_parses_overrides() { + let default_config = Config::default(); + assert_eq!(default_config.ui.host_cursor, HostCursorModeConfig::Auto); + + let native: Config = toml::from_str("[ui]\nhost_cursor = 'native'\n").unwrap(); + assert_eq!(native.ui.host_cursor, HostCursorModeConfig::Native); + + let drawn: Config = toml::from_str("[ui]\nhost_cursor = 'drawn'\n").unwrap(); + assert_eq!(drawn.ui.host_cursor, HostCursorModeConfig::Drawn); + } } diff --git a/src/config/model.rs b/src/config/model.rs index 7731a5b4..8f94b4da 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -108,6 +108,15 @@ impl AgentPanelSortConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] +pub enum HostCursorModeConfig { + #[default] + Auto, + Native, + Drawn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] pub enum SidebarCollapsedModeConfig { #[default] Compact, @@ -777,6 +786,8 @@ pub struct UiConfig { pub mobile_width_threshold: u16, /// Capture mouse input for Herdr's mouse UI. Default: true. pub mouse_capture: bool, + /// Host cursor policy. Default: auto. + pub host_cursor: HostCursorModeConfig, /// Modifier that lets right-click gestures pass through to pane apps. Empty disables it. pub right_click_passthrough_modifier: RightClickPassthroughModifierConfig, /// Force a full host-terminal redraw when the outer terminal regains focus. Default: true. @@ -978,6 +989,7 @@ impl Default for UiConfig { sidebar_collapsed_mode: SidebarCollapsedModeConfig::Compact, mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD, mouse_capture: true, + host_cursor: HostCursorModeConfig::Auto, right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(), redraw_on_focus_gained: true, mouse_scroll_lines: None, diff --git a/src/main.rs b/src/main.rs index 97546e13..0eba4968 100644 --- a/src/main.rs +++ b/src/main.rs @@ -256,6 +256,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 +# Host cursor policy: "auto", "native", or "drawn". +# "auto" draws Herdr's own cursor on Windows to avoid ConPTY cursor flicker, and uses the native terminal cursor elsewhere. +# "native" always uses the outer terminal cursor. "drawn" always draws Herdr's cursor as terminal cell content. +# host_cursor = "auto" + # Optional modifier that forwards right-click hold/drag gestures to pane apps instead of opening Herdr's pane menu. # Empty/off disables this. Shift is intentionally unsupported because terminals commonly reserve Shift+mouse. # right_click_passthrough_modifier = "" diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 3346bb9c..231aa19f 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -33,6 +33,8 @@ use unicode_width::UnicodeWidthStr; use crate::protocol::{underline_style_from_modifier, CellData, FrameData}; +const REVERSED_MODIFIER: u16 = 1 << 6; + /// Bytes produced by a [`BlitEncoder`] for one terminal frame. pub(crate) struct EncodedBlit { /// Terminal escape bytes ready to write to the host terminal. @@ -57,10 +59,23 @@ impl BlitEncoder { } pub(crate) fn encode(&self, frame: &FrameData, force_full: bool) -> EncodedBlit { - self.encode_inner(frame, force_full) + self.encode_inner(frame, force_full, false) + } + + pub(crate) fn encode_with_suppressed_visible_cursor( + &self, + frame: &FrameData, + force_full: bool, + ) -> EncodedBlit { + self.encode_inner(frame, force_full, true) } - fn encode_inner(&self, frame: &FrameData, force_full: bool) -> EncodedBlit { + fn encode_inner( + &self, + frame: &FrameData, + force_full: bool, + suppress_visible_cursor: bool, + ) -> EncodedBlit { let prev = if force_full { None } else { @@ -81,7 +96,7 @@ impl BlitEncoder { prev, &mut next_last_visible_cursor, &mut next_last_cursor_shape, - false, + suppress_visible_cursor, ); if let Some(stats) = prof_stats { crate::render_prof::duration_since("ansi_encode.total", prof_started); @@ -118,6 +133,19 @@ impl BlitEncoder { } } +pub(crate) fn frame_with_drawn_cursor(mut frame: FrameData) -> FrameData { + if let Some(cursor) = frame.cursor.as_ref().filter(|cursor| cursor.visible) { + let (x, y) = clamp_cursor_position(&frame, cursor.x, cursor.y); + let idx = (y as usize) + .saturating_mul(frame.width as usize) + .saturating_add(x as usize); + if let Some(cell) = frame.cells.get_mut(idx) { + cell.modifier ^= REVERSED_MODIFIER; + } + } + frame +} + #[derive(Clone, Copy, Default)] struct ProfBlitStats { scanned_cells: u64, @@ -280,7 +308,6 @@ fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> { const UNDERLINED: u16 = 1 << 3; // 0x08 const SLOW_BLINK: u16 = 1 << 4; // 0x10 const RAPID_BLINK: u16 = 1 << 5; // 0x20 - const REVERSED: u16 = 1 << 6; // 0x40 const HIDDEN: u16 = 1 << 7; // 0x80 const CROSSED_OUT: u16 = 1 << 8; // 0x100 @@ -308,7 +335,7 @@ fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> { if val & RAPID_BLINK != 0 { parts.push("6"); } - if val & REVERSED != 0 { + if val & REVERSED_MODIFIER != 0 { parts.push("7"); } if val & HIDDEN != 0 { @@ -1040,6 +1067,62 @@ mod tests { } #[test] + fn drawn_cursor_reverses_visible_cursor_cell() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0); 9], + width: 3, + height: 3, + cursor: Some(CursorState { + x: 2, + y: 1, + visible: true, + shape: 6, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let drawn = frame_with_drawn_cursor(frame.clone()); + + assert_eq!(drawn.cells[5].modifier, REVERSED_MODIFIER); + assert_eq!(frame.cells[5].modifier, 0); + + let encoded = BlitEncoder::new().encode_with_suppressed_visible_cursor(&drawn, false); + let output_str = String::from_utf8(encoded.bytes).unwrap(); + + assert!( + output_str.contains("\x1b[2;3H\x1b[6 q\x1b[?25l"), + "drawn cursor mode should park the host cursor hidden at the focused cursor position" + ); + assert!( + !output_str.contains("\x1b[?25h"), + "drawn cursor mode should not show the host cursor" + ); + assert!( + output_str.contains("\x1b[0;7;39;49mA"), + "drawn cursor should be emitted as reverse-video cell content" + ); + } + + #[test] + fn drawn_cursor_ignores_hidden_cursor() { + let frame = FrameData { + cells: vec![make_cell("A", 0, 0, 0)], + width: 1, + height: 1, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: false, + shape: 0, + }), + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + assert_eq!(frame_with_drawn_cursor(frame.clone()), frame); + } + + #[test] fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() { let frame = FrameData { cells: vec![make_cell("A", 0, 0, 0)], |
