Files
kebab/crates/kebab-tui/src/theme.rs
altair823 95ba7d5b39 review(p9-fb-14): 회차 1 nit 반영
- ask.rs `push_turn_lines` Q label: `Role::Heading + add_modifier(BOLD)`
  의 BOLD 가 중복 (Role::Heading 이 양 팔레트 모두 BOLD 포함). 제거 +
  주석으로 \"Heading 은 이미 BOLD\" 명시.
- run.rs `render_ingest_status` aborted 분기: `Role::Title` (= White +
  BOLD) 보다 `Role::Warning` (Yellow) 이 \"비정상 종료\" 의미와 정렬.
  BOLD 는 명시적으로 add_modifier 하여 라이브 진행 라인과의 대비 유지.
- theme.rs `impl Default for Theme` 위에 doc comment 추가 — `default()
  == dark()` invariant 와 `default_palette_is_dark` 테스트가 묶여
  있음을 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:12:38 +00:00

281 lines
10 KiB
Rust

//! p9-fb-14: TUI palette + role-style mapping.
//!
//! Every pane (`library`, `search`, `ask`, `inspect`, `error_popup`,
//! the `run::render_root` shell) routes its `ratatui::style::Style`
//! through `Theme::style(role)` instead of inlining
//! `Style::default().fg(...)`. Adding a new role here is the only
//! place a color decision needs to land — accidental drift between
//! panes (`Cyan` for one badge, `LightCyan` for another) becomes a
//! single-file diff.
//!
//! ## Why role-based, not "style table"
//!
//! Earlier sketches keyed a hashmap by role at runtime. A `match`
//! against an enum is faster (no allocation, no hashing), exhaustive
//! at compile time (forgetting a role for `Theme::light` is a
//! compile error if you `match` exhaustively on `Role` in the
//! palette body — and we do), and lets `Theme::style` return
//! `Style` by value without lifetimes.
//!
//! ## Accessibility
//!
//! Color is never the *only* signal — the score badge ships
//! `[score=0.92]` text alongside its color, the mode badge ships
//! `[Hybrid]` text, the refusal renders the literal `(refused)`
//! prefix. The theme just amplifies signals that the text already
//! carries.
use ratatui::style::{Color, Modifier, Style};
/// Role-style enumeration. Adding a variant requires updating both
/// `dark_style` and `light_style` (the compiler enforces it via the
/// exhaustive `match` in each palette).
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum Role {
/// Active pane border (the focused one).
BorderActive,
/// Inactive pane border.
BorderInactive,
/// Document/section title (bold, accent color).
Title,
/// Secondary path / subpath (dim).
Path,
/// Lexical search-mode badge.
ModeLexical,
/// Vector search-mode badge.
ModeVector,
/// Hybrid search-mode badge.
ModeHybrid,
/// Selected row in any list (search hits, library docs, …).
Selected,
/// Dim hint / placeholder text (mode line subtext, "loading…").
Hint,
/// Section heading (bold + accent — Inspect uses this).
Heading,
/// Warning yellow — refusals, malformed-frontmatter notices.
Warning,
/// Error red — error overlays, "spawn failed" lines.
Error,
/// Success green — completed ingest, grounded answer.
Success,
/// Citation marker (`[1]`, `[2]`) and citation link text.
CitationMarker,
/// Bullet glyph in list rendering.
Bullet,
/// Default body text (no decoration). Returned as
/// `Style::default()` in both palettes — kept as a Role so
/// callers don't sprinkle `Style::default()` directly.
Body,
}
/// Palette identity. `Theme` carries this so panes can branch on
/// "is dark" if they need a different glyph (rarely needed since
/// roles already abstract the color), but in practice the
/// `Theme::style` dispatcher is the only consumer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Palette {
Dark,
Light,
}
#[derive(Clone, Debug)]
pub struct Theme {
palette: Palette,
}
impl Theme {
/// Default dark palette — intended for the typical terminal
/// (white-on-black scheme). Distinct from `Theme::light`.
pub fn dark() -> Self {
Self {
palette: Palette::Dark,
}
}
/// Light palette — intended for users running a light-background
/// terminal scheme. Hues stay the same; brightness shifts so the
/// foreground stays readable on white.
pub fn light() -> Self {
Self {
palette: Palette::Light,
}
}
/// Resolve a config string ("dark" / "light", case-insensitive)
/// to a `Theme`. Unknown values fall back to dark — never errors.
/// p9-fb-14 spec: "config never errors on a typo, the TUI just
/// keeps the default theme so the user has a working shell."
pub fn from_name(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Self::light(),
_ => Self::dark(),
}
}
/// The underlying palette identity. Mostly a debugging aid.
pub fn palette(&self) -> Palette {
self.palette
}
/// Resolve a `Role` to a `Style`. Both palettes implement every
/// role exhaustively (compile error if a variant is added but
/// the palette body forgets it).
pub fn style(&self, role: Role) -> Style {
match self.palette {
Palette::Dark => dark_style(role),
Palette::Light => light_style(role),
}
}
}
/// `Theme::default() == Theme::dark()` — pinned by
/// `default_palette_is_dark` test. If the default ever flips, both
/// the test and downstream callers (e.g. integration smokes that
/// rely on dark contrast) need a coordinated update.
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
/// Dark palette — high-contrast on black. The exhaustive match
/// guarantees adding a `Role` variant here forces the same in
/// `light_style`.
fn dark_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Cyan),
Role::BorderInactive => Style::default().fg(Color::DarkGray),
Role::Title => Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::DarkGray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Cyan),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Cyan),
Role::Bullet => Style::default().fg(Color::DarkGray),
Role::Body => Style::default(),
}
}
/// Light palette — high-contrast on white. Same hues as dark
/// (so user mental-models transfer) but with darker variants where
/// `Color::*` differs in 16-color terminals (e.g., `LightYellow`
/// would wash out on white, so `Yellow` stays).
fn light_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Blue),
Role::BorderInactive => Style::default().fg(Color::Gray),
Role::Title => Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::Gray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Blue),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Blue),
Role::Bullet => Style::default().fg(Color::Gray),
Role::Body => Style::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Both palettes resolve every `Role` to a `Style` (no panic /
/// no `unreachable!()` branch). The exhaustive match in
/// `dark_style` / `light_style` makes this true at compile
/// time, but we exercise it at runtime so a regression to
/// `match _ => unreachable!()` would surface in test instead
/// of in production.
#[test]
fn every_role_resolves_in_dark_and_light() {
let roles = [
Role::BorderActive,
Role::BorderInactive,
Role::Title,
Role::Path,
Role::ModeLexical,
Role::ModeVector,
Role::ModeHybrid,
Role::Selected,
Role::Hint,
Role::Heading,
Role::Warning,
Role::Error,
Role::Success,
Role::CitationMarker,
Role::Bullet,
Role::Body,
];
for r in roles {
let _ = Theme::dark().style(r);
let _ = Theme::light().style(r);
}
}
/// `Theme::from_name` recognizes exactly two palette names; any
/// other input falls back to dark. Pinned per spec: "config
/// never errors on a typo".
#[test]
fn from_name_recognizes_dark_light_and_falls_back() {
assert_eq!(Theme::from_name("dark").palette(), Palette::Dark);
assert_eq!(Theme::from_name("DARK").palette(), Palette::Dark);
assert_eq!(Theme::from_name(" dark ").palette(), Palette::Dark);
assert_eq!(Theme::from_name("light").palette(), Palette::Light);
assert_eq!(Theme::from_name("LIGHT").palette(), Palette::Light);
assert_eq!(Theme::from_name("solarized").palette(), Palette::Dark);
assert_eq!(Theme::from_name("").palette(), Palette::Dark);
}
/// `Theme::default()` is dark — pinned so the default doesn't
/// silently flip in a future refactor.
#[test]
fn default_palette_is_dark() {
assert_eq!(Theme::default().palette(), Palette::Dark);
}
/// Critical roles emit `Style` with at least one decoration —
/// catches regressions where someone replaces a styled palette
/// branch with a bare `Style::default()`. `Body` is excluded
/// (it intentionally returns the default).
#[test]
fn primary_roles_carry_decoration_in_dark() {
let theme = Theme::dark();
for r in [
Role::Title,
Role::Selected,
Role::Heading,
Role::Error,
Role::Warning,
Role::Success,
] {
let style = theme.style(r);
let has_color = style.fg.is_some() || style.bg.is_some();
let has_modifier = !style.add_modifier.is_empty();
assert!(
has_color || has_modifier,
"role {:?} resolves to bare Style::default() in dark palette",
r
);
}
}
}