Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
418 lines
14 KiB
Rust
418 lines
14 KiB
Rust
use std::{
|
|
collections::VecDeque,
|
|
io::{self, Write},
|
|
};
|
|
|
|
use ratatui::layout::{Rect, Size};
|
|
|
|
use crate::common::TerminalLike;
|
|
|
|
/// Mock terminal for testing
|
|
#[derive(Debug, Clone)]
|
|
pub struct MockTerminal {
|
|
pub size: Size,
|
|
pub viewport_area: Rect,
|
|
pub clear_count: usize,
|
|
pub viewport_updates: Vec<Rect>,
|
|
pub writer: MockWriter,
|
|
}
|
|
|
|
/// Mock writer that captures all output
|
|
#[derive(Debug, Clone)]
|
|
pub struct MockWriter {
|
|
pub buffer: Vec<u8>,
|
|
pub flush_count: usize,
|
|
pub commands: VecDeque<String>,
|
|
}
|
|
|
|
impl Write for MockWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
self.buffer.extend_from_slice(buf);
|
|
// Parse and store readable command representation
|
|
if let Ok(s) = std::str::from_utf8(buf) {
|
|
self.commands.push_back(s.to_string());
|
|
}
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
self.flush_count += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl MockTerminal {
|
|
pub fn new(width: u16, height: u16, viewport_height: u16) -> Self {
|
|
let viewport_y = height - viewport_height;
|
|
Self {
|
|
size: Size { width, height },
|
|
viewport_area: Rect::new(0, viewport_y, width, viewport_height),
|
|
clear_count: 0,
|
|
viewport_updates: Vec::new(),
|
|
writer: MockWriter {
|
|
buffer: Vec::new(),
|
|
flush_count: 0,
|
|
commands: VecDeque::new(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TerminalLike for MockTerminal {
|
|
type Writer = MockWriter;
|
|
|
|
fn size(&self) -> io::Result<Size> {
|
|
Ok(self.size)
|
|
}
|
|
|
|
fn viewport_area(&self) -> Rect {
|
|
self.viewport_area
|
|
}
|
|
|
|
fn clear(&mut self) -> io::Result<()> {
|
|
self.clear_count += 1;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_viewport_area(&mut self, area: Rect) {
|
|
self.viewport_updates.push(area);
|
|
self.viewport_area = area;
|
|
}
|
|
|
|
fn writer_mut(&mut self) -> &mut Self::Writer {
|
|
&mut self.writer
|
|
}
|
|
|
|
fn reset_back_buffer(&mut self) {
|
|
// Mock implementation - just track that it was called
|
|
self.clear_count += 1;
|
|
}
|
|
}
|
|
|
|
/// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` /
|
|
/// `flush_with_links`).
|
|
mod links {
|
|
use std::io::{self, Write};
|
|
|
|
use ratatui::backend::{Backend, WindowSize};
|
|
use ratatui::buffer::Cell;
|
|
use ratatui::layout::{Position, Rect, Size};
|
|
use ratatui::style::Style;
|
|
use ratatui::{TerminalOptions, Viewport};
|
|
|
|
use crate::{LinkSpan, Terminal};
|
|
|
|
/// Backend that records the raw byte stream and renders each drawn cell as
|
|
/// its bare symbol, so tests can assert on OSC 8 sequences interleaved with
|
|
/// cell content without depending on crossterm's exact SGR output.
|
|
#[derive(Default)]
|
|
struct RecordingBackend {
|
|
buf: Vec<u8>,
|
|
/// Total lines passed to `append_lines` (used by the
|
|
/// `set_viewport_height` grow-path test).
|
|
appended_lines: u16,
|
|
}
|
|
|
|
impl Write for RecordingBackend {
|
|
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
|
self.buf.extend_from_slice(b);
|
|
Ok(b.len())
|
|
}
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Backend for RecordingBackend {
|
|
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
|
where
|
|
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
|
{
|
|
for (_x, _y, cell) in content {
|
|
self.buf.extend_from_slice(cell.symbol().as_bytes());
|
|
}
|
|
Ok(())
|
|
}
|
|
fn hide_cursor(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn show_cursor(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn get_cursor_position(&mut self) -> io::Result<Position> {
|
|
Ok(Position::ORIGIN)
|
|
}
|
|
fn set_cursor_position<P: Into<Position>>(&mut self, _position: P) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn clear(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
|
self.appended_lines += n;
|
|
Ok(())
|
|
}
|
|
fn size(&self) -> io::Result<Size> {
|
|
Ok(Size::new(80, 24))
|
|
}
|
|
fn window_size(&mut self) -> io::Result<WindowSize> {
|
|
Ok(WindowSize {
|
|
columns_rows: Size::new(80, 24),
|
|
pixels: Size::new(0, 0),
|
|
})
|
|
}
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn term(w: u16, h: u16) -> Terminal<RecordingBackend> {
|
|
Terminal::with_options(
|
|
RecordingBackend::default(),
|
|
TerminalOptions {
|
|
viewport: Viewport::Fixed(Rect::new(0, 0, w, h)),
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn span(col_start: u16, col_end: u16, url: &str, id: Option<u32>) -> LinkSpan {
|
|
LinkSpan {
|
|
row: 0,
|
|
col_start,
|
|
col_end,
|
|
url: url.into(),
|
|
id,
|
|
}
|
|
}
|
|
|
|
/// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted
|
|
/// during this single frame.
|
|
fn frame(t: &mut Terminal<RecordingBackend>, text: &str, spans: &[LinkSpan]) -> String {
|
|
t.backend_mut().buf.clear();
|
|
{
|
|
let mut f = t.get_frame();
|
|
f.buffer_mut().set_string(0, 0, text, Style::default());
|
|
}
|
|
t.set_frame_links(spans);
|
|
t.flush_with_links().unwrap();
|
|
t.swap_buffers();
|
|
String::from_utf8(t.backend().buf.clone()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn emits_osc8_around_linked_cells() {
|
|
let mut t = term(20, 3);
|
|
let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]);
|
|
assert!(
|
|
out.contains("\x1b]8;;https://kimi.com\x07"),
|
|
"missing open: {out:?}"
|
|
);
|
|
assert!(out.contains("AB"));
|
|
assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_link_emits_no_osc8() {
|
|
let mut t = term(20, 3);
|
|
let out = frame(&mut t, "AB", &[]);
|
|
assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn grow_viewport_scrolls_committed_lines_into_history() {
|
|
// A small inline viewport near the bottom of the screen, grown to full
|
|
// height, must scroll the rows it will cover up into native scrollback
|
|
// (append_lines) instead of overwriting them. Regression guard for the
|
|
// earlier-commented-out scroll_up in set_viewport_height's grow path
|
|
// (the overlay host depends on this in minimal mode).
|
|
let mut t = Terminal::with_options(
|
|
RecordingBackend::default(),
|
|
TerminalOptions {
|
|
viewport: Viewport::Inline(3),
|
|
},
|
|
)
|
|
.unwrap();
|
|
// Pin the 3-row viewport near the bottom of the 24-row screen.
|
|
t.set_viewport_area(Rect::new(0, 21, 80, 3));
|
|
let before = t.backend().appended_lines;
|
|
// Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up.
|
|
t.set_viewport_height(24).unwrap();
|
|
let scrolled = t.backend().appended_lines - before;
|
|
assert!(
|
|
scrolled >= 21,
|
|
"expected >= 21 lines scrolled into history, got {scrolled}"
|
|
);
|
|
}
|
|
|
|
/// Regression: `set_viewport_height` must judge grow-vs-shrink against the
|
|
/// live `viewport_area.height`, not the stored `Viewport::Inline(height)`.
|
|
///
|
|
/// Minimal mode resizes the viewport out-of-band via `set_viewport_area`
|
|
/// (its content-anchored commit path shrinks the region before
|
|
/// `insert_before`), which leaves the stored `Inline` height STALE. If the
|
|
/// next `set_viewport_height` compared against that stale (larger) height, a
|
|
/// genuine grow would be misread as a shrink: the grow-time `scroll_up`
|
|
/// would be skipped and the viewport's top would not move up, so the taller
|
|
/// viewport would run off the bottom of the screen (dropdown items rendered
|
|
/// off-screen — the "empty dropdown over a full screen" bug).
|
|
#[test]
|
|
fn grow_after_out_of_band_area_shrink_still_scrolls() {
|
|
let mut t = Terminal::with_options(
|
|
RecordingBackend::default(),
|
|
TerminalOptions {
|
|
// Stored Inline height starts tall (mimics a streaming turn that
|
|
// grew the viewport to near full screen).
|
|
viewport: Viewport::Inline(21),
|
|
},
|
|
)
|
|
.unwrap();
|
|
// Out-of-band shrink to a 3-row viewport pinned at the bottom of the
|
|
// 24-row screen — as the commit path does. This does NOT update the
|
|
// stored Inline height (still 21), creating the drift.
|
|
t.set_viewport_area(Rect::new(0, 21, 80, 3));
|
|
|
|
let before = t.backend().appended_lines;
|
|
// Grow to 10 rows. Against the real height (3) this is a GROW that
|
|
// overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up.
|
|
// Against the stale stored height (21) it would look like a shrink and
|
|
// scroll nothing.
|
|
t.set_viewport_height(10).unwrap();
|
|
|
|
let scrolled = t.backend().appended_lines - before;
|
|
assert!(
|
|
scrolled >= 7,
|
|
"grow after an out-of-band area shrink must scroll the covered rows \
|
|
into history (expected >= 7, got {scrolled})"
|
|
);
|
|
// The viewport top moved up so the whole 10-row region fits on screen.
|
|
let area = t.viewport_area();
|
|
assert_eq!(area.height, 10, "height should be the requested 10");
|
|
assert!(
|
|
area.y + area.height <= 24,
|
|
"viewport must fit on screen, got y={} h={}",
|
|
area.y,
|
|
area.height
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn link_removed_next_frame_rewrites_cells_without_osc8() {
|
|
let mut t = term(20, 3);
|
|
let _ = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]);
|
|
// Same glyphs, but the link is gone: the cells must be rewritten (so the
|
|
// terminal's hyperlink clears) and carry no OSC 8. This is the `/new`
|
|
// regression — clearing is driven purely by the diff.
|
|
let out = frame(&mut t, "AB", &[]);
|
|
assert!(out.contains("AB"), "cells should be redrawn: {out:?}");
|
|
assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_link_and_content_emits_nothing() {
|
|
let mut t = term(20, 3);
|
|
let _ = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]);
|
|
// Identical glyphs AND identical link → empty diff → no output at all.
|
|
let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]);
|
|
assert!(out.is_empty(), "expected empty diff, got: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn retargeted_link_rewrites_cells() {
|
|
let mut t = term(20, 3);
|
|
let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]);
|
|
let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]);
|
|
assert!(
|
|
out.contains("\x1b]8;;https://b\x07"),
|
|
"new url not emitted: {out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn emit_id_param_included() {
|
|
let mut t = term(20, 3);
|
|
let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", Some(7))]);
|
|
assert!(
|
|
out.contains("\x1b]8;id=7;https://kimi.com\x07"),
|
|
"id param missing: {out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn url_control_chars_sanitized() {
|
|
let mut t = term(20, 3);
|
|
let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]);
|
|
assert!(
|
|
out.contains("\x1b]8;;https://x/y\x07"),
|
|
"url not sanitized: {out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_links_split_into_separate_runs() {
|
|
let mut t = term(20, 3);
|
|
// "AxB": A→a, gap x (no link), B→b.
|
|
let out = frame(
|
|
&mut t,
|
|
"AxB",
|
|
&[span(0, 1, "https://a", None), span(2, 3, "https://b", None)],
|
|
);
|
|
// Each link wraps exactly its own cell; the gap is not wrapped.
|
|
assert!(
|
|
out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"),
|
|
"a-run: {out:?}"
|
|
);
|
|
assert!(
|
|
out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"),
|
|
"b-run: {out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wide_char_under_link_wraps_lead_cell_only() {
|
|
let mut t = term(20, 3);
|
|
// A width-2 char occupies two cells; only the lead cell is drawn, and
|
|
// the OSC 8 wraps it.
|
|
let out = frame(&mut t, "世", &[span(0, 2, "https://kimi.com", None)]);
|
|
assert!(
|
|
out.contains("\x1b]8;;https://kimi.com\x07世\x1b]8;;\x07"),
|
|
"wide-char run: {out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nonzero_origin_viewport_maps_links() {
|
|
// The screen→cell mapping subtracts the viewport offset; verify a link
|
|
// at an absolute (row, col) inside a non-origin viewport wraps the right
|
|
// cells (regression guard for `(y - area.y)` / `(x - area.x)`).
|
|
let area = Rect::new(2, 5, 20, 4);
|
|
let mut t = Terminal::with_options(
|
|
RecordingBackend::default(),
|
|
TerminalOptions {
|
|
viewport: Viewport::Fixed(area),
|
|
},
|
|
)
|
|
.unwrap();
|
|
{
|
|
let mut f = t.get_frame();
|
|
f.buffer_mut().set_string(2, 5, "AB", Style::default());
|
|
}
|
|
t.set_frame_links(&[LinkSpan {
|
|
row: 5,
|
|
col_start: 2,
|
|
col_end: 4,
|
|
url: "https://kimi.com".into(),
|
|
id: None,
|
|
}]);
|
|
t.flush_with_links().unwrap();
|
|
let out = String::from_utf8(t.backend().buf.clone()).unwrap();
|
|
assert!(
|
|
out.contains("\x1b]8;;https://kimi.com\x07AB\x1b]8;;\x07"),
|
|
"non-origin mapping: {out:?}"
|
|
);
|
|
}
|
|
}
|