Skip to main content

base/
settings.rs

1// settings -- typed settings: a schema of keys (type, default, limits, label) over a settings
2// file (Ini) with a format version and migrations
3//
4//   const DARK: Key<bool> = Key::bool("view.dark", false).label("Dunkles Design");
5//   const ZOOM: Key<i64> = Key::int("view.zoom", 100, 10, 800).label("Zoom (%)").step(10);
6//   static SCHEMA: Schema = Schema::new(2, &[DARK.d, ZOOM.d]).sections(&[("view", "Ansicht")])
7//       .migrate(migrate);
8//   let mut s = Settings::open("config://settings.ini", &SCHEMA);
9//   let zoom: i64 = s.get(&ZOOM);   s.set(&ZOOM, 120);   s.save();
10//
11// Key types: Key<bool> (Key::bool), Key<i64> (Key::int: min..max, step), Key<f64> (Key::num:
12// min..max, decimals), Key<String> (Key::text: at most max bytes of UTF-8), Key<usize>
13// (Key::choice: an index into (stored word, label) items; the file keeps the word),
14// Key<u32> (Key::color: 0xRRGGBB; the file "#RRGGBB").  Builders: label(s), help(s),
15// hidden() (not on the settings page), restart() (takes effect after a restart: the page
16// says so), step(n) (int: the number box's step).
17//
18//   Settings::open(path, &schema)   load, migrate, read (a missing file: the defaults)
19//   Settings::new(&schema)          only in memory (save does nothing)
20//   Settings::from_ini(ini, &schema)  in memory, from an Ini (tests)
21//   s.get(&key) -> T   s.text(&key) -> &str   s.set(&key, v) -> changed   s.reset(&key)
22//   s.reset_all()   s.is_default(&key)   s.save() -> ok   s.dirty()   s.revision()
23//   s.ini() / s.ini_mut()   the file for free-form data the schema does not describe (a
24//                           list of recent files); ini_mut marks the settings dirty
25//   s.file_version()        the version the file had (0: none or older than versions)
26//   s.schema(), s.at(i), s.set_at(i, val), s.default_at(i)   by schema index (the page)
27//   rename(&mut ini, "old.key", "new.key")   for migrations
28//
29// Names are "section.key" (no '.': the top of the file).  The file keeps only values that
30// differ from the default (setting the default removes the line), so a new default reaches
31// users who never changed it; comments, unknown keys and other sections survive (Ini).
32// The format version is "version=N" at the top: a file of an older version (no line: 0)
33// runs migrate(from, &mut ini) once before its values are read (rename keys, convert units)
34// and is saved with the new version by the next save; a missing file needs no migration; a
35// file of a newer version is read as far as the schema goes and keeps its number.  A value
36// the schema cannot use (not a number, an unknown choice, a bad colour) reads as the default
37// and a WARN goes to the log; numbers out of range are clamped, texts cut to their maximum.
38// Keys that are not in the schema still work through get / set (read from the file each
39// time) but are not on the page.
40
41use alloc::string::String;
42use alloc::vec::Vec;
43use core::marker::PhantomData;
44
45use crate::ini::Ini;
46use crate::num::{f64_format, f64_parse, parse_i64};
47
48/// Desc::flags: not on the settings page
49pub const KF_HIDDEN: u32 = 1;
50/// Desc::flags: takes effect after a restart (the page says so)
51pub const KF_RESTART: u32 = 2;
52
53/// what a key holds, with its default and limits
54#[derive(Clone, Copy, Debug)]
55#[non_exhaustive]
56pub enum Kind {
57    Bool(bool),
58    Int { def: i64, min: i64, max: i64, step: i64 },
59    Num { def: f64, min: f64, max: f64, dec: u32 },
60    Text { def: &'static str, max: usize },
61    Choice { def: usize, items: &'static [(&'static str, &'static str)] },
62    Color(u32),
63}
64
65/// a key of a schema (Key::d)
66#[derive(Clone, Copy, Debug)]
67pub struct Desc {
68    pub name: &'static str,
69    pub label: &'static str,
70    pub help: &'static str,
71    pub flags: u32,
72    pub kind: Kind,
73}
74
75impl Desc {
76    /// (section, key) of the name
77    pub fn split(&self) -> (&'static str, &'static str) {
78        split(self.name)
79    }
80
81    /// the default value
82    pub fn default_val(&self) -> Val {
83        match self.kind {
84            Kind::Bool(d) => Val::Bool(d),
85            Kind::Int { def, .. } => Val::Int(def),
86            Kind::Num { def, .. } => Val::Num(def),
87            Kind::Text { def, .. } => Val::Text(String::from(def)),
88            Kind::Choice { def, .. } => Val::Choice(def),
89            Kind::Color(d) => Val::Color(d & 0xFF_FFFF),
90        }
91    }
92
93    /// v made valid for this key (clamped, cut, the default when it is of another type)
94    pub fn fit(&self, v: Val) -> Val {
95        match (self.kind, v) {
96            (Kind::Bool(_), Val::Bool(b)) => Val::Bool(b),
97            (Kind::Int { min, max, .. }, Val::Int(x)) => Val::Int(x.clamp(min, max.max(min))),
98            (Kind::Num { min, max, .. }, Val::Num(x)) if !x.is_nan() && !min.is_nan() && !max.is_nan() => Val::Num(x.clamp(min, max.max(min))),
99            (Kind::Text { max, .. }, Val::Text(mut s)) => {
100                cut(&mut s, max);
101                Val::Text(s)
102            }
103            (Kind::Choice { items, .. }, Val::Choice(i)) if i < items.len() => Val::Choice(i),
104            (Kind::Color(_), Val::Color(c)) => Val::Color(c & 0xFF_FFFF),
105            _ => self.default_val(),
106        }
107    }
108
109    /// the value written into the file for v
110    pub fn format(&self, v: &Val) -> String {
111        match v {
112            Val::Bool(b) => String::from(if *b { "true" } else { "false" }),
113            Val::Int(x) => alloc::format!("{}", x),
114            Val::Num(x) => f64_format(*x),
115            Val::Text(s) => s.clone(),
116            Val::Choice(i) => match self.kind {
117                Kind::Choice { items, .. } => String::from(items.get(*i).map_or("", |it| it.0)),
118                _ => String::new(),
119            },
120            Val::Color(c) => alloc::format!("#{:06X}", c & 0xFF_FFFF),
121        }
122    }
123
124    /// the text of the file read as this key: Some(valid value) (clamped / cut) or None
125    pub fn parse(&self, s: &str) -> Option<Val> {
126        let t = s.trim();
127        let v = match self.kind {
128            Kind::Bool(_) => Val::Bool(parse_i64(t.as_bytes())? != 0),
129            Kind::Int { .. } => Val::Int(parse_i64(t.as_bytes())?),
130            Kind::Num { .. } => {
131                let (x, used) = f64_parse(t.as_bytes());
132                if used == 0 || used != t.len() || x.is_nan() {
133                    return None;
134                }
135                Val::Num(x)
136            }
137            Kind::Text { .. } => Val::Text(String::from(s)),
138            Kind::Choice { items, .. } => Val::Choice(items.iter().position(|it| it.0.eq_ignore_ascii_case(t))?),
139            Kind::Color(_) => {
140                let h = t.strip_prefix('#').unwrap_or(t);
141                if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
142                    return None;
143                }
144                Val::Color(u32::from_str_radix(h, 16).ok()?)
145            }
146        };
147        Some(self.fit(v))
148    }
149}
150
151/// a value of a key
152#[derive(Clone, Debug, PartialEq)]
153#[non_exhaustive]
154pub enum Val {
155    Bool(bool),
156    Int(i64),
157    Num(f64),
158    Text(String),
159    Choice(usize),
160    Color(u32),
161}
162
163/// the Rust types a key can have: bool, i64, f64, String, usize (choice), u32 (colour)
164pub trait Value: Sized {
165    #[doc(hidden)]
166    fn from_val(v: &Val) -> Option<Self>;
167    #[doc(hidden)]
168    fn into_val(self) -> Val;
169}
170
171impl Value for bool {
172    fn from_val(v: &Val) -> Option<bool> {
173        if let Val::Bool(b) = v { Some(*b) } else { None }
174    }
175    fn into_val(self) -> Val {
176        Val::Bool(self)
177    }
178}
179
180impl Value for i64 {
181    fn from_val(v: &Val) -> Option<i64> {
182        if let Val::Int(x) = v { Some(*x) } else { None }
183    }
184    fn into_val(self) -> Val {
185        Val::Int(self)
186    }
187}
188
189impl Value for f64 {
190    fn from_val(v: &Val) -> Option<f64> {
191        if let Val::Num(x) = v { Some(*x) } else { None }
192    }
193    fn into_val(self) -> Val {
194        Val::Num(self)
195    }
196}
197
198impl Value for String {
199    fn from_val(v: &Val) -> Option<String> {
200        if let Val::Text(s) = v { Some(s.clone()) } else { None }
201    }
202    fn into_val(self) -> Val {
203        Val::Text(self)
204    }
205}
206
207impl Value for usize {
208    fn from_val(v: &Val) -> Option<usize> {
209        if let Val::Choice(i) = v { Some(*i) } else { None }
210    }
211    fn into_val(self) -> Val {
212        Val::Choice(self)
213    }
214}
215
216impl Value for u32 {
217    fn from_val(v: &Val) -> Option<u32> {
218        if let Val::Color(c) = v { Some(*c) } else { None }
219    }
220    fn into_val(self) -> Val {
221        Val::Color(self)
222    }
223}
224
225/// a typed key: its description (d) and the Rust type of its value
226pub struct Key<T> {
227    pub d: Desc,
228    t: PhantomData<fn() -> T>,
229}
230
231impl<T> Clone for Key<T> {
232    fn clone(&self) -> Key<T> {
233        *self
234    }
235}
236impl<T> Copy for Key<T> {}
237
238const fn key<T>(name: &'static str, kind: Kind) -> Key<T> {
239    Key { d: Desc { name, label: name, help: "", flags: 0, kind }, t: PhantomData }
240}
241
242impl Key<bool> {
243    pub const fn bool(name: &'static str, def: bool) -> Key<bool> {
244        key(name, Kind::Bool(def))
245    }
246}
247
248impl Key<i64> {
249    /// a whole number in min..=max (step 1)
250    pub const fn int(name: &'static str, def: i64, min: i64, max: i64) -> Key<i64> {
251        key(name, Kind::Int { def, min, max, step: 1 })
252    }
253
254    /// the step of the number box's arrows and arrow keys
255    pub const fn step(self, step: i64) -> Key<i64> {
256        let mut k = self;
257        if let Kind::Int { def, min, max, .. } = k.d.kind {
258            k.d.kind = Kind::Int { def, min, max, step };
259        }
260        k
261    }
262}
263
264impl Key<f64> {
265    /// a number in min..=max, shown with dec decimals
266    pub const fn num(name: &'static str, def: f64, min: f64, max: f64, dec: u32) -> Key<f64> {
267        key(name, Kind::Num { def, min, max, dec })
268    }
269}
270
271impl Key<String> {
272    /// a text of at most max bytes (UTF-8)
273    pub const fn text(name: &'static str, def: &'static str, max: usize) -> Key<String> {
274        key(name, Kind::Text { def, max })
275    }
276}
277
278impl Key<usize> {
279    /// one of items (stored word, label); the value is its index
280    pub const fn choice(name: &'static str, def: usize, items: &'static [(&'static str, &'static str)]) -> Key<usize> {
281        key(name, Kind::Choice { def, items })
282    }
283}
284
285impl Key<u32> {
286    /// a colour 0xRRGGBB
287    pub const fn color(name: &'static str, def: u32) -> Key<u32> {
288        key(name, Kind::Color(def))
289    }
290}
291
292impl<T> Key<T> {
293    /// the text of the settings page (default: the name)
294    pub const fn label(self, label: &'static str) -> Key<T> {
295        let mut k = self;
296        k.d.label = label;
297        k
298    }
299
300    /// a line of explanation below the label
301    pub const fn help(self, help: &'static str) -> Key<T> {
302        let mut k = self;
303        k.d.help = help;
304        k
305    }
306
307    /// not on the settings page (state the app keeps)
308    pub const fn hidden(self) -> Key<T> {
309        let mut k = self;
310        k.d.flags |= KF_HIDDEN;
311        k
312    }
313
314    /// takes effect after a restart
315    pub const fn restart(self) -> Key<T> {
316        let mut k = self;
317        k.d.flags |= KF_RESTART;
318        k
319    }
320}
321
322/// the keys of a settings file, its format version, the page's section titles and the
323/// migration of older files
324pub struct Schema {
325    pub version: u32,
326    pub keys: &'static [Desc],
327    /// (section, title) for the settings page, in this order (sections without a title come
328    /// after them, titled by their name)
329    pub sections: &'static [(&'static str, &'static str)],
330    /// migrate(from, &mut ini): bring a file of version `from` up to `version`
331    pub migrate: Option<fn(u32, &mut Ini)>,
332}
333
334impl Schema {
335    pub const fn new(version: u32, keys: &'static [Desc]) -> Schema {
336        Schema { version, keys, sections: &[], migrate: None }
337    }
338
339    pub const fn sections(self, sections: &'static [(&'static str, &'static str)]) -> Schema {
340        Schema { sections, ..self }
341    }
342
343    pub const fn migrate(self, f: fn(u32, &mut Ini)) -> Schema {
344        Schema { migrate: Some(f), ..self }
345    }
346
347    /// the index of a key name
348    pub fn index(&self, name: &str) -> Option<usize> {
349        self.keys.iter().position(|d| d.name == name)
350    }
351}
352
353/// "section.key" -> (section, key); no '.': ("", name)
354pub fn split(name: &str) -> (&str, &str) {
355    name.split_once('.').unwrap_or(("", name))
356}
357
358/// for migrations: move a value from one name to another ("section.key"); false: not there
359pub fn rename(ini: &mut Ini, old: &str, new: &str) -> bool {
360    let (os, ok) = split(old);
361    let Some(v) = ini.get(os, ok).map(String::from) else { return false };
362    let (ns, nk) = split(new);
363    if !ini.set(ns, nk, &v) {
364        return false;
365    }
366    if (os, ok) != (ns, nk) {
367        ini.remove(os, ok);
368    }
369    true
370}
371
372// cut s to at most max bytes at a character boundary
373fn cut(s: &mut String, max: usize) {
374    if s.len() > max {
375        let mut n = max;
376        while !s.is_char_boundary(n) {
377            n -= 1;
378        }
379        s.truncate(n);
380    }
381}
382
383/// typed settings over a file (see the module header)
384pub struct Settings {
385    schema: &'static Schema,
386    path: String,
387    ini: Ini,
388    vals: Vec<Val>,
389    dirty: bool,
390    rev: u32,
391    file_version: u32,
392    unreadable: bool, // the file exists but could not be read: save must not overwrite it
393}
394
395impl Settings {
396    /// the settings of a file (a missing or unreadable file: the defaults)
397    pub fn open(path: &str, schema: &'static Schema) -> Settings {
398        let exists = plat::file_exists(path) == 1;
399        let loaded = Ini::load_checked(path);
400        let unreadable = loaded.is_none();
401        let mut s = Settings::from_ini_ex(loaded.unwrap_or_else(Ini::new), schema, exists && !unreadable);
402        s.path = String::from(path);
403        s.unreadable = unreadable;
404        if unreadable {
405            plat::log_warn("settings", &alloc::format!("could not read {}, it is not overwritten", path));
406        }
407        s
408    }
409
410    /// settings only in memory (save does nothing)
411    pub fn new(schema: &'static Schema) -> Settings {
412        Settings::from_ini_ex(Ini::new(), schema, false)
413    }
414
415    /// settings in memory, read from an Ini (migrated like a file)
416    pub fn from_ini(ini: Ini, schema: &'static Schema) -> Settings {
417        Settings::from_ini_ex(ini, schema, true)
418    }
419
420    fn from_ini_ex(mut ini: Ini, schema: &'static Schema, existed: bool) -> Settings {
421        let mut dirty = false;
422        let file_version = ini.get_int("", "version", 0).clamp(0, u32::MAX as i64) as u32;
423        if existed && file_version < schema.version {
424            if let Some(m) = schema.migrate {
425                m(file_version, &mut ini);
426            }
427            ini.set_int("", "version", schema.version as i64);
428            dirty = true;
429            plat::log_info("settings", &alloc::format!("migrated from version {} to {}", file_version, schema.version));
430        } else if file_version > schema.version {
431            plat::log_warn("settings", &alloc::format!("the file has version {}, the program knows {}", file_version, schema.version));
432        }
433        let vals = schema.keys.iter().map(|d| read(&ini, d)).collect();
434        Settings { schema, path: String::new(), ini, vals, dirty, rev: 0, file_version, unreadable: false }
435    }
436
437    pub fn schema(&self) -> &'static Schema {
438        self.schema
439    }
440
441    /// the file's path ("" in memory)
442    pub fn path(&self) -> &str {
443        &self.path
444    }
445
446    /// the format version the file had before a migration (0: none / no version line)
447    pub fn file_version(&self) -> u32 {
448        self.file_version
449    }
450
451    /// a value (the key's default when the key is not in the schema and not in the file)
452    pub fn get<T: Value>(&self, k: &Key<T>) -> T {
453        let v = match self.schema.index(k.d.name) {
454            Some(i) => T::from_val(&self.vals[i]),
455            None => T::from_val(&read(&self.ini, &k.d)),
456        };
457        v.or_else(|| T::from_val(&k.d.default_val())).unwrap_or_else(|| {
458            plat::log_warn("settings", &alloc::format!("{}: the key has another type than the schema's", k.d.name));
459            T::from_val(&Val::Bool(false)).or_else(|| T::from_val(&Val::Int(0))).or_else(|| T::from_val(&Val::Num(0.0))).or_else(|| T::from_val(&Val::Text(String::new()))).or_else(|| T::from_val(&Val::Choice(0))).or_else(|| T::from_val(&Val::Color(0))).expect("settings: the type of a key")
460        })
461    }
462
463    /// a text without copying it
464    pub fn text(&self, k: &Key<String>) -> &str {
465        match self.schema.index(k.d.name).map(|i| &self.vals[i]) {
466            Some(Val::Text(s)) => s,
467            _ => {
468                let (s, n) = k.d.split();
469                match self.ini.get(s, n) {
470                    Some(t) => t,
471                    None => match k.d.kind {
472                        Kind::Text { def, .. } => def,
473                        _ => "",
474                    },
475                }
476            }
477        }
478    }
479
480    /// set a value (made valid for the key) -> it changed
481    pub fn set<T: Value>(&mut self, k: &Key<T>, v: T) -> bool {
482        match self.schema.index(k.d.name) {
483            Some(i) => self.set_at(i, v.into_val()),
484            None => {
485                let v = k.d.fit(v.into_val());
486                if read(&self.ini, &k.d) == v {
487                    return false;
488                }
489                write(&mut self.ini, &k.d, &v);
490                self.changed();
491                true
492            }
493        }
494    }
495
496    /// back to the default (the line leaves the file)
497    pub fn reset<T>(&mut self, k: &Key<T>) -> bool {
498        match self.schema.index(k.d.name) {
499            Some(i) => self.set_at(i, k.d.default_val()),
500            None => {
501                let (s, n) = k.d.split();
502                let gone = self.ini.remove(s, n);
503                if gone {
504                    self.changed();
505                }
506                gone
507            }
508        }
509    }
510
511    /// every key of the schema back to its default
512    pub fn reset_all(&mut self) -> bool {
513        let mut any = false;
514        for i in 0..self.schema.keys.len() {
515            any |= self.set_at(i, self.schema.keys[i].default_val());
516        }
517        any
518    }
519
520    /// the key has its default value
521    pub fn is_default<T>(&self, k: &Key<T>) -> bool {
522        match self.schema.index(k.d.name) {
523            Some(i) => self.vals[i] == self.default_at(i),
524            None => read(&self.ini, &k.d) == k.d.default_val(),
525        }
526    }
527
528    /// the value of schema key i
529    pub fn at(&self, i: usize) -> &Val {
530        &self.vals[i]
531    }
532
533    /// the default of schema key i
534    pub fn default_at(&self, i: usize) -> Val {
535        self.schema.keys[i].default_val()
536    }
537
538    /// set schema key i (made valid) -> it changed
539    pub fn set_at(&mut self, i: usize, v: Val) -> bool {
540        let d = &self.schema.keys[i];
541        let v = d.fit(v);
542        if self.vals[i] == v {
543            return false;
544        }
545        write(&mut self.ini, d, &v);
546        self.vals[i] = v;
547        self.changed();
548        true
549    }
550
551    fn changed(&mut self) {
552        self.dirty = true;
553        self.rev = self.rev.wrapping_add(1);
554    }
555
556    /// unsaved changes
557    pub fn dirty(&self) -> bool {
558        self.dirty
559    }
560
561    /// counts every change (redraw or apply what depends on the settings when it moved)
562    pub fn revision(&self) -> u32 {
563        self.rev
564    }
565
566    /// the file's data (free-form sections the schema does not describe)
567    pub fn ini(&self) -> &Ini {
568        &self.ini
569    }
570
571    /// change free-form data (marks the settings dirty; keys of the schema: use set)
572    pub fn ini_mut(&mut self) -> &mut Ini {
573        self.changed();
574        &mut self.ini
575    }
576
577    /// write the file when something changed (atomic; in memory: nothing) -> ok
578    pub fn save(&mut self) -> bool {
579        if !self.dirty {
580            return true;
581        }
582        if self.path.is_empty() {
583            self.dirty = false;
584            return true;
585        }
586        if self.unreadable {
587            return false;
588        }
589        if self.ini.get("", "version").is_none() || self.file_version < self.schema.version {
590            self.ini.set_int("", "version", self.schema.version.max(self.file_version) as i64);
591        }
592        let ok = self.ini.save(&self.path);
593        if ok {
594            self.dirty = false;
595        } else {
596            plat::log_warn("settings", &alloc::format!("could not write {}", self.path));
597        }
598        ok
599    }
600}
601
602// the key's value in the file (invalid: the default and a WARN)
603fn read(ini: &Ini, d: &Desc) -> Val {
604    let (s, n) = d.split();
605    match ini.get(s, n) {
606        None => d.default_val(),
607        Some(t) => match d.parse(t) {
608            Some(v) => v,
609            None => {
610                plat::log_warn("settings", &alloc::format!("{}: \"{}\" is not a valid value, the default is used", d.name, t));
611                d.default_val()
612            }
613        },
614    }
615}
616
617// the key's line: removed at the default, else written
618fn write(ini: &mut Ini, d: &Desc, v: &Val) {
619    let (s, n) = d.split();
620    if *v == d.default_val() {
621        ini.remove(s, n);
622    } else {
623        ini.set(s, n, &d.format(v));
624    }
625}