T02: settings model, ranges, load and atomic save
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Successful in 9m49s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 11:26:34 -04:00
1 parent bcc799d76e
commit e1770ba8f9
7 files changed
+375 -1

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
mod store;
use std::ops::RangeInclusive;
use serde::{Deserialize, Serialize};
pub use store::{SettingsStore, load, save};
/// Where a new session starts.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartIn {
#[default]
Home,
Session,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CursorStyle {
#[default]
Block,
Bar,
Underline,
Hollow,
}
/// Which Option keys type Alt on macOS.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OptionAsAlt {
#[default]
Off,
Left,
Right,
Both,
}
/// What the right panel shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanelTab {
#[default]
Git,
Files,
}
/// Everything the settings window edits. A field the file leaves out takes its default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
/// A program path; empty runs the login shell.
pub shell: String,
pub start_in: StartIn,
pub theme: String,
/// The theme while the system is dark; none keeps `theme`.
pub dark_theme: Option<String>,
pub font_family: String,
pub font_size: f32,
pub line_height: f32,
pub ligatures: bool,
pub cursor_style: CursorStyle,
pub cursor_blink: bool,
pub background_opacity: f32,
pub background_blur: bool,
pub padding_x: f32,
pub padding_y: f32,
pub scrollback: u32,
pub option_as_alt: OptionAsAlt,
pub copy_on_select: bool,
pub show_sessions: bool,
pub show_panel: bool,
pub panel: PanelTab,
}
impl Default for Settings {
fn default() -> Self {
Self {
shell: String::new(),
start_in: StartIn::Home,
theme: "catppuccin-mocha".into(),
dark_theme: None,
font_family: "JetBrains Mono".into(),
font_size: 13.0,
line_height: 1.2,
ligatures: true,
cursor_style: CursorStyle::Block,
cursor_blink: true,
background_opacity: 0.88,
background_blur: true,
padding_x: 10.0,
padding_y: 8.0,
scrollback: 10_000,
option_as_alt: OptionAsAlt::Off,
copy_on_select: false,
show_sessions: true,
show_panel: true,
panel: PanelTab::Git,
}
}
}
pub const FONT_SIZE: RangeInclusive<f32> = 6.0..=72.0;
pub const LINE_HEIGHT: RangeInclusive<f32> = 1.0..=2.0;
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
impl Settings {
/// Every value inside its range, every name filled; the error names the field.
pub fn validate(&self) -> anyhow::Result<()> {
within("font_size", self.font_size, &FONT_SIZE)?;
within("line_height", self.line_height, &LINE_HEIGHT)?;
within("background_opacity", self.background_opacity, &OPACITY)?;
within("padding_x", self.padding_x, &PADDING)?;
within("padding_y", self.padding_y, &PADDING)?;
within("scrollback", self.scrollback, &SCROLLBACK)?;
anyhow::ensure!(!self.theme.is_empty(), "theme is empty");
anyhow::ensure!(
self.dark_theme.as_ref().is_none_or(|name| !name.is_empty()),
"dark_theme is empty"
);
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
Ok(())
}
}
fn within<T: PartialOrd + std::fmt::Display>(
field: &str,
value: T,
range: &RangeInclusive<T>,
) -> anyhow::Result<()> {
anyhow::ensure!(
range.contains(&value),
"{field} is {value}, outside {} to {}",
range.start(),
range.end()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_valid() {
Settings::default().validate().expect("defaults pass");
}
#[test]
fn a_value_out_of_range_names_its_field() {
let settings = Settings {
line_height: 2.5,
..Settings::default()
};
let error = settings.validate().expect_err("2.5 is too tall");
assert_eq!(error.to_string(), "line_height is 2.5, outside 1 to 2");
let settings = Settings {
scrollback: 100_001,
..Settings::default()
};
let error = settings.validate().expect_err("too many lines");
assert_eq!(
error.to_string(),
"scrollback is 100001, outside 0 to 100000"
);
}
#[test]
fn a_nan_is_out_of_range() {
let settings = Settings {
font_size: f32::NAN,
..Settings::default()
};
assert!(settings.validate().is_err());
}
#[test]
fn an_empty_name_fails() {
let settings = Settings {
dark_theme: Some(String::new()),
..Settings::default()
};
assert_eq!(
settings.validate().expect_err("empty").to_string(),
"dark_theme is empty"
);
}
}