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