T02: settings model, ranges, load and atomic save
This commit is contained in:
7 files changed
+375
-1
No files matched your search
@@ -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.
|
||||
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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"))'] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod settings;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::<Self>().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::<Self>();
|
||||
let mut next = store.settings.clone();
|
||||
edit(&mut next);
|
||||
if next == store.settings {
|
||||
return Ok(());
|
||||
}
|
||||
next.validate()?;
|
||||
save(&store.path, &next)?;
|
||||
cx.global_mut::<Self>().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<Settings> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user