diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 2d05d3a08b..45320ff294 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1083,6 +1083,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/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, diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 4b461b8c06..e7f02cc984 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -1884,9 +1884,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, @@ -1923,19 +1921,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 { @@ -2032,6 +2034,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()); @@ -3768,6 +3779,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::*; @@ -3828,4 +3851,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());