use serde::Deserialize;
use std::env;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
use crate::resources::ensure_default_background;
use crate::terminal::{
DEFAULT_IMAGE_OPACITY, DEFAULT_IMAGE_TERMINAL_OPACITY, DEFAULT_OVERLAY_OPACITY,
DEFAULT_TERMINAL_OPACITY, MAX_SCROLLBACK_LINES, PromptConfig, TerminalConfig,
};
use crate::theme::{
BRONCO_ACCENT, CustomThemeDefinition, THEME_NAMES, TerminalTheme, ThemeCatalog,
};
static CONFIG_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
const SAMPLE_CONFIG: &str = r##"[window]
title = "Lios"
width = 960
height = 640
decorated = false
renderer = "cairo" # cairo disables GPU; auto/gl use OpenGL, vulkan explicitly tries Vulkan
opacity = 1.00 # master/full-window opacity, 0.0 to 1.0
[terminal]
font = "Monospace 12"
scrollback_lines = 1000
# Named custom themes inherit one built-in, then override only the values shown.
[[custom_theme]]
name = "midnight-copper"
base = "default"
foreground = "#f4e7d3"
background = "#100c0a"
cursor_background = "#d97745"
cursor_foreground = "#100c0a"
palette = ["#100c0a", "#c65f46", "#7f9f5f", "#d4a656", "#6688aa", "#a8759b", "#5f9f9a", "#d8c8b8", "#5b504a", "#e07a5f", "#9fbd78", "#f2cc72", "#82a7c9", "#c394b7", "#7fc2ba", "#fff4e6"]
[theme]
name = "default"
# foreground = "#ffffff"
# background = "#000000"
# Optional XFCE Red cursor preset values:
# cursor_background = "#e6192e"
# cursor_foreground = "#a51d2d"
# cursor = "#e6192e" # legacy alias for cursor_background
# cursor_match_overlay = false # cursor follows an existing effective accent
# unified_accent = false # enables a visible accent and matches the cursor
# Saved appearance profiles from Preferences use [[theme_profile]] tables.
# [[theme_profile]]
# name = "Ocean Glass"
# theme = "ocean"
# font = "Monospace 12"
# window_opacity = 0.95
# terminal_opacity = 0.50
# overlay_color = "#00b4d8"
# overlay_opacity = 0.12
# random_overlay = false
[background]
# image = "/home/example/Pictures/wallpaper.jpg"
image_opacity = 1.00
terminal_opacity = 1.00
# Use terminal_opacity = 0.50 with an image, or lower values for no-image glass.
# overlay_color = "#7c3aed"
overlay_opacity = 0.00
random_overlay = true
"##;
#[derive(Debug, Clone, Default)]
pub struct AppSettings {
pub window: WindowSettings,
pub terminal: TerminalConfig,
pub theme_profiles: Vec<ThemeProfile>,
pub theme_catalog: ThemeCatalog,
active_theme_overrides: ThemeOverrides,
}
#[derive(Debug, Clone, Default, PartialEq)]
struct ThemeOverrides {
foreground: Option<String>,
background: Option<String>,
cursor_background: Option<String>,
cursor_foreground: Option<String>,
palette: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeProfile {
pub name: String,
pub theme_name: String,
pub cursor_background: Option<String>,
pub cursor_foreground: Option<String>,
pub cursor_match_overlay: bool,
pub unified_accent: bool,
pub font: String,
pub decorated: bool,
pub window_opacity: f64,
pub background_image: Option<PathBuf>,
pub image_opacity: f64,
pub terminal_opacity: f64,
pub overlay_color: Option<gtk::gdk::RGBA>,
pub overlay_opacity: f64,
pub random_overlay: bool,
}
#[derive(Debug, Clone)]
pub struct WindowSettings {
pub title: String,
pub default_width: i32,
pub default_height: i32,
pub decorated: bool,
pub renderer: RendererPreference,
pub opacity: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RendererPreference {
Auto,
Gl,
Vulkan,
Cairo,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub renderer: Option<String>,
pub theme_name: Option<String>,
pub font: Option<String>,
pub background_image: Option<PathBuf>,
pub background_image_opacity: Option<f64>,
pub terminal_opacity: Option<f64>,
pub overlay_color: Option<String>,
pub overlay_opacity: Option<f64>,
pub random_overlay: Option<bool>,
pub decorated: Option<bool>,
pub window_opacity: Option<f64>,
}
#[derive(Debug, Clone)]
pub enum ConfigCommand {
Path,
Sample,
Init {
path: Option<PathBuf>,
force: bool,
},
Show {
path: Option<PathBuf>,
},
Themes {
path: Option<PathBuf>,
},
Validate {
path: Option<PathBuf>,
},
ThemeTemplate {
name: String,
base: String,
},
Set {
path: Option<PathBuf>,
key: String,
value: String,
},
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct FileConfig {
window: FileWindow,
terminal: FileTerminal,
theme: FileTheme,
background: FileBackground,
custom_theme: Vec<CustomThemeDefinition>,
theme_profile: Vec<FileThemeProfile>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileWindow {
title: Option<String>,
width: Option<i32>,
height: Option<i32>,
decorated: Option<bool>,
renderer: Option<String>,
opacity: Option<f64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileTerminal {
font: Option<String>,
scrollback_lines: Option<i64>,
#[serde(rename = "prompt_profile")]
_legacy_prompt_profile: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileTheme {
name: Option<String>,
foreground: Option<String>,
background: Option<String>,
cursor: Option<String>,
cursor_background: Option<String>,
cursor_foreground: Option<String>,
cursor_match_overlay: Option<bool>,
unified_accent: Option<bool>,
palette: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileBackground {
image: Option<PathBuf>,
image_opacity: Option<f64>,
terminal_opacity: Option<f64>,
overlay_color: Option<String>,
overlay_opacity: Option<f64>,
random_overlay: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct FileThemeProfile {
name: Option<String>,
theme: Option<String>,
theme_name: Option<String>,
cursor_background: Option<String>,
cursor_foreground: Option<String>,
cursor_match_overlay: Option<bool>,
unified_accent: Option<bool>,
font: Option<String>,
decorated: Option<bool>,
window_opacity: Option<f64>,
image: Option<PathBuf>,
background_image: Option<PathBuf>,
image_opacity: Option<f64>,
terminal_opacity: Option<f64>,
overlay_color: Option<String>,
overlay_opacity: Option<f64>,
random_overlay: Option<bool>,
}
impl AppSettings {
pub fn load(config_path: Option<PathBuf>, overrides: ConfigOverrides) -> Result<Self, String> {
Self::load_internal(config_path, overrides, true)
}
fn load_for_inspection(config_path: Option<PathBuf>) -> Result<Self, String> {
Self::load_internal(config_path, ConfigOverrides::default(), false)
}
fn load_internal(
config_path: Option<PathBuf>,
overrides: ConfigOverrides,
install_packaged_background: bool,
) -> Result<Self, String> {
let mut settings = Self::default();
let config_path = config_path.or_else(default_existing_config_path);
let loaded_config = config_path.is_some();
if let Some(path) = config_path {
let file = read_config_file(path)?;
settings.apply_file(file)?;
}
if !loaded_config && install_packaged_background {
settings.apply_packaged_default_background();
}
settings.apply_overrides(overrides)?;
settings.terminal.reconcile_unified_accent();
Ok(settings)
}
pub fn persist(&self, path: &Path) -> Result<(), String> {
let mut doc = if path.exists() {
read_document(path)?
} else {
DocumentMut::new()
};
let existing_profiles = document_theme_profiles(&doc);
ensure_table(&mut doc, "window");
set_item(
&mut doc["window"],
"title",
value(self.window.title.clone()),
);
set_item(
&mut doc["window"],
"width",
value(i64::from(self.window.default_width)),
);
set_item(
&mut doc["window"],
"height",
value(i64::from(self.window.default_height)),
);
set_item(
&mut doc["window"],
"decorated",
value(self.window.decorated),
);
set_item(
&mut doc["window"],
"renderer",
value(self.window.renderer.as_config()),
);
set_item(&mut doc["window"], "opacity", value(self.window.opacity));
ensure_table(&mut doc, "terminal");
set_item(
&mut doc["terminal"],
"font",
value(self.terminal.font.clone()),
);
set_item(
&mut doc["terminal"],
"scrollback_lines",
value(self.terminal.scrollback_lines),
);
ensure_table(&mut doc, "theme");
set_item(
&mut doc["theme"],
"name",
value(self.terminal.theme_name.clone()),
);
set_optional_string(
&mut doc["theme"],
"foreground",
self.active_theme_overrides.foreground.as_deref(),
);
set_optional_string(
&mut doc["theme"],
"background",
self.active_theme_overrides.background.as_deref(),
);
set_optional_string(
&mut doc["theme"],
"cursor_background",
self.active_theme_overrides
.cursor_background
.as_deref()
.or(self.terminal.cursor_background.as_deref()),
);
set_optional_string(
&mut doc["theme"],
"cursor_foreground",
self.active_theme_overrides
.cursor_foreground
.as_deref()
.or(self.terminal.cursor_foreground.as_deref()),
);
if let Some(palette) = &self.active_theme_overrides.palette {
if !document_palette_matches(&doc["theme"], palette) {
let mut colors = Array::new();
for color in palette {
colors.push(color.as_str());
}
set_item(&mut doc["theme"], "palette", value(colors));
}
} else {
doc["theme"].as_table_mut().unwrap().remove("palette");
}
doc["theme"].as_table_mut().unwrap().remove("cursor");
set_item(
&mut doc["theme"],
"cursor_match_overlay",
value(self.terminal.cursor_match_overlay),
);
set_item(
&mut doc["theme"],
"unified_accent",
value(self.terminal.unified_accent),
);
ensure_table(&mut doc, "background");
let image = self
.terminal
.background
.image
.as_ref()
.map(|image| image.to_string_lossy().into_owned());
set_optional_string(&mut doc["background"], "image", image.as_deref());
set_item(
&mut doc["background"],
"image_opacity",
value(self.terminal.background.image_opacity),
);
set_item(
&mut doc["background"],
"terminal_opacity",
value(self.terminal.background.terminal_opacity),
);
let overlay_color = self
.terminal
.background
.overlay_color
.as_ref()
.map(color_to_hex);
set_optional_string(
&mut doc["background"],
"overlay_color",
overlay_color.as_deref(),
);
set_item(
&mut doc["background"],
"overlay_opacity",
value(self.terminal.background.overlay_opacity),
);
set_item(
&mut doc["background"],
"random_overlay",
value(self.terminal.background.random_overlay),
);
if existing_profiles.as_deref() != Some(self.theme_profiles.as_slice()) {
merge_theme_profile_tables(
&mut doc,
&self.theme_profiles,
existing_profiles.as_deref(),
);
}
write_document(path, doc)
}
pub fn upsert_theme_profile(&mut self, profile: ThemeProfile) {
if let Some(existing) = self
.theme_profiles
.iter_mut()
.find(|existing| existing.name.eq_ignore_ascii_case(&profile.name))
{
*existing = profile;
} else {
self.theme_profiles.push(profile);
}
}
fn apply_file(&mut self, file: FileConfig) -> Result<(), String> {
self.theme_catalog = ThemeCatalog::new(file.custom_theme)?;
let image_was_configured = file.background.image.is_some();
let terminal_opacity_was_configured = file.background.terminal_opacity.is_some();
if let Some(title) = file.window.title {
self.window.title = title;
}
if let Some(width) = file.window.width {
self.window.default_width = positive_i32(width, "window.width")?;
}
if let Some(height) = file.window.height {
self.window.default_height = positive_i32(height, "window.height")?;
}
if let Some(decorated) = file.window.decorated {
self.window.decorated = decorated;
}
if let Some(renderer) = file.window.renderer {
self.window.renderer = RendererPreference::parse(&renderer)?;
}
if let Some(opacity) = file.window.opacity {
self.window.opacity = unit_interval(opacity, "window.opacity")?;
}
if let Some(font) = file.terminal.font {
self.terminal.font = font;
}
if let Some(lines) = file.terminal.scrollback_lines {
self.terminal.scrollback_lines =
bounded_scrollback(lines, "terminal.scrollback_lines")?;
}
let selected_name = file.theme.name.as_deref().unwrap_or("default");
let (canonical_name, mut theme) = self.theme_catalog.resolve(selected_name)?;
let cursor_background = file
.theme
.cursor_background
.as_deref()
.or(file.theme.cursor.as_deref());
self.active_theme_overrides = ThemeOverrides {
foreground: file.theme.foreground.clone(),
background: file.theme.background.clone(),
cursor_background: cursor_background.map(str::to_string),
cursor_foreground: file.theme.cursor_foreground.clone(),
palette: file.theme.palette.clone(),
};
self.terminal.theme_name = canonical_name;
self.terminal.cursor_background = nonempty_config_color(cursor_background);
self.terminal.cursor_foreground =
nonempty_config_color(file.theme.cursor_foreground.as_deref());
if let Some(cursor_match_overlay) = file.theme.cursor_match_overlay {
self.terminal.cursor_match_overlay = cursor_match_overlay;
}
if let Some(unified_accent) = file.theme.unified_accent {
self.terminal.unified_accent = unified_accent;
}
theme.apply_overrides(
file.theme.foreground.as_deref(),
file.theme.background.as_deref(),
cursor_background,
file.theme.cursor_foreground.as_deref(),
file.theme.palette.as_deref(),
)?;
self.terminal.theme = theme;
self.apply_builtin_theme_preset()?;
if let Some(image) = file.background.image {
self.terminal.background.image = Some(expand_user_path(image));
}
if let Some(opacity) = file.background.image_opacity {
self.terminal.background.image_opacity =
unit_interval(opacity, "background.image_opacity")?;
}
if let Some(opacity) = file.background.terminal_opacity {
self.terminal.background.terminal_opacity =
unit_interval(opacity, "background.terminal_opacity")?;
}
if let Some(color) = file.background.overlay_color {
self.terminal.background.overlay_color = Some(TerminalTheme::parse_color(&color)?);
}
if let Some(opacity) = file.background.overlay_opacity {
self.terminal.background.overlay_opacity =
unit_interval(opacity, "background.overlay_opacity")?;
}
if let Some(random_overlay) = file.background.random_overlay {
self.terminal.background.random_overlay = random_overlay;
}
if self.terminal.unified_accent {
self.terminal.set_unified_accent(true);
}
if image_was_configured
&& (!terminal_opacity_was_configured
|| self.terminal.background.terminal_opacity >= 0.999)
{
self.terminal.background.terminal_opacity = DEFAULT_IMAGE_TERMINAL_OPACITY;
}
let theme_profiles = file
.theme_profile
.into_iter()
.map(|profile| theme_profile_from_file(profile, &self.theme_catalog))
.collect::<Result<Vec<_>, _>>()?;
validate_unique_theme_profile_names(&theme_profiles)?;
self.theme_profiles = theme_profiles;
Ok(())
}
fn apply_overrides(&mut self, overrides: ConfigOverrides) -> Result<(), String> {
let image_was_overridden = overrides.background_image.is_some();
let terminal_opacity_was_overridden = overrides.terminal_opacity.is_some();
if let Some(renderer) = overrides.renderer {
self.window.renderer = RendererPreference::parse(&renderer)?;
}
if let Some(theme_name) = overrides.theme_name {
self.select_theme(&theme_name)?;
}
if let Some(font) = overrides.font {
self.terminal.font = font;
}
if let Some(image) = overrides.background_image {
self.terminal.background.image = Some(expand_user_path(image));
}
if let Some(opacity) = overrides.background_image_opacity {
self.terminal.background.image_opacity =
unit_interval(opacity, "--background-image-opacity")?;
}
if let Some(opacity) = overrides.terminal_opacity {
self.terminal.background.terminal_opacity =
unit_interval(opacity, "--background-opacity")?;
}
if let Some(color) = overrides.overlay_color {
self.terminal.background.overlay_color = Some(TerminalTheme::parse_color(&color)?);
}
if let Some(opacity) = overrides.overlay_opacity {
self.terminal.background.overlay_opacity = unit_interval(opacity, "--overlay-opacity")?;
}
if let Some(random_overlay) = overrides.random_overlay {
self.terminal.background.random_overlay = random_overlay;
}
if let Some(decorated) = overrides.decorated {
self.window.decorated = decorated;
}
if let Some(opacity) = overrides.window_opacity {
self.window.opacity = unit_interval(opacity, "--opacity")?;
}
if image_was_overridden
&& !terminal_opacity_was_overridden
&& self.terminal.background.terminal_opacity >= 0.999
{
self.terminal.background.terminal_opacity = DEFAULT_IMAGE_TERMINAL_OPACITY;
}
Ok(())
}
pub fn selectable_theme_names(&self) -> Vec<String> {
self.theme_catalog.selectable_names()
}
pub fn resolve_theme(&self, name: &str) -> Result<(String, TerminalTheme), String> {
self.theme_catalog.resolve(name)
}
pub fn select_theme(&mut self, name: &str) -> Result<(), String> {
let (canonical_name, theme) = self.resolve_theme(name)?;
self.terminal.theme_name = canonical_name;
self.terminal.theme = theme;
self.terminal.cursor_background = None;
self.terminal.cursor_foreground = None;
self.active_theme_overrides = ThemeOverrides::default();
self.apply_builtin_theme_preset()?;
Ok(())
}
fn apply_builtin_theme_preset(&mut self) -> Result<(), String> {
if self.terminal.theme_name != "bronco" {
return Ok(());
}
self.terminal.prompt = PromptConfig::named("lightbar")?;
self.terminal.background.overlay_color = Some(TerminalTheme::parse_color(BRONCO_ACCENT)?);
self.terminal.background.random_overlay = false;
if self.terminal.background.overlay_opacity <= 0.0 {
self.terminal.background.overlay_opacity = DEFAULT_OVERLAY_OPACITY;
}
Ok(())
}
pub fn set_theme_cursor_overrides(
&mut self,
cursor_background: Option<String>,
cursor_foreground: Option<String>,
) -> Result<(), String> {
let (_, mut theme) = self.resolve_theme(&self.terminal.theme_name)?;
theme.apply_overrides(
self.active_theme_overrides.foreground.as_deref(),
self.active_theme_overrides.background.as_deref(),
cursor_background.as_deref(),
cursor_foreground.as_deref(),
self.active_theme_overrides.palette.as_deref(),
)?;
self.active_theme_overrides.cursor_background = cursor_background.clone();
self.active_theme_overrides.cursor_foreground = cursor_foreground.clone();
self.terminal.cursor_background =
cursor_background.filter(|color| !color.trim().is_empty());
self.terminal.cursor_foreground =
cursor_foreground.filter(|color| !color.trim().is_empty());
self.terminal.theme = theme;
Ok(())
}
fn apply_packaged_default_background(&mut self) {
if self.terminal.background.image.is_some() {
return;
}
if let Ok(path) = ensure_default_background() {
self.terminal.background.image = Some(path);
if self.terminal.background.terminal_opacity >= 0.999 {
self.terminal.background.terminal_opacity = DEFAULT_IMAGE_TERMINAL_OPACITY;
}
}
}
}
impl ThemeProfile {
pub fn from_settings(name: &str, settings: &AppSettings) -> Result<Self, String> {
Ok(Self {
name: normalized_profile_name(name)?,
theme_name: settings.terminal.theme_name.clone(),
cursor_background: settings.active_theme_overrides.cursor_background.clone(),
cursor_foreground: settings.active_theme_overrides.cursor_foreground.clone(),
cursor_match_overlay: settings.terminal.cursor_match_overlay,
unified_accent: settings.terminal.unified_accent,
font: settings.terminal.font.clone(),
decorated: settings.window.decorated,
window_opacity: settings.window.opacity,
background_image: settings.terminal.background.image.clone(),
image_opacity: settings.terminal.background.image_opacity,
terminal_opacity: settings.terminal.background.terminal_opacity,
overlay_color: settings.terminal.background.overlay_color,
overlay_opacity: settings.terminal.background.overlay_opacity,
random_overlay: settings.terminal.background.random_overlay,
})
}
pub fn apply_to(&self, settings: &mut AppSettings) -> Result<(), String> {
settings.select_theme(&self.theme_name)?;
settings.set_theme_cursor_overrides(
self.cursor_background.clone(),
self.cursor_foreground.clone(),
)?;
settings.window.decorated = self.decorated;
settings.window.opacity = self.window_opacity;
settings.terminal.cursor_match_overlay = self.cursor_match_overlay;
settings.terminal.unified_accent = false;
settings.terminal.font = self.font.clone();
settings.terminal.background.image = self.background_image.clone();
settings.terminal.background.image_opacity = self.image_opacity;
settings.terminal.background.terminal_opacity = self.terminal_opacity;
settings.terminal.background.overlay_color = self.overlay_color;
settings.terminal.background.overlay_opacity = self.overlay_opacity;
settings.terminal.background.random_overlay = self.random_overlay;
if self.unified_accent {
settings.terminal.set_unified_accent(true);
}
Ok(())
}
}
fn theme_profile_from_file(
file: FileThemeProfile,
catalog: &ThemeCatalog,
) -> Result<ThemeProfile, String> {
let theme_name = file
.theme
.or(file.theme_name)
.unwrap_or_else(|| "default".to_string());
let (canonical_name, mut theme) = catalog.resolve(&theme_name).map_err(|message| {
format!(
"theme profile '{}': {message}",
file.name.as_deref().unwrap_or_default()
)
})?;
theme.apply_overrides(
None,
None,
file.cursor_background.as_deref(),
file.cursor_foreground.as_deref(),
None,
)?;
Ok(ThemeProfile {
name: normalized_profile_name(file.name.as_deref().unwrap_or_default())?,
theme_name: canonical_name,
cursor_background: file.cursor_background,
cursor_foreground: file.cursor_foreground,
cursor_match_overlay: file.cursor_match_overlay.unwrap_or(false),
unified_accent: file.unified_accent.unwrap_or(false),
font: file.font.unwrap_or_else(|| "Monospace 12".to_string()),
decorated: file.decorated.unwrap_or(false),
window_opacity: unit_interval(
file.window_opacity.unwrap_or(1.0),
"theme_profile.window_opacity",
)?,
background_image: file.background_image.or(file.image).map(expand_user_path),
image_opacity: unit_interval(
file.image_opacity.unwrap_or(DEFAULT_IMAGE_OPACITY),
"theme_profile.image_opacity",
)?,
terminal_opacity: unit_interval(
file.terminal_opacity.unwrap_or(DEFAULT_TERMINAL_OPACITY),
"theme_profile.terminal_opacity",
)?,
overlay_color: file
.overlay_color
.as_deref()
.map(TerminalTheme::parse_color)
.transpose()?,
overlay_opacity: unit_interval(
file.overlay_opacity.unwrap_or(0.0),
"theme_profile.overlay_opacity",
)?,
random_overlay: file.random_overlay.unwrap_or(false),
})
}
fn normalized_profile_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
Err("theme profile name is required".to_string())
} else {
Ok(name.to_string())
}
}
fn validate_unique_theme_profile_names(profiles: &[ThemeProfile]) -> Result<(), String> {
for (index, profile) in profiles.iter().enumerate() {
if profiles[..index]
.iter()
.any(|existing| existing.name.eq_ignore_ascii_case(&profile.name))
{
return Err(format!("duplicate theme profile name '{}'", profile.name));
}
}
Ok(())
}
fn profile_table(profile: &ThemeProfile) -> Table {
let mut table = Table::new();
table["name"] = value(profile.name.clone());
table["theme"] = value(profile.theme_name.clone());
if let Some(color) = &profile.cursor_background {
table["cursor_background"] = value(color.clone());
}
if let Some(color) = &profile.cursor_foreground {
table["cursor_foreground"] = value(color.clone());
}
table["cursor_match_overlay"] = value(profile.cursor_match_overlay);
table["unified_accent"] = value(profile.unified_accent);
table["font"] = value(profile.font.clone());
table["decorated"] = value(profile.decorated);
table["window_opacity"] = value(profile.window_opacity);
if let Some(image) = &profile.background_image {
table["background_image"] = value(image.to_string_lossy().to_string());
}
table["image_opacity"] = value(profile.image_opacity);
table["terminal_opacity"] = value(profile.terminal_opacity);
if let Some(color) = &profile.overlay_color {
table["overlay_color"] = value(color_to_hex(color));
}
table["overlay_opacity"] = value(profile.overlay_opacity);
table["random_overlay"] = value(profile.random_overlay);
table
}
fn document_theme_profiles(doc: &DocumentMut) -> Option<Vec<ThemeProfile>> {
let file = parse_config_text(&doc.to_string(), Path::new("<current-config>")).ok()?;
let catalog = ThemeCatalog::new(file.custom_theme).ok()?;
file.theme_profile
.into_iter()
.map(|profile| theme_profile_from_file(profile, &catalog))
.collect::<Result<Vec<_>, _>>()
.ok()
}
fn merge_theme_profile_tables(
doc: &mut DocumentMut,
profiles: &[ThemeProfile],
existing_profiles: Option<&[ThemeProfile]>,
) {
let existing_tables = doc
.get("theme_profile")
.and_then(Item::as_array_of_tables)
.map(|tables| tables.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let existing_profiles = existing_profiles.unwrap_or_default();
let mut used = vec![false; existing_profiles.len()];
let mut merged = ArrayOfTables::new();
for profile in profiles {
let existing_index = existing_profiles
.iter()
.enumerate()
.find(|(index, existing)| !used[*index] && *existing == profile)
.map(|(index, _)| index);
if let Some(index) = existing_index
&& let Some(table) = existing_tables.get(index)
{
used[index] = true;
merged.push(table.clone());
continue;
}
merged.push(profile_table(profile));
}
doc.as_table_mut().remove("theme_profile");
if !merged.is_empty() {
doc["theme_profile"] = Item::ArrayOfTables(merged);
}
}
fn document_palette_matches(theme: &Item, expected: &[String]) -> bool {
let Some(palette) = theme
.as_table()
.and_then(|table| table.get("palette"))
.and_then(Item::as_array)
else {
return false;
};
palette.len() == expected.len()
&& palette
.iter()
.zip(expected)
.all(|(current, expected)| current.as_str() == Some(expected.as_str()))
}
impl Default for WindowSettings {
fn default() -> Self {
Self {
title: "Lios".to_string(),
default_width: 900,
default_height: 620,
decorated: false,
renderer: RendererPreference::Cairo,
opacity: 1.0,
}
}
}
impl RendererPreference {
pub const GPU_MODE_NAMES: [&'static str; 3] = ["auto", "gl", "vulkan"];
pub fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "default" => Ok(Self::Auto),
"gl" | "opengl" | "gpu" => Ok(Self::Gl),
"vulkan" | "vk" => Ok(Self::Vulkan),
"cairo" | "software" | "cpu" => Ok(Self::Cairo),
_ => Err(format!(
"unknown renderer '{value}'. Use auto, gl, vulkan, or cairo"
)),
}
}
pub fn parse_gpu_mode(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "default" => Ok(Self::Auto),
"gl" | "opengl" => Ok(Self::Gl),
"vulkan" | "vk" => Ok(Self::Vulkan),
_ => Err(format!(
"unknown GPU mode '{value}'. Use auto, gl, or vulkan"
)),
}
}
pub fn from_gpu_settings(enabled: bool, mode: &str) -> Result<Self, String> {
if enabled {
Self::parse_gpu_mode(mode)
} else {
Ok(Self::Cairo)
}
}
pub fn gpu_enabled(self) -> bool {
!matches!(self, Self::Cairo)
}
pub fn gpu_mode_config(self) -> &'static str {
match self {
Self::Auto | Self::Cairo => "auto",
Self::Gl => "gl",
Self::Vulkan => "vulkan",
}
}
pub fn as_config(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Gl => "gl",
Self::Vulkan => "vulkan",
Self::Cairo => "cairo",
}
}
pub fn gsk_renderer(self) -> Option<&'static str> {
match self {
Self::Auto => Some("gl"),
Self::Gl => Some("gl"),
Self::Vulkan => Some("vulkan"),
Self::Cairo => Some("cairo"),
}
}
}
pub fn run_config_command(command: ConfigCommand) -> Result<String, String> {
match command {
ConfigCommand::Path => default_config_path()
.map(|path| format!("{}\n", path.display()))
.ok_or_else(|| {
"unable to determine config path; set HOME or XDG_CONFIG_HOME".to_string()
}),
ConfigCommand::Sample => Ok(sample_config().to_string()),
ConfigCommand::Init { path, force } => {
let path = config_path_for_write(path)?;
if path.exists() && !force {
return Err(format!(
"config already exists at '{}'; use --force to overwrite",
path.display()
));
}
write_text(&path, sample_config())?;
Ok(format!("wrote {}\n", path.display()))
}
ConfigCommand::Show { path } => {
let path = config_path_for_write(path)?;
fs::read_to_string(&path)
.map_err(|error| format!("failed to read config '{}': {error}", path.display()))
}
ConfigCommand::Themes { path } => list_themes(path),
ConfigCommand::Validate { path } => validate_config(path),
ConfigCommand::ThemeTemplate { name, base } => theme_template(&name, &base),
ConfigCommand::Set { path, key, value } => {
let path = config_path_for_write(path)?;
set_config_value(&path, &key, &value)?;
Ok(format!("set {key} in {}\n", path.display()))
}
}
}
fn list_themes(path: Option<PathBuf>) -> Result<String, String> {
let settings = AppSettings::load_for_inspection(path)?;
let names = settings.selectable_theme_names();
let custom = &names[THEME_NAMES.len()..];
let mut output = format!(
"Active theme: {}\n\nBuilt-in themes:\n",
settings.terminal.theme_name
);
for name in THEME_NAMES {
output.push_str(&format!(" {name}\n"));
}
output.push_str("\nConfigured custom themes:\n");
if custom.is_empty() {
output.push_str(" (none)\n");
} else {
for name in custom {
output.push_str(&format!(" {name}\n"));
}
}
Ok(output)
}
fn validate_config(path: Option<PathBuf>) -> Result<String, String> {
let path = config_path_for_write(path)?;
if !path.is_file() {
return Err(format!("config does not exist at '{}'", path.display()));
}
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default())?;
let custom_count = settings
.selectable_theme_names()
.len()
.saturating_sub(THEME_NAMES.len());
Ok(format!(
"valid {}\nactive theme: {}\ncustom themes: {custom_count}\nappearance profiles: {}\n",
path.display(),
settings.terminal.theme_name,
settings.theme_profiles.len()
))
}
fn theme_template(name: &str, base: &str) -> Result<String, String> {
ThemeCatalog::new(vec![CustomThemeDefinition {
name: name.to_string(),
base: Some(base.to_string()),
..CustomThemeDefinition::default()
}])?;
let canonical_base = TerminalTheme::canonical_name(base)?;
Ok(format!(
"[[custom_theme]]\nname = \"{name}\"\nbase = \"{canonical_base}\"\n# foreground = \"#f4e7d3\"\n# background = \"#100c0a\"\n# cursor_background = \"#d97745\"\n# cursor_foreground = \"#100c0a\"\n# Optional palette overrides require exactly 16 valid colors in ANSI order.\n"
))
}
pub fn sample_config() -> &'static str {
SAMPLE_CONFIG
}
pub fn default_config_path() -> Option<PathBuf> {
if let Ok(config_home) = env::var("XDG_CONFIG_HOME") {
if !config_home.is_empty() {
return Some(PathBuf::from(config_home).join("lios/config.toml"));
}
}
env::var("HOME")
.ok()
.filter(|home| !home.is_empty())
.map(|home| PathBuf::from(home).join(".config/lios/config.toml"))
}
pub fn config_path_for_write(path: Option<PathBuf>) -> Result<PathBuf, String> {
path.or_else(default_config_path)
.ok_or_else(|| "unable to determine config path; set HOME or XDG_CONFIG_HOME".to_string())
}
pub fn expand_user_path(path: PathBuf) -> PathBuf {
let Some(text) = path.to_str() else {
return path;
};
let Some(home) = env::var_os("HOME") else {
return path;
};
if text == "~" {
PathBuf::from(home)
} else if let Some(rest) = text.strip_prefix("~/") {
PathBuf::from(home).join(rest)
} else {
path
}
}
fn default_existing_config_path() -> Option<PathBuf> {
default_config_path().filter(|path| path.exists())
}
fn read_config_file(path: PathBuf) -> Result<FileConfig, String> {
let text = fs::read_to_string(&path)
.map_err(|error| format!("failed to read config '{}': {error}", path.display()))?;
parse_config_text(&text, &path)
}
fn set_config_value(path: &Path, key: &str, raw_value: &str) -> Result<(), String> {
let mut doc = read_document(path)?;
let (section, field) = config_key_path(key)?;
ensure_table(&mut doc, section);
let item = match (section, field) {
("theme", "name") => {
let file = parse_config_text(&doc.to_string(), path)?;
let catalog = ThemeCatalog::new(file.custom_theme)?;
let (canonical_name, _) = catalog.resolve(raw_value)?;
let theme = doc["theme"].as_table_mut().unwrap();
for override_name in [
"foreground",
"background",
"cursor",
"cursor_background",
"cursor_foreground",
"palette",
] {
theme.remove(override_name);
}
value(canonical_name)
}
("window", "renderer") => {
let renderer = renderer_from_config_value(key, raw_value)?;
value(renderer.as_config())
}
("theme", "foreground")
| ("theme", "background")
| ("theme", "cursor")
| ("theme", "cursor_background")
| ("theme", "cursor_foreground")
| ("background", "overlay_color") => {
TerminalTheme::parse_color(raw_value)?;
value(raw_value)
}
("window", "decorated")
| ("theme", "cursor_match_overlay")
| ("theme", "unified_accent")
| ("background", "random_overlay") => value(parse_bool(raw_value)?),
("terminal", "scrollback_lines") => {
let parsed = raw_value
.parse::<i64>()
.map_err(|_| format!("{key} requires an integer"))?;
value(bounded_scrollback(parsed, key)?)
}
("window", "width") | ("window", "height") => value(
raw_value
.parse::<i64>()
.map_err(|_| format!("{key} requires an integer"))?,
),
("window", "opacity")
| ("background", "image_opacity")
| ("background", "terminal_opacity")
| ("background", "overlay_opacity") => {
let parsed = raw_value
.parse::<f64>()
.map_err(|_| format!("{key} requires a number"))?;
if !(0.0..=1.0).contains(&parsed) {
return Err(format!("{key} must be between 0.0 and 1.0"));
}
value(parsed)
}
_ => value(raw_value),
};
set_item(&mut doc[section], field, item);
write_document(path, doc)
}
fn config_key_path(key: &str) -> Result<(&'static str, &'static str), String> {
match key.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"theme" | "theme.name" | "theme_name" => Ok(("theme", "name")),
"foreground" | "theme.foreground" => Ok(("theme", "foreground")),
"theme.background" => Ok(("theme", "background")),
"cursor" | "theme.cursor" => Ok(("theme", "cursor")),
"cursor_background" | "cursor_bg" | "theme.cursor_background" => {
Ok(("theme", "cursor_background"))
}
"cursor_foreground" | "cursor_fg" | "theme.cursor_foreground" => {
Ok(("theme", "cursor_foreground"))
}
"cursor_match_overlay"
| "cursor_match_accent"
| "match_accent"
| "theme.cursor_match_overlay" => Ok(("theme", "cursor_match_overlay")),
"unified_accent" | "theme.unified_accent" => Ok(("theme", "unified_accent")),
"font" | "terminal.font" => Ok(("terminal", "font")),
"scrollback" | "scrollback_lines" | "terminal.scrollback_lines" => {
Ok(("terminal", "scrollback_lines"))
}
"title" | "window.title" => Ok(("window", "title")),
"width" | "window.width" => Ok(("window", "width")),
"height" | "window.height" => Ok(("window", "height")),
"renderer"
| "gpu"
| "gpu_acceleration"
| "gpu_mode"
| "window.renderer"
| "window.gpu"
| "window.gpu_acceleration"
| "window.gpu_mode" => Ok(("window", "renderer")),
"opacity" | "window.opacity" | "window_opacity" | "total_opacity" | "master_opacity" => {
Ok(("window", "opacity"))
}
"topbar" | "titlebar" | "decorated" | "window.decorated" => Ok(("window", "decorated")),
"background_image" | "background.image" | "background_image_path" => {
Ok(("background", "image"))
}
"background_image_opacity" | "background.image_opacity" => {
Ok(("background", "image_opacity"))
}
"background_opacity" | "terminal_opacity" | "background.terminal_opacity" => {
Ok(("background", "terminal_opacity"))
}
"overlay_color" | "background.overlay_color" => Ok(("background", "overlay_color")),
"overlay_opacity" | "background.overlay_opacity" => Ok(("background", "overlay_opacity")),
"random_overlay" | "background.random_overlay" => Ok(("background", "random_overlay")),
_ => Err(format!(
"unknown config key '{key}'. Try gpu, gpu_mode, theme, font, cursor_background, cursor_foreground, cursor_match_overlay, unified_accent, opacity, background.image, background_opacity, overlay_color, overlay_opacity, random_overlay, or topbar"
)),
}
}
fn renderer_from_config_value(key: &str, raw_value: &str) -> Result<RendererPreference, String> {
match key.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"gpu" | "gpu_acceleration" | "window.gpu" | "window.gpu_acceleration" => {
if let Ok(enabled) = parse_bool(raw_value) {
Ok(if enabled {
RendererPreference::Auto
} else {
RendererPreference::Cairo
})
} else {
RendererPreference::parse(raw_value)
}
}
"gpu_mode" | "window.gpu_mode" => RendererPreference::parse_gpu_mode(raw_value),
_ => RendererPreference::parse(raw_value),
}
}
fn read_document(path: &Path) -> Result<DocumentMut, String> {
let text = if path.exists() {
fs::read_to_string(path)
.map_err(|error| format!("failed to read config '{}': {error}", path.display()))?
} else {
sample_config().to_string()
};
text.parse::<DocumentMut>()
.map_err(|error| format!("failed to parse config '{}': {error}", path.display()))
}
fn write_document(path: &Path, doc: DocumentMut) -> Result<(), String> {
let text = doc.to_string();
write_text(path, &text)
}
fn write_text(path: &Path, text: &str) -> Result<(), String> {
validate_config_text(text, path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create config directory '{}': {error}",
parent.display()
)
})?;
}
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let counter = CONFIG_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let temporary = parent.join(format!(
".lios-config-{}-{counter:x}-{nanos:x}.tmp",
std::process::id()
));
let mode = fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o777)
.unwrap_or(0o600);
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&temporary)?;
file.write_all(text.as_bytes())?;
file.flush()?;
file.sync_all()?;
fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|error| format!("failed to write config '{}': {error}", path.display()))
}
fn ensure_table(doc: &mut DocumentMut, section: &str) {
if !doc.get(section).is_some_and(Item::is_table) {
doc[section] = Item::Table(Table::new());
}
}
fn set_optional_string(section: &mut Item, field: &str, value_text: Option<&str>) {
let table = section.as_table_mut().expect("section was ensured above");
if let Some(value_text) = value_text {
let mut item = value(value_text);
preserve_value_decor(table.get(field), &mut item);
table[field] = item;
} else {
table.remove(field);
}
}
fn set_item(section: &mut Item, field: &str, mut item: Item) {
let table = section.as_table_mut().expect("section was ensured above");
preserve_value_decor(table.get(field), &mut item);
table[field] = item;
}
fn preserve_value_decor(previous: Option<&Item>, next: &mut Item) {
let Some(decor) = previous
.and_then(Item::as_value)
.map(|value| value.decor().clone())
else {
return;
};
if let Some(value) = next.as_value_mut() {
*value.decor_mut() = decor;
}
}
fn nonempty_config_color(value: Option<&str>) -> Option<String> {
value
.filter(|color| !color.trim().is_empty())
.map(str::to_string)
}
fn parse_config_text(text: &str, path: &Path) -> Result<FileConfig, String> {
toml::from_str(text)
.map_err(|error| format!("failed to parse config '{}': {error}", path.display()))
}
fn validate_config_text(text: &str, path: &Path) -> Result<(), String> {
let file = parse_config_text(text, path)?;
let mut settings = AppSettings::default();
settings.apply_file(file)
}
fn parse_bool(value: &str) -> Result<bool, String> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("'{value}' is not a boolean")),
}
}
fn unit_interval(value: f64, name: &str) -> Result<f64, String> {
if (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(format!("{name} must be between 0.0 and 1.0"))
}
}
fn positive_i32(value: i32, name: &str) -> Result<i32, String> {
if value > 0 {
Ok(value)
} else {
Err(format!("{name} must be greater than 0"))
}
}
fn bounded_scrollback(value: i64, name: &str) -> Result<i64, String> {
if !(0..=MAX_SCROLLBACK_LINES).contains(&value) {
Err(format!(
"{name} must be between 0 and {MAX_SCROLLBACK_LINES}"
))
} else {
Ok(value)
}
}
fn color_to_hex(color: >k::gdk::RGBA) -> String {
let red = (color.red().clamp(0.0, 1.0) * 255.0).round() as u8;
let green = (color.green().clamp(0.0, 1.0) * 255.0).round() as u8;
let blue = (color.blue().clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{red:02x}{green:02x}{blue:02x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_uses_reference_dark_default() {
let path = write_temp_config("");
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.terminal.theme_name, "default");
assert!(!settings.window.decorated);
assert_eq!(settings.window.renderer, RendererPreference::Cairo);
assert_eq!(settings.terminal.background.terminal_opacity, 1.0);
assert!(settings.terminal.background.random_overlay);
let _ = fs::remove_file(path);
}
#[test]
fn bronco_selects_ember_lightbar_and_cyan_accent() {
let mut settings = AppSettings::default();
settings.select_theme("solarized-light").unwrap();
assert_eq!(settings.terminal.theme_name, "bronco");
assert_eq!(settings.terminal.prompt.profile_name, "lightbar");
assert_eq!(
color_to_hex(&settings.terminal.background.overlay_color.unwrap()),
BRONCO_ACCENT
);
assert!(!settings.terminal.background.random_overlay);
assert_eq!(
settings.terminal.background.overlay_opacity,
DEFAULT_OVERLAY_OPACITY
);
}
#[test]
fn bronco_config_allows_explicit_accent_override() {
let path = write_temp_config(
r##"
[theme]
name = "bronco"
[background]
overlay_color = "#123456"
overlay_opacity = 0.0
random_overlay = true
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.terminal.prompt.profile_name, "lightbar");
assert_eq!(
color_to_hex(&settings.terminal.background.overlay_color.unwrap()),
"#123456"
);
assert_eq!(settings.terminal.background.overlay_opacity, 0.0);
assert!(settings.terminal.background.random_overlay);
let _ = fs::remove_file(path);
}
#[test]
fn image_config_with_opaque_terminal_uses_reference_shade() {
let path = write_temp_config(
r#"
[background]
image = "/tmp/wallpaper.jpg"
terminal_opacity = 1.00
"#,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
settings.terminal.background.terminal_opacity,
DEFAULT_IMAGE_TERMINAL_OPACITY
);
let _ = fs::remove_file(path);
}
#[test]
fn image_override_lowers_default_terminal_opacity() {
let path = write_temp_config("");
let overrides = ConfigOverrides {
background_image: Some(PathBuf::from("/tmp/wallpaper.jpg")),
..ConfigOverrides::default()
};
let settings = AppSettings::load(Some(path.clone()), overrides).unwrap();
assert_eq!(
settings.terminal.background.terminal_opacity,
DEFAULT_IMAGE_TERMINAL_OPACITY
);
let _ = fs::remove_file(path);
}
#[test]
fn window_opacity_loads_from_config() {
let path = write_temp_config(
r#"
[window]
opacity = 0.72
"#,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.window.opacity, 0.72);
let _ = fs::remove_file(path);
}
#[test]
fn window_opacity_override_applies() {
let path = write_temp_config("");
let overrides = ConfigOverrides {
window_opacity: Some(0.66),
..ConfigOverrides::default()
};
let settings = AppSettings::load(Some(path.clone()), overrides).unwrap();
assert_eq!(settings.window.opacity, 0.66);
let _ = fs::remove_file(path);
}
#[test]
fn paired_cursor_colors_load_from_config() {
let path = write_temp_config(
r##"
[theme]
name = "xfce"
cursor_background = "#112233"
cursor_foreground = "#445566"
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#112233"
);
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_foreground_color().unwrap()),
"#445566"
);
assert_eq!(
settings.terminal.cursor_background.as_deref(),
Some("#112233")
);
assert_eq!(
settings.terminal.cursor_foreground.as_deref(),
Some("#445566")
);
let _ = fs::remove_file(path);
}
#[test]
fn legacy_cursor_alias_loads_as_cursor_background() {
let path = write_temp_config(
r##"
[theme]
cursor = "#778899"
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#778899"
);
assert_eq!(
settings.terminal.cursor_background.as_deref(),
Some("#778899")
);
let _ = fs::remove_file(path);
}
#[test]
fn cursor_background_takes_precedence_over_legacy_cursor_alias() {
let path = write_temp_config(
r##"
[theme]
cursor = "#778899"
cursor_background = "#112233"
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#112233"
);
assert_eq!(
settings.terminal.cursor_background.as_deref(),
Some("#112233")
);
let _ = fs::remove_file(path);
}
#[test]
fn persist_writes_paired_cursor_colors() {
let path = write_temp_config("");
let mut settings = AppSettings::default();
settings.terminal.cursor_background = Some("#112233".to_string());
settings.terminal.cursor_foreground = Some("#445566".to_string());
settings.terminal.cursor_match_overlay = true;
settings.persist(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("cursor_background = \"#112233\""));
assert!(text.contains("cursor_foreground = \"#445566\""));
assert!(text.contains("cursor_match_overlay = true"));
let _ = fs::remove_file(path);
}
#[test]
fn persist_does_not_write_runtime_prompt_profile() {
let path = write_temp_config("");
let mut settings = AppSettings::default();
settings.terminal.prompt = crate::terminal::PromptConfig::named("lightbar").unwrap();
settings.persist(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(!text.contains("prompt_profile"));
assert!(!text.contains("lightbar"));
let _ = fs::remove_file(path);
}
#[test]
fn legacy_terminal_prompt_profile_is_ignored_and_preserved() {
let path = write_temp_config(
"[terminal]\nfont = \"Monospace 12\"\nprompt_profile = \"lightbar\" # legacy\n",
);
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
settings.terminal.prompt,
crate::terminal::PromptConfig::default()
);
settings.terminal.font = "Monospace 14".to_string();
settings.persist(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("prompt_profile = \"lightbar\" # legacy"));
assert!(text.contains("font = \"Monospace 14\""));
let _ = fs::remove_file(path);
}
#[test]
fn cursor_match_overlay_loads_from_config() {
let path = write_temp_config(
r##"
[theme]
cursor_match_overlay = true
[background]
overlay_opacity = 0.0
random_overlay = false
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.cursor_match_overlay);
assert!(!settings.terminal.unified_accent);
assert!(!settings.terminal.background.random_overlay);
assert_eq!(settings.terminal.background.overlay_opacity, 0.0);
let _ = fs::remove_file(path);
}
#[test]
fn unified_accent_loads_without_overriding_cli_overlay_settings() {
let path = write_temp_config(
r#"
[theme]
unified_accent = true
[background]
overlay_opacity = 0.24
random_overlay = true
"#,
);
let overrides = ConfigOverrides {
overlay_opacity: Some(0.0),
random_overlay: Some(false),
..ConfigOverrides::default()
};
let settings = AppSettings::load(Some(path.clone()), overrides).unwrap();
assert!(!settings.terminal.unified_accent);
assert!(settings.terminal.cursor_match_overlay);
assert!(!settings.terminal.background.random_overlay);
assert_eq!(settings.terminal.background.overlay_opacity, 0.0);
let _ = fs::remove_file(path);
}
#[test]
fn random_overlay_false_loads_from_config() {
let path = write_temp_config(
r#"
[background]
random_overlay = false
"#,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(!settings.terminal.background.random_overlay);
let _ = fs::remove_file(path);
}
#[test]
fn theme_profiles_load_from_config() {
let path = write_temp_config(
r##"
[[theme_profile]]
name = "Dracula Glass"
theme = "dracula"
cursor_background = "#bd93f9"
cursor_foreground = "#282a36"
cursor_match_overlay = true
font = "Monospace 13"
decorated = true
window_opacity = 0.91
background_image = "~/wallpaper.png"
image_opacity = 0.8
terminal_opacity = 0.5
overlay_color = "#bd93f9"
overlay_opacity = 0.12
random_overlay = false
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.theme_profiles.len(), 1);
let profile = &settings.theme_profiles[0];
assert_eq!(profile.name, "Dracula Glass");
assert_eq!(profile.theme_name, "dracula");
assert_eq!(profile.cursor_background.as_deref(), Some("#bd93f9"));
assert!(profile.cursor_match_overlay);
assert!(!profile.unified_accent);
assert_eq!(profile.font, "Monospace 13");
assert!(profile.decorated);
assert_eq!(profile.window_opacity, 0.91);
assert_eq!(profile.terminal_opacity, 0.5);
assert_eq!(color_to_hex(&profile.overlay_color.unwrap()), "#bd93f9");
let mut applied = AppSettings::default();
profile.apply_to(&mut applied).unwrap();
assert!(applied.terminal.cursor_match_overlay);
assert!(!applied.terminal.unified_accent);
let _ = fs::remove_file(path);
}
#[test]
fn persist_writes_theme_profiles() {
let path = write_temp_config("");
let mut settings = AppSettings::default();
settings.terminal.theme_name = "dracula".to_string();
settings.terminal.theme = TerminalTheme::named("dracula").unwrap();
settings.terminal.background.overlay_color =
Some(TerminalTheme::parse_color("#bd93f9").unwrap());
settings.terminal.background.overlay_opacity = 0.12;
settings.terminal.set_unified_accent(true);
settings
.upsert_theme_profile(ThemeProfile::from_settings("Dracula Glass", &settings).unwrap());
settings.persist(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("[[theme_profile]]"));
assert!(text.contains("name = \"Dracula Glass\""));
assert!(text.contains("theme = \"dracula\""));
assert!(text.contains("overlay_color = \"#bd93f9\""));
assert!(text.contains("unified_accent = true"));
let _ = fs::remove_file(path);
}
#[test]
fn theme_profile_apply_updates_appearance_settings() {
let mut settings = AppSettings::default();
let mut profile = ThemeProfile::from_settings("Dracula", &settings).unwrap();
profile.theme_name = "dracula".to_string();
profile.font = "Monospace 13".to_string();
profile.window_opacity = 0.91;
profile.overlay_color = Some(TerminalTheme::parse_color("#bd93f9").unwrap());
profile.overlay_opacity = 0.12;
profile.unified_accent = true;
profile.apply_to(&mut settings).unwrap();
assert_eq!(settings.terminal.theme_name, "dracula");
assert_eq!(settings.terminal.font, "Monospace 13");
assert_eq!(settings.window.opacity, 0.91);
assert!(settings.terminal.unified_accent);
assert!(settings.terminal.cursor_match_overlay);
assert_eq!(
color_to_hex(&settings.terminal.background.overlay_color.unwrap()),
"#bd93f9"
);
}
#[test]
fn theme_profile_upsert_replaces_same_name() {
let mut settings = AppSettings::default();
let mut first = ThemeProfile::from_settings("Night", &settings).unwrap();
first.theme_name = "default".to_string();
let mut second = first.clone();
second.theme_name = "dracula".to_string();
settings.upsert_theme_profile(first);
settings.upsert_theme_profile(second);
assert_eq!(settings.theme_profiles.len(), 1);
assert_eq!(settings.theme_profiles[0].theme_name, "dracula");
}
#[test]
fn renderer_gpu_settings_map_to_expected_backends() {
assert_eq!(
RendererPreference::from_gpu_settings(false, "vulkan").unwrap(),
RendererPreference::Cairo
);
assert_eq!(
RendererPreference::from_gpu_settings(true, "auto").unwrap(),
RendererPreference::Auto
);
assert_eq!(
RendererPreference::from_gpu_settings(true, "gl").unwrap(),
RendererPreference::Gl
);
assert_eq!(
RendererPreference::from_gpu_settings(true, "vulkan").unwrap(),
RendererPreference::Vulkan
);
assert!(!RendererPreference::Cairo.gpu_enabled());
assert!(RendererPreference::Vulkan.gpu_enabled());
assert_eq!(RendererPreference::Cairo.gpu_mode_config(), "auto");
assert_eq!(RendererPreference::Auto.gsk_renderer(), Some("gl"));
assert_eq!(RendererPreference::Vulkan.gsk_renderer(), Some("vulkan"));
assert_eq!(RendererPreference::Cairo.gsk_renderer(), Some("cairo"));
}
#[test]
fn config_set_gpu_alias_writes_renderer() {
let path = write_temp_config(sample_config());
set_config_value(&path, "gpu", "on").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.window.renderer, RendererPreference::Auto);
set_config_value(&path, "gpu", "off").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.window.renderer, RendererPreference::Cairo);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_gpu_mode_writes_renderer() {
let path = write_temp_config(sample_config());
set_config_value(&path, "gpu_mode", "vulkan").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.window.renderer, RendererPreference::Vulkan);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_opacity_writes_window_opacity() {
let path = write_temp_config(sample_config());
set_config_value(&path, "opacity", "0.73").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.window.opacity, 0.73);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_background_opacity_writes_terminal_shade() {
let path = write_temp_config(sample_config());
set_config_value(&path, "background_opacity", "0.41").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.terminal.background.terminal_opacity, 0.41);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_writes_paired_cursor_colors() {
let path = write_temp_config(sample_config());
set_config_value(&path, "cursor_background", "#112233").unwrap();
set_config_value(&path, "cursor_foreground", "#445566").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#112233"
);
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_foreground_color().unwrap()),
"#445566"
);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_writes_cursor_match_overlay() {
let path = write_temp_config(sample_config());
set_config_value(&path, "cursor_match_overlay", "on").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.cursor_match_overlay);
let _ = fs::remove_file(path);
}
#[test]
fn config_set_writes_unified_accent() {
let path = write_temp_config(sample_config());
set_config_value(&path, "unified_accent", "on").unwrap();
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.unified_accent);
assert!(settings.terminal.cursor_match_overlay);
assert_eq!(
settings.terminal.background.overlay_opacity,
crate::terminal::DEFAULT_OVERLAY_OPACITY
);
let _ = fs::remove_file(path);
}
#[test]
fn custom_theme_loads_before_active_overrides() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"COPPER-NIGHT\"\nbackground = \"#010203\"\ncursor_background = \"\"\n",
custom_theme_toml()
));
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(settings.terminal.theme_name, "copper-night");
assert_eq!(
color_to_hex(&settings.terminal.theme.background_color()),
"#010203"
);
assert!(settings.terminal.theme.cursor_background_color().is_none());
assert_eq!(
settings.selectable_theme_names().last().map(String::as_str),
Some("copper-night")
);
let _ = fs::remove_file(path);
}
#[test]
fn custom_theme_tables_reject_unknown_fields() {
let text = custom_theme_toml().replace(
"base = \"default\"",
"base = \"default\"\nunsupported = true",
);
let path = write_temp_config(&text);
let error = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap_err();
assert!(error.contains("unknown field `unsupported`"));
let _ = fs::remove_file(path);
}
#[test]
fn unrelated_persist_preserves_custom_comments_sections_and_active_colors() {
let original = format!(
"# authored header\n{}\n\n[theme]\nname = \"copper-night\"\nforeground = \"#abcdef\" # keep foreground\nbackground = \"#010203\"\npalette = {}\n\n[plugin_data]\n# plugin comment\nenabled = true\n",
custom_theme_toml(),
palette_toml()
);
let path = write_temp_config(&original);
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
settings.terminal.font = "Monospace 14".to_string();
settings.persist(&path).unwrap();
let persisted = fs::read_to_string(&path).unwrap();
assert!(persisted.contains("# authored header"));
assert!(persisted.contains("# custom theme comment"));
assert!(persisted.contains("# plugin comment"));
assert!(persisted.contains("[plugin_data]"));
assert!(persisted.contains("foreground = \"#abcdef\" # keep foreground"));
assert!(persisted.contains("background = \"#010203\""));
let reloaded = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(reloaded.terminal.font, "Monospace 14");
assert_eq!(
reloaded.theme_catalog.selectable_names().len(),
THEME_NAMES.len() + 1
);
let _ = fs::remove_file(path);
}
#[test]
fn first_persist_does_not_inject_sample_custom_themes() {
let path = write_temp_config("");
fs::remove_file(&path).unwrap();
AppSettings::default().persist(&path).unwrap();
let persisted = fs::read_to_string(&path).unwrap();
assert!(!persisted.contains("[[custom_theme]]"));
assert!(!persisted.contains("midnight-copper"));
let reloaded = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(
reloaded.selectable_theme_names().len(),
crate::theme::THEME_NAMES.len()
);
let _ = fs::remove_file(path);
}
#[test]
fn unrelated_persist_preserves_palette_and_profile_comments() {
let original = format!(
"{}\n[theme]\nname = \"copper-night\"\npalette = [\n \"#000000\", # authored palette comment\n \"#110000\", \"#001100\", \"#111100\", \"#000011\", \"#110011\", \"#001111\", \"#aaaaaa\",\n \"#555555\", \"#ff5555\", \"#55ff55\", \"#ffff55\", \"#5555ff\", \"#ff55ff\", \"#55ffff\", \"#ffffff\",\n]\n\n[[theme_profile]]\n# authored profile comment\nname = \"Copper Look\"\ntheme = \"copper-night\"\nowner_note = \"preserve me\"\n",
custom_theme_toml()
);
let path = write_temp_config(&original);
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
settings.terminal.font = "Monospace 15".to_string();
settings.persist(&path).unwrap();
let persisted = fs::read_to_string(&path).unwrap();
assert!(persisted.contains("# authored palette comment"));
assert!(persisted.contains("# authored profile comment"));
assert!(persisted.contains("owner_note = \"preserve me\""));
let _ = fs::remove_file(path);
}
#[test]
fn updating_one_profile_preserves_other_authored_profile_fields() {
let original = format!(
"{}\n[theme]\nname = \"copper-night\"\n\n[[theme_profile]]\nname = \"First\"\ntheme = \"copper-night\"\nfont = \"Monospace 12\"\nfirst_note = \"may be replaced\"\n\n[[theme_profile]]\n# preserve second profile comment\nname = \"Second\"\ntheme = \"copper-night\"\nfont = \"Monospace 13\"\nsecond_note = \"preserve me\"\n",
custom_theme_toml()
);
let path = write_temp_config(&original);
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
settings.theme_profiles[0].font = "Monospace 18".to_string();
settings.persist(&path).unwrap();
let persisted = fs::read_to_string(&path).unwrap();
assert!(persisted.contains("# preserve second profile comment"));
assert!(persisted.contains("second_note = \"preserve me\""));
let reloaded = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(reloaded.theme_profiles[0].font, "Monospace 18");
assert_eq!(reloaded.theme_profiles[1].font, "Monospace 13");
let _ = fs::remove_file(path);
}
#[test]
fn deliberate_theme_selection_clears_only_active_theme_overrides() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"xfce\"\nforeground = \"#abcdef\"\nbackground = \"#010203\"\ncursor_background = \"#112233\"\npalette = {}\n\n[unrelated]\nvalue = 7\n",
custom_theme_toml(),
palette_toml()
));
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
settings.select_theme("Copper-Night").unwrap();
settings.persist(&path).unwrap();
let doc = read_document(&path).unwrap();
let theme = doc["theme"].as_table().unwrap();
assert_eq!(theme["name"].as_str(), Some("copper-night"));
for field in [
"foreground",
"background",
"cursor_background",
"cursor_foreground",
"palette",
] {
assert!(!theme.contains_key(field), "unexpected {field}");
}
assert_eq!(doc["unrelated"]["value"].as_integer(), Some(7));
assert!(doc["custom_theme"].is_array_of_tables());
let _ = fs::remove_file(path);
}
#[test]
fn config_set_resolves_custom_theme_and_invalid_value_is_atomic() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"xfce\"\nbackground = \"#010203\"\n",
custom_theme_toml()
));
set_config_value(&path, "theme", "COPPER-NIGHT").unwrap();
let after_valid = fs::read_to_string(&path).unwrap();
assert!(after_valid.contains("name = \"copper-night\""));
let doc = read_document(&path).unwrap();
assert!(!doc["theme"].as_table().unwrap().contains_key("background"));
let error = set_config_value(&path, "theme", "missing-theme").unwrap_err();
assert!(error.contains("unknown theme 'missing-theme'"));
assert_eq!(fs::read_to_string(&path).unwrap(), after_valid);
let _ = fs::remove_file(path);
}
#[test]
fn cli_override_resolves_custom_theme_from_selected_config() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"xfce\"\nbackground = \"#010203\"\n",
custom_theme_toml()
));
let settings = AppSettings::load(
Some(path.clone()),
ConfigOverrides {
theme_name: Some("COPPER-NIGHT".to_string()),
..ConfigOverrides::default()
},
)
.unwrap();
assert_eq!(settings.terminal.theme_name, "copper-night");
assert_eq!(
color_to_hex(&settings.terminal.theme.background_color()),
"#100c0a"
);
assert_eq!(settings.active_theme_overrides, ThemeOverrides::default());
let _ = fs::remove_file(path);
}
#[test]
fn theme_listing_reports_active_builtins_and_custom_themes() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"copper-night\"\n",
custom_theme_toml()
));
let output = list_themes(Some(path.clone())).unwrap();
assert!(output.contains("Active theme: copper-night"));
assert!(output.contains("Built-in themes:\n default"));
assert!(!output.contains("\n xfce\n"));
assert!(!output.contains("\n xterm\n"));
assert!(output.contains("Configured custom themes:\n copper-night"));
let _ = fs::remove_file(path);
}
#[test]
fn config_validation_reports_counts_and_rejects_missing_files() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"copper-night\"\n\n[[theme_profile]]\nname = \"Work\"\ntheme = \"copper-night\"\n",
custom_theme_toml()
));
let output = validate_config(Some(path.clone())).unwrap();
assert!(output.starts_with(&format!("valid {}\n", path.display())));
assert!(output.contains("active theme: copper-night"));
assert!(output.contains("custom themes: 1"));
assert!(output.contains("appearance profiles: 1"));
fs::remove_file(&path).unwrap();
assert!(
validate_config(Some(path))
.unwrap_err()
.contains("does not exist")
);
}
#[test]
fn config_validation_rejects_unknown_core_settings() {
let path = write_temp_config("[window]\nopacit = 0.5\n");
let original = fs::read_to_string(&path).unwrap();
let error = validate_config(Some(path.clone())).unwrap_err();
assert!(error.contains("unknown field `opacit`"));
assert_eq!(fs::read_to_string(&path).unwrap(), original);
let _ = fs::remove_file(path);
}
#[test]
fn theme_template_is_valid_and_canonicalizes_the_base() {
let output = theme_template("quiet-night", "solarized_dark").unwrap();
assert!(output.contains("name = \"quiet-night\""));
assert!(output.contains("base = \"solarized-dark\""));
let definition = format!("{output}\n[theme]\nname = \"quiet-night\"\n");
validate_config_text(&definition, Path::new("template.toml")).unwrap();
assert!(theme_template("Bad Name", "xfce").is_err());
assert!(theme_template("xfce", "xfce").is_err());
assert!(theme_template("quiet-night", "missing").is_err());
}
#[test]
fn cursor_override_removal_restores_custom_theme_cursor() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"copper-night\"\ncursor_background = \"\"\ncursor_foreground = \"\"\n",
custom_theme_toml()
));
let mut settings =
AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.theme.cursor_background_color().is_none());
assert!(settings.terminal.theme.cursor_foreground_color().is_none());
settings.set_theme_cursor_overrides(None, None).unwrap();
settings.persist(&path).unwrap();
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#d97745"
);
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_foreground_color().unwrap()),
"#100c0a"
);
let doc = read_document(&path).unwrap();
assert!(
!doc["theme"]
.as_table()
.unwrap()
.contains_key("cursor_background")
);
assert!(
!doc["theme"]
.as_table()
.unwrap()
.contains_key("cursor_foreground")
);
let _ = fs::remove_file(path);
}
#[test]
fn custom_theme_profile_loads_applies_and_persists() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"xfce\"\n\n[[theme_profile]]\nname = \"Copper Look\"\ntheme = \"COPPER-NIGHT\"\ncursor_background = \"\"\n",
custom_theme_toml()
));
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
let profile = settings.theme_profiles[0].clone();
assert_eq!(profile.theme_name, "copper-night");
let mut applied = settings.clone();
profile.apply_to(&mut applied).unwrap();
assert_eq!(applied.terminal.theme_name, "copper-night");
assert!(applied.terminal.theme.cursor_background_color().is_none());
applied.persist(&path).unwrap();
let persisted = fs::read_to_string(&path).unwrap();
assert!(persisted.contains("theme = \"COPPER-NIGHT\""));
assert!(persisted.contains("cursor_background = \"\""));
assert!(persisted.contains("[[custom_theme]]"));
let reloaded = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert_eq!(reloaded.theme_profiles[0].theme_name, "copper-night");
let _ = fs::remove_file(path);
}
#[test]
fn invalid_custom_profile_reference_fails_clearly() {
let path = write_temp_config(&format!(
"{}\n[[theme_profile]]\nname = \"Broken\"\ntheme = \"missing-theme\"\n",
custom_theme_toml()
));
let error = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap_err();
assert!(error.contains("theme profile 'Broken'"));
assert!(error.contains("unknown theme 'missing-theme'"));
let _ = fs::remove_file(path);
}
#[test]
fn duplicate_profile_names_are_rejected_case_insensitively() {
let path = write_temp_config(
"[[theme_profile]]\nname = \"Work\"\ntheme = \"xfce\"\n\n[[theme_profile]]\nname = \"work\"\ntheme = \"ocean\"\n",
);
let error = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap_err();
assert!(error.contains("duplicate theme profile name 'work'"));
let _ = fs::remove_file(path);
}
#[test]
fn custom_default_cursor_is_not_persisted_as_an_active_override() {
let path = write_temp_config(&format!(
"{}\n[theme]\nname = \"copper-night\"\n",
custom_theme_toml()
));
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.theme.cursor_background_color().is_some());
assert!(settings.terminal.cursor_background.is_none());
settings.persist(&path).unwrap();
let doc = read_document(&path).unwrap();
assert!(
!doc["theme"]
.as_table()
.unwrap()
.contains_key("cursor_background")
);
let _ = fs::remove_file(path);
}
#[test]
fn validation_failure_leaves_original_config_unchanged() {
let path = write_temp_config("# original\n[theme]\nname = \"xfce\"\n");
let original = fs::read_to_string(&path).unwrap();
let mut settings = AppSettings::default();
let mut profile = ThemeProfile::from_settings("Broken", &settings).unwrap();
profile.theme_name = "missing-theme".to_string();
settings.theme_profiles.push(profile);
let error = settings.persist(&path).unwrap_err();
assert!(error.contains("unknown theme 'missing-theme'"));
assert_eq!(fs::read_to_string(&path).unwrap(), original);
let _ = fs::remove_file(path);
}
fn custom_theme_toml() -> &'static str {
r##"# custom theme comment
[[custom_theme]]
name = "copper-night"
base = "default"
foreground = "#f4e7d3"
background = "#100c0a"
cursor_background = "#d97745"
cursor_foreground = "#100c0a"
palette = ["#100c0a", "#c65f46", "#7f9f5f", "#d4a656", "#6688aa", "#a8759b", "#5f9f9a", "#d8c8b8", "#5b504a", "#e07a5f", "#9fbd78", "#f2cc72", "#82a7c9", "#c394b7", "#7fc2ba", "#fff4e6"]
"##
}
fn palette_toml() -> &'static str {
r##"["#000000", "#110000", "#001100", "#111100", "#000011", "#110011", "#001111", "#aaaaaa", "#555555", "#ff5555", "#55ff55", "#ffff55", "#5555ff", "#ff55ff", "#55ffff", "#ffffff"]"##
}
fn write_temp_config(contents: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("lios-test-{}-{nonce}.toml", std::process::id()));
fs::write(&path, contents).unwrap();
path
}
}