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#[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, 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#[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 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 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 pub auto_frontlight: bool,
266 #[serde(skip_serializing_if = "Option::is_none")]
269 pub auto_frontlight_night_brightness: Option<LightLevel>,
270 #[serde(skip_serializing_if = "Option::is_none")]
275 pub auto_frontlight_manual_coordinates: Option<Coordinates>,
276 #[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 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#[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#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
506#[serde(rename_all = "lowercase")]
507pub enum FileExtension {
508 Cbr,
510 Cbz,
512 Djvu,
514 Epub,
516 Fb2,
518 Html,
520 Jpeg,
522 Jpg,
524 Mobi,
526 Oxps,
528 Pdf,
530 Png,
532 Svg,
534 Txt,
536 Webp,
538 Xps,
540}
541
542impl FileExtension {
543 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
590#[error("unknown file extension: {0}")]
591pub struct UnknownFileExtension(
592 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#[derive(Debug, Clone, Serialize, Deserialize)]
743#[serde(default, rename_all = "kebab-case")]
744pub struct LoggingSettings {
745 pub enabled: bool,
747 pub level: String,
749 pub max_files: usize,
751 pub directory: PathBuf,
753 #[serde(skip_serializing_if = "Option::is_none")]
755 pub otlp_endpoint: Option<String>,
756 #[serde(skip_serializing_if = "Option::is_none")]
758 pub pyroscope_endpoint: Option<String>,
759 pub enable_kern_log: bool,
761 pub enable_dbus_log: bool,
763}
764
765#[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 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
1055pub 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}