use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, value};
use crate::terminal::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
[terminal]
font = "Monospace 12"
scrollback_lines = 1000
[theme]
name = "dark-pastels"
# foreground = "#f8fafc"
# background = "#020617"
# cursor = "#a78bfa"
[background]
# image = "/home/you/Pictures/wallpaper.jpg"
image_opacity = 0.70
terminal_opacity = 0.82
overlay_color = "#7c3aed"
overlay_opacity = 0.18
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,
}
#[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>,
}
#[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>,
}
#[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["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> {
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(font) = file.terminal.font {
self.terminal.font = font;
}
if let Some(lines) = file.terminal.scrollback_lines {
self.terminal.scrollback_lines = non_negative_i64(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(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;
}
Ok(())
}
fn apply_overrides(&mut self, overrides: ConfigOverrides) -> Result<(), String> {
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(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;
}
Ok(())
}
}
impl Default for WindowSettings {
fn default() -> Self {
Self {
title: "Lios".to_string(),
default_width: 900,
default_height: 620,
decorated: true,
renderer: RendererPreference::Auto,
}
}
}
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())
}
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)?),
("window", "width") | ("window", "height") | ("terminal", "scrollback_lines") => value(
raw_value
.parse::<i64>()
.map_err(|_| format!("{key} requires an integer"))?,
),
("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")),
"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, 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 non_negative_i64(value: i64, name: &str) -> Result<i64, String> {
if value >= 0 {
Ok(value)
} else {
Err(format!("{name} must be 0 or greater"))
}
}
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}")
}