aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs30
-rw-r--r--src/client/mod.rs63
-rw-r--r--src/config.rs18
-rw-r--r--src/config/model.rs12
-rw-r--r--src/main.rs5
-rw-r--r--src/protocol/render_ansi.rs93
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)],