use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value};
use crate::resources::ensure_default_background;
use crate::terminal::{
DEFAULT_IMAGE_OPACITY, DEFAULT_IMAGE_TERMINAL_OPACITY, DEFAULT_TERMINAL_OPACITY,
MAX_SCROLLBACK_LINES, TerminalConfig,
};
use crate::theme::TerminalTheme;
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
[theme]
name = "xfce"
# 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 the effective accent overlay when true
# 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>,
}
#[derive(Debug, Clone)]
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 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>,
},
Set {
path: Option<PathBuf>,
key: String,
value: String,
},
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct FileConfig {
window: FileWindow,
terminal: FileTerminal,
theme: FileTheme,
background: FileBackground,
theme_profile: Vec<FileThemeProfile>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
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)]
struct FileTerminal {
font: Option<String>,
scrollback_lines: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
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>,
palette: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
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>,
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> {
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 {
settings.apply_packaged_default_background();
}
settings.apply_overrides(overrides)?;
Ok(settings)
}
pub fn persist(&self, path: &Path) -> Result<(), String> {
let mut doc = DocumentMut::new();
doc["window"] = Item::Table(Table::new());
doc["window"]["title"] = value(self.window.title.clone());
doc["window"]["width"] = value(i64::from(self.window.default_width));
doc["window"]["height"] = value(i64::from(self.window.default_height));
doc["window"]["decorated"] = value(self.window.decorated);
doc["window"]["renderer"] = value(self.window.renderer.as_config());
doc["window"]["opacity"] = value(self.window.opacity);
doc["terminal"] = Item::Table(Table::new());
doc["terminal"]["font"] = value(self.terminal.font.clone());
doc["terminal"]["scrollback_lines"] = value(self.terminal.scrollback_lines);
doc["theme"] = Item::Table(Table::new());
doc["theme"]["name"] = value(self.terminal.theme_name.clone());
if let Some(color) = &self.terminal.cursor_background {
doc["theme"]["cursor_background"] = value(color.clone());
}
if let Some(color) = &self.terminal.cursor_foreground {
doc["theme"]["cursor_foreground"] = value(color.clone());
}
doc["theme"]["cursor_match_overlay"] = value(self.terminal.cursor_match_overlay);
doc["background"] = Item::Table(Table::new());
if let Some(image) = &self.terminal.background.image {
doc["background"]["image"] = value(image.to_string_lossy().to_string());
}
doc["background"]["image_opacity"] = value(self.terminal.background.image_opacity);
doc["background"]["terminal_opacity"] = value(self.terminal.background.terminal_opacity);
if let Some(color) = &self.terminal.background.overlay_color {
doc["background"]["overlay_color"] = value(color_to_hex(color));
}
doc["background"]["overlay_opacity"] = value(self.terminal.background.overlay_opacity);
doc["background"]["random_overlay"] = value(self.terminal.background.random_overlay);
if !self.theme_profiles.is_empty() {
let mut profiles = ArrayOfTables::new();
for profile in &self.theme_profiles {
profiles.push(profile_table(profile));
}
doc["theme_profile"] = Item::ArrayOfTables(profiles);
}
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> {
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")?;
}
if let Some(name) = file.theme.name {
let canonical_name = TerminalTheme::canonical_name(&name)?;
self.terminal.theme_name = canonical_name.to_string();
self.terminal.theme = TerminalTheme::named(canonical_name)?;
}
let cursor_background = file
.theme
.cursor_background
.as_deref()
.or(file.theme.cursor.as_deref());
self.terminal.cursor_background = cursor_background.map(|color| color.to_string());
self.terminal.cursor_foreground = file.theme.cursor_foreground.clone();
if let Some(cursor_match_overlay) = file.theme.cursor_match_overlay {
self.terminal.cursor_match_overlay = cursor_match_overlay;
}
self.terminal.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(),
)?;
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 file.background.random_overlay == Some(true) {
self.terminal.background.random_overlay = 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;
}
self.theme_profiles = file
.theme_profile
.into_iter()
.map(theme_profile_from_file)
.collect::<Result<Vec<_>, _>>()?;
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 {
let canonical_name = TerminalTheme::canonical_name(&theme_name)?;
self.terminal.theme_name = canonical_name.to_string();
self.terminal.theme = TerminalTheme::named(canonical_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(())
}
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.terminal.cursor_background.clone(),
cursor_foreground: settings.terminal.cursor_foreground.clone(),
cursor_match_overlay: settings.terminal.cursor_match_overlay,
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> {
let canonical_name = TerminalTheme::canonical_name(&self.theme_name)?;
let mut theme = TerminalTheme::named(canonical_name)?;
theme.apply_overrides(
None,
None,
self.cursor_background.as_deref(),
self.cursor_foreground.as_deref(),
None,
)?;
settings.window.decorated = self.decorated;
settings.window.opacity = self.window_opacity;
settings.terminal.theme_name = canonical_name.to_string();
settings.terminal.cursor_background = self.cursor_background.clone();
settings.terminal.cursor_foreground = self.cursor_foreground.clone();
settings.terminal.cursor_match_overlay = self.cursor_match_overlay;
settings.terminal.font = self.font.clone();
settings.terminal.theme = theme;
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;
Ok(())
}
}
fn theme_profile_from_file(file: FileThemeProfile) -> Result<ThemeProfile, String> {
let theme_name = file
.theme
.or(file.theme_name)
.unwrap_or_else(|| "xfce".to_string());
let canonical_name = TerminalTheme::canonical_name(&theme_name)?;
let mut theme = TerminalTheme::named(canonical_name)?;
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.to_string(),
cursor_background: file.cursor_background,
cursor_foreground: file.cursor_foreground,
cursor_match_overlay: file.cursor_match_overlay.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 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["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
}
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::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()))
}
}
}
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()))?;
toml::from_str(&text)
.map_err(|error| format!("failed to parse config '{}': {error}", path.display()))
}
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);
doc[section][field] = match (section, field) {
("theme", "name") => {
let canonical_name = TerminalTheme::canonical_name(raw_value)?;
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")
| ("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),
};
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")),
"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, 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> {
write_text(path, &doc.to_string())
}
fn write_text(path: &Path, text: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create config directory '{}': {error}",
parent.display()
)
})?;
}
fs::write(path, text)
.map_err(|error| format!("failed to write config '{}': {error}", path.display()))
}
fn ensure_table(doc: &mut DocumentMut, section: &str) {
if !doc[section].is_table() {
doc[section] = Item::Table(Table::new());
}
}
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::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[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, "xfce");
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 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 cursor_match_overlay_loads_from_config() {
let path = write_temp_config(
r##"
[theme]
cursor_match_overlay = true
"##,
);
let settings = AppSettings::load(Some(path.clone()), ConfigOverrides::default()).unwrap();
assert!(settings.terminal.cursor_match_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_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 _ = 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
.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\""));
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.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_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 = "xfce".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);
}
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
}
}