use std::collections::BTreeMap;
use std::path::Path;
use gwk_theme::tier::Tier16;
use gwk_theme::{ColorTier, Token};
use ratatui::style::Style;
use crate::theme;
pub const CHROME_THEME_ENV: &str = "GWK_CHROME_THEME";
const MAX_THEME_BYTES: u64 = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ChromeRole {
PaneBorder,
PaneBorderFocused,
PaneTitle,
TabActive,
TabInactive,
WorkspaceLabel,
StatusBar,
}
impl ChromeRole {
pub const ALL: [Self; 7] = [
Self::PaneBorder,
Self::PaneBorderFocused,
Self::PaneTitle,
Self::TabActive,
Self::TabInactive,
Self::WorkspaceLabel,
Self::StatusBar,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::PaneBorder => "pane_border",
Self::PaneBorderFocused => "pane_border_focused",
Self::PaneTitle => "pane_title",
Self::TabActive => "tab_active",
Self::TabInactive => "tab_inactive",
Self::WorkspaceLabel => "workspace_label",
Self::StatusBar => "status_bar",
}
}
pub const fn default_token(self) -> &'static str {
match self {
Self::PaneBorder => "gws_border",
Self::PaneBorderFocused => "gws_focus",
Self::PaneTitle => "gws_fg",
Self::TabActive => "gws_hue_bright",
Self::TabInactive => "gws_muted",
Self::WorkspaceLabel => "gws_hue",
Self::StatusBar => "gws_muted",
}
}
pub fn parse(key: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|role| role.as_str() == key)
}
}
impl std::fmt::Display for ChromeRole {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ChromeError {
#[error("chrome theme names no such role: {key} (one of: {allowed})")]
UnknownRole { key: String, allowed: String },
#[error("chrome role {role} names no such Signal token: {token}")]
UnknownToken { role: ChromeRole, token: String },
#[error(
"chrome role {role} cannot use {token}: it is an elevation step, not a \
terminal foreground at any tier -- the result would be invisible, not \
a different colour"
)]
NotAForeground {
role: ChromeRole,
token: &'static str,
},
#[error("chrome role {role} must be given a token name, got {actual}")]
NotAString {
role: ChromeRole,
actual: &'static str,
},
#[error("chrome theme is not valid TOML: {source}")]
InvalidToml {
#[source]
source: toml::de::Error,
},
#[error("chrome theme at {path} is {actual} bytes, over the {MAX_THEME_BYTES}-byte bound")]
TooLarge { path: String, actual: u64 },
#[error("chrome theme at {path} could not be read: {source}")]
Unreadable {
path: String,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChromeTheme {
bindings: BTreeMap<ChromeRole, &'static str>,
}
impl Default for ChromeTheme {
fn default() -> Self {
Self::signal()
}
}
impl ChromeTheme {
pub fn signal() -> Self {
Self {
bindings: ChromeRole::ALL
.iter()
.map(|role| (*role, role.default_token()))
.collect(),
}
}
pub fn from_env() -> Result<Self, ChromeError> {
match std::env::var_os(CHROME_THEME_ENV) {
Some(path) if !path.is_empty() => Self::load(Path::new(&path)),
_ => Ok(Self::signal()),
}
}
pub fn load(path: &Path) -> Result<Self, ChromeError> {
use std::io::Read as _;
let display = path.display().to_string();
let unreadable = |source| ChromeError::Unreadable {
path: display.clone(),
source,
};
let mut text = String::new();
std::fs::File::open(path)
.map_err(&unreadable)?
.take(MAX_THEME_BYTES + 1)
.read_to_string(&mut text)
.map_err(&unreadable)?;
if text.len() as u64 > MAX_THEME_BYTES {
return Err(ChromeError::TooLarge {
path: display,
actual: text.len() as u64,
});
}
Self::parse_toml(&text)
}
pub fn parse_toml(text: &str) -> Result<Self, ChromeError> {
let table: toml::Table = text
.parse()
.map_err(|source| ChromeError::InvalidToml { source })?;
let mut theme = Self::signal();
for (key, value) in &table {
let role = ChromeRole::parse(key).ok_or_else(|| ChromeError::UnknownRole {
key: key.clone(),
allowed: ChromeRole::ALL
.iter()
.map(|role| role.as_str())
.collect::<Vec<_>>()
.join(", "),
})?;
let name = value.as_str().ok_or(ChromeError::NotAString {
role,
actual: value.type_str(),
})?;
theme.bind(role, name)?;
}
Ok(theme)
}
pub fn bind(&mut self, role: ChromeRole, token: &str) -> Result<(), ChromeError> {
let resolved = gwk_theme::SIGNAL
.iter()
.find(|candidate| candidate.name == token)
.ok_or_else(|| ChromeError::UnknownToken {
role,
token: token.to_owned(),
})?;
if matches!(resolved.tier16, Tier16::NotAColor) {
return Err(ChromeError::NotAForeground {
role,
token: resolved.name,
});
}
self.bindings.insert(role, resolved.name);
Ok(())
}
pub fn token(&self, role: ChromeRole) -> &'static Token {
let name = self
.bindings
.get(&role)
.copied()
.unwrap_or_else(|| role.default_token());
gwk_theme::SIGNAL
.iter()
.find(|token| token.name == name)
.unwrap_or(&gwk_theme::SIGNAL[0])
}
pub fn style(&self, role: ChromeRole, tier: ColorTier) -> Style {
theme::token_style(self.token(role), tier)
}
pub fn is_signal(&self) -> bool {
*self == Self::signal()
}
pub fn bindings(&self) -> impl Iterator<Item = (ChromeRole, &'static Token)> + '_ {
ChromeRole::ALL
.into_iter()
.map(move |role| (role, self.token(role)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chrome_the_role_set_is_pinned_by_name() {
let names: Vec<&str> = ChromeRole::ALL.iter().map(|role| role.as_str()).collect();
assert_eq!(
names,
[
"pane_border",
"pane_border_focused",
"pane_title",
"tab_active",
"tab_inactive",
"workspace_label",
"status_bar",
]
);
for role in ChromeRole::ALL {
assert!(
!role.as_str().contains("content"),
"{role} reads as pane content, which is the session's own"
);
assert_eq!(ChromeRole::parse(role.as_str()), Some(role));
}
assert_eq!(ChromeRole::parse("pane-border"), None, "no nearest match");
}
#[test]
fn chrome_every_default_resolves_and_paints() {
let theme = ChromeTheme::signal();
assert!(theme.is_signal());
for role in ChromeRole::ALL {
let token = theme.token(role);
assert_eq!(token.name, role.default_token());
assert!(
!matches!(token.tier16, Tier16::NotAColor),
"{role} defaults to an elevation step and would be invisible"
);
assert!(
theme.style(role, ColorTier::Truecolor).fg.is_some(),
"{role} paints nothing at truecolor"
);
}
}
#[test]
fn chrome_a_file_names_only_what_it_changes() {
let theme = ChromeTheme::parse_toml("tab_active = \"gws_ok\"\n").expect("theme");
assert_eq!(theme.token(ChromeRole::TabActive).name, "gws_ok");
assert_eq!(
theme.token(ChromeRole::PaneBorder).name,
ChromeRole::PaneBorder.default_token(),
"an unnamed role keeps its default"
);
assert!(!theme.is_signal());
assert!(ChromeTheme::parse_toml("").expect("empty").is_signal());
}
#[test]
fn chrome_a_remap_cannot_introduce_a_colour_or_an_invisible_one() {
for (text, expected) in [
("pane_border = \"#FF00FF\"", "no such Signal token"),
("pane_border = \"hue\"", "no such Signal token"),
("pane_border = \"gws_bg\"", "elevation step"),
("pane_border = \"gws_surface_2\"", "elevation step"),
("pane_edge = \"gws_ok\"", "no such role"),
("pane_border = 7", "must be given a token name"),
("pane_border = ", "not valid TOML"),
] {
let error = ChromeTheme::parse_toml(text)
.expect_err(&format!("{text:?} was accepted"))
.to_string();
assert!(
error.contains(expected),
"{text:?} refused with {error:?}, wanted {expected:?}"
);
}
for token in gwk_theme::SIGNAL
.iter()
.filter(|token| !matches!(token.tier16, Tier16::NotAColor))
{
for role in ChromeRole::ALL {
ChromeTheme::signal()
.bind(role, token.name)
.unwrap_or_else(|why| panic!("{role} could not take {}: {why}", token.name));
}
}
}
#[test]
fn chrome_a_remapped_role_degrades_like_the_token_it_now_names() {
let mut theme = ChromeTheme::signal();
theme.bind(ChromeRole::PaneTitle, "gws_ok").expect("bind");
for tier in ColorTier::ALL {
assert_eq!(
theme.style(ChromeRole::PaneTitle, *tier),
theme::token_style(
gwk_theme::SIGNAL
.iter()
.find(|token| token.name == "gws_ok")
.expect("gws_ok"),
*tier
),
"the remap diverged from the token at {}",
tier.as_str()
);
}
}
#[test]
fn chrome_an_absent_env_var_is_the_signal_default_and_a_bad_path_is_an_error() {
let missing = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("goldens")
.join("no-such-chrome-theme.toml");
let error = ChromeTheme::load(&missing).expect_err("a named path must exist");
assert!(
error.to_string().contains("could not be read"),
"a typo'd path is an error, never a silent revert: {error}"
);
}
#[test]
fn chrome_the_size_bound_reads_bytes_rather_than_trusting_a_stat() {
let path = std::env::temp_dir().join(format!("gwk-chrome-big-{}", std::process::id()));
std::fs::write(&path, "#".repeat(MAX_THEME_BYTES as usize + 1)).expect("write");
let error = ChromeTheme::load(&path).expect_err("oversized theme");
let _ = std::fs::remove_file(&path);
assert!(
matches!(error, ChromeError::TooLarge { .. }),
"an oversized theme is refused by size, not by parse: {error}"
);
}
#[test]
fn chrome_orchestration_never_reaches_this_module() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut scanned = 0;
let mut positive_control = false;
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("read src") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name == "workspace") {
continue;
}
stack.push(path);
continue;
}
if path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read module");
let mentions = text.contains("ChromeTheme") || text.contains("chrome::");
if path.file_name().is_some_and(|name| name == "chrome.rs") {
positive_control = mentions;
continue;
}
let declares_only = path.file_name().is_some_and(|name| name == "lib.rs");
assert!(
!mentions || declares_only,
"{} consults the chrome theme -- orchestration surfaces are \
Signal and that is not negotiable",
path.display()
);
scanned += 1;
}
}
assert!(scanned >= 10, "the scan found only {scanned} modules");
assert!(
positive_control,
"the scan cannot see this file's own mentions, so it proves nothing"
);
}
}