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 pub auto_time: bool,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub rotation_lock: Option<RotationLock>,
262 pub button_scheme: ButtonScheme,
263 pub auto_suspend: f32,
264 pub auto_power_off: f32,
265 pub time_format: String,
266 pub date_format: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub external_urls_queue: Option<PathBuf>,
269 #[serde(skip_serializing_if = "Vec::is_empty")]
270 pub libraries: Vec<LibrarySettings>,
271 pub intermissions: Intermissions,
272 #[serde(skip_serializing_if = "Vec::is_empty")]
273 pub frontlight_presets: Vec<LightPreset>,
274 pub home: HomeSettings,
275 pub reader: ReaderSettings,
276 pub import: ImportSettings,
277 pub dictionary: DictionarySettings,
278 pub sketch: SketchSettings,
279 pub calculator: CalculatorSettings,
280 pub battery: BatterySettings,
281 pub frontlight_levels: LightLevels,
282 pub ota: OtaSettings,
283 pub logging: LoggingSettings,
284 pub settings_retention: usize,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub locale: Option<LanguageIdentifier>,
287}
288
289impl Settings {
290 pub fn sanitize(&mut self) -> bool {
292 self.intermissions.sanitize()
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(default, rename_all = "kebab-case")]
298pub struct LibrarySettings {
299 pub name: String,
300 pub path: PathBuf,
301 pub sort_method: SortMethod,
302 pub first_column: FirstColumn,
303 pub second_column: SecondColumn,
304 pub thumbnail_previews: bool,
305 #[serde(skip_serializing_if = "Vec::is_empty")]
306 pub hooks: Vec<Hook>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub finished: Option<FinishedAction>,
309}
310
311impl Default for LibrarySettings {
312 fn default() -> Self {
313 LibrarySettings {
314 name: "Unnamed".to_string(),
315 path: env::current_dir()
316 .ok()
317 .unwrap_or_else(|| PathBuf::from("/")),
318 sort_method: SortMethod::Opened,
319 first_column: FirstColumn::TitleAndAuthor,
320 second_column: SecondColumn::Progress,
321 thumbnail_previews: true,
322 hooks: Vec::new(),
323 finished: None,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(default, rename_all = "kebab-case")]
331pub struct ImportSettings {
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 sync_metadata: true,
891 metadata_kinds: ["epub", "pdf", "djvu"]
892 .iter()
893 .map(|k| k.to_string())
894 .collect(),
895 allowed_kinds: [
896 FileExtension::Pdf,
897 FileExtension::Djvu,
898 FileExtension::Epub,
899 FileExtension::Fb2,
900 FileExtension::Txt,
901 FileExtension::Xps,
902 FileExtension::Oxps,
903 FileExtension::Mobi,
904 FileExtension::Cbz,
905 FileExtension::Webp,
906 FileExtension::Png,
907 FileExtension::Jpg,
908 FileExtension::Jpeg,
909 ]
910 .iter()
911 .copied()
912 .collect(),
913 }
914 }
915}
916
917impl ImportSettings {
918 pub fn is_kind_allowed(&self, kind: FileExtension) -> bool {
920 self.allowed_kinds.contains(&kind)
921 }
922}
923
924impl Default for BatterySettings {
925 fn default() -> Self {
926 BatterySettings {
927 warn: 10.0,
928 power_off: 3.0,
929 }
930 }
931}
932
933impl Default for LoggingSettings {
934 fn default() -> Self {
935 LoggingSettings {
936 enabled: true,
937 level: "info".to_string(),
938 max_files: 3,
939 directory: PathBuf::from("logs"),
940 otlp_endpoint: None,
941 pyroscope_endpoint: None,
942 enable_kern_log: false,
943 enable_dbus_log: false,
944 }
945 }
946}
947
948impl Default for Settings {
949 fn default() -> Self {
950 Settings {
951 selected_library: 0,
952 libraries: cfg_select! {
953 feature = "emulator" => {
954 vec![LibrarySettings {
955 name: "Cadmus Source".to_string(),
956 path: PathBuf::from("."),
957 ..Default::default()
958 }]
959 }
960 _ => {
961 vec![
962 LibrarySettings {
963 name: "On Board".to_string(),
964 path: PathBuf::from(INTERNAL_CARD_ROOT),
965 hooks: vec![Hook {
966 path: PathBuf::from("Articles"),
967 program: PathBuf::from("bin/article_fetcher/article_fetcher"),
968 sort_method: Some(SortMethod::Added),
969 first_column: Some(FirstColumn::TitleAndAuthor),
970 second_column: Some(SecondColumn::Progress),
971 }],
972 ..Default::default()
973 },
974 LibrarySettings {
975 name: "Removable".to_string(),
976 path: PathBuf::from(EXTERNAL_CARD_ROOT),
977 ..Default::default()
978 },
979 LibrarySettings {
980 name: "Dropbox".to_string(),
981 path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
982 ..Default::default()
983 },
984 LibrarySettings {
985 name: "KePub".to_string(),
986 path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
987 ..Default::default()
988 },
989 ]
990 }
991 },
992 external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
993 keyboard_layout: "English".to_string(),
994 frontlight: true,
995 wifi: false,
996 inverted: false,
997 sleep_cover: true,
998 auto_share: false,
999 auto_time: 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#"
1191sync-metadata = true
1192metadata-kinds = ["epub"]
1193allowed-kinds = ["epub", "pdf", "cbz"]
1194"#;
1195 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1196
1197 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1198 assert!(settings.allowed_kinds.contains(&FileExtension::Pdf));
1199 assert!(settings.allowed_kinds.contains(&FileExtension::Cbz));
1200 assert_eq!(settings.allowed_kinds.len(), 3);
1201 }
1202
1203 #[test]
1204 fn test_allowed_kinds_silently_drops_unknown_extensions() {
1205 let toml_str = r#"
1206sync-metadata = true
1207metadata-kinds = []
1208allowed-kinds = ["epub", "unknown-format", "another-unknown"]
1209"#;
1210 let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1211
1212 assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1213 assert_eq!(settings.allowed_kinds.len(), 1);
1214 }
1215
1216 #[test]
1217 fn test_dithered_kinds_deserializes_known_extensions() {
1218 let toml_str = r#"
1219finished = "close"
1220dithered-kinds = ["cbz", "png", "jpeg"]
1221"#;
1222 let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1223
1224 assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1225 assert!(settings.dithered_kinds.contains(&FileExtension::Png));
1226 assert!(settings.dithered_kinds.contains(&FileExtension::Jpeg));
1227 assert_eq!(settings.dithered_kinds.len(), 3);
1228 }
1229
1230 #[test]
1231 fn test_dithered_kinds_silently_drops_unknown_extensions() {
1232 let toml_str = r#"
1233finished = "close"
1234dithered-kinds = ["cbz", "unknown-format"]
1235"#;
1236 let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1237
1238 assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1239 assert_eq!(settings.dithered_kinds.len(), 1);
1240 }
1241
1242 #[test]
1243 fn test_file_extension_round_trip_via_from_str() {
1244 for ext in FileExtension::all() {
1245 let parsed = ext.as_str().parse::<FileExtension>().ok();
1246 assert_eq!(parsed, Some(*ext), "round trip failed for {:?}", ext);
1247 }
1248 }
1249
1250 #[test]
1251 fn test_htm_extension_parses_as_html() {
1252 let parsed = "htm".parse::<FileExtension>();
1253 assert_eq!(parsed, Ok(FileExtension::Html));
1254 }
1255
1256 #[test]
1257 fn test_html_extension_still_parses() {
1258 let parsed = "html".parse::<FileExtension>();
1259 assert_eq!(parsed, Ok(FileExtension::Html));
1260 }
1261}