use crate::Result;
use crate::config::ConfigStore;
use ratatui_kit::Palette;
use ratatui_kit_themes::{IntoKitPalette, ThemeName, terminal_background};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
pub struct AppearanceConfig {
pub theme_slug: String,
pub background: BackgroundMode,
pub show_header: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundMode {
Theme,
Terminal,
}
impl AppearanceConfig {
const DEFAULT_THEME: ThemeName = ThemeName::TokyoNight;
pub fn load() -> Result<Self> {
Ok(ConfigStore::user_default()?.load()?.appearance)
}
pub fn save(&self) -> Result<()> {
ConfigStore::user_default()?.update(|config| config.appearance = self.clone())?;
Ok(())
}
pub fn theme_name(&self) -> ThemeName {
ThemeName::all()
.iter()
.copied()
.find(|name| name.slug() == self.theme_slug)
.unwrap_or(Self::DEFAULT_THEME)
}
pub fn palette(&self) -> Palette {
let palette = self.theme_name().into_kit_palette();
match self.background {
BackgroundMode::Theme => palette,
BackgroundMode::Terminal => terminal_background(palette),
}
}
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
theme_slug: Self::DEFAULT_THEME.slug().to_string(),
background: BackgroundMode::Terminal,
show_header: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ReaderDisplayConfig {
#[serde(default = "default_show_title")]
pub follow_tts: bool,
#[serde(default = "default_show_title")]
pub show_title: bool,
#[serde(default = "default_page_overlap")]
pub page_overlap: u16,
#[serde(default = "default_paragraph_spacing")]
pub paragraph_spacing: bool,
}
impl ReaderDisplayConfig {
const PAGE_OVERLAP_MAX: u16 = 10;
pub fn page_step(&self, visible_lines: usize) -> usize {
visible_lines
.saturating_sub(self.page_overlap as usize)
.max(1)
}
pub fn increase_page_overlap(&mut self) {
self.page_overlap = (self.page_overlap + 1).min(Self::PAGE_OVERLAP_MAX);
}
pub fn decrease_page_overlap(&mut self) {
self.page_overlap = self.page_overlap.saturating_sub(1);
}
pub(crate) fn normalize(&mut self) {
self.page_overlap = self.page_overlap.min(Self::PAGE_OVERLAP_MAX);
}
pub fn load() -> Result<Self> {
Ok(ConfigStore::user_default()?.load()?.reader)
}
pub fn save(&self) -> Result<()> {
ConfigStore::user_default()?.update(|config| config.reader = *self)?;
Ok(())
}
}
impl Default for ReaderDisplayConfig {
fn default() -> Self {
Self {
follow_tts: true,
show_title: default_show_title(),
page_overlap: default_page_overlap(),
paragraph_spacing: default_paragraph_spacing(),
}
}
}
fn default_show_title() -> bool {
true
}
fn default_paragraph_spacing() -> bool {
false
}
fn default_page_overlap() -> u16 {
2
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn existing_appearance_config_keeps_header_visible() {
let config: AppearanceConfig = toml::from_str("theme_slug = 'tokyo-night'").unwrap();
assert!(config.show_header);
}
#[test]
fn hidden_header_preference_round_trips() {
let config = AppearanceConfig {
show_header: false,
..Default::default()
};
let restored: AppearanceConfig =
toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
assert!(!restored.show_header);
}
#[test]
fn unknown_theme_slug_falls_back_to_default_theme() {
let config = AppearanceConfig {
theme_slug: "missing-theme".to_string(),
background: BackgroundMode::Terminal,
show_header: true,
};
assert_eq!(config.theme_name(), AppearanceConfig::DEFAULT_THEME);
}
#[test]
fn page_overlap_defaults_to_two() {
assert_eq!(ReaderDisplayConfig::default().page_overlap, 2);
}
#[test]
fn partial_config_without_page_overlap_takes_default() {
let config: ReaderDisplayConfig = serde_json::from_str(r#"{"show_title": false}"#).unwrap();
assert!(!config.show_title);
assert_eq!(config.page_overlap, 2);
assert!(!config.paragraph_spacing);
}
#[test]
fn paragraph_spacing_defaults_to_off_and_round_trips() {
assert!(!ReaderDisplayConfig::default().paragraph_spacing);
let config: ReaderDisplayConfig =
serde_json::from_str(r#"{"paragraph_spacing": true}"#).unwrap();
assert!(config.paragraph_spacing);
}
#[test]
fn follow_defaults_on_and_round_trips_off() {
let legacy: ReaderDisplayConfig = serde_json::from_str(r#"{"show_title":false}"#).unwrap();
assert!(legacy.follow_tts);
let config = ReaderDisplayConfig {
follow_tts: false,
..Default::default()
};
let restored: ReaderDisplayConfig =
serde_json::from_str(&serde_json::to_string(&config).unwrap()).unwrap();
assert!(!restored.follow_tts);
}
#[test]
fn page_overlap_stays_within_range() {
let mut config = ReaderDisplayConfig::default();
for _ in 0..ReaderDisplayConfig::PAGE_OVERLAP_MAX + 5 {
config.increase_page_overlap();
}
assert_eq!(config.page_overlap, ReaderDisplayConfig::PAGE_OVERLAP_MAX);
for _ in 0..ReaderDisplayConfig::PAGE_OVERLAP_MAX + 5 {
config.decrease_page_overlap();
}
assert_eq!(config.page_overlap, 0);
}
#[test]
fn page_step_subtracts_overlap_and_never_stalls() {
let mut config = ReaderDisplayConfig::default();
assert_eq!(config.page_step(30), 28);
config.page_overlap = 0;
assert_eq!(config.page_step(30), 30);
config.page_overlap = ReaderDisplayConfig::PAGE_OVERLAP_MAX;
assert_eq!(config.page_step(3), 1);
}
#[test]
fn terminal_background_resets_only_background_layers() {
let config = AppearanceConfig {
theme_slug: ThemeName::Dracula.slug().to_string(),
background: BackgroundMode::Terminal,
show_header: true,
};
let palette = config.palette();
let themed = ThemeName::Dracula.into_kit_palette();
assert_eq!(palette.bg, Color::Reset);
assert_eq!(palette.surface, Color::Reset);
assert_eq!(palette.overlay, Color::Reset);
assert_eq!(palette.accent, themed.accent);
assert_eq!(palette.fg, themed.fg);
}
}