Skip to main content

cadmus_core/settings/
mod.rs

1mod preset;
2pub mod versioned;
3
4use crate::color::{BLACK, Color};
5use crate::device::CURRENT_DEVICE;
6use crate::fl;
7use crate::frontlight::{LightLevel, LightLevels};
8use crate::geolocation::Coordinates;
9use crate::i18n::I18nDisplay;
10use crate::metadata::{SortMethod, TextAlign};
11use crate::unit::mm_to_px;
12use fxhash::FxHashSet;
13use sqlx::encode::IsNull;
14use sqlx::error::BoxDynError;
15use sqlx::sqlite::{Sqlite, SqliteArgumentValue, SqliteTypeInfo, SqliteValueRef};
16use unic_langid::LanguageIdentifier;
17
18pub use self::preset::{LightPreset, guess_frontlight};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, HashMap};
21use std::env;
22use std::fmt::{self, Debug};
23use std::ops::{Index, IndexMut};
24use std::path::PathBuf;
25
26pub const SETTINGS_PATH: &str = "Settings.toml";
27pub const DEFAULT_FONT_PATH: &str = "/mnt/onboard/fonts";
28pub const INTERNAL_CARD_ROOT: &str = "/mnt/onboard";
29pub const EXTERNAL_CARD_ROOT: &str = "/mnt/sd";
30const LOGO_SPECIAL_PATH: &str = "logo:";
31const COVER_SPECIAL_PATH: &str = "cover:";
32const CALENDAR_SPECIAL_PATH: &str = "calendar:";
33const BLANK_SPECIAL_PATH: &str = "blank:";
34const BLANK_INVERTED_SPECIAL_PATH: &str = "blank-inverted:";
35
36/// How to display intermission screens.
37/// Logo, Cover, Calendar, Blank and BlankInverted are special values that map
38/// to built-in displays.
39#[derive(Debug, Clone, Eq, PartialEq)]
40pub enum IntermissionDisplay {
41    /// Display the built-in logo image.
42    Logo,
43    /// Display the cover of the currently reading book.
44    Cover,
45    /// Display the built-in calendar view.
46    Calendar,
47    /// Display a blank white screen.
48    Blank,
49    /// Display a blank black screen.
50    BlankInverted,
51    /// Display a custom image from the given path.
52    Image(PathBuf),
53}
54
55impl Serialize for IntermissionDisplay {
56    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
57    where
58        S: serde::Serializer,
59    {
60        match self {
61            IntermissionDisplay::Logo => serializer.serialize_str(LOGO_SPECIAL_PATH),
62            IntermissionDisplay::Cover => serializer.serialize_str(COVER_SPECIAL_PATH),
63            IntermissionDisplay::Calendar => serializer.serialize_str(CALENDAR_SPECIAL_PATH),
64            IntermissionDisplay::Blank => serializer.serialize_str(BLANK_SPECIAL_PATH),
65            IntermissionDisplay::BlankInverted => {
66                serializer.serialize_str(BLANK_INVERTED_SPECIAL_PATH)
67            }
68            IntermissionDisplay::Image(path) => {
69                serializer.serialize_str(path.to_string_lossy().as_ref())
70            }
71        }
72    }
73}
74
75impl<'de> Deserialize<'de> for IntermissionDisplay {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: serde::Deserializer<'de>,
79    {
80        let s = String::deserialize(deserializer)?;
81        Ok(match s.as_str() {
82            LOGO_SPECIAL_PATH => IntermissionDisplay::Logo,
83            COVER_SPECIAL_PATH => IntermissionDisplay::Cover,
84            CALENDAR_SPECIAL_PATH => IntermissionDisplay::Calendar,
85            BLANK_SPECIAL_PATH => IntermissionDisplay::Blank,
86            BLANK_INVERTED_SPECIAL_PATH => IntermissionDisplay::BlankInverted,
87            _ => IntermissionDisplay::Image(PathBuf::from(s)),
88        })
89    }
90}
91
92impl fmt::Display for IntermissionDisplay {
93    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
94        match self {
95            IntermissionDisplay::Logo => write!(f, "Logo"),
96            IntermissionDisplay::Cover => write!(f, "Cover"),
97            IntermissionDisplay::Calendar => write!(f, "Calendar"),
98            IntermissionDisplay::Blank => write!(f, "Blank"),
99            IntermissionDisplay::BlankInverted => write!(f, "Blank Inverted"),
100            IntermissionDisplay::Image(_) => write!(f, "Custom"),
101        }
102    }
103}
104
105impl IntermissionDisplay {
106    /// Returns whether this display mode is supported for the given intermission kind.
107    pub fn is_supported_for(&self, kind: IntermKind) -> bool {
108        if !matches!(self, IntermissionDisplay::Calendar) {
109            return true;
110        }
111
112        kind.supports_calendar()
113    }
114}
115
116// Default font size in points.
117pub const DEFAULT_FONT_SIZE: f32 = 11.0;
118// Default margin width in millimeters.
119pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
120// Default line height in ems.
121pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
122// Default font family name.
123pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
124// Default text alignment.
125pub const DEFAULT_TEXT_ALIGN: TextAlign = TextAlign::Left;
126pub const HYPHEN_PENALTY: i32 = 50;
127pub const STRETCH_TOLERANCE: f32 = 1.26;
128
129#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum RotationLock {
132    Landscape,
133    Portrait,
134    Current,
135}
136
137#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum ButtonScheme {
140    Natural,
141    Inverted,
142}
143
144impl fmt::Display for ButtonScheme {
145    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
146        Debug::fmt(self, f)
147    }
148}
149
150impl I18nDisplay for ButtonScheme {
151    fn to_i18n_string(&self) -> String {
152        match self {
153            ButtonScheme::Natural => fl!("settings-button-scheme-natural"),
154            ButtonScheme::Inverted => fl!("settings-button-scheme-inverted"),
155        }
156    }
157}
158
159#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "kebab-case")]
161pub enum IntermKind {
162    Suspend,
163    PowerOff,
164    Share,
165}
166
167impl IntermKind {
168    pub fn text(&self) -> &str {
169        match self {
170            IntermKind::Suspend => "Sleeping",
171            IntermKind::PowerOff => "Powered off",
172            IntermKind::Share => "Shared",
173        }
174    }
175
176    pub fn supports_calendar(self) -> bool {
177        matches!(self, IntermKind::Suspend)
178    }
179}
180
181/// Configuration for intermission screen displays.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "kebab-case")]
184pub struct Intermissions {
185    suspend: IntermissionDisplay,
186    power_off: IntermissionDisplay,
187    share: IntermissionDisplay,
188}
189
190impl Index<IntermKind> for Intermissions {
191    type Output = IntermissionDisplay;
192
193    fn index(&self, key: IntermKind) -> &Self::Output {
194        match key {
195            IntermKind::Suspend => &self.suspend,
196            IntermKind::PowerOff => &self.power_off,
197            IntermKind::Share => &self.share,
198        }
199    }
200}
201
202impl IndexMut<IntermKind> for Intermissions {
203    fn index_mut(&mut self, key: IntermKind) -> &mut Self::Output {
204        match key {
205            IntermKind::Suspend => &mut self.suspend,
206            IntermKind::PowerOff => &mut self.power_off,
207            IntermKind::Share => &mut self.share,
208        }
209    }
210}
211
212impl Intermissions {
213    /// Updates an intermission display when the selected mode is valid for the target kind.
214    pub fn set_display(&mut self, kind: IntermKind, display: IntermissionDisplay) -> bool {
215        if !display.is_supported_for(kind) {
216            return false;
217        }
218
219        self[kind] = display;
220        true
221    }
222
223    /// Replaces unsupported intermission modes with the default logo display.
224    pub fn sanitize(&mut self) -> bool {
225        let mut changed = false;
226
227        changed |= self.sanitize_kind(IntermKind::Suspend);
228        changed |= self.sanitize_kind(IntermKind::PowerOff);
229        changed |= self.sanitize_kind(IntermKind::Share);
230
231        if changed {
232            eprintln!(
233                "ignoring unsupported calendar intermissions for power-off/share; using logo instead"
234            );
235        }
236
237        changed
238    }
239
240    fn sanitize_kind(&mut self, kind: IntermKind) -> bool {
241        if self[kind].is_supported_for(kind) {
242            return false;
243        }
244
245        self[kind] = IntermissionDisplay::Logo;
246        true
247    }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(default, rename_all = "kebab-case")]
252pub struct Settings {
253    pub selected_library: usize,
254    pub keyboard_layout: String,
255    pub frontlight: bool,
256    pub wifi: bool,
257    pub inverted: bool,
258    pub sleep_cover: bool,
259    pub auto_share: bool,
260    pub auto_time: bool,
261    /// Whether frontlight levels should be managed automatically.
262    ///
263    /// When enabled, Cadmus derives brightness and warmth from the current
264    /// time and a known location.
265    pub auto_frontlight: bool,
266    /// The brightness to use after sunset and before sunrise when automatic
267    /// frontlight control is enabled.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub auto_frontlight_night_brightness: Option<LightLevel>,
270    /// A user-specified location for automatic frontlight calculations.
271    ///
272    /// When present, this takes precedence over coordinates discovered from
273    /// automatic time syncing.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub auto_frontlight_manual_coordinates: Option<Coordinates>,
276    /// The last automatically discovered location for automatic frontlight
277    /// calculations.
278    ///
279    /// This is typically refreshed during automatic time synchronization and
280    /// is used only when no manual coordinates are configured.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub auto_frontlight_last_coordinates: Option<Coordinates>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub rotation_lock: Option<RotationLock>,
285    pub button_scheme: ButtonScheme,
286    pub auto_suspend: f32,
287    pub auto_power_off: f32,
288    pub time_format: String,
289    pub date_format: String,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub external_urls_queue: Option<PathBuf>,
292    #[serde(skip_serializing_if = "Vec::is_empty")]
293    pub libraries: Vec<LibrarySettings>,
294    pub intermissions: Intermissions,
295    #[serde(skip_serializing_if = "Vec::is_empty")]
296    pub frontlight_presets: Vec<LightPreset>,
297    pub home: HomeSettings,
298    pub reader: ReaderSettings,
299    pub import: ImportSettings,
300    pub dictionary: DictionarySettings,
301    pub sketch: SketchSettings,
302    pub calculator: CalculatorSettings,
303    pub battery: BatterySettings,
304    pub frontlight_levels: LightLevels,
305    pub ota: OtaSettings,
306    pub logging: LoggingSettings,
307    pub settings_retention: usize,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub locale: Option<LanguageIdentifier>,
310}
311
312impl Settings {
313    /// Normalizes unsupported settings values loaded from disk.
314    pub fn sanitize(&mut self) -> bool {
315        self.intermissions.sanitize()
316    }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(default, rename_all = "kebab-case")]
321pub struct LibrarySettings {
322    pub name: String,
323    pub path: PathBuf,
324    pub sort_method: SortMethod,
325    pub first_column: FirstColumn,
326    pub second_column: SecondColumn,
327    pub thumbnail_previews: bool,
328    #[serde(skip_serializing_if = "Vec::is_empty")]
329    pub hooks: Vec<Hook>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub finished: Option<FinishedAction>,
332}
333
334impl Default for LibrarySettings {
335    fn default() -> Self {
336        LibrarySettings {
337            name: "Unnamed".to_string(),
338            path: env::current_dir()
339                .ok()
340                .unwrap_or_else(|| PathBuf::from("/")),
341            sort_method: SortMethod::Opened,
342            first_column: FirstColumn::TitleAndAuthor,
343            second_column: SecondColumn::Progress,
344            thumbnail_previews: true,
345            hooks: Vec::new(),
346            finished: None,
347        }
348    }
349}
350
351/// Settings controlling which files are imported into the library.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default, rename_all = "kebab-case")]
354pub struct ImportSettings {
355    pub sync_metadata: bool,
356    pub metadata_kinds: FxHashSet<String>,
357    #[serde(deserialize_with = "deserialize_file_extension_set")]
358    pub allowed_kinds: FxHashSet<FileExtension>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(default, rename_all = "kebab-case")]
363pub struct DictionarySettings {
364    pub margin_width: i32,
365    pub font_size: f32,
366    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
367    pub languages: BTreeMap<String, Vec<String>>,
368}
369
370impl Default for DictionarySettings {
371    fn default() -> Self {
372        DictionarySettings {
373            font_size: 11.0,
374            margin_width: 4,
375            languages: BTreeMap::new(),
376        }
377    }
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(default, rename_all = "kebab-case")]
382pub struct SketchSettings {
383    pub save_path: PathBuf,
384    pub notify_success: bool,
385    pub pen: Pen,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389#[serde(default, rename_all = "kebab-case")]
390pub struct CalculatorSettings {
391    pub font_size: f32,
392    pub margin_width: i32,
393    pub history_size: usize,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[serde(default, rename_all = "kebab-case")]
398pub struct Pen {
399    pub size: i32,
400    pub color: Color,
401    pub dynamic: bool,
402    pub amplitude: f32,
403    pub min_speed: f32,
404    pub max_speed: f32,
405}
406
407impl Default for Pen {
408    fn default() -> Self {
409        Pen {
410            size: 2,
411            color: BLACK,
412            dynamic: true,
413            amplitude: 4.0,
414            min_speed: 0.0,
415            max_speed: mm_to_px(254.0, CURRENT_DEVICE.dpi),
416        }
417    }
418}
419
420impl Default for SketchSettings {
421    fn default() -> Self {
422        SketchSettings {
423            save_path: PathBuf::from("Sketches"),
424            notify_success: true,
425            pen: Pen::default(),
426        }
427    }
428}
429
430impl Default for CalculatorSettings {
431    fn default() -> Self {
432        CalculatorSettings {
433            font_size: 8.0,
434            margin_width: 2,
435            history_size: 4096,
436        }
437    }
438}
439
440#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "kebab-case")]
442pub struct Columns {
443    first: FirstColumn,
444    second: SecondColumn,
445}
446
447#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
448#[serde(rename_all = "kebab-case")]
449pub enum FirstColumn {
450    TitleAndAuthor,
451    FileName,
452}
453
454#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
455#[serde(rename_all = "kebab-case")]
456pub enum SecondColumn {
457    Progress,
458    Year,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[serde(default, rename_all = "kebab-case")]
463pub struct Hook {
464    pub path: PathBuf,
465    pub program: PathBuf,
466    pub sort_method: Option<SortMethod>,
467    pub first_column: Option<FirstColumn>,
468    pub second_column: Option<SecondColumn>,
469}
470
471impl Default for Hook {
472    fn default() -> Self {
473        Hook {
474            path: PathBuf::default(),
475            program: PathBuf::default(),
476            sort_method: None,
477            first_column: None,
478            second_column: None,
479        }
480    }
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(default, rename_all = "kebab-case")]
485pub struct HomeSettings {
486    pub address_bar: bool,
487    pub navigation_bar: bool,
488    pub max_levels: usize,
489    pub max_trash_size: u64,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493#[serde(default, rename_all = "kebab-case")]
494pub struct RefreshRateSettings {
495    #[serde(flatten)]
496    pub global: RefreshRatePair,
497    #[serde(skip_serializing_if = "HashMap::is_empty")]
498    pub by_kind: HashMap<String, RefreshRatePair>,
499}
500
501/// A known file extension for which per-kind refresh rates can be configured.
502///
503/// The serialized string (e.g. `"epub"`, `"cbz"`) is used as the key in
504/// [`RefreshRateSettings::by_kind`] and as values in [`ImportSettings::allowed_kinds`].
505#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
506#[serde(rename_all = "lowercase")]
507pub enum FileExtension {
508    /// Comic book RAR archive.
509    Cbr,
510    /// Comic book ZIP archive.
511    Cbz,
512    /// DjVu document.
513    Djvu,
514    /// EPUB ebook.
515    Epub,
516    /// FictionBook document.
517    Fb2,
518    /// HTML document.
519    Html,
520    /// JPEG image using the long extension.
521    Jpeg,
522    /// JPEG image using the short extension.
523    Jpg,
524    /// Mobipocket ebook.
525    Mobi,
526    /// OpenXPS document.
527    Oxps,
528    /// PDF document.
529    Pdf,
530    /// PNG image.
531    Png,
532    /// SVG image.
533    Svg,
534    /// Plain text document.
535    Txt,
536    /// WebP image.
537    Webp,
538    /// XPS document.
539    Xps,
540}
541
542impl FileExtension {
543    /// Returns all known file extensions.
544    pub fn all() -> &'static [FileExtension] {
545        &[
546            FileExtension::Cbr,
547            FileExtension::Cbz,
548            FileExtension::Djvu,
549            FileExtension::Epub,
550            FileExtension::Fb2,
551            FileExtension::Html,
552            FileExtension::Jpeg,
553            FileExtension::Jpg,
554            FileExtension::Mobi,
555            FileExtension::Oxps,
556            FileExtension::Pdf,
557            FileExtension::Png,
558            FileExtension::Svg,
559            FileExtension::Txt,
560            FileExtension::Webp,
561            FileExtension::Xps,
562        ]
563    }
564
565    /// Returns the lowercase string representation used as the TOML key.
566    pub fn as_str(self) -> &'static str {
567        match self {
568            FileExtension::Cbr => "cbr",
569            FileExtension::Cbz => "cbz",
570            FileExtension::Djvu => "djvu",
571            FileExtension::Epub => "epub",
572            FileExtension::Fb2 => "fb2",
573            FileExtension::Html => "html",
574            FileExtension::Jpeg => "jpeg",
575            FileExtension::Jpg => "jpg",
576            FileExtension::Mobi => "mobi",
577            FileExtension::Oxps => "oxps",
578            FileExtension::Pdf => "pdf",
579            FileExtension::Png => "png",
580            FileExtension::Svg => "svg",
581            FileExtension::Txt => "txt",
582            FileExtension::Webp => "webp",
583            FileExtension::Xps => "xps",
584        }
585    }
586}
587
588/// Error returned when a string does not match any known file extension.
589#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
590#[error("unknown file extension: {0}")]
591pub struct UnknownFileExtension(
592    /// Extension string that could not be parsed.
593    pub String,
594);
595
596impl std::str::FromStr for FileExtension {
597    type Err = UnknownFileExtension;
598
599    fn from_str(s: &str) -> Result<Self, Self::Err> {
600        match s {
601            "cbr" => Ok(FileExtension::Cbr),
602            "cbz" => Ok(FileExtension::Cbz),
603            "djvu" => Ok(FileExtension::Djvu),
604            "epub" => Ok(FileExtension::Epub),
605            "fb2" => Ok(FileExtension::Fb2),
606            "html" | "htm" => Ok(FileExtension::Html),
607            "jpeg" => Ok(FileExtension::Jpeg),
608            "jpg" => Ok(FileExtension::Jpg),
609            "mobi" => Ok(FileExtension::Mobi),
610            "oxps" => Ok(FileExtension::Oxps),
611            "pdf" => Ok(FileExtension::Pdf),
612            "png" => Ok(FileExtension::Png),
613            "svg" => Ok(FileExtension::Svg),
614            "txt" => Ok(FileExtension::Txt),
615            "webp" => Ok(FileExtension::Webp),
616            "xps" => Ok(FileExtension::Xps),
617            _ => Err(UnknownFileExtension(s.to_owned())),
618        }
619    }
620}
621
622impl sqlx::Type<Sqlite> for FileExtension {
623    fn type_info() -> SqliteTypeInfo {
624        <String as sqlx::Type<Sqlite>>::type_info()
625    }
626
627    fn compatible(ty: &SqliteTypeInfo) -> bool {
628        <String as sqlx::Type<Sqlite>>::compatible(ty)
629    }
630}
631
632impl<'q> sqlx::Encode<'q, Sqlite> for FileExtension {
633    fn encode_by_ref(&self, buf: &mut Vec<SqliteArgumentValue<'q>>) -> Result<IsNull, BoxDynError> {
634        self.as_str().encode_by_ref(buf)
635    }
636}
637
638impl<'r> sqlx::Decode<'r, Sqlite> for FileExtension {
639    fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
640        let s = <String as sqlx::Decode<'r, Sqlite>>::decode(value)?;
641        s.parse()
642            .map_err(|UnknownFileExtension(ext)| format!("unknown file extension: {ext}").into())
643    }
644}
645
646fn deserialize_file_extension_set<'de, D>(
647    deserializer: D,
648) -> Result<FxHashSet<FileExtension>, D::Error>
649where
650    D: serde::Deserializer<'de>,
651{
652    struct FileExtensionSetVisitor;
653
654    impl<'de> serde::de::Visitor<'de> for FileExtensionSetVisitor {
655        type Value = FxHashSet<FileExtension>;
656
657        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
658            formatter.write_str("a sequence of file extension strings")
659        }
660
661        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
662        where
663            A: serde::de::SeqAccess<'de>,
664        {
665            let mut set = FxHashSet::default();
666
667            while let Some(s) = seq.next_element::<String>()? {
668                match s.parse::<FileExtension>() {
669                    Ok(ext) => {
670                        set.insert(ext);
671                    }
672                    Err(e) => {
673                        tracing::warn!(extension = %s, error = %e, "failed to load extension");
674                    }
675                }
676            }
677
678            Ok(set)
679        }
680    }
681
682    deserializer.deserialize_seq(FileExtensionSetVisitor)
683}
684
685impl fmt::Display for FileExtension {
686    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
687        write!(f, "{}", self.as_str())
688    }
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
692#[serde(rename_all = "kebab-case")]
693pub struct RefreshRatePair {
694    pub regular: u8,
695    pub inverted: u8,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(default, rename_all = "kebab-case")]
700pub struct ReaderSettings {
701    pub finished: FinishedAction,
702    pub south_east_corner: SouthEastCornerAction,
703    pub bottom_right_gesture: BottomRightGestureAction,
704    pub south_strip: SouthStripAction,
705    pub west_strip: WestStripAction,
706    pub east_strip: EastStripAction,
707    pub strip_width: f32,
708    pub corner_width: f32,
709    pub font_path: String,
710    pub font_family: String,
711    pub font_size: f32,
712    pub min_font_size: f32,
713    pub max_font_size: f32,
714    pub text_align: TextAlign,
715    pub margin_width: i32,
716    pub min_margin_width: i32,
717    pub max_margin_width: i32,
718    pub line_height: f32,
719    pub continuous_fit_to_width: bool,
720    pub ignore_document_css: bool,
721    #[serde(deserialize_with = "deserialize_file_extension_set")]
722    pub dithered_kinds: FxHashSet<FileExtension>,
723    pub paragraph_breaker: ParagraphBreakerSettings,
724    pub refresh_rate: RefreshRateSettings,
725}
726
727#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
728#[serde(default, rename_all = "kebab-case")]
729pub struct ParagraphBreakerSettings {
730    pub hyphen_penalty: i32,
731    pub stretch_tolerance: f32,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
735#[serde(default, rename_all = "kebab-case")]
736pub struct BatterySettings {
737    pub warn: f32,
738    pub power_off: f32,
739}
740
741/// Configures structured logging to disk and optional OTLP export.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743#[serde(default, rename_all = "kebab-case")]
744pub struct LoggingSettings {
745    /// Enables logging output when set to true.
746    pub enabled: bool,
747    /// Minimum log level to record (for example: "info", "debug").
748    pub level: String,
749    /// Maximum number of rotated log files to keep.
750    pub max_files: usize,
751    /// Directory where JSON log files are written.
752    pub directory: PathBuf,
753    /// Optional OTLP endpoint; env vars override this value.
754    #[serde(skip_serializing_if = "Option::is_none")]
755    pub otlp_endpoint: Option<String>,
756    /// Optional Pyroscope server URL for continuous profiling; env vars override this value.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub pyroscope_endpoint: Option<String>,
759    /// Captures kernel logs via logread if kernel log capture is supported.
760    pub enable_kern_log: bool,
761    /// Captures D-Bus signals via the in-process zbus DbusMonitorTask when D-Bus log capture is supported.
762    pub enable_dbus_log: bool,
763}
764
765/// OTA update settings.
766///
767/// Authentication is handled via GitHub device auth flow — no token configuration
768/// is required in `Settings.toml`. The token is obtained interactively and
769/// persisted to disk by the application.
770#[derive(Debug, Clone, Default, Serialize, Deserialize)]
771#[serde(default, rename_all = "kebab-case")]
772pub struct OtaSettings {}
773
774#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
775#[serde(rename_all = "kebab-case")]
776pub enum FinishedAction {
777    Notify,
778    Close,
779    GoToNext,
780}
781
782impl fmt::Display for FinishedAction {
783    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
784        match self {
785            FinishedAction::Notify => write!(f, "Notify"),
786            FinishedAction::Close => write!(f, "Close"),
787            FinishedAction::GoToNext => write!(f, "Go to Next"),
788        }
789    }
790}
791
792impl I18nDisplay for FinishedAction {
793    fn to_i18n_string(&self) -> String {
794        match self {
795            FinishedAction::Notify => fl!("settings-finished-action-notify"),
796            FinishedAction::Close => fl!("settings-finished-action-close"),
797            FinishedAction::GoToNext => fl!("settings-finished-action-goto-next"),
798        }
799    }
800}
801
802#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
803#[serde(rename_all = "kebab-case")]
804pub enum SouthEastCornerAction {
805    NextPage,
806    GoToPage,
807}
808
809#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
810#[serde(rename_all = "kebab-case")]
811pub enum BottomRightGestureAction {
812    ToggleDithered,
813    ToggleInverted,
814}
815
816#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
817#[serde(rename_all = "kebab-case")]
818pub enum SouthStripAction {
819    ToggleBars,
820    NextPage,
821}
822
823#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
824#[serde(rename_all = "kebab-case")]
825pub enum EastStripAction {
826    PreviousPage,
827    NextPage,
828    None,
829}
830
831#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
832#[serde(rename_all = "kebab-case")]
833pub enum WestStripAction {
834    PreviousPage,
835    NextPage,
836    None,
837}
838
839impl Default for RefreshRateSettings {
840    fn default() -> Self {
841        RefreshRateSettings {
842            global: RefreshRatePair {
843                regular: 8,
844                inverted: 2,
845            },
846            by_kind: HashMap::new(),
847        }
848    }
849}
850
851impl Default for HomeSettings {
852    fn default() -> Self {
853        HomeSettings {
854            address_bar: false,
855            navigation_bar: true,
856            max_levels: 3,
857            max_trash_size: 32 * (1 << 20),
858        }
859    }
860}
861
862impl Default for ParagraphBreakerSettings {
863    fn default() -> Self {
864        ParagraphBreakerSettings {
865            hyphen_penalty: HYPHEN_PENALTY,
866            stretch_tolerance: STRETCH_TOLERANCE,
867        }
868    }
869}
870
871impl Default for ReaderSettings {
872    fn default() -> Self {
873        ReaderSettings {
874            finished: FinishedAction::Close,
875            south_east_corner: SouthEastCornerAction::GoToPage,
876            bottom_right_gesture: BottomRightGestureAction::ToggleDithered,
877            south_strip: SouthStripAction::ToggleBars,
878            west_strip: WestStripAction::PreviousPage,
879            east_strip: EastStripAction::NextPage,
880            strip_width: 0.6,
881            corner_width: 0.4,
882            font_path: DEFAULT_FONT_PATH.to_string(),
883            font_family: DEFAULT_FONT_FAMILY.to_string(),
884            font_size: DEFAULT_FONT_SIZE,
885            min_font_size: DEFAULT_FONT_SIZE / 2.0,
886            max_font_size: 3.0 * DEFAULT_FONT_SIZE / 2.0,
887            text_align: DEFAULT_TEXT_ALIGN,
888            margin_width: DEFAULT_MARGIN_WIDTH,
889            min_margin_width: DEFAULT_MARGIN_WIDTH.saturating_sub(8),
890            max_margin_width: DEFAULT_MARGIN_WIDTH.saturating_add(2),
891            line_height: DEFAULT_LINE_HEIGHT,
892            continuous_fit_to_width: true,
893            ignore_document_css: false,
894            dithered_kinds: [
895                FileExtension::Cbz,
896                FileExtension::Png,
897                FileExtension::Jpg,
898                FileExtension::Jpeg,
899                FileExtension::Webp,
900            ]
901            .iter()
902            .copied()
903            .collect(),
904            paragraph_breaker: ParagraphBreakerSettings::default(),
905            refresh_rate: RefreshRateSettings::default(),
906        }
907    }
908}
909
910impl Default for ImportSettings {
911    fn default() -> Self {
912        ImportSettings {
913            sync_metadata: true,
914            metadata_kinds: ["epub", "pdf", "djvu"]
915                .iter()
916                .map(|k| k.to_string())
917                .collect(),
918            allowed_kinds: [
919                FileExtension::Pdf,
920                FileExtension::Djvu,
921                FileExtension::Epub,
922                FileExtension::Fb2,
923                FileExtension::Txt,
924                FileExtension::Xps,
925                FileExtension::Oxps,
926                FileExtension::Mobi,
927                FileExtension::Cbz,
928                FileExtension::Webp,
929                FileExtension::Png,
930                FileExtension::Jpg,
931                FileExtension::Jpeg,
932            ]
933            .iter()
934            .copied()
935            .collect(),
936        }
937    }
938}
939
940impl ImportSettings {
941    /// Returns `true` if `kind` is in the set of allowed file kinds.
942    pub fn is_kind_allowed(&self, kind: FileExtension) -> bool {
943        self.allowed_kinds.contains(&kind)
944    }
945}
946
947impl Default for BatterySettings {
948    fn default() -> Self {
949        BatterySettings {
950            warn: 10.0,
951            power_off: 3.0,
952        }
953    }
954}
955
956impl Default for LoggingSettings {
957    fn default() -> Self {
958        LoggingSettings {
959            enabled: true,
960            level: "info".to_string(),
961            max_files: 3,
962            directory: PathBuf::from("logs"),
963            otlp_endpoint: None,
964            pyroscope_endpoint: None,
965            enable_kern_log: false,
966            enable_dbus_log: false,
967        }
968    }
969}
970
971impl Default for Settings {
972    fn default() -> Self {
973        Settings {
974            selected_library: 0,
975            libraries: cfg_select! {
976                feature = "emulator" => {
977                    vec![LibrarySettings {
978                        name: "Cadmus Source".to_string(),
979                        path: PathBuf::from("."),
980                        ..Default::default()
981                    }]
982                }
983                _ => {
984                    vec![
985                        LibrarySettings {
986                            name: "On Board".to_string(),
987                            path: PathBuf::from(INTERNAL_CARD_ROOT),
988                            hooks: vec![Hook {
989                                path: PathBuf::from("Articles"),
990                                program: PathBuf::from("bin/article_fetcher/article_fetcher"),
991                                sort_method: Some(SortMethod::Added),
992                                first_column: Some(FirstColumn::TitleAndAuthor),
993                                second_column: Some(SecondColumn::Progress),
994                            }],
995                            ..Default::default()
996                        },
997                        LibrarySettings {
998                            name: "Removable".to_string(),
999                            path: PathBuf::from(EXTERNAL_CARD_ROOT),
1000                            ..Default::default()
1001                        },
1002                        LibrarySettings {
1003                            name: "Dropbox".to_string(),
1004                            path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
1005                            ..Default::default()
1006                        },
1007                        LibrarySettings {
1008                            name: "KePub".to_string(),
1009                            path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
1010                            ..Default::default()
1011                        },
1012                    ]
1013                }
1014            },
1015            external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
1016            keyboard_layout: "English".to_string(),
1017            frontlight: true,
1018            wifi: false,
1019            inverted: false,
1020            sleep_cover: true,
1021            auto_share: false,
1022            auto_time: false,
1023            auto_frontlight: false,
1024            auto_frontlight_night_brightness: Some(LightLevel::default()),
1025            auto_frontlight_manual_coordinates: None,
1026            auto_frontlight_last_coordinates: None,
1027            rotation_lock: None,
1028            button_scheme: ButtonScheme::Natural,
1029            auto_suspend: 30.0,
1030            auto_power_off: 3.0,
1031            time_format: "%H:%M".to_string(),
1032            date_format: "%A, %B %-d, %Y".to_string(),
1033            intermissions: Intermissions {
1034                suspend: IntermissionDisplay::Logo,
1035                power_off: IntermissionDisplay::Logo,
1036                share: IntermissionDisplay::Logo,
1037            },
1038            home: HomeSettings::default(),
1039            reader: ReaderSettings::default(),
1040            import: ImportSettings::default(),
1041            dictionary: DictionarySettings::default(),
1042            sketch: SketchSettings::default(),
1043            calculator: CalculatorSettings::default(),
1044            battery: BatterySettings::default(),
1045            frontlight_levels: LightLevels::default(),
1046            frontlight_presets: Vec::new(),
1047            ota: OtaSettings::default(),
1048            logging: LoggingSettings::default(),
1049            settings_retention: 3,
1050            locale: None,
1051        }
1052    }
1053}
1054
1055/// Returns the coordinates to use for automatic frontlight calculations.
1056///
1057/// Manual coordinates take precedence over the last automatically discovered
1058/// location.
1059pub fn resolve_coordinates(settings: &Settings) -> Option<Coordinates> {
1060    settings
1061        .auto_frontlight_manual_coordinates
1062        .or(settings.auto_frontlight_last_coordinates)
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    #[test]
1070    fn test_ota_settings_serializes_empty() {
1071        let settings = OtaSettings::default();
1072        let serialized = toml::to_string(&settings).expect("Failed to serialize");
1073        assert!(
1074            serialized.is_empty(),
1075            "OtaSettings should serialize to an empty string"
1076        );
1077    }
1078
1079    #[test]
1080    fn test_intermissions_struct_serialization() {
1081        let intermissions = Intermissions {
1082            suspend: IntermissionDisplay::Blank,
1083            power_off: IntermissionDisplay::BlankInverted,
1084            share: IntermissionDisplay::Image(PathBuf::from("/custom/share.png")),
1085        };
1086
1087        let serialized = toml::to_string(&intermissions).expect("Failed to serialize");
1088
1089        assert!(
1090            serialized.contains("blank:"),
1091            "Should contain blank: for suspend"
1092        );
1093        assert!(
1094            serialized.contains("blank-inverted:"),
1095            "Should contain blank-inverted: for power-off"
1096        );
1097        assert!(
1098            serialized.contains("/custom/share.png"),
1099            "Should contain custom path for share"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_intermissions_struct_deserialization() {
1105        let toml_str = r#"
1106suspend = "blank:"
1107power-off = "blank-inverted:"
1108share = "/path/to/custom.png"
1109"#;
1110
1111        let intermissions: Intermissions = toml::from_str(toml_str).expect("Failed to deserialize");
1112
1113        assert!(
1114            matches!(intermissions.suspend, IntermissionDisplay::Blank),
1115            "suspend should deserialize to Blank"
1116        );
1117        assert!(
1118            matches!(intermissions.power_off, IntermissionDisplay::BlankInverted),
1119            "power_off should deserialize to BlankInverted"
1120        );
1121        assert!(
1122            matches!(
1123                intermissions.share,
1124                IntermissionDisplay::Image(ref path) if path == &PathBuf::from("/path/to/custom.png")
1125            ),
1126            "share should deserialize to Image with correct path"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_intermissions_struct_round_trip() {
1132        let original = Intermissions {
1133            suspend: IntermissionDisplay::Blank,
1134            power_off: IntermissionDisplay::BlankInverted,
1135            share: IntermissionDisplay::Image(PathBuf::from("/some/custom/image.jpg")),
1136        };
1137
1138        let serialized = toml::to_string(&original).expect("Failed to serialize");
1139        let deserialized: Intermissions =
1140            toml::from_str(&serialized).expect("Failed to deserialize");
1141
1142        assert_eq!(
1143            original.suspend, deserialized.suspend,
1144            "suspend should survive round trip"
1145        );
1146        assert_eq!(
1147            original.power_off, deserialized.power_off,
1148            "power_off should survive round trip"
1149        );
1150        assert_eq!(
1151            original.share, deserialized.share,
1152            "share should survive round trip"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_intermissions_reject_unsupported_calendar_selection() {
1158        let mut intermissions = Intermissions {
1159            suspend: IntermissionDisplay::Logo,
1160            power_off: IntermissionDisplay::Logo,
1161            share: IntermissionDisplay::Logo,
1162        };
1163
1164        assert!(!intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::Calendar));
1165        assert!(!intermissions.set_display(IntermKind::Share, IntermissionDisplay::Calendar));
1166        assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Calendar));
1167
1168        assert_eq!(
1169            intermissions[IntermKind::PowerOff],
1170            IntermissionDisplay::Logo
1171        );
1172        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1173        assert_eq!(
1174            intermissions[IntermKind::Suspend],
1175            IntermissionDisplay::Calendar
1176        );
1177    }
1178
1179    #[test]
1180    fn test_intermissions_accept_blank_selection_for_all_kinds() {
1181        let mut intermissions = Intermissions {
1182            suspend: IntermissionDisplay::Logo,
1183            power_off: IntermissionDisplay::Logo,
1184            share: IntermissionDisplay::Logo,
1185        };
1186
1187        assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Blank));
1188        assert!(
1189            intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::BlankInverted)
1190        );
1191        assert!(intermissions.set_display(IntermKind::Share, IntermissionDisplay::Blank));
1192
1193        assert_eq!(
1194            intermissions[IntermKind::Suspend],
1195            IntermissionDisplay::Blank
1196        );
1197        assert_eq!(
1198            intermissions[IntermKind::PowerOff],
1199            IntermissionDisplay::BlankInverted
1200        );
1201        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Blank);
1202    }
1203
1204    #[test]
1205    fn test_intermissions_sanitize_replaces_unsupported_calendar() {
1206        let mut intermissions = Intermissions {
1207            suspend: IntermissionDisplay::Calendar,
1208            power_off: IntermissionDisplay::Calendar,
1209            share: IntermissionDisplay::Calendar,
1210        };
1211
1212        assert!(intermissions.sanitize());
1213
1214        assert_eq!(
1215            intermissions[IntermKind::Suspend],
1216            IntermissionDisplay::Calendar
1217        );
1218        assert_eq!(
1219            intermissions[IntermKind::PowerOff],
1220            IntermissionDisplay::Logo
1221        );
1222        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1223    }
1224
1225    #[test]
1226    fn test_allowed_kinds_deserializes_known_extensions() {
1227        let toml_str = r#"
1228sync-metadata = true
1229metadata-kinds = ["epub"]
1230allowed-kinds = ["epub", "pdf", "cbz"]
1231"#;
1232        let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1233
1234        assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1235        assert!(settings.allowed_kinds.contains(&FileExtension::Pdf));
1236        assert!(settings.allowed_kinds.contains(&FileExtension::Cbz));
1237        assert_eq!(settings.allowed_kinds.len(), 3);
1238    }
1239
1240    #[test]
1241    fn test_allowed_kinds_silently_drops_unknown_extensions() {
1242        let toml_str = r#"
1243sync-metadata = true
1244metadata-kinds = []
1245allowed-kinds = ["epub", "unknown-format", "another-unknown"]
1246"#;
1247        let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1248
1249        assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1250        assert_eq!(settings.allowed_kinds.len(), 1);
1251    }
1252
1253    #[test]
1254    fn test_dithered_kinds_deserializes_known_extensions() {
1255        let toml_str = r#"
1256finished = "close"
1257dithered-kinds = ["cbz", "png", "jpeg"]
1258"#;
1259        let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1260
1261        assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1262        assert!(settings.dithered_kinds.contains(&FileExtension::Png));
1263        assert!(settings.dithered_kinds.contains(&FileExtension::Jpeg));
1264        assert_eq!(settings.dithered_kinds.len(), 3);
1265    }
1266
1267    #[test]
1268    fn test_dithered_kinds_silently_drops_unknown_extensions() {
1269        let toml_str = r#"
1270finished = "close"
1271dithered-kinds = ["cbz", "unknown-format"]
1272"#;
1273        let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1274
1275        assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1276        assert_eq!(settings.dithered_kinds.len(), 1);
1277    }
1278
1279    #[test]
1280    fn test_file_extension_round_trip_via_from_str() {
1281        for ext in FileExtension::all() {
1282            let parsed = ext.as_str().parse::<FileExtension>().ok();
1283            assert_eq!(parsed, Some(*ext), "round trip failed for {:?}", ext);
1284        }
1285    }
1286
1287    #[test]
1288    fn test_htm_extension_parses_as_html() {
1289        let parsed = "htm".parse::<FileExtension>();
1290        assert_eq!(parsed, Ok(FileExtension::Html));
1291    }
1292
1293    #[test]
1294    fn test_html_extension_still_parses() {
1295        let parsed = "html".parse::<FileExtension>();
1296        assert_eq!(parsed, Ok(FileExtension::Html));
1297    }
1298}