use crate::config::{AppearanceSettings, ColorChoice, McPaths, Settings};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt};
use cap_std::{
ambient_authority,
fs::{Dir as CapDir, DirEntry as CapDirEntry, File as CapFile, OpenOptions as CapOpenOptions},
};
use opaline::{
Gradient, OpalineColor, OpalineStyle, Theme, ThemeFile,
builtins::{builtin_names, load_by_name},
names::{gradients, styles, tokens},
};
use std::{
collections::{BTreeMap, HashSet},
env,
ffi::OsStr,
io::{ErrorKind, Read},
path::Path,
sync::Arc,
};
pub(crate) const DEFAULT_THEME_ID: &str = "matrix-green";
const FAST_GRADIENT_NAME: &str = "magi.gradient.fast";
const MAX_CUSTOM_THEME_BYTES: usize = 512 * 1024;
const MAX_CUSTOM_THEME_FILES: usize = 128;
const MAX_CUSTOM_THEME_DIRECTORY_ENTRIES: usize = 256;
const MAX_CUSTOM_THEME_COLORS: usize = 4096;
const MAX_CUSTOM_THEME_STYLES: usize = 1024;
const MAX_CUSTOM_THEME_GRADIENTS: usize = 256;
const MAX_CUSTOM_THEME_GRADIENT_STOPS: usize = 4096;
const MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH: usize = 64;
const MAX_THEME_DIAGNOSTICS: usize = 24;
const MATRIX_GREEN_TOML: &str = include_str!("appearance/matrix-green.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ColorDepth {
TrueColor,
Ansi256,
Ansi16,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ColorPolicy {
pub(crate) depth: ColorDepth,
pub(crate) color_enabled: bool,
pub(crate) unicode_enabled: bool,
pub(crate) rich_output: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct RuntimeAppearance {
pub(crate) theme: Arc<ResolvedTheme>,
pub(crate) policy: ColorPolicy,
pub(crate) reduced_motion: bool,
}
impl Default for RuntimeAppearance {
fn default() -> Self {
let theme = load_matrix_theme();
Self {
theme: Arc::new(ResolvedTheme::from_theme(DEFAULT_THEME_ID, &theme)),
policy: ColorPolicy {
depth: ColorDepth::TrueColor,
color_enabled: true,
unicode_enabled: true,
rich_output: true,
},
reduced_motion: false,
}
}
}
impl RuntimeAppearance {
pub(crate) fn with_resolved_theme(&self, theme: ResolvedTheme) -> Self {
Self {
theme: Arc::new(theme),
policy: self.policy,
reduced_motion: self.reduced_motion,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct AppearanceResolution {
pub(crate) appearance: RuntimeAppearance,
pub(crate) diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ThemeCatalog {
entries: BTreeMap<String, CatalogTheme>,
diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
struct CatalogTheme {
theme: Theme,
display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ThemeCatalogEntry {
pub(crate) id: String,
pub(crate) display_name: String,
}
impl ThemeCatalog {
pub(crate) fn load(mc_home: &Path) -> Self {
let mut catalog = Self {
entries: BTreeMap::new(),
diagnostics: Vec::new(),
};
for &(id, _) in builtin_names() {
let theme = load_builtin_theme(id).expect("registered Opaline builtin must load");
catalog.insert_builtin(id, theme);
}
catalog.insert_builtin(DEFAULT_THEME_ID, load_matrix_theme());
catalog.scan_custom(mc_home);
catalog
}
pub(crate) fn resolve(&self, id: &str) -> Option<ResolvedTheme> {
self.entries
.get(id)
.map(|entry| ResolvedTheme::from_theme(id, &entry.theme))
}
pub(crate) fn entries(&self) -> Vec<ThemeCatalogEntry> {
self.entries
.iter()
.map(|(id, entry)| ThemeCatalogEntry {
id: id.clone(),
display_name: entry.display_name.clone(),
})
.collect()
}
pub(crate) fn resolve_runtime_appearance(
&self,
id: &str,
base: &RuntimeAppearance,
) -> Option<RuntimeAppearance> {
self.resolve(id)
.map(|theme| base.with_resolved_theme(theme))
}
pub(crate) fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
fn insert_builtin(&mut self, id: &str, theme: Theme) {
self.entries.insert(
id.to_string(),
CatalogTheme {
display_name: sanitize_name(&theme.meta.name),
theme,
},
);
}
fn scan_custom(&mut self, mc_home: &Path) {
let themes_dir = match open_themes_directory(mc_home) {
Ok(Some(directory)) => directory,
Ok(None) => return,
Err(reason) => {
self.warn(&format!("custom themes directory skipped: {reason}"));
return;
}
};
let Ok(mut directory) = themes_dir.entries() else {
self.warn("custom themes directory could not be read");
return;
};
let mut entries = Vec::with_capacity(MAX_CUSTOM_THEME_DIRECTORY_ENTRIES);
for entry_index in 0..=MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
let Some(entry_result) = directory.next() else {
break;
};
if entry_index == MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
self.warn(
"custom theme directory entry limit reached; remaining entries were skipped",
);
break;
}
match entry_result {
Ok(entry) => entries.push(entry),
Err(_) => self.warn("a custom theme directory entry could not be inspected"),
}
}
entries.sort_by_key(|entry| entry.file_name());
let mut accepted_count = 0;
for entry in entries {
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
self.warn("a custom theme had a non-UTF-8 filename");
continue;
};
if !valid_custom_filename(file_name) {
continue;
}
if accepted_count >= MAX_CUSTOM_THEME_FILES {
self.warn("custom theme file limit reached; remaining files were skipped");
break;
}
let id = format!("custom:{file_name}");
let theme = match read_custom_theme_from_entry(&entry, Some(Path::new(file_name))) {
Ok(theme) => theme,
Err(error) => {
self.warn_for_file(file_name, error.reason());
continue;
}
};
if self.entries.contains_key(&id) {
self.warn_for_file(file_name, "theme ID conflicts with an existing theme");
continue;
}
accepted_count += 1;
self.entries.insert(
id,
CatalogTheme {
display_name: sanitize_name(&theme.meta.name),
theme,
},
);
}
}
fn warn_for_file(&mut self, file_name: &str, reason: &str) {
self.warn(&format!(
"custom theme '{}' skipped: {reason}",
sanitize_name(file_name)
));
}
fn warn(&mut self, message: &str) {
if self.diagnostics.len() < MAX_THEME_DIAGNOSTICS {
self.diagnostics.push(sanitize_diagnostic(message));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThemeLoadFailure {
Missing,
InvalidId,
Link,
NonRegular,
TooLarge,
Unreadable,
InvalidUtf8,
Malformed,
SafetyLimit,
Incomplete,
DirectoryUnsafe,
}
impl ThemeLoadFailure {
fn reason(self) -> &'static str {
match self {
Self::Missing => "theme file is missing",
Self::InvalidId => "theme ID is not a direct .toml filename",
Self::Link => "links are not allowed",
Self::NonRegular => "only regular files are allowed",
Self::TooLarge => "file is too large",
Self::Unreadable => "file could not be read",
Self::InvalidUtf8 => "file contents are not UTF-8",
Self::Malformed => "theme is malformed or uses unsupported fields",
Self::SafetyLimit => "theme exceeds safety limits",
Self::Incomplete => "theme is missing required standard entries",
Self::DirectoryUnsafe => "themes directory is not a safe directory",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid theme '{id}': {reason}")]
pub(crate) struct InvalidExplicitTheme {
id: String,
reason: &'static str,
}
fn load_builtin_theme(id: &str) -> Option<Theme> {
let theme = load_by_name(id)?;
assert!(
has_standard_theme_contract(&theme),
"Opaline builtin {id} is missing the standard theme contract"
);
Some(theme)
}
fn load_matrix_theme() -> Theme {
parse_theme_source(MATRIX_GREEN_TOML, None)
.expect("embedded Matrix Green theme must remain valid")
}
fn parse_theme_source(source: &str, path: Option<&Path>) -> Result<Theme, ThemeLoadFailure> {
if source.len() > MAX_CUSTOM_THEME_BYTES {
return Err(ThemeLoadFailure::TooLarge);
}
let theme_file: ThemeFile = toml::from_str(source).map_err(|_| ThemeLoadFailure::Malformed)?;
validate_theme_file_bounds(&theme_file)?;
let theme = opaline::load_from_str(source, path).map_err(|_| ThemeLoadFailure::Malformed)?;
if !has_standard_theme_contract(&theme) {
return Err(ThemeLoadFailure::Incomplete);
}
Ok(theme)
}
fn has_standard_theme_contract(theme: &Theme) -> bool {
let token_names = theme.token_names();
STANDARD_TOKENS
.iter()
.all(|name| token_names.contains(name))
&& STANDARD_STYLES
.iter()
.all(|name| theme.try_style(name).is_some())
&& STANDARD_GRADIENTS
.iter()
.all(|name| theme.get_gradient(name).is_some())
}
const STANDARD_TOKENS: [&str; 26] = [
tokens::TEXT_PRIMARY,
tokens::TEXT_SECONDARY,
tokens::TEXT_MUTED,
tokens::TEXT_DIM,
tokens::BG_BASE,
tokens::BG_PANEL,
tokens::BG_CODE,
tokens::BG_HIGHLIGHT,
tokens::BG_SELECTION,
tokens::ACCENT_PRIMARY,
tokens::ACCENT_SECONDARY,
tokens::ACCENT_TERTIARY,
tokens::ACCENT_DEEP,
tokens::SUCCESS,
tokens::ERROR,
tokens::WARNING,
tokens::INFO,
tokens::BORDER_FOCUSED,
tokens::BORDER_UNFOCUSED,
tokens::CODE_KEYWORD,
tokens::CODE_FUNCTION,
tokens::CODE_STRING,
tokens::CODE_NUMBER,
tokens::CODE_COMMENT,
tokens::CODE_TYPE,
tokens::CODE_LINE_NUMBER,
];
const STANDARD_STYLES: [&str; 13] = [
styles::KEYWORD,
styles::LINE_NUMBER,
styles::SELECTED,
styles::ACTIVE_SELECTED,
styles::FOCUSED_BORDER,
styles::UNFOCUSED_BORDER,
styles::SUCCESS_STYLE,
styles::ERROR_STYLE,
styles::WARNING_STYLE,
styles::INFO_STYLE,
styles::DIMMED,
styles::MUTED,
styles::INLINE_CODE,
];
const STANDARD_GRADIENTS: [&str; 5] = [
gradients::PRIMARY,
gradients::WARM,
gradients::SUCCESS_GRADIENT,
gradients::ERROR_GRADIENT,
gradients::AURORA,
];
fn validate_theme_file_bounds(theme_file: &ThemeFile) -> Result<(), ThemeLoadFailure> {
if theme_file
.palette
.len()
.saturating_add(theme_file.tokens.len())
> MAX_CUSTOM_THEME_COLORS
|| theme_file.styles.len() > MAX_CUSTOM_THEME_STYLES
|| theme_file.gradients.len() > MAX_CUSTOM_THEME_GRADIENTS
{
return Err(ThemeLoadFailure::SafetyLimit);
}
let stop_count = theme_file
.gradients
.values()
.fold(0usize, |count, stops| count.saturating_add(stops.len()));
if stop_count > MAX_CUSTOM_THEME_GRADIENT_STOPS {
return Err(ThemeLoadFailure::SafetyLimit);
}
if !token_reference_depth_is_bounded(theme_file) {
return Err(ThemeLoadFailure::SafetyLimit);
}
Ok(())
}
fn token_reference_depth_is_bounded(theme_file: &ThemeFile) -> bool {
for name in theme_file.tokens.keys() {
let mut current = name.as_str();
let mut seen = HashSet::new();
let mut depth = 0;
loop {
if depth >= MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH || !seen.insert(current) {
return false;
}
depth += 1;
let Some(reference) = theme_file.tokens.get(current) else {
break;
};
if reference.starts_with('#') || theme_file.palette.contains_key(reference) {
break;
}
if !theme_file.tokens.contains_key(reference) {
break;
}
current = reference;
}
}
true
}
fn open_themes_directory(mc_home: &Path) -> Result<Option<CapDir>, &'static str> {
let home = match CapDir::open_ambient_dir(mc_home, ambient_authority()) {
Ok(home) => home,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(_) => return Err("MC_HOME could not be inspected"),
};
match home.open_dir_nofollow("themes") {
Ok(themes) => Ok(Some(themes)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(_) => Err("themes directory is not a safe directory"),
}
}
fn read_custom_theme_from_entry(
entry: &CapDirEntry,
path: Option<&Path>,
) -> Result<Theme, ThemeLoadFailure> {
let file = open_theme_file_entry(entry)?;
read_custom_theme_file(file, path)
}
fn read_custom_theme(directory: &CapDir, file_name: &str) -> Result<Theme, ThemeLoadFailure> {
if !valid_custom_filename(file_name) {
return Err(ThemeLoadFailure::InvalidId);
}
let entry = find_custom_theme_entry(directory, file_name)?;
read_custom_theme_from_entry(&entry, Some(Path::new(file_name)))
}
fn find_custom_theme_entry(
directory: &CapDir,
file_name: &str,
) -> Result<CapDirEntry, ThemeLoadFailure> {
let mut entries = directory
.entries()
.map_err(|_| ThemeLoadFailure::Unreadable)?;
for _ in 0..MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
let Some(entry_result) = entries.next() else {
return Err(ThemeLoadFailure::Missing);
};
let Ok(entry) = entry_result else {
continue;
};
if entry.file_name().to_str() == Some(file_name) {
return Ok(entry);
}
}
Err(ThemeLoadFailure::Missing)
}
fn read_custom_theme_file(file: CapFile, path: Option<&Path>) -> Result<Theme, ThemeLoadFailure> {
let metadata = file.metadata().map_err(|_| ThemeLoadFailure::Unreadable)?;
if metadata.len() > MAX_CUSTOM_THEME_BYTES as u64 {
return Err(ThemeLoadFailure::TooLarge);
}
let mut bytes = Vec::with_capacity(metadata.len().min(MAX_CUSTOM_THEME_BYTES as u64) as usize);
file.take((MAX_CUSTOM_THEME_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|_| ThemeLoadFailure::Unreadable)?;
if bytes.len() > MAX_CUSTOM_THEME_BYTES {
return Err(ThemeLoadFailure::TooLarge);
}
let source = std::str::from_utf8(&bytes).map_err(|_| ThemeLoadFailure::InvalidUtf8)?;
parse_theme_source(source, path)
}
fn theme_file_open_options() -> CapOpenOptions {
let mut options = CapOpenOptions::new();
options.read(true).follow(FollowSymlinks::No).nonblock(true);
options
}
fn open_theme_file_entry(entry: &CapDirEntry) -> Result<CapFile, ThemeLoadFailure> {
let file_type = entry
.file_type()
.map_err(|_| ThemeLoadFailure::Unreadable)?;
if file_type.is_symlink() {
return Err(ThemeLoadFailure::Link);
}
if !file_type.is_file() {
return Err(ThemeLoadFailure::NonRegular);
}
let file = entry
.open_with(&theme_file_open_options())
.map_err(|error| {
if error.kind() == ErrorKind::NotFound {
ThemeLoadFailure::Missing
} else {
ThemeLoadFailure::Unreadable
}
})?;
validate_opened_theme_file(file)
}
fn validate_opened_theme_file(file: CapFile) -> Result<CapFile, ThemeLoadFailure> {
let metadata = file.metadata().map_err(|_| ThemeLoadFailure::Unreadable)?;
if metadata.file_type().is_symlink() {
return Err(ThemeLoadFailure::Link);
}
if !metadata.is_file() {
return Err(ThemeLoadFailure::NonRegular);
}
Ok(file)
}
fn load_selected_theme(mc_home: &Path, id: &str) -> Result<Option<Theme>, ThemeLoadFailure> {
if id == DEFAULT_THEME_ID {
return Ok(Some(load_matrix_theme()));
}
if builtin_names()
.iter()
.any(|(builtin_id, _)| *builtin_id == id)
{
return load_builtin_theme(id)
.map(Some)
.ok_or(ThemeLoadFailure::Malformed);
}
let Some(file_name) = id.strip_prefix("custom:") else {
return Ok(None);
};
if !valid_custom_filename(file_name) {
return Err(ThemeLoadFailure::InvalidId);
}
let themes_dir = open_themes_directory(mc_home)
.map_err(|_| ThemeLoadFailure::DirectoryUnsafe)?
.ok_or(ThemeLoadFailure::Missing)?;
read_custom_theme(&themes_dir, file_name).map(Some)
}
fn valid_custom_filename(file_name: &str) -> bool {
!file_name.is_empty()
&& file_name.ends_with(".toml")
&& !file_name.contains('\0')
&& Path::new(file_name)
.file_name()
.is_some_and(|component| component == OsStr::new(file_name))
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedTheme {
pub(crate) id: String,
pub(crate) roles: ThemeRoles,
}
impl ResolvedTheme {
fn from_theme(id: &str, theme: &Theme) -> Self {
Self {
id: id.to_string(),
roles: ThemeRoles::from_theme(theme),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ThemeRoles {
pub(crate) surface_background: OpalineColor,
pub(crate) surface_panel: OpalineColor,
pub(crate) surface_panel_alt: OpalineColor,
pub(crate) surface_overlay: OpalineColor,
pub(crate) surface_selection: OpalineColor,
pub(crate) surface_code: OpalineColor,
pub(crate) surface_success: OpalineColor,
pub(crate) surface_pending: OpalineColor,
pub(crate) surface_error: OpalineColor,
pub(crate) text_primary: OpalineColor,
pub(crate) text_muted: OpalineColor,
pub(crate) text_dim: OpalineColor,
pub(crate) text_accent: OpalineColor,
pub(crate) text_title: OpalineColor,
pub(crate) text_prompt: OpalineColor,
pub(crate) text_status: OpalineColor,
pub(crate) text_help: OpalineColor,
pub(crate) text_metadata: OpalineColor,
pub(crate) text_command: OpalineColor,
pub(crate) text_command_active: OpalineColor,
pub(crate) border_default: OpalineColor,
pub(crate) border_active: OpalineColor,
pub(crate) border_alternate: OpalineColor,
pub(crate) border_command: OpalineColor,
pub(crate) border_command_active: OpalineColor,
pub(crate) border_error: OpalineColor,
pub(crate) status_queued: OpalineColor,
pub(crate) status_running: OpalineColor,
pub(crate) status_writing: OpalineColor,
pub(crate) status_success: OpalineColor,
pub(crate) status_failed: OpalineColor,
pub(crate) status_canceled: OpalineColor,
pub(crate) status_tool: OpalineColor,
pub(crate) status_subagent: OpalineColor,
pub(crate) status_assistant: OpalineColor,
pub(crate) status_diagnostic: OpalineColor,
pub(crate) transcript_user: OpalineColor,
pub(crate) transcript_assistant: OpalineColor,
pub(crate) transcript_thinking: OpalineColor,
pub(crate) transcript_tool: OpalineColor,
pub(crate) transcript_session: OpalineColor,
pub(crate) transcript_diagnostic: OpalineColor,
pub(crate) transcript_heading: OpalineColor,
pub(crate) transcript_section: OpalineColor,
pub(crate) transcript_error_label: OpalineColor,
pub(crate) transcript_error_body: OpalineColor,
pub(crate) diff_inserted: OpalineColor,
pub(crate) diff_removed: OpalineColor,
pub(crate) diff_changed: OpalineColor,
pub(crate) diff_context: OpalineColor,
pub(crate) diff_hunk_header: OpalineColor,
pub(crate) diff_file_header: OpalineColor,
pub(crate) diff_metadata: OpalineColor,
pub(crate) syntax_plain: OpalineColor,
pub(crate) syntax_keyword: OpalineColor,
pub(crate) syntax_string: OpalineColor,
pub(crate) syntax_comment: OpalineColor,
pub(crate) syntax_number: OpalineColor,
pub(crate) syntax_function: OpalineColor,
pub(crate) syntax_type: OpalineColor,
pub(crate) syntax_operator: OpalineColor,
pub(crate) syntax_punctuation: OpalineColor,
pub(crate) syntax_code_fence: OpalineColor,
pub(crate) syntax_language_label: OpalineColor,
pub(crate) syntax_gutter: OpalineColor,
pub(crate) syntax_fallback: OpalineColor,
pub(crate) heading_style: OpalineStyle,
pub(crate) user_heading_style: OpalineStyle,
pub(crate) assistant_heading_style: OpalineStyle,
pub(crate) thinking_heading_style: OpalineStyle,
pub(crate) tool_heading_style: OpalineStyle,
pub(crate) standard_styles: ThemeStandardStyles,
pub(crate) gradients: ThemeGradients,
}
#[derive(Debug, Clone)]
pub(crate) struct ThemeGradients {
pub(crate) fast: Gradient,
}
#[derive(Debug, Clone)]
pub(crate) struct ThemeStandardStyles {
pub(crate) keyword: OpalineStyle,
pub(crate) line_number: OpalineStyle,
pub(crate) selected: OpalineStyle,
pub(crate) active_selected: OpalineStyle,
pub(crate) focused_border: OpalineStyle,
pub(crate) unfocused_border: OpalineStyle,
pub(crate) success: OpalineStyle,
pub(crate) error: OpalineStyle,
pub(crate) warning: OpalineStyle,
pub(crate) info: OpalineStyle,
pub(crate) dimmed: OpalineStyle,
pub(crate) muted: OpalineStyle,
pub(crate) inline_code: OpalineStyle,
}
impl ThemeRoles {
fn from_theme(theme: &Theme) -> Self {
let text_primary = role_color(theme, "magi.text.primary", &[tokens::TEXT_PRIMARY]);
let text_muted = role_color(theme, "magi.text.muted", &[tokens::TEXT_MUTED]);
let text_dim = role_color(theme, "magi.text.dim", &[tokens::TEXT_DIM]);
let text_accent = role_color(theme, "magi.text.accent", &[tokens::ACCENT_PRIMARY]);
let text_title = role_color(
theme,
"magi.text.title",
&["magi.text.accent", tokens::ACCENT_PRIMARY],
);
let text_prompt = role_color(theme, "magi.text.prompt", &[tokens::TEXT_PRIMARY]);
let text_status = role_color(theme, "magi.text.status", &[tokens::TEXT_SECONDARY]);
let text_help = role_color(theme, "magi.text.help", &[tokens::TEXT_SECONDARY]);
let text_metadata = role_color(theme, "magi.text.metadata", &[tokens::TEXT_MUTED]);
let text_command = role_color(theme, "magi.text.command", &[tokens::ACCENT_SECONDARY]);
let text_command_active =
role_color(theme, "magi.text.command_active", &[tokens::ACCENT_PRIMARY]);
let surface_background = role_color(theme, "magi.surface.background", &[tokens::BG_BASE]);
let surface_panel = role_color(
theme,
"magi.surface.panel",
&[tokens::BG_PANEL, tokens::BG_BASE],
);
let surface_panel_alt = role_color(
theme,
"magi.surface.panel_alt",
&[tokens::BG_HIGHLIGHT, tokens::BG_PANEL],
);
let surface_overlay = role_color(
theme,
"magi.surface.overlay",
&["bg.elevated", tokens::BG_HIGHLIGHT, tokens::BG_PANEL],
);
let surface_selection = role_color(
theme,
"magi.surface.selection",
&[tokens::BG_SELECTION, tokens::BG_HIGHLIGHT],
);
let surface_code = role_color(
theme,
"magi.surface.code",
&[tokens::BG_CODE, tokens::BG_BASE],
);
let surface_success = role_color(
theme,
"magi.surface.success",
&[tokens::BG_HIGHLIGHT, tokens::BG_PANEL],
);
let surface_pending = role_color(
theme,
"magi.surface.pending",
&[tokens::BG_HIGHLIGHT, tokens::BG_PANEL],
);
let surface_error = role_color(
theme,
"magi.surface.error",
&[tokens::BG_HIGHLIGHT, tokens::BG_PANEL],
);
let border_default = role_color(theme, "magi.border.default", &[tokens::BORDER_UNFOCUSED]);
let border_active = role_color(theme, "magi.border.active", &[tokens::BORDER_FOCUSED]);
let border_alternate =
role_color(theme, "magi.border.alternate", &[tokens::ACCENT_SECONDARY]);
let border_command = role_color(theme, "magi.border.command", &[tokens::BORDER_UNFOCUSED]);
let border_command_active = role_color(
theme,
"magi.border.command_active",
&[tokens::BORDER_FOCUSED],
);
let border_error = role_color(theme, "magi.border.error", &[tokens::ERROR]);
let status_queued = role_color(theme, "magi.status.queued", &[tokens::TEXT_MUTED]);
let status_running = role_color(theme, "magi.status.running", &[tokens::WARNING]);
let status_writing = role_color(theme, "magi.status.writing", &[tokens::WARNING]);
let status_success = role_color(theme, "magi.status.success", &[tokens::SUCCESS]);
let status_failed = role_color(theme, "magi.status.failed", &[tokens::ERROR]);
let status_canceled = role_color(theme, "magi.status.canceled", &[tokens::TEXT_MUTED]);
let status_tool = role_color(theme, "magi.status.tool", &[tokens::ACCENT_SECONDARY]);
let status_subagent = role_color(theme, "magi.status.subagent", &[tokens::ACCENT_TERTIARY]);
let status_assistant =
role_color(theme, "magi.status.assistant", &[tokens::ACCENT_PRIMARY]);
let status_diagnostic = role_color(theme, "magi.status.diagnostic", &[tokens::INFO]);
let transcript_user =
role_color(theme, "magi.transcript.user", &[tokens::ACCENT_SECONDARY]);
let transcript_assistant =
role_color(theme, "magi.transcript.assistant", &[tokens::TEXT_PRIMARY]);
let transcript_thinking = role_color(
theme,
"magi.transcript.thinking",
&[tokens::ACCENT_TERTIARY],
);
let transcript_tool =
role_color(theme, "magi.transcript.tool", &[tokens::ACCENT_SECONDARY]);
let transcript_session =
role_color(theme, "magi.transcript.session", &[tokens::TEXT_MUTED]);
let transcript_diagnostic =
role_color(theme, "magi.transcript.diagnostic", &[tokens::INFO]);
let transcript_heading =
role_color(theme, "magi.transcript.heading", &[tokens::ACCENT_PRIMARY]);
let transcript_section =
role_color(theme, "magi.transcript.section", &[tokens::TEXT_PRIMARY]);
let transcript_error_label =
role_color(theme, "magi.transcript.error_label", &[tokens::ERROR]);
let transcript_error_body =
role_color(theme, "magi.transcript.error_body", &[tokens::TEXT_PRIMARY]);
let diff_inserted = role_color(theme, "magi.diff.inserted", &[tokens::SUCCESS]);
let diff_removed = role_color(theme, "magi.diff.removed", &[tokens::ERROR]);
let diff_changed = role_color(theme, "magi.diff.changed", &[tokens::WARNING]);
let diff_context = role_color(theme, "magi.diff.context", &[tokens::TEXT_PRIMARY]);
let diff_hunk_header =
role_color(theme, "magi.diff.hunk_header", &[tokens::ACCENT_SECONDARY]);
let diff_file_header =
role_color(theme, "magi.diff.file_header", &[tokens::ACCENT_PRIMARY]);
let diff_metadata = role_color(theme, "magi.diff.metadata", &[tokens::TEXT_MUTED]);
let syntax_plain = role_color(theme, "magi.syntax.plain", &[tokens::TEXT_PRIMARY]);
let syntax_keyword = role_color(theme, "magi.syntax.keyword", &[tokens::CODE_KEYWORD]);
let syntax_string = role_color(theme, "magi.syntax.string", &[tokens::CODE_STRING]);
let syntax_comment = role_color(theme, "magi.syntax.comment", &[tokens::CODE_COMMENT]);
let syntax_number = role_color(theme, "magi.syntax.number", &[tokens::CODE_NUMBER]);
let syntax_function = role_color(theme, "magi.syntax.function", &[tokens::CODE_FUNCTION]);
let syntax_type = role_color(theme, "magi.syntax.type", &[tokens::CODE_TYPE]);
let syntax_operator =
role_color(theme, "magi.syntax.operator", &[tokens::ACCENT_SECONDARY]);
let syntax_punctuation =
role_color(theme, "magi.syntax.punctuation", &[tokens::TEXT_SECONDARY]);
let syntax_code_fence =
role_color(theme, "magi.syntax.code_fence", &[tokens::ACCENT_PRIMARY]);
let syntax_language_label = role_color(
theme,
"magi.syntax.language_label",
&[tokens::ACCENT_SECONDARY],
);
let syntax_gutter = role_color(theme, "magi.syntax.gutter", &[tokens::CODE_LINE_NUMBER]);
let syntax_fallback = role_color(theme, "magi.syntax.fallback", &[tokens::TEXT_SECONDARY]);
let default_heading_style = heading_style(theme, styles::INFO_STYLE, transcript_heading);
let user_heading_style = heading_style(theme, styles::INFO_STYLE, transcript_user);
let assistant_heading_style =
heading_style(theme, styles::INFO_STYLE, transcript_assistant);
let thinking_heading_style = heading_style(theme, styles::INFO_STYLE, transcript_thinking);
let tool_heading_style = heading_style(theme, styles::INFO_STYLE, transcript_tool);
let standard_styles = ThemeStandardStyles {
keyword: standard_style(theme, styles::KEYWORD, syntax_keyword),
line_number: standard_style(theme, styles::LINE_NUMBER, syntax_gutter),
selected: standard_style(theme, styles::SELECTED, text_primary),
active_selected: standard_style(theme, styles::ACTIVE_SELECTED, text_primary),
focused_border: standard_style(theme, styles::FOCUSED_BORDER, border_active),
unfocused_border: standard_style(theme, styles::UNFOCUSED_BORDER, border_default),
success: standard_style(theme, styles::SUCCESS_STYLE, status_success),
error: standard_style(theme, styles::ERROR_STYLE, status_failed),
warning: standard_style(theme, styles::WARNING_STYLE, status_running),
info: standard_style(theme, styles::INFO_STYLE, status_diagnostic),
dimmed: standard_style(theme, styles::DIMMED, text_dim),
muted: standard_style(theme, styles::MUTED, text_muted),
inline_code: standard_style(theme, styles::INLINE_CODE, syntax_plain),
};
let fast = fast_gradient(theme, Gradient::new(vec![text_accent, text_primary]));
Self {
surface_background,
surface_panel,
surface_panel_alt,
surface_overlay,
surface_selection,
surface_code,
surface_success,
surface_pending,
surface_error,
text_primary,
text_muted,
text_dim,
text_accent,
text_title,
text_prompt,
text_status,
text_help,
text_metadata,
text_command,
text_command_active,
border_default,
border_active,
border_alternate,
border_command,
border_command_active,
border_error,
status_queued,
status_running,
status_writing,
status_success,
status_failed,
status_canceled,
status_tool,
status_subagent,
status_assistant,
status_diagnostic,
transcript_user,
transcript_assistant,
transcript_thinking,
transcript_tool,
transcript_session,
transcript_diagnostic,
transcript_heading,
transcript_section,
transcript_error_label,
transcript_error_body,
diff_inserted,
diff_removed,
diff_changed,
diff_context,
diff_hunk_header,
diff_file_header,
diff_metadata,
syntax_plain,
syntax_keyword,
syntax_string,
syntax_comment,
syntax_number,
syntax_function,
syntax_type,
syntax_operator,
syntax_punctuation,
syntax_code_fence,
syntax_language_label,
syntax_gutter,
syntax_fallback,
heading_style: default_heading_style,
user_heading_style,
assistant_heading_style,
thinking_heading_style,
tool_heading_style,
gradients: ThemeGradients { fast },
standard_styles,
}
}
}
fn role_color(theme: &Theme, extension: &str, fallbacks: &[&str]) -> OpalineColor {
token_color(theme, extension)
.or_else(|| fallbacks.iter().find_map(|token| token_color(theme, token)))
.unwrap_or(OpalineColor::FALLBACK)
}
fn token_color(theme: &Theme, name: &str) -> Option<OpalineColor> {
theme
.token_names()
.contains(&name)
.then(|| theme.try_color(name))
.flatten()
}
fn standard_style(theme: &Theme, standard_style: &str, fallback: OpalineColor) -> OpalineStyle {
let mut style = theme
.try_style(standard_style)
.cloned()
.unwrap_or_else(|| OpalineStyle::fg(fallback));
if style.fg.is_none() {
style.fg = Some(fallback);
}
style
}
fn heading_style(theme: &Theme, standard_style: &str, fallback: OpalineColor) -> OpalineStyle {
let mut style = theme
.try_style(standard_style)
.cloned()
.unwrap_or_else(|| OpalineStyle::fg(fallback));
if style.fg.is_none() {
style.fg = Some(fallback);
}
style.bold = true;
style
}
fn fast_gradient(theme: &Theme, fallback: Gradient) -> Gradient {
theme
.get_gradient(FAST_GRADIENT_NAME)
.cloned()
.unwrap_or(fallback)
}
pub(crate) fn resolve_startup(
paths: &McPaths,
settings: &Settings,
appearance: &AppearanceSettings,
cli_theme: Option<&str>,
cli_color: Option<ColorChoice>,
stdout_is_tty: bool,
) -> anyhow::Result<AppearanceResolution> {
let selected_id = cli_theme.unwrap_or(appearance.theme.as_str());
let mut diagnostics = Vec::new();
let resolved = match load_selected_theme(&paths.root, selected_id) {
Ok(Some(theme)) => ResolvedTheme::from_theme(selected_id, &theme),
Ok(None) if cli_theme.is_some() => {
return Err(anyhow::Error::new(InvalidExplicitTheme {
id: sanitize_name(selected_id),
reason: "theme ID is not a known built-in or custom theme",
}));
}
Err(error) if cli_theme.is_some() => {
return Err(anyhow::Error::new(InvalidExplicitTheme {
id: sanitize_name(selected_id),
reason: error.reason(),
}));
}
Ok(None) | Err(_) => {
diagnostics.push(format!(
"saved theme '{}' is unavailable; using Matrix Green",
sanitize_name(selected_id)
));
let matrix = load_matrix_theme();
ResolvedTheme::from_theme(DEFAULT_THEME_ID, &matrix)
}
};
let policy = resolve_color_policy_from_env(
settings.no_color,
cli_color,
stdout_is_tty,
env::var_os("NO_COLOR").is_some(),
env::var("COLORTERM").ok().as_deref(),
env::var("TERM").ok().as_deref(),
);
Ok(AppearanceResolution {
appearance: RuntimeAppearance {
theme: Arc::new(resolved),
policy,
reduced_motion: appearance.reduced_motion,
},
diagnostics: bounded_diagnostics(diagnostics),
})
}
pub(crate) fn detect_color_depth(colorterm: Option<&str>, term: Option<&str>) -> ColorDepth {
if colorterm.is_some_and(|value| {
value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit")
}) {
ColorDepth::TrueColor
} else if term.is_some_and(|value| value.contains("256color")) {
ColorDepth::Ansi256
} else {
ColorDepth::Ansi16
}
}
pub(crate) fn resolve_color_policy_from_env(
saved_no_color: Option<bool>,
cli_color: Option<ColorChoice>,
stdout_is_tty: bool,
no_color_present: bool,
colorterm: Option<&str>,
term: Option<&str>,
) -> ColorPolicy {
let color_enabled = match cli_color {
Some(ColorChoice::Always) => true,
Some(ColorChoice::Never) => false,
Some(ColorChoice::Auto) => stdout_is_tty,
None => {
stdout_is_tty
&& !saved_no_color.unwrap_or(false)
&& !no_color_present
&& !term.is_some_and(|value| value.eq_ignore_ascii_case("dumb"))
}
};
let depth = if color_enabled {
detect_color_depth(colorterm, term)
} else {
ColorDepth::None
};
ColorPolicy {
depth,
color_enabled,
unicode_enabled: stdout_is_tty,
rich_output: stdout_is_tty || color_enabled,
}
}
pub(crate) fn ansi256_index(color: OpalineColor) -> u8 {
let cube_levels = [0u8, 95, 135, 175, 215, 255];
let nearest_level = |value: u8| {
cube_levels
.iter()
.enumerate()
.min_by_key(|(_, level)| u8::abs_diff(value, **level))
.map(|(index, _)| index as u8)
.unwrap_or_default()
};
let cube_index =
16 + 36 * nearest_level(color.r) + 6 * nearest_level(color.g) + nearest_level(color.b);
let cube_color = ansi256_rgb(cube_index);
let cube_distance = color_distance(color, cube_color.0, cube_color.1, cube_color.2);
if color.r == color.g && color.g == color.b {
let gray_step = ((u16::from(color.r) + 2) / 10).saturating_sub(1).min(23) as u8;
let gray_index = 232 + gray_step;
let gray_color = ansi256_rgb(gray_index);
if color_distance(color, gray_color.0, gray_color.1, gray_color.2) < cube_distance {
return gray_index;
}
}
cube_index
}
pub(crate) fn ansi16_index(color: OpalineColor) -> u8 {
let mut best_index = 0;
let mut best_distance = u32::MAX;
for (index, &(r, g, b)) in ANSI16_PALETTE.iter().enumerate() {
let distance = color_distance(color, r, g, b);
if distance < best_distance {
best_distance = distance;
best_index = index as u8;
}
}
best_index
}
const ANSI16_PALETTE: [(u8, u8, u8); 16] = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
fn ansi256_rgb(index: u8) -> (u8, u8, u8) {
match index {
0..=15 => ANSI16_PALETTE[index as usize],
16..=231 => {
let index = index - 16;
let level = [0, 95, 135, 175, 215, 255];
(
level[(index / 36) as usize],
level[((index % 36) / 6) as usize],
level[(index % 6) as usize],
)
}
232..=255 => {
let gray = 8 + (index - 232) * 10;
(gray, gray, gray)
}
}
}
fn color_distance(color: OpalineColor, r: u8, g: u8, b: u8) -> u32 {
let dr = i32::from(color.r) - i32::from(r);
let dg = i32::from(color.g) - i32::from(g);
let db = i32::from(color.b) - i32::from(b);
(dr * dr + dg * dg + db * db) as u32
}
fn bounded_diagnostics(mut diagnostics: Vec<String>) -> Vec<String> {
diagnostics.truncate(MAX_THEME_DIAGNOSTICS);
diagnostics
}
fn sanitize_name(name: &str) -> String {
truncate_chars(name.chars().filter(|ch| !ch.is_control()).collect(), 96)
}
fn sanitize_diagnostic(message: &str) -> String {
truncate_chars(message.chars().filter(|ch| !ch.is_control()).collect(), 240)
}
fn truncate_chars(mut value: String, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value;
}
let end = value
.char_indices()
.nth(max_chars)
.map(|(index, _)| index)
.unwrap_or(value.len());
value.truncate(end);
value.push('…');
value
}
#[cfg(test)]
mod tests {
#[test]
fn documented_custom_theme_example_is_parser_valid() {
const START: &str = "<!-- runtime-theme-example-start -->";
const END: &str = "<!-- runtime-theme-example-end -->";
let docs = include_str!("../docs/features/runtime-theming.md");
assert_eq!(
docs.matches(START).count(),
1,
"runtime theming docs must contain exactly one TOML example start marker"
);
assert_eq!(
docs.matches(END).count(),
1,
"runtime theming docs must contain exactly one TOML example end marker"
);
let source = docs
.split_once(START)
.and_then(|(_, rest)| rest.split_once(END).map(|(source, _)| source))
.expect("runtime theming docs TOML example markers are missing")
.trim()
.strip_prefix("```toml\n")
.and_then(|source| source.strip_suffix("\n```"))
.expect("runtime theming docs TOML example must be fenced")
.trim();
parse_theme_source(source, Some(Path::new("Solarized.toml")))
.unwrap_or_else(|error| panic!("documented TOML theme is invalid: {error:?}"));
}
use super::*;
use crate::config::Settings;
use std::{collections::HashMap, fs, io::Write};
#[cfg(unix)]
use std::{ffi::CString, os::unix::ffi::OsStrExt};
use tempfile::TempDir;
fn empty_theme_file() -> ThemeFile {
ThemeFile {
meta: opaline::ThemeMeta::new("bounds"),
palette: HashMap::new(),
tokens: HashMap::new(),
styles: HashMap::new(),
gradients: HashMap::new(),
}
}
fn theme_file_with_color_counts(palette_count: usize, token_count: usize) -> ThemeFile {
let mut theme_file = empty_theme_file();
for index in 0..palette_count {
theme_file
.palette
.insert(format!("palette-{index}"), "#000000".to_string());
}
for index in 0..token_count {
theme_file
.tokens
.insert(format!("token-{index}"), "#000000".to_string());
}
theme_file
}
fn theme_file_with_token_chain(length: usize) -> ThemeFile {
let mut theme_file = empty_theme_file();
theme_file
.palette
.insert("base".to_string(), "#000000".to_string());
for index in 0..length {
let reference = if index + 1 == length {
"base".to_string()
} else {
format!("token-{}", index + 1)
};
theme_file
.tokens
.insert(format!("token-{index}"), reference);
}
theme_file
}
#[cfg(unix)]
fn create_fifo(path: &std::path::Path) {
let c_path = CString::new(path.as_os_str().as_bytes()).unwrap();
let result = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
assert_eq!(
result,
0,
"mkfifo failed: {}",
std::io::Error::last_os_error()
);
}
fn complete_theme_source(name: &str) -> String {
MATRIX_GREEN_TOML.replace("name = \"Matrix Green\"", &format!("name = \"{name}\""))
}
fn complete_theme_source_with_primary(name: &str, color: &str) -> String {
complete_theme_source(name)
.replace("primary = \"#d8ffe9\"", &format!("primary = \"{color}\""))
}
fn complete_theme_source_without_magi(name: &str) -> String {
let mut source = complete_theme_source(name)
.lines()
.filter(|line| !line.trim_start().starts_with("\"magi."))
.collect::<Vec<_>>()
.join("\n");
source.push('\n');
source
}
fn palette_masquerade(source: &str, token: &str, color: &str) -> String {
let token_line = format!("\"{token}\" = \"primary\"");
assert!(source.lines().any(|line| line == token_line.as_str()));
let source = source
.lines()
.filter(|line| *line != token_line.as_str())
.collect::<Vec<_>>()
.join("\n");
source.replacen(
"[palette]\n",
&format!("[palette]\n\"{token}\" = \"{color}\"\n"),
1,
)
}
#[test]
fn required_standard_token_must_be_an_actual_token_not_palette() {
let source = palette_masquerade(
&complete_theme_source("Palette"),
tokens::TEXT_PRIMARY,
"#abcdef",
);
assert!(matches!(
parse_theme_source(&source, None),
Err(ThemeLoadFailure::Incomplete)
));
}
#[test]
fn optional_magi_token_ignores_palette_masquerade_and_uses_standard_fallback() {
let source = palette_masquerade(
&complete_theme_source("Palette extension"),
"magi.text.primary",
"#ff00ff",
);
let theme = parse_theme_source(&source, None).unwrap();
let standard = theme.try_color(tokens::TEXT_PRIMARY).unwrap();
let roles = ThemeRoles::from_theme(&theme);
assert!(!theme.token_names().contains(&"magi.text.primary"));
assert_eq!(
theme.try_color("magi.text.primary"),
Some(OpalineColor::new(255, 0, 255))
);
assert_eq!(roles.text_primary, standard);
}
#[test]
fn complete_custom_theme_without_magi_roles_uses_standard_fallbacks() {
let source = complete_theme_source_without_magi("Standard only");
let theme = parse_theme_source(&source, None).unwrap();
let roles = ThemeRoles::from_theme(&theme);
assert!(has_standard_theme_contract(&theme));
assert_eq!(
roles.text_primary,
theme.try_color(tokens::TEXT_PRIMARY).unwrap()
);
assert_eq!(
roles.syntax_keyword,
theme.try_color(tokens::CODE_KEYWORD).unwrap()
);
assert!(!roles.gradients.fast.is_empty());
}
#[test]
fn named_fast_gradient_overrides_primary_fallback() {
let source = complete_theme_source("Fast override").replace(
"\"magi.gradient.fast\" = [\"accent\", \"accent_secondary\", \"accent_tertiary\"]",
"\"magi.gradient.fast\" = [\"error\", \"warning\"]",
);
let theme = parse_theme_source(&source, None).unwrap();
let roles = ThemeRoles::from_theme(&theme);
let error = theme.try_color(tokens::ERROR).unwrap();
let warning = theme.try_color(tokens::WARNING).unwrap();
assert_eq!(roles.gradients.fast.stops(), &[error, warning]);
}
#[test]
fn catalog_contains_all_builtins_and_matrix_green() {
let temp = TempDir::new().unwrap();
let catalog = ThemeCatalog::load(temp.path());
for &(id, _) in builtin_names() {
assert!(catalog.resolve(id).is_some(), "missing builtin {id}");
}
assert!(catalog.resolve(DEFAULT_THEME_ID).is_some());
assert!(
catalog
.entries()
.into_iter()
.all(|entry| entry.id != "matrix-green.toml")
);
}
#[test]
fn custom_ids_are_exact_filenames_and_bad_siblings_are_isolated() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
fs::write(
themes.join("Neon.Name.toml"),
complete_theme_source("Custom"),
)
.unwrap();
fs::write(themes.join("bad.toml"), "not = \"a theme\"").unwrap();
fs::create_dir(themes.join("nested.toml")).unwrap();
let catalog = ThemeCatalog::load(temp.path());
assert!(catalog.resolve("custom:Neon.Name.toml").is_some());
assert!(catalog.resolve("custom:neon.name.toml").is_none());
assert!(
catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("bad.toml"))
);
assert!(
catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("nested.toml"))
);
}
#[cfg(unix)]
#[test]
fn custom_utf8_filename_ids_round_trip_without_unsanitized_diagnostics() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
let backslash_name = "back\\slash.toml";
let control_name = "control\u{1}name.toml";
fs::write(
themes.join(backslash_name),
complete_theme_source("Backslash"),
)
.unwrap();
fs::write(themes.join(control_name), complete_theme_source("Control")).unwrap();
fs::write(themes.join("bad\u{1}.toml"), "not = \"a theme\"").unwrap();
let catalog = ThemeCatalog::load(temp.path());
for (file_name, expected_name) in [(backslash_name, "Backslash"), (control_name, "Control")]
{
let id = format!("custom:{file_name}");
assert!(
catalog.resolve(&id).is_some(),
"missing catalog entry {id:?}"
);
let selected = load_selected_theme(temp.path(), &id).unwrap().unwrap();
assert_eq!(selected.meta.name, expected_name);
}
assert!(
catalog
.diagnostics()
.iter()
.all(|diagnostic| !diagnostic.chars().any(char::is_control))
);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn custom_non_utf8_filename_is_rejected_without_unsanitized_diagnostics() {
use std::os::unix::ffi::OsStringExt;
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
let non_utf8_name = std::ffi::OsString::from_vec(b"nonutf8-\xff.toml".to_vec());
fs::write(
themes.join(&non_utf8_name),
complete_theme_source("Non UTF-8"),
)
.unwrap();
let catalog = ThemeCatalog::load(temp.path());
assert!(
catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("non-UTF-8 filename"))
);
assert!(
catalog
.diagnostics()
.iter()
.all(|diagnostic| !diagnostic.chars().any(char::is_control))
);
assert!(
catalog
.entries()
.iter()
.all(|entry| !entry.id.contains("nonutf8"))
);
}
#[test]
fn malformed_custom_candidates_do_not_consume_accepted_theme_limit() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
for index in 0..(MAX_CUSTOM_THEME_FILES + 1) {
fs::write(
themes.join(format!("bad-{index:03}.toml")),
"not = \"a theme\"",
)
.unwrap();
}
fs::write(
themes.join("zz-valid.toml"),
complete_theme_source("After malformed candidates"),
)
.unwrap();
let catalog = ThemeCatalog::load(temp.path());
assert!(catalog.resolve("custom:zz-valid.toml").is_some());
}
#[test]
fn custom_theme_limits_and_diagnostics_are_bounded() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
fs::write(
themes.join("000-too-large.toml"),
vec![b'x'; MAX_CUSTOM_THEME_BYTES + 1],
)
.unwrap();
for index in 0..(MAX_CUSTOM_THEME_FILES + 2) {
fs::write(
themes.join(format!("bad-{index:03}.toml")),
"not = \"a theme\"",
)
.unwrap();
}
let catalog = ThemeCatalog::load(temp.path());
assert!(catalog.diagnostics().len() <= MAX_THEME_DIAGNOSTICS);
assert!(
catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("too large"))
);
assert!(
catalog
.diagnostics()
.iter()
.all(|diagnostic| !diagnostic.chars().any(char::is_control))
);
}
#[test]
fn diagnostic_truncation_keeps_utf8_boundaries() {
let truncated = truncate_chars("é".repeat(100), 96);
assert_eq!(truncated.chars().count(), 97);
assert!(truncated.ends_with('…'));
}
#[cfg(unix)]
#[test]
fn custom_symlinks_are_not_followed() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
fs::write(themes.join("valid.toml"), complete_theme_source("Valid")).unwrap();
std::os::unix::fs::symlink(themes.join("valid.toml"), themes.join("link.toml")).unwrap();
let catalog = ThemeCatalog::load(temp.path());
assert!(catalog.resolve("custom:valid.toml").is_some());
assert!(catalog.resolve("custom:link.toml").is_none());
}
#[test]
fn saved_theme_falls_back_without_changing_settings() {
let temp = TempDir::new().unwrap();
let settings = Settings::default();
let appearance = AppearanceSettings {
theme: "missing-theme".to_string(),
..AppearanceSettings::default()
};
let paths = McPaths::from_root(temp.path().join("mc"));
let resolution = resolve_startup(&paths, &settings, &appearance, None, None, true).unwrap();
assert_eq!(resolution.appearance.theme.id, DEFAULT_THEME_ID);
assert!(
resolution
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("missing-theme"))
);
assert_eq!(appearance.theme, "missing-theme");
}
#[test]
fn malformed_saved_custom_theme_falls_back_without_rewriting_settings() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let themes = paths.root.join("themes");
fs::create_dir_all(&themes).unwrap();
fs::write(themes.join("broken.toml"), "not = \"a theme\"").unwrap();
let settings_source = r#"{"appearance":{"theme":"custom:broken.toml"}}"#;
fs::write(&paths.settings_file, settings_source).unwrap();
let settings = Settings::default();
let appearance = AppearanceSettings {
theme: "custom:broken.toml".to_string(),
..AppearanceSettings::default()
};
let before = fs::read(&paths.settings_file).unwrap();
let resolution = resolve_startup(&paths, &settings, &appearance, None, None, true).unwrap();
assert_eq!(resolution.appearance.theme.id, DEFAULT_THEME_ID);
assert!(
resolution
.diagnostics
.iter()
.any(|diagnostic| { diagnostic.contains("saved theme 'custom:broken.toml'") })
);
assert_eq!(appearance.theme, "custom:broken.toml");
assert_eq!(fs::read(&paths.settings_file).unwrap(), before);
}
#[test]
fn saved_theme_fallback_warning_survives_catalog_warning_budget() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
for index in 0..(MAX_THEME_DIAGNOSTICS + 2) {
fs::write(
themes.join(format!("bad-{index:02}.toml")),
"not = \"a theme\"",
)
.unwrap();
}
let settings = Settings::default();
let appearance = AppearanceSettings {
theme: "missing-theme".to_string(),
..AppearanceSettings::default()
};
let paths = McPaths::from_root(temp.path().join("mc"));
let resolution = resolve_startup(&paths, &settings, &appearance, None, None, true).unwrap();
assert!(resolution.diagnostics.len() <= MAX_THEME_DIAGNOSTICS);
assert!(
resolution
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("saved theme 'missing-theme'"))
);
}
#[test]
fn explicit_unknown_theme_is_an_error() {
let temp = TempDir::new().unwrap();
let settings = Settings::default();
let appearance = AppearanceSettings::default();
let paths = McPaths::from_root(temp.path().join("mc"));
let error = resolve_startup(&paths, &settings, &appearance, Some("missing"), None, true)
.unwrap_err();
assert!(error.to_string().contains("invalid theme 'missing'"));
}
#[test]
fn color_depth_detection_is_query_free_and_ordered() {
assert_eq!(
detect_color_depth(Some("truecolor"), Some("xterm-256color")),
ColorDepth::TrueColor
);
assert_eq!(
detect_color_depth(Some("24bit"), Some("xterm")),
ColorDepth::TrueColor
);
assert_eq!(
detect_color_depth(None, Some("screen-256color")),
ColorDepth::Ansi256
);
assert_eq!(detect_color_depth(None, Some("xterm")), ColorDepth::Ansi16);
assert_eq!(detect_color_depth(None, None), ColorDepth::Ansi16);
}
#[test]
fn color_policy_honors_tty_no_color_and_always_rules() {
let policy = resolve_color_policy_from_env(
Some(true),
None,
true,
true,
Some("truecolor"),
Some("xterm"),
);
assert_eq!(policy.depth, ColorDepth::None);
assert!(policy.rich_output);
let forced = resolve_color_policy_from_env(
Some(true),
Some(ColorChoice::Always),
false,
true,
None,
Some("xterm-256color"),
);
assert_eq!(forced.depth, ColorDepth::Ansi256);
assert!(forced.rich_output);
let dumb =
resolve_color_policy_from_env(None, None, true, false, Some("truecolor"), Some("dumb"));
assert_eq!(dumb.depth, ColorDepth::None);
}
#[test]
fn rgb_lowering_is_deterministic_and_uses_expected_primary_entries() {
assert_eq!(ansi256_index(OpalineColor::new(255, 0, 0)), 196);
assert_eq!(ansi256_index(OpalineColor::new(0, 255, 0)), 46);
assert_eq!(ansi256_index(OpalineColor::new(0, 0, 255)), 21);
}
#[test]
fn ansi16_index_is_the_single_shared_palette_projection() {
for (index, (r, g, b)) in ANSI16_PALETTE.into_iter().enumerate() {
let color = OpalineColor::new(r, g, b);
let index = index as u8;
assert_eq!(ansi16_index(color), index);
}
}
#[test]
fn matrix_roles_are_app_owned_and_standard_roles_are_projected() {
let temp = TempDir::new().unwrap();
let catalog = ThemeCatalog::load(temp.path());
let theme = catalog.resolve(DEFAULT_THEME_ID).unwrap();
assert_eq!(theme.id, DEFAULT_THEME_ID);
assert_ne!(theme.roles.text_primary, OpalineColor::FALLBACK);
assert_eq!(theme.roles.syntax_keyword, theme.roles.text_accent);
assert!(!theme.roles.gradients.fast.is_empty());
}
#[test]
fn custom_theme_snapshot_survives_source_removal() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
let path = themes.join("snapshot.toml");
let source = complete_theme_source_with_primary("Snapshot", "#abcdef");
let mut file = fs::File::create(&path).unwrap();
file.write_all(source.as_bytes()).unwrap();
let catalog = ThemeCatalog::load(temp.path());
let snapshot = Arc::new(catalog.resolve("custom:snapshot.toml").unwrap());
fs::remove_file(path).unwrap();
assert_eq!(
catalog
.entries()
.iter()
.find(|entry| entry.id == "custom:snapshot.toml")
.unwrap()
.display_name,
"Snapshot"
);
assert_eq!(
snapshot.roles.text_primary,
OpalineColor::new(0xab, 0xcd, 0xef)
);
}
#[test]
fn catalog_ids_are_sorted() {
let temp = TempDir::new().unwrap();
let catalog = ThemeCatalog::load(temp.path());
let ids = catalog
.entries()
.into_iter()
.map(|entry| entry.id)
.collect::<Vec<_>>();
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(ids, sorted);
}
#[test]
fn theme_bounds_allow_combined_palette_and_tokens_at_limit_only() {
let at_limit = theme_file_with_color_counts(MAX_CUSTOM_THEME_COLORS, 0);
assert!(validate_theme_file_bounds(&at_limit).is_ok());
let over_limit = theme_file_with_color_counts(MAX_CUSTOM_THEME_COLORS - 1, 2);
assert!(matches!(
validate_theme_file_bounds(&over_limit),
Err(ThemeLoadFailure::SafetyLimit)
));
}
#[test]
fn theme_bounds_allow_styles_at_limit_only() {
let mut at_limit = empty_theme_file();
for index in 0..MAX_CUSTOM_THEME_STYLES {
at_limit
.styles
.insert(format!("style-{index}"), opaline::StyleDef::default());
}
assert!(validate_theme_file_bounds(&at_limit).is_ok());
let mut over_limit = empty_theme_file();
for index in 0..=MAX_CUSTOM_THEME_STYLES {
over_limit
.styles
.insert(format!("style-{index}"), opaline::StyleDef::default());
}
assert!(matches!(
validate_theme_file_bounds(&over_limit),
Err(ThemeLoadFailure::SafetyLimit)
));
}
#[test]
fn theme_bounds_allow_gradient_count_at_limit_only() {
let mut at_limit = empty_theme_file();
for index in 0..MAX_CUSTOM_THEME_GRADIENTS {
at_limit
.gradients
.insert(format!("gradient-{index}"), vec!["#000000".to_string()]);
}
assert!(validate_theme_file_bounds(&at_limit).is_ok());
let mut over_limit = empty_theme_file();
for index in 0..=MAX_CUSTOM_THEME_GRADIENTS {
over_limit
.gradients
.insert(format!("gradient-{index}"), vec!["#000000".to_string()]);
}
assert!(matches!(
validate_theme_file_bounds(&over_limit),
Err(ThemeLoadFailure::SafetyLimit)
));
}
#[test]
fn theme_bounds_allow_total_gradient_stops_at_limit_only() {
let mut at_limit = empty_theme_file();
at_limit.gradients.insert(
"gradient".to_string(),
(0..MAX_CUSTOM_THEME_GRADIENT_STOPS)
.map(|_| "#000000".to_string())
.collect(),
);
assert!(validate_theme_file_bounds(&at_limit).is_ok());
let mut over_limit = empty_theme_file();
over_limit.gradients.insert(
"gradient".to_string(),
(0..=MAX_CUSTOM_THEME_GRADIENT_STOPS)
.map(|_| "#000000".to_string())
.collect(),
);
assert!(matches!(
validate_theme_file_bounds(&over_limit),
Err(ThemeLoadFailure::SafetyLimit)
));
}
#[test]
fn theme_bounds_allow_token_reference_depth_at_limit_only() {
let at_limit = theme_file_with_token_chain(MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH);
assert!(validate_theme_file_bounds(&at_limit).is_ok());
let over_limit = theme_file_with_token_chain(MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH + 1);
assert!(matches!(
validate_theme_file_bounds(&over_limit),
Err(ThemeLoadFailure::SafetyLimit)
));
}
#[test]
fn custom_theme_byte_limit_rejects_only_over_limit_sources() {
let at_limit = " ".repeat(MAX_CUSTOM_THEME_BYTES);
assert!(!matches!(
parse_theme_source(&at_limit, None),
Err(ThemeLoadFailure::TooLarge)
));
let over_limit = " ".repeat(MAX_CUSTOM_THEME_BYTES + 1);
assert!(matches!(
parse_theme_source(&over_limit, None),
Err(ThemeLoadFailure::TooLarge)
));
}
#[test]
fn selected_custom_file_bounds_and_encoding_are_enforced() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
let mut exact_source = complete_theme_source("Exact byte limit");
if !exact_source.ends_with('\n') {
exact_source.push('\n');
}
assert!(exact_source.len() <= MAX_CUSTOM_THEME_BYTES);
exact_source.push_str(&"#".repeat(MAX_CUSTOM_THEME_BYTES - exact_source.len()));
assert_eq!(exact_source.len(), MAX_CUSTOM_THEME_BYTES);
fs::write(themes.join("at-limit.toml"), exact_source).unwrap();
let selected = load_selected_theme(temp.path(), "custom:at-limit.toml")
.unwrap()
.unwrap();
assert_eq!(selected.meta.name, "Exact byte limit");
fs::write(
themes.join("oversized.toml"),
vec![b'x'; MAX_CUSTOM_THEME_BYTES + 1],
)
.unwrap();
assert!(matches!(
load_selected_theme(temp.path(), "custom:oversized.toml"),
Err(ThemeLoadFailure::TooLarge)
));
fs::write(themes.join("invalid-utf8.toml"), [0xff, 0xfe]).unwrap();
assert!(matches!(
load_selected_theme(temp.path(), "custom:invalid-utf8.toml"),
Err(ThemeLoadFailure::InvalidUtf8)
));
}
#[test]
fn diagnostic_limit_keeps_exactly_the_allowed_count() {
let at_limit = vec!["warning".to_string(); MAX_THEME_DIAGNOSTICS];
assert_eq!(bounded_diagnostics(at_limit).len(), MAX_THEME_DIAGNOSTICS);
let over_limit = vec!["warning".to_string(); MAX_THEME_DIAGNOSTICS + 1];
assert_eq!(bounded_diagnostics(over_limit).len(), MAX_THEME_DIAGNOSTICS);
}
#[test]
fn raw_custom_theme_directory_entry_limit_is_reported() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
for index in 0..MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
fs::write(themes.join(format!("entry-{index:03}.txt")), "ignored").unwrap();
}
let at_limit_catalog = ThemeCatalog::load(temp.path());
assert!(
!at_limit_catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("directory entry limit reached"))
);
fs::write(
themes.join(format!("entry-{MAX_CUSTOM_THEME_DIRECTORY_ENTRIES:03}.txt")),
"ignored",
)
.unwrap();
let over_limit_catalog = ThemeCatalog::load(temp.path());
assert!(
over_limit_catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("directory entry limit reached"))
);
}
#[test]
fn eligible_custom_theme_file_limit_is_reported() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
for index in 0..MAX_CUSTOM_THEME_FILES {
fs::write(
themes.join(format!("theme-{index:03}.toml")),
complete_theme_source(&format!("Theme {index}")),
)
.unwrap();
}
let at_limit_catalog = ThemeCatalog::load(temp.path());
let at_limit_count = at_limit_catalog
.entries()
.into_iter()
.filter(|entry| entry.id.starts_with("custom:"))
.count();
assert_eq!(at_limit_count, MAX_CUSTOM_THEME_FILES);
assert!(
!at_limit_catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("custom theme file limit reached"))
);
fs::write(
themes.join(format!("theme-{MAX_CUSTOM_THEME_FILES:03}.toml")),
complete_theme_source("Over limit"),
)
.unwrap();
let over_limit_catalog = ThemeCatalog::load(temp.path());
let over_limit_count = over_limit_catalog
.entries()
.into_iter()
.filter(|entry| entry.id.starts_with("custom:"))
.count();
assert_eq!(over_limit_count, MAX_CUSTOM_THEME_FILES);
assert!(
over_limit_catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("custom theme file limit reached"))
);
}
#[cfg(unix)]
#[test]
fn static_symlinked_themes_directory_is_not_followed() {
let temp = TempDir::new().unwrap();
let mc_home = temp.path().join("mc-home");
let outside = temp.path().join("outside");
fs::create_dir_all(&mc_home).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(
outside.join("outside.toml"),
complete_theme_source("Outside"),
)
.unwrap();
std::os::unix::fs::symlink(&outside, mc_home.join("themes")).unwrap();
assert!(matches!(
open_themes_directory(&mc_home),
Err("themes directory is not a safe directory")
));
let catalog = ThemeCatalog::load(&mc_home);
assert!(catalog.resolve("custom:outside.toml").is_none());
assert!(
catalog
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("safe directory"))
);
assert!(matches!(
load_selected_theme(&mc_home, "custom:outside.toml"),
Err(ThemeLoadFailure::DirectoryUnsafe)
));
}
#[cfg(unix)]
#[test]
fn selected_custom_symlink_is_rejected_without_following() {
let temp = TempDir::new().unwrap();
let mc_home = temp.path().join("mc-home");
let themes = mc_home.join("themes");
let outside = temp.path().join("outside.toml");
fs::create_dir_all(&themes).unwrap();
fs::write(&outside, complete_theme_source("Outside")).unwrap();
std::os::unix::fs::symlink(&outside, themes.join("link.toml")).unwrap();
let catalog = ThemeCatalog::load(&mc_home);
assert!(catalog.resolve("custom:link.toml").is_none());
assert!(matches!(
load_selected_theme(&mc_home, "custom:link.toml"),
Err(ThemeLoadFailure::Link)
));
}
#[test]
fn selected_custom_directory_is_rejected_as_nonregular() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
fs::create_dir(themes.join("directory.toml")).unwrap();
assert!(matches!(
load_selected_theme(temp.path(), "custom:directory.toml"),
Err(ThemeLoadFailure::NonRegular)
));
}
#[cfg(unix)]
#[test]
fn selected_custom_fifo_is_rejected_as_nonregular() {
let temp = TempDir::new().unwrap();
let themes = temp.path().join("themes");
fs::create_dir_all(&themes).unwrap();
create_fifo(&themes.join("pipe.toml"));
assert!(matches!(
load_selected_theme(temp.path(), "custom:pipe.toml"),
Err(ThemeLoadFailure::NonRegular)
));
}
#[cfg(unix)]
#[test]
fn opened_themes_handle_stays_on_original_directory_after_replacement() {
let temp = TempDir::new().unwrap();
let mc_home = temp.path().join("mc-home");
let themes = mc_home.join("themes");
let outside = temp.path().join("outside");
fs::create_dir_all(&themes).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(
themes.join("selected.toml"),
complete_theme_source("Inside"),
)
.unwrap();
fs::write(
outside.join("selected.toml"),
complete_theme_source("Outside"),
)
.unwrap();
let opened = open_themes_directory(&mc_home).unwrap().unwrap();
fs::rename(&themes, mc_home.join("themes-original")).unwrap();
std::os::unix::fs::symlink(&outside, mc_home.join("themes")).unwrap();
let loaded = read_custom_theme(&opened, "selected.toml").unwrap();
assert_eq!(loaded.meta.name, "Inside");
assert!(matches!(
load_selected_theme(&mc_home, "custom:selected.toml"),
Err(ThemeLoadFailure::DirectoryUnsafe)
));
}
}