From e19e1831bbc2967d55e1dcd4575a41bab955e64c Mon Sep 17 00:00:00 2001 From: mingjian Date: Wed, 21 Oct 2026 17:46:08 +0800 Subject: [PATCH 1/2] feat(colors): replace palette roles under [colors] --- README.md | 24 +++ .../plannotator-tui/src/app/archive_view.rs | 5 +- crates/plannotator-tui/src/app/draw.rs | 24 ++- crates/plannotator-tui/src/app/header.rs | 2 +- crates/plannotator-tui/src/app/menu.rs | 4 +- crates/plannotator-tui/src/app/pick.rs | 4 +- crates/plannotator-tui/src/cli.rs | 9 +- crates/plannotator-tui/src/config.rs | 29 ++- crates/plannotator-tui/src/layout.rs | 7 +- crates/plannotator-tui/src/theme.rs | 189 +++++++++++++++++- crates/plannotator-tui/tests/theme.rs | 18 ++ 11 files changed, 277 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 87b47cb..728b301 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,30 @@ wins over the file, and `plannotator-tui config` prints whichever is in effect. backgrounds plannotator-tui paints itself change; the document keeps your terminal's own colours either way. +`[colors]` replaces individual roles of whichever palette is in force; a role left out keeps +its light or dark value. A colour is a name (`yellow`, `light-cyan`), a 256-colour index +(`238`) or `#rrggbb`. An unknown role or colour is an error that names it, and +`plannotator-tui config` prints the roles you set. Every role, with the dark palette's values +(apart from `selection`, which is reverse video by default): + +```toml +[colors] +accent = "cyan" # focused borders and gutter, the open file, menu and picker frames +heading = "cyan" # every heading level +comment = "yellow" # comment titles, the compose box, annotated rows in the gutter +looks-good = "green" +delete = "red" # also the struck-through text +comment-bg = "58" # the tint under commented text +looks-good-bg = "22" # the tint under looks-good text +block = "236" # the focused block and the focused tree row +toolbar = "238" # the floating toolbar and the idle header buttons +cursor = "240" # the keyboard cursor cell +selection = "#45475a" # a character selection, a picker row, a menu row +``` + +`cursor` and `selection` become plain backgrounds when set: the text on them keeps the +terminal's colour. + ### Keys Every key is rebindable under `[keys]`: an action, and the keys that run it. diff --git a/crates/plannotator-tui/src/app/archive_view.rs b/crates/plannotator-tui/src/app/archive_view.rs index 90ca137..706eb07 100644 --- a/crates/plannotator-tui/src/app/archive_view.rs +++ b/crates/plannotator-tui/src/app/archive_view.rs @@ -10,6 +10,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use super::draw::picker_help; use super::{App, Mode, label}; use crate::keys::{self, Action}; +use crate::theme::palette; impl App { pub(super) fn archive_key(&mut self, key: KeyEvent) { @@ -63,7 +64,7 @@ impl App { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::new().fg(Color::Cyan)) + .border_style(Style::new().fg(palette().accent)) .title(format!(" Archived annotations ({}) ", self.archive_items.len())) .title_bottom(picker_help( "select", @@ -99,7 +100,7 @@ impl App { let quote = item.annotation.anchor.original_text.split_whitespace().collect::>().join(" "); let body = item.annotation.body.split_whitespace().collect::>().join(" "); let style = if index == self.archive_cursor { - Style::new().fg(Color::Black).bg(Color::Cyan) + Style::new().fg(Color::Black).bg(palette().accent) } else { Style::new() }; diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index decc205..ffa26c6 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -26,9 +26,9 @@ const COMPOSE_WIDTH: u16 = 48; fn accent(kind: Kind) -> Color { match kind { - Kind::Comment => Color::Yellow, - Kind::LooksGood => Color::Green, - Kind::Delete => Color::Red, + Kind::Comment => palette().comment, + Kind::LooksGood => palette().looks_good, + Kind::Delete => palette().delete, } } @@ -99,7 +99,8 @@ impl App { fn draw_tree(&self, frame: &mut Frame, area: Rect) { let Some(tree) = &self.tree else { return }; let focused = self.focus == Focus::Tree; - let border = if focused { Style::new().fg(Color::Cyan) } else { Style::new().fg(Color::DarkGray) }; + let border = + if focused { Style::new().fg(palette().accent) } else { Style::new().fg(Color::DarkGray) }; let block = Block::default().borders(Borders::RIGHT).border_style(border); let inner = block.inner(area); frame.render_widget(block, area); @@ -123,7 +124,7 @@ impl App { }; let mut style = if row.is_dir { Style::new().dim() } else { Style::new() }; if open_path == Some(row.path.as_path()) { - style = style.bold().fg(Color::Cyan); + style = style.bold().fg(palette().accent); } let row_bg = (focused && i == self.tree_cursor).then(|| palette().block_bg); if let Some(bg) = row_bg { @@ -138,7 +139,7 @@ impl App { Line::from(vec![ Span::styled(format!(" {name}"), style), Span::styled(" ".repeat(pad), with_bg(Style::new())), - Span::styled(count, with_bg(Style::new().fg(Color::Yellow))), + Span::styled(count, with_bg(Style::new().fg(palette().comment))), ]) }) .collect(); @@ -179,7 +180,7 @@ impl App { Kind::Comment => Style::new().bg(palette().comment_bg), Kind::LooksGood => Style::new().bg(palette().approve_bg), Kind::Delete => { - Style::new().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT | Modifier::DIM) + Style::new().fg(palette().delete).add_modifier(Modifier::CROSSED_OUT | Modifier::DIM) } }; buf.set_style(Rect { x: doc.x + col as u16, y: screen_y, width: 1, height: 1 }, style); @@ -204,8 +205,8 @@ impl App { } let marker = match (block == self.selected, row_has_annotation) { - (true, _) => Span::styled("▍", Style::new().fg(Color::Cyan)), - (false, true) => Span::styled("▍", Style::new().fg(Color::Yellow)), + (true, _) => Span::styled("▍", Style::new().fg(palette().accent)), + (false, true) => Span::styled("▍", Style::new().fg(palette().comment)), (false, false) => Span::raw(" "), }; buf.set_span(gutter.x, screen_y, &marker, 1); @@ -285,7 +286,7 @@ impl App { let boxed = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::new().fg(Color::Yellow)) + .border_style(Style::new().fg(palette().comment)) .title(Span::styled(title.to_owned(), Style::new().dim())); let inner = boxed.inner(rect); frame.render_widget(boxed, rect); @@ -395,7 +396,8 @@ impl App { let label = format!(" {} ", label.as_deref().unwrap_or("Undo finish")); let width = (label.width() as u16).min(area.width); let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height }; - frame.render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(Color::Cyan)), rect); + frame + .render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(palette().accent)), rect); self.geometry.undo_button = Some(rect); area.width = area.width.saturating_sub(width); } diff --git a/crates/plannotator-tui/src/app/header.rs b/crates/plannotator-tui/src/app/header.rs index 3917b95..8f074b4 100644 --- a/crates/plannotator-tui/src/app/header.rs +++ b/crates/plannotator-tui/src/app/header.rs @@ -81,7 +81,7 @@ impl App { } Button::Review => { self.geometry.review_button = Some(rect); - Style::new().fg(Color::Cyan).bg(palette().toolbar_bg) + Style::new().fg(palette().accent).bg(palette().toolbar_bg) } }; frame.buffer_mut().set_span(rect.x, rect.y, &Span::styled(label, style), rect.width); diff --git a/crates/plannotator-tui/src/app/menu.rs b/crates/plannotator-tui/src/app/menu.rs index 10580d0..a37d116 100644 --- a/crates/plannotator-tui/src/app/menu.rs +++ b/crates/plannotator-tui/src/app/menu.rs @@ -5,7 +5,7 @@ use anyhow::Result; use ratatui::Frame; use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; @@ -180,7 +180,7 @@ impl App { let boxed = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::new().fg(Color::Cyan)) + .border_style(Style::new().fg(palette().accent)) .title(Span::styled(" review ", Style::new().dim())) .title_bottom(Span::styled(help, Style::new().dim())); let inner = boxed.inner(rect); diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index a4bf1d1..4f56f32 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -10,7 +10,7 @@ use plannotator_tui_hosts::Message; use ratatui::Frame; use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; @@ -145,7 +145,7 @@ impl App { let boxed = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::new().fg(Color::Cyan)) + .border_style(Style::new().fg(palette().accent)) .title(Span::styled(" which message? ", Style::new().dim())) .title_bottom(Span::styled( picker_help( diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 126b6c2..33f4bc0 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -337,11 +337,10 @@ pub(crate) fn run_ui(build: impl FnOnce(usize) -> Result) -> Result<()> { // A config that fails to parse never kept the plain TUI from starting; it still does // not. `plannotator-tui config` is where the error is reported. let config = Config::load().unwrap_or_default(); - crate::theme::install(crate::theme::resolve( - |key| std::env::var(key).ok(), - config.ui.theme, - crate::theme::detect, - )?); + crate::theme::install( + crate::theme::resolve(|key| std::env::var(key).ok(), config.ui.theme, crate::theme::detect)?, + &config.colors, + ); let mut terminal = ratatui::init(); execute!(stdout(), EnableMouseCapture)?; let _ = execute!(stdout(), EnableBracketedPaste); diff --git a/crates/plannotator-tui/src/config.rs b/crates/plannotator-tui/src/config.rs index 79d5bdc..d97fe93 100644 --- a/crates/plannotator-tui/src/config.rs +++ b/crates/plannotator-tui/src/config.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::keys::Keymap; -use crate::theme::ThemeSetting; +use crate::theme::{Colors, ThemeSetting}; #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] @@ -20,9 +20,10 @@ pub(crate) struct Config { pub(crate) herdr: HerdrConfig, pub(crate) ui: UiConfig, pub(crate) keys: Keymap, + pub(crate) colors: Colors, } -/// How the app looks. One key so far; the colours themselves are not configurable yet. +/// Which palette the app starts from; `[colors]` adjusts individual roles of it. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] pub(crate) struct UiConfig { @@ -251,4 +252,28 @@ mod tests { let text = Config::default().to_toml().expect("serializes"); assert_eq!(Config::parse(&text).expect("parses"), Config::default()); } + + #[test] + fn colors_are_read_by_role_and_roundtrip() { + let config = + Config::parse("[colors]\naccent = \"magenta\"\nlooks-good-bg = \"#a6e3a1\"\nblock = \"236\"\n") + .expect("parses"); + assert_eq!(config.colors.accent.map(|c| c.0), Some(ratatui::style::Color::Magenta)); + assert_eq!(config.colors.looks_good_bg.map(|c| c.0), Some(ratatui::style::Color::Rgb(166, 227, 161))); + assert_eq!(config.colors.heading, None); + let text = config.to_toml().expect("serializes"); + assert_eq!(Config::parse(&text).expect("reparses"), config); + } + + #[test] + fn an_unknown_color_role_error_names_the_role() { + let err = Config::parse("[colors]\nbackground = \"black\"\n").expect_err("rejected"); + assert!(err.to_string().contains("background"), "{err}"); + } + + #[test] + fn an_unreadable_color_error_names_the_value() { + let err = Config::parse("[colors]\nheading = \"chartreuse\"\n").expect_err("rejected"); + assert!(err.to_string().contains("chartreuse"), "{err}"); + } } diff --git a/crates/plannotator-tui/src/layout.rs b/crates/plannotator-tui/src/layout.rs index 1658628..ee1922c 100644 --- a/crates/plannotator-tui/src/layout.rs +++ b/crates/plannotator-tui/src/layout.rs @@ -12,6 +12,7 @@ use tui_markdown::{Options, StyleSheet}; use crate::doc::{BlockKind, Document}; use crate::srcmap::{LineOffsets, align}; +use crate::theme::palette; use crate::wrap::{Row, clip_line, wrap_line, wrap_table}; /// Rows of vertical space between blocks. @@ -25,9 +26,9 @@ struct Styles; impl StyleSheet for Styles { fn heading(&self, level: u8) -> Style { match level { - 1 => Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - 2 => Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), - _ => Style::new().fg(Color::LightCyan).add_modifier(Modifier::BOLD), + 1 => Style::new().fg(palette().heading).add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + 2 => Style::new().fg(palette().heading).add_modifier(Modifier::BOLD), + _ => Style::new().fg(palette().subheading).add_modifier(Modifier::BOLD), } } fn heading_marker(&self, _level: u8) -> &'static str { diff --git a/crates/plannotator-tui/src/theme.rs b/crates/plannotator-tui/src/theme.rs index 377c7d0..de4be54 100644 --- a/crates/plannotator-tui/src/theme.rs +++ b/crates/plannotator-tui/src/theme.rs @@ -6,7 +6,7 @@ //! installs a palette in tests or in headless runs, so those keep the dark one. //! //! Precedence: `PLANNOTATOR_TUI_THEME` → `[ui] theme` in the config file → asking the -//! terminal → dark. +//! terminal → dark. `[colors]` then replaces individual roles of whichever palette won. use std::fmt; use std::io::IsTerminal as _; @@ -83,14 +83,27 @@ impl Theme { } } -/// Every colour we paint that is not the terminal's own. +/// Every colour a theme or `[colors]` can change. /// -/// A role is here because it is painted *under* text whose colour belongs to the terminal: -/// a fixed dark background hides dark text on a light terminal, and the other way round. -/// Styles that set both a foreground and a background read the same either way and stay -/// where they are used. +/// The backgrounds are here because they are painted *under* text whose colour belongs to +/// the terminal: a fixed dark background hides dark text on a light terminal, and the other +/// way round. The foregrounds are ANSI colours that already follow the terminal, so both +/// palettes share them; they are here so `[colors]` can reach them. Styles that set both a +/// foreground and a background read the same either way and stay where they are used. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct Palette { + /// Focused borders and gutter, the open file in the tree, active header text. + pub(crate) accent: Color, + /// Level one and two headings. + pub(crate) heading: Color, + /// Level three and deeper headings. + pub(crate) subheading: Color, + /// Comment titles, the compose box, annotated rows in the gutter, tree counts. + pub(crate) comment: Color, + /// Looks-good titles and toolbar label. + pub(crate) looks_good: Color, + /// Delete titles, toolbar label and struck-through text. + pub(crate) delete: Color, /// The focused block's bar, and the focused row of the file tree. pub(crate) block_bg: Color, /// The floating toolbar and the idle header buttons. @@ -109,6 +122,12 @@ pub(crate) struct Palette { /// The palette this app has always had. Dark terminals must see no change at all. pub(crate) static DARK: Palette = Palette { + accent: Color::Cyan, + heading: Color::Cyan, + subheading: Color::LightCyan, + comment: Color::Yellow, + looks_good: Color::Green, + delete: Color::Red, block_bg: Color::Indexed(236), toolbar_bg: Color::Indexed(238), idle_fg: Color::Gray, @@ -123,6 +142,12 @@ pub(crate) static DARK: Palette = Palette { /// annotation tints are the pale ends of the same hues. `REVERSED` becomes a real /// background: reversing dark-on-light gives a black bar that swallows the tints under it. pub(crate) static LIGHT: Palette = Palette { + accent: DARK.accent, + heading: DARK.heading, + subheading: DARK.subheading, + comment: DARK.comment, + looks_good: DARK.looks_good, + delete: DARK.delete, block_bg: Color::Indexed(254), toolbar_bg: Color::Indexed(252), idle_fg: Color::DarkGray, @@ -133,17 +158,103 @@ pub(crate) static LIGHT: Palette = Palette { selection: Style::new().bg(Color::Indexed(153)), }; -static PALETTE: OnceLock<&'static Palette> = OnceLock::new(); +/// A colour as `[colors]` writes it: a name (`yellow`, `light-cyan`), a 256-colour index +/// (`238`) or `#rrggbb`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ColorValue(pub(crate) Color); + +impl FromStr for ColorValue { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + s.parse().map(Self).map_err(|_| { + anyhow::anyhow!("unknown color {s:?}; expected a name like yellow, an index 0-255, or #rrggbb") + }) + } +} + +impl Serialize for ColorValue { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.collect_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ColorValue { + fn deserialize>(deserializer: D) -> std::result::Result { + let text = String::deserialize(deserializer)?; + text.parse().map_err(serde::de::Error::custom) + } +} + +/// `[colors]`: per-role replacements for the palette in force. A role left out keeps the +/// light or dark value; `cursor` and `selection` become plain backgrounds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub(crate) struct Colors { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) accent: Option, + /// Every heading level. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) heading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) looks_good: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) delete: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) comment_bg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) looks_good_bg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) block: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) toolbar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) selection: Option, +} + +impl Palette { + /// This palette with every role `colors` names replaced. + pub(crate) fn with(mut self, colors: &Colors) -> Self { + let set = |slot: &mut Color, value: Option| { + if let Some(ColorValue(color)) = value { + *slot = color; + } + }; + set(&mut self.accent, colors.accent); + set(&mut self.heading, colors.heading); + set(&mut self.subheading, colors.heading); + set(&mut self.comment, colors.comment); + set(&mut self.looks_good, colors.looks_good); + set(&mut self.delete, colors.delete); + set(&mut self.comment_bg, colors.comment_bg); + set(&mut self.approve_bg, colors.looks_good_bg); + set(&mut self.block_bg, colors.block); + set(&mut self.toolbar_bg, colors.toolbar); + if let Some(ColorValue(color)) = colors.cursor { + self.cursor = Style::new().bg(color); + } + if let Some(ColorValue(color)) = colors.selection { + self.selection = Style::new().bg(color); + } + self + } +} + +static PALETTE: OnceLock = OnceLock::new(); /// The palette in force. Dark until [`install`] says otherwise, which is what tests, /// `--snapshot`, `--export` and every other headless run get. pub(crate) fn palette() -> &'static Palette { - PALETTE.get().copied().unwrap_or(&DARK) + PALETTE.get().unwrap_or(&DARK) } /// Fix the palette for this process. The first call wins; later ones are ignored. -pub(crate) fn install(theme: Theme) { - let _ = PALETTE.set(theme.palette()); +pub(crate) fn install(theme: Theme, colors: &Colors) { + let _ = PALETTE.set(theme.palette().with(colors)); } /// Ask the terminal whether its background is light or dark. @@ -266,6 +377,11 @@ mod tests { assert_eq!(DARK.approve_bg, Color::Indexed(22)); assert_eq!(DARK.cursor, Style::new().bg(Color::Indexed(240))); assert_eq!(DARK.selection, Style::new().add_modifier(Modifier::REVERSED)); + assert_eq!( + (DARK.accent, DARK.heading, DARK.subheading), + (Color::Cyan, Color::Cyan, Color::LightCyan) + ); + assert_eq!((DARK.comment, DARK.looks_good, DARK.delete), (Color::Yellow, Color::Green, Color::Red)); } /// Nothing installs a palette in a test binary, so every existing snapshot and style @@ -295,6 +411,59 @@ mod tests { assert!(LIGHT.selection.bg.is_some()); } + #[test] + fn no_colors_leave_both_palettes_alone() { + assert_eq!(DARK.with(&Colors::default()), DARK); + assert_eq!(LIGHT.with(&Colors::default()), LIGHT); + } + + #[test] + fn a_color_replaces_its_role_and_nothing_else() { + let colors = Colors { block: Some(ColorValue(Color::Rgb(1, 2, 3))), ..Colors::default() }; + let palette = LIGHT.with(&colors); + assert_eq!(palette.block_bg, Color::Rgb(1, 2, 3)); + assert_eq!(Palette { block_bg: LIGHT.block_bg, ..palette }, LIGHT); + } + + #[test] + fn heading_covers_every_level() { + let colors = Colors { heading: Some(ColorValue(Color::Magenta)), ..Colors::default() }; + let palette = DARK.with(&colors); + assert_eq!((palette.heading, palette.subheading), (Color::Magenta, Color::Magenta)); + } + + /// A configured cursor or selection is the whole style: the dark selection's reversal + /// and the light cursor's white glyph would fight a colour picked to match a theme. + #[test] + fn cursor_and_selection_become_plain_backgrounds() { + let colors = Colors { + cursor: Some(ColorValue(Color::Indexed(7))), + selection: Some(ColorValue(Color::Indexed(8))), + ..Colors::default() + }; + assert_eq!(LIGHT.with(&colors).cursor, Style::new().bg(Color::Indexed(7))); + assert_eq!(DARK.with(&colors).selection, Style::new().bg(Color::Indexed(8))); + } + + #[test] + fn a_color_reads_names_indexes_and_hex_and_prints_back_the_same() { + for (text, color) in [ + ("light-cyan", Color::LightCyan), + ("238", Color::Indexed(238)), + ("#1e1e2e", Color::Rgb(30, 30, 46)), + ] { + let parsed: ColorValue = text.parse().expect("parses"); + assert_eq!(parsed.0, color); + assert_eq!(parsed.0.to_string().parse::().expect("reparses"), parsed); + } + } + + #[test] + fn an_unreadable_color_is_an_error_naming_it() { + let err = "chartreuse".parse::().expect_err("rejected"); + assert!(err.to_string().contains("chartreuse"), "{err}"); + } + #[test] fn a_theme_setting_roundtrips_through_its_own_name() { for setting in [ThemeSetting::Auto, ThemeSetting::Light, ThemeSetting::Dark] { diff --git a/crates/plannotator-tui/tests/theme.rs b/crates/plannotator-tui/tests/theme.rs index fc566f9..7fee26a 100644 --- a/crates/plannotator-tui/tests/theme.rs +++ b/crates/plannotator-tui/tests/theme.rs @@ -33,6 +33,24 @@ fn the_printed_theme_is_the_environments_when_it_speaks() { assert!(config_output("env", "dark", Some("light")).contains("theme = \"light\"")); } +#[test] +fn configured_colors_are_printed_with_the_theme() { + let dir = std::env::temp_dir().join(format!("plannotator-tui-colors-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let path = dir.join("config.toml"); + std::fs::write(&path, "[colors]\nheading = \"#89B4FA\"\nlooks-good = \"green\"\n").expect("config"); + let out = Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) + .env("PLANNOTATOR_TUI_CONFIG", &path) + .env_remove("PLANNOTATOR_TUI_THEME") + .arg("config") + .output() + .expect("config runs"); + std::fs::remove_dir_all(&dir).expect("cleanup"); + let text = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{text}{}", String::from_utf8_lossy(&out.stderr)); + assert!(text.contains("[colors]\nheading = \"#89B4FA\"\nlooks-good = \"Green\""), "{text}"); +} + #[test] fn an_unreadable_theme_in_the_environment_is_reported_rather_than_ignored() { let mut command = Command::new(env!("CARGO_BIN_EXE_plannotator-tui")); From 69b692fcbe9b1d23fd715a7474df9de09b627c42 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 23 Oct 2026 14:31:10 -0400 Subject: [PATCH 2/2] fix(colors): accept a bare color index and print colors as the config spells them - heading = 238 reads like heading = "238" - plannotator-tui config prints light-blue, 238 and #rrggbb instead of ratatui's names - the archive's selected row reverses the accent so its text stays readable --- README.md | 2 +- .../plannotator-tui/src/app/archive_view.rs | 6 +- crates/plannotator-tui/src/config.rs | 8 ++ crates/plannotator-tui/src/theme.rs | 92 ++++++++++++++++++- crates/plannotator-tui/tests/theme.rs | 8 +- 5 files changed, 107 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 728b301..a5ceebc 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ colours either way. `[colors]` replaces individual roles of whichever palette is in force; a role left out keeps its light or dark value. A colour is a name (`yellow`, `light-cyan`), a 256-colour index -(`238`) or `#rrggbb`. An unknown role or colour is an error that names it, and +(`238`, quoted or not) or `#rrggbb`. An unknown role or colour is an error that names it, and `plannotator-tui config` prints the roles you set. Every role, with the dark palette's values (apart from `selection`, which is reverse video by default): diff --git a/crates/plannotator-tui/src/app/archive_view.rs b/crates/plannotator-tui/src/app/archive_view.rs index 706eb07..fb82a01 100644 --- a/crates/plannotator-tui/src/app/archive_view.rs +++ b/crates/plannotator-tui/src/app/archive_view.rs @@ -3,7 +3,7 @@ use ratatui::Frame; use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; @@ -100,7 +100,9 @@ impl App { let quote = item.annotation.anchor.original_text.split_whitespace().collect::>().join(" "); let body = item.annotation.body.split_whitespace().collect::>().join(" "); let style = if index == self.archive_cursor { - Style::new().fg(Color::Black).bg(palette().accent) + // Reversed accent: the row text takes the terminal background, which an accent + // picked to read as text on that background contrasts with. + Style::new().fg(palette().accent).add_modifier(Modifier::REVERSED) } else { Style::new() }; diff --git a/crates/plannotator-tui/src/config.rs b/crates/plannotator-tui/src/config.rs index d97fe93..a10aae5 100644 --- a/crates/plannotator-tui/src/config.rs +++ b/crates/plannotator-tui/src/config.rs @@ -265,6 +265,14 @@ mod tests { assert_eq!(Config::parse(&text).expect("reparses"), config); } + #[test] + fn a_color_may_be_a_bare_index() { + let config = Config::parse("[colors]\nheading = 238\n").expect("parses"); + assert_eq!(config.colors.heading.map(|c| c.0), Some(ratatui::style::Color::Indexed(238))); + let err = Config::parse("[colors]\nheading = 256\n").expect_err("out of range"); + assert!(err.to_string().contains("256"), "{err}"); + } + #[test] fn an_unknown_color_role_error_names_the_role() { let err = Config::parse("[colors]\nbackground = \"black\"\n").expect_err("rejected"); diff --git a/crates/plannotator-tui/src/theme.rs b/crates/plannotator-tui/src/theme.rs index de4be54..8fcde61 100644 --- a/crates/plannotator-tui/src/theme.rs +++ b/crates/plannotator-tui/src/theme.rs @@ -173,16 +173,71 @@ impl FromStr for ColorValue { } } +/// Prints the colour the way `[colors]` writes it, so `plannotator-tui config` output can be +/// pasted back: `light-blue`, `238`, `#1e1e2e`. +impl fmt::Display for ColorValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self.0 { + Color::Indexed(index) => return write!(f, "{index}"), + Color::Rgb(r, g, b) => return write!(f, "#{r:02x}{g:02x}{b:02x}"), + Color::Reset => "reset", + Color::Black => "black", + Color::Red => "red", + Color::Green => "green", + Color::Yellow => "yellow", + Color::Blue => "blue", + Color::Magenta => "magenta", + Color::Cyan => "cyan", + Color::Gray => "gray", + Color::DarkGray => "dark-gray", + Color::LightRed => "light-red", + Color::LightGreen => "light-green", + Color::LightYellow => "light-yellow", + Color::LightBlue => "light-blue", + Color::LightMagenta => "light-magenta", + Color::LightCyan => "light-cyan", + Color::White => "white", + }; + f.write_str(name) + } +} + impl Serialize for ColorValue { fn serialize(&self, serializer: S) -> std::result::Result { - serializer.collect_str(&self.0) + serializer.collect_str(self) } } +/// A string as above, or a bare 256-colour index: `heading = 238`. impl<'de> Deserialize<'de> for ColorValue { fn deserialize>(deserializer: D) -> std::result::Result { - let text = String::deserialize(deserializer)?; - text.parse().map_err(serde::de::Error::custom) + struct Visitor; + + impl serde::de::Visitor<'_> for Visitor { + type Value = ColorValue; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a color name, an index 0-255, or #rrggbb") + } + + fn visit_str(self, text: &str) -> std::result::Result { + text.parse().map_err(E::custom) + } + + fn visit_i64(self, index: i64) -> std::result::Result { + u8::try_from(index) + .map(|index| ColorValue(Color::Indexed(index))) + .map_err(|_| E::custom(format!("unknown color {index}; a color index is 0-255"))) + } + + fn visit_u64(self, index: u64) -> std::result::Result { + u8::try_from(index) + .map(|index| ColorValue(Color::Indexed(index))) + .map_err(|_| E::custom(format!("unknown color {index}; a color index is 0-255"))) + } + } + + deserializer.deserialize_any(Visitor) } } @@ -454,7 +509,36 @@ mod tests { ] { let parsed: ColorValue = text.parse().expect("parses"); assert_eq!(parsed.0, color); - assert_eq!(parsed.0.to_string().parse::().expect("reparses"), parsed); + assert_eq!(parsed.to_string(), text); + } + } + + #[test] + fn every_color_prints_in_a_spelling_that_parses_back() { + for color in [ + Color::Reset, + Color::Black, + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::Magenta, + Color::Cyan, + Color::Gray, + Color::DarkGray, + Color::LightRed, + Color::LightGreen, + Color::LightYellow, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::White, + Color::Indexed(0), + Color::Indexed(255), + Color::Rgb(0, 0, 255), + ] { + let text = ColorValue(color).to_string(); + assert_eq!(text.parse::().expect("parses").0, color, "{text}"); } } diff --git a/crates/plannotator-tui/tests/theme.rs b/crates/plannotator-tui/tests/theme.rs index 7fee26a..40bc9c1 100644 --- a/crates/plannotator-tui/tests/theme.rs +++ b/crates/plannotator-tui/tests/theme.rs @@ -38,7 +38,8 @@ fn configured_colors_are_printed_with_the_theme() { let dir = std::env::temp_dir().join(format!("plannotator-tui-colors-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("dir"); let path = dir.join("config.toml"); - std::fs::write(&path, "[colors]\nheading = \"#89B4FA\"\nlooks-good = \"green\"\n").expect("config"); + std::fs::write(&path, "[colors]\nheading = \"#89B4FA\"\nlooks-good = \"light-green\"\nblock = 236\n") + .expect("config"); let out = Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) .env("PLANNOTATOR_TUI_CONFIG", &path) .env_remove("PLANNOTATOR_TUI_THEME") @@ -48,7 +49,10 @@ fn configured_colors_are_printed_with_the_theme() { std::fs::remove_dir_all(&dir).expect("cleanup"); let text = String::from_utf8_lossy(&out.stdout); assert!(out.status.success(), "{text}{}", String::from_utf8_lossy(&out.stderr)); - assert!(text.contains("[colors]\nheading = \"#89B4FA\"\nlooks-good = \"Green\""), "{text}"); + assert!( + text.contains("[colors]\nheading = \"#89b4fa\"\nlooks-good = \"light-green\"\nblock = \"236\""), + "{text}" + ); } #[test]