From e1770ba8f92ed4c9b755703232033113c4b233fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 11:26:34 -0400 Subject: [PATCH] T02: settings model, ranges, load and atomic save --- AGENTS.md | 4 +- Cargo.lock | 3 + Cargo.toml | 3 + src/lib.rs | 1 + src/main.rs | 5 ++ src/settings/mod.rs | 190 ++++++++++++++++++++++++++++++++++++++++++ src/settings/store.rs | 170 +++++++++++++++++++++++++++++++++++++ 7 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 src/lib.rs create mode 100644 src/settings/mod.rs create mode 100644 src/settings/store.rs diff --git a/AGENTS.md b/AGENTS.md index 5eb815c..6423ebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; ## Layout -- `src/main.rs`: start, window. +- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them. +- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. ## Rules @@ -33,3 +34,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: the working directory comes from OSC 7, read by a tap on the pty reader, sent by injected shell integration. vte drops OSC 7. - 2026-10-14: Git through the `git` command line, not libgit2. - 2026-10-14: license MIT, as Ely. +- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/Cargo.lock b/Cargo.lock index 6445364..cc0a089 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1749,11 +1749,14 @@ name = "elyterminal" version = "0.1.0" dependencies = [ "anyhow", + "dirs 7.0.0", "ely-gpui-component", "env_logger", "gpui-pre", "gpui-pre-platform", "log", + "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 80636d7..b5e3194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,14 @@ publish = false [dependencies] anyhow = "1" +dirs = "7" ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" } env_logger = "0.11" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6e98cef --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod settings; diff --git a/src/main.rs b/src/main.rs index fb427d3..265b981 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use ely_gpui_component::{ primitives::FocusScope, theme::{ActiveTheme, Mode, Theme}, }; +use elyterminal::settings::SettingsStore; use gpui::{ App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions, Window, WindowBounds, WindowOptions, px, size, @@ -37,6 +38,10 @@ fn main() { .init(); application().with_assets(Assets).run(|cx: &mut App| { ely_gpui_component::init(cx).expect("Ely failed to start"); + if let Err(error) = SettingsStore::init(cx) { + log::error!("{error:#}"); + std::process::exit(1); + } Theme::set_mode_now(Mode::Dark, cx); let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx); let options = WindowOptions { diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..03689d3 --- /dev/null +++ b/src/settings/mod.rs @@ -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, + 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 = 6.0..=72.0; +pub const LINE_HEIGHT: RangeInclusive = 1.0..=2.0; +pub const OPACITY: RangeInclusive = 0.3..=1.0; +pub const PADDING: RangeInclusive = 0.0..=64.0; +pub const SCROLLBACK: RangeInclusive = 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( + field: &str, + value: T, + range: &RangeInclusive, +) -> 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" + ); + } +} diff --git a/src/settings/store.rs b/src/settings/store.rs new file mode 100644 index 0000000..5b31528 --- /dev/null +++ b/src/settings/store.rs @@ -0,0 +1,170 @@ +use std::{ + fs, + io::{ErrorKind, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::Context as _; +use gpui::{App, Global}; + +use super::Settings; + +/// The settings in force and the file they live in; the only writer of that file. +pub struct SettingsStore { + settings: Settings, + path: PathBuf, +} + +impl Global for SettingsStore {} + +impl SettingsStore { + /// Loads `settings.json` from the platform config folder into the app. + pub fn init(cx: &mut App) -> anyhow::Result<()> { + let folder = dirs::config_dir().context("settings: the platform names no config folder")?; + let path = folder.join("ELYTerminal").join("settings.json"); + let settings = load(&path)?; + cx.set_global(Self { settings, path }); + Ok(()) + } + + pub fn get(cx: &App) -> &Settings { + &cx.global::().settings + } + + /// Applies `edit`, checks the result and saves it; a bad edit changes nothing. + pub fn update(cx: &mut App, edit: impl FnOnce(&mut Settings)) -> anyhow::Result<()> { + let store = cx.global::(); + let mut next = store.settings.clone(); + edit(&mut next); + if next == store.settings { + return Ok(()); + } + next.validate()?; + save(&store.path, &next)?; + cx.global_mut::().settings = next; + Ok(()) + } +} + +/// The file's settings; defaults when there is no file. A file that fails to read or check is an error. +pub fn load(path: &Path) -> anyhow::Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == ErrorKind::NotFound => { + log::info!("settings: no file at {}, defaults", path.display()); + return Ok(Settings::default()); + } + Err(error) => { + return Err(error).with_context(|| format!("settings: cannot read {}", path.display())); + } + }; + let settings: Settings = serde_json::from_str(&text) + .with_context(|| format!("settings: {} does not parse", path.display()))?; + settings + .validate() + .with_context(|| format!("settings: {} holds a bad value", path.display()))?; + log::info!("settings: loaded {}", path.display()); + Ok(settings) +} + +/// Writes a temp file beside `path`, then renames it over `path`. +pub fn save(path: &Path, settings: &Settings) -> anyhow::Result<()> { + let folder = path + .parent() + .with_context(|| format!("settings: {} has no folder", path.display()))?; + fs::create_dir_all(folder) + .with_context(|| format!("settings: cannot create {}", folder.display()))?; + let temp = path.with_extension("json.tmp"); + let mut text = serde_json::to_string_pretty(settings).context("settings: cannot encode")?; + text.push('\n'); + let mut file = fs::File::create(&temp) + .with_context(|| format!("settings: cannot create {}", temp.display()))?; + file.write_all(text.as_bytes()) + .and_then(|()| file.sync_all()) + .with_context(|| format!("settings: cannot write {}", temp.display()))?; + fs::rename(&temp, path).with_context(|| { + format!( + "settings: cannot move {} to {}", + temp.display(), + path.display() + ) + })?; + log::info!("settings: saved {}", path.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::CursorStyle; + + fn folder(name: &str) -> PathBuf { + let folder = + std::env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); + if folder.exists() { + fs::remove_dir_all(&folder).expect("a stale test folder goes"); + } + folder + } + + #[test] + fn no_file_gives_defaults() { + let path = folder("none").join("settings.json"); + assert_eq!(load(&path).expect("defaults"), Settings::default()); + } + + #[test] + fn a_saved_file_loads_back_and_leaves_no_temp() { + let folder = folder("round"); + let path = folder.join("settings.json"); + let settings = Settings { + font_size: 15.5, + cursor_style: CursorStyle::Bar, + dark_theme: Some("catppuccin-latte".into()), + ..Settings::default() + }; + save(&path, &settings).expect("saves"); + assert_eq!(load(&path).expect("loads"), settings); + assert!(!path.with_extension("json.tmp").exists()); + fs::remove_dir_all(folder).expect("cleanup"); + } + + #[test] + fn a_missing_field_takes_its_default() { + let folder = folder("partial"); + let path = folder.join("settings.json"); + fs::create_dir_all(&folder).expect("folder"); + fs::write(&path, r#"{ "font_size": 16.0 }"#).expect("write"); + let settings = load(&path).expect("loads"); + assert_eq!(settings.font_size, 16.0); + assert_eq!(settings.theme, Settings::default().theme); + fs::remove_dir_all(folder).expect("cleanup"); + } + + #[test] + fn a_bad_file_is_an_error_naming_it() { + let folder = folder("bad"); + let path = folder.join("settings.json"); + fs::create_dir_all(&folder).expect("folder"); + fs::write(&path, "{ font_size: ").expect("write"); + let error = load(&path).expect_err("does not parse"); + assert!(format!("{error:#}").contains("does not parse")); + assert!(error.to_string().contains("settings.json")); + fs::write(&path, r#"{ "cursor_style": "beam" }"#).expect("write"); + assert!(format!("{:#}", load(&path).expect_err("unknown style")).contains("beam")); + fs::write(&path, r#"{ "font_sise": 12 }"#).expect("write"); + assert!(format!("{:#}", load(&path).expect_err("unknown field")).contains("font_sise")); + fs::remove_dir_all(folder).expect("cleanup"); + } + + #[test] + fn a_bad_value_is_an_error_naming_the_field() { + let folder = folder("range"); + let path = folder.join("settings.json"); + fs::create_dir_all(&folder).expect("folder"); + fs::write(&path, r#"{ "background_opacity": 0.1 }"#).expect("write"); + let error = load(&path).expect_err("too clear"); + assert!(format!("{error:#}").contains("background_opacity is 0.1")); + fs::remove_dir_all(folder).expect("cleanup"); + } +}