use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub use badterm_core::dialer::BellMode;
pub use badterm_core::modem::ModemConfig;
use serde::{Deserialize, Serialize};
use crate::crt::{CrtMode, Curvature, Glow, Scanlines};
use crate::ui::theme::Theme;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Music {
Off,
#[default]
On,
All,
}
impl Music {
pub fn next(self) -> Music {
match self {
Music::Off => Music::On,
Music::On => Music::All,
Music::All => Music::Off,
}
}
pub fn name(self) -> &'static str {
match self {
Music::Off => "BBS sound and music off",
Music::On => "BBS sound and music on",
Music::All => "BBS sound and music on, including ESC[M",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Aspect {
#[default]
Square,
#[serde(rename = "4:3")]
FourThree,
#[serde(rename = "16:10")]
SixteenTen,
#[serde(rename = "16:9")]
SixteenNine,
}
impl Aspect {
pub const ALL: [Aspect; 4] = [Aspect::Square, Aspect::FourThree, Aspect::SixteenTen, Aspect::SixteenNine];
pub fn name(self) -> &'static str {
match self {
Aspect::Square => "Square pixels",
Aspect::FourThree => "4:3, as on a CRT",
Aspect::SixteenTen => "16:10",
Aspect::SixteenNine => "16:9",
}
}
pub fn ratio(self) -> Option<f64> {
match self {
Aspect::Square => None,
Aspect::FourThree => Some(4.0 / 3.0),
Aspect::SixteenTen => Some(16.0 / 10.0),
Aspect::SixteenNine => Some(16.0 / 9.0),
}
}
pub fn next(self) -> Aspect {
let i = Aspect::ALL.iter().position(|&a| a == self).unwrap_or(0);
Aspect::ALL[(i + 1) % Aspect::ALL.len()]
}
}
fn aspect_or_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Aspect, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Old(bool),
New(Aspect),
}
Ok(match Either::deserialize(d)? {
Either::Old(true) => Aspect::FourThree,
Either::Old(false) => Aspect::Square,
Either::New(a) => a,
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScaleMode {
Auto,
Integer,
#[default]
Fit,
Stretch,
}
impl ScaleMode {
pub const ALL: [ScaleMode; 4] = [ScaleMode::Auto, ScaleMode::Integer, ScaleMode::Fit, ScaleMode::Stretch];
pub fn name(self) -> &'static str {
match self {
ScaleMode::Auto => "Auto",
ScaleMode::Integer => "Whole numbers",
ScaleMode::Fit => "Fit, keep shape",
ScaleMode::Stretch => "Stretch to fill",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Folders {
#[serde(skip_serializing_if = "Option::is_none")]
pub downloads: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captures: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saves: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uploads: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub art: Option<PathBuf>,
}
impl Folders {
pub const NAMES: [&str; 5] = ["Downloads", "Captures", "Saved screens", "Uploads start in", "ANSI art starts in"];
pub const DOWNLOADS: usize = 0;
pub const CAPTURES: usize = 1;
pub const SAVES: usize = 2;
pub const UPLOADS: usize = 3;
pub const ART: usize = 4;
fn slot(&mut self, i: usize) -> &mut Option<PathBuf> {
match i {
0 => &mut self.downloads,
1 => &mut self.captures,
2 => &mut self.saves,
3 => &mut self.uploads,
_ => &mut self.art,
}
}
pub fn get(&self, i: usize) -> Option<&PathBuf> {
[&self.downloads, &self.captures, &self.saves, &self.uploads, &self.art][i].as_ref()
}
pub fn set(&mut self, i: usize, path: Option<PathBuf>) {
*self.slot(i) = path;
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub crt: CrtMode,
pub glow: Glow,
pub scanlines: Scanlines,
pub curvature: Curvature,
pub nine_dot: bool,
#[serde(deserialize_with = "aspect_or_bool")]
pub aspect: Aspect,
pub scaling: ScaleMode,
pub music: Music,
pub bell: BellMode,
pub redial: bool,
pub colors: Theme,
pub bezel: String,
pub macros: Vec<String>,
pub folders: Folders,
pub splash: bool,
pub check_updates: bool,
pub modem: ModemConfig,
pub entry_font: u8,
pub entry_screen: (u16, u16),
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub hotkeys: BTreeMap<String, String>,
pub window: WindowState,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WindowState {
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<i32>,
pub maximized: bool,
}
impl Default for Settings {
fn default() -> Settings {
Settings {
crt: CrtMode::Off,
glow: Glow::Light,
scanlines: Scanlines::Medium,
curvature: Curvature::Off,
nine_dot: false,
aspect: Aspect::Square,
scaling: ScaleMode::Fit,
music: Music::On,
bell: BellMode::Sound,
redial: true,
colors: Theme::default(),
bezel: String::new(),
macros: vec![String::new(); crate::macros::COUNT],
folders: Folders::default(),
splash: true,
check_updates: false,
modem: ModemConfig::default(),
entry_font: 0,
entry_screen: (80, 25),
hotkeys: BTreeMap::new(),
window: WindowState::default(),
}
}
}
impl Settings {
pub fn load(path: &Path) -> Settings {
match std::fs::read_to_string(path) {
Ok(s) => toml::from_str(&s).unwrap_or_else(|e| {
log::warn!("ignoring {}: {e}", path.display());
Settings::default()
}),
Err(_) => Settings::default(),
}
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, toml::to_string_pretty(self)?)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_and_defaults() {
let mut colors = Theme::default();
colors.selected.fg = 14;
let s = Settings {
crt: CrtMode::Amber,
glow: Glow::Strong,
scanlines: Scanlines::Strong,
curvature: Curvature::Off,
nine_dot: true,
aspect: Aspect::SixteenNine,
scaling: ScaleMode::Stretch,
music: Music::All,
bell: BellMode::Visual,
redial: false,
colors,
bezel: "Beige".into(),
macros: vec!["{user}^M".into(); 10],
folders: Folders { downloads: Some("/tmp/dl".into()), ..Folders::default() },
splash: false,
check_updates: true,
modem: ModemConfig { device: "/dev/ttyUSB0".into(), init: "AT&F".into(), ..ModemConfig::default() },
entry_font: 36,
entry_screen: (80, 50),
hotkeys: [("D".to_string(), "Y".to_string())].into(),
window: WindowState { width: Some(1280), height: Some(800), x: Some(-1900), y: Some(40), maximized: true },
};
let text = toml::to_string_pretty(&s).unwrap();
assert_eq!(toml::from_str::<Settings>(&text).unwrap(), s);
assert!(text.contains("crt = \"amber\""), "{text}");
assert_eq!(toml::from_str::<Settings>("").unwrap(), Settings::default());
}
#[test]
fn old_aspect_setting_still_reads() {
let s: Settings = toml::from_str("aspect = true").unwrap();
assert_eq!(s.aspect, Aspect::FourThree);
let s: Settings = toml::from_str("aspect = false\nscaling = \"stretch\"").unwrap();
assert_eq!((s.aspect, s.scaling), (Aspect::Square, ScaleMode::Stretch));
let s: Settings = toml::from_str("aspect = \"16:9\"").unwrap();
assert_eq!(s.aspect, Aspect::SixteenNine);
}
#[test]
fn defaults() {
let s = Settings::default();
assert_eq!((s.crt, s.scanlines, s.curvature), (CrtMode::Off, Scanlines::Medium, Curvature::Off));
assert_eq!((s.nine_dot, s.aspect, s.scaling, s.bezel.as_str()), (false, Aspect::Square, ScaleMode::Fit, ""));
assert_eq!(toml::from_str::<Settings>("music = \"on\"").unwrap(), s);
}
}