aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/blit.rs188
1 files changed, 184 insertions, 4 deletions
diff --git a/src/client/blit.rs b/src/client/blit.rs
index 1a4fcd81..3d562563 100644
--- a/src/client/blit.rs
+++ b/src/client/blit.rs
@@ -178,7 +178,11 @@ fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
/// Checks if two cells are visually identical.
#[cfg(test)]
fn cells_equal(a: &CellData, b: &CellData) -> bool {
- a.symbol == b.symbol && a.fg == b.fg && a.bg == b.bg && a.modifier == b.modifier
+ a.symbol == b.symbol
+ && a.fg == b.fg
+ && a.bg == b.bg
+ && a.modifier == b.modifier
+ && a.hyperlink == b.hyperlink
// Skip flag is only for ratatui internal use, not visual.
}
@@ -226,6 +230,11 @@ fn blit_frame_to_with_cursor_memory(
// on terminals that render the hardware cursor at intermediate CUP positions.
let _ = writer.write_all(b"\x1b[?25l");
+ // Start each frame from a known OSC 8 state. If a previous write was
+ // interrupted or the outer terminal had an active hyperlink, unlinked cells
+ // must not inherit it.
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+
if full_redraw {
// Clear the screen and write all cells.
let _ = writer.write_all(b"\x1b[2J\x1b[H");
@@ -338,6 +347,7 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat
}
fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
+ let mut active_hyperlink = None;
for row in 0..frame.height {
let mut to_skip = 0usize;
for col in 0..frame.width {
@@ -360,17 +370,87 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
let _ = writer.write_all(sgr.as_bytes());
+ write_hyperlink_if_changed(
+ writer,
+ &mut active_hyperlink,
+ cell_hyperlink_uri(frame, cell),
+ );
+
// Write the symbol.
let _ = writer.write_all(cell.symbol.as_bytes());
to_skip = cell_width(cell).saturating_sub(1);
}
}
+ close_hyperlink(writer, &mut active_hyperlink);
+
// Reset style at the end.
let _ = writer.write_all(b"\x1b[0m");
}
-fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) {
+fn cell_hyperlink_uri<'a>(frame: &'a FrameData, cell: &CellData) -> Option<&'a str> {
+ let index = cell.hyperlink? as usize;
+ frame.hyperlinks.get(index).map(String::as_str)
+}
+
+fn sanitized_hyperlink_uri(uri: &str) -> Option<String> {
+ let sanitized: String = uri
+ .chars()
+ .filter(|ch| *ch != '\x1b' && *ch != '\x07' && !ch.is_control())
+ .collect();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
+fn sanitized_frame_hyperlinks(frame: &FrameData) -> Vec<Option<String>> {
+ frame
+ .hyperlinks
+ .iter()
+ .map(|uri| sanitized_hyperlink_uri(uri))
+ .collect()
+}
+
+fn sanitized_cell_hyperlink_uri<'a>(
+ sanitized_hyperlinks: &'a [Option<String>],
+ cell: &CellData,
+) -> Option<&'a str> {
+ let index = cell.hyperlink? as usize;
+ sanitized_hyperlinks.get(index)?.as_deref()
+}
+
+fn write_hyperlink_if_changed(
+ writer: &mut impl Write,
+ active: &mut Option<String>,
+ requested: Option<&str>,
+) {
+ let requested = requested.and_then(sanitized_hyperlink_uri);
+ if active.as_deref() == requested.as_deref() {
+ return;
+ }
+
+ if active.is_some() {
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+ }
+ *active = requested;
+ if let Some(uri) = active.as_deref() {
+ let _ = write!(writer, "\x1b]8;;{uri}\x1b\\");
+ }
+}
+
+fn close_hyperlink(writer: &mut impl Write, active: &mut Option<String>) {
+ if active.take().is_some() {
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+ }
+}
+
+fn write_cell(
+ writer: &mut impl Write,
+ row: u16,
+ col: u16,
+ cell: &CellData,
+ last_sgr: &mut String,
+ active_hyperlink: &mut Option<String>,
+ frame: &FrameData,
+) {
if cell.skip {
return;
}
@@ -383,12 +463,31 @@ fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last
*last_sgr = sgr;
}
+ write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell));
let _ = writer.write_all(cell.symbol.as_bytes());
}
/// Writes only the cells that changed between the previous and current frame.
+fn cells_visually_equal(
+ sanitized_hyperlinks: &[Option<String>],
+ cell: &CellData,
+ prev_sanitized_hyperlinks: &[Option<String>],
+ prev_cell: &CellData,
+) -> bool {
+ cell.symbol == prev_cell.symbol
+ && cell.fg == prev_cell.fg
+ && cell.bg == prev_cell.bg
+ && cell.modifier == prev_cell.modifier
+ && sanitized_cell_hyperlink_uri(sanitized_hyperlinks, cell)
+ == sanitized_cell_hyperlink_uri(prev_sanitized_hyperlinks, prev_cell)
+ // Skip flag is only for ratatui internal use, not visual.
+}
+
fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
+ let mut active_hyperlink = None;
+ let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame);
+ let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev);
for row in 0..frame.height {
let mut invalidated = 0usize;
@@ -399,8 +498,24 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD
let cell = &frame.cells[idx];
let prev_cell = &prev.cells[idx];
- if !cell.skip && (cell != prev_cell || invalidated > 0) && to_skip == 0 {
- write_cell(writer, row, col, cell, &mut last_sgr);
+ if !cell.skip
+ && (!cells_visually_equal(
+ &sanitized_hyperlinks,
+ cell,
+ &prev_sanitized_hyperlinks,
+ prev_cell,
+ ) || invalidated > 0)
+ && to_skip == 0
+ {
+ write_cell(
+ writer,
+ row,
+ col,
+ cell,
+ &mut last_sgr,
+ &mut active_hyperlink,
+ frame,
+ );
}
to_skip = cell_width(cell).saturating_sub(1);
@@ -409,6 +524,8 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD
}
}
+ close_hyperlink(writer, &mut active_hyperlink);
+
// Reset style if we wrote anything.
if !last_sgr.is_empty() {
let _ = writer.write_all(b"\x1b[0m");
@@ -433,6 +550,7 @@ mod tests {
bg,
modifier,
skip: false,
+ hyperlink: None,
}
}
@@ -442,9 +560,16 @@ mod tests {
width,
height,
cursor: None,
+ hyperlinks: Vec::new(),
}
}
+ fn linked_cell(symbol: &str, index: u32) -> CellData {
+ let mut cell = make_cell(symbol, 0, 0, 0);
+ cell.hyperlink = Some(index);
+ cell
+ }
+
#[test]
fn color_to_sgr_fg_named_colors() {
assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset
@@ -619,6 +744,7 @@ mod tests {
y: 1,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -646,6 +772,7 @@ mod tests {
y: 0,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let hidden = FrameData {
cells: vec![make_cell("B", 0, 0, 0); 9],
@@ -656,6 +783,7 @@ mod tests {
y: 1,
visible: false,
}),
+ hyperlinks: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -681,6 +809,42 @@ mod tests {
}
#[test]
+ fn blit_frame_emits_osc8_for_linked_cells() {
+ let mut frame = make_frame(
+ 3,
+ 1,
+ vec![
+ linked_cell("L", 0),
+ linked_cell("i", 0),
+ make_cell("!", 0, 0, 0),
+ ],
+ );
+ frame.hyperlinks.push("https://example.com".to_owned());
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
+ assert!(output_str.contains('i'));
+ assert!(output_str.contains("\x1b]8;;\x1b\\"));
+ }
+
+ #[test]
+ fn blit_frame_sanitizes_hyperlink_uris() {
+ let mut frame = make_frame(1, 1, vec![linked_cell("L", 0)]);
+ frame
+ .hyperlinks
+ .push("https://exa\x1b\x07mple.com".to_owned());
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
+ }
+
+ #[test]
fn blit_frame_first_frame_produces_output() {
let frame = make_frame(
2,
@@ -773,6 +937,7 @@ mod tests {
y: 0,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -796,6 +961,7 @@ mod tests {
y: 0,
visible: false,
}),
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -815,6 +981,7 @@ mod tests {
width: 1,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -839,6 +1006,7 @@ mod tests {
y: 0,
visible: false,
}),
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -858,6 +1026,7 @@ mod tests {
y: 0,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -884,6 +1053,7 @@ mod tests {
y: 0,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let mut curr = prev.clone();
curr.cells[0] = make_cell("B", 0, 0, 0);
@@ -920,12 +1090,14 @@ mod tests {
y: 1,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let hidden = FrameData {
cells: vec![make_cell("B", 0, 0, 0); 9],
width: 3,
height: 3,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -956,6 +1128,7 @@ mod tests {
width: 3,
height: 2,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -980,12 +1153,14 @@ mod tests {
y: 0,
visible: true,
}),
+ hyperlinks: Vec::new(),
};
let curr = FrameData {
cells: vec![make_cell("B", 0, 0, 0)],
width: 1,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -1008,6 +1183,7 @@ mod tests {
width: 3,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -1030,6 +1206,7 @@ mod tests {
width: 3,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let curr = FrameData {
cells: vec![
@@ -1040,6 +1217,7 @@ mod tests {
width: 3,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();
@@ -1064,6 +1242,7 @@ mod tests {
width: 3,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let curr = FrameData {
cells: vec![
@@ -1074,6 +1253,7 @@ mod tests {
width: 3,
height: 1,
cursor: None,
+ hyperlinks: Vec::new(),
};
let mut output = Vec::new();