From a74991270c1b7848528ec521f84f1d9397bedcd4 Mon Sep 17 00:00:00 2001 From: Grant McNatt <60593837+ZoOtMcNoOt@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:48:20 -0500 Subject: [PATCH 1/2] fix: correct hyperlink click pipeline on Windows Fixes #1457 --- frontends/rioterm/src/application.rs | 14 ++++- frontends/rioterm/src/screen/mod.rs | 77 +++++++++++++++++++++++++--- rio-backend/src/config/hints.rs | 2 +- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 4e1092d65f..4be26312b4 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -162,7 +162,11 @@ impl ApplicationHandler for Application<'_> { return; } - let theme = self.config.force_theme.map(|t| t.to_window_theme()).or(event_loop.system_theme()); + let theme = self + .config + .force_theme + .map(|t| t.to_window_theme()) + .or(event_loop.system_theme()); update_colors_based_on_theme(&mut self.config, theme); self.router.create_window( @@ -386,7 +390,11 @@ impl ApplicationHandler for Application<'_> { // Apply system theme to ensure colors are consistent if !has_checked_adaptive_colors { let system_theme = route.window.winit_window.theme(); - let theme = self.config.force_theme.map(|t| t.to_window_theme()).or(system_theme); + let theme = self + .config + .force_theme + .map(|t| t.to_window_theme()) + .or(system_theme); update_colors_based_on_theme(&mut self.config, theme); has_checked_adaptive_colors = true; } @@ -1068,6 +1076,8 @@ impl ApplicationHandler for Application<'_> { route.window.screen.select_current_based_on_mouse(); if route.window.screen.trigger_hyperlink() { + route.window.screen.clear_highlighted_hint(); + route.request_redraw(); return; } diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index aecf302b1c..0c4d09713d 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -1885,9 +1885,7 @@ impl Screen<'_> { post_processing: true, persist: false, action: rio_backend::config::hints::HintAction::Command { - command: rio_backend::config::hints::HintCommand::Simple( - "xdg-open".to_string(), - ), + command: rio_backend::config::hints::default_url_command(), }, mouse: rio_backend::config::hints::HintMouse::default(), binding: None, @@ -1924,19 +1922,23 @@ impl Screen<'_> { return None; } - // Extract text from the line + // Extract line text and map byte offsets to column indices + // (regex returns byte offsets which diverge from columns for non-ASCII) let mut line_text = String::new(); for col in 0..grid.columns() { let cell = &grid[point.row][rio_backend::crosswords::pos::Column(col)]; line_text.push(cell.c); } + let byte_to_col = build_byte_to_col(line_text.chars()); let line_text = line_text.trim_end(); // Find all matches in this line and check if point is within any of them for mat in regex.find_iter(line_text) { - let start_col = rio_backend::crosswords::pos::Column(mat.start()); - let end_col = - rio_backend::crosswords::pos::Column(mat.end().saturating_sub(1)); + let start_col = + rio_backend::crosswords::pos::Column(byte_to_col[mat.start()]); + let end_col = rio_backend::crosswords::pos::Column( + byte_to_col[mat.end().saturating_sub(1)], + ); // Check if the point is within this match if point.col >= start_col && point.col <= end_col { @@ -2033,6 +2035,15 @@ impl Screen<'_> { } } + /// Clear the highlighted hint to prevent double-fire on click + #[inline] + pub fn clear_highlighted_hint(&mut self) { + self.context_manager + .current_mut() + .renderable_content + .highlighted_hint = None; + } + fn open_hyperlink(&self, hyperlink: Hyperlink) { // Apply post-processing to remove trailing delimiters and handle uneven brackets let processed_uri = post_process_hyperlink_uri(hyperlink.uri()); @@ -3717,6 +3728,18 @@ fn post_process_hyperlink_uri(uri: &str) -> String { chars.into_iter().take(end_idx + 1).collect() } +/// Build a mapping from byte offsets to column indices for a sequence of chars. +/// Each char occupies one grid column but may be 1-4 bytes in UTF-8. +fn build_byte_to_col(chars: impl Iterator) -> Vec { + let mut byte_to_col = Vec::new(); + for (col, ch) in chars.enumerate() { + for _ in 0..ch.len_utf8() { + byte_to_col.push(col); + } + } + byte_to_col +} + #[cfg(test)] mod tests { use super::*; @@ -3777,4 +3800,44 @@ mod tests { "https://example.com/path[with]brackets" ); } + + #[test] + fn test_byte_to_col_with_regex_match() { + // Reproduces the bug from #1457: regex byte offsets used as column + // indices cause URL truncation when non-ASCII chars precede the URL + let url_re = + regex::Regex::new(rio_backend::config::hints::DEFAULT_URL_REGEX).unwrap(); + + // ASCII-only: byte offsets happen to equal column indices + let line = "see https://example.com ok"; + let byte_to_col = build_byte_to_col(line.chars()); + let mat = url_re.find(line).unwrap(); + assert_eq!(mat.as_str(), "https://example.com"); + assert_eq!(byte_to_col[mat.start()], 4); // correct column + assert_eq!(mat.start(), 4); // byte offset matches column for ASCII + + // 2-byte char (é) before URL: byte offset diverges from column + let line = "café https://example.com ok"; + let byte_to_col = build_byte_to_col(line.chars()); + let mat = url_re.find(line).unwrap(); + assert_eq!(mat.as_str(), "https://example.com"); + assert_eq!(byte_to_col[mat.start()], 5); // correct column + assert_eq!(mat.start(), 6); // raw byte offset is 6 (é = 2 bytes) + + // 3-byte CJK char: offset diverges further + let line = "中 https://example.com ok"; + let byte_to_col = build_byte_to_col(line.chars()); + let mat = url_re.find(line).unwrap(); + assert_eq!(mat.as_str(), "https://example.com"); + assert_eq!(byte_to_col[mat.start()], 2); // correct column + assert_eq!(mat.start(), 4); // raw byte offset is 4 (中 = 3 bytes) + + // 4-byte emoji: worst divergence + let line = "😀 https://example.com ok"; + let byte_to_col = build_byte_to_col(line.chars()); + let mat = url_re.find(line).unwrap(); + assert_eq!(mat.as_str(), "https://example.com"); + assert_eq!(byte_to_col[mat.start()], 2); // correct column + assert_eq!(mat.start(), 5); // raw byte offset is 5 (😀 = 4 bytes) + } } diff --git a/rio-backend/src/config/hints.rs b/rio-backend/src/config/hints.rs index 38f656d2f6..f84d046ad5 100644 --- a/rio-backend/src/config/hints.rs +++ b/rio-backend/src/config/hints.rs @@ -162,7 +162,7 @@ fn default_hints_enabled() -> Vec { }] } -fn default_url_command() -> HintCommand { +pub fn default_url_command() -> HintCommand { #[cfg(not(any(target_os = "macos", target_os = "windows")))] return HintCommand::Simple("xdg-open".to_string()); From 63aaf08b40094501c19c9891c1e7c491c6e0dc91 Mon Sep 17 00:00:00 2001 From: Grant McNatt <60593837+ZoOtMcNoOt@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:17:11 -0500 Subject: [PATCH 2/2] fix(tests): use default_shell() in context tests for Windows CI The test helper start_with_capacity hardcoded bash as the fallback shell, which hangs on Windows CI when spawned through conpty. Use the existing default_shell() which returns powershell on Windows. --- frontends/rioterm/src/context/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 792c796c76..0510e6a20f 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -417,10 +417,7 @@ impl ContextManager { #[cfg(not(target_os = "windows"))] use_fork: true, working_dir: None, - shell: Shell { - program: std::env::var("SHELL").unwrap_or("bash".to_string()), - args: vec![], - }, + shell: rio_backend::config::defaults::default_shell(), spawn_performer: false, is_native: false, should_update_title_extra: false,