chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "jcode-terminal-image"
version = "0.1.0"
edition = "2024"
publish = false
description = "Terminal image display (Kitty/iTerm2/Sixel) and image metadata formatting helpers for jcode"
[lib]
name = "jcode_terminal_image"
path = "src/lib.rs"
[dependencies]
base64 = "0.22"
crossterm = "0.29"
+444
View File
@@ -0,0 +1,444 @@
//! Terminal image display support
//!
//! Supports Kitty graphics protocol (Kitty, Ghostty), iTerm2 inline images,
//! and Sixel graphics (xterm, foot, mlterm, WezTerm).
//! Falls back to a simple placeholder if no image protocol is available.
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use std::io::{self, Write};
use std::path::Path;
use std::process::Command;
use std::sync::LazyLock;
/// Cache whether ImageMagick is available for Sixel conversion
static HAS_IMAGEMAGICK: LazyLock<bool> = LazyLock::new(|| {
Command::new("convert")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
});
/// Terminal image protocol support
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ImageProtocol {
/// Kitty graphics protocol (most feature-rich)
Kitty,
/// iTerm2 inline images
ITerm2,
/// Sixel graphics (xterm, foot, mlterm, WezTerm)
Sixel,
/// No image support
None,
}
impl ImageProtocol {
/// Detect the best available image protocol for the current terminal
pub fn detect() -> Self {
// Check for Kitty first (most capable)
if std::env::var("KITTY_WINDOW_ID").is_ok() {
return Self::Kitty;
}
// Check TERM for kitty or ghostty
if let Ok(term) = std::env::var("TERM")
&& (term.contains("kitty") || term.contains("ghostty"))
{
return Self::Kitty;
}
// Check TERM_PROGRAM for Ghostty
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
if term_program == "ghostty" {
return Self::Kitty;
}
if term_program == "iTerm.app" {
return Self::ITerm2;
}
// WezTerm supports Sixel
if term_program == "WezTerm" {
return Self::Sixel;
}
}
// Check LC_TERMINAL for iTerm2
if let Ok(lc_terminal) = std::env::var("LC_TERMINAL")
&& lc_terminal == "iTerm2"
{
return Self::ITerm2;
}
// Check for Sixel-capable terminals
if Self::detect_sixel() {
return Self::Sixel;
}
Self::None
}
/// Detect if terminal supports Sixel graphics
fn detect_sixel() -> bool {
// Only enable Sixel if we have ImageMagick to do the conversion
if !*HAS_IMAGEMAGICK {
return false;
}
if let Ok(term) = std::env::var("TERM") {
let term_lower = term.to_lowercase();
// Known Sixel-capable terminals
if term_lower.contains("xterm")
|| term_lower.contains("foot")
|| term_lower.contains("mlterm")
|| term_lower.contains("yaft")
|| term_lower.contains("mintty")
|| term_lower.contains("contour")
{
return true;
}
}
// Check TERM_PROGRAM for other Sixel terminals
if let Ok(prog) = std::env::var("TERM_PROGRAM")
&& (prog == "mintty" || prog == "contour")
{
return true;
}
false
}
/// Check if image display is supported
pub fn is_supported(&self) -> bool {
*self != Self::None
}
}
/// Display parameters for terminal images
#[derive(Debug, Clone)]
pub struct ImageDisplayParams {
/// Maximum width in terminal columns
pub max_cols: u16,
/// Maximum height in terminal rows
pub max_rows: u16,
}
impl Default for ImageDisplayParams {
fn default() -> Self {
Self {
max_cols: 80,
max_rows: 24,
}
}
}
impl ImageDisplayParams {
/// Create display params based on terminal size
pub fn from_terminal() -> Self {
let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40));
// Use about 2/3 of terminal width, capped at 100 columns
// Use about 1/2 of terminal height, capped at 30 rows
Self {
max_cols: (cols * 2 / 3).clamp(40, 100),
max_rows: (rows / 2).clamp(10, 30),
}
}
}
/// Display an image in the terminal
///
/// Returns Ok(true) if the image was displayed, Ok(false) if not supported,
/// or an error if something went wrong.
pub fn display_image(path: &Path, params: &ImageDisplayParams) -> io::Result<bool> {
let protocol = ImageProtocol::detect();
if !protocol.is_supported() {
return Ok(false);
}
// Read the image file
let data = std::fs::read(path)?;
// Get image dimensions to calculate aspect ratio
let (img_width, img_height) = get_image_dimensions(&data).unwrap_or((0, 0));
match protocol {
ImageProtocol::Kitty => display_kitty(&data, params, img_width, img_height),
ImageProtocol::ITerm2 => display_iterm2(&data, path, params, img_width, img_height),
ImageProtocol::Sixel => display_sixel(path, params, img_width, img_height),
ImageProtocol::None => Ok(false),
}
}
/// Get image dimensions from raw data
fn get_image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
// PNG: check signature and parse IHDR chunk
if data.len() > 24 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
return Some((width, height));
}
// JPEG: look for SOF0/SOF2 markers
if data.len() > 2 && data[0] == 0xFF && data[1] == 0xD8 {
let mut i = 2;
while i + 9 < data.len() {
if data[i] != 0xFF {
i += 1;
continue;
}
let marker = data[i + 1];
// SOF0 (baseline) or SOF2 (progressive)
if marker == 0xC0 || marker == 0xC2 {
let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
return Some((width, height));
}
// Skip to next marker
if i + 3 < data.len() {
let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
i += 2 + len;
} else {
break;
}
}
}
// GIF: parse header
if data.len() > 10 && (&data[0..6] == b"GIF87a" || &data[0..6] == b"GIF89a") {
let width = u16::from_le_bytes([data[6], data[7]]) as u32;
let height = u16::from_le_bytes([data[8], data[9]]) as u32;
return Some((width, height));
}
// WebP: parse RIFF header
if data.len() > 30 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
// VP8 chunk
if &data[12..16] == b"VP8 " && data.len() > 30 {
// VP8 bitstream starts at offset 23, dimensions at offset 26
if data[23] == 0x9D && data[24] == 0x01 && data[25] == 0x2A {
let width = (u16::from_le_bytes([data[26], data[27]]) & 0x3FFF) as u32;
let height = (u16::from_le_bytes([data[28], data[29]]) & 0x3FFF) as u32;
return Some((width, height));
}
}
// VP8L (lossless)
if &data[12..16] == b"VP8L" && data.len() > 25 {
let bits = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
let width = (bits & 0x3FFF) + 1;
let height = ((bits >> 14) & 0x3FFF) + 1;
return Some((width, height));
}
}
None
}
/// Calculate display size maintaining aspect ratio
fn calculate_display_size(
img_width: u32,
img_height: u32,
max_cols: u16,
max_rows: u16,
) -> (u16, u16) {
if img_width == 0 || img_height == 0 {
return (max_cols.min(40), max_rows.min(20));
}
// Terminal cells are typically ~2:1 aspect ratio (taller than wide)
// So we need to account for that when calculating display size
let cell_aspect = 2.0; // height/width ratio of a terminal cell
let img_aspect = img_width as f64 / img_height as f64;
let max_width = max_cols as f64;
let max_height = max_rows as f64 * cell_aspect; // Convert rows to "width units"
let (display_width, display_height) = if img_aspect > max_width / max_height {
// Image is wider than available space
(max_width, max_width / img_aspect)
} else {
// Image is taller than available space
(max_height * img_aspect, max_height)
};
(
(display_width as u16).max(10),
(display_height / cell_aspect) as u16, // Convert back to rows
)
}
/// Display image using Kitty graphics protocol
fn display_kitty(
data: &[u8],
params: &ImageDisplayParams,
img_width: u32,
img_height: u32,
) -> io::Result<bool> {
let (cols, rows) =
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
// Encode image data as base64
let encoded = BASE64.encode(data);
let mut stdout = io::stdout().lock();
// Kitty graphics protocol:
// \x1b_G<key>=<value>,...;<payload>\x1b\\
//
// Keys:
// a=T - action: transmit and display
// f=100 - format: auto-detect
// c=<cols> - display width in cells
// r=<rows> - display height in cells
// m=1 - more data follows (chunked)
// m=0 - final chunk
// Send in chunks (max 4096 bytes per chunk for safety)
const CHUNK_SIZE: usize = 4096;
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(CHUNK_SIZE)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
for (i, chunk) in chunks.iter().enumerate() {
let is_first = i == 0;
let is_last = i == chunks.len() - 1;
let more = if is_last { 0 } else { 1 };
if is_first {
// First chunk includes all parameters
write!(
stdout,
"\x1b_Ga=T,f=100,c={},r={},m={};{}\x1b\\",
cols, rows, more, chunk
)?;
} else {
// Subsequent chunks only have m flag
write!(stdout, "\x1b_Gm={};{}\x1b\\", more, chunk)?;
}
}
// Newline after image
writeln!(stdout)?;
stdout.flush()?;
Ok(true)
}
/// Display image using iTerm2 inline image protocol
fn display_iterm2(
data: &[u8],
path: &Path,
params: &ImageDisplayParams,
img_width: u32,
img_height: u32,
) -> io::Result<bool> {
let (cols, _rows) =
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
// Encode image data as base64
let encoded = BASE64.encode(data);
let filename = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "image".to_string());
let filename_b64 = BASE64.encode(filename.as_bytes());
let mut stdout = io::stdout().lock();
// iTerm2 inline image protocol:
// \x1b]1337;File=name=<base64name>;size=<size>;inline=1;width=<cols>:<base64data>\x07
write!(
stdout,
"\x1b]1337;File=name={};size={};inline=1;width={}:{}\x07",
filename_b64,
data.len(),
cols,
encoded
)?;
// Newline after image
writeln!(stdout)?;
stdout.flush()?;
Ok(true)
}
/// Display image using Sixel graphics protocol
///
/// Uses ImageMagick's `convert` command to generate Sixel output.
/// This is the same approach used by image.nvim and other terminal image tools.
fn display_sixel(
path: &Path,
params: &ImageDisplayParams,
img_width: u32,
img_height: u32,
) -> io::Result<bool> {
if !*HAS_IMAGEMAGICK {
return Ok(false);
}
let (cols, rows) =
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
// Calculate pixel dimensions based on typical terminal cell size
// Assuming ~8px wide x 16px tall cells (common default)
let pixel_width = (cols as u32) * 8;
let pixel_height = (rows as u32) * 16;
// Use ImageMagick to convert to Sixel
// -geometry: resize to fit
// -colors 256: limit palette for Sixel
// sixel:-: output Sixel to stdout
let output = Command::new("convert")
.arg(path)
.arg("-geometry")
.arg(format!("{}x{}>", pixel_width, pixel_height))
.arg("-colors")
.arg("256")
.arg("sixel:-")
.output()?;
if !output.status.success() {
return Ok(false);
}
let mut stdout = io::stdout().lock();
stdout.write_all(&output.stdout)?;
writeln!(stdout)?;
stdout.flush()?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_protocol_detection() {
// This test just verifies the detection doesn't panic
let protocol = ImageProtocol::detect();
println!("Detected protocol: {:?}", protocol);
}
#[test]
fn test_calculate_display_size() {
// Wide image
let (w, h) = calculate_display_size(1920, 1080, 80, 24);
assert!(w <= 80);
assert!(h <= 24);
// Tall image
let (w, h) = calculate_display_size(1080, 1920, 80, 24);
assert!(w <= 80);
assert!(h <= 24);
// Square image
let (w, h) = calculate_display_size(500, 500, 80, 24);
assert!(w <= 80);
assert!(h <= 24);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Low-level terminal image primitives for jcode.
//!
//! This crate has no dependency on the rest of jcode; it provides:
//! - [`display`]: terminal image rendering (Kitty / iTerm2 / Sixel) with a
//! graceful no-protocol fallback.
//! - [`metadata`]: small pure helpers for formatting image dimensions, byte
//! counts, formats, and estimating base64-decoded sizes.
//!
//! It is shared by the TUI and the `read` tool so neither has to depend on
//! the other just to display or describe an image.
pub mod display;
pub mod metadata;
pub use display::{ImageDisplayParams, ImageProtocol, display_image};
+138
View File
@@ -0,0 +1,138 @@
pub fn compact_path_label(path: &str) -> String {
let trimmed = path.trim();
std::path::Path::new(trimmed)
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| trimmed.to_string())
}
pub fn compact_image_format(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return "image".to_string();
}
let lower = trimmed
.strip_prefix("image/")
.unwrap_or(trimmed)
.to_ascii_lowercase();
match lower.as_str() {
"jpg" | "jpeg" => "JPEG".to_string(),
"png" => "PNG".to_string(),
"webp" => "WebP".to_string(),
"gif" => "GIF".to_string(),
"bmp" => "BMP".to_string(),
"ico" | "x-icon" => "ICO".to_string(),
"svg" | "svg+xml" => "SVG".to_string(),
_ => trimmed.to_string(),
}
}
pub fn format_dimensions(width: u32, height: u32) -> String {
format!("{width}×{height}")
}
pub fn aspect_ratio(width: u32, height: u32) -> Option<String> {
if width == 0 || height == 0 {
return None;
}
let divisor = gcd(width, height).max(1);
Some(format!("{}:{}", width / divisor, height / divisor))
}
pub fn format_byte_count(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
if bytes == 1 {
return "1 B".to_string();
}
if bytes < 1024 {
return format!("{bytes} B");
}
let (value, unit) = if (bytes as f64) >= GB {
(bytes as f64 / GB, "GB")
} else if (bytes as f64) >= MB {
(bytes as f64 / MB, "MB")
} else {
(bytes as f64 / KB, "KB")
};
let mut formatted = if value >= 100.0 {
format!("{value:.0}")
} else {
format!("{value:.1}")
};
if formatted.ends_with(".0") {
formatted.truncate(formatted.len().saturating_sub(2));
}
format!("{formatted} {unit}")
}
pub fn estimate_base64_decoded_len(data: &str) -> Option<u64> {
let trimmed = data.trim();
let payload = if trimmed.starts_with("data:") {
trimmed.split_once(',').map(|(_, payload)| payload)?
} else {
trimmed
};
let mut len = 0u64;
let mut padding = 0u64;
for ch in payload.chars() {
if ch.is_whitespace() {
continue;
}
len += 1;
if ch == '=' {
padding += 1;
}
}
if len == 0 || len % 4 == 1 || padding > 2 {
return None;
}
Some(((len * 3) / 4).saturating_sub(padding))
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
let next = a % b;
a = b;
b = next;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_byte_count_uses_compact_units() {
assert_eq!(format_byte_count(1), "1 B");
assert_eq!(format_byte_count(1536), "1.5 KB");
assert_eq!(format_byte_count(2 * 1024 * 1024), "2 MB");
}
#[test]
fn estimate_base64_decoded_len_handles_common_payloads() {
assert_eq!(estimate_base64_decoded_len("TQ=="), Some(1));
assert_eq!(estimate_base64_decoded_len("TWE="), Some(2));
assert_eq!(estimate_base64_decoded_len("TWFu"), Some(3));
assert_eq!(
estimate_base64_decoded_len("data:image/png;base64,TWFu"),
Some(3)
);
}
#[test]
fn aspect_ratio_reduces_dimensions() {
assert_eq!(aspect_ratio(1024, 1024).as_deref(), Some("1:1"));
assert_eq!(aspect_ratio(1792, 1024).as_deref(), Some("7:4"));
}
}