diff --git a/Cargo.lock b/Cargo.lock index 41a5789..25fe178 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,6 +1534,7 @@ dependencies = [ "flate2", "image", "insta", + "libc", "notify", "open", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 24e8d0b..d79713a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,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 99fed6b..7e4a1c8 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,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). @@ -229,7 +231,11 @@ 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. + +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 @@ -362,9 +368,13 @@ 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" + +# 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 @@ -412,6 +422,8 @@ 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) + --ascii Draw borders, bullets and markers in plain ASCII Subcommands: outline Show document heading structure @@ -422,6 +434,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 @@ -452,7 +465,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/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 8cbbec8..244e3c9 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)?; @@ -73,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 { @@ -110,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( @@ -136,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 }); @@ -158,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), ]) @@ -180,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), @@ -189,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 1c28d96..9fb1257 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, @@ -77,6 +78,19 @@ 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, + + /// 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. @@ -145,8 +159,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")] @@ -211,11 +226,31 @@ 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. 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, + // 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 { @@ -241,7 +276,19 @@ pub fn run() -> Result<()> { Ok(()) } Commands::Doctor { save } => { - return crate::doctor::run(save.as_deref()); + 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(); @@ -291,15 +338,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); @@ -333,22 +371,8 @@ 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), - 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(), + mouse_capture: mouse_capture(cli.no_mouse, &user_config).0, + clipboard: clipboard_mode(&user_config).0, }; // Check if input is a directory or no input with a TTY → launch file browser @@ -386,7 +410,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)?; @@ -529,6 +553,70 @@ 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), + } +} + +/// 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( + 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), @@ -586,10 +674,15 @@ 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" +# 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" @@ -753,3 +846,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/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/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 e4cc6c5..9bb1c23 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,42 +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>) -> anyhow::Result<()> { - let report = build_report(); +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", @@ -45,20 +142,54 @@ 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 glyphs = crate::glyphs::current(); + let _ = writeln!( + r, + "glyphs = {} ({})", + if glyphs.ascii { "ASCII" } else { "Unicode" }, + crate::glyphs::reason() + ); + 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"); @@ -67,66 +198,205 @@ 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, + "reader colours = {} ({tui_why})", + if tui { "on" } else { "off" } + ); + let (plain, plain_why) = caps::color_enabled_why(ctx.color_choice, tty); + let _ = writeln!( + r, + "--plain colours here = {} ({plain_why})", + if plain { "on" } else { "off" } + ); +} + +fn theme_section(r: &mut String, ctx: &Context) { + use crate::theme::detect::BackgroundSource; + let _ = writeln!(r, "\n[theme]"); + let theme = ctx.theme; + match ctx.theme_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); + } + } +} + +fn clipboard_section(r: &mut String, ctx: &Context) { + use crate::clipboard::{self, Wrap}; + let _ = writeln!(r, "\n[clipboard]"); let _ = writeln!( r, - "stdout is a TTY = {}", - std::io::stdout().is_terminal() + "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::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) => { + 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, - "query result = failed ({e}) — ink falls back to half-blocks" + " (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 { - let _ = writeln!( - r, - "query result = skipped (stdout is not a TTY; run `ink doctor` directly in your terminal)" - ); + 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()); - - 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" - ); - r } 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", @@ -148,3 +418,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/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/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/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 5d49d2e..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; @@ -11,6 +12,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/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/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/render/mod.rs b/src/render/mod.rs index 4136567..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()) @@ -239,10 +280,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, @@ -430,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)), ]); @@ -456,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 @@ -476,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( @@ -523,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) @@ -552,7 +609,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 { @@ -570,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) @@ -650,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() @@ -691,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 } @@ -731,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 @@ -750,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/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/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/src/theme/caps.rs b/src/theme/caps.rs index 3aac40f..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,34 +64,189 @@ 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 { - 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, if any. +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", + "ghostty", + "wezterm", + ] + .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 (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. @@ -79,7 +264,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 +278,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") { + return (true, "CLICOLOR_FORCE"); } - if env("CLICOLOR_FORCE").is_some_and(|v| v != "0") || env("FORCE_COLOR").is_some() { - return true; + 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. @@ -117,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(), } } @@ -238,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/detect.rs b/src/theme/detect.rs index 0027541..7e2b39c 100644 --- a/src/theme/detect.rs +++ b/src/theme/detect.rs @@ -1,24 +1,489 @@ -/// 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 { + if crate::platform::is_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/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 6cb21c4..6c7e6dd 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] @@ -534,3 +534,113 @@ 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;")); +} + +/// `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 new file mode 100644 index 0000000..c034e84 --- /dev/null +++ b/tests/pty.rs @@ -0,0 +1,423 @@ +//! 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") + .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())); + 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:?}"); +} + +/// 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)); + } +} + +/// 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 { + 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:?}"); +} + +#[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}"); +}