// SPDX-License-Identifier: GPL-2.0-only
//
// rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator.
//
// Copyright (C) 2024 rs-mrxvt contributors
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see .
//! Configuration loader.
//!
//! Format: TOML. Stored at `~/.config/rs-mrxvt/config.toml` by default
//! (overridable with `--config` or `$MRXVT_CONFIG`).
//!
//! The original mrxvt used X-resources; we deliberately use TOML because
//! (a) it's distro-agnostic and doesn't require an X server, and
//! (b) it round-trips cleanly with serde.
//!
//! A Lua layer (`config.lua`) is planned as a future enhancement — see
//! `ARCHITECTURE.md` for how the trait-based [`ConfigSource`] design
//! accommodates that without forcing a hard dependency on `mlua`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Top-level config.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub terminal: TerminalConfig,
#[serde(default)]
pub profiles: HashMap,
/// Default profile name used by `--exec`-less tabs.
#[serde(default = "default_profile")]
pub default_profile: String,
/// Macro table: keybinding chord → command name.
///
/// Example: `"Ctrl+Shift+R" = "ResetTerminal"`
#[serde(default)]
pub macros: HashMap,
/// Pseudo-transparency + tinting configuration.
///
/// Only effective when built with `--features gpu` and using a GUI
/// backend (wgpu or softbuffer). Ignored by the TUI backend.
#[serde(default)]
pub transparency: TransparencyConfig,
/// GPU detection and rendering configuration.
///
/// Controls how the wgpu backend probes for hardware and what
/// happens when no suitable GPU is found. Only effective when built
/// with `--features gpu`.
#[serde(default)]
pub gpu: GpuConfig,
}
/// Transparency config (re-exported here so users don't need to import from
/// the ui module when writing config files).
#[cfg(feature = "gpu")]
pub type TransparencyConfig = crate::ui::transparency::TransparencyConfig;
/// Stub type when the gpu feature is off — keeps the config schema the same.
#[cfg(not(feature = "gpu"))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransparencyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_tint_stub")]
pub tint: String,
#[serde(default = "default_opacity_stub")]
pub opacity: f32,
#[serde(default)]
pub background_image: Option,
}
#[cfg(not(feature = "gpu"))]
fn default_tint_stub() -> String { "#000000".into() }
#[cfg(not(feature = "gpu"))]
fn default_opacity_stub() -> f32 { 1.0 }
/// GPU detection and rendering configuration.
///
/// All fields have sensible defaults. Users only need to set these if
/// they want to override the auto-detection behavior.
///
/// Example config.toml:
///
/// ```toml
/// [gpu]
/// preferred_backend = "vulkan,gl"
/// accept_software_rasterizer = false
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpuConfig {
/// Comma-separated list of preferred wgpu backends, tried in order.
///
/// Valid values: "vulkan", "metal", "dx12", "gl".
/// Empty string (default) = use the built-in order: vulkan → metal → dx12 → gl.
#[serde(default)]
pub preferred_backend: Option,
/// Accept CPU software rasterizers (llvmpipe, swiftshader) as valid GPUs.
///
/// When `false` (default), a software rasterizer adapter is rejected
/// and the probe continues to the next backend. Set to `true` if you
/// want to use the GPU pipeline even on headless/VM systems where the
/// only "GPU" is a CPU-based Vulkan implementation.
#[serde(default)]
pub accept_software_rasterizer: bool,
/// Force wgpu to use its built-in software fallback adapter.
///
/// This bypasses ALL hardware probes and renders via CPU. Useful for
/// debugging the wgpu pipeline without a real GPU. Implies
/// `accept_software_rasterizer = true`.
#[serde(default)]
pub force_fallback_adapter: bool,
/// Require a minimum maximum texture dimension (2D).
///
/// If the detected adapter's max_texture_dimension_2d is below this
/// value, the adapter is rejected. The glyph atlas needs at least
/// the atlas size. 0 = no minimum (default).
#[serde(default)]
pub min_texture_size: u32,
}
impl Default for GpuConfig {
fn default() -> Self {
Self {
preferred_backend: None,
accept_software_rasterizer: false,
force_fallback_adapter: false,
min_texture_size: 0,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
ui: UiConfig::default(),
terminal: TerminalConfig::default(),
profiles: HashMap::new(),
default_profile: default_profile(),
macros: HashMap::new(),
transparency: TransparencyConfig::default(),
gpu: GpuConfig::default(),
}
}
}
fn default_profile() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
/// Mouse focus model: "click" (default) or "follow".
#[serde(default = "default_focus")]
pub focus: String,
/// Show the tab bar even when only one tab is open.
#[serde(default = "default_true")]
pub always_show_tabs: bool,
/// Disable the command palette overlay entirely.
#[serde(default)]
pub disable_palette: bool,
/// Tab bar height in terminal rows.
#[serde(default = "default_tabbar_height")]
pub tabbar_height: u16,
/// Theme: "mrxvt" (classic green-on-black), "tokyo-night", "gruvbox".
#[serde(default = "default_theme")]
pub theme: String,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
focus: default_focus(),
always_show_tabs: default_true(),
disable_palette: false,
tabbar_height: default_tabbar_height(),
theme: default_theme(),
}
}
}
fn default_focus() -> String { "click".into() }
fn default_true() -> bool { true }
fn default_tabbar_height() -> u16 { 1 }
fn default_theme() -> String { "mrxvt".into() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalConfig {
/// Initial columns.
#[serde(default = "default_cols")]
pub cols: u16,
/// Initial rows.
#[serde(default = "default_rows")]
pub rows: u16,
/// Scrollback lines kept in memory per tab.
#[serde(default = "default_scrollback")]
pub scrollback: usize,
/// Shell to launch when no profile overrides it.
#[serde(default = "default_shell")]
pub shell: String,
}
impl Default for TerminalConfig {
fn default() -> Self {
Self {
cols: default_cols(),
rows: default_rows(),
scrollback: default_scrollback(),
shell: default_shell(),
}
}
}
fn default_cols() -> u16 { 80 }
fn default_rows() -> u16 { 24 }
fn default_scrollback() -> usize { 10_000 }
fn default_shell() -> String {
// Prefer bash explicitly — it's the most common default shell across
// distros. Fall back to $SHELL, then /bin/sh.
if let Some(candidate) = ["/bin/bash", "/usr/bin/bash"]
.iter()
.find(|c| std::path::Path::new(c).exists())
{
return candidate.to_string();
}
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())
}
/// A named profile. The `default` profile is consulted when no `--exec` is given.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
/// Command line (split on whitespace; no shell semantics).
/// If empty, falls back to `terminal.shell`.
#[serde(default)]
pub command: Vec,
/// Working directory.
#[serde(default)]
pub cwd: Option,
/// Optional tag for grouped broadcasting.
#[serde(default)]
pub tag: Option,
/// Environment overrides (`KEY=value`).
#[serde(default)]
pub env: HashMap,
}
/// Abstraction over config sources so we can later swap TOML for Lua without
/// touching the rest of the codebase.
pub trait ConfigSource {
fn load(&self) -> Result;
}
/// Filesystem-backed TOML config.
pub struct FileConfigSource {
pub path: PathBuf,
}
impl ConfigSource for FileConfigSource {
fn load(&self) -> Result {
if !self.path.exists() {
return Ok(Config::default());
}
let raw = std::fs::read_to_string(&self.path)
.with_context(|| format!("reading config {}", self.path.display()))?;
let cfg: Config = toml::from_str(&raw)
.with_context(|| format!("parsing config {}", self.path.display()))?;
Ok(cfg)
}
}
impl Config {
/// Resolve the default config path: `$MRXVT_CONFIG` or `~/.config/rs-mrxvt/config.toml`.
pub fn default_path() -> PathBuf {
if let Ok(p) = std::env::var("MRXVT_CONFIG") {
return PathBuf::from(p);
}
let xdg = std::env::var("XDG_CONFIG_HOME").ok().filter(|s| !s.is_empty());
let base = xdg.map(PathBuf::from).unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config")
});
base.join("rs-mrxvt").join("config.toml")
}
/// Load config from a specific path (or the default if `path` is `None`).
///
/// When the `lua` feature is enabled, this delegates to
/// [`config_lua::load_config_smart`] which picks the parser based on
/// file extension (`.lua` → Lua, `.toml` or anything else → TOML).
pub fn load(path: Option<&Path>) -> Result {
#[cfg(feature = "lua")]
{
return crate::config_lua::load_config_smart(path);
}
#[cfg(not(feature = "lua"))]
{
let path = path.map(|p| p.to_path_buf()).unwrap_or_else(Self::default_path);
let expanded = PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).to_string());
FileConfigSource { path: expanded }.load()
}
}
/// Look up a profile by name, falling back to `default` then a synthesized
/// empty profile.
pub fn profile(&self, name: &str) -> Profile {
self.profiles.get(name).cloned().unwrap_or_else(|| match name {
"default" => Profile::default(),
_other => Profile {
command: vec![],
cwd: None,
tag: None,
env: HashMap::new(),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_config() {
let toml = r#"
[terminal]
cols = 120
rows = 40
[profiles.default]
command = ["bash"]
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.terminal.cols, 120);
assert_eq!(cfg.terminal.rows, 40);
assert_eq!(cfg.profiles["default"].command, vec!["bash".to_string()]);
}
#[test]
fn defaults_are_sane() {
let cfg = Config::default();
assert!(cfg.terminal.cols >= 80);
assert!(cfg.terminal.rows >= 24);
assert!(cfg.terminal.scrollback > 0);
assert!(!cfg.terminal.shell.is_empty());
assert_eq!(cfg.default_profile, "default");
}
#[test]
fn missing_file_yields_default() {
let cfg = Config::load(Some(Path::new("/nonexistent/rs-mrxvt.toml"))).unwrap();
assert_eq!(cfg.terminal.cols, default_cols());
}
#[test]
fn profile_fallback_chain() {
let cfg = Config::default();
// Unknown profile → empty profile (does not panic).
let p = cfg.profile("does-not-exist");
assert!(p.command.is_empty());
// "default" on an empty config → empty profile.
let p = cfg.profile("default");
assert!(p.command.is_empty());
}
#[test]
fn macros_round_trip() {
let toml = r#"
[macros]
"Ctrl+Shift+R" = "ResetTerminal"
"Ctrl+Shift+Q" = "Quit"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.macros.get("Ctrl+Shift+R").unwrap(), "ResetTerminal");
}
#[test]
fn gpu_config_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.gpu.preferred_backend.is_none());
assert!(!cfg.gpu.accept_software_rasterizer);
assert!(!cfg.gpu.force_fallback_adapter);
assert_eq!(cfg.gpu.min_texture_size, 0);
}
#[test]
fn gpu_config_parses_all_fields() {
let toml = r#"
[gpu]
preferred_backend = "gl,vulkan"
accept_software_rasterizer = true
force_fallback_adapter = true
min_texture_size = 4096
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.gpu.preferred_backend.as_deref(), Some("gl,vulkan"));
assert!(cfg.gpu.accept_software_rasterizer);
assert!(cfg.gpu.force_fallback_adapter);
assert_eq!(cfg.gpu.min_texture_size, 4096);
}
}