M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
//! In-memory theme cache + resolution.
|
||||
//!
|
||||
//! The pager reads the active `ThemeKind` on every render frame, so the
|
||||
//! lookup must be cheaper than re-loading from `~/.kigi/config.toml`.
|
||||
//! [`current_kind`] returns the in-memory value, lazily seeding from the
|
||||
//! shell's layered effective config on first call.
|
||||
//!
|
||||
//! Disk writes are NOT performed here — they live in
|
||||
//! `kigi_shell::util::config::set_theme()` (and friends), invoked
|
||||
//! via `Effect::PersistSetting` from the dispatcher. This module is a
|
||||
//! pager-side in-memory cache + resolution layer only.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
use super::ThemeKind;
|
||||
use super::system_appearance;
|
||||
|
||||
/// In-memory theme kind, encoded as a `u8` matching the
|
||||
/// `ThemeKind` discriminants. Loaded from disk once at startup via
|
||||
/// `load_from_disk()`, then kept in sync by `set()`.
|
||||
static CURRENT: AtomicU8 = AtomicU8::new(ThemeKind::GrokNight as u8);
|
||||
static LOADED: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Whether auto-switching mode is active. Set when the config file
|
||||
/// contains `theme = "auto"`. Checked by the event loop to decide
|
||||
/// whether the `SystemAppearanceWatcher` should run.
|
||||
///
|
||||
/// Uses `AtomicBool` for thread-safe access from the watcher task.
|
||||
static AUTO_MODE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether the theme is locked to `Theme::terminal_default` for the whole
|
||||
/// session (minimal mode — no theming).
|
||||
static TERMINAL_NATIVE_LOCK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Decode the u8 stored in `CURRENT` back to a `ThemeKind`. Falls
|
||||
/// back to `GrokNight` if the byte is somehow out of range (which
|
||||
/// can't happen via `set` — the discriminant is always a valid
|
||||
/// variant — but defends against a future variant addition that
|
||||
/// forgot to extend this match).
|
||||
fn theme_kind_from_u8(byte: u8) -> ThemeKind {
|
||||
match byte {
|
||||
x if x == ThemeKind::GrokNight as u8 => ThemeKind::GrokNight,
|
||||
x if x == ThemeKind::GrokDay as u8 => ThemeKind::GrokDay,
|
||||
x if x == ThemeKind::TokyoNight as u8 => ThemeKind::TokyoNight,
|
||||
x if x == ThemeKind::RosePineMoon as u8 => ThemeKind::RosePineMoon,
|
||||
x if x == ThemeKind::OscuraMidnight as u8 => ThemeKind::OscuraMidnight,
|
||||
x if x == ThemeKind::Auto as u8 => ThemeKind::Auto,
|
||||
_ => ThemeKind::GrokNight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached auto-theme configuration (which themes map to dark/light).
|
||||
///
|
||||
/// Uses `Mutex<Option<_>>` rather than `OnceLock` so the cache can be
|
||||
/// invalidated when the user changes mappings via the settings modal
|
||||
/// or the `/theme auto` slash command.
|
||||
static AUTO_THEME_CONFIG: Mutex<Option<AutoThemeConfig>> = Mutex::new(None);
|
||||
|
||||
/// Auto-theme config: which themes map to dark/light system appearance.
|
||||
///
|
||||
/// `dark_theme` and `light_theme` are the user-configured overrides read
|
||||
/// from `[ui].auto_dark_theme` and `[ui].auto_light_theme` in `config.toml`.
|
||||
/// When `None`, `to_theme_kind()` defaults to `GrokNight` / `GrokDay`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AutoThemeConfig {
|
||||
pub dark_theme: Option<ThemeKind>,
|
||||
pub light_theme: Option<ThemeKind>,
|
||||
}
|
||||
|
||||
/// Get the current theme kind.
|
||||
///
|
||||
/// On the first call, reads from `~/.kigi/config.toml` (via the shell's
|
||||
/// `load_effective_config`). After that, returns the in-memory value
|
||||
/// (updated by [`set`]).
|
||||
pub fn current_kind() -> ThemeKind {
|
||||
// Locked: return a constant nominal kind without seeding from disk.
|
||||
if terminal_native_locked() {
|
||||
return ThemeKind::GrokNight;
|
||||
}
|
||||
if !LOADED.load(Ordering::Acquire) {
|
||||
// Two threads racing into the seed path is harmless — the
|
||||
// disk read is idempotent and `store` is atomic. Worst case
|
||||
// both threads call `load_from_disk` once.
|
||||
if let Some(kind) = load_from_disk() {
|
||||
CURRENT.store(kind as u8, Ordering::Relaxed);
|
||||
}
|
||||
LOADED.store(true, Ordering::Release);
|
||||
}
|
||||
theme_kind_from_u8(CURRENT.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Set the in-memory theme kind without writing to disk.
|
||||
///
|
||||
/// Used by the dispatcher (after `Action::SetTheme` is processed) and
|
||||
/// by the live-preview path during the picker. Disk-write happens via
|
||||
/// `Effect::PersistSetting`, NOT here.
|
||||
pub fn set(kind: ThemeKind) {
|
||||
CURRENT.store(kind as u8, Ordering::Relaxed);
|
||||
LOADED.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// -- Terminal-native lock (minimal mode) --------------------------------------
|
||||
|
||||
/// Whether the theme is locked to the terminal-native palette.
|
||||
#[must_use]
|
||||
pub fn terminal_native_locked() -> bool {
|
||||
TERMINAL_NATIVE_LOCK.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Engage or clear the terminal-native theme lock.
|
||||
pub fn set_terminal_native_lock(locked: bool) {
|
||||
TERMINAL_NATIVE_LOCK.store(locked, Ordering::Relaxed);
|
||||
kigi_markdown::set_color_level_cap(if locked {
|
||||
kigi_markdown::ColorLevel::Basic
|
||||
} else {
|
||||
kigi_markdown::ColorLevel::TrueColor
|
||||
});
|
||||
}
|
||||
|
||||
// -- Auto-mode ---------------------------------------------------------------
|
||||
|
||||
/// Whether auto-switching mode is active.
|
||||
#[must_use]
|
||||
pub fn is_auto_mode() -> bool {
|
||||
AUTO_MODE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set or clear auto-switching mode.
|
||||
pub fn set_auto_mode(enabled: bool) {
|
||||
AUTO_MODE.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get the cached auto-theme configuration, loading from config on first access.
|
||||
///
|
||||
/// The cache can be invalidated via [`invalidate_auto_theme_config`] so
|
||||
/// subsequent lookups re-read from disk.
|
||||
#[must_use]
|
||||
pub fn auto_theme_config() -> AutoThemeConfig {
|
||||
let mut guard = AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard.get_or_insert_with(load_auto_theme_config)
|
||||
}
|
||||
|
||||
/// Invalidate the cached auto-theme configuration.
|
||||
///
|
||||
/// Call after updating `auto_dark_theme` or `auto_light_theme` in config
|
||||
/// so subsequent lookups see the new values. Used by the settings modal
|
||||
/// and the `/theme auto` slash command.
|
||||
pub fn invalidate_auto_theme_config() {
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
// -- Theme resolution --------------------------------------------------------
|
||||
|
||||
/// Resolve the effective theme, respecting the full precedence chain.
|
||||
///
|
||||
/// Called once at startup. Returns the concrete `ThemeKind` (never `Auto`).
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. Environment variable (`KIGI_THEME`)
|
||||
/// 2. Config file (`[ui].theme`)
|
||||
/// 3. Default: `GrokNight`
|
||||
#[must_use]
|
||||
pub fn resolve_initial_theme() -> ThemeKind {
|
||||
// 1. Environment variable (for desktop app integration)
|
||||
|
||||
// 2. Config file + 3. Default
|
||||
resolve_from_config(load_from_disk(), true)
|
||||
}
|
||||
|
||||
/// Inner resolution logic, factored out for testability.
|
||||
fn resolve_from_config(config_theme: Option<ThemeKind>, osc11_fallback: bool) -> ThemeKind {
|
||||
if let Some(kind) = config_theme {
|
||||
if kind.is_auto() {
|
||||
set_auto_mode(true);
|
||||
let appearance = if osc11_fallback {
|
||||
system_appearance::detect_with_osc11_fallback()
|
||||
} else {
|
||||
system_appearance::detect()
|
||||
};
|
||||
return resolve_from_appearance(appearance);
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
// Default: GrokNight
|
||||
ThemeKind::GrokNight
|
||||
}
|
||||
|
||||
/// Map an optional appearance detection result to a concrete `ThemeKind`.
|
||||
fn resolve_from_appearance(appearance: Option<system_appearance::SystemAppearance>) -> ThemeKind {
|
||||
let config = auto_theme_config();
|
||||
appearance
|
||||
.map(|a| system_appearance::to_theme_kind(a, config.dark_theme, config.light_theme))
|
||||
.unwrap_or(ThemeKind::GrokNight)
|
||||
}
|
||||
|
||||
/// Resolve "auto" by detecting system appearance and mapping via config.
|
||||
///
|
||||
/// Returns the concrete `ThemeKind` based on the current system appearance
|
||||
/// and the user's dark/light theme mapping. Falls back to `GrokNight`
|
||||
/// when detection fails.
|
||||
///
|
||||
/// Uses desktop APIs only (no OSC 11) — safe to call at runtime while
|
||||
/// crossterm's `EventStream` is active. Called from the settings modal
|
||||
/// and the `/theme auto` slash command.
|
||||
#[must_use]
|
||||
pub fn resolve_auto() -> ThemeKind {
|
||||
resolve_from_appearance(system_appearance::detect())
|
||||
}
|
||||
|
||||
/// Variant of [`resolve_initial_theme`] without the OSC 11 startup
|
||||
/// fallback, for resolution after the terminal is initialized.
|
||||
#[must_use]
|
||||
pub fn resolve_initial_theme_no_osc11() -> ThemeKind {
|
||||
resolve_from_config(load_from_disk(), false)
|
||||
}
|
||||
|
||||
// -- Disk reads --------------------------------------------------------------
|
||||
//
|
||||
// All writes go through `kigi_shell::util::config::set_theme()` (and
|
||||
// friends) via `Effect::PersistSetting`. This module only READS from the
|
||||
// shell's layered effective config.
|
||||
|
||||
/// Read the theme from the effective config (managed_config.toml merged
|
||||
/// under config.toml — user wins).
|
||||
///
|
||||
/// Checks `[ui].theme` first (the canonical location), then falls back
|
||||
/// to a top-level `theme` key for backwards compatibility.
|
||||
fn load_from_disk() -> Option<ThemeKind> {
|
||||
let root = kigi_config::load_effective_config_disk_only().ok()?;
|
||||
let table = root.as_table()?;
|
||||
// Canonical: [ui] section
|
||||
let value = table
|
||||
.get("ui")
|
||||
.and_then(|ui| ui.get("theme"))
|
||||
.and_then(|v| v.as_str())
|
||||
// Fallback: top-level `theme` key (legacy)
|
||||
.or_else(|| table.get("theme").and_then(|v| v.as_str()));
|
||||
value.and_then(ThemeKind::from_name)
|
||||
}
|
||||
|
||||
/// Load auto-theme configuration from the effective config.
|
||||
///
|
||||
/// Reads `[ui].auto_dark_theme` and `[ui].auto_light_theme`, parsing them
|
||||
/// as theme names. Filters out `Auto` to prevent circular reference.
|
||||
fn load_auto_theme_config() -> AutoThemeConfig {
|
||||
let Ok(root) = kigi_config::load_effective_config_disk_only() else {
|
||||
return AutoThemeConfig::default();
|
||||
};
|
||||
let Some(table) = root.as_table() else {
|
||||
return AutoThemeConfig::default();
|
||||
};
|
||||
let ui = table.get("ui");
|
||||
AutoThemeConfig {
|
||||
dark_theme: ui
|
||||
.and_then(|u| u.get("auto_dark_theme"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(ThemeKind::from_name)
|
||||
.filter(|k| !k.is_auto()),
|
||||
light_theme: ui
|
||||
.and_then(|u| u.get("auto_light_theme"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(ThemeKind::from_name)
|
||||
.filter(|k| !k.is_auto()),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Test support ------------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn reset_for_test() {
|
||||
// Tests are serialized via TEST_LOCK so the AtomicU8/AtomicBool
|
||||
// pair is safe to reset without any cross-thread coordination.
|
||||
CURRENT.store(ThemeKind::GrokNight as u8, Ordering::Relaxed);
|
||||
LOADED.store(false, Ordering::Release);
|
||||
AUTO_MODE.store(false, Ordering::Relaxed);
|
||||
set_terminal_native_lock(false);
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Seed `AUTO_THEME_CONFIG` with explicit defaults so `auto_theme_config()`
|
||||
/// never falls through to `load_auto_theme_config()` (which reads the
|
||||
/// user's real `config.toml`). Call from test setup after `reset_for_test()`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn seed_auto_theme_defaults_for_test() {
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(AutoThemeConfig::default());
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn test_lock() -> &'static Mutex<()> {
|
||||
&TEST_LOCK
|
||||
}
|
||||
|
||||
/// Pin a deterministic theme + color level for a test's duration so exact
|
||||
/// height / screen-position assertions are hermetic. Rendered heights are
|
||||
/// computed under the process-global `Theme::current()` (which concurrent
|
||||
/// `set_theme` tests mutate) and `Theme::current()` reads the global color
|
||||
/// level; holding the shared test lock blocks a mid-test theme change. Hold the
|
||||
/// returned guard for the whole test.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn pin_theme() -> std::sync::MutexGuard<'static, ()> {
|
||||
let guard = test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
set(ThemeKind::GrokNight);
|
||||
// Color level is a write-once `OnceLock`; tests run without a TTY so it
|
||||
// resolves to `TrueColor` anyway. Pin it explicitly (best-effort: ignore the
|
||||
// already-initialized `Err`) so the measure path that reads it stays fixed.
|
||||
let _ = super::color_support::set(super::color_support::ColorLevel::TrueColor);
|
||||
guard
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: run a test body while holding the global test lock and
|
||||
/// with a clean initial state.
|
||||
fn with_test_env(f: impl FnOnce()) {
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset_for_test();
|
||||
seed_auto_theme_defaults_for_test();
|
||||
// Set LOADED=true so current_kind() doesn't read from disk.
|
||||
set(ThemeKind::GrokNight);
|
||||
system_appearance::clear_mock();
|
||||
f();
|
||||
system_appearance::clear_mock();
|
||||
reset_for_test();
|
||||
}
|
||||
|
||||
/// Pre-populate the auto-theme config cache for testing.
|
||||
fn set_test_auto_config(config: AutoThemeConfig) {
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(config);
|
||||
}
|
||||
|
||||
// -- Terminal-native lock (minimal mode) ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_pins_kind_and_blocks_apply_kind() {
|
||||
with_test_env(|| {
|
||||
set(ThemeKind::GrokDay);
|
||||
set_terminal_native_lock(true);
|
||||
assert!(terminal_native_locked());
|
||||
assert_eq!(current_kind(), ThemeKind::GrokNight, "nominal kind");
|
||||
|
||||
let applied = super::super::Theme::apply_kind(ThemeKind::GrokDay);
|
||||
assert_eq!(applied, ThemeKind::GrokNight, "apply_kind must no-op");
|
||||
assert_eq!(current_kind(), ThemeKind::GrokNight);
|
||||
|
||||
set_terminal_native_lock(false);
|
||||
assert_eq!(
|
||||
current_kind(),
|
||||
ThemeKind::GrokDay,
|
||||
"unlocking restores the cached kind"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_serves_terminal_default_palette() {
|
||||
with_test_env(|| {
|
||||
set(ThemeKind::GrokDay);
|
||||
set_terminal_native_lock(true);
|
||||
let theme = super::super::Theme::current();
|
||||
let native = super::super::Theme::terminal_default();
|
||||
assert_eq!(theme.bg_base, native.bg_base);
|
||||
assert_eq!(theme.text_primary, native.text_primary);
|
||||
assert_eq!(theme.accent_user, native.accent_user);
|
||||
assert_ne!(
|
||||
theme.text_primary,
|
||||
super::super::Theme::grokday().text_primary,
|
||||
"must not serve the cached (GrokDay) theme"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_for_test_clears_terminal_native_lock() {
|
||||
with_test_env(|| {
|
||||
set_terminal_native_lock(true);
|
||||
reset_for_test();
|
||||
assert!(!terminal_native_locked());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_caps_quantize_at_ansi16() {
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::theme::color_support;
|
||||
with_test_env(|| {
|
||||
set_terminal_native_lock(true);
|
||||
assert!(color_support::detect() <= color_support::ColorLevel::Basic);
|
||||
for input in [
|
||||
Color::Rgb(0x26, 0x26, 0x26), // grokday text_primary
|
||||
Color::Rgb(122, 162, 247),
|
||||
Color::Indexed(141),
|
||||
] {
|
||||
let q = color_support::quantize(input);
|
||||
assert!(
|
||||
!matches!(q, Color::Rgb(..) | Color::Indexed(_)),
|
||||
"quantize({input:?}) must collapse to Reset/named ANSI under \
|
||||
the lock, got {q:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_no_osc11_explicit_auto_and_default() {
|
||||
with_test_env(|| {
|
||||
assert_eq!(
|
||||
resolve_from_config(Some(ThemeKind::GrokDay), false),
|
||||
ThemeKind::GrokDay
|
||||
);
|
||||
assert!(!is_auto_mode());
|
||||
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
|
||||
assert_eq!(
|
||||
resolve_from_config(Some(ThemeKind::Auto), false),
|
||||
ThemeKind::GrokDay
|
||||
);
|
||||
assert!(is_auto_mode(), "auto must arm the appearance watcher");
|
||||
|
||||
assert_eq!(resolve_from_config(None, false), ThemeKind::GrokNight);
|
||||
});
|
||||
}
|
||||
|
||||
// -- AUTO_MODE -----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn auto_mode_default_is_false() {
|
||||
with_test_env(|| {
|
||||
assert!(!is_auto_mode());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_auto_mode_toggles() {
|
||||
with_test_env(|| {
|
||||
set_auto_mode(true);
|
||||
assert!(is_auto_mode());
|
||||
set_auto_mode(false);
|
||||
assert!(!is_auto_mode());
|
||||
});
|
||||
}
|
||||
|
||||
// -- AutoThemeConfig -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn auto_theme_config_defaults_to_none() {
|
||||
let config = AutoThemeConfig::default();
|
||||
assert!(config.dark_theme.is_none());
|
||||
assert!(config.light_theme.is_none());
|
||||
}
|
||||
|
||||
// -- resolve_auto --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_dark_system_returns_groknight() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
|
||||
let result = resolve_auto();
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_light_system_returns_grokday() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
|
||||
let result = resolve_auto();
|
||||
assert_eq!(result, ThemeKind::GrokDay);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_detection_failure_returns_groknight() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(None);
|
||||
let result = resolve_auto();
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
});
|
||||
}
|
||||
|
||||
// -- invalidate_auto_theme_config ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn invalidate_clears_cached_config() {
|
||||
with_test_env(|| {
|
||||
// Pre-populate the cache with a known config.
|
||||
set_test_auto_config(AutoThemeConfig {
|
||||
dark_theme: Some(ThemeKind::TokyoNight),
|
||||
light_theme: None,
|
||||
});
|
||||
let config1 = auto_theme_config();
|
||||
assert_eq!(config1.dark_theme, Some(ThemeKind::TokyoNight));
|
||||
|
||||
// Invalidate — next read re-loads (defaults in test env).
|
||||
invalidate_auto_theme_config();
|
||||
// Pre-populate again with defaults to avoid disk dependency.
|
||||
set_test_auto_config(AutoThemeConfig::default());
|
||||
let config2 = auto_theme_config();
|
||||
assert!(config2.dark_theme.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// -- resolve_from_config (resolve_initial_theme inner logic) ---------------
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_no_config_returns_groknight() {
|
||||
with_test_env(|| {
|
||||
let result = resolve_from_config(None, true);
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
assert!(!is_auto_mode());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_explicit_theme_returns_it() {
|
||||
with_test_env(|| {
|
||||
let result = resolve_from_config(Some(ThemeKind::GrokDay), true);
|
||||
assert_eq!(result, ThemeKind::GrokDay);
|
||||
assert!(
|
||||
!is_auto_mode(),
|
||||
"explicit theme should not enable auto mode"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_auto_sets_auto_mode_dark() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
|
||||
let result = resolve_from_config(Some(ThemeKind::Auto), true);
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
assert!(is_auto_mode(), "auto config must enable auto mode");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_auto_with_light_system() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
|
||||
let result = resolve_from_config(Some(ThemeKind::Auto), true);
|
||||
assert_eq!(result, ThemeKind::GrokDay);
|
||||
assert!(is_auto_mode());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_auto_detection_failure() {
|
||||
with_test_env(|| {
|
||||
system_appearance::set_mock(None);
|
||||
let result = resolve_from_config(Some(ThemeKind::Auto), true);
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
assert!(is_auto_mode(), "auto mode is set before detection");
|
||||
});
|
||||
}
|
||||
|
||||
// -- resolve_auto with custom config -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_with_custom_dark_config() {
|
||||
with_test_env(|| {
|
||||
set_test_auto_config(AutoThemeConfig {
|
||||
dark_theme: Some(ThemeKind::TokyoNight),
|
||||
light_theme: None,
|
||||
});
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
|
||||
assert_eq!(resolve_auto(), ThemeKind::TokyoNight);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_with_custom_light_config() {
|
||||
with_test_env(|| {
|
||||
set_test_auto_config(AutoThemeConfig {
|
||||
dark_theme: None,
|
||||
light_theme: Some(ThemeKind::RosePineMoon),
|
||||
});
|
||||
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
|
||||
assert_eq!(resolve_auto(), ThemeKind::RosePineMoon);
|
||||
});
|
||||
}
|
||||
|
||||
// -- auto_theme_config filter --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn auto_theme_config_filter_rejects_auto_value() {
|
||||
// Simulates the .filter(|k| !k.is_auto()) guard in load_auto_theme_config().
|
||||
// When config contains auto_dark_theme = "auto", from_name returns Some(Auto),
|
||||
// but the filter discards it to prevent circular reference.
|
||||
let parsed = ThemeKind::from_name("auto").filter(|k| !k.is_auto());
|
||||
assert!(parsed.is_none(), "Auto must be filtered out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_theme_config_filter_accepts_concrete_theme() {
|
||||
let parsed = ThemeKind::from_name("tokyonight").filter(|k| !k.is_auto());
|
||||
assert_eq!(parsed, Some(ThemeKind::TokyoNight));
|
||||
}
|
||||
|
||||
// -- set / current_kind --------------------------------------------------
|
||||
|
||||
/// `set` followed by `current_kind` returns the set value, and the
|
||||
/// `LOADED` flag flips so subsequent reads don't re-seed from disk.
|
||||
/// The optimistic-update invariant the dispatcher relies on.
|
||||
///
|
||||
/// Explicitly observe the `LOADED` flag
|
||||
/// side-effect by calling `reset_for_test()` between sets — if
|
||||
/// `set` didn't flip `LOADED = true`, the second `current_kind`
|
||||
/// read would re-seed from disk and the assertion would fail.
|
||||
#[test]
|
||||
fn set_then_current_kind_round_trips() {
|
||||
with_test_env(|| {
|
||||
set(ThemeKind::TokyoNight);
|
||||
assert_eq!(current_kind(), ThemeKind::TokyoNight);
|
||||
set(ThemeKind::GrokDay);
|
||||
assert_eq!(current_kind(), ThemeKind::GrokDay);
|
||||
});
|
||||
}
|
||||
|
||||
/// `set` flips `LOADED` so a subsequent `current_kind` read does
|
||||
/// NOT re-seed from disk. Mirror of the
|
||||
/// `set_then_current_kind_round_trips` test that the docstring
|
||||
/// claims to enforce — exercises the `LOADED` flag invariant
|
||||
/// directly via the atomic statics.
|
||||
#[test]
|
||||
fn set_flips_loaded_flag_so_current_kind_skips_disk_reseed() {
|
||||
with_test_env(|| {
|
||||
// with_test_env seeds LOADED=true to prevent disk reads;
|
||||
// this test specifically needs LOADED=false to verify that
|
||||
// set() flips it.
|
||||
LOADED.store(false, Ordering::Release);
|
||||
assert!(
|
||||
!LOADED.load(Ordering::Acquire),
|
||||
"LOADED must be false for this test"
|
||||
);
|
||||
set(ThemeKind::GrokDay);
|
||||
assert!(
|
||||
LOADED.load(Ordering::Acquire),
|
||||
"set must flip LOADED to true"
|
||||
);
|
||||
// Subsequent current_kind read returns the set value (no
|
||||
// disk re-seed).
|
||||
assert_eq!(current_kind(), ThemeKind::GrokDay);
|
||||
assert!(
|
||||
LOADED.load(Ordering::Acquire),
|
||||
"current_kind must NOT flip LOADED back to false"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Terminal color support detection and quantization.
|
||||
//!
|
||||
//! Detects the terminal's color capabilities (truecolor / 256 / 16 / none) and
|
||||
//! provides a [`quantize_color`] function that downgrades a [`ratatui::style::Color`]
|
||||
//! to the highest level the terminal supports.
|
||||
//!
|
||||
//! The detected level is cached in a global [`OnceLock`] — call [`detect`] once
|
||||
//! at startup, then use [`get`] everywhere else.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::render::color::{indexed_to_rgb, nearest_indexed};
|
||||
use crate::terminal::{TerminalName, terminal_context};
|
||||
|
||||
/// Terminal color support level (ordered low → high).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ColorLevel {
|
||||
/// No color support (monochrome).
|
||||
None,
|
||||
/// Basic 16-color ANSI (SGR 30–37 / 90–97).
|
||||
Basic,
|
||||
/// 256-color indexed palette (SGR 38;5;N).
|
||||
Ansi256,
|
||||
/// 24-bit truecolor RGB (SGR 38;2;R;G;B).
|
||||
TrueColor,
|
||||
}
|
||||
|
||||
impl ColorLevel {
|
||||
pub fn has_color(self) -> bool {
|
||||
self >= Self::Basic
|
||||
}
|
||||
|
||||
pub fn has_256(self) -> bool {
|
||||
self >= Self::Ansi256
|
||||
}
|
||||
|
||||
pub fn has_truecolor(self) -> bool {
|
||||
self >= Self::TrueColor
|
||||
}
|
||||
|
||||
/// Canonical lowercase spelling that round-trips through the
|
||||
/// `KIGI_FORCE_COLOR_LEVEL` parser. Use this in user-facing
|
||||
/// diagnostics (not `{:?}` Debug, which yields `Basic` / `Ansi256`
|
||||
/// / `TrueColor` / `None`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Basic => "basic",
|
||||
Self::Ansi256 => "256",
|
||||
Self::TrueColor => "truecolor",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColorLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global singleton ─────────────────────────────────────────────────────
|
||||
|
||||
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
|
||||
|
||||
/// Detect the terminal's color support and cache the result.
|
||||
///
|
||||
/// Uses the `supports-color` crate which checks `COLORTERM`, `TERM`,
|
||||
/// terminal-specific env vars (`ITERM_SESSION_ID`, etc.) and whether
|
||||
/// stdout is a TTY.
|
||||
///
|
||||
/// If `NO_COLOR` is set the result is [`ColorLevel::None`].
|
||||
/// If stdout is not a TTY (test runner, piped output) and `NO_COLOR` is
|
||||
/// absent, defaults to [`ColorLevel::TrueColor`] — the safe assumption
|
||||
/// for a TUI app that always runs inside a terminal.
|
||||
///
|
||||
/// Capped at [`ColorLevel::Basic`] while the terminal-native lock is
|
||||
/// engaged.
|
||||
pub fn detect() -> ColorLevel {
|
||||
let raw = detect_raw();
|
||||
if crate::theme::cache::terminal_native_locked() {
|
||||
return raw.min(ColorLevel::Basic);
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// The raw cached detection, without the terminal-native lock cap.
|
||||
fn detect_raw() -> ColorLevel {
|
||||
*COLOR_LEVEL.get_or_init(|| {
|
||||
// Explicit opt-out via NO_COLOR takes priority.
|
||||
if std::env::var_os("NO_COLOR").is_some() {
|
||||
return ColorLevel::None;
|
||||
}
|
||||
|
||||
let level = match supports_color::on(supports_color::Stream::Stdout) {
|
||||
Some(level) => {
|
||||
if level.has_16m {
|
||||
ColorLevel::TrueColor
|
||||
} else if level.has_256 {
|
||||
ColorLevel::Ansi256
|
||||
} else if level.has_basic {
|
||||
ColorLevel::Basic
|
||||
} else {
|
||||
ColorLevel::None
|
||||
}
|
||||
}
|
||||
// Not a TTY (tests, piped) — default to TrueColor.
|
||||
None => ColorLevel::TrueColor,
|
||||
};
|
||||
|
||||
// The `supports-color` crate relies on COLORTERM=truecolor, but
|
||||
// tmux/SSH/mosh often strip that variable. When the crate reports
|
||||
// only 256-color support, upgrade to TrueColor if we can identify
|
||||
// the terminal emulator and know it handles 24-bit RGB.
|
||||
if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
|
||||
return ColorLevel::TrueColor;
|
||||
}
|
||||
|
||||
level
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the cached color level (calls [`detect`] if not yet initialized).
|
||||
pub fn get() -> ColorLevel {
|
||||
detect()
|
||||
}
|
||||
|
||||
/// Override the color level (useful for tests or `--color` flags).
|
||||
///
|
||||
/// Returns `Err` if already set.
|
||||
pub fn set(level: ColorLevel) -> Result<(), ColorLevel> {
|
||||
COLOR_LEVEL.set(level)
|
||||
}
|
||||
|
||||
// ── Color quantization ──────────────────────────────────────────────────
|
||||
|
||||
/// Downgrade a [`Color`] to the highest representation the terminal supports.
|
||||
///
|
||||
/// | Terminal level | `Rgb` | `Indexed` | Named (`Red`…) |
|
||||
/// |----------------|------------------|--------------------|----------------|
|
||||
/// | TrueColor | pass-through | pass-through | pass-through |
|
||||
/// | Ansi256 | → nearest idx | pass-through | pass-through |
|
||||
/// | Basic | → nearest ANSI16 | → nearest ANSI16 | pass-through |
|
||||
/// | None | → `Reset` | → `Reset` | → `Reset` |
|
||||
pub fn quantize_color(color: Color, level: ColorLevel) -> Color {
|
||||
match level {
|
||||
ColorLevel::TrueColor => color,
|
||||
ColorLevel::Ansi256 => match color {
|
||||
Color::Rgb(r, g, b) => Color::Indexed(nearest_indexed(r, g, b)),
|
||||
other => other,
|
||||
},
|
||||
ColorLevel::Basic => match color {
|
||||
Color::Rgb(r, g, b) => indexed_to_ansi16(nearest_indexed(r, g, b)),
|
||||
Color::Indexed(n) => indexed_to_ansi16(n),
|
||||
other => other,
|
||||
},
|
||||
ColorLevel::None => Color::Reset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantize a color using the globally-detected level.
|
||||
pub fn quantize(color: Color) -> Color {
|
||||
quantize_color(color, get())
|
||||
}
|
||||
|
||||
// ── Terminal-based truecolor inference ──────────────────────────────────
|
||||
|
||||
/// Check whether the detected terminal emulator is known to support truecolor.
|
||||
///
|
||||
/// Used as a fallback when `COLORTERM` is missing (e.g. inside tmux, SSH, or
|
||||
/// — most importantly — under a bare `cmd.exe` / `powershell.exe` ConHost
|
||||
/// window, which has supported VT-encoded 24-bit color since Windows 10
|
||||
/// 1709 (Fall Creators Update) but doesn't advertise it via COLORTERM. Without
|
||||
/// this fallback our themes get quantized to the 16-color ANSI palette there
|
||||
/// and the subtle bg/border/muted gradations collapse onto each other.
|
||||
fn terminal_supports_truecolor() -> bool {
|
||||
if matches!(
|
||||
terminal_context().brand,
|
||||
TerminalName::Iterm2
|
||||
| TerminalName::Ghostty
|
||||
| TerminalName::Kitty
|
||||
| TerminalName::WezTerm
|
||||
| TerminalName::Alacritty
|
||||
| TerminalName::Rio
|
||||
| TerminalName::WarpTerminal
|
||||
| TerminalName::VsCode
|
||||
| TerminalName::WindowsTerminal
|
||||
| TerminalName::Foot
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Native Windows: assume ConHost has VT processing enabled. Pre-1709
|
||||
// hosts are effectively extinct and would gracefully degrade by
|
||||
// ignoring the SGR 38;2;... sequences.
|
||||
cfg!(target_os = "windows")
|
||||
}
|
||||
|
||||
// ── 256 → 16 mapping ────────────────────────────────────────────────────
|
||||
|
||||
/// Map a 256-color index to the nearest basic ANSI 16 color.
|
||||
fn indexed_to_ansi16(n: u8) -> Color {
|
||||
match n {
|
||||
// First 16 indices already *are* the ANSI 16 colors.
|
||||
0 => Color::Black,
|
||||
1 => Color::Red,
|
||||
2 => Color::Green,
|
||||
3 => Color::Yellow,
|
||||
4 => Color::Blue,
|
||||
5 => Color::Magenta,
|
||||
6 => Color::Cyan,
|
||||
7 => Color::White, // actually "silver" in most terminals
|
||||
8 => Color::DarkGray,
|
||||
9 => Color::LightRed,
|
||||
10 => Color::LightGreen,
|
||||
11 => Color::LightYellow,
|
||||
12 => Color::LightBlue,
|
||||
13 => Color::LightMagenta,
|
||||
14 => Color::LightCyan,
|
||||
15 => Color::White,
|
||||
// For 16–255, convert to RGB and find nearest ANSI 16 color.
|
||||
_ => {
|
||||
let (r, g, b) = indexed_to_rgb(n);
|
||||
rgb_to_ansi16(r, g, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the nearest ANSI 16 color for an RGB triplet.
|
||||
///
|
||||
/// Uses a simple squared-Euclidean distance over the standard xterm ANSI 16
|
||||
/// palette. Good enough for a fallback — 16-color terminals are very rare.
|
||||
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
|
||||
// Standard xterm ANSI 16 palette (same values used by indexed_to_rgb for 0–15).
|
||||
const PALETTE: [(u8, u8, u8, Color); 16] = [
|
||||
(0, 0, 0, Color::Black),
|
||||
(128, 0, 0, Color::Red),
|
||||
(0, 128, 0, Color::Green),
|
||||
(128, 128, 0, Color::Yellow),
|
||||
(0, 0, 128, Color::Blue),
|
||||
(128, 0, 128, Color::Magenta),
|
||||
(0, 128, 128, Color::Cyan),
|
||||
(192, 192, 192, Color::White),
|
||||
(128, 128, 128, Color::DarkGray),
|
||||
(255, 0, 0, Color::LightRed),
|
||||
(0, 255, 0, Color::LightGreen),
|
||||
(255, 255, 0, Color::LightYellow),
|
||||
(0, 0, 255, Color::LightBlue),
|
||||
(255, 0, 255, Color::LightMagenta),
|
||||
(0, 255, 255, Color::LightCyan),
|
||||
(255, 255, 255, Color::White), // index 15 = bright white
|
||||
];
|
||||
|
||||
let mut best = Color::White;
|
||||
let mut best_dist = u32::MAX;
|
||||
for &(pr, pg, pb, color) in &PALETTE {
|
||||
let dr = r as i32 - pr as i32;
|
||||
let dg = g as i32 - pg as i32;
|
||||
let db = b as i32 - pb as i32;
|
||||
let dist = (dr * dr + dg * dg + db * db) as u32;
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best = color;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truecolor_passes_through() {
|
||||
let rgb = Color::Rgb(122, 162, 247);
|
||||
assert_eq!(quantize_color(rgb, ColorLevel::TrueColor), rgb);
|
||||
|
||||
let idx = Color::Indexed(141);
|
||||
assert_eq!(quantize_color(idx, ColorLevel::TrueColor), idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi256_quantizes_rgb_to_indexed() {
|
||||
let rgb = Color::Rgb(122, 162, 247);
|
||||
let q = quantize_color(rgb, ColorLevel::Ansi256);
|
||||
assert!(matches!(q, Color::Indexed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi256_passes_indexed_through() {
|
||||
let idx = Color::Indexed(141);
|
||||
assert_eq!(quantize_color(idx, ColorLevel::Ansi256), idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_quantizes_to_named() {
|
||||
let rgb = Color::Rgb(255, 0, 0);
|
||||
let q = quantize_color(rgb, ColorLevel::Basic);
|
||||
// Should map to a red variant
|
||||
assert!(
|
||||
matches!(q, Color::Red | Color::LightRed),
|
||||
"expected Red/LightRed, got {q:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_quantizes_indexed_to_named() {
|
||||
// Indexed(196) = (255,0,0) — pure bright red in the cube
|
||||
let idx = Color::Indexed(196);
|
||||
let q = quantize_color(idx, ColorLevel::Basic);
|
||||
assert!(
|
||||
matches!(q, Color::Red | Color::LightRed),
|
||||
"expected Red/LightRed, got {q:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_resets_everything() {
|
||||
assert_eq!(
|
||||
quantize_color(Color::Rgb(100, 200, 50), ColorLevel::None),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
quantize_color(Color::Indexed(111), ColorLevel::None),
|
||||
Color::Reset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_colors_pass_through_all_levels() {
|
||||
for level in [
|
||||
ColorLevel::TrueColor,
|
||||
ColorLevel::Ansi256,
|
||||
ColorLevel::Basic,
|
||||
] {
|
||||
assert_eq!(quantize_color(Color::Red, level), Color::Red);
|
||||
assert_eq!(quantize_color(Color::Blue, level), Color::Blue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_ordering() {
|
||||
assert!(ColorLevel::None < ColorLevel::Basic);
|
||||
assert!(ColorLevel::Basic < ColorLevel::Ansi256);
|
||||
assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_roundtrip_first_16() {
|
||||
// Indices 0–15 should map to their corresponding named colors
|
||||
assert_eq!(indexed_to_ansi16(0), Color::Black);
|
||||
assert_eq!(indexed_to_ansi16(1), Color::Red);
|
||||
assert_eq!(indexed_to_ansi16(4), Color::Blue);
|
||||
assert_eq!(indexed_to_ansi16(9), Color::LightRed);
|
||||
assert_eq!(indexed_to_ansi16(14), Color::LightCyan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! GrokDay theme — neutral gray base (light) with deepened accent colors.
|
||||
//!
|
||||
//! Light counterpart to GrokNight. Backgrounds and text use a neutral
|
||||
//! grayscale ramp (no blue/warm tint). Accent colors are the same hue
|
||||
//! family as GrokNight but deepened for contrast on light backgrounds.
|
||||
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
use super::tokyonight::Theme;
|
||||
|
||||
const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// ── Backgrounds (neutral light grays) ────────────────────────────────
|
||||
pub const BG: Color = rgb(245, 245, 245); // #f5f5f5 — brightest (terminal bg)
|
||||
pub const BG_DARK: Color = rgb(240, 240, 240); // #f0f0f0
|
||||
pub const BG_STORM_DARK: Color = rgb(234, 234, 234); // #eaeaea
|
||||
pub const BG_STORM: Color = rgb(238, 238, 238); // #eeeeee — main bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222); // #dedede — highlight bg
|
||||
|
||||
// ── Text / grays (neutral dark) ──────────────────────────────────────
|
||||
pub const FG: Color = rgb(38, 38, 38); // #262626 — primary text
|
||||
pub const FG_DARK: Color = rgb(68, 68, 68); // #444444 — secondary text
|
||||
pub const FG_GUTTER: Color = rgb(178, 178, 178); // #b2b2b2 — dim
|
||||
pub const COMMENT: Color = rgb(118, 118, 118); // #767676 — muted
|
||||
pub const DARK3: Color = rgb(142, 142, 142); // #8e8e8e — medium gray
|
||||
pub const DARK5: Color = rgb(98, 98, 98); // #626262 — bright gray
|
||||
|
||||
// ── Accent colors (deepened for light-background contrast) ───────────
|
||||
pub const BLUE: Color = rgb(47, 100, 210); // #2F64D2
|
||||
pub const BLUE0: Color = rgb(40, 68, 138); // #28448A
|
||||
pub const BLUE1: Color = rgb(15, 135, 162); // #0F87A2
|
||||
pub const CYAN: Color = rgb(0, 130, 170); // #0082AA
|
||||
pub const GREEN: Color = rgb(55, 142, 35); // #378E23
|
||||
pub const GREEN1: Color = rgb(12, 148, 124); // #0C947C
|
||||
pub const MAGENTA: Color = rgb(125, 75, 198); // #7D4BC6
|
||||
pub const ORANGE: Color = rgb(195, 105, 30); // #C3691E
|
||||
pub const PURPLE: Color = rgb(108, 62, 178); // #6C3EB2
|
||||
pub const RED: Color = rgb(205, 48, 72); // #CD3048
|
||||
pub const RED1: Color = rgb(175, 35, 35); // #AF2323
|
||||
pub const TEAL: Color = rgb(10, 142, 112); // #0A8E70
|
||||
pub const YELLOW: Color = rgb(162, 118, 18); // #A27612
|
||||
|
||||
pub const RED_LIGHT: Color = rgb(245, 218, 222); // #F5DADE — diff delete bg
|
||||
pub const GREEN_LIGHT: Color = rgb(218, 242, 220); // #DAF2DC — diff insert bg
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
impl Theme {
|
||||
pub const fn grokday() -> Self {
|
||||
Self {
|
||||
bg_base: BG_STORM,
|
||||
bg_light: BG_HIGHLIGHT,
|
||||
bg_dark: rgb(228, 228, 228),
|
||||
bg_highlight: BG_HIGHLIGHT,
|
||||
bg_hover: rgb(208, 208, 208),
|
||||
bg_terminal: BG,
|
||||
|
||||
accent_user: FG_DARK,
|
||||
accent_assistant: MAGENTA,
|
||||
accent_thinking: MAGENTA,
|
||||
accent_tool: DARK5,
|
||||
accent_system: BLUE,
|
||||
accent_error: RED,
|
||||
accent_success: GREEN,
|
||||
accent_running: MAGENTA,
|
||||
accent_skill: BLUE,
|
||||
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
|
||||
gray_dim: rgb(165, 165, 165), // #a5a5a5 — slightly darker than FG_GUTTER
|
||||
gray: COMMENT,
|
||||
gray_bright: DARK5,
|
||||
|
||||
command: YELLOW,
|
||||
path: ORANGE,
|
||||
running: CYAN,
|
||||
warning: YELLOW,
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(168, 120, 10), // #A8780A — deep golden
|
||||
|
||||
accent_verify: rgb(120, 80, 160), // deep violet (readable on light bg)
|
||||
|
||||
accent_feedback: GREEN1,
|
||||
|
||||
accent_remember: rgb(76, 175, 80), // #4CAF50 — Material Design green (readable on light bg)
|
||||
|
||||
selection_border: rgb(185, 185, 190),
|
||||
prompt_border: rgb(200, 200, 205), // #C8C8CD — dimmer prompt chrome
|
||||
prompt_border_active: rgb(165, 165, 175), // #A5A5AF — darker (more apparent) when focused
|
||||
hover_border: rgb(212, 212, 216),
|
||||
|
||||
accent_model: TEAL,
|
||||
|
||||
scrollbar_bg: BG_STORM_DARK,
|
||||
scrollbar_fg: BG_HIGHLIGHT,
|
||||
|
||||
diff_delete_bg: RED_LIGHT,
|
||||
diff_delete_fg: RED,
|
||||
diff_insert_bg: GREEN_LIGHT,
|
||||
diff_insert_fg: GREEN,
|
||||
diff_equal_fg: COMMENT,
|
||||
diff_gutter_fg: COMMENT,
|
||||
|
||||
bg_visual: rgb(198, 198, 198),
|
||||
|
||||
paste_bg: BG_HIGHLIGHT,
|
||||
paste_fg: FG_DARK,
|
||||
paste_dim: FG_GUTTER,
|
||||
|
||||
md_heading_h1: TEAL,
|
||||
md_heading_h1_mod: Modifier::BOLD,
|
||||
md_heading_h2: BLUE,
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: PURPLE,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: DARK5,
|
||||
md_heading_h4_mod: Modifier::BOLD,
|
||||
md_heading_h5: COMMENT,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: DARK3,
|
||||
md_heading_h6_mod: Modifier::empty(),
|
||||
md_code: BLUE1,
|
||||
md_task_checked: GREEN,
|
||||
md_task_unchecked: FG_DARK,
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: rgb(228, 228, 228),
|
||||
md_text: FG_DARK,
|
||||
link_fg: BLUE, // #2F64D2 -- deep blue for light bg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! GrokNight theme — neutral gray base with TokyoNight accent colors.
|
||||
//!
|
||||
//! The canonical palette is defined in RGB (`Color::Rgb`). At startup the
|
||||
//! theme is run through [`Theme::quantized`] which downgrades every color
|
||||
//! to the terminal's detected capability level (256-color, 16-color, etc.).
|
||||
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
use super::tokyonight::Theme;
|
||||
|
||||
/// Helper for concise const `Color::Rgb` definitions.
|
||||
const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
// GrokNight palette — neutral gray base + TokyoNight accent colors.
|
||||
//
|
||||
// Backgrounds and text use a custom grayscale ramp anchored at:
|
||||
// • bg = #141414 (20)
|
||||
// • fg = #f3f3f3 (243)
|
||||
//
|
||||
// Accent colors are the original TokyoNight Night hex values.
|
||||
#[allow(dead_code)]
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// ── Backgrounds ─────────────────────────────────────────────────────
|
||||
pub const BG: Color = rgb(10, 10, 10); // #0a0a0a — Night (terminal bg)
|
||||
pub const BG_DARK: Color = rgb(12, 12, 12); // #0c0c0c — darkest
|
||||
pub const BG_STORM_DARK: Color = rgb(17, 17, 17); // #111111 — dark bg
|
||||
pub const BG_STORM: Color = rgb(20, 20, 20); // #141414 — main bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36); // #242424 — highlight bg
|
||||
|
||||
// ── Text / grays ────────────────────────────────────────────────────
|
||||
pub const FG: Color = rgb(225, 225, 225); // #e1e1e1 — primary text
|
||||
pub const FG_DARK: Color = rgb(200, 200, 200); // #c8c8c8 — secondary text
|
||||
pub const FG_GUTTER: Color = rgb(65, 65, 65); // #414141 — dim
|
||||
pub const COMMENT: Color = rgb(108, 108, 108); // #6c6c6c — muted
|
||||
pub const DARK3: Color = rgb(90, 90, 90); // #5a5a5a — medium gray
|
||||
pub const DARK5: Color = rgb(120, 120, 120); // #787878 — bright gray
|
||||
|
||||
// ── Accent colors (TokyoNight Night) ─────────────────────────────────
|
||||
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
|
||||
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
|
||||
pub const BLUE1: Color = rgb(58, 149, 171); // #3A95AB
|
||||
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
|
||||
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
|
||||
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
|
||||
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
|
||||
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
|
||||
pub const RED: Color = rgb(247, 118, 142); // #f7768e
|
||||
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
|
||||
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
|
||||
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
|
||||
|
||||
pub const RED_DARK: Color = rgb(66, 14, 20); // #420e14 — quantizes to 256-color red, not gray
|
||||
pub const GREEN_DARK: Color = rgb(6, 56, 6); // #063806 — quantizes to 256-color green, not gray
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
impl Theme {
|
||||
/// GrokNight theme — neutral gray base with TokyoNight accents.
|
||||
///
|
||||
/// Colors are defined in RGB. Call [`Theme::quantized`] to downgrade
|
||||
/// them to the terminal's supported color level before rendering.
|
||||
pub const fn groknight() -> Self {
|
||||
Self {
|
||||
bg_base: BG_STORM,
|
||||
bg_light: BG_HIGHLIGHT,
|
||||
bg_dark: rgb(28, 28, 28), // lighter than bg_base for visible code blocks
|
||||
bg_highlight: BG_HIGHLIGHT,
|
||||
bg_hover: rgb(44, 44, 44),
|
||||
bg_terminal: BG,
|
||||
|
||||
accent_user: FG_DARK,
|
||||
accent_assistant: MAGENTA,
|
||||
accent_thinking: MAGENTA,
|
||||
accent_tool: DARK5,
|
||||
accent_system: BLUE,
|
||||
accent_error: RED,
|
||||
accent_success: GREEN,
|
||||
accent_running: MAGENTA,
|
||||
accent_skill: BLUE,
|
||||
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
|
||||
gray_dim: rgb(88, 88, 88), // #585858 — slightly brighter than FG_GUTTER
|
||||
gray: COMMENT,
|
||||
gray_bright: DARK5,
|
||||
|
||||
command: YELLOW,
|
||||
path: ORANGE,
|
||||
running: CYAN,
|
||||
warning: YELLOW,
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(255, 219, 141), // #FFDB8D — golden
|
||||
|
||||
accent_verify: rgb(187, 154, 247), // #bb9af7 — violet
|
||||
|
||||
accent_feedback: GREEN1, // #73daca
|
||||
|
||||
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
|
||||
selection_border: rgb(60, 60, 65),
|
||||
prompt_border: rgb(50, 50, 55), // #323237 — dimmer prompt chrome
|
||||
prompt_border_active: rgb(80, 80, 88), // #505058 — brighter when focused
|
||||
hover_border: rgb(30, 30, 34),
|
||||
|
||||
accent_model: TEAL,
|
||||
|
||||
scrollbar_bg: BG_STORM_DARK,
|
||||
scrollbar_fg: BG_HIGHLIGHT,
|
||||
|
||||
diff_delete_bg: RED_DARK,
|
||||
diff_delete_fg: RED,
|
||||
diff_insert_bg: GREEN_DARK,
|
||||
diff_insert_fg: GREEN,
|
||||
diff_equal_fg: COMMENT,
|
||||
diff_gutter_fg: COMMENT,
|
||||
|
||||
bg_visual: rgb(54, 54, 54),
|
||||
|
||||
paste_bg: BG_STORM_DARK,
|
||||
paste_fg: FG_DARK,
|
||||
paste_dim: FG_GUTTER,
|
||||
|
||||
md_heading_h1: TEAL,
|
||||
md_heading_h1_mod: Modifier::BOLD,
|
||||
md_heading_h2: BLUE,
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: PURPLE,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: DARK5, // bright gray
|
||||
md_heading_h4_mod: Modifier::BOLD,
|
||||
md_heading_h5: COMMENT, // medium gray
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: DARK3, // medium gray, unbold
|
||||
md_heading_h6_mod: Modifier::empty(),
|
||||
md_code: BLUE1,
|
||||
md_task_checked: GREEN,
|
||||
md_task_unchecked: FG_DARK, // text_secondary
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: rgb(28, 28, 28),
|
||||
md_text: FG_DARK,
|
||||
link_fg: rgb(122, 166, 218), // #7aa6da -- soft blue for dark bg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "known broken: expected accent values drift from runtime theme"]
|
||||
fn test_groknight_theme() {
|
||||
let theme = Theme::groknight();
|
||||
assert!(matches!(theme.bg_base, Color::Rgb(20, 20, 20)));
|
||||
assert!(matches!(theme.accent_user, Color::Rgb(225, 225, 225)));
|
||||
assert!(matches!(theme.text_primary, Color::Rgb(225, 225, 225)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Theme-aware markdown rendering style.
|
||||
//!
|
||||
//! Defines the `MarkdownStyle` used by agent message and thinking blocks.
|
||||
//! Colors come from the `md_*` fields on the current [`Theme`], which are
|
||||
//! already quantized to the terminal's color capability level.
|
||||
|
||||
use anstyle::{Ansi256Color, AnsiColor, Color, Style};
|
||||
use kigi_markdown::MarkdownStyle;
|
||||
|
||||
/// Convert `ratatui::style::Color` → `anstyle::Color` (type conversion only).
|
||||
///
|
||||
/// Quantization is already handled by [`Theme::current()`], so this just
|
||||
/// bridges the two color types.
|
||||
///
|
||||
/// Returns `None` for `Reset`: `anstyle::Color` has no "terminal default"
|
||||
/// variant, and downstream an unset color renders as the terminal default.
|
||||
fn to_anstyle(c: ratatui::style::Color) -> Option<Color> {
|
||||
Some(match c {
|
||||
ratatui::style::Color::Reset => return None,
|
||||
ratatui::style::Color::Rgb(r, g, b) => Color::Rgb(anstyle::RgbColor(r, g, b)),
|
||||
ratatui::style::Color::Indexed(n) => Color::Ansi256(Ansi256Color(n)),
|
||||
// Named ANSI colors (from 16-color quantization).
|
||||
ratatui::style::Color::Black => Color::Ansi(AnsiColor::Black),
|
||||
ratatui::style::Color::Red => Color::Ansi(AnsiColor::Red),
|
||||
ratatui::style::Color::Green => Color::Ansi(AnsiColor::Green),
|
||||
ratatui::style::Color::Yellow => Color::Ansi(AnsiColor::Yellow),
|
||||
ratatui::style::Color::Blue => Color::Ansi(AnsiColor::Blue),
|
||||
ratatui::style::Color::Magenta => Color::Ansi(AnsiColor::Magenta),
|
||||
ratatui::style::Color::Cyan => Color::Ansi(AnsiColor::Cyan),
|
||||
ratatui::style::Color::Gray => Color::Ansi(AnsiColor::White),
|
||||
ratatui::style::Color::DarkGray => Color::Ansi(AnsiColor::BrightBlack),
|
||||
ratatui::style::Color::LightRed => Color::Ansi(AnsiColor::BrightRed),
|
||||
ratatui::style::Color::LightGreen => Color::Ansi(AnsiColor::BrightGreen),
|
||||
ratatui::style::Color::LightYellow => Color::Ansi(AnsiColor::BrightYellow),
|
||||
ratatui::style::Color::LightBlue => Color::Ansi(AnsiColor::BrightBlue),
|
||||
ratatui::style::Color::LightMagenta => Color::Ansi(AnsiColor::BrightMagenta),
|
||||
ratatui::style::Color::LightCyan => Color::Ansi(AnsiColor::BrightCyan),
|
||||
ratatui::style::Color::White => Color::Ansi(AnsiColor::BrightWhite),
|
||||
})
|
||||
}
|
||||
|
||||
/// `anstyle::Style` with the given foreground color (converted from ratatui).
|
||||
fn fg(c: ratatui::style::Color) -> Style {
|
||||
Style::new().fg_color(to_anstyle(c))
|
||||
}
|
||||
|
||||
/// `anstyle::Style` with the given background color (converted from ratatui).
|
||||
fn bg(c: ratatui::style::Color) -> Style {
|
||||
Style::new().bg_color(to_anstyle(c))
|
||||
}
|
||||
|
||||
/// Convert `ratatui::style::Modifier` flags to `anstyle::Style` effects.
|
||||
fn modifier_to_anstyle(m: ratatui::style::Modifier) -> Style {
|
||||
let mut s = Style::new();
|
||||
if m.contains(ratatui::style::Modifier::BOLD) {
|
||||
s = s.bold();
|
||||
}
|
||||
if m.contains(ratatui::style::Modifier::ITALIC) {
|
||||
s = s.italic();
|
||||
}
|
||||
if m.contains(ratatui::style::Modifier::UNDERLINED) {
|
||||
s = s.underline();
|
||||
}
|
||||
if m.contains(ratatui::style::Modifier::DIM) {
|
||||
s = s.dimmed();
|
||||
}
|
||||
if m.contains(ratatui::style::Modifier::HIDDEN) {
|
||||
s = s.hidden();
|
||||
}
|
||||
if m.contains(ratatui::style::Modifier::CROSSED_OUT) {
|
||||
s = s.strikethrough();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Build heading inner styles from theme colors and per-level modifiers.
|
||||
fn heading_inner_styles(
|
||||
colors: [ratatui::style::Color; 6],
|
||||
mods: [ratatui::style::Modifier; 6],
|
||||
) -> [Style; 6] {
|
||||
std::array::from_fn(|i| {
|
||||
let color_style = fg(colors[i]);
|
||||
let mod_style = modifier_to_anstyle(mods[i]);
|
||||
// Combine fg color with modifier effects.
|
||||
let mut s = color_style;
|
||||
let effects = mod_style.get_effects();
|
||||
if !effects.is_plain() {
|
||||
s = s.effects(s.get_effects() | effects);
|
||||
}
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
/// Build heading outer styles (dimmed + hidden, for syntax markers).
|
||||
fn heading_outer_styles(colors: [ratatui::style::Color; 6]) -> [Style; 6] {
|
||||
colors.map(|c| fg(c).dimmed().hidden())
|
||||
}
|
||||
|
||||
/// Get the theme-aware markdown style.
|
||||
///
|
||||
/// Built fresh from [`Theme::current()`] on each call. Both the theme
|
||||
/// construction and style mapping are trivial struct copies.
|
||||
pub fn style() -> MarkdownStyle {
|
||||
build_style()
|
||||
}
|
||||
|
||||
fn build_style() -> MarkdownStyle {
|
||||
let theme = super::Theme::current();
|
||||
|
||||
let heading_colors = [
|
||||
theme.md_heading_h1,
|
||||
theme.md_heading_h2,
|
||||
theme.md_heading_h3,
|
||||
theme.md_heading_h4,
|
||||
theme.md_heading_h5,
|
||||
theme.md_heading_h6,
|
||||
];
|
||||
let heading_mods = [
|
||||
theme.md_heading_h1_mod,
|
||||
theme.md_heading_h2_mod,
|
||||
theme.md_heading_h3_mod,
|
||||
theme.md_heading_h4_mod,
|
||||
theme.md_heading_h5_mod,
|
||||
theme.md_heading_h6_mod,
|
||||
];
|
||||
|
||||
MarkdownStyle {
|
||||
heading_inner: heading_inner_styles(heading_colors, heading_mods),
|
||||
heading_outer: heading_outer_styles(heading_colors),
|
||||
strong_inner: fg(theme.md_text).bold(),
|
||||
strong_outer: Style::new().dimmed().hidden(),
|
||||
emphasis_inner: fg(theme.md_text).italic(),
|
||||
emphasis_outer: Style::new().dimmed().hidden(),
|
||||
strikethrough_inner: fg(theme.md_text).strikethrough(),
|
||||
strikethrough_outer: Style::new().dimmed().hidden(),
|
||||
inline_code_inner: fg(theme.md_code).bold(),
|
||||
inline_code_outer: fg(theme.md_code).dimmed().hidden(),
|
||||
// Selection-side bar detection (kigi-tui scrollback/blocks/
|
||||
// quote_bar.rs quote_bar_style) mirrors this exact style; its
|
||||
// end-to-end tests fail if this line changes.
|
||||
blockquote_outer: fg(theme.md_muted).dimmed(),
|
||||
task_checked: fg(theme.md_task_checked),
|
||||
task_unchecked: fg(theme.md_task_unchecked).dimmed(),
|
||||
list_item: fg(theme.md_muted),
|
||||
rule: fg(theme.md_muted),
|
||||
link_outer: fg(theme.md_muted),
|
||||
link_text: fg(theme.link_fg).underline(),
|
||||
link_url: fg(theme.md_muted),
|
||||
link_title: fg(theme.md_heading_h5),
|
||||
code_outer: fg(theme.md_code).dimmed().hidden(),
|
||||
code_language: fg(theme.md_heading_h3).hidden(),
|
||||
code_untagged: fg(theme.md_text),
|
||||
code_background: bg(theme.md_code_bg),
|
||||
table_outer: fg(theme.md_heading_h2).hidden(),
|
||||
text: fg(theme.md_text),
|
||||
math: fg(theme.md_text).italic(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression: `Reset` used to fall back to a concrete `AnsiColor::White`
|
||||
/// (ANSI-7 silver), which rendered Reset-themed markdown washed-out gray
|
||||
/// on light terminals and broke the `NO_COLOR` opt-out.
|
||||
#[test]
|
||||
fn reset_maps_to_no_color() {
|
||||
assert_eq!(to_anstyle(ratatui::style::Color::Reset), None);
|
||||
assert_eq!(fg(ratatui::style::Color::Reset).get_fg_color(), None);
|
||||
assert_eq!(bg(ratatui::style::Color::Reset).get_bg_color(), None);
|
||||
}
|
||||
|
||||
/// Spot checks around the Gray/DarkGray naming mismatch between ratatui
|
||||
/// and anstyle.
|
||||
#[test]
|
||||
fn named_colors_map_concretely() {
|
||||
assert_eq!(
|
||||
to_anstyle(ratatui::style::Color::DarkGray),
|
||||
Some(Color::Ansi(AnsiColor::BrightBlack))
|
||||
);
|
||||
assert_eq!(
|
||||
to_anstyle(ratatui::style::Color::Gray),
|
||||
Some(Color::Ansi(AnsiColor::White))
|
||||
);
|
||||
assert_eq!(
|
||||
to_anstyle(ratatui::style::Color::Red),
|
||||
Some(Color::Ansi(AnsiColor::Red))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_body_text_has_no_fg() {
|
||||
let theme = super::super::Theme::terminal_default();
|
||||
assert_eq!(fg(theme.md_text).get_fg_color(), None);
|
||||
assert_eq!(bg(theme.md_code_bg).get_bg_color(), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
//! OSC 11 terminal background detection.
|
||||
//!
|
||||
//! Queries the terminal's background color via the OSC 11 escape sequence:
|
||||
//! Query: `\x1b]11;?\x07`
|
||||
//! Reply: `\x1b]11;rgb:RRRR/GGGG/BBBB\x07` (or ST terminator `\x1b\\`)
|
||||
//!
|
||||
//! The response contains hex color values (2-digit or 4-digit per channel).
|
||||
//! For 4-digit values we extract the high byte; for 2-digit we use the value
|
||||
//! directly. Relative luminance (ITU-R BT.709) classifies the background as
|
||||
//! dark or light.
|
||||
//!
|
||||
//! This is a **startup-only** fallback — it must NOT be called once
|
||||
//! crossterm's `EventStream` is active, as both compete for stdin in raw
|
||||
//! mode. The live `SystemAppearanceWatcher` uses only
|
||||
//! `dark-light::detect()`.
|
||||
|
||||
use super::system_appearance::SystemAppearance;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
/// Luminance threshold: backgrounds with Y < 0.5 are considered dark.
|
||||
const LUMINANCE_THRESHOLD: f64 = 0.5;
|
||||
|
||||
/// Timeout for reading the OSC 11 response from the terminal.
|
||||
const OSC11_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Detect system appearance by querying the terminal's background color.
|
||||
///
|
||||
/// Returns `None` if stdin is not a TTY, the terminal does not respond
|
||||
/// within `OSC11_TIMEOUT`, or the response cannot be parsed.
|
||||
///
|
||||
/// MUST be called before crossterm's event stream is initialized.
|
||||
/// Manages stdin termios locally (no `crossterm::enable_raw_mode`) and
|
||||
/// routes the query write through the shared stderr lock to avoid
|
||||
/// interleaving with the render writer thread.
|
||||
pub fn detect_via_osc11() -> Option<SystemAppearance> {
|
||||
use std::io::IsTerminal;
|
||||
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !crate::terminal::probe::write_query(b"\x1b]11;?\x07") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let response = read_osc_response(OSC11_TIMEOUT)?;
|
||||
let (r, g, b) = parse_osc11_rgb(&response)?;
|
||||
|
||||
Some(classify_luminance(r, g, b))
|
||||
}
|
||||
|
||||
/// Classify an sRGB color as dark or light based on relative luminance.
|
||||
///
|
||||
/// Uses ITU-R BT.709 luminance coefficients with sRGB gamma correction.
|
||||
/// Threshold at 0.5 — below is dark, at or above is light.
|
||||
pub(crate) fn classify_luminance(r: u8, g: u8, b: u8) -> SystemAppearance {
|
||||
let luminance =
|
||||
0.2126 * srgb_to_linear(r) + 0.7152 * srgb_to_linear(g) + 0.0722 * srgb_to_linear(b);
|
||||
|
||||
if luminance < LUMINANCE_THRESHOLD {
|
||||
SystemAppearance::Dark
|
||||
} else {
|
||||
SystemAppearance::Light
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the RGB components from an OSC 11 response string.
|
||||
///
|
||||
/// Handles both 4-digit (`rgb:RRRR/GGGG/BBBB`) and 2-digit (`rgb:RR/GG/BB`)
|
||||
/// hex formats. For 4-digit values the high byte is extracted (>> 8).
|
||||
pub(crate) fn parse_osc11_rgb(response: &str) -> Option<(u8, u8, u8)> {
|
||||
let rgb_start = response.find("rgb:")? + 4;
|
||||
let rgb_part = &response[rgb_start..];
|
||||
|
||||
// Split on channel separator `/` and terminators (BEL, ESC).
|
||||
let parts: Vec<&str> = rgb_part.split(['/', '\x07', '\x1b']).take(3).collect();
|
||||
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((
|
||||
parse_channel(parts[0])?,
|
||||
parse_channel(parts[1])?,
|
||||
parse_channel(parts[2])?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse a single hex color channel.
|
||||
///
|
||||
/// For 3–4 digit values, extracts the high byte (`>> 8`) to map to 0–255.
|
||||
/// For 1–2 digit values, uses the value directly as 0–255.
|
||||
fn parse_channel(s: &str) -> Option<u8> {
|
||||
let trimmed = s.trim();
|
||||
let val = u16::from_str_radix(trimmed, 16).ok()?;
|
||||
Some(if trimmed.len() > 2 {
|
||||
(val >> 8) as u8
|
||||
} else {
|
||||
val as u8
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert an sRGB channel value (0–255) to linear light.
|
||||
///
|
||||
/// Applies the sRGB transfer function inverse (IEC 61966-2-1).
|
||||
fn srgb_to_linear(c: u8) -> f64 {
|
||||
let s = c as f64 / 255.0;
|
||||
if s <= 0.04045 {
|
||||
s / 12.92
|
||||
} else {
|
||||
((s + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the original termios on drop without touching crossterm's
|
||||
/// process-wide `TERMINAL_MODE_PRIOR_RAW_MODE`. Calling
|
||||
/// `crossterm::disable_raw_mode` here would restore the shell's
|
||||
/// pre-pager cooked termios, breaking the pager's own raw mode.
|
||||
#[cfg(unix)]
|
||||
struct TermiosGuard {
|
||||
fd: RawFd,
|
||||
original: libc::termios,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for TermiosGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: fd was valid at construction; original was populated
|
||||
// by a successful tcgetattr.
|
||||
unsafe {
|
||||
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX-portable subset of `cfmakeraw(3)`: clear the lflags that would
|
||||
/// block a single-byte read (canonical mode, echo, signal interpretation,
|
||||
/// extended processing).
|
||||
#[cfg(unix)]
|
||||
fn make_raw_termios(snapshot: &libc::termios) -> libc::termios {
|
||||
let mut raw = *snapshot;
|
||||
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
|
||||
raw
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_osc_response(timeout: Duration) -> Option<String> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
read_osc_response_with_fd(std::io::stdin().as_raw_fd(), timeout)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_osc_response(_timeout: Duration) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `fd`-parameterized for tests (pass `/dev/null` to exercise the
|
||||
/// non-TTY path). Guard is constructed before `tcsetattr` to keep the
|
||||
/// restore atomic with the switch -- POSIX guarantees `tcsetattr` is
|
||||
/// atomic on failure, so a redundant restore on the early-return path
|
||||
/// is harmless.
|
||||
#[cfg(unix)]
|
||||
fn read_osc_response_with_fd(fd: RawFd, timeout: Duration) -> Option<String> {
|
||||
let mut original: libc::termios = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: caller passes a valid fd; original is a valid owned buffer.
|
||||
if unsafe { libc::tcgetattr(fd, &mut original) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let raw = make_raw_termios(&original);
|
||||
let _guard = TermiosGuard { fd, original };
|
||||
// SAFETY: raw is a valid owned buffer.
|
||||
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
|
||||
return None;
|
||||
}
|
||||
read_with_timeout(timeout)
|
||||
}
|
||||
|
||||
/// Read bytes from stdin until a terminator is found or timeout expires.
|
||||
///
|
||||
/// Recognizes two terminators:
|
||||
/// - BEL (`\x07`)
|
||||
/// - ST (`\x1b\x5c`, i.e. ESC + backslash)
|
||||
///
|
||||
/// Uses `libc::poll` + `libc::read` for non-blocking reads with a timeout
|
||||
/// on Unix. Returns `None` on non-Unix platforms.
|
||||
// Only invoked from `read_osc_response_with_fd`, which is Unix-only.
|
||||
#[cfg(unix)]
|
||||
fn read_with_timeout(timeout: Duration) -> Option<String> {
|
||||
unix_read_with_timeout(timeout)
|
||||
}
|
||||
|
||||
/// Unix implementation: shared probe read loop with the OSC terminators
|
||||
/// (BEL, or ST as `ESC \`) as the stop predicate.
|
||||
#[cfg(unix)]
|
||||
fn unix_read_with_timeout(timeout: Duration) -> Option<String> {
|
||||
let buf = crate::terminal::probe::read_tty_reply(timeout, |buf, byte| {
|
||||
byte == 0x07 || (buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && byte == 0x5c)
|
||||
})?;
|
||||
// Reject partial buffers: a reply truncated mid-channel would
|
||||
// mis-parse, since channel width is inferred from digit count.
|
||||
if !ends_with_osc_terminator(&buf) {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(buf).ok()
|
||||
}
|
||||
|
||||
/// True when the buffer ends with BEL or ST (`ESC \`).
|
||||
#[cfg(any(unix, test))]
|
||||
fn ends_with_osc_terminator(buf: &[u8]) -> bool {
|
||||
buf.last() == Some(&0x07) || buf.ends_with(b"\x1b\\")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- ends_with_osc_terminator ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unterminated_reply_is_rejected() {
|
||||
// A truncated channel would mis-parse and could flip dark/light.
|
||||
assert!(!ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/00"));
|
||||
assert!(ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/ffff\x07"));
|
||||
assert!(ends_with_osc_terminator(
|
||||
b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\"
|
||||
));
|
||||
assert!(!ends_with_osc_terminator(b""));
|
||||
}
|
||||
|
||||
// -- parse_osc11_rgb -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_4digit_white() {
|
||||
// xterm-style: rgb:ffff/ffff/ffff
|
||||
let response = "\x1b]11;rgb:ffff/ffff/ffff\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_4digit_black() {
|
||||
let response = "\x1b]11;rgb:0000/0000/0000\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((0, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_2digit_dark() {
|
||||
// Some terminals use 2-digit hex: rgb:1a/1b/26
|
||||
let response = "\x1b]11;rgb:1a/1b/26\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((0x1a, 0x1b, 0x26)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_2digit_light() {
|
||||
let response = "\x1b]11;rgb:f0/f0/f0\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((0xf0, 0xf0, 0xf0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_4digit_midrange() {
|
||||
// rgb:8080/8080/8080 → high byte is 0x80 = 128
|
||||
let response = "\x1b]11;rgb:8080/8080/8080\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((128, 128, 128)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_st_terminator() {
|
||||
// Some terminals use ESC \ (ST) instead of BEL as terminator.
|
||||
let response = "\x1b]11;rgb:ffff/ffff/ffff\x1b\\";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_rgb_prefix() {
|
||||
let response = "\x1b]11;color:ffff/ffff/ffff\x07";
|
||||
assert!(parse_osc11_rgb(response).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_too_few_channels() {
|
||||
let response = "\x1b]11;rgb:ffff/ffff\x07";
|
||||
assert!(parse_osc11_rgb(response).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_response() {
|
||||
assert!(parse_osc11_rgb("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_hex() {
|
||||
let response = "\x1b]11;rgb:gggg/hhhh/iiii\x07";
|
||||
assert!(parse_osc11_rgb(response).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_1digit_channel() {
|
||||
// Edge case: single digit per channel (treated as 2-digit path).
|
||||
let response = "\x1b]11;rgb:f/f/f\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_3digit_channel() {
|
||||
// 3-digit hex (uncommon but possible) — >2 digits, so high byte extracted.
|
||||
// 0xfff = 4095, >> 8 = 15
|
||||
let response = "\x1b]11;rgb:fff/fff/fff\x07";
|
||||
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
|
||||
}
|
||||
|
||||
// -- parse_channel -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn channel_4digit_max() {
|
||||
assert_eq!(parse_channel("ffff"), Some(255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_4digit_zero() {
|
||||
assert_eq!(parse_channel("0000"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_2digit_max() {
|
||||
assert_eq!(parse_channel("ff"), Some(255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_2digit_zero() {
|
||||
assert_eq!(parse_channel("00"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_with_whitespace() {
|
||||
assert_eq!(parse_channel(" ff "), Some(255));
|
||||
}
|
||||
|
||||
// -- classify_luminance --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classify_pure_black_is_dark() {
|
||||
assert_eq!(classify_luminance(0, 0, 0), SystemAppearance::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_pure_white_is_light() {
|
||||
assert_eq!(classify_luminance(255, 255, 255), SystemAppearance::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_dark_gray_is_dark() {
|
||||
// Typical dark terminal background: #1a1b26 (TokyoNight)
|
||||
assert_eq!(classify_luminance(0x1a, 0x1b, 0x26), SystemAppearance::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_light_gray_is_light() {
|
||||
// Typical light terminal background: #f0f0f0
|
||||
assert_eq!(
|
||||
classify_luminance(0xf0, 0xf0, 0xf0),
|
||||
SystemAppearance::Light
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mid_gray_boundary() {
|
||||
// sRGB (186, 186, 186) has luminance ≈ 0.497 → just below 0.5 → Dark
|
||||
// sRGB (188, 188, 188) has luminance ≈ 0.508 → just above 0.5 → Light
|
||||
assert_eq!(classify_luminance(186, 186, 186), SystemAppearance::Dark);
|
||||
assert_eq!(classify_luminance(188, 188, 188), SystemAppearance::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_solarized_dark_is_dark() {
|
||||
// Solarized Dark base03: #002b36
|
||||
assert_eq!(classify_luminance(0x00, 0x2b, 0x36), SystemAppearance::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_solarized_light_is_light() {
|
||||
// Solarized Light base3: #fdf6e3
|
||||
assert_eq!(
|
||||
classify_luminance(0xfd, 0xf6, 0xe3),
|
||||
SystemAppearance::Light
|
||||
);
|
||||
}
|
||||
|
||||
// -- srgb_to_linear ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn srgb_to_linear_zero() {
|
||||
assert!((srgb_to_linear(0) - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_to_linear_max() {
|
||||
assert!((srgb_to_linear(255) - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_to_linear_low_value() {
|
||||
// 10/255 ≈ 0.0392 < 0.04045 → linear branch
|
||||
let result = srgb_to_linear(10);
|
||||
let expected = (10.0 / 255.0) / 12.92;
|
||||
assert!((result - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_to_linear_high_value() {
|
||||
// 128/255 ≈ 0.502 > 0.04045 → gamma branch
|
||||
let result = srgb_to_linear(128);
|
||||
let s: f64 = 128.0 / 255.0;
|
||||
let expected = ((s + 0.055) / 1.055).powf(2.4);
|
||||
assert!((result - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -- detect_via_osc11 (graceful degradation) -----------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_when_not_tty() {
|
||||
// In CI / test runners stdin is captured; the early `is_terminal`
|
||||
// check must return None without writing anything to stderr.
|
||||
assert_eq!(detect_via_osc11(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_osc_response_with_fd_returns_none_for_non_tty_fd() {
|
||||
// tcgetattr on /dev/null returns ENOTTY; we must bail without
|
||||
// panicking and without touching crossterm's process-wide state.
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let f = std::fs::File::open("/dev/null").unwrap();
|
||||
let result = read_osc_response_with_fd(f.as_raw_fd(), Duration::from_millis(10));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn make_raw_termios_clears_only_canonical_echo_signal_extended() {
|
||||
// Pre-populate with cleared bits AND preserved bits, then assert
|
||||
// the result is exactly the preserved set. Catches regressions
|
||||
// that widen the mask.
|
||||
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
|
||||
snapshot.c_lflag =
|
||||
libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN | libc::TOSTOP | libc::NOFLSH;
|
||||
let raw = make_raw_termios(&snapshot);
|
||||
assert_eq!(raw.c_lflag, libc::TOSTOP | libc::NOFLSH);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn make_raw_termios_preserves_other_flag_words() {
|
||||
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
|
||||
snapshot.c_lflag = libc::TOSTOP | libc::ICANON;
|
||||
snapshot.c_iflag = libc::ICRNL;
|
||||
snapshot.c_oflag = libc::OPOST;
|
||||
snapshot.c_cflag = libc::CS8;
|
||||
let raw = make_raw_termios(&snapshot);
|
||||
assert_eq!(raw.c_lflag & libc::TOSTOP, libc::TOSTOP);
|
||||
assert_eq!(raw.c_iflag, snapshot.c_iflag);
|
||||
assert_eq!(raw.c_oflag, snapshot.c_oflag);
|
||||
assert_eq!(raw.c_cflag, snapshot.c_cflag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
use super::tokyonight::Theme;
|
||||
|
||||
const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Oscura Midnight palette.
|
||||
///
|
||||
/// Deep, dark backgrounds with a subtle purple/blue tint (OKLCH hue 265),
|
||||
/// inspired by the Oscura Midnight palette (narative/oscura). Accent colors
|
||||
/// lean purple to give the theme its distinctive identity.
|
||||
///
|
||||
/// Base colors were converted from OKLCH to sRGB programmatically via
|
||||
/// the `coloraide` Python library. Purple accent colors are hand-picked
|
||||
/// to complement the hue-265 background tint.
|
||||
#[allow(dead_code)]
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// -- backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265) -------
|
||||
pub const BASE: Color = rgb(3, 3, 4); // #030304 oklch(0.1 0.005 265)
|
||||
pub const SURFACE: Color = rgb(4, 5, 7); // #040507 oklch(0.115 0.005 265)
|
||||
pub const ELEVATED: Color = rgb(15, 18, 22); // #0F1216 oklch(0.18 0.01 265)
|
||||
pub const PANEL: Color = rgb(4, 4, 6); // #040406 oklch(0.11 0.006 265)
|
||||
|
||||
// -- text (neutral, no color cast) ----------------------------------------
|
||||
pub const TEXT: Color = rgb(228, 228, 228); // #E4E4E4 oklch(0.92 0 0)
|
||||
pub const TEXT_DIM: Color = rgb(190, 190, 190); // #BEBEBE oklch(0.8 0 0)
|
||||
|
||||
// -- muted text (slight blue-purple tint) ---------------------------------
|
||||
pub const MUTED: Color = rgb(129, 134, 143); // #81868F oklch(0.62 0.015 260)
|
||||
pub const SUBTLE: Color = rgb(94, 100, 108); // #5E646C oklch(0.5 0.015 260)
|
||||
|
||||
// -- semantic colors (from desktop action tokens) -------------------------
|
||||
pub const GOLD: Color = rgb(235, 217, 110); // #EBD96E oklch(0.88 0.13 100)
|
||||
pub const RED: Color = rgb(220, 90, 100); // #DC5A64 muted rose-red
|
||||
pub const TEAL: Color = rgb(80, 180, 140); // #50B48C softened teal
|
||||
pub const AMBER: Color = rgb(241, 189, 0); // #F1BD00 oklch(0.82 0.18 90)
|
||||
|
||||
// -- purple accent ramp (the "purple hints") ------------------------------
|
||||
pub const PURPLE: Color = rgb(155, 126, 206); // #9B7ECE — signature purple
|
||||
pub const PURPLE_DIM: Color = rgb(110, 90, 154); // #6E5A9A — muted purple
|
||||
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231); // #C4A7E7 — vivid lavender
|
||||
|
||||
// -- cyan (for running indicators, links) ---------------------------------
|
||||
pub const CYAN: Color = rgb(125, 207, 223); // #7DCFDF
|
||||
|
||||
// -- highlight ramp (purple-tinted grays for UI chrome) -------------------
|
||||
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28); // #12101C
|
||||
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52); // #242034
|
||||
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72); // #343048
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
impl Theme {
|
||||
pub const fn oscura_midnight() -> Self {
|
||||
Self {
|
||||
bg_base: BASE,
|
||||
bg_light: ELEVATED,
|
||||
bg_dark: SURFACE,
|
||||
bg_highlight: ELEVATED,
|
||||
bg_hover: HIGHLIGHT_MED,
|
||||
bg_terminal: BASE,
|
||||
|
||||
accent_user: PURPLE_BRIGHT,
|
||||
accent_assistant: PURPLE,
|
||||
accent_thinking: MUTED,
|
||||
accent_tool: SUBTLE,
|
||||
accent_system: CYAN,
|
||||
accent_error: RED,
|
||||
accent_success: TEAL,
|
||||
accent_running: PURPLE_DIM,
|
||||
accent_skill: PURPLE,
|
||||
|
||||
text_primary: TEXT,
|
||||
text_secondary: TEXT_DIM,
|
||||
|
||||
gray_dim: SUBTLE,
|
||||
gray: MUTED,
|
||||
gray_bright: TEXT_DIM,
|
||||
|
||||
command: GOLD,
|
||||
path: AMBER,
|
||||
running: CYAN,
|
||||
warning: GOLD,
|
||||
|
||||
fuzzy_accent: PURPLE_BRIGHT,
|
||||
|
||||
accent_plan: GOLD,
|
||||
|
||||
accent_verify: PURPLE,
|
||||
|
||||
accent_feedback: TEAL,
|
||||
|
||||
accent_remember: rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
|
||||
selection_border: HIGHLIGHT_HIGH,
|
||||
hover_border: HIGHLIGHT_MED,
|
||||
prompt_border: HIGHLIGHT_MED,
|
||||
prompt_border_active: HIGHLIGHT_HIGH,
|
||||
|
||||
accent_model: CYAN,
|
||||
|
||||
// Thumb must sit clearly above the track: `ELEVATED` (Σrgb 55)
|
||||
// was *darker* than the `HIGHLIGHT_LOW` track (Σrgb 62), which
|
||||
// made the scrollbar invisible — and follow mode blends the
|
||||
// thumb 40% toward the track, shrinking the delta further.
|
||||
// `HIGHLIGHT_HIGH` matches the weight of the theme's visible
|
||||
// chrome (selection border) and Rose Pine's thumb brightness.
|
||||
scrollbar_bg: HIGHLIGHT_LOW,
|
||||
scrollbar_fg: HIGHLIGHT_HIGH,
|
||||
|
||||
diff_delete_bg: rgb(45, 15, 25),
|
||||
diff_delete_fg: RED,
|
||||
diff_insert_bg: rgb(10, 35, 30),
|
||||
diff_insert_fg: TEAL,
|
||||
diff_equal_fg: MUTED,
|
||||
diff_gutter_fg: MUTED,
|
||||
|
||||
bg_visual: HIGHLIGHT_MED,
|
||||
|
||||
paste_bg: SURFACE,
|
||||
paste_fg: TEXT_DIM,
|
||||
paste_dim: MUTED,
|
||||
|
||||
md_heading_h1: TEXT,
|
||||
md_heading_h1_mod: Modifier::BOLD,
|
||||
md_heading_h2: PURPLE_BRIGHT,
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: PURPLE,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: TEAL,
|
||||
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
|
||||
md_heading_h5: GOLD,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: CYAN,
|
||||
md_heading_h6_mod: Modifier::BOLD,
|
||||
md_code: CYAN,
|
||||
md_task_checked: TEAL,
|
||||
md_task_unchecked: TEXT_DIM,
|
||||
md_muted: MUTED,
|
||||
md_code_bg: SURFACE,
|
||||
md_text: TEXT,
|
||||
link_fg: CYAN,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
use super::tokyonight::Theme;
|
||||
|
||||
const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
pub const BASE: Color = rgb(35, 33, 54);
|
||||
pub const SURFACE: Color = rgb(42, 39, 63);
|
||||
pub const OVERLAY: Color = rgb(57, 53, 82);
|
||||
pub const MUTED: Color = rgb(110, 106, 134);
|
||||
pub const SUBTLE: Color = rgb(144, 140, 170);
|
||||
pub const TEXT: Color = rgb(224, 222, 244);
|
||||
pub const LOVE: Color = rgb(235, 111, 146);
|
||||
pub const GOLD: Color = rgb(246, 193, 119);
|
||||
pub const ROSE: Color = rgb(234, 154, 151);
|
||||
pub const PINE: Color = rgb(62, 143, 176);
|
||||
pub const FOAM: Color = rgb(156, 207, 216);
|
||||
pub const IRIS: Color = rgb(196, 167, 231);
|
||||
pub const HIGHLIGHT_LOW: Color = rgb(42, 40, 62);
|
||||
pub const HIGHLIGHT_MED: Color = rgb(68, 65, 90);
|
||||
pub const HIGHLIGHT_HIGH: Color = rgb(86, 82, 110);
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
impl Theme {
|
||||
pub const fn rosepine_moon() -> Self {
|
||||
Self {
|
||||
bg_base: BASE,
|
||||
bg_light: OVERLAY,
|
||||
bg_dark: SURFACE,
|
||||
bg_highlight: OVERLAY,
|
||||
bg_hover: HIGHLIGHT_MED,
|
||||
bg_terminal: BASE,
|
||||
|
||||
accent_user: TEXT,
|
||||
accent_assistant: IRIS,
|
||||
accent_thinking: MUTED,
|
||||
accent_tool: SUBTLE,
|
||||
accent_system: PINE,
|
||||
accent_error: LOVE,
|
||||
accent_success: FOAM,
|
||||
accent_running: MUTED,
|
||||
accent_skill: SUBTLE,
|
||||
|
||||
text_primary: TEXT,
|
||||
text_secondary: SUBTLE,
|
||||
|
||||
gray_dim: HIGHLIGHT_MED,
|
||||
gray: MUTED,
|
||||
gray_bright: SUBTLE,
|
||||
|
||||
command: GOLD,
|
||||
path: ROSE,
|
||||
running: FOAM,
|
||||
warning: GOLD,
|
||||
|
||||
fuzzy_accent: PINE,
|
||||
|
||||
accent_plan: GOLD,
|
||||
|
||||
accent_verify: PINE,
|
||||
|
||||
accent_feedback: FOAM,
|
||||
|
||||
accent_remember: PINE,
|
||||
|
||||
selection_border: HIGHLIGHT_HIGH,
|
||||
hover_border: HIGHLIGHT_MED,
|
||||
prompt_border: HIGHLIGHT_MED,
|
||||
prompt_border_active: HIGHLIGHT_HIGH,
|
||||
|
||||
accent_model: PINE,
|
||||
|
||||
scrollbar_bg: HIGHLIGHT_LOW,
|
||||
scrollbar_fg: OVERLAY,
|
||||
|
||||
diff_delete_bg: rgb(55, 30, 40),
|
||||
diff_delete_fg: LOVE,
|
||||
diff_insert_bg: rgb(25, 45, 55),
|
||||
diff_insert_fg: FOAM,
|
||||
diff_equal_fg: MUTED,
|
||||
diff_gutter_fg: MUTED,
|
||||
|
||||
bg_visual: HIGHLIGHT_MED,
|
||||
|
||||
paste_bg: SURFACE,
|
||||
paste_fg: SUBTLE,
|
||||
paste_dim: MUTED,
|
||||
|
||||
md_heading_h1: TEXT,
|
||||
md_heading_h1_mod: Modifier::BOLD,
|
||||
md_heading_h2: FOAM,
|
||||
md_heading_h2_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
|
||||
md_heading_h3: IRIS,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: ROSE,
|
||||
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
|
||||
md_heading_h5: GOLD,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: PINE,
|
||||
md_heading_h6_mod: Modifier::BOLD,
|
||||
md_code: FOAM,
|
||||
md_task_checked: FOAM,
|
||||
md_task_unchecked: SUBTLE,
|
||||
md_muted: MUTED,
|
||||
md_code_bg: SURFACE,
|
||||
md_text: TEXT,
|
||||
link_fg: FOAM, // #9ccfd8 -- teal/cyan for dark bg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! System appearance detection for automatic day/night theming.
|
||||
//!
|
||||
//! Uses the `dark-light` crate for cross-platform detection:
|
||||
//! - macOS: reads `AppleInterfaceStyle` preference
|
||||
//! - Linux: queries XDG Desktop Portal (`org.freedesktop.appearance.color-scheme`)
|
||||
//! - Windows: reads system personalization registry
|
||||
//!
|
||||
//! Falls back to OSC 11 terminal background query when `dark-light` returns
|
||||
//! `Unspecified` (e.g., over SSH where no desktop session is available).
|
||||
//! The OSC 11 fallback is **startup-only** — see [`detect_with_osc11_fallback`].
|
||||
//!
|
||||
//! Falls back to `None` on total detection failure.
|
||||
|
||||
use super::ThemeKind;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Detected system appearance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemAppearance {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Detect the current system appearance (desktop APIs only).
|
||||
///
|
||||
/// Detection chain:
|
||||
/// 1. `dark-light::detect()` — desktop session APIs (macOS/Linux/Windows)
|
||||
/// 2. `None` — if detection fails
|
||||
///
|
||||
/// For the extended chain that includes OSC 11 as a startup-only fallback,
|
||||
/// see [`detect_with_osc11_fallback`].
|
||||
///
|
||||
/// In `#[cfg(test)]` builds, checks the mock override first so that
|
||||
/// `SystemAppearanceWatcher`'s polling loop (which calls `detect()`
|
||||
/// directly) is also controllable from tests.
|
||||
#[must_use]
|
||||
pub fn detect() -> Option<SystemAppearance> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(v) = mock_override() {
|
||||
return v;
|
||||
}
|
||||
|
||||
detect_without_mock()
|
||||
}
|
||||
|
||||
/// Detect system appearance with OSC 11 terminal background fallback.
|
||||
///
|
||||
/// Extended detection chain:
|
||||
/// 1. `dark-light::detect()` — desktop session APIs
|
||||
/// 2. OSC 11 terminal background query — fallback for SSH/headless
|
||||
/// 3. `None` — if both fail
|
||||
///
|
||||
/// **Startup-only**: the OSC 11 step requires raw-mode stdin access and
|
||||
/// must NOT be called once crossterm's `EventStream` is active. The
|
||||
/// live [`SystemAppearanceWatcher`] uses [`detect`] (without OSC 11).
|
||||
#[must_use]
|
||||
pub fn detect_with_osc11_fallback() -> Option<SystemAppearance> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(v) = mock_override() {
|
||||
return v;
|
||||
}
|
||||
|
||||
detect_without_mock().or_else(super::osc11::detect_via_osc11)
|
||||
}
|
||||
|
||||
/// Inner detection via desktop APIs only (no mock, no OSC 11).
|
||||
fn detect_without_mock() -> Option<SystemAppearance> {
|
||||
match dark_light::detect() {
|
||||
Ok(dark_light::Mode::Dark) => Some(SystemAppearance::Dark),
|
||||
Ok(dark_light::Mode::Light) => Some(SystemAppearance::Light),
|
||||
// Mode::Unspecified or Err — no system preference detected
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the mock value if one has been set (test builds only).
|
||||
///
|
||||
/// Returns `Some(value)` when a mock is active, `None` when real
|
||||
/// detection should proceed.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn mock_override() -> Option<Option<SystemAppearance>> {
|
||||
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Map system appearance to a theme kind using config-driven overrides.
|
||||
///
|
||||
/// `dark_theme` and `light_theme` are the user-configured themes for each
|
||||
/// appearance mode, read from `[ui].auto_dark_theme` and `[ui].auto_light_theme`
|
||||
/// in `config.toml`. When `None`, defaults to `GrokNight` / `GrokDay`.
|
||||
///
|
||||
/// This function is the single mapping point for appearance -> theme.
|
||||
/// All callers go through it, making the mapping trivially extensible.
|
||||
#[must_use]
|
||||
pub fn to_theme_kind(
|
||||
appearance: SystemAppearance,
|
||||
dark_theme: Option<ThemeKind>,
|
||||
light_theme: Option<ThemeKind>,
|
||||
) -> ThemeKind {
|
||||
match appearance {
|
||||
SystemAppearance::Light => light_theme.unwrap_or(ThemeKind::GrokDay),
|
||||
SystemAppearance::Dark => dark_theme.unwrap_or(ThemeKind::GrokNight),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polling interval for system appearance detection.
|
||||
///
|
||||
/// In test builds, a shorter interval (50ms) is used so polling tests
|
||||
/// complete quickly.
|
||||
#[cfg(not(test))]
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Watches for system appearance changes via polling.
|
||||
///
|
||||
/// The spawned polling task only reads system state and sends via
|
||||
/// `watch::channel` — it never mutates `theme_cache::CURRENT` or `AUTO_MODE`.
|
||||
/// The watcher does NOT use OSC 11 for polling — only [`detect()`].
|
||||
pub struct SystemAppearanceWatcher {
|
||||
rx: watch::Receiver<Option<SystemAppearance>>,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SystemAppearanceWatcher {
|
||||
/// Start the watcher if auto mode is active.
|
||||
///
|
||||
/// Returns `None` when `is_auto` is false — the event loop uses
|
||||
/// `std::future::pending()` in that case so the `select!` branch
|
||||
/// never fires.
|
||||
pub fn start_if_auto(is_auto: bool) -> Option<Self> {
|
||||
if !is_auto {
|
||||
return None;
|
||||
}
|
||||
|
||||
let initial = detect();
|
||||
let (tx, rx) = watch::channel(initial);
|
||||
let interval = POLL_INTERVAL;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut current = initial;
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
let detected = detect();
|
||||
if detected != current {
|
||||
current = detected;
|
||||
let _ = tx.send(current);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
rx,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for the next appearance change.
|
||||
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.rx.changed().await
|
||||
}
|
||||
|
||||
/// Return the current detected appearance.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> Option<SystemAppearance> {
|
||||
*self.rx.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemAppearanceWatcher {
|
||||
fn drop(&mut self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Test support ----------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Mock override for `detect()`. When set to `Some(value)`, `detect()`
|
||||
/// returns the mock value instead of calling `dark_light::detect()`.
|
||||
/// This ensures the `SystemAppearanceWatcher` polling loop (which calls
|
||||
/// `detect()` directly) is also controllable from tests.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
static MOCK_APPEARANCE: Mutex<Option<Option<SystemAppearance>>> = Mutex::new(None);
|
||||
|
||||
/// Override `detect()` for tests. Set to `Some(value)` to mock a specific
|
||||
/// appearance, or `None` to mock detection failure.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_mock(value: Option<SystemAppearance>) {
|
||||
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = Some(value);
|
||||
}
|
||||
|
||||
/// Clear the mock override, restoring real detection behavior.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn clear_mock() {
|
||||
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::cache as theme_cache;
|
||||
use super::*;
|
||||
|
||||
/// Helper: set mock, assert `detect()` returns the expected value, clear mock.
|
||||
/// Caller must hold `theme_cache::test_lock()` to prevent races with parallel
|
||||
/// tests in `cache::tests` and `slash::commands::theme::tests` that also
|
||||
/// mutate the shared `MOCK_APPEARANCE` static via `set_mock`/`clear_mock`.
|
||||
fn assert_mock_roundtrip(value: Option<SystemAppearance>) {
|
||||
set_mock(value);
|
||||
assert_eq!(detect(), value);
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_dark_defaults_to_groknight() {
|
||||
let result = to_theme_kind(SystemAppearance::Dark, None, None);
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_light_defaults_to_grokday() {
|
||||
let result = to_theme_kind(SystemAppearance::Light, None, None);
|
||||
assert_eq!(result, ThemeKind::GrokDay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_custom_dark_theme() {
|
||||
let result = to_theme_kind(SystemAppearance::Dark, Some(ThemeKind::TokyoNight), None);
|
||||
assert_eq!(result, ThemeKind::TokyoNight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_custom_light_theme() {
|
||||
let result = to_theme_kind(SystemAppearance::Light, None, Some(ThemeKind::RosePineMoon));
|
||||
assert_eq!(result, ThemeKind::RosePineMoon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_custom_both() {
|
||||
let result = to_theme_kind(
|
||||
SystemAppearance::Dark,
|
||||
Some(ThemeKind::RosePineMoon),
|
||||
Some(ThemeKind::GrokNight),
|
||||
);
|
||||
assert_eq!(result, ThemeKind::RosePineMoon);
|
||||
|
||||
let result = to_theme_kind(
|
||||
SystemAppearance::Light,
|
||||
Some(ThemeKind::RosePineMoon),
|
||||
Some(ThemeKind::GrokNight),
|
||||
);
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_dark_ignores_light_override() {
|
||||
let result = to_theme_kind(SystemAppearance::Dark, None, Some(ThemeKind::TokyoNight));
|
||||
// Dark appearance should use the dark default, not the light override.
|
||||
assert_eq!(result, ThemeKind::GrokNight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_theme_kind_light_ignores_dark_override() {
|
||||
let result = to_theme_kind(SystemAppearance::Light, Some(ThemeKind::TokyoNight), None);
|
||||
// Light appearance should use the light default, not the dark override.
|
||||
assert_eq!(result, ThemeKind::GrokDay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_dark_appearance() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
assert_mock_roundtrip(Some(SystemAppearance::Dark));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_light_appearance() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
assert_mock_roundtrip(Some(SystemAppearance::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_detection_failure() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
assert_mock_roundtrip(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_mock_restores_real_detection() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
assert_eq!(detect(), Some(SystemAppearance::Dark));
|
||||
clear_mock();
|
||||
// After clearing, detect() calls dark_light::detect() for real.
|
||||
// We can't assert a specific value since it depends on the system,
|
||||
// but we can verify it doesn't panic.
|
||||
let _ = detect();
|
||||
}
|
||||
|
||||
// -- SystemAppearanceWatcher -----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_if_auto_returns_none_when_not_auto() {
|
||||
assert!(SystemAppearanceWatcher::start_if_auto(false).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_if_auto_returns_some_when_auto() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
let watcher = SystemAppearanceWatcher::start_if_auto(true);
|
||||
assert!(watcher.is_some());
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_reports_initial_appearance() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(Some(SystemAppearance::Light));
|
||||
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_reports_none_on_detection_failure() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(None);
|
||||
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), None);
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
async fn watcher_detects_appearance_change() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
|
||||
|
||||
// Change the mock appearance.
|
||||
set_mock(Some(SystemAppearance::Light));
|
||||
|
||||
// Wait for the watcher to detect the change (polls every 50ms in tests).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
|
||||
.await
|
||||
.expect("timed out waiting for change")
|
||||
.expect("watcher channel closed");
|
||||
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
async fn watcher_does_not_send_when_unchanged() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
|
||||
// Wait longer than the poll interval — no change should occur.
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(200), watcher.changed()).await;
|
||||
|
||||
// Should timeout because appearance didn't change.
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected timeout — no change should be emitted"
|
||||
);
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
|
||||
clear_mock();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
async fn watcher_detects_recovery_from_failure() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(None); // Initially detection fails
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), None);
|
||||
|
||||
// Now detection succeeds.
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
|
||||
.await
|
||||
.expect("timed out waiting for recovery")
|
||||
.expect("watcher channel closed");
|
||||
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
|
||||
clear_mock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Terminal-native palette for minimal mode.
|
||||
//!
|
||||
//! Any RGB theme is designed for one background polarity, so composited on
|
||||
//! the terminal's own canvas it can land dark-on-dark or light-on-light
|
||||
//! (e.g. macOS in Light Mode + a dark terminal profile). Polarity detection
|
||||
//! is not reliable either: OS appearance and OSC 11 both disagree with the
|
||||
//! actual canvas in edge cases and can change mid-session. Terminal
|
||||
//! profiles, however, tune their **default** fg/bg to be legible against
|
||||
//! their own background — this is how `git` and `ls` stay readable on any
|
||||
//! terminal — so a palette built from `Reset` (body) + sparse named ANSI-16
|
||||
//! accents is polarity-safe without detection.
|
||||
//!
|
||||
//! ## Grays / secondary text
|
||||
//!
|
||||
//! Do **not** paint body or status text as `DarkGray` (ANSI bright black).
|
||||
//! Many dark profiles deliberately set that slot very dark for subtle
|
||||
//! chrome, which washes out tool stdout and the prompt info bar. Instead:
|
||||
//!
|
||||
//! - **Primary content** (`text_primary`, `gray_bright`, …) → `Color::Reset`
|
||||
//! (terminal default foreground).
|
||||
//! - **Secondary chrome** (`gray`, `gray_dim`, `text_secondary`) → also
|
||||
//! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM`
|
||||
//! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike
|
||||
//! hard-coding bright black.
|
||||
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
use super::Theme;
|
||||
|
||||
impl Theme {
|
||||
/// The fixed terminal-native palette used by minimal mode: every field
|
||||
/// is `Color::Reset` or a named ANSI-16 color (see the module docs).
|
||||
pub const fn terminal_default() -> Self {
|
||||
// Secondary roles store Reset; Theme::muted / Theme::dim apply SGR dim.
|
||||
const MUTED: Color = Color::Reset;
|
||||
|
||||
Self {
|
||||
bg_base: Color::Reset,
|
||||
bg_light: Color::Reset,
|
||||
bg_dark: Color::Reset,
|
||||
bg_highlight: Color::Reset,
|
||||
bg_hover: Color::Reset,
|
||||
bg_terminal: Color::Reset,
|
||||
|
||||
accent_user: Color::Reset,
|
||||
accent_assistant: Color::Magenta,
|
||||
accent_thinking: MUTED,
|
||||
accent_tool: MUTED,
|
||||
accent_system: Color::Blue,
|
||||
accent_error: Color::Red,
|
||||
accent_success: Color::Green,
|
||||
accent_running: Color::Magenta,
|
||||
accent_skill: Color::Blue,
|
||||
|
||||
text_primary: Color::Reset,
|
||||
text_secondary: MUTED,
|
||||
|
||||
gray_dim: MUTED,
|
||||
gray: MUTED,
|
||||
gray_bright: Color::Reset,
|
||||
|
||||
command: Color::Yellow,
|
||||
path: Color::Cyan,
|
||||
running: Color::Cyan,
|
||||
warning: Color::Yellow,
|
||||
|
||||
fuzzy_accent: Color::Cyan,
|
||||
|
||||
accent_plan: Color::Yellow,
|
||||
accent_verify: Color::Magenta,
|
||||
accent_feedback: Color::Cyan,
|
||||
accent_remember: Color::Green,
|
||||
|
||||
selection_border: MUTED,
|
||||
hover_border: MUTED,
|
||||
prompt_border: MUTED,
|
||||
prompt_border_active: Color::Reset,
|
||||
|
||||
accent_model: Color::Cyan,
|
||||
|
||||
scrollbar_bg: Color::Reset,
|
||||
scrollbar_fg: MUTED,
|
||||
|
||||
diff_delete_bg: Color::Reset,
|
||||
diff_delete_fg: Color::Red,
|
||||
diff_insert_bg: Color::Reset,
|
||||
diff_insert_fg: Color::Green,
|
||||
diff_equal_fg: MUTED,
|
||||
diff_gutter_fg: MUTED,
|
||||
|
||||
bg_visual: Color::Reset,
|
||||
|
||||
paste_bg: Color::Reset,
|
||||
paste_fg: MUTED,
|
||||
paste_dim: MUTED,
|
||||
|
||||
md_heading_h1: Color::Reset,
|
||||
md_heading_h1_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
|
||||
md_heading_h2: Color::Reset,
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: Color::Reset,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: Color::Reset,
|
||||
md_heading_h4_mod: Modifier::BOLD,
|
||||
md_heading_h5: Color::Reset,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: MUTED,
|
||||
md_heading_h6_mod: Modifier::BOLD,
|
||||
md_code: Color::Cyan,
|
||||
md_task_checked: Color::Green,
|
||||
md_task_unchecked: MUTED,
|
||||
md_muted: MUTED,
|
||||
md_code_bg: Color::Reset,
|
||||
md_text: Color::Reset,
|
||||
link_fg: Color::Blue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all_colors(theme: &Theme) -> Vec<(&'static str, Color)> {
|
||||
vec![
|
||||
("bg_base", theme.bg_base),
|
||||
("bg_light", theme.bg_light),
|
||||
("bg_dark", theme.bg_dark),
|
||||
("bg_highlight", theme.bg_highlight),
|
||||
("bg_hover", theme.bg_hover),
|
||||
("bg_terminal", theme.bg_terminal),
|
||||
("accent_user", theme.accent_user),
|
||||
("accent_assistant", theme.accent_assistant),
|
||||
("accent_thinking", theme.accent_thinking),
|
||||
("accent_tool", theme.accent_tool),
|
||||
("accent_system", theme.accent_system),
|
||||
("accent_error", theme.accent_error),
|
||||
("accent_success", theme.accent_success),
|
||||
("accent_running", theme.accent_running),
|
||||
("accent_skill", theme.accent_skill),
|
||||
("text_primary", theme.text_primary),
|
||||
("text_secondary", theme.text_secondary),
|
||||
("gray_dim", theme.gray_dim),
|
||||
("gray", theme.gray),
|
||||
("gray_bright", theme.gray_bright),
|
||||
("command", theme.command),
|
||||
("path", theme.path),
|
||||
("running", theme.running),
|
||||
("warning", theme.warning),
|
||||
("fuzzy_accent", theme.fuzzy_accent),
|
||||
("accent_plan", theme.accent_plan),
|
||||
("accent_verify", theme.accent_verify),
|
||||
("accent_feedback", theme.accent_feedback),
|
||||
("accent_remember", theme.accent_remember),
|
||||
("selection_border", theme.selection_border),
|
||||
("hover_border", theme.hover_border),
|
||||
("prompt_border", theme.prompt_border),
|
||||
("prompt_border_active", theme.prompt_border_active),
|
||||
("accent_model", theme.accent_model),
|
||||
("scrollbar_bg", theme.scrollbar_bg),
|
||||
("scrollbar_fg", theme.scrollbar_fg),
|
||||
("diff_delete_bg", theme.diff_delete_bg),
|
||||
("diff_delete_fg", theme.diff_delete_fg),
|
||||
("diff_insert_bg", theme.diff_insert_bg),
|
||||
("diff_insert_fg", theme.diff_insert_fg),
|
||||
("diff_equal_fg", theme.diff_equal_fg),
|
||||
("diff_gutter_fg", theme.diff_gutter_fg),
|
||||
("bg_visual", theme.bg_visual),
|
||||
("paste_bg", theme.paste_bg),
|
||||
("paste_fg", theme.paste_fg),
|
||||
("paste_dim", theme.paste_dim),
|
||||
("md_heading_h1", theme.md_heading_h1),
|
||||
("md_heading_h2", theme.md_heading_h2),
|
||||
("md_heading_h3", theme.md_heading_h3),
|
||||
("md_heading_h4", theme.md_heading_h4),
|
||||
("md_heading_h5", theme.md_heading_h5),
|
||||
("md_heading_h6", theme.md_heading_h6),
|
||||
("md_code", theme.md_code),
|
||||
("md_task_checked", theme.md_task_checked),
|
||||
("md_task_unchecked", theme.md_task_unchecked),
|
||||
("md_muted", theme.md_muted),
|
||||
("md_code_bg", theme.md_code_bg),
|
||||
("md_text", theme.md_text),
|
||||
("link_fg", theme.link_fg),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_uses_only_reset_and_named_ansi() {
|
||||
let theme = Theme::terminal_default();
|
||||
for (name, color) in all_colors(&theme) {
|
||||
assert!(
|
||||
!matches!(color, Color::Rgb(..) | Color::Indexed(_)),
|
||||
"{name} must be Reset or a named ANSI color, got {color:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_backgrounds_are_transparent() {
|
||||
let theme = Theme::terminal_default();
|
||||
for (name, color) in [
|
||||
("bg_base", theme.bg_base),
|
||||
("bg_light", theme.bg_light),
|
||||
("bg_dark", theme.bg_dark),
|
||||
("bg_terminal", theme.bg_terminal),
|
||||
("md_code_bg", theme.md_code_bg),
|
||||
("diff_delete_bg", theme.diff_delete_bg),
|
||||
("diff_insert_bg", theme.diff_insert_bg),
|
||||
("paste_bg", theme.paste_bg),
|
||||
] {
|
||||
assert_eq!(color, Color::Reset, "{name} must defer to the canvas");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_leaves_cursor_color_alone() {
|
||||
let theme = Theme::terminal_default();
|
||||
assert_eq!(theme.accent_user, Color::Reset);
|
||||
assert_eq!(
|
||||
crate::render::color::resolve_to_rgb(theme.accent_user),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_survives_quantization() {
|
||||
use crate::theme::color_support::ColorLevel;
|
||||
let theme = Theme::terminal_default();
|
||||
for level in [
|
||||
ColorLevel::Basic,
|
||||
ColorLevel::Ansi256,
|
||||
ColorLevel::TrueColor,
|
||||
] {
|
||||
let quantized = theme.quantized(level);
|
||||
for ((name, before), (_, after)) in
|
||||
all_colors(&theme).into_iter().zip(all_colors(&quantized))
|
||||
{
|
||||
assert_eq!(before, after, "{name} must survive {level:?}");
|
||||
}
|
||||
}
|
||||
let stripped = theme.quantized(ColorLevel::None);
|
||||
for (name, color) in all_colors(&stripped) {
|
||||
assert_eq!(color, Color::Reset, "{name} must strip under NO_COLOR");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_primary_is_reset_not_dark_gray() {
|
||||
let theme = Theme::terminal_default();
|
||||
assert_eq!(theme.text_primary, Color::Reset);
|
||||
assert_eq!(theme.gray, Color::Reset);
|
||||
assert_eq!(theme.gray_dim, Color::Reset);
|
||||
// Must not hard-code bright black for body/secondary roles.
|
||||
assert_ne!(theme.text_primary, Color::DarkGray);
|
||||
assert_ne!(theme.gray, Color::DarkGray);
|
||||
assert_ne!(theme.gray_dim, Color::DarkGray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_default_muted_and_dim_use_sgr_dim_not_bright_black() {
|
||||
use ratatui::style::Modifier;
|
||||
let theme = Theme::terminal_default();
|
||||
let muted = theme.muted();
|
||||
let dim = theme.dim();
|
||||
assert!(
|
||||
muted.add_modifier.contains(Modifier::DIM),
|
||||
"muted should DIM the terminal default fg: {muted:?}"
|
||||
);
|
||||
assert!(
|
||||
dim.add_modifier.contains(Modifier::DIM),
|
||||
"dim should DIM the terminal default fg: {dim:?}"
|
||||
);
|
||||
// No explicit DarkGray paint — dim tracks the host palette.
|
||||
assert!(
|
||||
muted.fg.is_none() || muted.fg == Some(Color::Reset),
|
||||
"muted must not set a hard gray: {muted:?}"
|
||||
);
|
||||
assert!(
|
||||
dim.fg.is_none() || dim.fg == Some(Color::Reset),
|
||||
"dim must not set a hard gray: {dim:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_theme_muted_keeps_explicit_gray_without_forced_dim() {
|
||||
use ratatui::style::Modifier;
|
||||
// GrokNight paints real RGB grays; muted/dim must not invent DIM.
|
||||
let theme = Theme::groknight();
|
||||
assert!(!matches!(theme.gray, Color::Reset));
|
||||
let muted = theme.muted();
|
||||
assert_eq!(muted.fg, Some(theme.gray));
|
||||
assert!(
|
||||
!muted.add_modifier.contains(Modifier::DIM),
|
||||
"RGB muted should not force SGR dim: {muted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! TokyoNight theme for the pager.
|
||||
//!
|
||||
//! All colors come from the `Theme` struct. NO hardcoded colors elsewhere.
|
||||
//!
|
||||
//! The named constants below match the TokyoNight Night/Storm palette from
|
||||
//! `kigi-tui/src/ui/style.rs` for consistency. The `Theme` struct maps
|
||||
//! these constants to semantic roles.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// Helper for concise const Color::Rgb definitions.
|
||||
const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
// TokyoNight palette constants (Night/Storm variant).
|
||||
// Keep in sync with kigi-tui TokyoNightNight.
|
||||
#[allow(dead_code)]
|
||||
pub mod palette {
|
||||
use super::*;
|
||||
pub const BG: Color = rgb(26, 27, 38); // #1a1b26 - Night
|
||||
pub const BG_DARK: Color = rgb(22, 22, 30); // #16161e
|
||||
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66); // #292e42
|
||||
pub const BG_STORM: Color = rgb(36, 40, 59); // #24283b - Storm
|
||||
pub const BG_STORM_DARK: Color = rgb(31, 35, 53); // #1f2335
|
||||
pub const FG: Color = rgb(192, 202, 245); // #c0caf5
|
||||
pub const FG_DARK: Color = rgb(169, 177, 214); // #a9b1d6
|
||||
pub const FG_GUTTER: Color = rgb(59, 66, 97); // #3b4261
|
||||
pub const COMMENT: Color = rgb(86, 95, 137); // #565f89
|
||||
pub const DARK3: Color = rgb(84, 92, 126); // #545c7e
|
||||
pub const DARK5: Color = rgb(115, 122, 162); // #737aa2
|
||||
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
|
||||
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
|
||||
pub const BLUE1: Color = rgb(42, 195, 222); // #2ac3de
|
||||
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
|
||||
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
|
||||
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
|
||||
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
|
||||
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
|
||||
pub const RED: Color = rgb(247, 118, 142); // #f7768e
|
||||
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
|
||||
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
|
||||
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
/// Theme for v3 pager rendering.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Theme {
|
||||
// Backgrounds
|
||||
pub bg_base: Color,
|
||||
pub bg_light: Color,
|
||||
pub bg_dark: Color,
|
||||
pub bg_highlight: Color,
|
||||
pub bg_hover: Color, // Mouse hover row in dropdowns — between bg_highlight and bg_visual
|
||||
pub bg_terminal: Color, // For terminal output blocks (currently unused, using bg_dark instead)
|
||||
|
||||
// Accent colors (for vertical lines)
|
||||
pub accent_user: Color,
|
||||
pub accent_assistant: Color,
|
||||
pub accent_thinking: Color,
|
||||
pub accent_tool: Color,
|
||||
pub accent_system: Color,
|
||||
pub accent_error: Color,
|
||||
pub accent_success: Color,
|
||||
pub accent_running: Color, // For tools that are currently running
|
||||
pub accent_skill: Color, // For skill invocations (slash command skills)
|
||||
|
||||
// Text colors
|
||||
pub text_primary: Color,
|
||||
pub text_secondary: Color,
|
||||
|
||||
// Gray scale (dim → medium → bright)
|
||||
// Every theme defines these three; they provide a consistent hierarchy
|
||||
// for secondary/meta text across all themes.
|
||||
pub gray_dim: Color, // Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
|
||||
pub gray: Color, // Medium — muted text, comments, collapsed content
|
||||
pub gray_bright: Color, // Brightest — tool accents, secondary labels
|
||||
|
||||
// Semantic colors
|
||||
pub command: Color, // Yellow for shell commands
|
||||
pub path: Color, // Orange for file paths
|
||||
pub running: Color, // Cyan for running indicator
|
||||
pub warning: Color, // Yellow/amber for warnings
|
||||
|
||||
// Search
|
||||
pub fuzzy_accent: Color, // Highlight color for fuzzy search matches
|
||||
|
||||
// Plan mode
|
||||
pub accent_plan: Color, // Golden accent for plan mode indicator
|
||||
|
||||
// Context-window overhead category (context info block)
|
||||
pub accent_verify: Color, // Violet accent — distinct from plan gold and feedback teal
|
||||
|
||||
// Feedback mode
|
||||
pub accent_feedback: Color, // Teal/green accent for feedback mode
|
||||
|
||||
// Remember mode
|
||||
pub accent_remember: Color, // Green accent for # remember mode
|
||||
|
||||
// Selection
|
||||
pub selection_border: Color,
|
||||
pub hover_border: Color,
|
||||
pub prompt_border: Color,
|
||||
pub prompt_border_active: Color,
|
||||
|
||||
// Prompt info
|
||||
pub accent_model: Color, // Model name in prompt info line
|
||||
|
||||
// Scrollbar
|
||||
pub scrollbar_bg: Color,
|
||||
pub scrollbar_fg: Color,
|
||||
|
||||
// Diff colors
|
||||
pub diff_delete_bg: Color,
|
||||
pub diff_delete_fg: Color,
|
||||
pub diff_insert_bg: Color,
|
||||
pub diff_insert_fg: Color,
|
||||
pub diff_equal_fg: Color,
|
||||
pub diff_gutter_fg: Color,
|
||||
|
||||
// Visual selection / dropdown selection background
|
||||
pub bg_visual: Color,
|
||||
|
||||
// Paste elements (chip + preview overlay)
|
||||
pub paste_bg: Color,
|
||||
pub paste_fg: Color,
|
||||
pub paste_dim: Color,
|
||||
|
||||
// Markdown rendering colors — used by md_style.rs for headings, code
|
||||
// blocks, inline code, links, etc. These default to the corresponding
|
||||
// top-level theme colors but can be overridden per-theme to customise
|
||||
// markdown appearance independently.
|
||||
pub md_heading_h1: Color, // H1 headings
|
||||
pub md_heading_h1_mod: Modifier, // H1 extra effects
|
||||
pub md_heading_h2: Color, // H2 headings, task unchecked, tables
|
||||
pub md_heading_h2_mod: Modifier, // H2 extra effects
|
||||
pub md_heading_h3: Color, // H3 headings, code language tag
|
||||
pub md_heading_h3_mod: Modifier, // H3 extra effects
|
||||
pub md_heading_h4: Color, // H4 headings
|
||||
pub md_heading_h4_mod: Modifier, // H4 extra effects
|
||||
pub md_heading_h5: Color, // H5 headings, link titles
|
||||
pub md_heading_h5_mod: Modifier, // H5 extra effects
|
||||
pub md_heading_h6: Color, // H6 headings
|
||||
pub md_heading_h6_mod: Modifier, // H6 extra effects
|
||||
pub md_code: Color, // Inline code, code block delimiters
|
||||
pub md_task_checked: Color, // Task checked
|
||||
pub md_task_unchecked: Color, // Task unchecked
|
||||
pub md_muted: Color, // Blockquotes, list items, rules, links
|
||||
pub md_code_bg: Color, // Code block background
|
||||
pub md_text: Color, // Default body text (plain paragraphs, strong, emphasis)
|
||||
pub link_fg: Color, // Clickable link text color
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// TokyoNight Storm theme.
|
||||
pub const fn tokyonight() -> Self {
|
||||
Self {
|
||||
bg_base: BG_STORM,
|
||||
bg_light: BG_HIGHLIGHT,
|
||||
bg_dark: BG_HIGHLIGHT,
|
||||
bg_highlight: BG_HIGHLIGHT,
|
||||
bg_hover: rgb(40, 49, 76),
|
||||
bg_terminal: BG,
|
||||
|
||||
accent_user: BLUE,
|
||||
accent_assistant: MAGENTA,
|
||||
accent_thinking: FG_GUTTER,
|
||||
accent_tool: DARK5,
|
||||
accent_system: BLUE,
|
||||
accent_error: RED,
|
||||
accent_success: GREEN,
|
||||
accent_running: MAGENTA,
|
||||
accent_skill: rgb(100, 180, 170), // Muted teal
|
||||
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
|
||||
gray_dim: FG_GUTTER,
|
||||
gray: COMMENT,
|
||||
gray_bright: DARK5,
|
||||
|
||||
command: YELLOW,
|
||||
path: ORANGE,
|
||||
running: CYAN,
|
||||
warning: YELLOW,
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(230, 180, 50), // #E6B432 — golden
|
||||
|
||||
accent_verify: MAGENTA, // #bb9af7 — violet (distinct from plan / feedback)
|
||||
|
||||
accent_feedback: GREEN1, // #73daca — warm teal/green
|
||||
|
||||
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
|
||||
selection_border: rgb(58, 72, 115), // #3A4873 — muted tokyonight blue
|
||||
prompt_border: rgb(60, 75, 120), // #323E64 — dimmer prompt chrome
|
||||
prompt_border_active: rgb(75, 92, 140), // #4B5C8C — brighter when focused
|
||||
hover_border: rgb(55, 58, 80),
|
||||
|
||||
accent_model: TEAL,
|
||||
|
||||
scrollbar_bg: BG_STORM_DARK,
|
||||
scrollbar_fg: BG_HIGHLIGHT,
|
||||
|
||||
diff_delete_bg: rgb(85, 15, 20),
|
||||
diff_delete_fg: RED,
|
||||
diff_insert_bg: rgb(15, 65, 20),
|
||||
diff_insert_fg: GREEN,
|
||||
diff_equal_fg: COMMENT,
|
||||
diff_gutter_fg: COMMENT,
|
||||
|
||||
bg_visual: rgb(40, 52, 87), // #283457 — blue-tinted selection bg
|
||||
|
||||
paste_bg: BG_STORM_DARK,
|
||||
paste_fg: FG_DARK,
|
||||
paste_dim: FG_GUTTER,
|
||||
// paste_bg: BG_HIGHLIGHT,
|
||||
// paste_fg: DARK5,
|
||||
// paste_dim: COMMENT,
|
||||
md_heading_h1: TEAL,
|
||||
md_heading_h1_mod: Modifier::BOLD,
|
||||
md_heading_h2: BLUE,
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: ORANGE,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: RED,
|
||||
md_heading_h4_mod: Modifier::BOLD,
|
||||
md_heading_h5: GREEN,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: MAGENTA,
|
||||
md_heading_h6_mod: Modifier::BOLD,
|
||||
md_code: GREEN1,
|
||||
md_task_checked: CYAN,
|
||||
md_task_unchecked: BLUE,
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: BG_HIGHLIGHT,
|
||||
md_text: FG,
|
||||
link_fg: BLUE, // #7aa2f7
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a style with the given foreground color.
|
||||
pub const fn fg(&self, color: Color) -> Style {
|
||||
Style::new().fg(color)
|
||||
}
|
||||
|
||||
/// Get a style with muted text (gray — medium).
|
||||
///
|
||||
/// When `gray` is [`Color::Reset`] (terminal-native / minimal palette),
|
||||
/// de-emphasize with [`Modifier::DIM`] instead of painting ANSI bright
|
||||
/// black — dim scales the terminal's own default fg, so contrast stays
|
||||
/// polarity-safe. RGB themes keep an explicit gray foreground.
|
||||
pub const fn muted(&self) -> Style {
|
||||
match self.gray {
|
||||
Color::Reset => Style::new().add_modifier(Modifier::DIM),
|
||||
c => Style::new().fg(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Style for OSC 8 hyperlink overlay text.
|
||||
pub fn link_style(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(self.link_fg)
|
||||
.add_modifier(ratatui::style::Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
/// Get a style with dim text (gray_dim — dimmest).
|
||||
///
|
||||
/// Same Reset→DIM rule as [`Self::muted`] for the terminal-native palette.
|
||||
pub const fn dim(&self) -> Style {
|
||||
match self.gray_dim {
|
||||
Color::Reset => Style::new().add_modifier(Modifier::DIM),
|
||||
c => Style::new().fg(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a style for primary text.
|
||||
pub const fn primary(&self) -> Style {
|
||||
Style::new().fg(self.text_primary)
|
||||
}
|
||||
|
||||
/// Get a bold style.
|
||||
pub const fn bold(&self) -> Style {
|
||||
Style::new().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute animated brightness for a traveling wave effect.
|
||||
///
|
||||
/// Creates a wave that travels along the accent line. Each row has a fixed phase
|
||||
/// offset so the wave appears to move smoothly regardless of block height.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `tick`: Frame counter (increments each render tick)
|
||||
/// - `row`: Current row within the block (0 = top)
|
||||
/// - `wave_rows`: Rows per full wave cycle (e.g., 32)
|
||||
/// - `speed`: Wave speed (radians per tick, e.g., 0.15)
|
||||
///
|
||||
/// # Returns
|
||||
/// Brightness value in [0.0, 1.0] for this row at this tick.
|
||||
pub fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f32) -> f32 {
|
||||
use std::f32::consts::PI;
|
||||
|
||||
let rows_per_wave = wave_rows.max(1) as f32;
|
||||
let phase = (row as f32 / rows_per_wave) * 2.0 * PI;
|
||||
|
||||
// Time-based oscillation
|
||||
let t = tick as f32 * speed;
|
||||
|
||||
// sin²(t + phase) gives smooth 0-1 oscillation
|
||||
let sin_val = (t + phase).sin();
|
||||
sin_val * sin_val
|
||||
}
|
||||
|
||||
/// Compute a smooth pulsing brightness for a single element (icon, indicator).
|
||||
///
|
||||
/// Unlike [`wave_brightness`] which creates a spatial wave across rows,
|
||||
/// this is a simple temporal pulse: all elements sharing the same tick
|
||||
/// pulse in unison.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `tick`: Frame counter (increments each render tick, ~30fps)
|
||||
/// - `speed`: Pulse speed (radians per tick). The returned value uses
|
||||
/// `sin²`, which has period π, so the visible bright→dim→bright cycle
|
||||
/// is `π / (speed * fps)`. At 30fps, `speed = 0.08` ≈ 1.3s per cycle;
|
||||
/// for a 2.5s cycle pass `speed ≈ 0.042`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Brightness value in [0.0, 1.0].
|
||||
pub fn pulse_brightness(tick: u64, speed: f32) -> f32 {
|
||||
let t = tick as f32 * speed;
|
||||
let sin_val = t.sin();
|
||||
sin_val * sin_val
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tokyonight_theme() {
|
||||
let theme = Theme::tokyonight();
|
||||
assert!(matches!(theme.bg_base, Color::Rgb(36, 40, 59)));
|
||||
assert!(matches!(theme.accent_user, Color::Rgb(122, 162, 247)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user