From 1de7a9aa808592f17fb7e277f3222f6e3117a15e Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 13:52:44 +0200 Subject: [PATCH 1/7] feat(theme): detect light/dark background with an OSC 11 query With no --theme and no config theme, ask the terminal for its background colour (OSC 11, bounded by a trailing DA1 request and a 100 ms timeout, 400 ms over SSH) before the TUI starts, in raw mode with the mode restored on every path. Parses rgb:R/G/B (1-4 hex digits) and #RGB forms; light when perceived luminance > 0.5. Precedence: flag/config > OSC 11 > COLORFGBG > dark. Skipped for TERM=dumb/linux, non-TTY stdin/stdout, and Windows outside Windows Terminal. COLORFGBG now treats index 7 as light; the dead TERM_PROGRAM branch (with its Ghostty casing bug) is gone. --- Cargo.lock | 1 + Cargo.toml | 2 + README.md | 2 +- src/cli.rs | 43 +++- src/doctor.rs | 40 +++- src/theme/detect.rs | 494 ++++++++++++++++++++++++++++++++++++++++++-- tests/pty.rs | 190 +++++++++++++++++ 7 files changed, 745 insertions(+), 27 deletions(-) create mode 100644 tests/pty.rs diff --git a/Cargo.lock b/Cargo.lock index 2526505..d4cfd9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,6 +1535,7 @@ dependencies = [ "flate2", "image", "insta", + "libc", "notify", "open", "predicates", diff --git a/Cargo.toml b/Cargo.toml index b3bccf0..0164821 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,8 @@ notify = "8" # Real (Myers) line diff for `ink diff` similar = "2" signal-hook = "0.3" +# termios + poll for the OSC 11 background query (unix only; empty on Windows) +libc = "0.2" ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] } [dev-dependencies] diff --git a/README.md b/README.md index 70d01cd..1e18d82 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Press `t` to toggle a sidebar showing every heading in the document. Tracks your Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, and Solarized. Press `T` to open the theme picker and preview each one live. -Auto-detects your terminal background and picks dark or light mode by default. +With no theme set, ink asks the terminal for its background colour (an OSC 11 query, answered by nearly every modern terminal and passed through by tmux) and picks Light or Dark to match, falling back to `COLORFGBG` and then Dark. An explicit `--theme` or `theme =` in the config always wins, and never triggers the query. ### Math and emoji diff --git a/src/cli.rs b/src/cli.rs index 1c28d96..fc21bda 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -217,6 +217,15 @@ pub fn run() -> Result<()> { let stdout_tty = std::io::stdout().is_terminal(); let color = theme::caps::color_enabled(cli.color, stdout_tty); + let (theme, theme_origin) = theme_choice(&cli.theme, &user_config); + // `auto` asks the terminal for its background colour (OSC 11) once, now, + // before anything owns the screen. Only with a terminal on both ends: + // the reply arrives on stdin, and a pipe would never answer. + let wants_terminal = matches!(cli.command, None | Some(Commands::Doctor { .. })); + if theme == "auto" && wants_terminal { + theme::detect::init_background(stdout_tty && std::io::stdin().is_terminal()); + } + // Handle subcommands if let Some(cmd) = &cli.command { return match cmd { @@ -241,7 +250,7 @@ pub fn run() -> Result<()> { Ok(()) } Commands::Doctor { save } => { - return crate::doctor::run(save.as_deref()); + return crate::doctor::run(save.as_deref(), &theme, theme_origin); } Commands::Keybindings => { print_keybindings(); @@ -291,15 +300,6 @@ pub fn run() -> Result<()> { let toc = cli.toc || cfg_flag(|c| c.toc); let frontmatter = cli.frontmatter || cfg_flag(|c| c.frontmatter); - let theme = if cli.theme == "auto" { - user_config - .as_ref() - .and_then(|c| c.theme.clone()) - .unwrap_or_else(|| "auto".to_string()) - } else { - cli.theme.clone() - }; - // Resolve once now, while stderr still reaches the user: a broken or // unknown theme warns here instead of inside the alternate screen. let _ = theme::resolve_theme(&theme); @@ -529,6 +529,29 @@ fn read_file(path: &str) -> Result { } } +/// Where the active theme name came from (for `ink doctor`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeOrigin { + /// `--theme NAME`. + Flag, + /// `theme = "NAME"` in the config file. + Config, + /// Neither: `auto`, detected from the terminal background. + Auto, +} + +/// The theme to use: an explicit `--theme` beats the config file, which beats +/// `auto`. (`--theme auto` is the clap default, so it means "not given".) +fn theme_choice(flag: &str, config: &Option) -> (String, ThemeOrigin) { + if flag != "auto" { + return (flag.to_string(), ThemeOrigin::Flag); + } + match config.as_ref().and_then(|c| c.theme.clone()) { + Some(t) if t != "auto" => (t, ThemeOrigin::Config), + _ => ("auto".to_string(), ThemeOrigin::Auto), + } +} + fn resolve_width(width: &Option, config: &Option) -> Option { match width { Some(WidthArg::Columns(n)) => Some(*n), diff --git a/src/doctor.rs b/src/doctor.rs index e4cc6c5..fd751f0 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -11,8 +11,13 @@ use std::fmt::Write as _; use std::io::IsTerminal; /// Build the report and print it; optionally save to `path`. -pub fn run(save: Option<&std::path::Path>) -> anyhow::Result<()> { - let report = build_report(); +pub fn run( + save: Option<&std::path::Path>, + theme: &str, + origin: crate::cli::ThemeOrigin, +) -> anyhow::Result<()> { + let mut report = build_report(); + report.push_str(&theme_section(theme, origin)); print!("{report}"); if let Some(path) = save { std::fs::write(path, &report)?; @@ -123,6 +128,37 @@ fn build_report() -> String { r } +/// `[theme]`: the theme in use and why. +fn theme_section(theme: &str, origin: crate::cli::ThemeOrigin) -> String { + use crate::cli::ThemeOrigin; + use crate::theme::detect::BackgroundSource; + let mut r = String::new(); + let _ = writeln!(r, "\n[theme]"); + match origin { + ThemeOrigin::Flag => { + let _ = writeln!(r, "theme = {theme} (from --theme)"); + } + ThemeOrigin::Config => { + let _ = writeln!(r, "theme = {theme} (from config)"); + } + ThemeOrigin::Auto => { + let bg = crate::theme::detect::background(); + let resolved = if bg.dark { "dark" } else { "light" }; + let why = match &bg.source { + BackgroundSource::Osc11 { + rgb: (r8, g8, b8), + raw, + } => format!("terminal background {raw} = #{r8:02x}{g8:02x}{b8:02x}, via OSC 11"), + BackgroundSource::Colorfgbg(v) => format!("COLORFGBG={v}"), + BackgroundSource::Default => "default; nothing reported a background".into(), + }; + let _ = writeln!(r, "theme = auto -> {resolved} ({why})"); + let _ = writeln!(r, "background query = {}", bg.query); + } + } + r +} + fn fmt_env(var: &str) -> String { std::env::var(var).unwrap_or_else(|_| "(unset)".into()) } diff --git a/src/theme/detect.rs b/src/theme/detect.rs index 0027541..cd07684 100644 --- a/src/theme/detect.rs +++ b/src/theme/detect.rs @@ -1,24 +1,490 @@ -/// Detect if the terminal has a dark background. +//! Light/dark background detection for `theme = "auto"`. +//! +//! Precedence (an explicit `--theme` or config theme never reaches this +//! module): +//! +//! 1. the terminal's own answer to an OSC 11 background-colour query; +//! 2. `COLORFGBG` (set by rxvt, Konsole and a few others); +//! 3. dark. +//! +//! The query runs once, before the TUI owns the terminal, through +//! [`init_background`]; every later [`is_dark_background`] reads the cached +//! answer (the theme is re-resolved on every frame). + +use std::sync::OnceLock; +use std::time::Duration; + +/// Where the light/dark decision came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BackgroundSource { + /// The terminal answered OSC 11 with this colour. + Osc11 { rgb: (u8, u8, u8), raw: String }, + /// `COLORFGBG` named this palette index as the background. + Colorfgbg(String), + /// Nothing to go on. + Default, +} + +/// The detected background, plus why the OSC 11 query did or did not answer +/// (for `ink doctor`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Background { + pub dark: bool, + pub source: BackgroundSource, + /// What happened to the OSC 11 query: "answered", "no reply", or why it + /// was skipped. + pub query: String, +} + +static BACKGROUND: OnceLock = OnceLock::new(); + +/// Detect the background once, querying the terminal only when `allow_query` +/// is true (the caller has checked that stdin and stdout are terminals). +/// Later calls return the first result. +pub fn init_background(allow_query: bool) -> &'static Background { + BACKGROUND.get_or_init(|| detect(allow_query)) +} + +/// The cached background, detecting it without a terminal query if nothing +/// has run [`init_background`] yet (tests, `--plain` into a pipe). +pub fn background() -> &'static Background { + init_background(false) +} + +/// Is the terminal background dark? pub fn is_dark_background() -> bool { - // Check COLORFGBG environment variable (set by some terminals) - if let Ok(val) = std::env::var("COLORFGBG") { - if let Some(bg) = val.split(';').next_back() { - if let Ok(bg_num) = bg.parse::() { - // Low values = dark background - return bg_num < 8; + background().dark +} + +fn detect(allow_query: bool) -> Background { + let query = if allow_query { + match query_skip_reason() { + Some(reason) => Err(reason.to_string()), + None => query_background(), + } + } else { + Err("skipped (not a terminal)".to_string()) + }; + let query_note = match &query { + Ok(_) => "answered".to_string(), + Err(why) => why.clone(), + }; + if let Ok(reply) = &query { + if let Some(rgb) = parse_osc11_reply(reply) { + return Background { + dark: !is_light(rgb), + source: BackgroundSource::Osc11 { + rgb, + raw: osc11_payload(reply).unwrap_or_default(), + }, + query: query_note, + }; + } + } + let query_note = match &query { + Ok(reply) if osc11_payload(reply).is_none() => { + "not supported (terminal answered only DA1)".to_string() + } + Ok(_) => "unparseable reply".to_string(), + Err(_) => query_note, + }; + if let Ok(v) = std::env::var("COLORFGBG") { + if let Some(dark) = colorfgbg_is_dark(&v) { + return Background { + dark, + source: BackgroundSource::Colorfgbg(v), + query: query_note, + }; + } + } + Background { + dark: true, + source: BackgroundSource::Default, + query: query_note, + } +} + +/// Environment-based reasons not to ask the terminal at all. +fn query_skip_reason() -> Option<&'static str> { + let term = std::env::var("TERM").unwrap_or_default(); + skip_reason_for( + &term, + std::env::var_os("WT_SESSION").is_some(), + cfg!(windows), + ) +} + +/// Pure form of [`query_skip_reason`]. +fn skip_reason_for(term: &str, wt_session: bool, windows: bool) -> Option<&'static str> { + if term == "dumb" || term == "linux" { + return Some("skipped (TERM cannot answer)"); + } + // Legacy conhost ignores OSC 11 and would make us wait out the timeout; + // Windows Terminal answers it. + if windows && !wt_session { + return Some("skipped (not Windows Terminal)"); + } + None +} + +/// How long to wait for a terminal that answers neither query. Responsive +/// terminals end the wait early through the DA1 reply, so this only costs +/// time on the rare terminal that answers nothing — but across SSH the round +/// trip itself can take longer. +fn query_timeout() -> Duration { + let ssh = std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some(); + if ssh { + Duration::from_millis(400) + } else { + Duration::from_millis(100) + } +} + +/// The background query, followed by a primary device attributes request +/// (DA1). Nearly every terminal answers DA1, and answers in order, so the +/// DA1 reply marks the point where an OSC 11 answer would already have come. +const QUERY: &str = "\x1b]11;?\x07\x1b[c"; + +/// Ask the terminal for its background colour; returns everything it sent +/// back (the OSC 11 reply, if any, and the DA1 reply). +#[cfg(unix)] +fn query_background() -> Result { + use std::io::Write; + use std::os::fd::AsRawFd; + use std::time::Instant; + + let stdin = std::io::stdin(); + let fd = stdin.as_raw_fd(); + + // Raw mode (no echo, no line buffering) for the duration of the query, so + // the reply is neither printed nor held back until Enter. The guard puts + // the original mode back on every path out of this function. + struct Restore { + fd: i32, + orig: libc::termios, + } + impl Drop for Restore { + fn drop(&mut self) { + // SAFETY: restoring attributes previously read from the same fd. + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig); } } } + // SAFETY: termios is plain data; tcgetattr fills it or fails. + let mut orig: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 { + return Err("skipped (cannot read terminal mode)".into()); + } + let mut raw = orig; + // SAFETY: cfmakeraw only edits the struct it is given. + unsafe { libc::cfmakeraw(&mut raw) }; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return Err("skipped (cannot set raw mode)".into()); + } + let _restore = Restore { fd, orig }; + + let mut out = std::io::stdout(); + if out + .write_all(QUERY.as_bytes()) + .and_then(|_| out.flush()) + .is_err() + { + return Err("skipped (cannot write to terminal)".into()); + } + + let deadline = Instant::now() + query_timeout(); + let mut buf: Vec = Vec::new(); + let mut chunk = [0u8; 256]; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: one valid pollfd, bounded timeout. + let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis().max(1) as libc::c_int) }; + if n < 0 { + if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + break; + } + if n == 0 { + break; + } + // SAFETY: reading into a stack buffer of the stated length. + let got = unsafe { libc::read(fd, chunk.as_mut_ptr().cast(), chunk.len()) }; + if got <= 0 { + break; + } + buf.extend_from_slice(&chunk[..got as usize]); + if contains_da1_reply(&buf) { + break; + } + } + if buf.is_empty() { + return Err("no reply".into()); + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Windows Terminal: the reply arrives as console input, which crossterm +/// surfaces as key presses. Reassemble them into the byte stream. +#[cfg(windows)] +fn query_background() -> Result { + use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + use std::io::Write; + use std::time::Instant; + + if crossterm::terminal::enable_raw_mode().is_err() { + return Err("skipped (cannot set raw mode)".into()); + } + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } + } + let _restore = Restore; - // Check for common dark terminal indicators - if let Ok(term_program) = std::env::var("TERM_PROGRAM") { - // Most modern terminals default to dark - match term_program.as_str() { - "iTerm.app" | "WezTerm" | "Ghostty" => return true, + let mut out = std::io::stdout(); + if out + .write_all(QUERY.as_bytes()) + .and_then(|_| out.flush()) + .is_err() + { + return Err("skipped (cannot write to terminal)".into()); + } + let deadline = Instant::now() + query_timeout(); + let mut text = String::new(); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() || !event::poll(left).unwrap_or(false) { + break; + } + let Ok(Event::Key(k)) = event::read() else { + continue; + }; + if k.kind == KeyEventKind::Release { + continue; + } + match k.code { + KeyCode::Esc => text.push('\x1b'), + KeyCode::Char('g') if k.modifiers.contains(KeyModifiers::CONTROL) => text.push('\x07'), + KeyCode::Char(c) => text.push(c), _ => {} } + if contains_da1_reply(text.as_bytes()) { + break; + } + } + if text.is_empty() { + return Err("no reply".into()); + } + Ok(text) +} + +#[cfg(not(any(unix, windows)))] +fn query_background() -> Result { + Err("skipped (unsupported platform)".into()) +} + +/// Does `buf` hold a complete DA1 reply (`ESC [ ? … c`)? +fn contains_da1_reply(buf: &[u8]) -> bool { + let mut i = 0; + while i + 2 < buf.len() { + if buf[i] == 0x1b && buf[i + 1] == b'[' && buf[i + 2] == b'?' { + let rest = &buf[i + 3..]; + if let Some(end) = rest + .iter() + .position(|b| !(b.is_ascii_digit() || *b == b';')) + { + if rest[end] == b'c' { + return true; + } + } + } + i += 1; } + false +} + +/// The colour spec inside an OSC 11 reply (`rgb:…` or `#…`), without the +/// introducer or terminator. `None` when there is no complete reply. +fn osc11_payload(reply: &str) -> Option { + let start = reply.find("\x1b]11;")? + "\x1b]11;".len(); + let rest = &reply[start..]; + // BEL or ST (ESC \) ends it; a reply cut off before either is truncated. + let end = rest.find(['\x07', '\x1b'])?; + Some(rest[..end].to_string()) +} + +/// Parse a terminal's OSC 11 reply into 8-bit RGB. Accepts the X11 forms +/// terminals actually send: `rgb:R/G/B` with 1–4 hex digits per channel (and +/// `rgba:R/G/B/A`), and `#RGB`, `#RRGGBB`, `#RRRGGGBBB`, `#RRRRGGGGBBBB`. +pub fn parse_osc11_reply(reply: &str) -> Option<(u8, u8, u8)> { + parse_color_spec(&osc11_payload(reply)?) +} - // Default to dark (most developers use dark themes) - true +/// Parse an X11 colour spec (see [`parse_osc11_reply`]). +pub fn parse_color_spec(spec: &str) -> Option<(u8, u8, u8)> { + let spec = spec.trim(); + if let Some(body) = spec + .strip_prefix("rgb:") + .or_else(|| spec.strip_prefix("rgba:")) + { + let mut parts = body.split('/'); + let r = scale_hex(parts.next()?)?; + let g = scale_hex(parts.next()?)?; + let b = scale_hex(parts.next()?)?; + return Some((r, g, b)); + } + if let Some(hex) = spec.strip_prefix('#') { + if hex.is_empty() || hex.len() % 3 != 0 || hex.len() > 12 { + return None; + } + let n = hex.len() / 3; + return Some(( + scale_hex(&hex[..n])?, + scale_hex(&hex[n..2 * n])?, + scale_hex(&hex[2 * n..])?, + )); + } + None +} + +/// Scale a 1–4 digit hex channel to 0..=255 (`f` → 255, `ffff` → 255, +/// `80` → 128). +fn scale_hex(digits: &str) -> Option { + if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let v = u32::from_str_radix(digits, 16).ok()?; + let max = (1u32 << (4 * digits.len())) - 1; + Some(((v * 255 + max / 2) / max) as u8) +} + +/// Perceived luminance (Rec. 709 weights on the encoded values), 0.0–1.0. +pub fn luminance((r, g, b): (u8, u8, u8)) -> f64 { + (0.2126 * r as f64 + 0.7152 * g as f64 + 0.0722 * b as f64) / 255.0 +} + +/// A background is light when its luminance is above one half. +pub fn is_light(rgb: (u8, u8, u8)) -> bool { + luminance(rgb) > 0.5 +} + +/// `COLORFGBG` is `fg;bg` (sometimes `fg;default;bg`); the last field is the +/// background palette index. Indices 7 and 9–15 are the light ones. +fn colorfgbg_is_dark(value: &str) -> Option { + let bg = value.split(';').next_back()?.trim().parse::().ok()?; + Some(!matches!(bg, 7 | 9..=15)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_rgb_replies_with_either_terminator() { + let white = "\x1b]11;rgb:ffff/ffff/ffff\x07"; + assert_eq!(parse_osc11_reply(white), Some((255, 255, 255))); + let st = "\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\"; + assert_eq!(parse_osc11_reply(st), Some((30, 30, 46))); + // Followed by the DA1 reply, as the real query produces. + let both = "\x1b]11;rgb:0000/0000/0000\x07\x1b[?62;22c"; + assert_eq!(parse_osc11_reply(both), Some((0, 0, 0))); + } + + #[test] + fn rgb_channels_of_one_to_four_digits_scale_to_8_bit() { + assert_eq!(parse_color_spec("rgb:f/0/8"), Some((255, 0, 136))); + assert_eq!(parse_color_spec("rgb:ff/00/80"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("rgb:fff/000/800"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("rgb:ffff/0000/8000"), Some((255, 0, 128))); + assert_eq!( + parse_color_spec("rgba:ffff/ffff/ffff/ffff"), + Some((255, 255, 255)) + ); + } + + #[test] + fn parses_hash_forms() { + assert_eq!(parse_color_spec("#ffffff"), Some((255, 255, 255))); + assert_eq!(parse_color_spec("#1E1E2E"), Some((30, 30, 46))); + assert_eq!(parse_color_spec("#fff"), Some((255, 255, 255))); + assert_eq!(parse_color_spec("#ffff00000000"), Some((255, 0, 0))); + assert_eq!( + parse_osc11_reply("\x1b]11;#fdf6e3\x07"), + Some((253, 246, 227)) + ); + } + + #[test] + fn garbage_and_truncated_replies_are_rejected() { + for bad in [ + "", + "\x1b[?62;c", // DA1 only: terminal ignored OSC 11 + "\x1b]11;rgb:ffff/ffff", // truncated, no terminator + "\x1b]11;rgb:ffff/ffff\x07", // two channels + "\x1b]11;rgb:fffff/0/0\x07", // five digits + "\x1b]11;rgb:zz/00/00\x07", // not hex + "\x1b]11;#ffff\x07", // not a multiple of three + "\x1b]11;?\x07", // our own query echoed back + "\x1b]11;white\x07", // named colour + "\x1b]10;rgb:ffff/ffff/ffff\x07", // foreground, not background + ] { + assert_eq!(parse_osc11_reply(bad), None, "{bad:?}"); + } + } + + #[test] + fn luminance_threshold() { + assert!(is_light((255, 255, 255))); + assert!(is_light((253, 246, 227)), "solarized light"); + assert!(is_light((200, 200, 200))); + assert!(!is_light((0, 0, 0))); + assert!(!is_light((30, 30, 46)), "catppuccin mocha"); + assert!(!is_light((0, 43, 54)), "solarized dark"); + // Mid grey sits on the line and stays dark (strictly greater). + assert!(!is_light((127, 127, 127))); + assert!(is_light((129, 129, 129))); + // Pure blue is dark, pure yellow is light. + assert!(!is_light((0, 0, 255))); + assert!(is_light((255, 255, 0))); + } + + #[test] + fn da1_reply_detection() { + assert!(contains_da1_reply(b"\x1b[?62;22c")); + assert!(contains_da1_reply(b"\x1b]11;rgb:0/0/0\x07\x1b[?1;2c")); + assert!(!contains_da1_reply(b"\x1b[?62;22")); + assert!(!contains_da1_reply(b"\x1b]11;rgb:0/0/0\x07")); + } + + #[test] + fn colorfgbg_light_and_dark_indices() { + assert_eq!(colorfgbg_is_dark("15;0"), Some(true)); + assert_eq!(colorfgbg_is_dark("0;15"), Some(false)); + assert_eq!(colorfgbg_is_dark("0;7"), Some(false)); + assert_eq!(colorfgbg_is_dark("15;default;0"), Some(true)); + assert_eq!(colorfgbg_is_dark("15;default"), None); + } + + #[test] + fn query_skipped_where_it_cannot_be_answered() { + assert!(skip_reason_for("linux", false, false).is_some()); + assert!(skip_reason_for("dumb", false, false).is_some()); + assert!(skip_reason_for("xterm-256color", false, false).is_none()); + assert!(skip_reason_for("", false, true).is_some(), "conhost"); + assert!( + skip_reason_for("", true, true).is_none(), + "Windows Terminal" + ); + } } diff --git a/tests/pty.rs b/tests/pty.rs new file mode 100644 index 0000000..c6da181 --- /dev/null +++ b/tests/pty.rs @@ -0,0 +1,190 @@ +//! End-to-end checks that need a real terminal: ink runs on the slave side +//! of a pseudo-terminal while the test plays the terminal on the master side +//! (answering queries, typing keys, and recording every byte ink writes). +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A scripted terminal: `respond` sees everything ink has written so far and +/// returns bytes to "type" back (query answers or keys), or nothing. +struct Session { + output: Vec, +} + +fn run_in_pty( + args: &[&str], + envs: &[(&str, &str)], + mut respond: impl FnMut(&[u8], Duration) -> Option>, +) -> Session { + let (mut master, slave) = open_pty(); + let config_home = tempfile::tempdir().unwrap(); + // An `ink/` directory makes ink use this (empty) config instead of the + // developer's real one. + std::fs::create_dir(config_home.path().join("ink")).unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_ink")); + cmd.args(args) + .env("TERM", "xterm-256color") + .env("XDG_CONFIG_HOME", config_home.path()) + .env_remove("COLORFGBG") + .env_remove("TMUX") + .env_remove("STY") + .env_remove("NO_COLOR") + .env_remove("COLORTERM") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave.try_clone().unwrap())); + for (k, v) in envs { + cmd.env(k, v); + } + // SAFETY: only async-signal-safe libc calls between fork and exec. Gives + // ink a session whose controlling terminal is the pty, like a shell would. + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + libc::ioctl(0, libc::TIOCSCTTY as _, 0); + Ok(()) + }); + } + let mut child = cmd.spawn().expect("spawn ink"); + drop(slave); + drop(cmd); + + let fd = master.as_raw_fd(); + let start = Instant::now(); + let mut output = Vec::new(); + let mut buf = [0u8; 8192]; + while start.elapsed() < Duration::from_secs(15) { + if let Some(reply) = respond(&output, start.elapsed()) { + master.write_all(&reply).unwrap(); + } + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: one valid pollfd. + let ready = unsafe { libc::poll(&mut pfd, 1, 50) }; + if ready > 0 { + match master.read(&mut buf) { + Ok(0) | Err(_) => break, // EOF / EIO: the slave side closed + Ok(n) => output.extend_from_slice(&buf[..n]), + } + } else if let Ok(Some(_)) = child.try_wait() { + break; + } + } + let _ = child.kill(); + let _ = child.wait(); + Session { output } +} + +fn open_pty() -> (std::fs::File, OwnedFd) { + let (mut m, mut s) = (0, 0); + let mut size = libc::winsize { + ws_row: 30, + ws_col: 100, + ws_xpixel: 0, + ws_ypixel: 0, + }; + // SAFETY: out-pointers to two ints and a winsize; null name/termios. + let rc = unsafe { + libc::openpty( + &mut m, + &mut s, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut size, + ) + }; + assert_eq!(rc, 0, "openpty failed"); + // SAFETY: openpty returned two fresh descriptors we now own. + unsafe { (std::fs::File::from_raw_fd(m), OwnedFd::from_raw_fd(s)) } +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +/// Answers the OSC 11 background query (once) with `reply`, as a terminal +/// whose background is that colour would. +fn answer_osc11(reply: &'static [u8]) -> impl FnMut(&[u8], Duration) -> Option> { + let mut answered = false; + move |out, _| { + if !answered && contains(out, b"\x1b]11;?") { + answered = true; + Some(reply.to_vec()) + } else { + None + } + } +} + +#[test] +fn doctor_reports_a_light_background_from_osc_11() { + let s = run_in_pty( + &["doctor"], + &[], + answer_osc11(b"\x1b]11;rgb:ffff/ffff/ffff\x07\x1b[?62;22c"), + ); + let text = String::from_utf8_lossy(&s.output); + assert!( + text.contains("auto -> light") && text.contains("via OSC 11"), + "doctor did not report the light background:\n{text}" + ); +} + +#[test] +fn doctor_reports_a_dark_background_from_osc_11() { + let s = run_in_pty( + &["doctor"], + &[], + answer_osc11(b"\x1b]11;#1e1e2e\x1b\\\x1b[?62;22c"), + ); + let text = String::from_utf8_lossy(&s.output); + assert!(text.contains("auto -> dark"), "{text}"); +} + +#[test] +fn an_explicit_theme_never_queries_the_terminal() { + let s = run_in_pty(&["--theme", "dracula", "doctor"], &[], |_, _| None); + assert!(!contains(&s.output, b"\x1b]11;?")); + assert!(String::from_utf8_lossy(&s.output).contains("dracula (from --theme)")); +} + +#[test] +fn the_reader_uses_the_light_theme_and_leaves_no_reply_on_screen() { + let mut osc = answer_osc11(b"\x1b]11;rgb:ffff/ffff/ffff\x07\x1b[?62;22c"); + let mut last_q = Duration::ZERO; + let s = run_in_pty( + &[ + "--image-protocol", + "halfblocks", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test.md"), + ], + &[("COLORTERM", "truecolor")], + |out, t| { + if let Some(r) = osc(out, t) { + return Some(r); + } + // Quit once the reader is up; repeat in case a slow start lost it. + if contains(out, b"\x1b[?1049h") && t > last_q + Duration::from_millis(500) { + last_q = t; + return Some(b"q".to_vec()); + } + None + }, + ); + // The light theme's white background is painted… + assert!(contains(&s.output, b"48;2;255;255;255")); + // …and the reply was consumed, never echoed or drawn. + assert!(!contains(&s.output, b"rgb:ffff")); + // The terminal is handed back: alternate screen left. + let tail = + String::from_utf8_lossy(&s.output[s.output.len().saturating_sub(400)..]).into_owned(); + assert!(contains(&s.output, b"\x1b[?1049l"), "{tail:?}"); +} From 995c29fefa9d6e9b51a2b86bd3d3a7e90c264c31 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 13:55:43 +0200 Subject: [PATCH 2/7] fix(clipboard): Unicode-safe clip.exe, Termux and WSL helpers, OSC 52 under screen clip.exe decodes its input with the console code page, so accented, CJK and emoji text arrived as mojibake on Windows and WSL. Feed it UTF-16LE with a byte-order mark instead. On WSL without clip.exe on PATH, fall back to PowerShell's Set-Clipboard reading UTF-8. Use termux-clipboard-set when TERMUX_VERSION is set. Helper selection and the bytes written are now a pure, tested function. Under GNU screen (STY set, TERM=screen*, no TMUX) the OSC 52 sequence is sent in short DCS chunks so screen forwards it to the outer terminal. --- README.md | 2 +- src/cli.rs | 2 +- src/clipboard.rs | 344 +++++++++++++++++++++++++++++++++++++++----- src/lib.rs | 1 + src/platform.rs | 74 ++++++++++ src/theme/detect.rs | 3 +- 6 files changed, 383 insertions(+), 43 deletions(-) create mode 100644 src/platform.rs diff --git a/README.md b/README.md index 1e18d82..f6df228 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ mouse_capture = true # How copied text reaches the clipboard: # auto - OSC 52 escape (crosses SSH and tmux) plus a native helper, if present # osc52 - escape sequence only -# native - pbcopy / wl-copy / xclip / xsel / clip.exe only +# native - pbcopy / wl-copy / xclip / xsel / clip.exe / termux-clipboard-set only # off - copying is disabled clipboard = "auto" ``` diff --git a/src/cli.rs b/src/cli.rs index fc21bda..0fc65a0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -609,7 +609,7 @@ pub(crate) const STARTER_CONFIG: &str = r#"# ink configuration # How copied text reaches the clipboard: # auto - OSC 52 escape (works over SSH) and a native helper, if present # osc52 - escape sequence only -# native - pbcopy / wl-copy / xclip / xsel / clip.exe only +# native - pbcopy / wl-copy / xclip / xsel / clip.exe / termux-clipboard-set only # off - copying is disabled # clipboard = "auto" diff --git a/src/clipboard.rs b/src/clipboard.rs index ad15d50..5d2df08 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -6,9 +6,10 @@ //! * **OSC 52** — an escape sequence the terminal itself acts on, so it crosses //! an SSH boundary. Some terminals ship it disabled (Terminal.app) and none //! of them report back, so it can silently do nothing. -//! * **A native helper** (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) — -//! reliable locally, useless over SSH (it would write the *server's* -//! clipboard). +//! * **A native helper** (`pbcopy`, `termux-clipboard-set`, `wl-copy`, +//! `xclip`, `xsel`, `clip.exe`, or PowerShell's `Set-Clipboard` on WSL +//! without `clip.exe`) — reliable locally, useless over SSH (it would write +//! the *server's* clipboard). //! //! Running both can write the same bytes twice. That is harmless, and cheaper //! than trying to detect which one worked. @@ -63,60 +64,216 @@ pub enum CopyOutcome { /// drop the whole sequence, so a 200 KB selection would silently copy nothing. const OSC52_MAX_B64: usize = 74_994; +/// Which multiplexer envelope an OSC 52 sequence needs to reach the outer +/// terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wrap { + /// Write it as is. + None, + /// tmux passthrough: `ESC P tmux; … ESC \`, inner ESCs doubled. + Tmux, + /// GNU screen: the sequence split into short DCS strings, which screen + /// strips and forwards verbatim. + Screen, +} + +impl Wrap { + /// The envelope this session needs. + pub fn detect() -> Self { + Self::from_env( + std::env::var_os("TMUX").is_some(), + std::env::var_os("STY").is_some(), + &std::env::var("TERM").unwrap_or_default(), + ) + } + + /// Pure form of [`Wrap::detect`]. tmux wins when both are present: when + /// screen runs tmux, tmux is the layer ink talks to. + pub fn from_env(tmux: bool, sty: bool, term: &str) -> Self { + if tmux { + Self::Tmux + } else if sty && term.starts_with("screen") { + Self::Screen + } else { + Self::None + } + } +} + +/// GNU screen drops a DCS string past its buffer (768 bytes in most builds, +/// less in some), so the sequence goes out in pieces well under that. +const SCREEN_DCS_CHUNK: usize = 76; + /// Build the OSC 52 sequence for `text`, or `None` if it exceeds the cap. /// /// Inside tmux the sequence has to be wrapped in a passthrough envelope, with /// every inner ESC doubled, or tmux swallows it instead of forwarding it to the -/// outer terminal. +/// outer terminal. GNU screen needs it in DCS chunks. pub fn osc52_payload(text: &str) -> Option { - osc52_payload_in(text, std::env::var_os("TMUX").is_some()) + osc52_payload_wrapped(text, Wrap::detect()) } /// Testable core of [`osc52_payload`]: `tmux` selects the passthrough envelope /// instead of reading the environment. pub fn osc52_payload_in(text: &str, tmux: bool) -> Option { + osc52_payload_wrapped(text, if tmux { Wrap::Tmux } else { Wrap::None }) +} + +/// Build the OSC 52 sequence for `text` in the given envelope. +pub fn osc52_payload_wrapped(text: &str, wrap: Wrap) -> Option { let encoded = STANDARD.encode(text.as_bytes()); if encoded.len() > OSC52_MAX_B64 { return None; } let inner = format!("\x1b]52;c;{encoded}\x07"); - if !tmux { - return Some(inner); + Some(match wrap { + Wrap::None => inner, + Wrap::Tmux => format!("\x1bPtmux;{}\x1b\\", inner.replace('\x1b', "\x1b\x1b")), + Wrap::Screen => { + // The sequence is ASCII (ESC, `]52;c;`, base64, BEL), so byte + // chunks never split a character. + let mut out = String::with_capacity(inner.len() + inner.len() / 8 + 8); + for chunk in inner.as_bytes().chunks(SCREEN_DCS_CHUNK) { + out.push_str("\x1bP"); + out.push_str(std::str::from_utf8(chunk).unwrap_or_default()); + out.push_str("\x1b\\"); + } + out + } + }) +} + +/// How a helper wants its input encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Encoding { + Utf8, + /// `clip.exe` decodes plain bytes with the console code page, which turns + /// anything outside ASCII into mojibake; given a UTF-16LE byte-order mark + /// it reads the text as Unicode. + Utf16LeBom, +} + +/// A native clipboard program and how to feed it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Helper { + pub program: &'static str, + pub args: &'static [&'static str], + pub encoding: Encoding, +} + +impl Helper { + /// The exact bytes to write to the helper's stdin for `text`. + pub fn input_bytes(&self, text: &str) -> Vec { + match self.encoding { + Encoding::Utf8 => text.as_bytes().to_vec(), + Encoding::Utf16LeBom => utf16le_with_bom(text), + } } - Some(format!( - "\x1bPtmux;{}\x1b\\", - inner.replace('\x1b', "\x1b\x1b") - )) } -/// The clipboard helper to use on this machine, as (program, args). +/// `text` as UTF-16LE, prefixed with the byte-order mark `FF FE`. +pub fn utf16le_with_bom(text: &str) -> Vec { + let mut out = Vec::with_capacity(2 + text.len() * 2); + out.extend_from_slice(&[0xFF, 0xFE]); + for unit in text.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +/// What the helper choice depends on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct HelperEnv { + pub macos: bool, + /// `TERMUX_VERSION` is set (Android, Termux). + pub termux: bool, + /// `WAYLAND_DISPLAY` is set. + pub wayland: bool, + /// `DISPLAY` is set. + pub x11: bool, + /// Windows Subsystem for Linux. + pub wsl: bool, +} + +impl HelperEnv { + pub fn detect() -> Self { + Self { + macos: cfg!(target_os = "macos"), + termux: std::env::var_os("TERMUX_VERSION").is_some(), + wayland: std::env::var_os("WAYLAND_DISPLAY").is_some(), + x11: std::env::var_os("DISPLAY").is_some(), + wsl: crate::platform::is_wsl(), + } + } +} + +const PBCOPY: Helper = Helper { + program: "pbcopy", + args: &[], + encoding: Encoding::Utf8, +}; +const TERMUX: Helper = Helper { + program: "termux-clipboard-set", + args: &[], + encoding: Encoding::Utf8, +}; +const WL_COPY: Helper = Helper { + program: "wl-copy", + args: &[], + encoding: Encoding::Utf8, +}; +const XCLIP: Helper = Helper { + program: "xclip", + args: &["-selection", "clipboard"], + encoding: Encoding::Utf8, +}; +const XSEL: Helper = Helper { + program: "xsel", + args: &["--clipboard", "--input"], + encoding: Encoding::Utf8, +}; +const CLIP_EXE: Helper = Helper { + program: "clip.exe", + args: &[], + encoding: Encoding::Utf16LeBom, +}; +/// WSL with `clip.exe` missing from PATH (Windows PATH appending turned +/// off): PowerShell, told to read stdin as UTF-8, sets the clipboard. +const POWERSHELL: Helper = Helper { + program: "powershell.exe", + args: &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::InputEncoding = [Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ], + encoding: Encoding::Utf8, +}; + +/// Pick the clipboard helper, given the session and which programs exist. /// /// Ordered by how specific the signal is: a Wayland session that also exports -/// `DISPLAY` (XWayland) should still get `wl-copy`. -fn native_helper() -> Option<(&'static str, &'static [&'static str])> { - let candidates: &[(&str, &[&str], bool)] = &[ - ("pbcopy", &[], cfg!(target_os = "macos")), - ( - "wl-copy", - &[], - std::env::var_os("WAYLAND_DISPLAY").is_some(), - ), - ( - "xclip", - &["-selection", "clipboard"], - std::env::var_os("DISPLAY").is_some(), - ), - ( - "xsel", - &["--clipboard", "--input"], - std::env::var_os("DISPLAY").is_some(), - ), - ("clip.exe", &[], true), +/// `DISPLAY` (XWayland) should still get `wl-copy`, and Termux (which may run +/// an X server) gets its own bridge to the Android clipboard. +pub fn choose_helper(env: HelperEnv, available: &dyn Fn(&str) -> bool) -> Option { + let candidates = [ + (PBCOPY, env.macos), + (TERMUX, env.termux), + (WL_COPY, env.wayland), + (XCLIP, env.x11), + (XSEL, env.x11), + (CLIP_EXE, true), + (POWERSHELL, env.wsl), ]; candidates - .iter() - .find(|(prog, _, applicable)| *applicable && on_path(prog)) - .map(|(prog, args, _)| (*prog, *args)) + .into_iter() + .find(|(h, applicable)| *applicable && available(h.program)) + .map(|(h, _)| h) +} + +/// The clipboard helper to use on this machine. +pub fn native_helper() -> Option { + choose_helper(HelperEnv::detect(), &on_path) } fn on_path(program: &str) -> bool { @@ -132,11 +289,11 @@ fn on_path(program: &str) -> bool { /// resident to *own* the X/Wayland selection, so waiting would hang ink until /// the user copied something else. fn copy_native(text: &str) -> bool { - let Some((prog, args)) = native_helper() else { + let Some(helper) = native_helper() else { return false; }; - let Ok(mut child) = Command::new(prog) - .args(args) + let Ok(mut child) = Command::new(helper.program) + .args(helper.args) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -145,7 +302,7 @@ fn copy_native(text: &str) -> bool { return false; }; let wrote = match child.stdin.take() { - Some(mut stdin) => stdin.write_all(text.as_bytes()).is_ok(), + Some(mut stdin) => stdin.write_all(&helper.input_bytes(text)).is_ok(), None => false, }; // Reap it if it already exited (pbcopy, clip.exe); leave the persistent @@ -240,6 +397,115 @@ mod tests { assert_eq!(String::from_utf8(decoded).unwrap(), "héllo → 世界"); } + #[test] + fn osc52_under_screen_is_chunked_into_dcs_strings() { + let text = "x".repeat(300); + let wrapped = osc52_payload_wrapped(&text, Wrap::Screen).unwrap(); + let inner = osc52_payload_wrapped(&text, Wrap::None).unwrap(); + // Every piece is `ESC P … ESC \` and short enough for screen. + let pieces: Vec<&str> = wrapped.split("\x1b\\").filter(|p| !p.is_empty()).collect(); + assert!(pieces.len() > 1); + let mut rebuilt = String::new(); + for p in &pieces { + let body = p.strip_prefix("\x1bP").expect("DCS introducer"); + assert!(body.len() <= SCREEN_DCS_CHUNK); + rebuilt.push_str(body); + } + // Screen strips the envelopes; what reaches the terminal is the + // original sequence. + assert_eq!(rebuilt, inner); + } + + #[test] + fn wrap_follows_the_multiplexer() { + assert_eq!(Wrap::from_env(true, false, "tmux-256color"), Wrap::Tmux); + assert_eq!( + Wrap::from_env(false, true, "screen.xterm-256color"), + Wrap::Screen + ); + assert_eq!(Wrap::from_env(false, true, "screen"), Wrap::Screen); + // tmux inside screen: tmux is the layer ink talks to. + assert_eq!(Wrap::from_env(true, true, "screen"), Wrap::Tmux); + // STY leaked into a shell that is not under screen any more. + assert_eq!(Wrap::from_env(false, true, "xterm-256color"), Wrap::None); + assert_eq!(Wrap::from_env(false, false, "screen"), Wrap::None); + } + + #[test] + fn utf16le_has_a_bom_and_surrogate_pairs() { + // é (U+00E9), 世 (U+4E16), 😀 (U+1F600 → D83D DE00). + assert_eq!( + utf16le_with_bom("é世😀"), + vec![0xFF, 0xFE, 0xE9, 0x00, 0x16, 0x4E, 0x3D, 0xD8, 0x00, 0xDE] + ); + assert_eq!(utf16le_with_bom(""), vec![0xFF, 0xFE]); + } + + fn only(programs: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |p| programs.contains(&p) + } + + fn pick(env: HelperEnv, programs: &'static [&'static str]) -> Option<&'static str> { + choose_helper(env, &only(programs)).map(|h| h.program) + } + + #[test] + fn helper_choice_per_platform() { + let mac = HelperEnv { + macos: true, + ..Default::default() + }; + assert_eq!(pick(mac, &["pbcopy"]), Some("pbcopy")); + // XWayland exports DISPLAY too; wl-copy still wins. + let wayland = HelperEnv { + wayland: true, + x11: true, + ..Default::default() + }; + assert_eq!(pick(wayland, &["wl-copy", "xclip"]), Some("wl-copy")); + assert_eq!(pick(wayland, &["xsel"]), Some("xsel")); + // A helper that is installed but whose session is absent is skipped. + assert_eq!(pick(HelperEnv::default(), &["xclip", "wl-copy"]), None); + let termux = HelperEnv { + termux: true, + ..Default::default() + }; + assert_eq!( + pick(termux, &["termux-clipboard-set", "xclip"]), + Some("termux-clipboard-set") + ); + // Windows and WSL: clip.exe, with PowerShell only as WSL's fallback. + let wsl = HelperEnv { + wsl: true, + ..Default::default() + }; + assert_eq!(pick(wsl, &["clip.exe", "powershell.exe"]), Some("clip.exe")); + assert_eq!(pick(wsl, &["powershell.exe"]), Some("powershell.exe")); + assert_eq!(pick(HelperEnv::default(), &["powershell.exe"]), None); + assert_eq!(pick(HelperEnv::default(), &["clip.exe"]), Some("clip.exe")); + } + + #[test] + fn helper_input_bytes_match_the_helper() { + let wsl = HelperEnv { + wsl: true, + ..Default::default() + }; + let clip = choose_helper(wsl, &only(&["clip.exe"])).unwrap(); + assert_eq!( + clip.input_bytes("é世😀"), + vec![0xFF, 0xFE, 0xE9, 0x00, 0x16, 0x4E, 0x3D, 0xD8, 0x00, 0xDE] + ); + let ps = choose_helper(wsl, &only(&["powershell.exe"])).unwrap(); + assert_eq!(ps.input_bytes("é"), "é".as_bytes()); + let termux = HelperEnv { + termux: true, + ..Default::default() + }; + let t = choose_helper(termux, &only(&["termux-clipboard-set"])).unwrap(); + assert_eq!(t.input_bytes("世"), "世".as_bytes()); + } + #[test] fn off_writes_nothing() { assert_eq!(copy("anything", ClipboardMode::Off), CopyOutcome::Disabled); diff --git a/src/lib.rs b/src/lib.rs index 5d49d2e..8129849 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod input; pub mod layout; pub mod net; pub mod parser; +pub mod platform; pub mod render; pub mod sanitize; pub mod search; diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..dd4a888 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,74 @@ +//! Facts about the machine and session that more than one module needs: +//! WSL, SSH, multiplexers, and whether the locale is UTF-8. +//! +//! Each check has a pure form taking the inputs explicitly, so the rules are +//! unit-testable without touching the process environment. + +/// Non-empty value of an environment variable. +pub fn env_nonempty(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +/// Running under the Windows Subsystem for Linux? +pub fn is_wsl() -> bool { + if !cfg!(target_os = "linux") { + return false; + } + let release = std::fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default(); + is_wsl_from(env_nonempty("WSL_DISTRO_NAME").is_some(), &release) +} + +/// Pure form of [`is_wsl`]: WSL exports `WSL_DISTRO_NAME`, and its kernels +/// carry `microsoft` in the release string (`…-microsoft-standard-WSL2`). +pub fn is_wsl_from(distro_name_set: bool, kernel_release: &str) -> bool { + distro_name_set || kernel_release.to_ascii_lowercase().contains("microsoft") +} + +/// Inside an SSH session? +pub fn is_ssh() -> bool { + env_nonempty("SSH_CONNECTION").is_some() || env_nonempty("SSH_TTY").is_some() +} + +/// The locale variable that decides the character set, in POSIX precedence +/// (`LC_ALL` > `LC_CTYPE` > `LANG`), as (name, value). +pub fn effective_locale() -> Option<(&'static str, String)> { + ["LC_ALL", "LC_CTYPE", "LANG"] + .into_iter() + .find_map(|k| env_nonempty(k).map(|v| (k, v))) +} + +/// Is the locale's character set UTF-8? `None` when no locale variable is set +/// (then the C locale applies, which is not UTF-8 — but plenty of macOS and +/// container setups leave all three unset while the terminal is UTF-8, so +/// callers treat `None` as "unknown", not as "ASCII"). +pub fn locale_is_utf8() -> Option { + effective_locale().map(|(_, v)| locale_value_is_utf8(&v)) +} + +/// Pure check on one locale value (`en_US.UTF-8`, `C.utf8`, `de_DE@euro`). +pub fn locale_value_is_utf8(value: &str) -> bool { + let v = value.to_ascii_lowercase(); + v.contains("utf-8") || v.contains("utf8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wsl_from_either_signal() { + assert!(is_wsl_from(true, "")); + assert!(is_wsl_from(false, "5.15.153.1-microsoft-standard-WSL2")); + assert!(is_wsl_from(false, "4.4.0-19041-Microsoft")); + assert!(!is_wsl_from(false, "6.8.0-45-generic")); + } + + #[test] + fn utf8_locale_spellings() { + assert!(locale_value_is_utf8("en_US.UTF-8")); + assert!(locale_value_is_utf8("C.utf8")); + assert!(!locale_value_is_utf8("C")); + assert!(!locale_value_is_utf8("POSIX")); + assert!(!locale_value_is_utf8("de_DE.ISO-8859-1")); + } +} diff --git a/src/theme/detect.rs b/src/theme/detect.rs index cd07684..7e2b39c 100644 --- a/src/theme/detect.rs +++ b/src/theme/detect.rs @@ -132,8 +132,7 @@ fn skip_reason_for(term: &str, wt_session: bool, windows: bool) -> Option<&'stat /// time on the rare terminal that answers nothing — but across SSH the round /// trip itself can take longer. fn query_timeout() -> Duration { - let ssh = std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some(); - if ssh { + if crate::platform::is_ssh() { Duration::from_millis(400) } else { Duration::from_millis(100) From 57623e3d492dcd66668c4bc7a847491480fde289 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 13:57:06 +0200 Subject: [PATCH 3/7] feat(cli): --no-mouse, and the file browser honours mouse_capture The file browser always enabled mouse capture, ignoring [behavior] mouse_capture = false (it has no mouse actions, so capture only took the terminal's own selection away). It now follows the same setting as the reader. New --no-mouse flag forces capture off for the session in both; the flag beats the config. --- README.md | 5 ++- src/browser.rs | 12 +++++-- src/cli.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++---- tests/pty.rs | 65 ++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f6df228..3470b4c 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,9 @@ Press `/` to search within a document. Matches highlight inline; press Enter to ink captures the mouse for wheel-scroll, which used to mean giving up your terminal's own text selection. Now it does the job itself, and does it better — it knows where a code block starts and -where ink's own decoration ends. +where ink's own decoration ends. If you'd rather keep your terminal's own selection and link +clicking, run `ink --no-mouse` (one session) or set `mouse_capture = false` (always); both apply to +the reader and the file browser. - **`v`** starts a character-wise selection, **`V`** a line-wise one. Move with `h j k l`, arrows, `w`/`b` (word), `0`/`$` (line ends), `g`/`G` (document ends), `Ctrl+d`/`Ctrl+u` (half page). @@ -381,6 +383,7 @@ Options: --no-pager Never page --plain output, even on a TTY --frontmatter Show YAML/TOML frontmatter --spacing Line spacing: compact, normal, relaxed + --no-mouse Don't capture the mouse (overrides mouse_capture in config) Subcommands: outline Show document heading structure diff --git a/src/browser.rs b/src/browser.rs index 8cbbec8..84edcb1 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -19,7 +19,12 @@ struct FileEntry { /// /// Returns `Some(path)` if the user selected a file, or `None` if they quit. /// Gracefully handles empty directories, permission errors, etc. -pub fn browse(dir: &Path, theme_name: &str) -> Result> { +/// +/// `mouse_capture` follows the same setting as the reader (`--no-mouse`, +/// `[behavior] mouse_capture`); the browser has no mouse actions of its own, +/// but switching capture on here and off in the reader would flip the +/// terminal's selection behaviour between screens. +pub fn browse(dir: &Path, theme_name: &str, mouse_capture: bool) -> Result> { let files = find_markdown_files(dir); if files.is_empty() { eprintln!("ink: no markdown files found in {}", dir.display()); @@ -29,7 +34,10 @@ pub fn browse(dir: &Path, theme_name: &str) -> Result> { crate::app::install_panic_hook(); enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; + if mouse_capture { + execute!(stdout, EnableMouseCapture)?; + } let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; diff --git a/src/cli.rs b/src/cli.rs index 0fc65a0..d9c0a6f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -77,6 +77,12 @@ pub struct Cli { /// Line spacing [default: normal] #[arg(long, value_enum)] pub spacing: Option, + + /// Do not capture the mouse, in the reader or the file browser. Your + /// terminal keeps its own text selection and link clicking; the wheel no + /// longer scrolls inside ink. Overrides `[behavior] mouse_capture` + #[arg(long, global = true)] + pub no_mouse: bool, } /// A parsed `--width` value. @@ -333,11 +339,7 @@ pub fn run() -> Result<()> { image_protocol: cli.image_protocol, frontmatter, spacing, - mouse_capture: user_config - .as_ref() - .and_then(|c| c.behavior.as_ref()) - .and_then(|b| b.mouse_capture) - .unwrap_or(true), + mouse_capture: mouse_capture(cli.no_mouse, &user_config).0, clipboard: user_config .as_ref() .and_then(|c| c.behavior.as_ref()) @@ -386,7 +388,7 @@ pub fn run() -> Result<()> { .unwrap_or(false); loop { - let Some(selected) = browser::browse(&dir, &args.theme)? else { + let Some(selected) = browser::browse(&dir, &args.theme, args.mouse_capture)? else { break; }; let source = std::fs::read_to_string(&selected)?; @@ -552,6 +554,25 @@ fn theme_choice(flag: &str, config: &Option) -> (String, ThemeOr } } +/// Whether to capture the mouse, and what decided it: `--no-mouse` beats +/// `[behavior] mouse_capture`, which beats the default (on). +pub(crate) fn mouse_capture( + no_mouse_flag: bool, + config: &Option, +) -> (bool, &'static str) { + if no_mouse_flag { + return (false, "--no-mouse"); + } + match config + .as_ref() + .and_then(|c| c.behavior.as_ref()) + .and_then(|b| b.mouse_capture) + { + Some(v) => (v, "config behavior.mouse_capture"), + None => (true, "default"), + } +} + fn resolve_width(width: &Option, config: &Option) -> Option { match width { Some(WidthArg::Columns(n)) => Some(*n), @@ -776,3 +797,60 @@ fn read_input(args: &Args) -> Result { Some(input) => read_source(input), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(toml: &str) -> Option { + config::parse_config(toml, "test").config + } + + #[test] + fn no_mouse_flag_parses_before_and_after_a_subcommand() { + assert!( + Cli::try_parse_from(["ink", "--no-mouse", "a.md"]) + .unwrap() + .no_mouse + ); + assert!( + Cli::try_parse_from(["ink", "doctor", "--no-mouse"]) + .unwrap() + .no_mouse + ); + assert!(!Cli::try_parse_from(["ink", "a.md"]).unwrap().no_mouse); + } + + #[test] + fn no_mouse_beats_config_which_beats_the_default() { + let on = cfg("[behavior]\nmouse_capture = true\n"); + let off = cfg("[behavior]\nmouse_capture = false\n"); + assert_eq!(mouse_capture(false, &None), (true, "default")); + assert!(!mouse_capture(false, &off).0); + assert!(mouse_capture(false, &on).0); + assert_eq!(mouse_capture(true, &on), (false, "--no-mouse")); + assert_eq!(mouse_capture(true, &None), (false, "--no-mouse")); + } + + #[test] + fn theme_flag_beats_config_which_beats_auto() { + let c = cfg("theme = \"nord\"\n"); + assert_eq!( + theme_choice("dracula", &c), + ("dracula".into(), ThemeOrigin::Flag) + ); + assert_eq!( + theme_choice("auto", &c), + ("nord".into(), ThemeOrigin::Config) + ); + assert_eq!( + theme_choice("auto", &None), + ("auto".into(), ThemeOrigin::Auto) + ); + let auto = cfg("theme = \"auto\"\n"); + assert_eq!( + theme_choice("auto", &auto), + ("auto".into(), ThemeOrigin::Auto) + ); + } +} diff --git a/tests/pty.rs b/tests/pty.rs index c6da181..e8dc721 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -188,3 +188,68 @@ fn the_reader_uses_the_light_theme_and_leaves_no_reply_on_screen() { String::from_utf8_lossy(&s.output[s.output.len().saturating_sub(400)..]).into_owned(); assert!(contains(&s.output, b"\x1b[?1049l"), "{tail:?}"); } + +/// Quits the reader (`q`) once it has entered the alternate screen, retrying +/// in case a slow start swallowed the key. +fn quit_when_up() -> impl FnMut(&[u8], Duration) -> Option> { + let mut last = Duration::ZERO; + move |out, t| { + if contains(out, b"\x1b[?1049h") && t > last + Duration::from_millis(500) { + last = t; + Some(b"q".to_vec()) + } else { + None + } + } +} + +const MOUSE_ON: [&[u8]; 3] = [b"\x1b[?1000h", b"\x1b[?1002h", b"\x1b[?1006h"]; + +#[test] +fn no_mouse_never_enables_mouse_reporting() { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test.md"); + // Control: by default the reader does capture the mouse. + let s = run_in_pty( + &["--theme", "dark", "--image-protocol", "halfblocks", fixture], + &[], + quit_when_up(), + ); + assert!(MOUSE_ON.iter().all(|seq| contains(&s.output, seq))); + + let s = run_in_pty( + &[ + "--no-mouse", + "--theme", + "dark", + "--image-protocol", + "halfblocks", + fixture, + ], + &[], + quit_when_up(), + ); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + for seq in MOUSE_ON { + assert!( + !contains(&s.output, seq), + "{:?} was written", + String::from_utf8_lossy(seq) + ); + } +} + +#[test] +fn no_mouse_also_applies_to_the_file_browser() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.md"), "# a\n").unwrap(); + let path = dir.path().to_str().unwrap(); + let s = run_in_pty( + &["--no-mouse", "--theme", "dark", path], + &[], + quit_when_up(), + ); + assert!(contains(&s.output, b"\x1b[?1049l"), "browser did not exit"); + for seq in MOUSE_ON { + assert!(!contains(&s.output, seq)); + } +} From 76bbc4abf4a045c94891fd94aed89b013fe76f4c Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 13:58:27 +0200 Subject: [PATCH 4/7] fix(graphics): skip the image-protocol probe where it cannot be answered The ratatui-image probe waits up to ~2 s for terminals that never reply, so every start on them stalled. Skip it (half-blocks) when stdout is not a terminal, TERM is linux or dumb, or TERM_PROGRAM=Apple_Terminal. An explicit --image-protocol kitty|iterm2|sixel still applies, with an assumed cell size. Remove the catch_unwind around the probe: release builds abort on panic, so it never caught anything. --- src/graphics.rs | 70 ++++++++++++++++++++++++++++++++++++++++++------- tests/pty.rs | 25 ++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/graphics.rs b/src/graphics.rs index f84f413..6eb7939 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -57,20 +57,12 @@ impl Graphics { } /// Detect the terminal's image capability. Must be called on a real TTY, - /// before entering the alternate screen. Never panics; returns a - /// half-block capability on any failure or when `choice` is `HalfBlocks`. + /// before entering the alternate screen. Returns a half-block capability + /// on any failure or when `choice` is `HalfBlocks`. pub fn detect(choice: ProtocolChoice) -> Self { if choice == ProtocolChoice::HalfBlocks { return Self::halfblocks(); } - // Querying can fail on pipes, unsupported terminals, or timeout — any - // error means "no graphics", i.e. half-blocks. - let Ok(mut picker) = std::panic::catch_unwind(Picker::from_query_stdio) - .unwrap_or_else(|_| Err(ratatui_image::errors::Errors::NoFontSize)) - else { - return Self::halfblocks(); - }; - // Honor an explicit protocol request if the user forced one. let forced = match choice { ProtocolChoice::Kitty => Some(ProtocolType::Kitty), @@ -78,6 +70,26 @@ impl Graphics { ProtocolChoice::Sixel => Some(ProtocolType::Sixel), _ => None, }; + + // A terminal that never answers makes the query wait out its ~2 s + // timeout at every start, so known non-graphics terminals skip it. + // A forced protocol still applies (with an assumed cell size). + let mut picker = if query_skip_reason().is_some() { + if forced.is_none() { + return Self::halfblocks(); + } + Picker::halfblocks() + } else { + // Querying can fail on pipes, unsupported terminals, or timeout — + // any error means "no graphics", i.e. half-blocks. (No + // catch_unwind: release builds abort on panic, so it could never + // catch anything that matters.) + match Picker::from_query_stdio() { + Ok(p) => p, + Err(_) => return Self::halfblocks(), + } + }; + if let Some(pt) = forced { picker.set_protocol_type(pt); } else { @@ -119,6 +131,33 @@ impl Graphics { } } +/// Why the graphics query is not worth running here, if it is not: the +/// terminal cannot do pixel graphics, or there is no terminal to ask. +pub fn query_skip_reason() -> Option<&'static str> { + use std::io::IsTerminal; + skip_reason_for( + &std::env::var("TERM").unwrap_or_default(), + &std::env::var("TERM_PROGRAM").unwrap_or_default(), + std::io::stdout().is_terminal(), + ) +} + +/// Pure form of [`query_skip_reason`]. +fn skip_reason_for(term: &str, term_program: &str, stdout_tty: bool) -> Option<&'static str> { + if !stdout_tty { + return Some("stdout is not a terminal"); + } + if term == "linux" || term == "dumb" { + return Some("TERM has no graphics protocol"); + } + // Terminal.app speaks none of kitty / iTerm2 / sixel and does not answer + // the probe, so the query would only cost its full timeout. + if term_program == "Apple_Terminal" { + return Some("Terminal.app has no graphics protocol"); + } + None +} + /// What the query said vs. what we should actually use, when the user hasn't /// forced a protocol. /// @@ -248,6 +287,17 @@ mod tests { assert!(!is_iterm_env(None, None)); } + #[test] + fn query_is_skipped_on_terminals_that_cannot_answer() { + assert!(skip_reason_for("xterm-256color", "", false).is_some()); + assert!(skip_reason_for("linux", "", true).is_some()); + assert!(skip_reason_for("dumb", "", true).is_some()); + assert!(skip_reason_for("xterm-256color", "Apple_Terminal", true).is_some()); + assert!(skip_reason_for("xterm-256color", "iTerm.app", true).is_none()); + assert!(skip_reason_for("xterm-kitty", "", true).is_none()); + assert!(skip_reason_for("tmux-256color", "tmux", true).is_none()); + } + #[test] fn halfblocks_capability_is_not_graphical() { let g = Graphics::halfblocks(); diff --git a/tests/pty.rs b/tests/pty.rs index e8dc721..c531c2a 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -253,3 +253,28 @@ fn no_mouse_also_applies_to_the_file_browser() { assert!(!contains(&s.output, seq)); } } + +/// Time from launch until the reader enters the alternate screen, on a +/// terminal that answers no queries at all. +fn startup_time(envs: &[(&str, &str)]) -> Duration { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test.md"); + let mut up_at = None; + let mut quit = quit_when_up(); + run_in_pty(&["--theme", "dark", fixture], envs, |out, t| { + if up_at.is_none() && contains(out, b"\x1b[?1049h") { + up_at = Some(t); + } + quit(out, t) + }); + up_at.expect("reader never started") +} + +#[test] +fn terminals_without_graphics_skip_the_image_query() { + // Terminal.app never answers the graphics probe; asking would stall + // startup for the probe's full ~2 s timeout. + let t = startup_time(&[("TERM_PROGRAM", "Apple_Terminal")]); + assert!(t < Duration::from_millis(1500), "startup took {t:?}"); + let t = startup_time(&[("TERM", "linux")]); + assert!(t < Duration::from_millis(1500), "startup took {t:?}"); +} From 720caf3082056bd360bd7ac5319944704957b1cc Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 14:01:10 +0200 Subject: [PATCH 5/7] feat(doctor): report the whole environment, redacted for issues ink doctor now has [environment] (WSL, SSH, multiplexers, terminal and locale variables, UTF-8 locale, display presence), [colour] (depth and the signal that decided it, whether colour is on and why), [theme] (resolved theme and source, including the OSC 11 colour), [clipboard] (mode, native helper, OSC 52 wrapping), [input] (mouse capture and why) and [config] (path, existence, warnings), next to the existing graphics and decoder sections. Under tmux it shows allow-passthrough. Nothing identifying is printed: display and session variables are presence-only, SSH is yes/no, and the home directory is replaced with ~. Piped output skips terminal queries and says 'not a terminal'. --- README.md | 3 +- src/cli.rs | 53 ++++-- src/doctor.rs | 459 ++++++++++++++++++++++++++++++++++++++-------- src/theme/caps.rs | 85 +++++++-- tests/cli.rs | 2 +- 5 files changed, 492 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 3470b4c..2002bff 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ Subcommands: man Generate a man page config Config helpers (init, path) keybindings Print the active keybinding map + doctor Print a diagnostic report to attach to an issue ``` ## How it compares @@ -424,7 +425,7 @@ Terminal graphics support varies wildly. If an image is blank, garbled, or missi 1. **Try the universal renderer:** `ink --image-protocol halfblocks file.md`. If the image appears as coarse colored blocks, decoding is fine — the problem is your terminal's pixel graphics protocol. 2. **Try a specific protocol:** `--image-protocol iterm2` (iTerm2, WezTerm, VS Code, Warp and friends) or `--image-protocol sixel`. Some terminals advertise protocols they only partially implement — auto-detection already works around the known cases (e.g. iTerm2 claiming kitty support), but new terminal versions ship new quirks. -3. **Collect a diagnostic report:** run `ink doctor` in the affected terminal — it prints your terminal identity, what the graphics negotiation chose, and decoder self-tests. `ink doctor --save ink-doctor.txt` writes it to a file. +3. **Collect a diagnostic report:** run `ink doctor` in the affected terminal — it prints your platform and terminal identity, the colour depth and theme ink picked (and which signal decided each), the clipboard helper and OSC 52 wrapping it would use, mouse capture, config warnings, what the graphics negotiation chose, and decoder self-tests. It leaves out anything that identifies you (no hostnames, user names, IPs or session IDs; your home directory shows as `~`), so it is safe to paste into an issue. `ink doctor --save ink-doctor.txt` writes it to a file. The same report helps with colour, theme and clipboard problems. 4. **Send it to us:** open an [image rendering issue](https://github.com/borghei/ink/issues/new?template=image-rendering.yml) with the report attached. That output usually pinpoints the problem immediately. ## Security model diff --git a/src/cli.rs b/src/cli.rs index d9c0a6f..418f7e9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -151,8 +151,9 @@ pub enum Commands { }, /// Generate a man page (troff, to stdout) Man, - /// Print an image-rendering diagnostic report (terminal identity, - /// graphics-protocol negotiation, decoder self-tests) + /// Print a diagnostic report to attach to an issue: platform, terminal, + /// colour and theme detection, clipboard route, mouse, config problems, + /// graphics-protocol negotiation, decoder self-tests (no personal data) Doctor { /// Also write the report to this file (attach it to a GitHub issue) #[arg(long, value_name = "PATH")] @@ -256,7 +257,19 @@ pub fn run() -> Result<()> { Ok(()) } Commands::Doctor { save } => { - return crate::doctor::run(save.as_deref(), &theme, theme_origin); + let (clipboard, clipboard_source) = clipboard_mode(&user_config); + return crate::doctor::run( + save.as_deref(), + &crate::doctor::Context { + theme: &theme, + theme_origin, + color_choice: cli.color, + clipboard, + clipboard_source, + mouse: mouse_capture(cli.no_mouse, &user_config), + config_warnings: &warnings, + }, + ); } Commands::Keybindings => { print_keybindings(); @@ -340,17 +353,7 @@ pub fn run() -> Result<()> { frontmatter, spacing, mouse_capture: mouse_capture(cli.no_mouse, &user_config).0, - clipboard: user_config - .as_ref() - .and_then(|c| c.behavior.as_ref()) - .and_then(|b| b.clipboard.as_deref()) - .map(|v| { - crate::clipboard::ClipboardMode::parse(v).unwrap_or_else(|| { - eprintln!("ink: unknown clipboard mode '{v}', using 'auto'"); - crate::clipboard::ClipboardMode::Auto - }) - }) - .unwrap_or_default(), + clipboard: clipboard_mode(&user_config).0, }; // Check if input is a directory or no input with a TTY → launch file browser @@ -554,6 +557,28 @@ fn theme_choice(flag: &str, config: &Option) -> (String, ThemeOr } } +/// The configured clipboard mode and where it came from. An unknown value +/// warns (on stderr, before any alternate screen) and falls back to `auto`. +fn clipboard_mode( + config: &Option, +) -> (crate::clipboard::ClipboardMode, &'static str) { + use crate::clipboard::ClipboardMode; + let Some(v) = config + .as_ref() + .and_then(|c| c.behavior.as_ref()) + .and_then(|b| b.clipboard.as_deref()) + else { + return (ClipboardMode::default(), "default"); + }; + match ClipboardMode::parse(v) { + Some(mode) => (mode, "config behavior.clipboard"), + None => { + eprintln!("ink: unknown clipboard mode '{v}', using 'auto'"); + (ClipboardMode::Auto, "config value not recognised; default") + } + } +} + /// Whether to capture the mouse, and what decided it: `--no-mouse` beats /// `[behavior] mouse_capture`, which beats the default (on). pub(crate) fn mouse_capture( diff --git a/src/doctor.rs b/src/doctor.rs index fd751f0..0f71b8b 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,47 +1,139 @@ -//! `ink doctor` — a self-contained diagnostic report for image-rendering -//! issues. When a user says "images don't show", this collects everything -//! needed to debug it remotely: version, platform, terminal identity, the -//! graphics-protocol negotiation result, and decoder self-tests. +//! `ink doctor` — a self-contained diagnostic report. When a user says +//! "images don't show", "the colours are wrong" or "copy does nothing", this +//! collects everything needed to debug it remotely: version, platform, +//! terminal identity, colour and theme decisions, clipboard route, config +//! problems, the graphics-protocol negotiation, and decoder self-tests. //! -//! The protocol query talks to the terminal over stdio, so it only runs when -//! stdout is a real TTY; `--save` additionally writes the report to a file -//! the user can attach to a GitHub issue. +//! The report is meant to be pasted into a public issue, so it never prints +//! anything that identifies a person or a session: no hostnames, usernames, +//! IP addresses, session IDs or socket paths, and the home directory is +//! shown as `~`. +//! +//! Terminal queries only run when stdout is a real TTY; piped output +//! (`ink doctor | pbcopy`) says "not a terminal" for those fields. `--save` +//! additionally writes the report to a file the user can attach to an issue. +use crate::cli::ThemeOrigin; +use crate::clipboard::ClipboardMode; +use crate::theme::caps::ColorChoice; use std::fmt::Write as _; use std::io::IsTerminal; +/// What the CLI resolved before handing over to the doctor. +pub struct Context<'a> { + pub theme: &'a str, + pub theme_origin: ThemeOrigin, + pub color_choice: ColorChoice, + pub clipboard: ClipboardMode, + /// Where the clipboard mode came from ("config" / "default"). + pub clipboard_source: &'static str, + /// Mouse capture on/off and what decided it. + pub mouse: (bool, &'static str), + pub config_warnings: &'a [String], +} + /// Build the report and print it; optionally save to `path`. -pub fn run( - save: Option<&std::path::Path>, - theme: &str, - origin: crate::cli::ThemeOrigin, -) -> anyhow::Result<()> { - let mut report = build_report(); - report.push_str(&theme_section(theme, origin)); +pub fn run(save: Option<&std::path::Path>, ctx: &Context) -> anyhow::Result<()> { + let report = build_report(ctx); print!("{report}"); if let Some(path) = save { std::fs::write(path, &report)?; println!("\nReport saved to {}.", path.display()); - println!("Attach it to an issue: https://github.com/borghei/ink/issues/new?template=image-rendering.yml"); + println!("Attach it to an issue: https://github.com/borghei/ink/issues/new/choose"); } Ok(()) } -fn build_report() -> String { +fn build_report(ctx: &Context) -> String { + let tty = std::io::stdout().is_terminal(); + let home = dirs::home_dir().map(|h| h.display().to_string()); let mut r = String::new(); - let _ = writeln!(r, "ink doctor — image rendering diagnostics"); - let _ = writeln!(r, "========================================="); + let _ = writeln!(r, "ink doctor — environment diagnostics"); + let _ = writeln!(r, "===================================="); let _ = writeln!(r, "ink version : {}", env!("CARGO_PKG_VERSION")); let _ = writeln!( r, - "platform : {} / {}", + "platform : {} / {} ({})", std::env::consts::OS, - std::env::consts::ARCH + std::env::consts::ARCH, + libc_flavour() ); + environment_section(&mut r); + terminal_section(&mut r, tty); + colour_section(&mut r, ctx, tty); + theme_section(&mut r, ctx); + clipboard_section(&mut r, ctx); + input_section(&mut r, ctx); + config_section(&mut r, ctx, home.as_deref()); + graphics_section(&mut r, tty); + decoder_section(&mut r); + + let _ = writeln!( + r, + "\nIf images are blank or wrong: try `ink --image-protocol halfblocks `.\n\ + If half-blocks work but pixels don't, your terminal's graphics protocol\n\ + is the problem — please open an issue with this report attached:\n\ + https://github.com/borghei/ink/issues/new?template=image-rendering.yml" + ); + match home { + Some(h) => redact_home(&r, &h), + None => r, + } +} + +/// Which C library the binary was built against. +fn libc_flavour() -> &'static str { + if cfg!(target_env = "musl") { + "musl" + } else if cfg!(target_env = "gnu") { + "glibc" + } else if cfg!(target_env = "msvc") { + "msvc" + } else { + "system libc" + } +} + +/// `true`/`false` as `yes`/`no`. +fn yn(b: bool) -> &'static str { + if b { + "yes" + } else { + "no" + } +} + +fn set_or_unset(var: &str) -> &'static str { + if std::env::var_os(var).is_some_and(|v| !v.is_empty()) { + "(set)" + } else { + "(unset)" + } +} + +fn environment_section(r: &mut String) { let _ = writeln!(r, "\n[environment]"); - // Values that identify the terminal; session IDs are reported only as - // set/unset (they can encode window titles or paths). + let _ = writeln!(r, "{:<21}= {}", "WSL", yn(crate::platform::is_wsl())); + // Only whether this is SSH: the connection string holds both IPs. + let _ = writeln!( + r, + "{:<21}= {}", + "SSH session", + yn(crate::platform::is_ssh()) + ); + let mux: Vec<&str> = [("TMUX", "tmux"), ("STY", "screen"), ("ZELLIJ", "zellij")] + .into_iter() + .filter(|(var, _)| std::env::var_os(var).is_some()) + .map(|(_, name)| name) + .collect(); + let mux = if mux.is_empty() { + "none".to_string() + } else { + mux.join(", ") + }; + let _ = writeln!(r, "{:<21}= {mux}", "multiplexer"); + // Values that identify the terminal program, not the person. for var in [ "TERM", "TERM_PROGRAM", @@ -50,20 +142,47 @@ fn build_report() -> String { "COLORTERM", "KONSOLE_VERSION", "NO_COLOR", + "LANG", + "LC_ALL", + "LC_CTYPE", ] { let _ = writeln!(r, "{var:<21}= {}", fmt_env(var)); } + let utf8 = match crate::platform::locale_is_utf8() { + Some(true) => "yes".to_string(), + Some(false) => "no".to_string(), + None => "unknown (no LC_ALL / LC_CTYPE / LANG)".to_string(), + }; + let _ = writeln!(r, "{:<21}= {utf8}", "locale is UTF-8"); + // Presence only: display names and session IDs can encode hostnames, + // window titles or paths. for var in [ + "WAYLAND_DISPLAY", + "DISPLAY", "TMUX", + "STY", + "WT_SESSION", "KITTY_WINDOW_ID", "WEZTERM_EXECUTABLE", "ITERM_SESSION_ID", + "TERMUX_VERSION", ] { - let set = std::env::var_os(var).is_some_and(|v| !v.is_empty()); - let _ = writeln!(r, "{var:<21}= {}", if set { "(set)" } else { "(unset)" }); + let _ = writeln!(r, "{var:<21}= {}", set_or_unset(var)); } +} +fn terminal_section(r: &mut String, tty: bool) { let _ = writeln!(r, "\n[terminal]"); + let _ = writeln!(r, "stdout is a TTY = {tty}"); + let _ = writeln!( + r, + "stdin is a TTY = {}", + std::io::stdin().is_terminal() + ); + if !tty { + let _ = writeln!(r, "size = not a terminal"); + return; + } match crossterm::terminal::size() { Ok((w, h)) => { let _ = writeln!(r, "size = {w}x{h} cells"); @@ -72,69 +191,33 @@ fn build_report() -> String { let _ = writeln!(r, "size = unavailable ({e})"); } } +} + +fn colour_section(r: &mut String, ctx: &Context, tty: bool) { + use crate::theme::caps; + let _ = writeln!(r, "\n[colour]"); + let (level, signal) = caps::depth_report(); + let _ = writeln!(r, "depth = {level} (decided by {signal})"); + // The reader always runs on a terminal; --plain follows stdout. + let (tui, tui_why) = caps::color_enabled_why(ctx.color_choice, true); let _ = writeln!( r, - "stdout is a TTY = {}", - std::io::stdout().is_terminal() + "reader colours = {} ({tui_why})", + if tui { "on" } else { "off" } ); - - let _ = writeln!(r, "\n[graphics protocol]"); - if std::io::stdout().is_terminal() { - match ratatui_image::picker::Picker::from_query_stdio() { - Ok(p) => { - let detected = p.protocol_type(); - let _ = writeln!(r, "query result = {detected:?}"); - let _ = writeln!( - r, - "font size = {}x{} px", - p.font_size().width, - p.font_size().height - ); - let chosen = crate::graphics::auto_protocol_for_report(detected); - let _ = writeln!(r, "ink will use = {chosen:?}"); - if chosen != detected { - let _ = writeln!( - r, - " (overridden: this terminal's {detected:?} support is known-incomplete)" - ); - } - } - Err(e) => { - let _ = writeln!( - r, - "query result = failed ({e}) — ink falls back to half-blocks" - ); - } - } - } else { - let _ = writeln!( - r, - "query result = skipped (stdout is not a TTY; run `ink doctor` directly in your terminal)" - ); - } - - let _ = writeln!(r, "\n[decoder self-test]"); - let svg = br##""##; - let _ = writeln!(r, "svg rasterize = {}", self_test_svg(svg)); - let _ = writeln!(r, "png decode = {}", self_test_png()); - + let (plain, plain_why) = caps::color_enabled_why(ctx.color_choice, tty); let _ = writeln!( r, - "\nIf images are blank or wrong: try `ink --image-protocol halfblocks `.\n\ - If half-blocks work but pixels don't, your terminal's graphics protocol\n\ - is the problem — please open an issue with this report attached:\n\ - https://github.com/borghei/ink/issues/new?template=image-rendering.yml" + "--plain colours here = {} ({plain_why})", + if plain { "on" } else { "off" } ); - r } -/// `[theme]`: the theme in use and why. -fn theme_section(theme: &str, origin: crate::cli::ThemeOrigin) -> String { - use crate::cli::ThemeOrigin; +fn theme_section(r: &mut String, ctx: &Context) { use crate::theme::detect::BackgroundSource; - let mut r = String::new(); let _ = writeln!(r, "\n[theme]"); - match origin { + let theme = ctx.theme; + match ctx.theme_origin { ThemeOrigin::Flag => { let _ = writeln!(r, "theme = {theme} (from --theme)"); } @@ -156,13 +239,157 @@ fn theme_section(theme: &str, origin: crate::cli::ThemeOrigin) -> String { let _ = writeln!(r, "background query = {}", bg.query); } } - r +} + +fn clipboard_section(r: &mut String, ctx: &Context) { + use crate::clipboard::{self, Wrap}; + let _ = writeln!(r, "\n[clipboard]"); + let _ = writeln!( + r, + "mode = {:?} ({})", + ctx.clipboard, ctx.clipboard_source + ); + let helper = clipboard::native_helper() + .map(|h| h.program.to_string()) + .unwrap_or_else(|| "none found".into()); + let _ = writeln!(r, "native helper = {helper}"); + let wrap = match Wrap::detect() { + Wrap::None => "none", + Wrap::Tmux => "tmux passthrough", + Wrap::Screen => "GNU screen DCS chunks", + }; + let _ = writeln!(r, "OSC 52 wrapping = {wrap}"); +} + +fn input_section(r: &mut String, ctx: &Context) { + let _ = writeln!(r, "\n[input]"); + let (on, why) = ctx.mouse; + let _ = writeln!( + r, + "mouse capture = {} ({why})", + if on { "on" } else { "off" } + ); +} + +fn config_section(r: &mut String, ctx: &Context, home: Option<&str>) { + let _ = writeln!(r, "\n[config]"); + match crate::config::config_path() { + Some(p) => { + let shown = p.display().to_string(); + let shown = home.map_or(shown.clone(), |h| redact_home(&shown, h)); + let _ = writeln!(r, "path = {shown}"); + let _ = writeln!(r, "exists = {}", yn(p.is_file())); + } + None => { + let _ = writeln!( + r, + "path = (no config directory on this platform)" + ); + } + } + if ctx.config_warnings.is_empty() { + let _ = writeln!(r, "warnings = none"); + } else { + for w in ctx.config_warnings { + let _ = writeln!(r, "warning = {w}"); + } + } +} + +fn graphics_section(r: &mut String, tty: bool) { + let _ = writeln!(r, "\n[graphics protocol]"); + if std::env::var_os("TMUX").is_some() { + let _ = writeln!( + r, + "tmux allow-passthrough = {}", + tmux_allow_passthrough().unwrap_or_else(|| "unknown".into()) + ); + } + if !tty { + let _ = writeln!( + r, + "query result = skipped (not a terminal; run `ink doctor` directly in your terminal)" + ); + return; + } + if let Some(why) = crate::graphics::query_skip_reason() { + let _ = writeln!( + r, + "query result = skipped ({why}) — ink uses half-blocks unless --image-protocol forces one" + ); + return; + } + match ratatui_image::picker::Picker::from_query_stdio() { + Ok(p) => { + let detected = p.protocol_type(); + let _ = writeln!(r, "query result = {detected:?}"); + let _ = writeln!( + r, + "font size = {}x{} px", + p.font_size().width, + p.font_size().height + ); + let chosen = crate::graphics::auto_protocol_for_report(detected); + let _ = writeln!(r, "ink will use = {chosen:?}"); + if chosen != detected { + let _ = writeln!( + r, + " (overridden: this terminal's {detected:?} support is known-incomplete)" + ); + } + } + Err(e) => { + let _ = writeln!( + r, + "query result = failed ({e}) — ink falls back to half-blocks" + ); + } + } +} + +/// tmux's `allow-passthrough` for this pane. The image probe switches it on +/// under tmux (images need it); report what it is rather than change it. +fn tmux_allow_passthrough() -> Option { + let out = std::process::Command::new("tmux") + .args(["show", "-p", "-v", "allow-passthrough"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if out.status.success() { + Some(if v.is_empty() { + "off (unset)".into() + } else { + v + }) + } else { + None + } +} + +fn decoder_section(r: &mut String) { + let _ = writeln!(r, "\n[decoder self-test]"); + let svg = br##""##; + let _ = writeln!(r, "svg rasterize = {}", self_test_svg(svg)); + let _ = writeln!(r, "png decode = {}", self_test_png()); } fn fmt_env(var: &str) -> String { std::env::var(var).unwrap_or_else(|_| "(unset)".into()) } +/// Replace every occurrence of the home directory with `~`, so paths in the +/// report do not carry the user name. +fn redact_home(text: &str, home: &str) -> String { + let home = home.trim_end_matches(['/', '\\']); + // A bare `/` (or empty) home would rewrite every path; leave it alone. + if home.len() < 2 { + return text.to_string(); + } + text.replace(home, "~") +} + fn self_test_svg(svg: &[u8]) -> &'static str { match crate::image::self_test_rasterize(svg) { true => "OK", @@ -184,3 +411,79 @@ fn self_test_png() -> &'static str { false => "FAILED", } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(warnings: &[String]) -> Context<'_> { + Context { + theme: "dracula", + theme_origin: ThemeOrigin::Flag, + color_choice: ColorChoice::Auto, + clipboard: ClipboardMode::Auto, + clipboard_source: "default", + mouse: (false, "--no-mouse"), + config_warnings: warnings, + } + } + + #[test] + fn report_has_every_section() { + let report = build_report(&ctx(&[])); + for header in [ + "[environment]", + "[terminal]", + "[colour]", + "[theme]", + "[clipboard]", + "[input]", + "[config]", + "[graphics protocol]", + "[decoder self-test]", + ] { + assert!(report.contains(header), "missing {header}:\n{report}"); + } + assert!(report.contains("mouse capture = off (--no-mouse)")); + assert!(report.contains("dracula (from --theme)")); + } + + #[test] + fn home_directory_is_redacted() { + assert_eq!( + redact_home("path = /home/alice/.config/ink/config.toml", "/home/alice"), + "path = ~/.config/ink/config.toml" + ); + assert_eq!( + redact_home(r"C:\Users\alice\AppData\Roaming\ink", r"C:\Users\alice\"), + r"~\AppData\Roaming\ink" + ); + // A degenerate home must not mangle every path. + assert_eq!(redact_home("/etc/x", "/"), "/etc/x"); + } + + #[test] + fn config_warnings_are_reported_with_home_redacted() { + let Some(home) = dirs::home_dir() else { + return; + }; + let warning = format!( + "ink: config error in {}/.config/ink/config.toml: bad", + home.display() + ); + let report = build_report(&ctx(std::slice::from_ref(&warning))); + assert!(report.contains("config error in ~/.config/ink/config.toml: bad")); + assert!(!report.contains(&home.display().to_string())); + } + + #[test] + fn piped_report_does_not_query_the_terminal() { + // `cargo test` captures stdout, so this is the non-TTY path. + if std::io::stdout().is_terminal() { + return; + } + let report = build_report(&ctx(&[])); + assert!(report.contains("size = not a terminal")); + assert!(report.contains("skipped (not a terminal")); + } +} diff --git a/src/theme/caps.rs b/src/theme/caps.rs index 3aac40f..bdf9de2 100644 --- a/src/theme/caps.rs +++ b/src/theme/caps.rs @@ -54,14 +54,45 @@ impl TermCaps { /// Pure truecolor detection. Windows Terminal (`WT_SESSION`) and several /// common terminals support 24-bit color without setting `COLORTERM`. fn is_truecolor(colorterm: &str, term: &str, term_program: &str, wt_session: bool) -> bool { - colorterm.eq_ignore_ascii_case("truecolor") - || colorterm.eq_ignore_ascii_case("24bit") - || term.contains("truecolor") - || term.contains("direct") - || term.contains("kitty") - || term.contains("alacritty") - || wt_session - || matches!(term_program, "vscode" | "ghostty" | "WezTerm" | "iTerm.app") + truecolor_signal(colorterm, term, term_program, wt_session).is_some() +} + +/// The environment signal that advertises truecolor, for `ink doctor`. +fn truecolor_signal( + colorterm: &str, + term: &str, + term_program: &str, + wt_session: bool, +) -> Option { + if colorterm.eq_ignore_ascii_case("truecolor") || colorterm.eq_ignore_ascii_case("24bit") { + return Some(format!("COLORTERM={colorterm}")); + } + if ["truecolor", "direct", "kitty", "alacritty"] + .iter() + .any(|t| term.contains(t)) + { + return Some(format!("TERM={term}")); + } + if wt_session { + return Some("WT_SESSION (Windows Terminal)".into()); + } + if matches!(term_program, "vscode" | "ghostty" | "WezTerm" | "iTerm.app") { + return Some(format!("TERM_PROGRAM={term_program}")); + } + None +} + +/// The colour depth ink renders at and the signal that decided it, for +/// `ink doctor`. +pub fn depth_report() -> (&'static str, String) { + let colorterm = std::env::var("COLORTERM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let wt = std::env::var_os("WT_SESSION").is_some(); + match truecolor_signal(&colorterm, &term, &term_program, wt) { + Some(signal) => ("truecolour (24-bit)", signal), + None => ("256 colours", format!("TERM={term}; no truecolour signal")), + } } /// The `--color` flag. @@ -79,7 +110,12 @@ pub enum ColorChoice { /// Should non-TUI output carry escape sequences? See the module docs for /// the precedence. pub fn color_enabled(choice: ColorChoice, stdout_tty: bool) -> bool { - resolve_color( + color_enabled_why(choice, stdout_tty).0 +} + +/// [`color_enabled`] plus the signal that decided it (for `ink doctor`). +pub fn color_enabled_why(choice: ColorChoice, stdout_tty: bool) -> (bool, &'static str) { + resolve_color_why( choice, &env_set, std::env::var("TERM").unwrap_or_default().as_str(), @@ -88,27 +124,44 @@ pub fn color_enabled(choice: ColorChoice, stdout_tty: bool) -> bool { } /// Pure form of [`color_enabled`]: `env` returns a variable's non-empty value. +#[cfg(test)] fn resolve_color( choice: ColorChoice, env: &dyn Fn(&str) -> Option, term: &str, stdout_tty: bool, ) -> bool { + resolve_color_why(choice, env, term, stdout_tty).0 +} + +fn resolve_color_why( + choice: ColorChoice, + env: &dyn Fn(&str) -> Option, + term: &str, + stdout_tty: bool, +) -> (bool, &'static str) { match choice { - ColorChoice::Always => return true, - ColorChoice::Never => return false, + ColorChoice::Always => return (true, "--color=always"), + ColorChoice::Never => return (false, "--color=never"), ColorChoice::Auto => {} } if env("NO_COLOR").is_some() { - return false; + return (false, "NO_COLOR"); } - if env("CLICOLOR_FORCE").is_some_and(|v| v != "0") || env("FORCE_COLOR").is_some() { - return true; + if env("CLICOLOR_FORCE").is_some_and(|v| v != "0") { + return (true, "CLICOLOR_FORCE"); + } + if env("FORCE_COLOR").is_some() { + return (true, "FORCE_COLOR"); } if term == "dumb" { - return false; + return (false, "TERM=dumb"); + } + if stdout_tty { + (true, "stdout is a terminal") + } else { + (false, "stdout is not a terminal") } - stdout_tty } /// Cached capabilities for the current process. diff --git a/tests/cli.rs b/tests/cli.rs index 6cb21c4..dc95e00 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -189,7 +189,7 @@ fn doctor_save_writes_report_file() { .assert() .success(); let saved = std::fs::read_to_string(&path).unwrap(); - assert!(saved.contains("ink doctor — image rendering diagnostics")); + assert!(saved.contains("ink doctor — environment diagnostics")); } #[test] From 2e1fc1191f816d95a8e19cac6026bd4b44ea0965 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 14:08:21 +0200 Subject: [PATCH 6/7] feat(color): 16-colour tier, and a colourless reader for --color=never / NO_COLOR Colour depth is now truecolour, 256 or ANSI 16. TERM=linux, xterm, vt100, ansi, screen, cygwin (and any TERM without 256color and no other colour signal) get the 16 ANSI colours, as named colours so the terminal's own scheme applies; *-256color terminals keep exactly the old behaviour, and tmux, a non-truecolor COLORTERM, Terminal.app or an unset TERM stay at 256. Pastel theme colours keep their hue instead of collapsing to grey. Wired through the reader and --plain (30-37/90-97 SGR codes). The reader used to ignore --color=never and NO_COLOR. It now draws with the terminal's default colours and attributes only, with selections and code-block hint labels in reverse video. The unused adapt() helper is gone. --- README.md | 2 + src/cli.rs | 7 +- src/render/mod.rs | 18 +- src/render/plain.rs | 21 +- src/theme/caps.rs | 478 +++++++++++++++++++++++++++++++++++++++----- src/theme/mod.rs | 22 +- tests/cli.rs | 44 ++++ tests/pty.rs | 101 ++++++++++ 8 files changed, 612 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 2002bff..123a002 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, and Solarized. Pre With no theme set, ink asks the terminal for its background colour (an OSC 11 query, answered by nearly every modern terminal and passed through by tmux) and picks Light or Dark to match, falling back to `COLORFGBG` and then Dark. An explicit `--theme` or `theme =` in the config always wins, and never triggers the query. +Themes adapt to what the terminal can show: 24-bit colour where it is advertised, the 256-colour palette on `*-256color` terminals, and the 16 ANSI colours on the Linux console and other 16-colour terminals (`TERM=linux`, `xterm`, `vt100`, `screen`, …) — there ink uses your terminal's own palette, so your colour scheme applies. With `--color=never` or `NO_COLOR` the reader draws without colour at all, using bold, underline and reverse video for headings, links, search hits and selections. + ### Math and emoji Inline `$E=mc^2$` and block `$$...$$` math render in code style, and `:emoji:` shortcodes resolve to their glyph (`:rocket:` → 🚀). diff --git a/src/cli.rs b/src/cli.rs index 418f7e9..db01e91 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,7 +38,8 @@ pub struct Cli { /// When to emit color and hyperlinks in --plain and diff output. /// `auto`: only when stdout is a terminal, honoring NO_COLOR, - /// CLICOLOR_FORCE / FORCE_COLOR and TERM=dumb + /// CLICOLOR_FORCE / FORCE_COLOR and TERM=dumb. `never` (or NO_COLOR) also + /// draws the reader without colour #[arg(long, value_enum, value_name = "WHEN", default_value_t, global = true)] pub color: theme::caps::ColorChoice, @@ -223,6 +224,10 @@ pub fn run() -> Result<()> { // force variables, TERM=dumb, and finally whether stdout is a terminal. let stdout_tty = std::io::stdout().is_terminal(); let color = theme::caps::color_enabled(cli.color, stdout_tty); + // The reader always draws on a terminal, so it asks the same question as + // if stdout were one: `--color=never`, NO_COLOR and TERM=dumb turn its + // colours off (attributes only). + theme::caps::set_tui_level(theme::caps::color_enabled(cli.color, true)); let (theme, theme_origin) = theme_choice(&cli.theme, &user_config); // `auto` asks the terminal for its background colour (OSC 11) once, now, diff --git a/src/render/mod.rs b/src/render/mod.rs index 4136567..0b879b9 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -239,10 +239,15 @@ pub fn render_document_with_search( let width = plain.get(abs).map(|p| crate::selection::line_width(p)); match (width, sel.highlight_span(abs, width.unwrap_or(0))) { (Some(_), Some((from, to))) => { - let (bg, fg) = t.colors.selection(); - let style = Style::default() - .bg(theme::hex_to_color(&bg)) - .fg(theme::hex_to_color(&fg)); + let style = if theme::colorless() { + // No colours to paint with: reverse video. + Style::default().add_modifier(Modifier::REVERSED) + } else { + let (bg, fg) = t.colors.selection(); + Style::default() + .bg(theme::hex_to_color(&bg)) + .fg(theme::hex_to_color(&fg)) + }; restyle_columns(&line, from, to, style) } _ => line, @@ -552,7 +557,10 @@ pub fn render_code_hints( .as_ref() .map(|b| theme::hex_to_color(b)) .unwrap_or_else(|| theme::hex_to_color(&t.colors.fg)); - let style = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD); + let mut style = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD); + if theme::colorless() { + style = style.add_modifier(Modifier::REVERSED); + } for (label, row, col) in hints { if *row >= area.height { diff --git a/src/render/plain.rs b/src/render/plain.rs index 42986b5..670f7e7 100644 --- a/src/render/plain.rs +++ b/src/render/plain.rs @@ -49,7 +49,7 @@ pub fn render_plain_with_color(source: &str, args: &Args, color: bool) -> Result ) .lines; - let caps = theme::caps::caps(); + let depth = theme::caps::caps().depth; let mut output = String::new(); for line in &styled_lines { if !color { @@ -78,10 +78,10 @@ pub fn render_plain_with_color(source: &str, args: &Args, color: bool) -> Result } if span.style.fg.is_some() || span.style.bg.is_some() { if let Some(ref fg) = span.style.fg { - output.push_str(&sgr_color(theme::hex_to_rgb(fg), true, caps.truecolor)); + output.push_str(&sgr_color(theme::hex_to_rgb(fg), true, depth)); } if let Some(ref bg) = span.style.bg { - output.push_str(&sgr_color(theme::hex_to_rgb(bg), false, caps.truecolor)); + output.push_str(&sgr_color(theme::hex_to_rgb(bg), false, depth)); } } if !codes.is_empty() { @@ -122,14 +122,9 @@ pub fn render_plain_with_color(source: &str, args: &Args, color: bool) -> Result Ok(output) } -/// Build an SGR color escape: 24-bit when `truecolor`, else the 256-color -/// cube. `fg` selects foreground (38) vs background (48). -fn sgr_color((r, g, b): (u8, u8, u8), fg: bool, truecolor: bool) -> String { - let sel = if fg { 38 } else { 48 }; - if truecolor { - format!("\x1b[{sel};2;{r};{g};{b}m") - } else { - let idx = theme::caps::rgb_to_256(r, g, b); - format!("\x1b[{sel};5;{idx}m") - } +/// Build an SGR color escape at the terminal's depth: 24-bit, the 256-color +/// cube, or one of the 16 ANSI colours. `fg` selects foreground vs +/// background. +fn sgr_color(rgb: (u8, u8, u8), fg: bool, depth: theme::caps::ColorLevel) -> String { + format!("\x1b[{}m", theme::caps::sgr_params(rgb, fg, depth)) } diff --git a/src/theme/caps.rs b/src/theme/caps.rs index bdf9de2..8772d51 100644 --- a/src/theme/caps.rs +++ b/src/theme/caps.rs @@ -1,30 +1,60 @@ //! Terminal color capabilities, detected once from the environment. //! -//! Honors the `NO_COLOR` convention (https://no-color.org/) and downgrades -//! 24-bit RGB to the 256-color cube when the terminal doesn't advertise -//! truecolor, so themes stay legible on 256-color terminals. +//! Two independent questions: //! -//! [`color_enabled`] decides whether non-TUI output (`--plain`, `ink diff`) -//! carries escape sequences at all, with this precedence: +//! * **Depth** — how many colours the terminal can show: 24-bit, the +//! 256-colour palette, or the 16 ANSI colours ([`detect_depth`]). Theme +//! colours are RGB; below truecolor they are mapped to the nearest palette +//! entry. In 16-colour mode they become the *named* ANSI colours, so the +//! user's own terminal scheme decides the exact shades. +//! * **Enabled** — whether to emit colour at all. [`color_enabled`] decides, +//! for non-TUI output (`--plain`, `ink diff`), with this precedence: //! -//! 1. an explicit `--color always|never` beats everything; -//! 2. `NO_COLOR` (non-empty) turns color off; -//! 3. `CLICOLOR_FORCE` (non-empty, not `0`) or `FORCE_COLOR` (non-empty) -//! turns it on; -//! 4. `TERM=dumb` turns it off; -//! 5. otherwise color is on only when stdout is a terminal. +//! 1. an explicit `--color always|never` beats everything; +//! 2. `NO_COLOR` (non-empty) turns color off; +//! 3. `CLICOLOR_FORCE` (non-empty, not `0`) or `FORCE_COLOR` (non-empty) +//! turns it on; +//! 4. `TERM=dumb` turns it off; +//! 5. otherwise color is on only when stdout is a terminal. +//! +//! The reader asks the same question as if stdout were a terminal (it +//! always is); with colour off it draws with attributes only — bold, +//! underline, reverse video — see [`set_tui_level`]. use ratatui::style::Color; use std::sync::OnceLock; +/// How much colour to emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ColorLevel { + /// No colour: attributes only. + None, + /// The 16 ANSI colours (`TERM=linux`, `xterm`, `vt100`, …). + Ansi16, + /// The xterm 256-colour palette. + Ansi256, + /// 24-bit RGB. + TrueColor, +} + +impl ColorLevel { + /// Human-readable name for `ink doctor`. + pub fn describe(self) -> &'static str { + match self { + ColorLevel::None => "none", + ColorLevel::Ansi16 => "16 colours (ANSI)", + ColorLevel::Ansi256 => "256 colours", + ColorLevel::TrueColor => "truecolour (24-bit)", + } + } +} + #[derive(Debug, Clone, Copy)] pub struct TermCaps { - /// `NO_COLOR` was set to a non-empty value — strip color, keep text - /// attributes (TUI). + /// `NO_COLOR` was set to a non-empty value. pub no_color: bool, - /// Terminal advertises 24-bit color (`COLORTERM=truecolor|24bit`, a - /// known-truecolor `TERM`, Windows Terminal, or a known `TERM_PROGRAM`). - pub truecolor: bool, + /// What the terminal can display (never [`ColorLevel::None`]). + pub depth: ColorLevel, } /// Non-empty value of an environment variable. @@ -34,30 +64,126 @@ fn env_set(name: &str) -> Option { impl TermCaps { fn detect() -> Self { - let no_color = env_set("NO_COLOR").is_some(); - let colorterm = std::env::var("COLORTERM").unwrap_or_default(); - let term = std::env::var("TERM").unwrap_or_default(); - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let truecolor = is_truecolor( - &colorterm, - &term, - &term_program, - std::env::var_os("WT_SESSION").is_some(), - ); Self { - no_color, - truecolor, + no_color: env_set("NO_COLOR").is_some(), + depth: depth_from_env().0, } } } +fn depth_from_env() -> (ColorLevel, String) { + detect_depth(&DepthEnv { + colorterm: &std::env::var("COLORTERM").unwrap_or_default(), + term: &std::env::var("TERM").unwrap_or_default(), + term_program: &std::env::var("TERM_PROGRAM").unwrap_or_default(), + wt_session: std::env::var_os("WT_SESSION").is_some(), + tmux: std::env::var_os("TMUX").is_some(), + }) +} + +/// The inputs to [`detect_depth`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct DepthEnv<'a> { + pub colorterm: &'a str, + pub term: &'a str, + pub term_program: &'a str, + pub wt_session: bool, + pub tmux: bool, +} + +/// `TERM` values that mean 16 colours (or fewer) whatever else is set. +const ANSI16_TERMS: &[&str] = &[ + "linux", + "xterm", + "xterm-color", + "vt100", + "vt102", + "vt220", + "ansi", + "screen", + "cygwin", + "dumb", + "rxvt", + "rxvt-unicode", + "eterm", + "cons25", +]; + +/// `TERM` values without `256color` in the name whose terminals still do 256. +const BARE_256_TERMS: &[&str] = &[ + "tmux", + "foot", + "contour", + "rio", + "putty", + "konsole", + "mintty", + "st", + "ms-terminal", +]; + +/// Pure colour-depth detection, as (level, the signal that decided it). +/// +/// Conservative where it matters: almost every modern emulator reports +/// `xterm-256color`, which stays at 256; only a `TERM` that names a 16-colour +/// terminal, or an unknown one with no other colour signal, drops to 16. +pub fn detect_depth(env: &DepthEnv) -> (ColorLevel, String) { + let DepthEnv { + colorterm, + term, + term_program, + wt_session, + tmux, + } = *env; + if let Some(signal) = truecolor_signal(colorterm, term, term_program, wt_session) { + return (ColorLevel::TrueColor, signal); + } + if term.contains("256color") || term.contains("256-color") { + return (ColorLevel::Ansi256, format!("TERM={term}")); + } + // The Linux console can't do more, whatever the variables claim. + if term == "linux" { + return (ColorLevel::Ansi16, "TERM=linux".into()); + } + // tmux renders 256-colour output for its outer terminal even when the + // pane's TERM is plain `screen`. + if tmux { + return (ColorLevel::Ansi256, "TMUX (tmux re-renders colour)".into()); + } + // A non-truecolor COLORTERM (`gnome-terminal`, `rxvt-xpm`, `1`) still + // marks an emulator that predates the variable's truecolor meaning but + // does 256; so does Terminal.app. + if !colorterm.is_empty() { + return (ColorLevel::Ansi256, format!("COLORTERM={colorterm}")); + } + if term_program == "Apple_Terminal" { + return (ColorLevel::Ansi256, "TERM_PROGRAM=Apple_Terminal".into()); + } + // No TERM at all: Windows consoles (which take 256-colour sequences) or a + // stripped environment. Not enough evidence to downgrade. + if term.is_empty() { + return (ColorLevel::Ansi256, "TERM unset; assuming 256".into()); + } + if ANSI16_TERMS.contains(&term) { + return (ColorLevel::Ansi16, format!("TERM={term}")); + } + if BARE_256_TERMS.contains(&term) { + return (ColorLevel::Ansi256, format!("TERM={term}")); + } + ( + ColorLevel::Ansi16, + format!("TERM={term} (no 256color, no COLORTERM)"), + ) +} + /// Pure truecolor detection. Windows Terminal (`WT_SESSION`) and several /// common terminals support 24-bit color without setting `COLORTERM`. +#[cfg(test)] fn is_truecolor(colorterm: &str, term: &str, term_program: &str, wt_session: bool) -> bool { truecolor_signal(colorterm, term, term_program, wt_session).is_some() } -/// The environment signal that advertises truecolor, for `ink doctor`. +/// The environment signal that advertises truecolor, if any. fn truecolor_signal( colorterm: &str, term: &str, @@ -67,9 +193,16 @@ fn truecolor_signal( if colorterm.eq_ignore_ascii_case("truecolor") || colorterm.eq_ignore_ascii_case("24bit") { return Some(format!("COLORTERM={colorterm}")); } - if ["truecolor", "direct", "kitty", "alacritty"] - .iter() - .any(|t| term.contains(t)) + if [ + "truecolor", + "direct", + "kitty", + "alacritty", + "ghostty", + "wezterm", + ] + .iter() + .any(|t| term.contains(t)) { return Some(format!("TERM={term}")); } @@ -85,14 +218,35 @@ fn truecolor_signal( /// The colour depth ink renders at and the signal that decided it, for /// `ink doctor`. pub fn depth_report() -> (&'static str, String) { - let colorterm = std::env::var("COLORTERM").unwrap_or_default(); - let term = std::env::var("TERM").unwrap_or_default(); - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let wt = std::env::var_os("WT_SESSION").is_some(); - match truecolor_signal(&colorterm, &term, &term_program, wt) { - Some(signal) => ("truecolour (24-bit)", signal), - None => ("256 colours", format!("TERM={term}; no truecolour signal")), - } + let (level, signal) = depth_from_env(); + (level.describe(), signal) +} + +/// The reader's colour level, set once at startup by the CLI. +static TUI_LEVEL: OnceLock = OnceLock::new(); + +/// Fix the reader's colour level: the terminal's depth, or +/// [`ColorLevel::None`] when colour is turned off (`--color=never`, +/// `NO_COLOR`, `TERM=dumb`). Later calls are ignored. +pub fn set_tui_level(enabled: bool) { + let _ = TUI_LEVEL.set(if enabled { + caps().depth + } else { + ColorLevel::None + }); +} + +/// The level the reader draws at. Without [`set_tui_level`] (library use, +/// tests): the terminal's depth, or none under `NO_COLOR`. +pub fn tui_level() -> ColorLevel { + *TUI_LEVEL.get_or_init(|| { + let c = caps(); + if c.no_color { + ColorLevel::None + } else { + c.depth + } + }) } /// The `--color` flag. @@ -170,20 +324,131 @@ pub fn caps() -> TermCaps { *CAPS.get_or_init(TermCaps::detect) } -/// Adapt an RGB color to the terminal's capabilities: drop it entirely under -/// `NO_COLOR`, quantize to the 256-color cube when truecolor is unavailable, -/// or pass it through unchanged. -pub fn adapt(color: Color) -> Option { - let c = caps(); - if c.no_color { - return None; +/// Map an RGB colour to what the terminal can show at `level`: unchanged at +/// truecolor, the nearest 256-palette index, the nearest named ANSI colour, +/// or [`Color::Reset`] (the terminal's own default) with colour off. +pub fn rgb_at_level(r: u8, g: u8, b: u8, level: ColorLevel) -> Color { + match level { + ColorLevel::TrueColor => Color::Rgb(r, g, b), + ColorLevel::Ansi256 => Color::Indexed(rgb_to_256(r, g, b)), + ColorLevel::Ansi16 => ansi16_color(rgb_to_ansi16(r, g, b)), + ColorLevel::None => Color::Reset, + } +} + +/// xterm's default values for the 16 ANSI colours, used only to measure +/// which one a theme colour is closest to — the terminal draws its own. +pub const XTERM_16: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (205, 0, 0), + (0, 205, 0), + (205, 205, 0), + (0, 0, 238), + (205, 0, 205), + (0, 205, 205), + (229, 229, 229), + (127, 127, 127), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (92, 92, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), +]; + +/// Index (0–15) of the ANSI colour nearest to an RGB triple. +/// +/// Plain RGB distance to the xterm palette sends every pastel theme colour to +/// grey (a soft pink is closer to `(127,127,127)` than to pure red), which +/// would flatten a theme to monochrome. So: near-neutral colours (chroma +/// below 64) take the nearest of the four greys by palette distance; anything +/// with real colour keeps its hue — the nearest of the six ANSI hues — and +/// is bright when it is light or fully saturated. +pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 { + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let chroma = max - min; + if chroma < 64 { + let dist = |i: usize| { + let (pr, pg, pb) = XTERM_16[i]; + let d = |a: u8, b: u8| (a as i32 - b as i32).pow(2); + d(r, pr) + d(g, pg) + d(b, pb) + }; + return [0usize, 8, 7, 15] + .into_iter() + .min_by_key(|&i| dist(i)) + .unwrap_or(7) as u8; + } + // Hue in degrees, 0..360. + let (rf, gf, bf, c) = (r as f32, g as f32, b as f32, chroma as f32); + let hue = if max == r { + 60.0 * (((gf - bf) / c).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((bf - rf) / c + 2.0) + } else { + 60.0 * ((rf - gf) / c + 4.0) + }; + // Sectors tuned to how terminal palettes read: orange counts as yellow, + // yellow-green as green. + let base = match hue { + h if !(20.0..345.0).contains(&h) => 1, // red + h if h < 75.0 => 3, // yellow + h if h < 160.0 => 2, // green + h if h < 205.0 => 6, // cyan + h if h < 260.0 => 4, // blue + _ => 5, // magenta + }; + let lightness = (max as u16 + min as u16) / 2; + let bright = max >= 240 || lightness > 150; + if bright { + base + 8 + } else { + base } - if c.truecolor { - return Some(color); +} + +/// The named colour for ANSI index 0–15, so the terminal's own palette (the +/// user's scheme) applies. Out-of-range indices fall back to the default. +pub fn ansi16_color(idx: u8) -> Color { + match idx { + 0 => Color::Black, + 1 => Color::Red, + 2 => Color::Green, + 3 => Color::Yellow, + 4 => Color::Blue, + 5 => Color::Magenta, + 6 => Color::Cyan, + 7 => Color::Gray, + 8 => Color::DarkGray, + 9 => Color::LightRed, + 10 => Color::LightGreen, + 11 => Color::LightYellow, + 12 => Color::LightBlue, + 13 => Color::LightMagenta, + 14 => Color::LightCyan, + 15 => Color::White, + _ => Color::Reset, } - match color { - Color::Rgb(r, g, b) => Some(Color::Indexed(rgb_to_256(r, g, b))), - other => Some(other), +} + +/// The SGR parameters selecting `rgb` as foreground (`fg`) or background at +/// `level`, without the `ESC [` / `m` around them. Empty with colour off. +pub fn sgr_params((r, g, b): (u8, u8, u8), fg: bool, level: ColorLevel) -> String { + match level { + ColorLevel::TrueColor => format!("{};2;{r};{g};{b}", if fg { 38 } else { 48 }), + ColorLevel::Ansi256 => format!("{};5;{}", if fg { 38 } else { 48 }, rgb_to_256(r, g, b)), + ColorLevel::Ansi16 => { + let i = rgb_to_ansi16(r, g, b) as u16; + // 30–37 / 40–47 for the normal eight, 90–97 / 100–107 bright. + let base = match (fg, i < 8) { + (true, true) => 30, + (true, false) => 90 - 8, + (false, true) => 40, + (false, false) => 100 - 8, + }; + (base + i).to_string() + } + ColorLevel::None => String::new(), } } @@ -291,6 +556,115 @@ mod tests { assert!(!is_truecolor("", "xterm-256color", "Apple_Terminal", false)); } + fn depth(term: &str, colorterm: &str, term_program: &str) -> ColorLevel { + detect_depth(&DepthEnv { + colorterm, + term, + term_program, + ..Default::default() + }) + .0 + } + + #[test] + fn depth_detection_table() { + use ColorLevel::*; + let table: &[(&str, &str, &str, ColorLevel)] = &[ + // (TERM, COLORTERM, TERM_PROGRAM, expected) + ("xterm-256color", "truecolor", "", TrueColor), + ("xterm-256color", "", "iTerm.app", TrueColor), + ("xterm-kitty", "", "", TrueColor), + ("xterm-ghostty", "", "", TrueColor), + ("xterm-direct", "", "", TrueColor), + // The common case stays exactly where it was. + ("xterm-256color", "", "", Ansi256), + ("screen-256color", "", "", Ansi256), + ("tmux-256color", "", "", Ansi256), + ("xterm-256color", "", "Apple_Terminal", Ansi256), + ("putty", "", "", Ansi256), + ("tmux", "", "", Ansi256), + // Named 16-colour terminals. + ("linux", "", "", Ansi16), + ("linux", "gnome-terminal", "", Ansi16), + ("xterm", "", "", Ansi16), + ("vt100", "", "", Ansi16), + ("ansi", "", "", Ansi16), + ("screen", "", "", Ansi16), + ("cygwin", "", "", Ansi16), + ("rxvt-unicode", "", "", Ansi16), + // An unknown TERM with no 256color and no COLORTERM. + ("sun-color", "", "", Ansi16), + // …but any COLORTERM, or Terminal.app, is evidence of 256. + ("xterm", "gnome-terminal", "", Ansi256), + ("xterm", "", "Apple_Terminal", Ansi256), + // No TERM at all (Windows consoles): not enough to downgrade. + ("", "", "", Ansi256), + ]; + for &(term, colorterm, program, want) in table { + assert_eq!( + depth(term, colorterm, program), + want, + "TERM={term} COLORTERM={colorterm} TERM_PROGRAM={program}" + ); + } + // Windows Terminal and tmux. + let wt = DepthEnv { + wt_session: true, + ..Default::default() + }; + assert_eq!(detect_depth(&wt).0, TrueColor); + let tmux_screen = DepthEnv { + term: "screen", + tmux: true, + ..Default::default() + }; + assert_eq!(detect_depth(&tmux_screen).0, Ansi256); + } + + #[test] + fn nearest_ansi_colour() { + // Exact palette entries map to themselves. + for (i, &(r, g, b)) in XTERM_16.iter().enumerate() { + assert_eq!(rgb_to_ansi16(r, g, b), i as u8); + } + // Theme-like colours land on the obvious name. + assert_eq!(rgb_to_ansi16(0x1a, 0x1b, 0x26), 0, "dark bg -> black"); + assert_eq!(rgb_to_ansi16(0xff, 0xff, 0xff), 15, "light bg -> white"); + assert_eq!(rgb_to_ansi16(0xc0, 0xca, 0xf5), 7, "pale fg -> grey"); + assert_eq!(rgb_to_ansi16(0x7a, 0xa2, 0xf7), 12, "blue heading"); + assert_eq!(rgb_to_ansi16(0xf7, 0x76, 0x8e), 9, "red-pink"); + assert_eq!(rgb_to_ansi16(0x9e, 0xce, 0x6a), 10, "pastel green"); + assert_eq!(rgb_to_ansi16(0xe0, 0xaf, 0x68), 11, "amber"); + assert_eq!(rgb_to_ansi16(0xbb, 0x9a, 0xf7), 13, "lavender"); + assert_eq!(rgb_to_ansi16(0x7d, 0xcf, 0xff), 14, "sky"); + // Light-theme accents stay normal intensity (readable on white). + assert_eq!(rgb_to_ansi16(0x09, 0x69, 0xda), 4, "github blue"); + assert_eq!(rgb_to_ansi16(0xcf, 0x22, 0x2e), 1, "github red"); + assert_eq!(rgb_to_ansi16(0x24, 0x29, 0x2f), 0, "light-theme text"); + assert_eq!(rgb_to_ansi16(0x56, 0x5f, 0x89), 8, "muted -> bright black"); + // Named colours, so the user's palette applies. + assert_eq!(rgb_at_level(255, 0, 0, ColorLevel::Ansi16), Color::LightRed); + assert_eq!(rgb_at_level(0, 0, 0, ColorLevel::Ansi16), Color::Black); + assert_eq!(rgb_at_level(1, 2, 3, ColorLevel::None), Color::Reset); + assert_eq!( + rgb_at_level(1, 2, 3, ColorLevel::TrueColor), + Color::Rgb(1, 2, 3) + ); + } + + #[test] + fn sgr_codes_per_level() { + use ColorLevel::*; + assert_eq!(sgr_params((1, 2, 3), true, TrueColor), "38;2;1;2;3"); + assert_eq!(sgr_params((1, 2, 3), false, TrueColor), "48;2;1;2;3"); + assert_eq!(sgr_params((255, 0, 0), true, Ansi256), "38;5;196"); + assert_eq!(sgr_params((205, 0, 0), true, Ansi16), "31"); + assert_eq!(sgr_params((205, 0, 0), false, Ansi16), "41"); + assert_eq!(sgr_params((255, 255, 255), true, Ansi16), "97"); + assert_eq!(sgr_params((255, 255, 255), false, Ansi16), "107"); + assert_eq!(sgr_params((1, 2, 3), true, None), ""); + } + #[test] fn quantize_stays_in_palette() { for (r, g, b) in [(0, 0, 0), (255, 255, 255), (128, 64, 200), (10, 10, 10)] { diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 6142159..57f304f 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -188,18 +188,20 @@ pub fn hex_to_rgb(hex: &str) -> (u8, u8, u8) { (r, g, b) } -/// Convert a hex string to a ratatui color, adapted to the terminal: -/// quantized to the 256-color cube when truecolor isn't available. (In the -/// TUI, `NO_COLOR` is not honored — an alternate-screen reader without color -/// is not useful; use `--plain` for the no-color path.) +/// Convert a hex string to a ratatui color for the reader, adapted to the +/// terminal: 24-bit, the nearest 256-palette or named ANSI colour, or the +/// terminal's default colour ([`Color::Reset`](ratatui::style::Color::Reset)) +/// when colour is off (`--color=never`, `NO_COLOR`), where the reader relies +/// on bold, underline and reverse video instead. pub fn hex_to_color(hex: &str) -> ratatui::style::Color { let (r, g, b) = hex_to_rgb(hex); - let rgb = ratatui::style::Color::Rgb(r, g, b); - if caps::caps().truecolor { - rgb - } else { - ratatui::style::Color::Indexed(caps::rgb_to_256(r, g, b)) - } + caps::rgb_at_level(r, g, b, caps::tui_level()) +} + +/// Is the reader drawing without colour? Highlights that would otherwise be +/// a background colour (selection, hint labels) use reverse video then. +pub fn colorless() -> bool { + caps::tui_level() == caps::ColorLevel::None } /// A theme file written before selection colors existed must still load, and diff --git a/tests/cli.rs b/tests/cli.rs index dc95e00..233d597 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -534,3 +534,47 @@ fn valid_config_is_quiet() { .success() .stderr(predicate::str::is_empty()); } + +/// `ink --plain --color=always` with a scrubbed terminal environment. +fn plain_colour(term: &str) -> String { + let out = ink() + .args([ + "--plain", + "--color=always", + "--theme", + "dark", + "--width", + "80", + ]) + .arg("tests/fixtures/test.md") + .env("TERM", term) + .env_remove("COLORTERM") + .env_remove("TERM_PROGRAM") + .env_remove("WT_SESSION") + .env_remove("TMUX") + .env_remove("NO_COLOR") + .output() + .unwrap(); + assert!(out.status.success()); + String::from_utf8(out.stdout).unwrap() +} + +#[test] +fn plain_uses_ansi_16_codes_on_a_16_colour_terminal() { + for term in ["linux", "xterm", "vt100"] { + let text = plain_colour(term); + assert!(!text.contains("38;5;") && !text.contains("38;2;"), "{term}"); + assert!(!text.contains("48;5;") && !text.contains("48;2;"), "{term}"); + assert!( + ["\x1b[3", "\x1b[9"].iter().any(|p| text.contains(p)), + "{term}: no ANSI colour at all" + ); + } +} + +#[test] +fn plain_keeps_256_colours_on_xterm_256color() { + let text = plain_colour("xterm-256color"); + assert!(text.contains("38;5;")); + assert!(!text.contains("38;2;")); +} diff --git a/tests/pty.rs b/tests/pty.rs index c531c2a..1a64336 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -35,6 +35,10 @@ fn run_in_pty( .env_remove("STY") .env_remove("NO_COLOR") .env_remove("COLORTERM") + .env_remove("TERM_PROGRAM") + .env_remove("LC_TERMINAL") + .env_remove("ITERM_SESSION_ID") + .env_remove("WT_SESSION") .stdin(Stdio::from(slave.try_clone().unwrap())) .stdout(Stdio::from(slave.try_clone().unwrap())) .stderr(Stdio::from(slave.try_clone().unwrap())); @@ -254,6 +258,103 @@ fn no_mouse_also_applies_to_the_file_browser() { } } +/// Any extended (256 / 24-bit) colour selection in the output? +/// The parameter lists of every SGR (`CSI … m`) sequence in the output. +fn sgr_params(out: &[u8]) -> Vec> { + let text = String::from_utf8_lossy(out); + text.split("\x1b[") + .skip(1) + .filter_map(|seq| { + let end = seq.find(|c: char| !(c.is_ascii_digit() || c == ';'))?; + if !seq[end..].starts_with('m') { + return None; + } + Some( + seq[..end] + .split(';') + .filter_map(|p| p.parse().ok()) + .collect(), + ) + }) + .collect() +} + +/// Any 24-bit colour, or a 256-palette colour beyond the 16 ANSI ones? +/// (crossterm writes even the named colours as `38;5;0`–`38;5;15`, which +/// select the terminal's own ANSI palette entries.) +fn has_extended_colour(out: &[u8]) -> bool { + sgr_params(out).iter().any(|p| { + p.windows(3) + .any(|w| matches!(w[0], 38 | 48) && (w[1] == 2 || (w[1] == 5 && w[2] >= 16))) + }) +} + +/// Any SGR foreground/background colour at all (other than the 39/49 +/// "default colour" resets)? +fn has_any_colour(out: &[u8]) -> bool { + sgr_params(out) + .iter() + .flatten() + .any(|p| matches!(p, 30..=38 | 40..=48 | 90..=97 | 100..=107)) +} + +#[test] +fn colour_never_draws_the_reader_with_attributes_only() { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test.md"); + let mut step = 0; + let s = run_in_pty( + &[ + "--color=never", + "--theme", + "dark", + "--image-protocol", + "halfblocks", + fixture, + ], + &[("COLORTERM", "truecolor")], + |out, t| { + // Once up: select the current line (V), then leave (Esc, q). + let up = contains(out, b"\x1b[?1049h"); + let keys: &[u8] = match step { + 0 if up => b"V", + 1 if t > Duration::from_millis(1200) => b"j", + 2 if t > Duration::from_millis(1600) => b"\x1b", + 3 if t > Duration::from_millis(2000) => b"q", + _ => return None, + }; + step += 1; + Some(keys.to_vec()) + }, + ); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + assert!(!has_any_colour(&s.output), "colour escapes were written"); + // The selection is still visible: reverse video. + assert!( + contains(&s.output, b"\x1b[7m"), + "no reverse-video selection" + ); +} + +#[test] +fn a_16_colour_terminal_gets_only_ansi_colours() { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test.md"); + let s = run_in_pty( + &["--theme", "dark", "--image-protocol", "halfblocks", fixture], + &[("TERM", "xterm")], + quit_when_up(), + ); + assert!( + contains(&s.output, b"\x1b[?1049l"), + "reader did not exit: {:?}", + String::from_utf8_lossy(&s.output[s.output.len().saturating_sub(600)..]) + ); + assert!(has_any_colour(&s.output), "expected ANSI colours"); + assert!( + !has_extended_colour(&s.output), + "256/24-bit colour on TERM=xterm" + ); +} + /// Time from launch until the reader enters the alternate screen, on a /// terminal that answers no queries at all. fn startup_time(envs: &[(&str, &str)]) -> Duration { From 8a3fdd2866e5edbd5375565ee95e544ce6a191cd Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 14:16:32 +0200 Subject: [PATCH 7/7] feat(render): --ascii glyph set for consoles, legacy fonts and screen readers Box borders, bullets, task boxes, quote bars, heading markers, rules, ellipses, image markers, popup borders, status-bar separators, the progress bar and mermaid diagrams now come from one Glyphs set (Unicode or ASCII), chosen once at startup: --ascii, [behavior] ascii = true|false, or automatically for TERM=linux and non-UTF-8 locales (Unix). ASCII mode also turns off smart punctuation and emoji shortcodes and names images instead of drawing them in half-blocks, so an ASCII document prints as pure 7-bit ASCII. ink stats/outline use the same set; ink doctor reports it. Default (Unicode) output is byte-identical. --- README.md | 7 + src/app.rs | 20 ++- src/browser.rs | 28 ++- src/cli.rs | 19 +++ src/config.rs | 4 + src/doctor.rs | 7 + src/glyphs.rs | 370 ++++++++++++++++++++++++++++++++++++++++ src/input/mod.rs | 5 + src/layout/html.rs | 2 +- src/layout/mermaid.rs | 17 ++ src/layout/mod.rs | 79 ++++++--- src/layout/table.rs | 49 +++--- src/lib.rs | 1 + src/parser/mod.rs | 7 +- src/render/mod.rs | 97 +++++++++-- src/selection.rs | 10 +- src/stats.rs | 46 ++--- tests/cli.rs | 66 +++++++ tests/fixtures/ascii.md | 39 +++++ tests/pty.rs | 42 +++++ 20 files changed, 811 insertions(+), 104 deletions(-) create mode 100644 src/glyphs.rs create mode 100644 tests/fixtures/ascii.md diff --git a/README.md b/README.md index 123a002..263dfe5 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,8 @@ With no theme set, ink asks the terminal for its background colour (an OSC 11 qu Themes adapt to what the terminal can show: 24-bit colour where it is advertised, the 256-colour palette on `*-256color` terminals, and the 16 ANSI colours on the Linux console and other 16-colour terminals (`TERM=linux`, `xterm`, `vt100`, `screen`, …) — there ink uses your terminal's own palette, so your colour scheme applies. With `--color=never` or `NO_COLOR` the reader draws without colour at all, using bold, underline and reverse video for headings, links, search hits and selections. +On the Linux console, a non-UTF-8 locale, a legacy console font or a screen reader, box-drawing and symbol characters come out as junk. `ink --ascii` (or `ascii = true` under `[behavior]`) draws every border, bullet, task box, quote bar, rule and status-bar marker in plain 7-bit ASCII instead — `+--+`, `|`, `*`, `[x]`, `#` — and turns off smart quotes and emoji shortcodes, so an ASCII document prints as pure ASCII. It switches on by itself for `TERM=linux` and non-UTF-8 locales; `ascii = false` in the config keeps Unicode regardless. + ### Math and emoji Inline `$E=mc^2$` and block `$$...$$` math render in code style, and `:emoji:` shortcodes resolve to their glyph (`:rocket:` → 🚀). @@ -338,6 +340,10 @@ mouse_capture = true # native - pbcopy / wl-copy / xclip / xsel / clip.exe / termux-clipboard-set only # off - copying is disabled clipboard = "auto" + +# Plain-ASCII borders, bullets and markers (Linux console, legacy fonts, +# screen readers). Unset: automatic on TERM=linux and non-UTF-8 locales. +# ascii = false ``` ### Custom themes @@ -386,6 +392,7 @@ Options: --frontmatter Show YAML/TOML frontmatter --spacing Line spacing: compact, normal, relaxed --no-mouse Don't capture the mouse (overrides mouse_capture in config) + --ascii Draw borders, bullets and markers in plain ASCII Subcommands: outline Show document heading structure diff --git a/src/app.rs b/src/app.rs index b212563..84d8d62 100644 --- a/src/app.rs +++ b/src/app.rs @@ -562,7 +562,9 @@ fn run_inner( Style::default().fg(bar_bg) }; let sep_line = Line::from(Span::styled( - "▁".repeat(separator_area.width as usize), + crate::glyphs::current() + .separator + .repeat(separator_area.width as usize), sep_style, )); frame.render_widget(Paragraph::new(vec![sep_line]), separator_area); @@ -641,7 +643,10 @@ fn run_inner( frame.buffer_mut().set_string( doc_area.x + img.col_offset, doc_area.y + y as u16, - "🖼 (image could not be encoded for this terminal)", + format!( + "{} (image could not be encoded for this terminal)", + crate::glyphs::current().image + ), Style::default() .fg(theme::hex_to_color(&t.colors.link_url)) .add_modifier(Modifier::ITALIC), @@ -669,13 +674,16 @@ fn run_inner( .iter() .map(|h| (h.label, h.caption.clone(), h.url.clone())) .collect(); + let dash = crate::glyphs::current().dash; let title = match hint_kind { - HintKind::Open => { - "Follow link — press a letter, Y to copy instead, Esc to cancel" + HintKind::Open => format!( + "Follow link {dash} press a letter, Y to copy instead, Esc to cancel" + ), + HintKind::CopyUrl => { + format!("Copy link URL {dash} press a letter, Esc to cancel") } - HintKind::CopyUrl => "Copy link URL — press a letter, Esc to cancel", }; - render::render_link_hints(frame, main_area, &hints, title, &t); + render::render_link_hints(frame, main_area, &hints, &title, &t); } // Code-block hint labels, painted on the blocks themselves. diff --git a/src/browser.rs b/src/browser.rs index 84edcb1..244e3c9 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -81,6 +81,8 @@ fn browse_inner( terminal.draw(|frame| { let size = frame.area(); let t = theme::resolve_theme(theme_name); + let g = crate::glyphs::current(); + let dot = format!(" {} ", g.dot); // Fill background if let Some(ref bg_hex) = t.colors.bg { @@ -118,7 +120,10 @@ fn browse_inner( Line::from(""), Line::from(vec![ Span::styled(" ink ", Style::default().fg(accent).bold()), - Span::styled(format!("— {dir_display}"), Style::default().fg(dim)), + Span::styled( + format!("{} {dir_display}", g.dash), + Style::default().fg(dim), + ), ]), ]; frame.render_widget( @@ -144,7 +149,11 @@ fn browse_inner( .map(|(i, &file_idx)| { let entry = &files[file_idx]; let is_sel = i == selected; - let marker = if is_sel { " ▸ " } else { " " }; + let marker = if is_sel { + format!(" {} ", g.pointer) + } else { + " ".to_string() + }; let size_str = format_size(entry.size); let mut name_style = Style::default().fg(if is_sel { accent } else { fg }); @@ -166,7 +175,7 @@ fn browse_inner( } Line::from(vec![ - Span::styled(marker.to_string(), marker_style), + Span::styled(marker, marker_style), Span::styled(entry.relative_path.clone(), name_style), Span::styled(format!(" {size_str}"), dim_style), ]) @@ -188,7 +197,7 @@ fn browse_inner( Line::from(vec![ Span::styled(" / ", Style::default().fg(accent).bg(bar_bg).bold()), Span::styled(filter.clone(), Style::default().fg(bar_fg).bg(bar_bg)), - Span::styled("█", Style::default().fg(accent).bg(bar_bg)), + Span::styled(g.block, Style::default().fg(accent).bg(bar_bg)), Span::styled( format!(" {} matches", filtered.len()), Style::default().fg(bar_dim).bg(bar_bg), @@ -197,15 +206,18 @@ fn browse_inner( ]) } else { Line::from(vec![ - Span::styled(" ↑↓/jk ", Style::default().fg(bar_fg).bg(bar_bg).bold()), + Span::styled( + format!(" {} ", g.scroll_keys), + Style::default().fg(bar_fg).bg(bar_bg).bold(), + ), Span::styled("navigate", Style::default().fg(bar_dim).bg(bar_bg)), - Span::styled(" · ", Style::default().fg(bar_dim).bg(bar_bg)), + Span::styled(dot.clone(), Style::default().fg(bar_dim).bg(bar_bg)), Span::styled(" Enter ", Style::default().fg(bar_fg).bg(bar_bg).bold()), Span::styled("open", Style::default().fg(bar_dim).bg(bar_bg)), - Span::styled(" · ", Style::default().fg(bar_dim).bg(bar_bg)), + Span::styled(dot.clone(), Style::default().fg(bar_dim).bg(bar_bg)), Span::styled(" / ", Style::default().fg(bar_fg).bg(bar_bg).bold()), Span::styled("filter", Style::default().fg(bar_dim).bg(bar_bg)), - Span::styled(" · ", Style::default().fg(bar_dim).bg(bar_bg)), + Span::styled(dot.clone(), Style::default().fg(bar_dim).bg(bar_bg)), Span::styled(" q ", Style::default().fg(bar_fg).bg(bar_bg).bold()), Span::styled("quit", Style::default().fg(bar_dim).bg(bar_bg)), Span::styled(" ".repeat(size.width as usize), Style::default().bg(bar_bg)), diff --git a/src/cli.rs b/src/cli.rs index db01e91..9fb1257 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -84,6 +84,13 @@ pub struct Cli { /// longer scrolls inside ink. Overrides `[behavior] mouse_capture` #[arg(long, global = true)] pub no_mouse: bool, + + /// Draw borders, bullets and markers with plain ASCII instead of + /// box-drawing and symbol characters (for the Linux console, legacy + /// console fonts, screen readers). Automatic on TERM=linux and non-UTF-8 + /// locales; `[behavior] ascii` sets it either way + #[arg(long, global = true)] + pub ascii: bool, } /// A parsed `--width` value. @@ -219,6 +226,13 @@ pub fn run() -> Result<()> { eprintln!("{w}"); } input::init_keymap(user_config.as_ref().and_then(|c| c.keybindings.as_ref())); + crate::glyphs::select( + cli.ascii, + user_config + .as_ref() + .and_then(|c| c.behavior.as_ref()) + .and_then(|b| b.ascii), + ); // Escape sequences only when wanted: `--color`, then NO_COLOR, the // force variables, TERM=dumb, and finally whether stdout is a terminal. @@ -664,6 +678,11 @@ pub(crate) const STARTER_CONFIG: &str = r#"# ink configuration # off - copying is disabled # clipboard = "auto" +# Draw borders, bullets and markers with plain ASCII (for the Linux console, +# legacy console fonts, screen readers). Unset: automatic on TERM=linux and +# non-UTF-8 locales. Same as --ascii. +# ascii = false + [keybindings] # Built-in preset: "default" (vim-flavored), "vim", or "emacs" # preset = "default" diff --git a/src/config.rs b/src/config.rs index 307a90a..2f10da0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,9 @@ pub struct BehaviorConfig { pub mouse_capture: Option, /// How copies reach the clipboard: "auto" | "osc52" | "native" | "off". pub clipboard: Option, + /// Draw borders, bullets and markers with 7-bit ASCII. Unset: automatic + /// (on for the Linux console and non-UTF-8 locales). + pub ascii: Option, } #[derive(Debug, Deserialize)] @@ -193,6 +196,7 @@ pub fn parse_config(content: &str, path: &str) -> ConfigLoad { browser_loop: w.take(&mut t, "behavior.", "browser_loop"), mouse_capture: w.take(&mut t, "behavior.", "mouse_capture"), clipboard: w.take(&mut t, "behavior.", "clipboard"), + ascii: w.take(&mut t, "behavior.", "ascii"), }); w.unknown(&t, "behavior."); } diff --git a/src/doctor.rs b/src/doctor.rs index 0f71b8b..9bb1c23 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -174,6 +174,13 @@ fn environment_section(r: &mut String) { fn terminal_section(r: &mut String, tty: bool) { let _ = writeln!(r, "\n[terminal]"); let _ = writeln!(r, "stdout is a TTY = {tty}"); + let glyphs = crate::glyphs::current(); + let _ = writeln!( + r, + "glyphs = {} ({})", + if glyphs.ascii { "ASCII" } else { "Unicode" }, + crate::glyphs::reason() + ); let _ = writeln!( r, "stdin is a TTY = {}", diff --git a/src/glyphs.rs b/src/glyphs.rs new file mode 100644 index 0000000..2886591 --- /dev/null +++ b/src/glyphs.rs @@ -0,0 +1,370 @@ +//! The decorative glyphs ink draws: box borders, bullets, bars, markers. +//! +//! Two sets: [`UNICODE`] (the default) and [`ASCII`], for terminals whose +//! font or encoding cannot show box-drawing and symbol characters — the +//! Linux virtual console, legacy Windows console raster fonts, a non-UTF-8 +//! locale, screen readers. The set is chosen once at startup ([`select`]) +//! and read everywhere through [`current`]. +//! +//! Where a glyph's width feeds layout arithmetic, both sets give it the same +//! display width, so switching sets never changes line lengths; the few that +//! differ (heading markers, the ellipsis) are measured where they are used. + +use std::sync::OnceLock; + +/// One complete glyph set. +#[derive(Debug, PartialEq, Eq)] +pub struct Glyphs { + /// True for the 7-bit set. + pub ascii: bool, + + // ── Boxes: code blocks, tables, popups, mermaid frames ── + pub h: &'static str, + pub v: &'static str, + pub tl: &'static str, + pub tr: &'static str, + pub bl: &'static str, + pub br: &'static str, + /// `┬`: a column rule meeting the top border. + pub tee_down: &'static str, + /// `┴`: a column rule meeting the bottom border. + pub tee_up: &'static str, + /// `├`: the header separator meeting the left border. + pub tee_right: &'static str, + /// `┤`: the header separator meeting the right border. + pub tee_left: &'static str, + pub cross: &'static str, + /// Light dashed rule (stacked-table record separators, horizontal rules). + pub dashed: &'static str, + + // ── Document body ── + /// Heading markers for levels 1–3 (levels 4–6 are indented only). + pub heading: [&'static str; 3], + /// Unordered-list marker, with its indent (4 columns). + pub bullet: &'static str, + /// Task-list markers, with their indent (4 columns). + pub task_done: &'static str, + pub task_pending: &'static str, + /// Blockquote bar, with its indent (4 columns). + pub quote_bar: &'static str, + /// Centre ornament of a horizontal rule (5 columns). + pub hr_mid: &'static str, + /// Truncation marker. + pub ellipsis: &'static str, + /// Prefix for an image shown as text. + pub image: &'static str, + /// Admonition icons: note, tip, important, warning, caution, other. + pub admonition: [&'static str; 6], + + // ── Chrome: bars, overlays, the file browser ── + /// Progress bar fill (top bar). + pub progress: &'static str, + /// Progress bar remainder (drawn invisible when colours are available). + pub progress_rest: &'static str, + /// The row between the document and the status bar. + pub separator: &'static str, + /// Selected-item marker in lists (TOC, theme picker, browser). + pub pointer: &'static str, + /// "This one" / success tick. + pub check: &'static str, + /// Separator between status-bar items. + pub dot: &'static str, + /// Dash in prose (titles, headers). + pub dash: &'static str, + /// The scroll keys as shown in the status bar. + pub scroll_keys: &'static str, + /// Text cursor / accent block. + pub block: &'static str, + + // ── Mermaid ── + pub arrow_down: &'static str, + /// A horizontal arrow (3 columns). + pub arrow_right: &'static str, + /// Gantt / chart bar cell. + pub bar: &'static str, + pub note: &'static str, +} + +pub const UNICODE: Glyphs = Glyphs { + ascii: false, + h: "─", + v: "│", + tl: "╭", + tr: "╮", + bl: "╰", + br: "╯", + tee_down: "┬", + tee_up: "┴", + tee_right: "├", + tee_left: "┤", + cross: "┼", + dashed: "╌", + heading: ["█ ", "▌ ", "▎ "], + bullet: " ◦ ", + task_done: " ✓ ", + task_pending: " ○ ", + quote_bar: " │ ", + hr_mid: " ◆ ", + ellipsis: "…", + image: "🖼", + admonition: ["ℹ ", "💡 ", "❗ ", "⚠ ", "🔴 ", "▎ "], + progress: "▔", + progress_rest: "▔", + separator: "▁", + pointer: "▸", + check: "✓", + dot: "·", + dash: "—", + scroll_keys: "↑↓/jk", + block: "█", + arrow_down: "▼", + arrow_right: "──▶", + bar: "█", + note: "📝", +}; + +pub const ASCII: Glyphs = Glyphs { + ascii: true, + h: "-", + v: "|", + tl: "+", + tr: "+", + bl: "+", + br: "+", + tee_down: "+", + tee_up: "+", + tee_right: "+", + tee_left: "+", + cross: "+", + dashed: "-", + heading: ["# ", "## ", "### "], + bullet: " * ", + task_done: "[x] ", + task_pending: "[ ] ", + quote_bar: " | ", + hr_mid: "-----", + ellipsis: "...", + image: "[img]", + admonition: ["(i) ", "(*) ", "(!) ", "(!) ", "(x) ", "| "], + progress: "=", + progress_rest: "-", + separator: " ", + pointer: ">", + check: "*", + dot: "-", + dash: "-", + scroll_keys: "j/k", + block: "#", + arrow_down: "v", + arrow_right: "-->", + bar: "#", + note: "note:", +}; + +static CURRENT: OnceLock<&'static Glyphs> = OnceLock::new(); + +/// Why the set was chosen, for `ink doctor`. +static REASON: OnceLock<&'static str> = OnceLock::new(); + +/// The glyph set in use. Unicode unless [`select`] chose otherwise. +pub fn current() -> &'static Glyphs { + CURRENT.get_or_init(|| &UNICODE) +} + +/// Why [`current`] is what it is. +pub fn reason() -> &'static str { + REASON.get().copied().unwrap_or("default") +} + +/// Choose the glyph set for this process (first call wins). See +/// [`choose`] for the rule. +pub fn select(flag: bool, config: Option) { + let term = std::env::var("TERM").unwrap_or_default(); + let utf8 = if cfg!(unix) { + crate::platform::locale_is_utf8() + } else { + None + }; + let (ascii, why) = choose(flag, config, &term, utf8); + let _ = CURRENT.set(if ascii { &ASCII } else { &UNICODE }); + let _ = REASON.set(why); +} + +/// Pure selection rule: `--ascii` wins; then `[behavior] ascii` either way; +/// then ASCII on the Linux console or under a locale known not to be UTF-8 +/// (`utf8 == Some(false)`; an unset locale is not evidence). +pub fn choose( + flag: bool, + config: Option, + term: &str, + utf8: Option, +) -> (bool, &'static str) { + if flag { + return (true, "--ascii"); + } + if let Some(v) = config { + return (v, "config behavior.ascii"); + } + if term == "linux" { + return (true, "TERM=linux (console font)"); + } + if utf8 == Some(false) { + return (true, "locale is not UTF-8"); + } + (false, "default") +} + +/// Replace the drawing characters ink's own renderers emit (boxes, arrows, +/// bars) with ASCII stand-ins of the same width. Text without any of them is +/// returned borrowed. +pub fn asciify(text: &str) -> std::borrow::Cow<'_, str> { + fn stand_in(c: char) -> Option<&'static str> { + Some(match c { + '─' | '╌' | '━' => "-", + '│' | '┃' => "|", + '╭' | '╮' | '╰' | '╯' | '┌' | '┐' | '└' | '┘' | '┬' | '┴' | '├' | '┤' | '┼' => { + "+" + } + '▼' => "v", + '▲' => "^", + '▶' => ">", + '◀' => "<", + '█' | '▌' | '▎' => "#", + '◆' | '•' | '◦' => "*", + '📝' => "note:", + _ => return None, + }) + } + if !text.chars().any(|c| stand_in(c).is_some()) { + return std::borrow::Cow::Borrowed(text); + } + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match stand_in(c) { + Some(s) => out.push_str(s), + None => out.push(c), + } + } + std::borrow::Cow::Owned(out) +} + +/// Every glyph of a set, for checks over the whole set. +#[cfg(test)] +fn all(g: &Glyphs) -> Vec<&'static str> { + let mut v = vec![ + g.h, + g.v, + g.tl, + g.tr, + g.bl, + g.br, + g.tee_down, + g.tee_up, + g.tee_right, + g.tee_left, + g.cross, + g.dashed, + g.bullet, + g.task_done, + g.task_pending, + g.quote_bar, + g.hr_mid, + g.ellipsis, + g.image, + g.progress, + g.progress_rest, + g.separator, + g.pointer, + g.check, + g.dot, + g.dash, + g.scroll_keys, + g.block, + g.arrow_down, + g.arrow_right, + g.bar, + g.note, + ]; + v.extend(g.heading); + v.extend(g.admonition); + v +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn ascii_set_is_seven_bit() { + for glyph in all(&ASCII) { + assert!(glyph.is_ascii(), "{glyph:?}"); + } + } + + #[test] + fn layout_critical_glyphs_keep_their_width() { + let pick = |g: &Glyphs| { + [ + g.h, + g.v, + g.tl, + g.tr, + g.bl, + g.br, + g.tee_down, + g.tee_up, + g.tee_right, + g.tee_left, + g.cross, + g.dashed, + g.bullet, + g.task_done, + g.task_pending, + g.quote_bar, + g.hr_mid, + g.progress, + g.progress_rest, + g.separator, + g.pointer, + g.block, + g.arrow_down, + g.arrow_right, + g.bar, + ] + }; + for (u, a) in pick(&UNICODE).iter().zip(pick(&ASCII).iter()) { + assert_eq!(u.width(), a.width(), "{u:?} vs {a:?}"); + } + } + + #[test] + fn selection_rule() { + assert!(choose(true, Some(false), "xterm", Some(true)).0); + assert!(!choose(false, Some(false), "linux", Some(false)).0); + assert!(choose(false, Some(true), "xterm", Some(true)).0); + assert!(choose(false, None, "linux", None).0); + assert!(choose(false, None, "xterm", Some(false)).0); + assert!(!choose(false, None, "xterm-256color", None).0); + assert!(!choose(false, None, "xterm-256color", Some(true)).0); + } + + #[test] + fn asciify_translates_drawing_characters_only() { + assert_eq!(asciify("╭─ flow ─╮"), "+- flow -+"); + assert_eq!( + asciify("│ [ A ] ▼ ──▶ █"), + "| [ A ] v -->>> #".replace(">>>", ">") + ); + assert!(matches!( + asciify("plain é text"), + std::borrow::Cow::Borrowed(_) + )); + } + + #[test] + fn default_is_unicode() { + // Nothing in the unit-test process calls `select`. + assert!(!current().ascii); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index acbb5e7..28c8bc0 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -239,6 +239,11 @@ fn fmt_key(code: &KeyCode, mods: &KeyModifiers) -> String { KeyCode::Enter => "enter".into(), KeyCode::Tab => "tab".into(), KeyCode::BackTab => "backtab".into(), + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right + if crate::glyphs::current().ascii => + { + format!("{code:?}").to_lowercase() + } KeyCode::Up => "↑".into(), KeyCode::Down => "↓".into(), KeyCode::Left => "←".into(), diff --git a/src/layout/html.rs b/src/layout/html.rs index 26d41f4..302260d 100644 --- a/src/layout/html.rs +++ b/src/layout/html.rs @@ -368,7 +368,7 @@ fn image_placeholder_span( ) -> StyledSpan { let label = if alt.is_empty() { src } else { alt }; StyledSpan { - text: format!("🖼 {label}"), + text: format!("{} {label}", crate::glyphs::current().image), style: SpanStyle { fg: Some(ctx.theme.colors.link.clone()), ..style.clone() diff --git a/src/layout/mermaid.rs b/src/layout/mermaid.rs index 38c5172..478596c 100644 --- a/src/layout/mermaid.rs +++ b/src/layout/mermaid.rs @@ -5,6 +5,23 @@ use unicode_width::UnicodeWidthStr; /// Parse and render a mermaid diagram as styled terminal text. /// This is a built-in ASCII renderer for common diagram types. pub fn render_mermaid(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { + let mut lines = render_diagram(source, theme, width, margin); + // The diagram renderers draw with box and arrow characters; in ASCII + // mode translate them once here rather than in every renderer. (Each + // stand-in has the same width, except the note icon, which ends a line.) + if crate::glyphs::current().ascii { + for line in &mut lines { + for span in &mut line.spans { + if let std::borrow::Cow::Owned(t) = crate::glyphs::asciify(&span.text) { + span.text = t; + } + } + } + } + lines +} + +fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { let trimmed = source.trim(); let diagram_color = &theme.colors.heading2; let text_color = &theme.colors.code_fg; diff --git a/src/layout/mod.rs b/src/layout/mod.rs index fcf6912..8d951e9 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -412,10 +412,9 @@ fn layout_heading_spans( lines.push(StyledLine::empty()); } + let glyphs = crate::glyphs::current(); let prefix = match level { - 1 => "█ ", - 2 => "▌ ", - 3 => "▎ ", + 1..=3 => glyphs.heading[level as usize - 1], _ => " ", }; let prefix_w = prefix.width(); @@ -575,7 +574,10 @@ fn image_block_lines( let mut line = StyledLine::new(); ctx.add_margin(&mut line); line.push(StyledSpan { - text: format!("🖼 {alt_display} ({detail})"), + text: format!( + "{} {alt_display} ({detail})", + crate::glyphs::current().image + ), style: SpanStyle { fg: Some(ctx.theme.colors.link_url.clone()), italic: true, @@ -585,12 +587,20 @@ fn image_block_lines( }); vec![line] }; + // Half-block pixels are block glyphs too: in ASCII mode the image is + // named, not drawn. + if crate::glyphs::current().ascii && ctx.graphics_font.is_none() { + return placeholder("image; not drawn in ASCII mode".into()); + } let image_data = match crate::image::load_decoded(url, ctx.base_dir, ctx.images, Some(&ctx.theme.colors.fg)) { Ok(data) => data, Err(crate::image::ImageUnavailable::RemoteBlocked) => { - return placeholder("remote image — pass --remote-images to load".into()); + return placeholder(format!( + "remote image {} pass --remote-images to load", + crate::glyphs::current().dash + )); } Err(crate::image::ImageUnavailable::NotFound) => { return placeholder(if alt.is_empty() { @@ -838,13 +848,19 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut // blocks narrower than paragraphs on wide layouts). let border_width = ctx.width.max(8); let content_w = border_width.saturating_sub(4); // │ + space + content + space + │ + let g = crate::glyphs::current(); // Top border let mut header = StyledLine::new(); ctx.add_margin(&mut header); if lang.is_empty() { header.push(StyledSpan { - text: format!("╭{}╮", "─".repeat(border_width.saturating_sub(2))), + text: format!( + "{}{}{}", + g.tl, + g.h.repeat(border_width.saturating_sub(2)), + g.tr + ), style: SpanStyle { fg: Some(ctx.theme.colors.table_border.clone()), @@ -856,7 +872,7 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut // ╭─ (2) + label + + ╮ (1) == border_width let remaining = border_width.saturating_sub(label.width() + 3); header.push(StyledSpan { - text: "╭─".to_string(), + text: format!("{}{}", g.tl, g.h), style: SpanStyle { fg: Some(ctx.theme.colors.table_border.clone()), @@ -872,7 +888,7 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut }, }); header.push(StyledSpan { - text: format!("{}╮", "─".repeat(remaining)), + text: format!("{}{}", g.h.repeat(remaining), g.tr), style: SpanStyle { fg: Some(ctx.theme.colors.table_border.clone()), @@ -936,7 +952,7 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut let mut line = StyledLine::new(); ctx.add_margin(&mut line); line.push(StyledSpan { - text: "│ ".to_string(), + text: format!("{} ", g.v), style: border_style.clone(), }); let mut w = 0; @@ -946,7 +962,7 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut } let pad = content_w.saturating_sub(w); line.push(StyledSpan { - text: format!("{} │", " ".repeat(pad)), + text: format!("{} {}", " ".repeat(pad), g.v), style: border_style.clone(), }); lines.push(line); @@ -957,7 +973,12 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut let mut footer = StyledLine::new(); ctx.add_margin(&mut footer); footer.push(StyledSpan { - text: format!("╰{}╯", "─".repeat(border_width.saturating_sub(2))), + text: format!( + "{}{}{}", + g.bl, + g.h.repeat(border_width.saturating_sub(2)), + g.br + ), style: SpanStyle { fg: Some(ctx.theme.colors.table_border.clone()), ..Default::default() @@ -1047,18 +1068,19 @@ fn layout_blockquote<'a>( // Render admonition header if detected if let Some(ref adm_type) = admonition { + let icons = crate::glyphs::current().admonition; let icon = match adm_type.as_str() { - "NOTE" => "ℹ ", - "TIP" => "💡 ", - "IMPORTANT" => "❗ ", - "WARNING" => "⚠ ", - "CAUTION" => "🔴 ", - _ => "▎ ", + "NOTE" => icons[0], + "TIP" => icons[1], + "IMPORTANT" => icons[2], + "WARNING" => icons[3], + "CAUTION" => icons[4], + _ => icons[5], }; let mut header_line = StyledLine::new(); ctx.add_margin(&mut header_line); header_line.push(StyledSpan { - text: " │ ".to_string(), + text: crate::glyphs::current().quote_bar.to_string(), style: SpanStyle { fg: Some(bar_color.clone()), ..Default::default() @@ -1132,7 +1154,7 @@ fn layout_blockquote<'a>( let mut line = StyledLine::new(); ctx.add_margin(&mut line); line.push(StyledSpan { - text: " │ ".to_string(), + text: crate::glyphs::current().quote_bar.to_string(), style: SpanStyle { fg: Some(bar_color.clone()), ..Default::default() @@ -1211,7 +1233,7 @@ fn layout_list<'a>( for (i, item) in node.children().enumerate() { let marker = match list_type { - ListType::Bullet => " ◦ ".to_string(), + ListType::Bullet => crate::glyphs::current().bullet.to_string(), ListType::Ordered => format!(" {}. ", start + i), }; @@ -1259,10 +1281,11 @@ fn layout_list<'a>( ctx.add_margin(&mut line); if j == 0 { if let Some(checked) = is_checked { + let g = crate::glyphs::current(); let (icon, color) = if checked != ' ' { - (" ✓ ", &ctx.theme.colors.task_done) + (g.task_done, &ctx.theme.colors.task_done) } else { - (" ○ ", &ctx.theme.colors.task_pending) + (g.task_pending, &ctx.theme.colors.task_pending) }; line.push(StyledSpan { text: icon.to_string(), @@ -1308,11 +1331,11 @@ fn layout_hr(ctx: &LayoutContext, lines: &mut Vec) { } let half = width / 2; line.push(StyledSpan { - text: format!( - "{} ◆ {}", - "╌".repeat(half.saturating_sub(3)), - "╌".repeat(half.saturating_sub(3)) - ), + text: { + let g = crate::glyphs::current(); + let side = g.dashed.repeat(half.saturating_sub(3)); + format!("{side}{}{side}", g.hr_mid) + }, style: SpanStyle { fg: Some(ctx.theme.colors.hr.clone()), ..Default::default() @@ -1454,7 +1477,7 @@ fn collect_inlines<'a>( let alt = collect_child_text(child); let alt_display = if alt.is_empty() { img.url.clone() } else { alt }; spans.push(StyledSpan { - text: format!("🖼 {alt_display}"), + text: format!("{} {alt_display}", crate::glyphs::current().image), style: SpanStyle { fg: Some(ctx.theme.colors.link.clone()), diff --git a/src/layout/table.rs b/src/layout/table.rs index 2cbe4f8..4ac1358 100644 --- a/src/layout/table.rs +++ b/src/layout/table.rs @@ -77,12 +77,13 @@ pub fn layout_table<'a>( let border_color = &theme.colors.table_border; let header_color = &theme.colors.table_header; + let g = crate::glyphs::current(); // Top border: ╭───┬───╮ lines.push(border_line( &col_widths, - '╭', - '┬', - '╮', + g.tl, + g.tee_down, + g.tr, border_color, &margin_str, )); @@ -103,9 +104,9 @@ pub fn layout_table<'a>( // Separator: ├───┼───┤ lines.push(border_line( &col_widths, - '├', - '┼', - '┤', + g.tee_right, + g.cross, + g.tee_left, border_color, &margin_str, )); @@ -128,9 +129,9 @@ pub fn layout_table<'a>( // Bottom border: ╰───┴───╯ lines.push(border_line( &col_widths, - '╰', - '┴', - '╯', + g.bl, + g.tee_up, + g.br, border_color, &margin_str, )); @@ -215,7 +216,7 @@ fn render_transposed( }); } sep.push(StyledSpan { - text: "╌".repeat(max_width), + text: crate::glyphs::current().dashed.repeat(max_width), style: SpanStyle { fg: Some(border_color.clone()), ..Default::default() @@ -242,18 +243,21 @@ fn fit_label(s: &str, w: usize) -> String { if sw < w { return format!("{s}{}", " ".repeat(w - sw)); } + let ellipsis = crate::glyphs::current().ellipsis; + // The ellipsis only fits when the column is wider than it. + let ellipsis = if ellipsis.width() < w { ellipsis } else { "" }; let mut out = String::new(); let mut acc = 0; for g in s.graphemes(true) { let gw = g.width(); - if acc + gw > w - 1 { + if acc + gw > w - ellipsis.width() { break; } out.push_str(g); acc += gw; } - out.push('…'); - acc += 1; + out.push_str(ellipsis); + acc += ellipsis.width(); if acc < w { out.push_str(&" ".repeat(w - acc)); } @@ -402,7 +406,7 @@ fn render_row_lines( }); } line.push(StyledSpan { - text: "│".to_string(), + text: crate::glyphs::current().v.to_string(), style: SpanStyle { fg: Some(border_color.to_string()), ..Default::default() @@ -433,7 +437,7 @@ fn render_row_lines( style, }); line.push(StyledSpan { - text: "│".to_string(), + text: crate::glyphs::current().v.to_string(), style: SpanStyle { fg: Some(border_color.to_string()), ..Default::default() @@ -447,9 +451,9 @@ fn render_row_lines( fn border_line( widths: &[usize], - left: char, - mid: char, - right: char, + left: &str, + mid: &str, + right: &str, color: &str, margin: &str, ) -> StyledLine { @@ -461,14 +465,15 @@ fn border_line( }); } let mut parts = String::new(); - parts.push(left); + let h = crate::glyphs::current().h; + parts.push_str(left); for (i, w) in widths.iter().enumerate() { - parts.push_str(&"─".repeat(w + 2)); + parts.push_str(&h.repeat(w + 2)); if i < widths.len() - 1 { - parts.push(mid); + parts.push_str(mid); } } - parts.push(right); + parts.push_str(right); line.push(StyledSpan { text: parts, style: SpanStyle { diff --git a/src/lib.rs b/src/lib.rs index 8129849..38d2396 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod cli; pub mod clipboard; pub mod config; pub mod doctor; +pub mod glyphs; pub mod graphics; pub mod highlight; pub mod image; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5602b58..bd97ffc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -39,11 +39,14 @@ pub fn options() -> Options<'static> { opts.extension.footnotes = true; opts.extension.header_ids = Some(String::new()); opts.extension.math_dollars = true; - opts.extension.shortcodes = true; + // In ASCII mode, an ASCII document must stay ASCII: no `:smile:` → emoji + // and no smart quotes, dashes or ellipses. + let ascii = crate::glyphs::current().ascii; + opts.extension.shortcodes = !ascii; // `[[target]]` / `[[target|label]]`, parsed where inline markdown is // (never inside code or raw HTML); see `crate::wikilink`. opts.extension.wikilinks_title_after_pipe = true; - opts.parse.smart = true; + opts.parse.smart = !ascii; opts } diff --git a/src/render/mod.rs b/src/render/mod.rs index 0b879b9..3439de3 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -7,6 +7,35 @@ use crate::theme; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +/// Border symbols for popups: rounded box-drawing, or `+ - |` in ASCII mode. +fn popup_border() -> ratatui::symbols::border::Set<'static> { + if crate::glyphs::current().ascii { + ASCII_BORDER + } else { + ratatui::symbols::border::ROUNDED + } +} + +/// Border symbols for plain rules (the TOC divider). +fn plain_border() -> ratatui::symbols::border::Set<'static> { + if crate::glyphs::current().ascii { + ASCII_BORDER + } else { + ratatui::symbols::border::PLAIN + } +} + +const ASCII_BORDER: ratatui::symbols::border::Set<'static> = ratatui::symbols::border::Set { + top_left: "+", + top_right: "+", + bottom_left: "+", + bottom_right: "+", + vertical_left: "|", + vertical_right: "|", + horizontal_top: "-", + horizontal_bottom: "-", +}; + /// Convert our StyledLine list into ratatui Lines for display. pub fn styled_lines_to_ratatui(lines: &[StyledLine], theme_name: &str) -> Vec> { let t = theme::resolve_theme(theme_name); @@ -68,7 +97,7 @@ pub fn render_top_bar( if total_lines == 0 || total_lines <= viewport_height { let line = Line::from(Span::styled( - "▔".repeat(width), + crate::glyphs::current().progress.repeat(width), Style::default().fg(accent).bg(bg), )); frame.render_widget(Paragraph::new(vec![line]), area); @@ -80,8 +109,14 @@ pub fn render_top_bar( let empty = width.saturating_sub(filled); let line = Line::from(vec![ - Span::styled("▔".repeat(filled), Style::default().fg(accent).bg(bg)), - Span::styled("▔".repeat(empty), Style::default().fg(bg).bg(bg)), + Span::styled( + crate::glyphs::current().progress.repeat(filled), + Style::default().fg(accent).bg(bg), + ), + Span::styled( + crate::glyphs::current().progress_rest.repeat(empty), + Style::default().fg(bg).bg(bg), + ), ]); frame.render_widget(Paragraph::new(vec![line]), area); } @@ -104,7 +139,7 @@ pub fn render_bottom_bar( let dim_fg = theme::hex_to_color(&t.colors.link_url); let mut keys: Vec<(&str, &str)> = vec![ - ("↑↓/jk", "scroll"), + (crate::glyphs::current().scroll_keys, "scroll"), ("/", "search"), ("f", "links"), ("t", "toc"), @@ -126,7 +161,10 @@ pub fn render_bottom_bar( Style::default().fg(dim_fg).bg(bg), )); if i < keys.len() - 1 { - spans.push(Span::styled(" · ", Style::default().fg(dim_fg).bg(bg))); + spans.push(Span::styled( + format!(" {} ", crate::glyphs::current().dot), + Style::default().fg(dim_fg).bg(bg), + )); } } @@ -143,7 +181,10 @@ pub fn render_bottom_bar( }; let right_name = format!("{short_name}{tab_part}"); - let right_stats = format!(" {word_count} words · ~{reading_time} min "); + let right_stats = format!( + " {word_count} words {} ~{reading_time} min ", + crate::glyphs::current().dot + ); let missing_label = if file_missing { " [file missing] " } else { "" }; let right_total_len = unicode_width::UnicodeWidthStr::width(right_name.as_str()) + unicode_width::UnicodeWidthStr::width(right_stats.as_str()) @@ -435,7 +476,10 @@ pub fn render_search_bar(frame: &mut Frame, area: Rect, search: &SearchState, t: .add_modifier(Modifier::BOLD), ), Span::styled(search.query.clone(), Style::default().fg(fg).bg(bg)), - Span::styled("█", Style::default().fg(accent).bg(bg)), + Span::styled( + crate::glyphs::current().block, + Style::default().fg(accent).bg(bg), + ), Span::styled(match_info, Style::default().fg(dim_fg).bg(bg)), Span::styled(" ".repeat(area.width as usize), Style::default().bg(bg)), ]); @@ -461,7 +505,11 @@ pub fn render_toc( .enumerate() .map(|(i, entry)| { let indent = " ".repeat((entry.level as usize).saturating_sub(1)); - let marker = if i == selected { "▸ " } else { " " }; + let marker = if i == selected { + format!("{} ", crate::glyphs::current().pointer) + } else { + " ".to_string() + }; let text = format!("{indent}{marker}{}", entry.text); let color = if i == selected { active_color @@ -481,6 +529,7 @@ pub fn render_toc( let mut block = Block::default() .borders(Borders::RIGHT) + .border_set(plain_border()) .border_style(Style::default().fg(border_color)) .title(" Contents ") .title_style( @@ -528,8 +577,11 @@ pub fn render_help(frame: &mut Frame, area: Rect, t: &theme::Theme) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(" Keys — press any key to close ") + .border_set(popup_border()) + .title(format!( + " Keys {} press any key to close ", + crate::glyphs::current().dash + )) .title_style( Style::default() .fg(title_color) @@ -578,7 +630,7 @@ pub fn render_flash_bar(frame: &mut Frame, area: Rect, message: &str, t: &theme: let accent = theme::hex_to_color(&t.colors.heading2); let line = Line::from(vec![ Span::styled( - " ✓ ", + format!(" {} ", crate::glyphs::current().check), Style::default() .fg(accent) .bg(bg) @@ -658,7 +710,7 @@ pub fn render_link_hints( let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) - .border_type(ratatui::widgets::BorderType::Rounded) + .border_set(popup_border()) .title(format!(" {title} ")) .title_style( Style::default() @@ -699,17 +751,19 @@ fn truncate_to_width(s: &str, max: usize) -> String { if max == 0 { return String::new(); } + let ellipsis = crate::glyphs::current().ellipsis; + let ellipsis = if ellipsis.width() < max { ellipsis } else { "" }; let mut out = String::new(); let mut used = 0; for c in s.chars() { let w = c.width().unwrap_or(0); - if used + w > max - 1 { + if used + w > max - ellipsis.width() { break; } out.push(c); used += w; } - out.push('…'); + out.push_str(ellipsis); out } @@ -739,8 +793,17 @@ pub fn render_theme_picker( .iter() .enumerate() .map(|(i, name)| { - let marker = if i == selected { " ▸ " } else { " " }; - let check = if *name == current_theme { " ✓" } else { "" }; + let g = crate::glyphs::current(); + let marker = if i == selected { + format!(" {} ", g.pointer) + } else { + " ".to_string() + }; + let check = if *name == current_theme { + format!(" {}", g.check) + } else { + String::new() + }; let text = format!("{marker}{name}{check}"); let color = if i == selected { active_color @@ -758,7 +821,7 @@ pub fn render_theme_picker( let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) - .border_type(ratatui::widgets::BorderType::Rounded) + .border_set(popup_border()) .title(" Theme ") .title_style( Style::default() diff --git a/src/selection.rs b/src/selection.rs index c36769a..564f3a3 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -233,13 +233,21 @@ const DECORATION: &[char] = &[ pub fn content_start_col(text: &str) -> usize { let mut col = 0usize; let mut seen_decoration = false; + // ASCII mode draws the same structure with `#` (heading levels, as a + // run: `###`), `|` (bars, borders), `*` (bullets) and `+` (corners). + let ascii = crate::glyphs::current().ascii; for g in text.graphemes(true) { let is_space = g.chars().all(char::is_whitespace); - let is_decoration = g.chars().all(|c| DECORATION.contains(&c)); + let is_decoration = g.chars().all(|c| DECORATION.contains(&c)) + || (ascii && matches!(g, "#" | "|" | "*" | "+")); if is_space { col += g.width().max(1); continue; } + if ascii && g == "#" { + col += 1; + continue; + } if is_decoration && !seen_decoration { seen_decoration = true; col += g.width().max(1); diff --git a/src/stats.rs b/src/stats.rs index 0152888..26ce8f8 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -16,13 +16,12 @@ pub fn print_outline(source: &str) { return; } + let g = crate::glyphs::current(); for h in &doc.headings { let indent = " ".repeat((h.level as usize).saturating_sub(1)); let marker = match h.level { - 1 => "█", - 2 => "▌", - 3 => "▎", - _ => "·", + 1..=3 => g.heading[h.level as usize - 1].trim_end(), + _ => g.dot, }; println!("{indent}{marker} {}", sanitize_text(&h.text)); } @@ -44,23 +43,32 @@ pub fn print_stats(source: &str, filename: &str) { let image_count = count_pattern(&content, "!["); let table_count = content.lines().filter(|l| l.contains("---|")).count(); - println!("╭─ {} ─╮", sanitize_text(filename)); - println!("│"); - println!("│ Words: {words}"); - println!("│ Characters: {chars}"); - println!("│ Lines: {lines}"); - println!("│ Reading time: ~{reading_time} min"); - println!("│"); - println!("│ Headings: {heading_count}"); - println!("│ Links: {link_count}"); - println!("│ Code blocks: {code_block_count}"); - println!("│ Images: {image_count}"); - println!("│ Tables: {table_count}"); + let g = crate::glyphs::current(); + let v = g.v; + println!( + "{}{} {} {}{}", + g.tl, + g.h, + sanitize_text(filename), + g.h, + g.tr + ); + println!("{v}"); + println!("{v} Words: {words}"); + println!("{v} Characters: {chars}"); + println!("{v} Lines: {lines}"); + println!("{v} Reading time: ~{reading_time} min"); + println!("{v}"); + println!("{v} Headings: {heading_count}"); + println!("{v} Links: {link_count}"); + println!("{v} Code blocks: {code_block_count}"); + println!("{v} Images: {image_count}"); + println!("{v} Tables: {table_count}"); if fm.is_some() { - println!("│ Frontmatter: yes"); + println!("{v} Frontmatter: yes"); } - println!("│"); - println!("╰───╯"); + println!("{v}"); + println!("{}{}{}", g.bl, g.h.repeat(3), g.br); } /// Print a real (Myers) line diff between two markdown files. A single diff --git a/tests/cli.rs b/tests/cli.rs index 233d597..6c7e6dd 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -578,3 +578,69 @@ fn plain_keeps_256_colours_on_xterm_256color() { assert!(text.contains("38;5;")); assert!(!text.contains("38;2;")); } + +/// `tests/fixtures/ascii.md` is pure ASCII and covers headings (levels 1-3), +/// nested lists, an ordered list, a task list, a table that wraps, a fenced +/// code block, nested blockquotes, an admonition and a rule. In ASCII mode +/// every byte ink prints for it must be 7-bit. Nothing is exempt for this +/// fixture; images (named, not drawn) and math are not covered by it. +#[test] +fn ascii_mode_plain_output_is_seven_bit() { + let source = std::fs::read("tests/fixtures/ascii.md").unwrap(); + assert!(source.is_ascii(), "the fixture itself must be ASCII"); + for width in ["40", "80"] { + let out = ink() + .args(["--ascii", "--plain", "--color=never", "--width", width]) + .arg("tests/fixtures/ascii.md") + .output() + .unwrap(); + assert!(out.status.success()); + assert!(!out.stdout.is_empty()); + if let Some(pos) = out.stdout.iter().position(|b| *b >= 0x80) { + let text = String::from_utf8_lossy(&out.stdout); + panic!("non-ASCII byte at {pos} (width {width}):\n{text}"); + } + } +} + +#[test] +fn ascii_mode_turns_on_for_the_linux_console() { + let out = ink() + .args(["--plain", "--color=never"]) + .arg("tests/fixtures/ascii.md") + .env("TERM", "linux") + .output() + .unwrap(); + assert!(out.status.success()); + assert!(out.stdout.is_ascii()); + // …and stays Unicode on an ordinary terminal with a UTF-8 locale. + let out = ink() + .args(["--plain", "--color=never"]) + .arg("tests/fixtures/ascii.md") + .env("TERM", "xterm-256color") + .env("LANG", "en_US.UTF-8") + .env_remove("LC_ALL") + .env_remove("LC_CTYPE") + .output() + .unwrap(); + assert!(!out.stdout.is_ascii()); +} + +#[test] +fn ascii_config_key_is_accepted() { + let dir = xdg_with_config("[behavior]\nascii = true\n"); + let out = ink() + .args(["--plain", "--color=never"]) + .arg("tests/fixtures/ascii.md") + .env("XDG_CONFIG_HOME", dir.path()) + .env("TERM", "xterm-256color") + .output() + .unwrap(); + assert!(out.status.success()); + assert!( + out.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(out.stdout.is_ascii()); +} diff --git a/tests/fixtures/ascii.md b/tests/fixtures/ascii.md new file mode 100644 index 0000000..ca5ac45 --- /dev/null +++ b/tests/fixtures/ascii.md @@ -0,0 +1,39 @@ +# Heading One + +## Heading Two + +### Three + +Text with **bold** and `code`. + +- item one + - nested item + - deeper +- item two + +1. first +2. second + +- [x] done task +- [ ] open task + +| Name | Value | +|------|------:| +| alpha | 1 | +| a much longer cell that needs wrapping maybe | 22 | + +```rust +fn main() { + println!("hi"); +} +``` + +> A quote +> > nested quote + +> [!NOTE] +> An admonition. + +--- + +The end. diff --git a/tests/pty.rs b/tests/pty.rs index 1a64336..c034e84 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -379,3 +379,45 @@ fn terminals_without_graphics_skip_the_image_query() { let t = startup_time(&[("TERM", "linux")]); assert!(t < Duration::from_millis(1500), "startup took {t:?}"); } + +#[test] +fn ascii_mode_reader_and_overlays_write_only_7_bit_bytes() { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/ascii.md"); + let mut step = 0; + let s = run_in_pty( + &[ + "--ascii", + "--color=never", + "--theme", + "dark", + "--image-protocol", + "halfblocks", + fixture, + ], + &[], + |out, t| { + // Once up: table of contents, help overlay, close help, quit. + let up = contains(out, b"\x1b[?1049h"); + let keys: &[u8] = match step { + 0 if up => b"t", + 1 if t > Duration::from_millis(1200) => b"?", + 2 if t > Duration::from_millis(1600) => b"x", + // Close the TOC, then quit (repeated until it takes). + n if n >= 3 && t > Duration::from_millis(2000 + 400 * (n as u64 - 3)) => { + if n == 3 { + b"t" + } else { + b"q" + } + } + _ => return None, + }; + step += 1; + Some(keys.to_vec()) + }, + ); + let text = String::from_utf8_lossy(&s.output); + assert!(text.contains("Contents") && text.contains("Keys"), "{text}"); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + assert!(s.output.is_ascii(), "non-ASCII output: {text}"); +}