1mod preset;
2pub mod versioned;
3
4use crate::color::{Color, BLACK};
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::{guess_frontlight, LightPreset};
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#[derive(Debug, Clone, Eq, PartialEq)]
39pub enum IntermissionDisplay {
40 Logo,
42 Cover,
44 Calendar,
46 Blank,
48 BlankInverted,
50 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 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
115pub const DEFAULT_FONT_SIZE: f32 = 11.0;
117pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
119pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
121pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
123pub 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#[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 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 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!("ignoring unsupported calendar intermissions for power-off/share; using logo instead");
232 }
233
234 changed
235 }
236
237 fn sanitize_kind(&mut self, kind: IntermKind) -> bool {
238 if self[kind].is_supported_for(kind) {
239 return false;
240 }
241
242 self[kind] = IntermissionDisplay::Logo;
243 true
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default, rename_all = "kebab-case")]
249pub struct Settings {
250 pub selected_library: usize,
251 pub keyboard_layout: String,
252 pub frontlight: bool,
253 pub wifi: bool,
254 pub inverted: bool,
255 pub sleep_cover: bool,
256 pub auto_share: bool,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub rotation_lock: Option<RotationLock>,
259 pub button_scheme: ButtonScheme,
260 pub auto_suspend: f32,
261 pub auto_power_off: f32,
262 pub time_format: String,
263 pub date_format: String,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub external_urls_queue: Option<PathBuf>,
266 #[serde(skip_serializing_if = "Vec::is_empty")]
267 pub libraries: Vec<LibrarySettings>,
268 pub intermissions: Intermissions,
269 #[serde(skip_serializing_if = "Vec::is_empty")]
270 pub frontlight_presets: Vec<LightPreset>,
271 pub home: HomeSettings,
272 pub reader: ReaderSettings,
273 pub import: ImportSettings,
274 pub dictionary: DictionarySettings,
275 pub sketch: SketchSettings,
276 pub calculator: CalculatorSettings,
277 pub battery: BatterySettings,
278 pub frontlight_levels: LightLevels,
279 pub ota: OtaSettings,
280 pub logging: LoggingSettings,
281 pub settings_retention: usize,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub locale: Option<LanguageIdentifier>,
284}
285
286impl Settings {
287 pub fn sanitize(&mut self) -> bool {
289 self.intermissions.sanitize()
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(default, rename_all = "kebab-case")]
295pub struct LibrarySettings {
296 pub name: String,
297 pub path: PathBuf,
298 pub sort_method: SortMethod,
299 pub first_column: FirstColumn,
300 pub second_column: SecondColumn,
301 pub thumbnail_previews: bool,
302 #[serde(skip_serializing_if = "Vec::is_empty")]
303 pub hooks: Vec<Hook>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub finished: Option<FinishedAction>,
306}
307
308impl Default for LibrarySettings {
309 fn default() -> Self {
310 LibrarySettings {
311 name: "Unnamed".to_string(),
312 path: env::current_dir()
313 .ok()
314 .unwrap_or_else(|| PathBuf::from("/")),
315 sort_method: SortMethod::Opened,
316 first_column: FirstColumn::TitleAndAuthor,
317 second_column: SecondColumn::Progress,
318 thumbnail_previews: true,
319 hooks: Vec::new(),
320 finished: None,
321 }
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(default, rename_all = "kebab-case")]
328pub struct ImportSettings {
329 pub startup_trigger: bool,
330 pub sync_metadata: bool,
331 pub metadata_kinds: FxHashSet<String>,
332 #[serde(deserialize_with = "deserialize_allowed_kinds")]
333 pub allowed_kinds: FxHashSet<FileExtension>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(default, rename_all = "kebab-case")]
338pub struct DictionarySettings {
339 pub margin_width: i32,
340 pub font_size: f32,
341 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
342 pub languages: BTreeMap<String, Vec<String>>,
343}
344
345impl Default for DictionarySettings {
346 fn default() -> Self {
347 DictionarySettings {
348 font_size: 11.0,
349 margin_width: 4,
350 languages: BTreeMap::new(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(default, rename_all = "kebab-case")]
357pub struct SketchSettings {
358 pub save_path: PathBuf,
359 pub notify_success: bool,
360 pub pen: Pen,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(default, rename_all = "kebab-case")]
365pub struct CalculatorSettings {
366 pub font_size: f32,
367 pub margin_width: i32,
368 pub history_size: usize,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(default, rename_all = "kebab-case")]
373pub struct Pen {
374 pub size: i32,
375 pub color: Color,
376 pub dynamic: bool,
377 pub amplitude: f32,
378 pub min_speed: f32,
379 pub max_speed: f32,
380}
381
382impl Default for Pen {
383 fn default() -> Self {
384 Pen {
385 size: 2,
386 color: BLACK,
387 dynamic: true,
388 amplitude: 4.0,
389 min_speed: 0.0,
390 max_speed: mm_to_px(254.0, CURRENT_DEVICE.dpi),
391 }
392 }
393}
394
395impl Default for SketchSettings {
396 fn default() -> Self {
397 SketchSettings {
398 save_path: PathBuf::from("Sketches"),
399 notify_success: true,
400 pen: Pen::default(),
401 }
402 }
403}
404
405impl Default for CalculatorSettings {
406 fn default() -> Self {
407 CalculatorSettings {
408 font_size: 8.0,
409 margin_width: 2,
410 history_size: 4096,
411 }
412 }
413}
414
415#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "kebab-case")]
417pub struct Columns {
418 first: FirstColumn,
419 second: SecondColumn,
420}
421
422#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424pub enum FirstColumn {
425 TitleAndAuthor,
426 FileName,
427}
428
429#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
430#[serde(rename_all = "kebab-case")]
431pub enum SecondColumn {
432 Progress,
433 Year,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437#[serde(default, rename_all = "kebab-case")]
438pub struct Hook {
439 pub path: PathBuf,
440 pub program: PathBuf,
441 pub sort_method: Option<SortMethod>,
442 pub first_column: Option<FirstColumn>,
443 pub second_column: Option<SecondColumn>,
444}
445
446impl Default for Hook {
447 fn default() -> Self {
448 Hook {
449 path: PathBuf::default(),
450 program: PathBuf::default(),
451 sort_method: None,
452 first_column: None,
453 second_column: None,
454 }
455 }
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459#[serde(default, rename_all = "kebab-case")]
460pub struct HomeSettings {
461 pub address_bar: bool,
462 pub navigation_bar: bool,
463 pub max_levels: usize,
464 pub max_trash_size: u64,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[serde(default, rename_all = "kebab-case")]
469pub struct RefreshRateSettings {
470 #[serde(flatten)]
471 pub global: RefreshRatePair,
472 #[serde(skip_serializing_if = "HashMap::is_empty")]
473 pub by_kind: HashMap<String, RefreshRatePair>,
474}
475
476#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum FileExtension {
483 Epub,
484 Pdf,
485 Cbz,
486 Cbr,
487 Djvu,
488 Fb2,
489 Mobi,
490 Txt,
491 Html,
492 Xps,
493 Oxps,
494}
495
496impl FileExtension {
497 pub fn all() -> &'static [FileExtension] {
499 &[
500 FileExtension::Epub,
501 FileExtension::Pdf,
502 FileExtension::Cbz,
503 FileExtension::Cbr,
504 FileExtension::Djvu,
505 FileExtension::Fb2,
506 FileExtension::Mobi,
507 FileExtension::Txt,
508 FileExtension::Html,
509 FileExtension::Xps,
510 FileExtension::Oxps,
511 ]
512 }
513
514 pub fn as_str(self) -> &'static str {
516 match self {
517 FileExtension::Epub => "epub",
518 FileExtension::Pdf => "pdf",
519 FileExtension::Cbz => "cbz",
520 FileExtension::Cbr => "cbr",
521 FileExtension::Djvu => "djvu",
522 FileExtension::Fb2 => "fb2",
523 FileExtension::Mobi => "mobi",
524 FileExtension::Txt => "txt",
525 FileExtension::Html => "html",
526 FileExtension::Xps => "xps",
527 FileExtension::Oxps => "oxps",
528 }
529 }
530}
531
532impl std::str::FromStr for FileExtension {
533 type Err = ();
534
535 fn from_str(s: &str) -> Result<Self, Self::Err> {
536 match s {
537 "epub" => Ok(FileExtension::Epub),
538 "pdf" => Ok(FileExtension::Pdf),
539 "cbz" => Ok(FileExtension::Cbz),
540 "cbr" => Ok(FileExtension::Cbr),
541 "djvu" => Ok(FileExtension::Djvu),
542 "fb2" => Ok(FileExtension::Fb2),
543 "mobi" => Ok(FileExtension::Mobi),
544 "txt" => Ok(FileExtension::Txt),
545 "html" | "htm" => Ok(FileExtension::Html),
546 "xps" => Ok(FileExtension::Xps),
547 "oxps" => Ok(FileExtension::Oxps),
548 _ => Err(()),
549 }
550 }
551}
552
553impl sqlx::Type<Sqlite> for FileExtension {
554 fn type_info() -> SqliteTypeInfo {
555 <String as sqlx::Type<Sqlite>>::type_info()
556 }
557
558 fn compatible(ty: &SqliteTypeInfo) -> bool {
559 <String as sqlx::Type<Sqlite>>::compatible(ty)
560 }
561}
562
563impl<'q> sqlx::Encode<'q, Sqlite> for FileExtension {
564 fn encode_by_ref(&self, buf: &mut Vec<SqliteArgumentValue<'q>>) -> Result<IsNull, BoxDynError> {
565 self.as_str().encode_by_ref(buf)
566 }
567}
568
569impl<'r> sqlx::Decode<'r, Sqlite> for FileExtension {
570 fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
571 let s = <String as sqlx::Decode<'r, Sqlite>>::decode(value)?;
572 s.parse()
573 .map_err(|()| format!("unknown file extension: {s}").into())
574 }
575}
576
577fn deserialize_allowed_kinds<'de, D>(deserializer: D) -> Result<FxHashSet<FileExtension>, D::Error>
578where
579 D: serde::Deserializer<'de>,
580{
581 struct AllowedKindsVisitor;
582
583 impl<'de> serde::de::Visitor<'de> for AllowedKindsVisitor {
584 type Value = FxHashSet<FileExtension>;
585
586 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
587 formatter.write_str("a sequence of file extension strings")
588 }
589
590 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
591 where
592 A: serde::de::SeqAccess<'de>,
593 {
594 let mut set = FxHashSet::default();
595
596 while let Some(s) = seq.next_element::<String>()? {
597 match s.parse::<FileExtension>() {
598 Ok(ext) => {
599 set.insert(ext);
600 }
601 Err(()) => {
602 tracing::warn!(extension = %s, "Unknown file extension skipped");
603 }
604 }
605 }
606
607 Ok(set)
608 }
609 }
610
611 deserializer.deserialize_seq(AllowedKindsVisitor)
612}
613
614impl fmt::Display for FileExtension {
615 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
616 write!(f, "{}", self.as_str())
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
621#[serde(rename_all = "kebab-case")]
622pub struct RefreshRatePair {
623 pub regular: u8,
624 pub inverted: u8,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628#[serde(default, rename_all = "kebab-case")]
629pub struct ReaderSettings {
630 pub finished: FinishedAction,
631 pub south_east_corner: SouthEastCornerAction,
632 pub bottom_right_gesture: BottomRightGestureAction,
633 pub south_strip: SouthStripAction,
634 pub west_strip: WestStripAction,
635 pub east_strip: EastStripAction,
636 pub strip_width: f32,
637 pub corner_width: f32,
638 pub font_path: String,
639 pub font_family: String,
640 pub font_size: f32,
641 pub min_font_size: f32,
642 pub max_font_size: f32,
643 pub text_align: TextAlign,
644 pub margin_width: i32,
645 pub min_margin_width: i32,
646 pub max_margin_width: i32,
647 pub line_height: f32,
648 pub continuous_fit_to_width: bool,
649 pub ignore_document_css: bool,
650 pub dithered_kinds: FxHashSet<String>,
651 pub paragraph_breaker: ParagraphBreakerSettings,
652 pub refresh_rate: RefreshRateSettings,
653}
654
655#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
656#[serde(default, rename_all = "kebab-case")]
657pub struct ParagraphBreakerSettings {
658 pub hyphen_penalty: i32,
659 pub stretch_tolerance: f32,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(default, rename_all = "kebab-case")]
664pub struct BatterySettings {
665 pub warn: f32,
666 pub power_off: f32,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(default, rename_all = "kebab-case")]
672pub struct LoggingSettings {
673 pub enabled: bool,
675 pub level: String,
677 pub max_files: usize,
679 pub directory: PathBuf,
681 #[serde(skip_serializing_if = "Option::is_none")]
683 pub otlp_endpoint: Option<String>,
684 #[serde(skip_serializing_if = "Option::is_none")]
686 pub pyroscope_endpoint: Option<String>,
687 pub enable_kern_log: bool,
689 pub enable_dbus_log: bool,
691}
692
693#[derive(Debug, Clone, Default, Serialize, Deserialize)]
699#[serde(default, rename_all = "kebab-case")]
700pub struct OtaSettings {}
701
702#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
703#[serde(rename_all = "kebab-case")]
704pub enum FinishedAction {
705 Notify,
706 Close,
707 GoToNext,
708}
709
710impl fmt::Display for FinishedAction {
711 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
712 match self {
713 FinishedAction::Notify => write!(f, "Notify"),
714 FinishedAction::Close => write!(f, "Close"),
715 FinishedAction::GoToNext => write!(f, "Go to Next"),
716 }
717 }
718}
719
720impl I18nDisplay for FinishedAction {
721 fn to_i18n_string(&self) -> String {
722 match self {
723 FinishedAction::Notify => fl!("settings-finished-action-notify"),
724 FinishedAction::Close => fl!("settings-finished-action-close"),
725 FinishedAction::GoToNext => fl!("settings-finished-action-goto-next"),
726 }
727 }
728}
729
730#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
731#[serde(rename_all = "kebab-case")]
732pub enum SouthEastCornerAction {
733 NextPage,
734 GoToPage,
735}
736
737#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
738#[serde(rename_all = "kebab-case")]
739pub enum BottomRightGestureAction {
740 ToggleDithered,
741 ToggleInverted,
742}
743
744#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
745#[serde(rename_all = "kebab-case")]
746pub enum SouthStripAction {
747 ToggleBars,
748 NextPage,
749}
750
751#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "kebab-case")]
753pub enum EastStripAction {
754 PreviousPage,
755 NextPage,
756 None,
757}
758
759#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
760#[serde(rename_all = "kebab-case")]
761pub enum WestStripAction {
762 PreviousPage,
763 NextPage,
764 None,
765}
766
767impl Default for RefreshRateSettings {
768 fn default() -> Self {
769 RefreshRateSettings {
770 global: RefreshRatePair {
771 regular: 8,
772 inverted: 2,
773 },
774 by_kind: HashMap::new(),
775 }
776 }
777}
778
779impl Default for HomeSettings {
780 fn default() -> Self {
781 HomeSettings {
782 address_bar: false,
783 navigation_bar: true,
784 max_levels: 3,
785 max_trash_size: 32 * (1 << 20),
786 }
787 }
788}
789
790impl Default for ParagraphBreakerSettings {
791 fn default() -> Self {
792 ParagraphBreakerSettings {
793 hyphen_penalty: HYPHEN_PENALTY,
794 stretch_tolerance: STRETCH_TOLERANCE,
795 }
796 }
797}
798
799impl Default for ReaderSettings {
800 fn default() -> Self {
801 ReaderSettings {
802 finished: FinishedAction::Close,
803 south_east_corner: SouthEastCornerAction::GoToPage,
804 bottom_right_gesture: BottomRightGestureAction::ToggleDithered,
805 south_strip: SouthStripAction::ToggleBars,
806 west_strip: WestStripAction::PreviousPage,
807 east_strip: EastStripAction::NextPage,
808 strip_width: 0.6,
809 corner_width: 0.4,
810 font_path: DEFAULT_FONT_PATH.to_string(),
811 font_family: DEFAULT_FONT_FAMILY.to_string(),
812 font_size: DEFAULT_FONT_SIZE,
813 min_font_size: DEFAULT_FONT_SIZE / 2.0,
814 max_font_size: 3.0 * DEFAULT_FONT_SIZE / 2.0,
815 text_align: DEFAULT_TEXT_ALIGN,
816 margin_width: DEFAULT_MARGIN_WIDTH,
817 min_margin_width: DEFAULT_MARGIN_WIDTH.saturating_sub(8),
818 max_margin_width: DEFAULT_MARGIN_WIDTH.saturating_add(2),
819 line_height: DEFAULT_LINE_HEIGHT,
820 continuous_fit_to_width: true,
821 ignore_document_css: false,
822 dithered_kinds: ["cbz", "png", "jpg", "jpeg"]
823 .iter()
824 .map(|k| k.to_string())
825 .collect(),
826 paragraph_breaker: ParagraphBreakerSettings::default(),
827 refresh_rate: RefreshRateSettings::default(),
828 }
829 }
830}
831
832impl Default for ImportSettings {
833 fn default() -> Self {
834 ImportSettings {
835 startup_trigger: true,
836 sync_metadata: true,
837 metadata_kinds: ["epub", "pdf", "djvu"]
838 .iter()
839 .map(|k| k.to_string())
840 .collect(),
841 allowed_kinds: [
842 FileExtension::Pdf,
843 FileExtension::Djvu,
844 FileExtension::Epub,
845 FileExtension::Fb2,
846 FileExtension::Txt,
847 FileExtension::Xps,
848 FileExtension::Oxps,
849 FileExtension::Mobi,
850 FileExtension::Cbz,
851 ]
852 .iter()
853 .copied()
854 .collect(),
855 }
856 }
857}
858
859impl ImportSettings {
860 pub fn is_kind_allowed(&self, kind: FileExtension) -> bool {
862 self.allowed_kinds.contains(&kind)
863 }
864}
865
866impl Default for BatterySettings {
867 fn default() -> Self {
868 BatterySettings {
869 warn: 10.0,
870 power_off: 3.0,
871 }
872 }
873}
874
875impl Default for LoggingSettings {
876 fn default() -> Self {
877 LoggingSettings {
878 enabled: true,
879 level: "info".to_string(),
880 max_files: 3,
881 directory: PathBuf::from("logs"),
882 otlp_endpoint: None,
883 pyroscope_endpoint: None,
884 enable_kern_log: false,
885 enable_dbus_log: false,
886 }
887 }
888}
889
890impl Default for Settings {
891 fn default() -> Self {
892 Settings {
893 selected_library: 0,
894 #[cfg(feature = "emulator")]
895 libraries: vec![LibrarySettings {
896 name: "Cadmus Source".to_string(),
897 path: PathBuf::from("."),
898 ..Default::default()
899 }],
900 #[cfg(not(feature = "emulator"))]
901 libraries: vec![
902 LibrarySettings {
903 name: "On Board".to_string(),
904 path: PathBuf::from(INTERNAL_CARD_ROOT),
905 hooks: vec![Hook {
906 path: PathBuf::from("Articles"),
907 program: PathBuf::from("bin/article_fetcher/article_fetcher"),
908 sort_method: Some(SortMethod::Added),
909 first_column: Some(FirstColumn::TitleAndAuthor),
910 second_column: Some(SecondColumn::Progress),
911 }],
912 ..Default::default()
913 },
914 LibrarySettings {
915 name: "Removable".to_string(),
916 path: PathBuf::from(EXTERNAL_CARD_ROOT),
917 ..Default::default()
918 },
919 LibrarySettings {
920 name: "Dropbox".to_string(),
921 path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
922 ..Default::default()
923 },
924 LibrarySettings {
925 name: "KePub".to_string(),
926 path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
927 ..Default::default()
928 },
929 ],
930 external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
931 keyboard_layout: "English".to_string(),
932 frontlight: true,
933 wifi: false,
934 inverted: false,
935 sleep_cover: true,
936 auto_share: false,
937 rotation_lock: None,
938 button_scheme: ButtonScheme::Natural,
939 auto_suspend: 30.0,
940 auto_power_off: 3.0,
941 time_format: "%H:%M".to_string(),
942 date_format: "%A, %B %-d, %Y".to_string(),
943 intermissions: Intermissions {
944 suspend: IntermissionDisplay::Logo,
945 power_off: IntermissionDisplay::Logo,
946 share: IntermissionDisplay::Logo,
947 },
948 home: HomeSettings::default(),
949 reader: ReaderSettings::default(),
950 import: ImportSettings::default(),
951 dictionary: DictionarySettings::default(),
952 sketch: SketchSettings::default(),
953 calculator: CalculatorSettings::default(),
954 battery: BatterySettings::default(),
955 frontlight_levels: LightLevels::default(),
956 frontlight_presets: Vec::new(),
957 ota: OtaSettings::default(),
958 logging: LoggingSettings::default(),
959 settings_retention: 3,
960 locale: None,
961 }
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 #[test]
970 fn test_ota_settings_serializes_empty() {
971 let settings = OtaSettings::default();
972 let serialized = toml::to_string(&settings).expect("Failed to serialize");
973 assert!(
974 serialized.is_empty(),
975 "OtaSettings should serialize to an empty string"
976 );
977 }
978
979 #[test]
980 fn test_intermissions_struct_serialization() {
981 let intermissions = Intermissions {
982 suspend: IntermissionDisplay::Blank,
983 power_off: IntermissionDisplay::BlankInverted,
984 share: IntermissionDisplay::Image(PathBuf::from("/custom/share.png")),
985 };
986
987 let serialized = toml::to_string(&intermissions).expect("Failed to serialize");
988
989 assert!(
990 serialized.contains("blank:"),
991 "Should contain blank: for suspend"
992 );
993 assert!(
994 serialized.contains("blank-inverted:"),
995 "Should contain blank-inverted: for power-off"
996 );
997 assert!(
998 serialized.contains("/custom/share.png"),
999 "Should contain custom path for share"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_intermissions_struct_deserialization() {
1005 let toml_str = r#"
1006suspend = "blank:"
1007power-off = "blank-inverted:"
1008share = "/path/to/custom.png"
1009"#;
1010
1011 let intermissions: Intermissions = toml::from_str(toml_str).expect("Failed to deserialize");
1012
1013 assert!(
1014 matches!(intermissions.suspend, IntermissionDisplay::Blank),
1015 "suspend should deserialize to Blank"
1016 );
1017 assert!(
1018 matches!(intermissions.power_off, IntermissionDisplay::BlankInverted),
1019 "power_off should deserialize to BlankInverted"
1020 );
1021 assert!(
1022 matches!(
1023 intermissions.share,
1024 IntermissionDisplay::Image(ref path) if path == &PathBuf::from("/path/to/custom.png")
1025 ),
1026 "share should deserialize to Image with correct path"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_intermissions_struct_round_trip() {
1032 let original = Intermissions {
1033 suspend: IntermissionDisplay::Blank,
1034 power_off: IntermissionDisplay::BlankInverted,
1035 share: IntermissionDisplay::Image(PathBuf::from("/some/custom/image.jpg")),
1036 };
1037
1038 let serialized = toml::to_string(&original).expect("Failed to serialize");
1039 let deserialized: Intermissions =
1040 toml::from_str(&serialized).expect("Failed to deserialize");
1041
1042 assert_eq!(
1043 original.suspend, deserialized.suspend,
1044 "suspend should survive round trip"
1045 );
1046 assert_eq!(
1047 original.power_off, deserialized.power_off,
1048 "power_off should survive round trip"
1049 );
1050 assert_eq!(
1051 original.share, deserialized.share,
1052 "share should survive round trip"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_intermissions_reject_unsupported_calendar_selection() {
1058 let mut intermissions = Intermissions {
1059 suspend: IntermissionDisplay::Logo,
1060 power_off: IntermissionDisplay::Logo,
1061 share: IntermissionDisplay::Logo,
1062 };
1063
1064 assert!(!intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::Calendar));
1065 assert!(!intermissions.set_display(IntermKind::Share, IntermissionDisplay::Calendar));
1066 assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Calendar));
1067
1068 assert_eq!(
1069 intermissions[IntermKind::PowerOff],
1070 IntermissionDisplay::Logo
1071 );
1072 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1073 assert_eq!(
1074 intermissions[IntermKind::Suspend],
1075 IntermissionDisplay::Calendar
1076 );
1077 }
1078
1079 #[test]
1080 fn test_intermissions_accept_blank_selection_for_all_kinds() {
1081 let mut intermissions = Intermissions {
1082 suspend: IntermissionDisplay::Logo,
1083 power_off: IntermissionDisplay::Logo,
1084 share: IntermissionDisplay::Logo,
1085 };
1086
1087 assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Blank));
1088 assert!(intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::BlankInverted));
1089 assert!(intermissions.set_display(IntermKind::Share, IntermissionDisplay::Blank));
1090
1091 assert_eq!(
1092 intermissions[IntermKind::Suspend],
1093 IntermissionDisplay::Blank
1094 );
1095 assert_eq!(
1096 intermissions[IntermKind::PowerOff],
1097 IntermissionDisplay::BlankInverted
1098 );
1099 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Blank);
1100 }
1101
1102 #[test]
1103 fn test_intermissions_sanitize_replaces_unsupported_calendar() {
1104 let mut intermissions = Intermissions {
1105 suspend: IntermissionDisplay::Calendar,
1106 power_off: IntermissionDisplay::Calendar,
1107 share: IntermissionDisplay::Calendar,
1108 };
1109
1110 assert!(intermissions.sanitize());
1111
1112 assert_eq!(
1113 intermissions[IntermKind::Suspend],
1114 IntermissionDisplay::Calendar
1115 );
1116 assert_eq!(
1117 intermissions[IntermKind::PowerOff],
1118 IntermissionDisplay::Logo
1119 );
1120 assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1121 }
1122
1123 #[test]
1124 fn test_allowed_kinds_deserializes_known_extensions() {
1125 let toml_str = r#"
1126startup-trigger = true
1127sync-metadata = true
1128metadata-kinds = ["epub"]
1129allowed-kinds = ["epub", "pdf", "cbz"]
1130"#;
1131 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1132
1133 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1134 assert!(settings.allowed_kinds.contains(&FileExtension::Pdf));
1135 assert!(settings.allowed_kinds.contains(&FileExtension::Cbz));
1136 assert_eq!(settings.allowed_kinds.len(), 3);
1137 }
1138
1139 #[test]
1140 fn test_allowed_kinds_silently_drops_unknown_extensions() {
1141 let toml_str = r#"
1142startup-trigger = true
1143sync-metadata = true
1144metadata-kinds = []
1145allowed-kinds = ["epub", "unknown-format", "another-unknown"]
1146"#;
1147 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1148
1149 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1150 assert_eq!(settings.allowed_kinds.len(), 1);
1151 }
1152
1153 #[test]
1154 fn test_file_extension_round_trip_via_from_str() {
1155 for ext in FileExtension::all() {
1156 let parsed = ext.as_str().parse::<FileExtension>().ok();
1157 assert_eq!(parsed, Some(*ext), "round trip failed for {:?}", ext);
1158 }
1159 }
1160
1161 #[test]
1162 fn test_htm_extension_parses_as_html() {
1163 let parsed = "htm".parse::<FileExtension>();
1164 assert_eq!(parsed, Ok(FileExtension::Html));
1165 }
1166
1167 #[test]
1168 fn test_html_extension_still_parses() {
1169 let parsed = "html".parse::<FileExtension>();
1170 assert_eq!(parsed, Ok(FileExtension::Html));
1171 }
1172}