Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontends/rioterm/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,8 @@ impl ApplicationHandler<EventPayload> 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;
}

Expand Down
5 changes: 1 addition & 4 deletions frontends/rioterm/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,7 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
#[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,
Expand Down
77 changes: 70 additions & 7 deletions frontends/rioterm/src/screen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -3769,6 +3780,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<Item = char>) -> Vec<usize> {
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::*;
Expand Down Expand Up @@ -3829,4 +3852,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)
}
}
2 changes: 1 addition & 1 deletion rio-backend/src/config/hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ fn default_hints_enabled() -> Vec<Hint> {
}]
}

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());

Expand Down
Loading