305 lines
11 KiB
Rust
305 lines
11 KiB
Rust
//! Configuration types for Brittle.
|
|
//!
|
|
//! Two levels of config exist:
|
|
//! - [`GlobalConfig`] — stored at `~/.config/brittle/config.toml`; applies to all projects.
|
|
//! - [`ProjectConfig`] — stored at `{repo}/.brittle/config.toml`; overrides globals per project.
|
|
//!
|
|
//! Use [`MergedConfig::merge`] to produce the effective config the app should use.
|
|
|
|
mod global;
|
|
mod project;
|
|
|
|
pub use global::GlobalConfig;
|
|
pub use project::{PdfTabEntry, PdfTabState, ProjectConfig, SessionConfig};
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// ── Shared types ──────────────────────────────────────────────────────────────
|
|
|
|
/// Application colour theme.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Theme {
|
|
#[default]
|
|
Dark,
|
|
Light,
|
|
}
|
|
|
|
/// Appearance settings (font size, theme).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct AppearanceConfig {
|
|
pub theme: Theme,
|
|
pub font_size: u32,
|
|
}
|
|
|
|
impl Default for AppearanceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
theme: Theme::Dark,
|
|
font_size: 14,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Partial appearance override from a project config.
|
|
/// Only `Some` fields replace the global value.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
|
#[serde(default)]
|
|
pub struct AppearanceOverride {
|
|
pub theme: Option<Theme>,
|
|
pub font_size: Option<u32>,
|
|
}
|
|
|
|
/// Pane layout proportions and constraints.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LayoutConfig {
|
|
/// Initial fraction of the window width for the left pane (0..1).
|
|
pub left_pane_fraction: f32,
|
|
/// Initial fraction of the window width for the right pane (0..1).
|
|
pub right_pane_fraction: f32,
|
|
/// Minimum width of the left pane in pixels.
|
|
pub left_pane_min: i32,
|
|
/// Maximum width of the left pane in pixels.
|
|
pub left_pane_max: i32,
|
|
/// Minimum width of the right pane in pixels.
|
|
pub right_pane_min: i32,
|
|
/// Maximum width of the right pane in pixels.
|
|
pub right_pane_max: i32,
|
|
/// Minimum width of the center pane in pixels.
|
|
pub center_pane_min: i32,
|
|
}
|
|
|
|
impl Default for LayoutConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
left_pane_fraction: 0.20,
|
|
right_pane_fraction: 0.35,
|
|
left_pane_min: 120,
|
|
left_pane_max: 600,
|
|
right_pane_min: 150,
|
|
right_pane_max: 600,
|
|
center_pane_min: 200,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keybinding overrides — maps action name to key combo string.
|
|
///
|
|
/// Only actions the user wants to rebind need an entry here; everything else
|
|
/// falls back to the built-in defaults defined in `keymap`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
|
pub struct KeybindingsConfig(pub HashMap<String, String>);
|
|
|
|
// ── Merged config ─────────────────────────────────────────────────────────────
|
|
|
|
/// The effective config the application uses at runtime, produced by merging
|
|
/// global defaults with per-project overrides.
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)] // consumed in Phase 3 when AppState is wired up
|
|
pub struct MergedConfig {
|
|
pub appearance: AppearanceConfig,
|
|
pub layout: LayoutConfig,
|
|
pub keybindings: KeybindingsConfig,
|
|
/// Tabs that should be restored on launch.
|
|
pub open_tabs: Vec<PdfTabEntry>,
|
|
}
|
|
|
|
impl MergedConfig {
|
|
#[allow(dead_code)] // consumed in Phase 3 when AppState is wired up
|
|
/// Merge `global` with an optional `project` override.
|
|
///
|
|
/// Project values take precedence for appearance; layout and keybindings
|
|
/// are always taken from the global config (per-project overrides for those
|
|
/// are intentionally not supported — it would be confusing).
|
|
pub fn merge(global: &GlobalConfig, project: Option<&ProjectConfig>) -> Self {
|
|
let appearance = match project.and_then(|p| p.appearance.as_ref()) {
|
|
Some(ov) => AppearanceConfig {
|
|
theme: ov
|
|
.theme
|
|
.clone()
|
|
.unwrap_or_else(|| global.appearance.theme.clone()),
|
|
font_size: ov.font_size.unwrap_or(global.appearance.font_size),
|
|
},
|
|
None => global.appearance.clone(),
|
|
};
|
|
|
|
Self {
|
|
appearance,
|
|
layout: global.layout.clone(),
|
|
keybindings: global.keybindings.clone(),
|
|
open_tabs: project
|
|
.map(|p| p.session.open_tabs.clone())
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Error type ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConfigError {
|
|
#[error("no config directory found on this platform")]
|
|
NoConfigDir,
|
|
#[error("I/O error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("TOML parse error: {0}")]
|
|
Parse(#[from] toml::de::Error),
|
|
#[error("TOML serialize error: {0}")]
|
|
Serialize(#[from] toml::ser::Error),
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::project::{PdfTabEntry, SessionConfig};
|
|
|
|
// ── AppearanceConfig ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn appearance_defaults_are_dark_14pt() {
|
|
let a = AppearanceConfig::default();
|
|
assert_eq!(a.theme, Theme::Dark);
|
|
assert_eq!(a.font_size, 14);
|
|
}
|
|
|
|
#[test]
|
|
fn appearance_round_trips() {
|
|
let original = AppearanceConfig {
|
|
theme: Theme::Light,
|
|
font_size: 16,
|
|
};
|
|
let s = toml::to_string_pretty(&original).unwrap();
|
|
let parsed: AppearanceConfig = toml::from_str(&s).unwrap();
|
|
assert_eq!(parsed, original);
|
|
}
|
|
|
|
#[test]
|
|
fn appearance_missing_fields_use_defaults() {
|
|
// Only theme specified; font_size should fall back to 14.
|
|
let parsed: AppearanceConfig = toml::from_str("theme = \"light\"").unwrap();
|
|
assert_eq!(parsed.theme, Theme::Light);
|
|
assert_eq!(parsed.font_size, 14);
|
|
}
|
|
|
|
// ── LayoutConfig ─────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn layout_defaults() {
|
|
let l = LayoutConfig::default();
|
|
assert!((l.left_pane_fraction - 0.20).abs() < f32::EPSILON);
|
|
assert!((l.right_pane_fraction - 0.35).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_round_trips() {
|
|
let original = LayoutConfig {
|
|
left_pane_fraction: 0.25,
|
|
right_pane_fraction: 0.40,
|
|
..LayoutConfig::default()
|
|
};
|
|
let s = toml::to_string_pretty(&original).unwrap();
|
|
let parsed: LayoutConfig = toml::from_str(&s).unwrap();
|
|
assert_eq!(parsed, original);
|
|
}
|
|
|
|
// ── MergedConfig ─────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn merge_without_project_uses_globals() {
|
|
let global = GlobalConfig {
|
|
appearance: AppearanceConfig {
|
|
theme: Theme::Light,
|
|
font_size: 16,
|
|
},
|
|
..Default::default()
|
|
};
|
|
let merged = MergedConfig::merge(&global, None);
|
|
assert_eq!(merged.appearance.theme, Theme::Light);
|
|
assert_eq!(merged.appearance.font_size, 16);
|
|
assert!(merged.open_tabs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_project_overrides_theme() {
|
|
let global = GlobalConfig {
|
|
appearance: AppearanceConfig {
|
|
theme: Theme::Dark,
|
|
font_size: 14,
|
|
},
|
|
..Default::default()
|
|
};
|
|
let project = ProjectConfig {
|
|
appearance: Some(AppearanceOverride {
|
|
theme: Some(Theme::Light),
|
|
font_size: None,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let merged = MergedConfig::merge(&global, Some(&project));
|
|
assert_eq!(merged.appearance.theme, Theme::Light);
|
|
// font_size not overridden — inherits from global
|
|
assert_eq!(merged.appearance.font_size, 14);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_project_overrides_font_size_only() {
|
|
let global = GlobalConfig {
|
|
appearance: AppearanceConfig {
|
|
theme: Theme::Dark,
|
|
font_size: 14,
|
|
},
|
|
..Default::default()
|
|
};
|
|
let project = ProjectConfig {
|
|
appearance: Some(AppearanceOverride {
|
|
theme: None,
|
|
font_size: Some(18),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let merged = MergedConfig::merge(&global, Some(&project));
|
|
assert_eq!(merged.appearance.theme, Theme::Dark);
|
|
assert_eq!(merged.appearance.font_size, 18);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_project_open_tabs_are_included() {
|
|
let global = GlobalConfig::default();
|
|
let project = ProjectConfig {
|
|
session: SessionConfig {
|
|
open_tabs: vec![
|
|
PdfTabEntry { ref_id: "tab-a".to_string(), title: "a2024".to_string() },
|
|
PdfTabEntry { ref_id: "tab-b".to_string(), title: "b2024".to_string() },
|
|
],
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
let merged = MergedConfig::merge(&global, Some(&project));
|
|
assert_eq!(merged.open_tabs.len(), 2);
|
|
assert_eq!(merged.open_tabs[0].ref_id, "tab-a");
|
|
assert_eq!(merged.open_tabs[1].ref_id, "tab-b");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_layout_always_from_global() {
|
|
let global = GlobalConfig {
|
|
layout: LayoutConfig {
|
|
left_pane_fraction: 0.30,
|
|
right_pane_fraction: 0.40,
|
|
..LayoutConfig::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
// Project config has no layout override capability.
|
|
let merged = MergedConfig::merge(&global, None);
|
|
assert!((merged.layout.left_pane_fraction - 0.30).abs() < f32::EPSILON);
|
|
}
|
|
}
|