Skip to content
Merged
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 TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
- [ ] 检测文件变化
- [x] 文件到顶、末尾时,播放声音提示,增加喇叭icon
- [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示
- [ ] 整理项目文档
- [ ] 整理测试用的 markdown 文件,现在太乱
5 changes: 5 additions & 0 deletions fixtures/headings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Heading One

## Heading Two

### Heading Three
33 changes: 33 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use std::path::Path;
use std::process::{Command, Stdio};

pub fn binary_path() -> &'static str {
env!("CARGO_BIN_EXE_termdown")
}

/// Run the compiled termdown binary against `path` in a controlled test
/// environment: ghostty-like terminal (so kitty graphics emission is enabled),
/// dark theme, and `HOME`/`USERPROFILE` cleared so a developer's
/// `~/.termdown/config.toml` can't leak into the test. Returns raw stdout
/// bytes; callers decide whether to treat it as UTF-8 or scan for kitty APC
/// payloads.
pub fn run_termdown(path: &Path) -> Vec<u8> {
let out = Command::new(binary_path())
.arg("--theme")
.arg("dark")
.arg(path)
.env("TERM_PROGRAM", "ghostty")
.env_remove("HOME")
.env_remove("USERPROFILE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("termdown should run");
assert!(
out.status.success(),
"termdown failed on {path:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
128 changes: 128 additions & 0 deletions tests/headings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
mod common;

use base64::Engine;
use image::ImageReader;
use std::io::Cursor;
use std::path::Path;

use common::run_termdown;

/// Walk `out` looking for Kitty graphics protocol APC frames
/// (`ESC _ G <header> ; <payload> ESC \`). Frames where the header has `m=1`
/// continue the current image; `m=0` (or absent) terminates it. Returns one
/// decoded PNG byte vector per emitted image.
///
/// Panics if an APC frame is not terminated by `ESC \` — a regression where
/// termdown truncates output should surface as a clear parse error here,
/// not as a confusing base64-decode failure downstream.
fn extract_kitty_pngs(out: &[u8]) -> Vec<Vec<u8>> {
let mut images = Vec::new();
let mut current_b64 = String::new();
let mut i = 0;
while i + 2 < out.len() {
if !(out[i] == 0x1b && out[i + 1] == b'_' && out[i + 2] == b'G') {
i += 1;
continue;
}
let mut sep = i + 3;
while sep < out.len() && out[sep] != b';' && out[sep] != 0x1b {
sep += 1;
}
let header = std::str::from_utf8(&out[i + 3..sep]).expect("APC header is ASCII");

let payload_start = if sep < out.len() && out[sep] == b';' {
sep + 1
} else {
sep
};
let mut end = payload_start;
let mut terminated = false;
while end + 1 < out.len() {
if out[end] == 0x1b && out[end + 1] == b'\\' {
terminated = true;
break;
}
end += 1;
}
assert!(
terminated,
"unterminated kitty APC frame starting at byte offset {i}"
);
let chunk = std::str::from_utf8(&out[payload_start..end]).expect("APC payload is ASCII");
current_b64.push_str(chunk);

let more = header.split(',').any(|kv| kv == "m=1");
if !more {
let png = base64::engine::general_purpose::STANDARD
.decode(&current_b64)
.expect("base64 payload decodes");
images.push(png);
current_b64.clear();
}

i = end + 2;
}
images
}

#[test]
fn h1_h2_h3_emit_decodable_pngs_with_descending_heights() {
let fixture = Path::new("fixtures/headings.md");
let stdout = run_termdown(fixture);

// The very first APC frame must use the display form (`a=T`, transmit +
// display in one go). The lifecycle form (`a=t`, transmit-only, used by
// the TUI for cached placements) would not paint anything when run via
// `cat` mode and would silently regress the heading-rendering pipeline.
let display_header: &[u8] = b"\x1b_Gf=100,a=T";
assert!(
stdout
.windows(display_header.len())
.any(|w| w == display_header),
"expected display-form kitty header (\\x1b_Gf=100,a=T) in stdout"
);

let pngs = extract_kitty_pngs(&stdout);

assert_eq!(
pngs.len(),
3,
"expected one Kitty image per heading level (H1, H2, H3); got {}",
pngs.len()
);

let dims: Vec<(u32, u32)> = pngs
.iter()
.enumerate()
.map(|(idx, png)| {
let img = ImageReader::new(Cursor::new(png))
.with_guessed_format()
.unwrap_or_else(|e| panic!("png {idx} guess_format: {e}"))
.decode()
.unwrap_or_else(|e| panic!("png {idx} decode: {e}"))
.to_rgba8();
assert!(
img.width() > 0 && img.height() > 0,
"png {idx} has empty dimensions"
);
assert!(
img.pixels().any(|p| p[3] > 0),
"png {idx} is fully transparent"
);
(img.width(), img.height())
})
.collect();

let (_, h1_h) = dims[0];
let (_, h2_h) = dims[1];
let (_, h3_h) = dims[2];

assert!(
h1_h > h2_h,
"H1 height ({h1_h}) should exceed H2 height ({h2_h})"
);
assert!(
h2_h > h3_h,
"H2 height ({h2_h}) should exceed H3 height ({h3_h})"
);
}
23 changes: 5 additions & 18 deletions tests/snapshots.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
mod common;

use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};

fn binary_path() -> &'static str {
env!("CARGO_BIN_EXE_termdown")
}
use common::run_termdown;

/// Replace each run of kitty image APC sequences (`ESC _ G ... ESC \`) with a
/// single `<IMG>` marker. Font rasterization produces OS-specific PNG bytes
Expand Down Expand Up @@ -39,20 +38,8 @@ fn strip_kitty_images(s: &str) -> String {
}

fn render(path: &Path) -> String {
let out = Command::new(binary_path())
.arg("--theme")
.arg("dark")
.arg(path)
.env("TERM_PROGRAM", "ghostty")
.env_remove("HOME")
.env_remove("USERPROFILE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("termdown should run");
assert!(out.status.success(), "termdown failed on {path:?}");
let raw = String::from_utf8(out.stdout).expect("valid utf-8");
let stdout = run_termdown(path);
let raw = String::from_utf8(stdout).expect("valid utf-8");
strip_kitty_images(&raw)
}

Expand Down
Loading