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