use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, value};
use crate::terminal::{DEFAULT_IMAGE_TERMINAL_OPACITY, MAX_SCROLLBACK_LINES, TerminalConfig};
use crate::theme::TerminalTheme;
const SAMPLE_CONFIG: &str = r##"[window]
title = "Lios"
width = 960
height = 640
decorated = true
renderer = "auto" # auto, gl, vulkan, cairo
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"
# cursor = "#ffffff"
[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 = false
"##;
#[derive(Debug, Clone, Default)]
pub struct AppSettings {
pub window: WindowSettings,
pub terminal: TerminalConfig,
}
#[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,
}
#[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>,
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>,
}
impl AppSettings {
pub fn load(config_path: Option<PathBuf>, overrides: ConfigOverrides) -> Result<Self, String> {
let mut settings = Self::default();
if let Some(path) = config_path.or_else(default_existing_config_path) {
let file = read_config_file(path)?;
settings.apply_file(file)?;
}
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());
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);
write_document(path, doc)
}
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)?;
}
self.terminal.theme.apply_overrides(
file.theme.foreground.as_deref(),
file.theme.background.as_deref(),
file.theme.cursor.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 let Some(random_overlay) = file.background.random_overlay {
self.terminal.background.random_overlay = random_overlay;
}
if image_was_configured
&& (!terminal_opacity_was_configured
|| self.terminal.background.terminal_opacity >= 0.999)
{
self.terminal.background.terminal_opacity = DEFAULT_IMAGE_TERMINAL_OPACITY;
}
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(())
}
}
impl Default for WindowSettings {
fn default() -> Self {
Self {
title: "Lios".to_string(),
default_width: 900,
default_height: 620,
decorated: true,
renderer: RendererPreference::Auto,
opacity: 1.0,
}
}
}
impl RendererPreference {
pub const NAMES: [&'static str; 4] = ["auto", "gl", "vulkan", "cairo"];
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 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 => None,
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") => {
RendererPreference::parse(raw_value)?;
value(raw_value)
}
("background", "overlay_color") => {
TerminalTheme::parse_color(raw_value)?;
value(raw_value)
}
("window", "decorated") | ("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")),
"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" | "window.renderer" => 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 theme, font, opacity, background.image, background_opacity, overlay_color, overlay_opacity, random_overlay, or topbar"
)),
}
}
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_eq!(settings.terminal.background.terminal_opacity, 1.0);
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 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);
}
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
}
}