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, SqliteArgumentsBuffer, 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#[derive(Debug, Clone, Eq, PartialEq)]
40pub enum IntermissionDisplay {
41 Logo,
43 Cover,
45 Calendar,
47 Blank,
49 BlankInverted,
51 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 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
116pub const DEFAULT_FONT_SIZE: f32 = 11.0;
118pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
120pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
122pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
124pub 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, Default, Eq, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "kebab-case")]
161pub enum StartupMode {
162 #[default]
163 Home,
164 LastFile,
165}
166
167impl fmt::Display for StartupMode {
168 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
169 match self {
170 StartupMode::Home => write!(f, "Home"),
171 StartupMode::LastFile => write!(f, "Last File"),
172 }
173 }
174}
175
176impl I18nDisplay for StartupMode {
177 fn to_i18n_string(&self) -> String {
178 match self {
179 StartupMode::Home => fl!("settings-startup-mode-home"),
180 StartupMode::LastFile => fl!("settings-startup-mode-last-file"),
181 }
182 }
183}
184
185#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum IntermKind {
188 Suspend,
189 PowerOff,
190 Share,
191}
192
193impl IntermKind {
194 pub fn text(&self) -> &str {
195 match self {
196 IntermKind::Suspend => "Sleeping",
197 IntermKind::PowerOff => "Powered off",
198 IntermKind::Share => "Shared",
199 }
200 }
201
202 pub fn supports_calendar(self) -> bool {
203 matches!(self, IntermKind::Suspend)
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "kebab-case")]
210pub struct Intermissions {
211 suspend: IntermissionDisplay,
212 power_off: IntermissionDisplay,
213 share: IntermissionDisplay,
214}
215
216impl Index<IntermKind> for Intermissions {
217 type Output = IntermissionDisplay;
218
219 fn index(&self, key: IntermKind) -> &Self::Output {
220 match key {
221 IntermKind::Suspend => &self.suspend,
222 IntermKind::PowerOff => &self.power_off,
223 IntermKind::Share => &self.share,
224 }
225 }
226}
227
228impl IndexMut<IntermKind> for Intermissions {
229 fn index_mut(&mut self, key: IntermKind) -> &mut Self::Output {
230 match key {
231 IntermKind::Suspend => &mut self.suspend,
232 IntermKind::PowerOff => &mut self.power_off,
233 IntermKind::Share => &mut self.share,
234 }
235 }
236}
237
238impl Intermissions {
239 pub fn set_display(&mut self, kind: IntermKind, display: IntermissionDisplay) -> bool {
241 if !display.is_supported_for(kind) {
242 return false;
243 }
244
245 self[kind] = display;
246 true
247 }
248
249 pub fn sanitize(&mut self) -> bool {
251 let mut changed = false;
252
253 changed |= self.sanitize_kind(IntermKind::Suspend);
254 changed |= self.sanitize_kind(IntermKind::PowerOff);
255 changed |= self.sanitize_kind(IntermKind::Share);
256
257 if changed {
258 eprintln!(
259 "ignoring unsupported calendar intermissions for power-off/share; using logo instead"
260 );
261 }
262
263 changed
264 }
265
266 fn sanitize_kind(&mut self, kind: IntermKind) -> bool {
267 if self[kind].is_supported_for(kind) {
268 return false;
269 }
270
271 self[kind] = IntermissionDisplay::Logo;
272 true
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(default, rename_all = "kebab-case")]
278pub struct Settings {
279 pub selected_library: usize,
280 pub keyboard_layout: String,
281 pub frontlight: bool,
282 pub wifi: bool,
283 pub inverted: bool,
284 pub sleep_cover: bool,
285 pub auto_share: bool,
286 pub auto_time: bool,
287 pub auto_frontlight: bool,
292 #[serde(skip_serializing_if = "Option::is_none")]
295 pub auto_frontlight_night_brightness: Option<LightLevel>,
296 #[serde(skip_serializing_if = "Option::is_none")]
301 pub auto_frontlight_manual_coordinates: Option<Coordinates>,
302 #[serde(skip_serializing_if = "Option::is_none")]
308 pub auto_frontlight_last_coordinates: Option<Coordinates>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub rotation_lock: Option<RotationLock>,
311 pub button_scheme: ButtonScheme,
312 pub auto_suspend: f32,
313 pub auto_power_off: f32,
314 pub time_format: String,
315 pub date_format: String,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub external_urls_queue: Option<PathBuf>,
318 #[serde(skip_serializing_if = "Vec::is_empty")]
319 pub libraries: Vec<LibrarySettings>,
320 pub intermissions: Intermissions,
321 #[serde(skip_serializing_if = "Vec::is_empty")]
322 pub frontlight_presets: Vec<LightPreset>,
323 pub home: HomeSettings,
324 pub reader: ReaderSettings,
325 pub import: ImportSettings,
326 pub dictionary: DictionarySettings,
327 pub sketch: SketchSettings,
328 pub calculator: CalculatorSettings,
329 pub battery: BatterySettings,
330 pub frontlight_levels: LightLevels,
331 pub ota: OtaSettings,
332 pub logging: LoggingSettings,
333 pub settings_retention: usize,
334 pub db_backup_retention: usize,
335 pub startup_mode: StartupMode,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub locale: Option<LanguageIdentifier>,
338}
339
340impl Settings {
341 pub fn sanitize(&mut self) -> bool {
343 self.intermissions.sanitize()
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348#[serde(default, rename_all = "kebab-case")]
349pub struct LibrarySettings {
350 pub name: String,
351 pub path: PathBuf,
352 pub sort_method: SortMethod,
353 pub first_column: FirstColumn,
354 pub second_column: SecondColumn,
355 pub thumbnail_previews: bool,
356 #[serde(skip_serializing_if = "Vec::is_empty")]
357 pub hooks: Vec<Hook>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub finished: Option<FinishedAction>,
360}
361
362impl Default for LibrarySettings {
363 fn default() -> Self {
364 LibrarySettings {
365 name: "Unnamed".to_string(),
366 path: env::current_dir()
367 .ok()
368 .unwrap_or_else(|| PathBuf::from("/")),
369 sort_method: SortMethod::Opened,
370 first_column: FirstColumn::TitleAndAuthor,
371 second_column: SecondColumn::Progress,
372 thumbnail_previews: true,
373 hooks: Vec::new(),
374 finished: None,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(default, rename_all = "kebab-case")]
382pub struct ImportSettings {
383 pub sync_metadata: bool,
384 pub metadata_kinds: FxHashSet<String>,
385 #[serde(deserialize_with = "deserialize_file_extension_set")]
386 pub allowed_kinds: FxHashSet<FileExtension>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390#[serde(default, rename_all = "kebab-case")]
391pub struct DictionarySettings {
392 pub margin_width: i32,
393 pub font_size: f32,
394 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
395 pub languages: BTreeMap<String, Vec<String>>,
396}
397
398impl Default for DictionarySettings {
399 fn default() -> Self {
400 DictionarySettings {
401 font_size: 11.0,
402 margin_width: 4,
403 languages: BTreeMap::new(),
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(default, rename_all = "kebab-case")]
410pub struct SketchSettings {
411 pub save_path: PathBuf,
412 pub notify_success: bool,
413 pub pen: Pen,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default, rename_all = "kebab-case")]
418pub struct CalculatorSettings {
419 pub font_size: f32,
420 pub margin_width: i32,
421 pub history_size: usize,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(default, rename_all = "kebab-case")]
426pub struct Pen {
427 pub size: i32,
428 pub color: Color,
429 pub dynamic: bool,
430 pub amplitude: f32,
431 pub min_speed: f32,
432 pub max_speed: f32,
433}
434
435impl Default for Pen {
436 fn default() -> Self {
437 Pen {
438 size: 2,
439 color: BLACK,
440 dynamic: true,
441 amplitude: 4.0,
442 min_speed: 0.0,
443 max_speed: mm_to_px(254.0, CURRENT_DEVICE.dpi),
444 }
445 }
446}
447
448impl Default for SketchSettings {
449 fn default() -> Self {
450 SketchSettings {
451 save_path: PathBuf::from("Sketches"),
452 notify_success: true,
453 pen: Pen::default(),
454 }
455 }
456}
457
458impl Default for CalculatorSettings {
459 fn default() -> Self {
460 CalculatorSettings {
461 font_size: 8.0,
462 margin_width: 2,
463 history_size: 4096,
464 }
465 }
466}
467
468#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
469#[serde(rename_all = "kebab-case")]
470pub struct Columns {
471 first: FirstColumn,
472 second: SecondColumn,
473}
474
475#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "kebab-case")]
477pub enum FirstColumn {
478 TitleAndAuthor,
479 FileName,
480}
481
482#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
483#[serde(rename_all = "kebab-case")]
484pub enum SecondColumn {
485 Progress,
486 Year,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[serde(default, rename_all = "kebab-case")]
491pub struct Hook {
492 pub path: PathBuf,
493 pub program: PathBuf,
494 pub sort_method: Option<SortMethod>,
495 pub first_column: Option<FirstColumn>,
496 pub second_column: Option<SecondColumn>,
497}
498
499impl Default for Hook {
500 fn default() -> Self {
501 Hook {
502 path: PathBuf::default(),
503 program: PathBuf::default(),
504 sort_method: None,
505 first_column: None,
506 second_column: None,
507 }
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default, rename_all = "kebab-case")]
513pub struct HomeSettings {
514 pub address_bar: bool,
515 pub navigation_bar: bool,
516 pub max_levels: usize,
517 pub max_trash_size: u64,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[serde(default, rename_all = "kebab-case")]
522pub struct RefreshRateSettings {
523 #[serde(flatten)]
524 pub global: RefreshRatePair,
525 #[serde(skip_serializing_if = "HashMap::is_empty")]
526 pub by_kind: HashMap<String, RefreshRatePair>,
527}
528
529#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
534#[serde(rename_all = "lowercase")]
535pub enum FileExtension {
536 Cbr,
538 Cbz,
540 Djvu,
542 Epub,
544 Fb2,
546 Html,
548 Jpeg,
550 Jpg,
552 Mobi,
554 Oxps,
556 Pdf,
558 Png,
560 Svg,
562 Txt,
564 Webp,
566 Xps,
568}
569
570impl FileExtension {
571 pub fn all() -> &'static [FileExtension] {
573 &[
574 FileExtension::Cbr,
575 FileExtension::Cbz,
576 FileExtension::Djvu,
577 FileExtension::Epub,
578 FileExtension::Fb2,
579 FileExtension::Html,
580 FileExtension::Jpeg,
581 FileExtension::Jpg,
582 FileExtension::Mobi,
583 FileExtension::Oxps,
584 FileExtension::Pdf,
585 FileExtension::Png,
586 FileExtension::Svg,
587 FileExtension::Txt,
588 FileExtension::Webp,
589 FileExtension::Xps,
590 ]
591 }
592
593 pub fn as_str(self) -> &'static str {
595 match self {
596 FileExtension::Cbr => "cbr",
597 FileExtension::Cbz => "cbz",
598 FileExtension::Djvu => "djvu",
599 FileExtension::Epub => "epub",
600 FileExtension::Fb2 => "fb2",
601 FileExtension::Html => "html",
602 FileExtension::Jpeg => "jpeg",
603 FileExtension::Jpg => "jpg",
604 FileExtension::Mobi => "mobi",
605 FileExtension::Oxps => "oxps",
606 FileExtension::Pdf => "pdf",
607 FileExtension::Png => "png",
608 FileExtension::Svg => "svg",
609 FileExtension::Txt => "txt",
610 FileExtension::Webp => "webp",
611 FileExtension::Xps => "xps",
612 }
613 }
614}
615
616#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
618#[error("unknown file extension: {0}")]
619pub struct UnknownFileExtension(
620 pub String,
622);
623
624impl std::str::FromStr for FileExtension {
625 type Err = UnknownFileExtension;
626
627 fn from_str(s: &str) -> Result<Self, Self::Err> {
628 match s {
629 "cbr" => Ok(FileExtension::Cbr),
630 "cbz" => Ok(FileExtension::Cbz),
631 "djvu" => Ok(FileExtension::Djvu),
632 "epub" => Ok(FileExtension::Epub),
633 "fb2" => Ok(FileExtension::Fb2),
634 "html" | "htm" => Ok(FileExtension::Html),
635 "jpeg" => Ok(FileExtension::Jpeg),
636 "jpg" => Ok(FileExtension::Jpg),
637 "mobi" => Ok(FileExtension::Mobi),
638 "oxps" => Ok(FileExtension::Oxps),
639 "pdf" => Ok(FileExtension::Pdf),
640 "png" => Ok(FileExtension::Png),
641 "svg" => Ok(FileExtension::Svg),
642 "txt" => Ok(FileExtension::Txt),
643 "webp" => Ok(FileExtension::Webp),
644 "xps" => Ok(FileExtension::Xps),
645 _ => Err(UnknownFileExtension(s.to_owned())),
646 }
647 }
648}
649
650impl sqlx::Type<Sqlite> for FileExtension {
651 fn type_info() -> SqliteTypeInfo {
652 <String as sqlx::Type<Sqlite>>::type_info()
653 }
654
655 fn compatible(ty: &SqliteTypeInfo) -> bool {
656 <String as sqlx::Type<Sqlite>>::compatible(ty)
657 }
658}
659
660impl sqlx::Encode<'_, Sqlite> for FileExtension {
661 fn encode_by_ref(&self, buf: &mut SqliteArgumentsBuffer) -> Result<IsNull, BoxDynError> {
662 self.as_str().encode_by_ref(buf)
663 }
664}
665
666impl<'r> sqlx::Decode<'r, Sqlite> for FileExtension {
667 fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
668 let s = <String as sqlx::Decode<'r, Sqlite>>::decode(value)?;
669 s.parse()
670 .map_err(|UnknownFileExtension(ext)| format!("unknown file extension: {ext}").into())
671 }
672}
673
674fn deserialize_file_extension_set<'de, D>(
675 deserializer: D,
676) -> Result<FxHashSet<FileExtension>, D::Error>
677where
678 D: serde::Deserializer<'de>,
679{
680 struct FileExtensionSetVisitor;
681
682 impl<'de> serde::de::Visitor<'de> for FileExtensionSetVisitor {
683 type Value = FxHashSet<FileExtension>;
684
685 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
686 formatter.write_str("a sequence of file extension strings")
687 }
688
689 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
690 where
691 A: serde::de::SeqAccess<'de>,
692 {
693 let mut set = FxHashSet::default();
694
695 while let Some(s) = seq.next_element::<String>()? {
696 match s.parse::<FileExtension>() {
697 Ok(ext) => {
698 set.insert(ext);
699 }
700 Err(e) => {
701 tracing::warn!(extension = %s, error = %e, "failed to load extension");
702 }
703 }
704 }
705
706 Ok(set)
707 }
708 }
709
710 deserializer.deserialize_seq(FileExtensionSetVisitor)
711}
712
713impl fmt::Display for FileExtension {
714 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
715 write!(f, "{}", self.as_str())
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(rename_all = "kebab-case")]
721pub struct RefreshRatePair {
722 pub regular: u8,
723 pub inverted: u8,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
727#[serde(default, rename_all = "kebab-case")]
728pub struct ReaderSettings {
729 pub finished: FinishedAction,
730 pub south_east_corner: SouthEastCornerAction,
731 pub bottom_right_gesture: BottomRightGestureAction,
732 pub south_strip: SouthStripAction,
733 pub west_strip: WestStripAction,
734 pub east_strip: EastStripAction,
735 pub strip_width: f32,
736 pub corner_width: f32,
737 pub font_path: String,
738 pub font_family: String,
739 pub font_size: f32,
740 pub min_font_size: f32,
741 pub max_font_size: f32,
742 pub text_align: TextAlign,
743 pub margin_width: i32,
744 pub min_margin_width: i32,
745 pub max_margin_width: i32,
746 pub line_height: f32,
747 pub continuous_fit_to_width: bool,
748 pub ignore_document_css: bool,
749 #[serde(deserialize_with = "deserialize_file_extension_set")]
750 pub dithered_kinds: FxHashSet<FileExtension>,
751 pub paragraph_breaker: ParagraphBreakerSettings,
752 pub refresh_rate: RefreshRateSettings,
753}
754
755#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
756#[serde(default, rename_all = "kebab-case")]
757pub struct ParagraphBreakerSettings {
758 pub hyphen_penalty: i32,
759 pub stretch_tolerance: f32,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763#[serde(default, rename_all = "kebab-case")]
764pub struct BatterySettings {
765 pub warn: f32,
766 pub power_off: f32,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
771#[serde(default, rename_all = "kebab-case")]
772pub struct LoggingSettings {
773 pub enabled: bool,
775 pub level: String,
777 pub max_files: usize,
779 pub directory: PathBuf,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub otlp_endpoint: Option<String>,
784 #[serde(skip_serializing_if = "Option::is_none")]
786 pub pyroscope_endpoint: Option<String>,
787 pub enable_kern_log: bool,
789 pub enable_dbus_log: bool,
791}
792
793#[derive(Debug, Clone, Default, Serialize, Deserialize)]
799#[serde(default, rename_all = "kebab-case")]
800pub struct OtaSettings {}
801
802#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
803#[serde(rename_all = "kebab-case")]
804pub enum FinishedAction {
805 Notify,
806 Close,
807 GoToNext,
808}
809
810impl fmt::Display for FinishedAction {
811 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
812 match self {
813 FinishedAction::Notify => write!(f, "Notify"),
814 FinishedAction::Close => write!(f, "Close"),
815 FinishedAction::GoToNext => write!(f, "Go to Next"),
816 }
817 }
818}
819
820impl I18nDisplay for FinishedAction {
821 fn to_i18n_string(&self) -> String {
822 match self {
823 FinishedAction::Notify => fl!("settings-finished-action-notify"),
824 FinishedAction::Close => fl!("settings-finished-action-close"),
825 FinishedAction::GoToNext => fl!("settings-finished-action-goto-next"),
826 }
827 }
828}
829
830#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
831#[serde(rename_all = "kebab-case")]
832pub enum SouthEastCornerAction {
833 NextPage,
834 GoToPage,
835}
836
837#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
838#[serde(rename_all = "kebab-case")]
839pub enum BottomRightGestureAction {
840 ToggleDithered,
841 ToggleInverted,
842}
843
844#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
845#[serde(rename_all = "kebab-case")]
846pub enum SouthStripAction {
847 ToggleBars,
848 NextPage,
849}
850
851#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
852#[serde(rename_all = "kebab-case")]
853pub enum EastStripAction {
854 PreviousPage,
855 NextPage,
856 None,
857}
858
859#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
860#[serde(rename_all = "kebab-case")]
861pub enum WestStripAction {
862 PreviousPage,
863 NextPage,
864 None,
865}
866
867impl Default for RefreshRateSettings {
868 fn default() -> Self {
869 RefreshRateSettings {
870 global: RefreshRatePair {
871 regular: 8,
872 inverted: 2,
873 },
874 by_kind: HashMap::new(),
875 }
876 }
877}
878
879impl Default for HomeSettings {
880 fn default() -> Self {
881 HomeSettings {
882 address_bar: false,
883 navigation_bar: true,
884 max_levels: 3,
885 max_trash_size: 32 * (1 << 20),
886 }
887 }
888}
889
890impl Default for ParagraphBreakerSettings {
891 fn default() -> Self {
892 ParagraphBreakerSettings {
893 hyphen_penalty: HYPHEN_PENALTY,
894 stretch_tolerance: STRETCH_TOLERANCE,
895 }
896 }
897}
898
899impl Default for ReaderSettings {
900 fn default() -> Self {
901 ReaderSettings {
902 finished: FinishedAction::Close,
903 south_east_corner: SouthEastCornerAction::GoToPage,
904 bottom_right_gesture: BottomRightGestureAction::ToggleDithered,
905 south_strip: SouthStripAction::ToggleBars,
906 west_strip: WestStripAction::PreviousPage,
907 east_strip: EastStripAction::NextPage,
908 strip_width: 0.6,
909 corner_width: 0.4,
910 font_path: DEFAULT_FONT_PATH.to_string(),
911 font_family: DEFAULT_FONT_FAMILY.to_string(),
912 font_size: DEFAULT_FONT_SIZE,
913 min_font_size: DEFAULT_FONT_SIZE / 2.0,
914 max_font_size: 3.0 * DEFAULT_FONT_SIZE / 2.0,
915 text_align: DEFAULT_TEXT_ALIGN,
916 margin_width: DEFAULT_MARGIN_WIDTH,
917 min_margin_width: DEFAULT_MARGIN_WIDTH.saturating_sub(8),
918 max_margin_width: DEFAULT_MARGIN_WIDTH.saturating_add(2),
919 line_height: DEFAULT_LINE_HEIGHT,
920 continuous_fit_to_width: true,
921 ignore_document_css: false,
922 dithered_kinds: [
923 FileExtension::Cbz,
924 FileExtension::Png,
925 FileExtension::Jpg,
926 FileExtension::Jpeg,
927 FileExtension::Webp,
928 ]
929 .iter()
930 .copied()
931 .collect(),
932 paragraph_breaker: ParagraphBreakerSettings::default(),
933 refresh_rate: RefreshRateSettings::default(),
934 }
935 }
936}
937
938impl Default for ImportSettings {
939 fn default() -> Self {
940 ImportSettings {
941 sync_metadata: true,
942 metadata_kinds: ["epub", "pdf", "djvu"]
943 .iter()
944 .map(|k| k.to_string())
945 .collect(),
946 allowed_kinds: [
947 FileExtension::Pdf,
948 FileExtension::Djvu,
949 FileExtension::Epub,
950 FileExtension::Fb2,
951 FileExtension::Txt,
952 FileExtension::Xps,
953 FileExtension::Oxps,
954 FileExtension::Mobi,
955 FileExtension::Cbz,
956 FileExtension::Webp,
957 FileExtension::Png,
958 FileExtension::Jpg,
959 FileExtension::Jpeg,
960 ]
961 .iter()
962 .copied()
963 .collect(),
964 }
965 }
966}
967
968impl ImportSettings {
969 pub fn is_kind_allowed(&self, kind: FileExtension) -> bool {
971 self.allowed_kinds.contains(&kind)
972 }
973}
974
975impl Default for BatterySettings {
976 fn default() -> Self {
977 BatterySettings {
978 warn: 10.0,
979 power_off: 3.0,
980 }
981 }
982}
983
984impl Default for LoggingSettings {
985 fn default() -> Self {
986 LoggingSettings {
987 enabled: true,
988 level: "info".to_string(),
989 max_files: 3,
990 directory: PathBuf::from("logs"),
991 otlp_endpoint: None,
992 pyroscope_endpoint: None,
993 enable_kern_log: false,
994 enable_dbus_log: false,
995 }
996 }
997}
998
999impl Default for Settings {
1000 fn default() -> Self {
1001 Settings {
1002 selected_library: 0,
1003 libraries: cfg_select! {
1004 feature = "emulator" => {
1005 vec![LibrarySettings {
1006 name: "Cadmus Source".to_string(),
1007 path: PathBuf::from("."),
1008 ..Default::default()
1009 }]
1010 }
1011 _ => {
1012 vec![
1013 LibrarySettings {
1014 name: "On Board".to_string(),
1015 path: PathBuf::from(INTERNAL_CARD_ROOT),
1016 hooks: vec![Hook {
1017 path: PathBuf::from("Articles"),
1018 program: PathBuf::from("bin/article_fetcher/article_fetcher"),
1019 sort_method: Some(SortMethod::Added),
1020 first_column: Some(FirstColumn::TitleAndAuthor),
1021 second_column: Some(SecondColumn::Progress),
1022 }],
1023 ..Default::default()
1024 },
1025 LibrarySettings {
1026 name: "Removable".to_string(),
1027 path: PathBuf::from(EXTERNAL_CARD_ROOT),
1028 ..Default::default()
1029 },
1030 LibrarySettings {
1031 name: "Dropbox".to_string(),
1032 path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
1033 ..Default::default()
1034 },
1035 LibrarySettings {
1036 name: "KePub".to_string(),
1037 path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
1038 ..Default::default()
1039 },
1040 ]
1041 }
1042 },
1043 external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
1044 keyboard_layout: "English".to_string(),
1045 frontlight: true,
1046 wifi: false,
1047 inverted: false,
1048 sleep_cover: true,
1049 auto_share: false,
1050 auto_time: false,
1051 auto_frontlight: false,
1052 auto_frontlight_night_brightness: Some(LightLevel::default()),
1053 auto_frontlight_manual_coordinates: None,
1054 auto_frontlight_last_coordinates: None,
1055 rotation_lock: None,
1056 button_scheme: ButtonScheme::Natural,
1057 auto_suspend: 30.0,
1058 auto_power_off: 3.0,
1059 time_format: "%H:%M".to_string(),
1060 date_format: "%A, %B %-d, %Y".to_string(),
1061 intermissions: Intermissions {
1062 suspend: IntermissionDisplay::Logo,
1063 power_off: IntermissionDisplay::Logo,
1064 share: IntermissionDisplay::Logo,
1065 },
1066 home: HomeSettings::default(),
1067 reader: ReaderSettings::default(),
1068 import: ImportSettings::default(),
1069 dictionary: DictionarySettings::default(),
1070 sketch: SketchSettings::default(),
1071 calculator: CalculatorSettings::default(),
1072 battery: BatterySettings::default(),
1073 frontlight_levels: LightLevels::default(),
1074 frontlight_presets: Vec::new(),
1075 ota: OtaSettings::default(),
1076 logging: LoggingSettings::default(),
1077 settings_retention: 3,
1078 db_backup_retention: 2,
1079 startup_mode: StartupMode::default(),
1080 locale: None,
1081 }
1082 }
1083}
1084
1085pub fn resolve_coordinates(settings: &Settings) -> Option<Coordinates> {
1090 settings
1091 .auto_frontlight_manual_coordinates
1092 .or(settings.auto_frontlight_last_coordinates)
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 #[test]
1100 fn test_ota_settings_serializes_empty() {
1101 let settings = OtaSettings::default();
1102 let serialized = toml::to_string(&settings).expect("Failed to serialize");
1103 assert!(
1104 serialized.is_empty(),
1105 "OtaSettings should serialize to an empty string"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_intermissions_struct_serialization() {
1111 let intermissions = Intermissions {
1112 suspend: IntermissionDisplay::Blank,
1113 power_off: IntermissionDisplay::BlankInverted,
1114 share: IntermissionDisplay::Image(PathBuf::from("/custom/share.png")),
1115 };
1116
1117 let serialized = toml::to_string(&intermissions).expect("Failed to serialize");
1118
1119 assert!(
1120 serialized.contains("blank:"),
1121 "Should contain blank: for suspend"
1122 );
1123 assert!(
1124 serialized.contains("blank-inverted:"),
1125 "Should contain blank-inverted: for power-off"
1126 );
1127 assert!(
1128 serialized.contains("/custom/share.png"),
1129 "Should contain custom path for share"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_intermissions_struct_deserialization() {
1135 let toml_str = r#"
1136suspend = "blank:"
1137power-off = "blank-inverted:"
1138share = "/path/to/custom.png"
1139"#;
1140
1141 let intermissions: Intermissions = toml::from_str(toml_str).expect("Failed to deserialize");
1142
1143 assert!(
1144 matches!(intermissions.suspend, IntermissionDisplay::Blank),
1145 "suspend should deserialize to Blank"
1146 );
1147 assert!(
1148 matches!(intermissions.power_off, IntermissionDisplay::BlankInverted),
1149 "power_off should deserialize to BlankInverted"
1150 );
1151 assert!(
1152 matches!(
1153 intermissions.share,
1154 IntermissionDisplay::Image(ref path) if path == &PathBuf::from("/path/to/custom.png")
1155 ),
1156 "share should deserialize to Image with correct path"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_intermissions_struct_round_trip() {
1162 let original = Intermissions {
1163 suspend: IntermissionDisplay::Blank,
1164 power_off: IntermissionDisplay::BlankInverted,
1165 share: IntermissionDisplay::Image(PathBuf::from("/some/custom/image.jpg")),
1166 };
1167
1168 let serialized = toml::to_string(&original).expect("Failed to serialize");
1169 let deserialized: Intermissions =
1170 toml::from_str(&serialized).expect("Failed to deserialize");
1171
1172 assert_eq!(
1173 original.suspend, deserialized.suspend,
1174 "suspend should survive round trip"
1175 );
1176 assert_eq!(
1177 original.power_off, deserialized.power_off,
1178 "power_off should survive round trip"
1179 );
1180 assert_eq!(
1181 original.share, deserialized.share,
1182 "share should survive round trip"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_intermissions_reject_unsupported_calendar_selection() {
1188 let mut intermissions = Intermissions {
1189 suspend: IntermissionDisplay::Logo,
1190 power_off: IntermissionDisplay::Logo,
1191 share: IntermissionDisplay::Logo,
1192 };
1193
1194 assert!(!intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::Calendar));
1195 assert!(!intermissions.set_display(IntermKind::Share, IntermissionDisplay::Calendar));
1196 assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Calendar));
1197
1198 assert_eq!(
1199 intermissions[IntermKind::PowerOff],
1200 IntermissionDisplay::Logo
1201 );
1202 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1203 assert_eq!(
1204 intermissions[IntermKind::Suspend],
1205 IntermissionDisplay::Calendar
1206 );
1207 }
1208
1209 #[test]
1210 fn test_intermissions_accept_blank_selection_for_all_kinds() {
1211 let mut intermissions = Intermissions {
1212 suspend: IntermissionDisplay::Logo,
1213 power_off: IntermissionDisplay::Logo,
1214 share: IntermissionDisplay::Logo,
1215 };
1216
1217 assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Blank));
1218 assert!(
1219 intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::BlankInverted)
1220 );
1221 assert!(intermissions.set_display(IntermKind::Share, IntermissionDisplay::Blank));
1222
1223 assert_eq!(
1224 intermissions[IntermKind::Suspend],
1225 IntermissionDisplay::Blank
1226 );
1227 assert_eq!(
1228 intermissions[IntermKind::PowerOff],
1229 IntermissionDisplay::BlankInverted
1230 );
1231 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Blank);
1232 }
1233
1234 #[test]
1235 fn test_intermissions_sanitize_replaces_unsupported_calendar() {
1236 let mut intermissions = Intermissions {
1237 suspend: IntermissionDisplay::Calendar,
1238 power_off: IntermissionDisplay::Calendar,
1239 share: IntermissionDisplay::Calendar,
1240 };
1241
1242 assert!(intermissions.sanitize());
1243
1244 assert_eq!(
1245 intermissions[IntermKind::Suspend],
1246 IntermissionDisplay::Calendar
1247 );
1248 assert_eq!(
1249 intermissions[IntermKind::PowerOff],
1250 IntermissionDisplay::Logo
1251 );
1252 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1253 }
1254
1255 #[test]
1256 fn test_allowed_kinds_deserializes_known_extensions() {
1257 let toml_str = r#"
1258sync-metadata = true
1259metadata-kinds = ["epub"]
1260allowed-kinds = ["epub", "pdf", "cbz"]
1261"#;
1262 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1263
1264 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1265 assert!(settings.allowed_kinds.contains(&FileExtension::Pdf));
1266 assert!(settings.allowed_kinds.contains(&FileExtension::Cbz));
1267 assert_eq!(settings.allowed_kinds.len(), 3);
1268 }
1269
1270 #[test]
1271 fn test_allowed_kinds_silently_drops_unknown_extensions() {
1272 let toml_str = r#"
1273sync-metadata = true
1274metadata-kinds = []
1275allowed-kinds = ["epub", "unknown-format", "another-unknown"]
1276"#;
1277 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1278
1279 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1280 assert_eq!(settings.allowed_kinds.len(), 1);
1281 }
1282
1283 #[test]
1284 fn test_dithered_kinds_deserializes_known_extensions() {
1285 let toml_str = r#"
1286finished = "close"
1287dithered-kinds = ["cbz", "png", "jpeg"]
1288"#;
1289 let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1290
1291 assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1292 assert!(settings.dithered_kinds.contains(&FileExtension::Png));
1293 assert!(settings.dithered_kinds.contains(&FileExtension::Jpeg));
1294 assert_eq!(settings.dithered_kinds.len(), 3);
1295 }
1296
1297 #[test]
1298 fn test_dithered_kinds_silently_drops_unknown_extensions() {
1299 let toml_str = r#"
1300finished = "close"
1301dithered-kinds = ["cbz", "unknown-format"]
1302"#;
1303 let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1304
1305 assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1306 assert_eq!(settings.dithered_kinds.len(), 1);
1307 }
1308
1309 #[test]
1310 fn test_file_extension_round_trip_via_from_str() {
1311 for ext in FileExtension::all() {
1312 let parsed = ext.as_str().parse::<FileExtension>().ok();
1313 assert_eq!(parsed, Some(*ext), "round trip failed for {:?}", ext);
1314 }
1315 }
1316
1317 #[test]
1318 fn test_htm_extension_parses_as_html() {
1319 let parsed = "htm".parse::<FileExtension>();
1320 assert_eq!(parsed, Ok(FileExtension::Html));
1321 }
1322
1323 #[test]
1324 fn test_html_extension_still_parses() {
1325 let parsed = "html".parse::<FileExtension>();
1326 assert_eq!(parsed, Ok(FileExtension::Html));
1327 }
1328}