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