From 3919dd608373fcf5f76b58f8fba905c3f118a3af Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Sat, 3 Oct 2026 23:27:12 +0200 Subject: [PATCH 1/3] fix(mermaid): align pie bars when a label is wider than 12 columns The label column was a fixed {:>12}, so "Documentation" pushed its bar one column right. Size it to the widest label in display columns, and cut labels with an ellipsis only when the chart is too narrow. Closes #30 --- src/layout/mermaid/mod.rs | 23 ++++++++++++++++--- src/layout/mermaid/tests.rs | 23 +++++++++++++++++++ .../plain_snapshots__plain_diagrams_md.snap | 11 +++++---- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index b4111b1..d5a2844 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -807,9 +807,22 @@ fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec12}` let longer labels push their bar right. Labels are cut + // only when the chart is too narrow to hold them beside a 10-cell bar. + // "│ " + label + " " + bar + " 100.0%" + const FIXED: usize = 3 + 1 + 7; + let widest = slices + .iter() + .map(|(l, _)| text::width(l)) + .max() + .unwrap_or(0); + let label_w = widest.max(12).min(width.saturating_sub(FIXED + 10)).max(1); // 30 cells at the usual 56-column chart width; less when narrower. - let max_bar = 30usize.min(width.saturating_sub(26)).max(1); + let max_bar = 30usize + .min(width.saturating_sub(FIXED + label_w + 1)) + .max(1); for (i, (label, value)) in slices.iter().enumerate() { let pct = if total > 0.0 { value / total * 100.0 @@ -829,7 +842,11 @@ fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec12} ", label), + text: { + let label = text::truncate(label, label_w); + let pad = label_w.saturating_sub(text::width(&label)); + format!("{}{label} ", " ".repeat(pad)) + }, style: SpanStyle { fg: Some(color.clone()), bold: true, diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index 031e687..4c122bb 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -469,6 +469,29 @@ fn sequence_and_pie_wrap_when_narrow_and_keep_their_form_when_not() { ); } +// Issue #30: a label wider than 12 columns ("Documentation") pushed its bar +// one column right of the others. +#[test] +fn pie_bars_share_a_start_column_whatever_the_label_length() { + let pie = "pie\n title Weekly Time Allocation\n \"Coding\" : 40\n \"Code Review\" : 15\n \"Meetings\" : 15\n \"Planning\" : 10\n \"Documentation\" : 10\n \"Learning\" : 10"; + for w in [40, 60, 150] { + let out = render(pie, w); + let starts: Vec = out + .lines() + .filter(|l| l.contains('█')) + .map(|l| text::width(&l[..l.find('█').unwrap()])) + .collect(); + assert_eq!(starts.len(), 6, "{out}"); + assert!(starts.iter().all(|&s| s == starts[0]), "width {w}:\n{out}"); + assert!(out.contains("Documentation"), "width {w}:\n{out}"); + } + // Too narrow for the full label: cut with an ellipsis, still aligned. + let out = render(pie, 24); + for line in out.lines() { + assert!(text::width(line) <= 24, "{line}"); + } +} + #[test] fn corpus_is_big_enough() { assert!(corpus().len() >= 20, "{} diagrams", corpus().len()); diff --git a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap index e8d703b..8e3fbe2 100644 --- a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap +++ b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap @@ -1,5 +1,6 @@ --- source: tests/plain_snapshots.rs +assertion_line: 46 expression: rendered --- @@ -149,12 +150,12 @@ expression: rendered ▎ Time Allocation ╭─ Weekly Time Allocation ─────────────────────────────╮ - │  Coding ████████████ 40.0% - │  Code Review ████ 15.0% - │  Meetings ████ 15.0% - │  Planning ███ 10.0% + │  Coding ████████████ 40.0% + │  Code Review ████ 15.0% + │  Meetings ████ 15.0% + │  Planning ███ 10.0% │ Documentation ███ 10.0% - │  Learning ███ 10.0% + │  Learning ███ 10.0% ╰──────────────────────────────────────────────────────╯ From 59a3504c7b93afcbcc66ca7cd9032c8165157c19 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Sat, 3 Oct 2026 23:27:12 +0200 Subject: [PATCH 2/3] feat(theme): terminal theme that follows the terminal's own palette Theme colors may now be ansi:, ansi:0-15 or default besides hex; they are emitted as palette escapes at every color depth. The new built-in 'terminal' theme uses nothing else and highlights code with an ANSI syntect theme (code_theme = "ansi"). Closes #29 --- README.md | 14 +++-- docs/index.html | 4 +- docs/llms-full.txt | 4 +- docs/llms.txt | 4 +- src/app.rs | 1 + src/cli.rs | 2 +- src/highlight.rs | 101 +++++++++++++++++++++++++++++- src/layout/mod.rs | 17 +++-- src/render/mod.rs | 10 ++- src/render/plain.rs | 11 +--- src/theme/builtin.rs | 52 ++++++++++++++++ src/theme/caps.rs | 25 ++++---- src/theme/mod.rs | 144 +++++++++++++++++++++++++++++++++++++++++-- tests/cli.rs | 21 +++++++ 14 files changed, 366 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 8dbdf9c..11394c9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh The installer picks the right binary for your OS, CPU and libc (static musl build on Alpine, Raspberry Pi and older-glibc systems), checks it against `SHA256SUMS` and installs to `/usr/local/bin` (using `sudo` only if it has to). Pin a version with `INK_VERSION=v0.8.0` or change the target with `INK_INSTALL_DIR="$HOME/.local/bin"`: ```bash -curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | INK_VERSION=v0.10.0 INK_INSTALL_DIR="$HOME/.local/bin" sh +curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | INK_VERSION=v0.11.0 INK_INSTALL_DIR="$HOME/.local/bin" sh ``` ### Quick install (Windows PowerShell) @@ -38,7 +38,7 @@ curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | INK_V irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex ``` -Installs `ink.exe` (x64 or ARM64) to `%LOCALAPPDATA%\Programs\ink` and adds it to your user `PATH`. Set `$env:INK_VERSION = "v0.10.0"` first to pin a version. +Installs `ink.exe` (x64 or ARM64) to `%LOCALAPPDATA%\Programs\ink` and adds it to your user `PATH`. Set `$env:INK_VERSION = "v0.11.0"` first to pin a version. ### Homebrew (macOS / Linux) @@ -237,9 +237,11 @@ Press `t` to toggle a sidebar showing every heading in the document. Tracks your Press `o` to move into it (it opens if it was closed). `j`/`k` or the arrows move, `g`/`G` go to the first and last heading, `Ctrl+d`/`Ctrl+u` page, and `Enter` jumps there — `[` brings you back. `h`/`l` fold and unfold a section's subheadings in the sidebar, and `/` filters the list as you type (every word you type must appear, case doesn't matter). `Esc` clears the filter; `Esc` again, or `o`, goes back to the document without moving. With the mouse, click a heading to jump to it; the wheel over the sidebar scrolls the sidebar, not the page. -### 8 built-in themes +### 9 built-in themes -Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, and Solarized. Press `T` to open the theme picker and preview each one live. +Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, Solarized, and Terminal. Press `T` to open the theme picker and preview each one live. + +`terminal` uses your terminal's own colour scheme instead of fixed colours — the 16 palette colours and the default foreground, no background of its own, and code highlighted with the palette too (like `bat --theme=ansi`). It looks right on light and dark terminals without switching themes. 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. @@ -357,7 +359,7 @@ ink reads `$XDG_CONFIG_HOME/ink/config.toml` when `XDG_CONFIG_HOME` is set, othe Create the file: ```toml -# Default theme (dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized) +# Default theme (dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized, terminal) theme = "catppuccin" # Max rendering width in columns @@ -403,6 +405,8 @@ ink --theme mytheme README.md Every color is customizable — headings, code, links, blockquotes, admonitions, status bar, and more. Check any built-in theme in `src/theme/builtin.rs` for the full list of color keys. +A color is a hex value (`"#7aa2f7"`), one of your terminal's palette colours (`"ansi:blue"`, `"ansi:bright-black"`, or `"ansi:0"` through `"ansi:15"`), or `"default"` for the terminal's own foreground/background. Set `code_theme = "ansi"` to highlight code with the palette. + ## Shell integration ```bash diff --git a/docs/index.html b/docs/index.html index b36e85f..4193899 100644 --- a/docs/index.html +++ b/docs/index.html @@ -306,8 +306,8 @@

Mermaid diagrams

Flowcharts, state, class and ER diagrams laid out as boxes and arrows, plus sequence, pie, Gantt and mindmaps.

-

8 themes

-

Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, Solarized. Or make your own.

+

9 themes

+

Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, Solarized, and Terminal, which follows your terminal's own palette. Or make your own.

File browser

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index c6b38de..f0c2e0b 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -71,13 +71,13 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Browser opens a file then exits by default; press Shift+B to reopen, or set behavior.browser_loop = true to return automatically ### Themes -- 8 built-in themes: dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized +- 9 built-in themes: dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized, terminal (follows the terminal's own 16-colour palette) - Auto-detection of terminal background (dark/light) by querying the terminal (OSC 11), falling back to COLORFGBG - 24-bit, 256-colour and 16-colour output chosen from the terminal; --ascii (or [behavior] ascii = true) draws borders, bullets and markers in plain ASCII, automatic on the Linux console and non-UTF-8 locales - --no-mouse leaves the mouse to the terminal for one session - Live theme preview with T key; the chosen theme is saved to config - ink --list-themes lists all available themes (built-in + custom) -- 30+ customizable color properties per theme +- 30+ customizable color properties per theme; a color is hex, an ANSI palette slot (ansi:blue, ansi:bright-black, ansi:0–15) or default - Custom themes via TOML files in the themes/ folder of the ink config directory ($XDG_CONFIG_HOME/ink/themes/ when set) - Honors NO_COLOR and quantizes 24-bit colors to the 256-color palette on non-truecolor terminals (truecolor detected via COLORTERM, WT_SESSION, or TERM_PROGRAM vscode/ghostty/WezTerm/iTerm.app) diff --git a/docs/llms.txt b/docs/llms.txt index 41e0262..3c5d864 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -16,7 +16,7 @@ ink is a command-line tool that renders markdown files in the terminal with rich - Interactive TUI with multi-tab support, in-document search (n/N to cycle matches), keyboard link opening (press f), a help overlay (?), and a table of contents sidebar - Text selection and clipboard copy: v/V for character- and line-wise visual selection (y copies), mouse drag with double-click word and triple-click line, c to copy a code block's raw source, Y to copy the current section's markdown; delivered over OSC 52 (works across SSH/tmux) and a native helper (pbcopy/wl-copy/xclip/xsel/clip.exe) - File browser mode for exploring directories of markdown files -- 8 built-in color themes with auto dark/light detection, live theme picker that saves to config, plus custom TOML themes +- 9 built-in color themes (including terminal, which follows your terminal's palette) with auto dark/light detection, live theme picker that saves to config, plus custom TOML themes - Presentation mode (split on --- separators), navigate with arrows/space - Watch mode for live-reloading on file changes - Pager mode: `ink --plain` on a TTY pages long output through $PAGER (like bat); --line-range START:END renders only those source lines @@ -39,7 +39,7 @@ ink is a command-line tool that renders markdown files in the terminal with rich curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh # macOS / Linux (picks glibc or static musl build) irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex # Windows PowerShell (x64 / ARM64) cargo install ink-md # from source (Rust 1.90+) -INK_VERSION=v0.10.0 sh install.sh # pin a version +INK_VERSION=v0.11.0 sh install.sh # pin a version Prebuilt: Linux amd64/arm64 (glibc 2.35+), static musl Linux amd64/arm64/armv7 (any distro), macOS amd64/arm64, Windows amd64/arm64; .deb/.rpm and tar.gz/zip archives include the man page and completions. Verify with SHA256SUMS or `gh attestation verify --repo borghei/ink`. diff --git a/src/app.rs b/src/app.rs index 9def339..6f158c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -182,6 +182,7 @@ const THEME_LIST: &[&str] = &[ "tokyo-night", "gruvbox", "solarized", + "terminal", ]; /// How the document viewer exited. diff --git a/src/cli.rs b/src/cli.rs index f387914..af4880a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -674,7 +674,7 @@ fn config_init(force: bool) -> Result<()> { pub(crate) const STARTER_CONFIG: &str = r#"# ink configuration # https://github.com/borghei/ink -# Color theme: dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized +# Color theme: dark, light, dracula, catppuccin, nord, tokyo-night, gruvbox, solarized, terminal # theme = "catppuccin" # Max rendering width in columns (or use --width on the CLI) diff --git a/src/highlight.rs b/src/highlight.rs index c9ed9d7..2ab048c 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -6,10 +6,16 @@ //! every startup, resize, theme-picker keystroke, and watch reload. Loading //! them once behind a `OnceLock` removes that cost from every rebuild. +use std::str::FromStr; use std::sync::OnceLock; -use syntect::highlighting::ThemeSet; +use syntect::highlighting::{ + Color, FontStyle, ScopeSelectors, StyleModifier, Theme, ThemeItem, ThemeSet, ThemeSettings, +}; use syntect::parsing::SyntaxSet; +/// Name of the code theme drawn in the terminal's own 16-colour palette. +pub const ANSI_CODE_THEME: &str = "ansi"; + pub fn syntax_set() -> &'static SyntaxSet { static SYNTAX_SET: OnceLock = OnceLock::new(); SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) @@ -17,5 +23,96 @@ pub fn syntax_set() -> &'static SyntaxSet { pub fn theme_set() -> &'static ThemeSet { static THEME_SET: OnceLock = OnceLock::new(); - THEME_SET.get_or_init(ThemeSet::load_defaults) + THEME_SET.get_or_init(|| { + let mut set = ThemeSet::load_defaults(); + set.themes.insert(ANSI_CODE_THEME.to_string(), ansi_theme()); + set + }) +} + +/// A syntect colour that stands for ANSI palette slot `idx` rather than an +/// RGB value (the encoding bat uses): alpha 0, the index in `r`. +const fn ansi(idx: u8) -> Color { + Color { + r: idx, + g: 0, + b: 0, + a: 0, + } +} + +/// Alpha 1 marks "the terminal's default colour". +const DEFAULT: Color = Color { + r: 0, + g: 0, + b: 0, + a: 1, +}; + +/// The theme-colour string for a highlighted token: `ansi:N`, `None` for the +/// default colour (the caller falls back to the theme's text colour), or hex. +pub fn token_color(c: Color) -> Option { + match c.a { + 0 => Some(format!("ansi:{}", c.r & 15)), + 1 => None, + _ => Some(format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)), + } +} + +fn ansi_theme() -> Theme { + // (scopes, palette slot, italic) + let rules: &[(&str, Color, bool)] = &[ + ("comment, punctuation.definition.comment", ansi(8), true), + ("string, punctuation.definition.string", ansi(2), false), + ("constant.character.escape", ansi(6), false), + ( + "constant.numeric, constant.language, constant.character", + ansi(3), + false, + ), + ( + "keyword, storage.modifier, storage.type.function", + ansi(5), + false, + ), + ("keyword.operator", DEFAULT, false), + ( + "storage.type, support.type, entity.name.type, entity.name.class", + ansi(6), + false, + ), + ( + "entity.name.function, support.function, meta.function-call", + ansi(4), + false, + ), + ("entity.name.tag", ansi(4), false), + ("entity.other.attribute-name", ansi(3), false), + ("variable.language, support.constant", ansi(1), false), + ("markup.heading", ansi(4), false), + ("markup.inserted", ansi(2), false), + ("markup.deleted", ansi(1), false), + ("invalid", ansi(1), false), + ]; + Theme { + name: Some(ANSI_CODE_THEME.to_string()), + author: None, + settings: ThemeSettings { + foreground: Some(DEFAULT), + ..ThemeSettings::default() + }, + scopes: rules + .iter() + .filter_map(|&(scope, color, italic)| { + Some(ThemeItem { + scope: ScopeSelectors::from_str(scope).ok()?, + style: StyleModifier { + foreground: Some(color), + background: None, + font_style: italic.then_some(FontStyle::ITALIC), + }, + }) + }) + .collect(), + } } diff --git a/src/layout/mod.rs b/src/layout/mod.rs index a1e78ff..4f35212 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -828,6 +828,15 @@ fn image_backdrop(theme: &Theme) -> (u8, u8, u8) { if let Some(bg) = &theme.colors.bg { return crate::theme::hex_to_rgb(bg); } + // A `default` foreground says nothing about the theme's darkness; ask + // the terminal instead. + if crate::theme::parse_color(&theme.colors.fg) == crate::theme::ColorSpec::Default { + return if crate::theme::detect::is_dark_background() { + (0, 0, 0) + } else { + (255, 255, 255) + }; + } let (r, g, b) = crate::theme::hex_to_rgb(&theme.colors.fg); let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; // Light foreground → dark theme → composite over black; else white. @@ -1107,10 +1116,10 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut content.push(StyledSpan { text: trimmed.to_string(), style: SpanStyle { - fg: Some(format!( - "#{:02x}{:02x}{:02x}", - style.foreground.r, style.foreground.g, style.foreground.b - )), + fg: Some( + crate::highlight::token_color(style.foreground) + .unwrap_or_else(|| ctx.theme.colors.fg.clone()), + ), ..Default::default() }, }); diff --git a/src/render/mod.rs b/src/render/mod.rs index de9e474..01d5132 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -129,7 +129,9 @@ fn progress_line( let progress = (scroll_offset as f64 / (total_lines - viewport_height) as f64).min(1.0); let filled = ((progress * width as f64) as usize).max(1); let empty = width.saturating_sub(filled); - let rest = if accent == bg && glyphs.progress == glyphs.progress_rest { + // A `default` bar background (Reset) cannot hide the remainder either: + // drawn in Reset it shows in the terminal's foreground. + let rest = if (accent == bg || bg == Color::Reset) && glyphs.progress == glyphs.progress_rest { " " } else { glyphs.progress_rest @@ -1013,6 +1015,12 @@ mod tests { // 16 colours that differ keep the hidden remainder glyph. let rest = drawn_bar(50, Color::Cyan, Color::Black, g); assert_eq!(rest, "▔".repeat(20)); + // A `default` bar background (the terminal theme) cannot hide the + // remainder by colour, so it is blank even though the accent differs. + assert_eq!( + drawn_bar(50, Color::Cyan, Color::Reset, g), + format!("{}{}", "▔".repeat(10), " ".repeat(10)) + ); // ASCII glyphs already differ: `=` filled, `-` remainder. let ascii = drawn_bar(50, a, b, &crate::glyphs::ASCII); assert_eq!(ascii, format!("{}{}", "=".repeat(10), "-".repeat(10))); diff --git a/src/render/plain.rs b/src/render/plain.rs index 1e84798..7a5fa18 100644 --- a/src/render/plain.rs +++ b/src/render/plain.rs @@ -76,10 +76,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, depth)); + output.push_str(&theme::sgr_color(fg, true, depth)); } if let Some(ref bg) = span.style.bg { - output.push_str(&sgr_color(theme::hex_to_rgb(bg), false, depth)); + output.push_str(&theme::sgr_color(bg, false, depth)); } } if !codes.is_empty() { @@ -119,10 +119,3 @@ pub fn render_plain_with_color(source: &str, args: &Args, color: bool) -> Result Ok(output) } - -/// Build an SGR color escape at the terminal's depth: 24-bit, the 256-color -/// cube, or one of the 16 ANSI colours. `fg` selects foreground vs -/// background. -fn sgr_color(rgb: (u8, u8, u8), fg: bool, depth: theme::caps::ColorLevel) -> String { - format!("\x1b[{}m", theme::caps::sgr_params(rgb, fg, depth)) -} diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index 515db68..a1ec4c3 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -341,3 +341,55 @@ pub fn solarized() -> Theme { }, } } + +/// Follows the terminal's own colour scheme: every colour is one of the 16 +/// palette slots or the terminal default, there is no background, and code +/// is highlighted with the palette too (`code_theme = "ansi"`). One theme for +/// light and dark terminals alike, like `bat --theme=ansi`. +pub fn terminal() -> Theme { + let c = |s: &str| s.to_string(); + Theme { + name: c("terminal"), + code_theme: c(crate::highlight::ANSI_CODE_THEME), + colors: ThemeColors { + bg: None, + fg: c("default"), + heading1: c("ansi:blue"), + heading2: c("ansi:cyan"), + heading3: c("ansi:magenta"), + heading4: c("ansi:green"), + heading5: c("ansi:yellow"), + heading6: c("ansi:red"), + bold: c("default"), + italic: c("default"), + strikethrough: c("ansi:bright-black"), + code_fg: c("ansi:yellow"), + code_bg: c("default"), + code_block_bg: c("default"), + link: c("ansi:blue"), + link_url: c("ansi:bright-black"), + blockquote_bar: c("ansi:bright-black"), + blockquote_text: c("default"), + list_bullet: c("ansi:cyan"), + list_number: c("ansi:cyan"), + table_border: c("ansi:bright-black"), + table_header: c("ansi:cyan"), + hr: c("ansi:bright-black"), + task_done: c("ansi:green"), + task_pending: c("ansi:bright-black"), + search_match: c("ansi:yellow"), + search_current: c("ansi:red"), + selection_bg: Some(c("ansi:blue")), + selection_fg: Some(c("ansi:bright-white")), + status_bar_bg: c("default"), + status_bar_fg: c("default"), + toc_active: c("ansi:blue"), + toc_inactive: c("ansi:bright-black"), + admonition_note: c("ansi:blue"), + admonition_warning: c("ansi:yellow"), + admonition_tip: c("ansi:green"), + admonition_important: c("ansi:magenta"), + admonition_caution: c("ansi:red"), + }, + } +} diff --git a/src/theme/caps.rs b/src/theme/caps.rs index 8772d51..109d5c3 100644 --- a/src/theme/caps.rs +++ b/src/theme/caps.rs @@ -437,21 +437,24 @@ pub fn sgr_params((r, g, b): (u8, u8, u8), fg: bool, level: ColorLevel) -> Strin 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::Ansi16 => ansi16_sgr(rgb_to_ansi16(r, g, b), fg).to_string(), ColorLevel::None => String::new(), } } +/// The SGR parameter for ANSI colour `idx` (0–15) as foreground or +/// background: 30–37 / 40–47 for the normal eight, 90–97 / 100–107 bright. +pub fn ansi16_sgr(idx: u8, fg: bool) -> u16 { + let i = (idx & 15) as u16; + let base = match (fg, i < 8) { + (true, true) => 30, + (true, false) => 90 - 8, + (false, true) => 40, + (false, false) => 100 - 8, + }; + base + i +} + /// Map an RGB triple to the nearest xterm-256 palette index. pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 { // Grayscale ramp (232..=255) when the channels are close together. diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 57f304f..de6f3c6 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -14,6 +14,7 @@ pub const BUILTIN_THEMES: &[&str] = &[ "tokyo-night", "gruvbox", "solarized", + "terminal", ]; /// All available theme names: built-ins plus any `*.toml` in the user themes @@ -151,6 +152,7 @@ pub fn resolve_theme(name: &str) -> Theme { "tokyo-night" => builtin::tokyo_night(), "gruvbox" => builtin::gruvbox(), "solarized" => builtin::solarized(), + "terminal" => builtin::terminal(), _ => { // Try loading from the user config directory. Note the warning is // emitted on every path out of here, including "this platform has @@ -174,8 +176,80 @@ pub fn resolve_theme(name: &str) -> Theme { } } -/// Parse a hex color string to RGB. Returns (200,200,200) for invalid input. -pub fn hex_to_rgb(hex: &str) -> (u8, u8, u8) { +/// A theme colour as written: a 24-bit hex value, one of the terminal's 16 +/// palette slots, or the terminal's own default foreground/background. +/// +/// Palette slots and `default` let a theme follow the user's terminal scheme +/// instead of fixing exact colours (the `terminal` theme is built from +/// nothing else). They are spelled `ansi:red`, `ansi:bright-blue`, `ansi:0` +/// to `ansi:15`, and `default`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorSpec { + Rgb(u8, u8, u8), + /// ANSI palette index 0–15. + Ansi(u8), + /// The terminal's default colour (SGR 39 / 49). + Default, +} + +const ANSI_NAMES: [&str; 8] = [ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", +]; + +/// Parse a theme colour. Anything that is not a palette name or `default` +/// is read as hex, so invalid input degrades exactly as it always has. +pub fn parse_color(s: &str) -> ColorSpec { + let s = s.trim(); + if s.eq_ignore_ascii_case("default") { + return ColorSpec::Default; + } + if let Some(name) = s + .get(..5) + .filter(|p| p.eq_ignore_ascii_case("ansi:")) + .map(|_| s[5..].to_ascii_lowercase()) + { + if let Ok(i) = name.parse::() { + if i < 16 { + return ColorSpec::Ansi(i); + } + } + let (base, bright) = match name.strip_prefix("bright-") { + Some(rest) => (rest, true), + None => (name.as_str(), false), + }; + if let Some(i) = ANSI_NAMES.iter().position(|n| *n == base) { + return ColorSpec::Ansi(i as u8 + if bright { 8 } else { 0 }); + } + } + let (r, g, b) = parse_hex(s); + ColorSpec::Rgb(r, g, b) +} + +/// Parse a theme colour to RGB. Palette slots map to xterm's default values +/// (the terminal's actual palette is unknown); `default` and invalid input +/// give (200,200,200). +pub fn hex_to_rgb(color: &str) -> (u8, u8, u8) { + match parse_color(color) { + ColorSpec::Rgb(r, g, b) => (r, g, b), + ColorSpec::Ansi(i) => caps::XTERM_16[i as usize], + ColorSpec::Default => (200, 200, 200), + } +} + +/// The SGR escape selecting `color` as foreground (`fg`) or background at +/// `level`. Palette slots and `default` are emitted as themselves at every +/// level, so the terminal's scheme applies. +pub fn sgr_color(color: &str, fg: bool, level: caps::ColorLevel) -> String { + let params = match (parse_color(color), level) { + (_, caps::ColorLevel::None) => String::new(), + (ColorSpec::Rgb(r, g, b), _) => caps::sgr_params((r, g, b), fg, level), + (ColorSpec::Ansi(i), _) => caps::ansi16_sgr(i, fg).to_string(), + (ColorSpec::Default, _) => if fg { "39" } else { "49" }.to_string(), + }; + format!("\x1b[{params}m") +} + +fn parse_hex(hex: &str) -> (u8, u8, u8) { let hex = hex.trim_start_matches('#'); // Byte length alone is not enough to make the slices below safe: a 6-byte // string can be two multi-byte characters, and slicing would split one. @@ -193,9 +267,14 @@ pub fn hex_to_rgb(hex: &str) -> (u8, u8, u8) { /// 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); - caps::rgb_at_level(r, g, b, caps::tui_level()) +pub fn hex_to_color(color: &str) -> ratatui::style::Color { + let level = caps::tui_level(); + match parse_color(color) { + ColorSpec::Rgb(r, g, b) => caps::rgb_at_level(r, g, b, level), + ColorSpec::Ansi(_) if level == caps::ColorLevel::None => ratatui::style::Color::Reset, + ColorSpec::Ansi(i) => caps::ansi16_color(i), + ColorSpec::Default => ratatui::style::Color::Reset, + } } /// Is the reader drawing without colour? Highlights that would otherwise be @@ -269,6 +348,61 @@ mod hex_tests { assert_eq!(hex_to_rgb("00ff80"), (0, 255, 128)); } + #[test] + fn palette_names_and_default_parse() { + assert_eq!(parse_color("default"), ColorSpec::Default); + assert_eq!(parse_color("ansi:red"), ColorSpec::Ansi(1)); + assert_eq!(parse_color("ANSI:Bright-Blue"), ColorSpec::Ansi(12)); + assert_eq!(parse_color("ansi:bright-black"), ColorSpec::Ansi(8)); + assert_eq!(parse_color("ansi:15"), ColorSpec::Ansi(15)); + // Out of range or unknown names fall through to hex, i.e. the old + // invalid-colour fallback. + assert_eq!(parse_color("ansi:16"), ColorSpec::Rgb(200, 200, 200)); + assert_eq!(parse_color("ansi:orange"), ColorSpec::Rgb(200, 200, 200)); + assert_eq!(parse_color("ansi:é"), ColorSpec::Rgb(200, 200, 200)); + assert_eq!(parse_color("#ff0000"), ColorSpec::Rgb(255, 0, 0)); + } + + // Palette colours are emitted as palette escapes at every depth, so the + // terminal's scheme applies; with colour off nothing is emitted. + #[test] + fn palette_colours_survive_every_depth() { + use caps::ColorLevel::*; + for level in [TrueColor, Ansi256, Ansi16] { + assert_eq!(sgr_color("ansi:red", true, level), "\x1b[31m"); + assert_eq!(sgr_color("ansi:bright-cyan", false, level), "\x1b[106m"); + assert_eq!(sgr_color("default", true, level), "\x1b[39m"); + assert_eq!(sgr_color("default", false, level), "\x1b[49m"); + } + assert_eq!(sgr_color("#ff0000", true, TrueColor), "\x1b[38;2;255;0;0m"); + assert_eq!(sgr_color("ansi:red", true, None), "\x1b[m"); + } + + #[test] + fn terminal_theme_uses_only_palette_colours() { + let t = resolve_theme("terminal"); + assert_eq!(t.name, "terminal"); + assert!(t.colors.bg.is_none()); + let c = &t.colors; + for color in [ + &c.fg, + &c.heading1, + &c.heading6, + &c.code_fg, + &c.code_bg, + &c.link, + &c.table_border, + &c.status_bar_bg, + &c.status_bar_fg, + &c.search_match, + ] { + assert!(!matches!(parse_color(color), ColorSpec::Rgb(..)), "{color}"); + } + assert!(crate::highlight::theme_set() + .themes + .contains_key(&t.code_theme)); + } + /// Regression: the length guard counts bytes, so a 6-byte string of /// multi-byte characters passed it and then split a char while slicing. #[test] diff --git a/tests/cli.rs b/tests/cli.rs index feadc6b..e886231 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -161,6 +161,27 @@ fn known_theme_is_quiet() { .stderr(predicate::str::contains("falling back").not()); } +// Issue #29: the `terminal` theme draws only with the terminal's own palette +// (SGR 30–37/90–97 and 39), never a fixed 24-bit or 256-colour value — +// including inside highlighted code blocks. +#[test] +fn terminal_theme_emits_only_palette_colours() { + ink() + .args([ + "--plain", "--color", "always", "--theme", "terminal", "--width", "80", + ]) + .arg("tests/fixtures/test.md") + .assert() + .success() + .stderr(predicate::str::contains("falling back").not()) + .stdout( + predicate::str::contains("\x1b[34m") + .and(predicate::str::contains("\x1b[38;2;").not()) + .and(predicate::str::contains("\x1b[38;5;").not()) + .and(predicate::str::contains("\x1b[48;").not()), + ); +} + #[test] fn doctor_reports_without_a_tty() { // In a test harness stdout is a pipe: the protocol query must be skipped From ee2504f392480a77f5205892a8cbc94072e66fb0 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Sat, 3 Oct 2026 23:27:12 +0200 Subject: [PATCH 3/3] chore(release): v0.11.0 - terminal theme and ansi:/default theme colors (#29) - pie chart labels stay aligned (#30) --- CHANGELOG.md | 8 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- install.ps1 | 2 +- install.sh | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f115fed..868fbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.11.0 — 2026-10-03 + +### Added +- **`terminal` theme: use your terminal's colours.** A new built-in theme drawn only with the terminal's 16 palette colours and its default foreground, with no background of its own and code highlighted from the palette as well — so ink matches your terminal scheme on light and dark backgrounds without switching themes. Custom themes can do the same: any color may be `ansi:` (`ansi:blue`, `ansi:bright-black`, `ansi:0`–`ansi:15`) or `default`, and `code_theme = "ansi"` highlights code with the palette. (#29) + +### Fixed +- **Pie chart labels longer than 12 columns no longer push their bar out of line.** The label column now fits the widest label, measured in display columns, and long labels are cut with `…` only when the chart is too narrow. (#30) + ## 0.10.0 — 2026-10-01 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5ac1ffb..6c362e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "ink-md" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index c78c0ef..6e6e4d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ink-md" -version = "0.10.0" +version = "0.11.0" edition = "2021" # Highest rust-version declared by any resolved dependency (quantette, via # ratatui-image). CI's msrv job builds with exactly this toolchain. diff --git a/install.ps1 b/install.ps1 index f53d34b..dd70ac8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,7 +4,7 @@ # irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex # # Environment: -# $env:INK_VERSION = "v0.10.0" install a specific release (default: latest) +# $env:INK_VERSION = "v0.11.0" install a specific release (default: latest) # $env:INK_INSTALL_DIR = "C:\tools" install somewhere else # (default: %LOCALAPPDATA%\Programs\ink) # diff --git a/install.sh b/install.sh index c2beea7..80f34ee 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh # # Environment: -# INK_VERSION=v0.10.0 install a specific release (default: latest) +# INK_VERSION=v0.11.0 install a specific release (default: latest) # INK_INSTALL_DIR="$HOME/.local/bin" install somewhere else (default: /usr/local/bin) # # Options: