1use anyhow::{bail, Context, Result};
2use bitflags::bitflags;
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
4use directories::ProjectDirs;
5use ratatui::{
6 layout::Constraint,
7 style::{Color, Style},
8 widgets::{BorderType, Padding},
9};
10use serde::Deserialize;
11use std::{path::PathBuf, str::FromStr};
12use tracing::level_filters::LevelFilter;
13use wiki_api::{languages::Language, search, Endpoint};
14
15pub const CACHE_ENV: &str = "WIKI_TUI_CACHE";
16pub const CONFIG_ENV: &str = "WIKI_TUI_CONFIG";
17
18pub const THEME_FILE_NAME: &str = "theme.toml";
19pub const CONFIG_FILE_NAME: &str = "config.toml";
20
21pub fn project_dir() -> Option<ProjectDirs> {
22 ProjectDirs::from("com", "builditluc", "wiki-tui")
23}
24
25pub fn cache_dir() -> Result<PathBuf> {
26 let directory = if let Ok(dir) = std::env::var(CACHE_ENV) {
27 PathBuf::from(dir)
28 } else if let Some(project_dir) = project_dir() {
29 project_dir.cache_dir().to_path_buf()
30 } else {
31 bail!("Unable to find data directory for wiki-tui");
32 };
33
34 Ok(directory)
35}
36
37pub fn config_dir() -> Result<PathBuf> {
38 let directory = if let Ok(dir) = std::env::var(CONFIG_ENV) {
39 PathBuf::from(dir)
40 } else if let Some(project_dir) = project_dir() {
41 project_dir.config_local_dir().to_path_buf()
42 } else {
43 bail!("Unable to find config directory for wiki-tui");
44 };
45
46 if !directory.exists() {
47 std::fs::create_dir_all(&directory).context("Unable to create the config folder")?;
48 }
49
50 Ok(directory)
51}
52
53macro_rules! override_options {
54 ($config:expr, $uconfig:ident::{$( $option:ident ),+}) => {
55 $({override_options!($config, $uconfig::$option->$option)})+
56 };
57 ($config:expr, $uconfig:ident::{$( $uoption:ident->$option:ident ),+}) => {
58 $({override_options!($config, $uconfig::$uoption->$option)})+
59 };
60 ($config:expr, $uconfig:ident::$uoption:ident) => {
61 override_options!($config, $uconfig::$uoption->$uoption)
62 };
63 ($config:expr, $uconfig:ident::$uoption:ident->$option:ident) => {
64 if let Some(option) = $uconfig.$uoption {
65 $config.$option = option.into();
66 }
67 };
68}
69
70pub fn load_logging_config() -> Result<LoggingConfig> {
71 let mut default_config = LoggingConfig::default();
72 let user_config = toml::from_str::<UserLoggingConfig>(&get_user_config()?)
73 .context("failed loading the user logging configuration")?;
74
75 if let Some(inner) = user_config.logging {
76 override_options!(default_config, inner::enabled);
77
78 if let Some(ref level) = inner.level {
80 default_config.level = LevelFilter::from_str(level)?;
81 }
82 }
83
84 Ok(default_config)
85}
86
87pub struct LoggingConfig {
88 pub enabled: bool,
89 pub level: LevelFilter,
90}
91
92impl Default for LoggingConfig {
93 fn default() -> Self {
94 LoggingConfig {
95 enabled: true,
96 level: LevelFilter::WARN,
97 }
98 }
99}
100
101#[derive(Deserialize)]
102struct UserLoggingConfig {
103 logging: Option<UserLoggingConfigInner>,
104}
105
106#[derive(Deserialize)]
107struct UserLoggingConfigInner {
108 enabled: Option<bool>,
109 #[serde(rename = "log_level")]
110 level: Option<String>,
111}
112
113pub fn load_config() -> Result<Config> {
114 let mut default_config = Config::default();
115 let user_config = load_user_config().context("failed loading the user config")?;
116
117 if let Some(user_page_config) = user_config.page {
118 override_page_config(&mut default_config.page, user_page_config)
119 }
120
121 if let Some(user_bindings_config) = user_config.bindings {
122 override_bindings_config(&mut default_config.bindings, user_bindings_config)
123 }
124
125 if let Some(user_api_config) = user_config.api {
126 override_api_config(&mut default_config.api, user_api_config)?
127 }
128
129 if let Some(user_ui_config) = user_config.ui {
130 override_ui_config(&mut default_config.ui, user_ui_config)
131 }
132
133 Ok(default_config)
134}
135
136fn override_page_config(config: &mut PageConfig, user_config: UserPageConfig) {
137 if let Some(user_toc) = user_config.toc {
138 override_options!(config.toc, user_toc::{
139 enabled,
140 width_percentage,
141 position,
142 title,
143 item_format,
144
145 enable_scrolling
146 });
147 }
148
149 override_options!(config, user_config::padding);
150
151 if let Some(user_zen) = user_config.zen_mode {
152 override_options!(config, user_zen::{
153 default->default_zen,
154 include->zen_mode,
155
156 horizontal->zen_horizontal,
157 vertical->zen_vertical
158 });
159 }
160}
161
162fn override_bindings_config(config: &mut Keybindings, user_config: UserKeybindingsConfig) {
163 if let Some(user_global_bindings) = user_config.global {
164 override_options!(config.global, user_global_bindings::{
165 scroll_down,
166 scroll_up,
167
168 scroll_to_top,
169 scroll_to_bottom,
170
171 pop_popup,
172
173 half_down,
174 half_up,
175 unselect_scroll,
176
177 submit,
178 quit,
179 enter_search_bar,
180 exit_search_bar,
181
182 switch_context_search,
183 switch_context_page,
184
185 toggle_search_language_selection,
186 toggle_logger,
187
188 help
189 });
190 }
191
192 if let Some(user_search_bindings) = user_config.search {
193 override_options!(config.search, user_search_bindings::continue_search);
194 }
195
196 if let Some(user_page_bindings) = user_config.page {
197 override_options!(config.page, user_page_bindings::{
198 pop_page,
199 jump_to_header,
200 select_first_link,
201 select_last_link,
202 select_next_link,
203 select_prev_link,
204 open_link,
205 toggle_page_language_selection,
206 toggle_zen_mode,
207 toggle_toc
208 });
209 }
210}
211
212fn override_api_config(config: &mut ApiConfig, user_config: UserApiConfig) -> Result<()> {
213 {
215 let pre_language = match user_config.pre_language {
216 Some(ref language) => language.as_str(),
217 None => "https://",
218 };
219 let language = &user_config.language.unwrap_or(config.language);
220 let post_language = match user_config.post_language {
221 Some(ref language) => language.as_str(),
222 None => ".wikipedia.org/w/api.php",
223 };
224
225 config.endpoint = Endpoint::parse(&format!(
226 "{}{}{}",
227 pre_language,
228 language.code(),
229 post_language,
230 ))
231 .context("failed parsing the endpoint url")?;
232 }
233
234 override_options!(config, user_config::{
235 language,
236
237 search_limit,
238 search_info,
239 search_type,
240 search_qiprofile,
241 search_rewrites,
242 search_sort_order,
243
244 page_redirects
245 });
246
247 Ok(())
248}
249
250fn override_ui_config(config: &mut UiConfig, user_config: UserUiConfig) {
251 override_options!(config, user_config::{
252 popup_search_language_changed,
253 popup_page_language_changed
254 });
255}
256
257fn get_user_config() -> Result<String> {
258 let path = config_dir()
259 .context("failed retrieving the config dir")?
260 .join(CONFIG_FILE_NAME);
261
262 if !path.exists() {
263 std::fs::write(&path, "").context("failed creating the config file")?;
264 }
265
266 std::fs::read_to_string(&path).context("failed reading the config file")
267}
268
269fn load_user_config() -> Result<UserConfig> {
270 let user_config_str = get_user_config()?;
271 toml::from_str::<UserConfig>(&user_config_str).context("failed parsing the user config")
272}
273
274pub struct Config {
275 pub page: PageConfig,
276 pub bindings: Keybindings,
277 pub api: ApiConfig,
278 pub ui: UiConfig,
279}
280
281pub struct PageConfig {
282 pub toc: TocConfig,
283 pub padding: Padding,
284
285 pub default_zen: bool,
286 pub zen_mode: ZenModeComponents,
287
288 pub zen_horizontal: Constraint,
289 pub zen_vertical: Constraint,
290}
291
292bitflags! {
293 #[derive(Deserialize, Debug, Clone)]
294 pub struct ZenModeComponents: u8 {
295 const STATUS_BAR = 0b00000001;
296 const TOC = 0b00000010;
297 const SEARCH_BAR = 0b00000100;
298 const SCROLLBAR = 0b00001000;
299 }
300}
301
302#[derive(Deserialize, Debug)]
303#[serde(untagged)]
304pub enum PaddingConfig {
305 Uniform(u16),
306 Horizontal { horizontal: u16 },
307 Vertical { veritical: u16 },
308 Proportional { proportional: u16 },
309 Symmetric { symmetric: (u16, u16) },
310 Custom(u16, u16, u16, u16),
311}
312
313#[allow(clippy::from_over_into)] impl Into<Padding> for PaddingConfig {
316 fn into(self) -> Padding {
317 match self {
318 PaddingConfig::Uniform(val) => Padding::uniform(val),
319 PaddingConfig::Horizontal { horizontal } => Padding::horizontal(horizontal),
320 PaddingConfig::Vertical { veritical } => Padding::vertical(veritical),
321 PaddingConfig::Proportional { proportional } => Padding::proportional(proportional),
322 PaddingConfig::Symmetric { symmetric } => Padding::symmetric(symmetric.0, symmetric.1),
323 PaddingConfig::Custom(left, right, top, bottom) => {
324 Padding::new(left, right, top, bottom)
325 }
326 }
327 }
328}
329
330#[derive(Deserialize, PartialEq, Eq)]
331pub enum TocConfigPosition {
332 Left,
333 Right,
334}
335
336#[derive(Deserialize)]
337pub enum TocConfigTitle {
338 Default,
339 Article,
340 Custom(String),
341}
342
343pub struct TocConfig {
344 pub enabled: bool,
345 pub width_percentage: u16,
346 pub position: TocConfigPosition,
347 pub title: TocConfigTitle,
348 item_format: String,
349
350 pub enable_scrolling: bool,
351}
352
353#[derive(Deserialize)]
354pub struct Binding {
355 code: KeyCode,
356 modifiers: KeyModifiers,
357}
358
359impl std::fmt::Display for Binding {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 macro_rules! vim_string {
362 ($s:tt) => {
363 std::format!("<{}>", $s)
364 };
365 }
366
367 let mut string = String::with_capacity(10);
368
369 if self.modifiers.contains(KeyModifiers::CONTROL) {
370 string.push_str("CTRL-");
371 }
372 if self.modifiers.contains(KeyModifiers::META) {
373 string.push_str("META-");
374 }
375 if self.modifiers.contains(KeyModifiers::ALT) {
376 string.push_str("ALT-");
377 }
378 if self.modifiers.contains(KeyModifiers::SUPER) {
379 string.push_str("SUPER-");
380 }
381 if self.modifiers.contains(KeyModifiers::HYPER) {
382 string.push_str("HYPER-");
383 }
384 if self.modifiers.contains(KeyModifiers::SHIFT) {
385 string.push_str("SHIFT-");
386 }
387
388 string.push_str(&match self.code {
389 KeyCode::Null | KeyCode::Modifier(_) => String::default(),
390 KeyCode::F(n) => format!("F{n}"),
391 KeyCode::Char(c) => c.to_string(),
392 KeyCode::Media(media_key) => match media_key {
393 MediaKeyCode::Play => vim_string!("Play"),
394 MediaKeyCode::Pause => vim_string!("Pause"),
395 MediaKeyCode::PlayPause => vim_string!("Play Pause"),
396 MediaKeyCode::Reverse => vim_string!("Reverse"),
397 MediaKeyCode::Stop => vim_string!("Stop"),
398 MediaKeyCode::FastForward => vim_string!("Fast Forward"),
399 MediaKeyCode::Rewind => vim_string!("Rewind"),
400 MediaKeyCode::TrackNext => vim_string!("Track Next"),
401 MediaKeyCode::TrackPrevious => vim_string!("Track Previous"),
402 MediaKeyCode::Record => vim_string!("Record"),
403 MediaKeyCode::LowerVolume => vim_string!("Lower Volume"),
404 MediaKeyCode::RaiseVolume => vim_string!("Raise Volume"),
405 MediaKeyCode::MuteVolume => vim_string!("Mute Volume"),
406 },
407 KeyCode::Backspace => vim_string!("Backspace"),
408 KeyCode::Enter => vim_string!("Enter"),
409 KeyCode::Left => vim_string!("Left"),
410 KeyCode::Right => vim_string!("Right"),
411 KeyCode::Up => vim_string!("Up"),
412 KeyCode::Down => vim_string!("Down"),
413 KeyCode::Home => vim_string!("Home"),
414 KeyCode::End => vim_string!("End"),
415 KeyCode::PageUp => vim_string!("Page Up"),
416 KeyCode::PageDown => vim_string!("Page Down"),
417 KeyCode::Tab => vim_string!("Tab"),
418 KeyCode::BackTab => vim_string!("Back Tab"),
419 KeyCode::Delete => vim_string!("Delete"),
420 KeyCode::Insert => vim_string!("Insert"),
421 KeyCode::Esc => vim_string!("Esc"),
422 KeyCode::CapsLock => vim_string!("Caps Lock"),
423 KeyCode::ScrollLock => vim_string!("Scroll Lock"),
424 KeyCode::NumLock => vim_string!("Num Lock"),
425 KeyCode::PrintScreen => vim_string!("Print Screen"),
426 KeyCode::Pause => vim_string!("Pause"),
427 KeyCode::Menu => vim_string!("Menu"),
428 KeyCode::KeypadBegin => vim_string!("Keypad Begin"),
429 });
430
431 write!(f, "{}", string)
432 }
433}
434
435#[derive(Deserialize)]
436pub struct Keybinding {
437 bindings: Vec<Binding>,
438}
439
440impl Keybinding {
441 fn new() -> Self {
442 Self {
443 bindings: Vec::new(),
444 }
445 }
446
447 fn binding(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
448 self.bindings.push(Binding { code, modifiers });
449 self
450 }
451
452 pub fn matches_event(&self, event: KeyEvent) -> bool {
453 self.bindings
454 .iter()
455 .any(|x| x.code == event.code && x.modifiers == event.modifiers)
456 }
457
458 pub fn bindings(&self) -> &[Binding] {
459 &self.bindings
460 }
461}
462
463pub struct GlobalKeybindings {
464 pub scroll_down: Keybinding,
465 pub scroll_up: Keybinding,
466
467 pub scroll_to_top: Keybinding,
468 pub scroll_to_bottom: Keybinding,
469
470 pub pop_popup: Keybinding,
471
472 pub half_down: Keybinding,
473 pub half_up: Keybinding,
474 pub unselect_scroll: Keybinding,
475
476 pub submit: Keybinding,
477 pub quit: Keybinding,
478 pub enter_search_bar: Keybinding,
479 pub exit_search_bar: Keybinding,
480
481 pub switch_context_search: Keybinding,
482 pub switch_context_page: Keybinding,
483
484 pub toggle_search_language_selection: Keybinding,
485 pub toggle_logger: Keybinding,
486
487 pub help: Keybinding,
488}
489
490pub struct SearchKeybindings {
491 pub continue_search: Keybinding,
492}
493
494pub struct PageKeybindings {
495 pub pop_page: Keybinding,
496 pub jump_to_header: Keybinding,
497
498 pub select_first_link: Keybinding,
499 pub select_last_link: Keybinding,
500
501 pub select_prev_link: Keybinding,
502 pub select_next_link: Keybinding,
503
504 pub open_link: Keybinding,
505
506 pub toggle_page_language_selection: Keybinding,
507 pub toggle_zen_mode: Keybinding,
508 pub toggle_toc: Keybinding,
509}
510
511pub struct Keybindings {
512 pub global: GlobalKeybindings,
513 pub search: SearchKeybindings,
514 pub page: PageKeybindings,
515}
516
517pub struct ApiConfig {
518 pub endpoint: Endpoint,
519 pub language: Language,
520
521 pub search_limit: usize,
522 pub search_qiprofile: search::QiProfile,
523 pub search_type: search::SearchType,
524 pub search_info: search::Info,
525 pub search_rewrites: bool,
526 pub search_sort_order: search::SortOrder,
527
528 pub page_redirects: bool,
529}
530
531pub struct UiConfig {
532 pub popup_search_language_changed: bool,
533 pub popup_page_language_changed: bool,
534}
535
536impl Config {
537 pub fn new() -> Self {
538 macro_rules! keybinding {
539 ([$($ch:expr; $($md:ident)|*),+]) => {
540 {
541 Keybinding::new()
542 $(.binding(
543 $ch,
544 KeyModifiers::NONE$(|KeyModifiers::$md)*
545 ))+
546 }
547 };
548 }
549
550 Self {
551 page: PageConfig {
552 toc: TocConfig {
553 enabled: true,
554 width_percentage: 20,
555 position: TocConfigPosition::Right,
556 title: TocConfigTitle::Default,
557 item_format: "{NUMBER} {TEXT}".to_string(),
558
559 enable_scrolling: true,
560 },
561 padding: Padding::ZERO,
562
563 default_zen: false,
564 zen_mode: ZenModeComponents::empty(),
565
566 zen_horizontal: Constraint::Percentage(80),
567 zen_vertical: Constraint::Percentage(90),
568 },
569 bindings: Keybindings {
570 global: GlobalKeybindings {
571 scroll_down: keybinding!([KeyCode::Char('j');, KeyCode::Down;]),
572 scroll_up: keybinding!([KeyCode::Char('k');, KeyCode::Up;]),
573
574 scroll_to_top: keybinding!([KeyCode::Char('g');, KeyCode::Home;]),
575 scroll_to_bottom: keybinding!([KeyCode::Char('G'); SHIFT, KeyCode::End;]),
576
577 pop_popup: keybinding!([KeyCode::Esc;]),
578
579 half_down: keybinding!([KeyCode::Char('d'); CONTROL, KeyCode::PageDown;]),
580 half_up: keybinding!([KeyCode::Char('u'); CONTROL, KeyCode::PageUp;]),
581
582 unselect_scroll: keybinding!([KeyCode::Char('h');]),
583
584 submit: keybinding!([KeyCode::Enter;]),
585 quit: keybinding!([KeyCode::Char('q');, KeyCode::Char('c'); CONTROL]),
586
587 enter_search_bar: keybinding!([KeyCode::Char('i');]),
588 exit_search_bar: keybinding!([KeyCode::Esc;]),
589
590 switch_context_search: keybinding!([KeyCode::Char('s');]),
591 switch_context_page: keybinding!([KeyCode::Char('p');]),
592
593 toggle_search_language_selection: keybinding!([KeyCode::F(2);]),
594 toggle_logger: keybinding!([KeyCode::Char('l');]),
595
596 help: keybinding!([KeyCode::Char('?');]),
597 },
598 search: SearchKeybindings {
599 continue_search: keybinding!([KeyCode::Char('c');]),
600 },
601 page: PageKeybindings {
602 pop_page: keybinding!([KeyCode::Esc;]),
603 jump_to_header: keybinding!([KeyCode::Enter;]),
604 select_first_link: keybinding!([KeyCode::Left; SHIFT]),
605 select_last_link: keybinding!([KeyCode::Right; SHIFT]),
606 select_prev_link: keybinding!([KeyCode::Left;]),
607 select_next_link: keybinding!([KeyCode::Right;]),
608 open_link: keybinding!([KeyCode::Enter;]),
609 toggle_page_language_selection: keybinding!([KeyCode::F(3);]),
610 toggle_zen_mode: keybinding!([KeyCode::F(4);]),
611 toggle_toc: keybinding!([KeyCode::Tab;, KeyCode::BackTab;]),
612 },
613 },
614 api: ApiConfig {
615 endpoint: Endpoint::parse("https://en.wikipedia.org/w/api.php")
616 .expect("Hardcoded links should work"),
617 language: Language::English,
618
619 search_limit: 10,
620 search_qiprofile: search::QiProfile::default(),
621 search_type: search::SearchType::default(),
622 search_info: search::Info::default(),
623 search_rewrites: false,
624 search_sort_order: search::SortOrder::Relevance,
625
626 page_redirects: false,
627 },
628 ui: UiConfig {
629 popup_search_language_changed: true,
630 popup_page_language_changed: true,
631 },
632 }
633 }
634}
635
636impl TocConfig {
637 pub fn formatted_item(&self, number: &str, text: &str) -> String {
638 const NUMBER_FMT: &str = "{NUMBER}";
639 const TEXT_FMT: &str = "{TEXT}";
640
641 self.item_format
642 .replace(NUMBER_FMT, number)
643 .replace(TEXT_FMT, text)
644 }
645}
646
647impl Default for Config {
648 fn default() -> Self {
649 Config::new()
650 }
651}
652
653#[derive(Deserialize)]
654struct UserConfig {
655 page: Option<UserPageConfig>,
656 bindings: Option<UserKeybindingsConfig>,
657 api: Option<UserApiConfig>,
658 ui: Option<UserUiConfig>,
659}
660
661#[derive(Deserialize)]
662struct UserPageConfig {
663 toc: Option<UserTocConfig>,
664 padding: Option<PaddingConfig>,
665
666 zen_mode: Option<UserZenModeConfig>,
667}
668
669#[derive(Deserialize)]
670#[serde(rename_all = "lowercase")]
671enum UserConstraint {
672 Min(u16),
673 Max(u16),
674 Length(u16),
675 Percentage(u16),
676 Ratio(u32, u32),
677}
678
679#[allow(clippy::from_over_into)]
680impl Into<Constraint> for UserConstraint {
681 fn into(self) -> Constraint {
682 match self {
683 UserConstraint::Min(u) => Constraint::Min(u),
684 UserConstraint::Max(u) => Constraint::Max(u),
685 UserConstraint::Length(u) => Constraint::Length(u),
686 UserConstraint::Percentage(u) => Constraint::Percentage(u),
687 UserConstraint::Ratio(u, v) => Constraint::Ratio(u, v),
688 }
689 }
690}
691
692#[derive(Deserialize)]
693struct UserZenModeConfig {
694 default: Option<bool>,
695 include: Option<ZenModeComponents>,
696
697 horizontal: Option<UserConstraint>,
698 vertical: Option<UserConstraint>,
699}
700
701#[derive(Deserialize)]
702struct UserTocConfig {
703 enabled: Option<bool>,
704 width_percentage: Option<u16>,
705 position: Option<TocConfigPosition>,
706 title: Option<TocConfigTitle>,
707 item_format: Option<String>,
708
709 enable_scrolling: Option<bool>,
710}
711#[derive(Deserialize)]
712#[serde(rename_all = "lowercase")]
713enum UserKeyCodeInner {
714 Backspace,
715 Enter,
716 Left,
717 Right,
718 Up,
719 Down,
720 Home,
721 End,
722 PageUp,
723 PageDown,
724 Tab,
725 BackTab,
726 Delete,
727 Insert,
728 Esc,
729
730 F1,
731 F2,
732 F3,
733 F4,
734 F5,
735 F6,
736 F7,
737 F8,
738 F9,
739 F10,
740 F11,
741 F12,
742}
743
744#[derive(Deserialize)]
745#[serde(untagged)]
746enum UserKeyCode {
747 Char(char),
748 NonChar(UserKeyCodeInner),
749}
750
751#[allow(clippy::from_over_into)]
752impl Into<KeyCode> for UserKeyCode {
753 fn into(self) -> KeyCode {
754 match self {
755 Self::Char(char) => KeyCode::Char(char),
756 Self::NonChar(inner) => match inner {
757 UserKeyCodeInner::Backspace => KeyCode::Backspace,
758 UserKeyCodeInner::Enter => KeyCode::Enter,
759 UserKeyCodeInner::Left => KeyCode::Left,
760 UserKeyCodeInner::Right => KeyCode::Right,
761 UserKeyCodeInner::Up => KeyCode::Up,
762 UserKeyCodeInner::Down => KeyCode::Down,
763 UserKeyCodeInner::Home => KeyCode::Home,
764 UserKeyCodeInner::End => KeyCode::End,
765 UserKeyCodeInner::PageUp => KeyCode::PageUp,
766 UserKeyCodeInner::PageDown => KeyCode::PageDown,
767 UserKeyCodeInner::Tab => KeyCode::Tab,
768 UserKeyCodeInner::BackTab => KeyCode::BackTab,
769 UserKeyCodeInner::Delete => KeyCode::Delete,
770 UserKeyCodeInner::Insert => KeyCode::Insert,
771 UserKeyCodeInner::Esc => KeyCode::Esc,
772 UserKeyCodeInner::F1 => KeyCode::F(1),
773 UserKeyCodeInner::F2 => KeyCode::F(2),
774 UserKeyCodeInner::F3 => KeyCode::F(3),
775 UserKeyCodeInner::F4 => KeyCode::F(4),
776 UserKeyCodeInner::F5 => KeyCode::F(5),
777 UserKeyCodeInner::F6 => KeyCode::F(6),
778 UserKeyCodeInner::F7 => KeyCode::F(7),
779 UserKeyCodeInner::F8 => KeyCode::F(8),
780 UserKeyCodeInner::F9 => KeyCode::F(9),
781 UserKeyCodeInner::F10 => KeyCode::F(10),
782 UserKeyCodeInner::F11 => KeyCode::F(12),
783 UserKeyCodeInner::F12 => KeyCode::F(13),
784 },
785 }
786 }
787}
788
789#[derive(Deserialize)]
790#[serde(untagged)]
791enum UserBinding {
792 CodeOnly(UserKeyCode),
793 Binding {
794 code: UserKeyCode,
795 modifiers: Option<KeyModifiers>,
796 },
797}
798
799#[allow(clippy::from_over_into)]
800impl Into<Binding> for UserBinding {
801 fn into(self) -> Binding {
802 match self {
803 UserBinding::CodeOnly(code) => UserBinding::Binding {
804 code,
805 modifiers: None,
806 }
807 .into(),
808 UserBinding::Binding { code, modifiers } => Binding {
809 code: code.into(),
810 modifiers: modifiers.unwrap_or(KeyModifiers::empty()),
811 },
812 }
813 }
814}
815
816#[derive(Deserialize)]
817#[serde(untagged)]
818enum UserKeybinding {
819 SingleBinding(UserBinding),
820 MultipleBindings(Vec<UserBinding>),
821}
822
823#[allow(clippy::from_over_into)]
824impl Into<Keybinding> for UserKeybinding {
825 fn into(self) -> Keybinding {
826 match self {
827 UserKeybinding::SingleBinding(binding) => Keybinding {
828 bindings: vec![binding.into()],
829 },
830 UserKeybinding::MultipleBindings(bindings) => Keybinding {
831 bindings: bindings.into_iter().map(|x| x.into()).collect(),
832 },
833 }
834 }
835}
836
837macro_rules! user_keybindings {
838 ($name:ident, $($binding:ident),+) => {
839 #[derive(Deserialize)]
840 struct $name {
841 $($binding: Option<UserKeybinding>,)+
842 }
843 };
844}
845
846user_keybindings!(
847 UserGlobalKeybindings,
848 scroll_down,
849 scroll_up,
850 scroll_to_top,
851 scroll_to_bottom,
852 pop_popup,
853 half_down,
854 half_up,
855 unselect_scroll,
856 submit,
857 quit,
858 enter_search_bar,
859 exit_search_bar,
860 switch_context_search,
861 switch_context_page,
862 toggle_search_language_selection,
863 toggle_logger,
864 help
865);
866
867user_keybindings!(UserSearchKeybindings, continue_search);
868
869user_keybindings!(
870 UserPageKeybindings,
871 pop_page,
872 jump_to_header,
873 select_first_link,
874 select_last_link,
875 select_prev_link,
876 select_next_link,
877 open_link,
878 toggle_page_language_selection,
879 toggle_zen_mode,
880 toggle_toc
881);
882
883#[derive(Deserialize)]
884struct UserKeybindingsConfig {
885 global: Option<UserGlobalKeybindings>,
886 search: Option<UserSearchKeybindings>,
887 page: Option<UserPageKeybindings>,
888}
889
890#[derive(Deserialize)]
891struct UserApiConfig {
892 pre_language: Option<String>,
893 language: Option<Language>,
894 post_language: Option<String>,
895
896 search_limit: Option<usize>,
897 search_qiprofile: Option<search::QiProfile>,
898 search_type: Option<search::SearchType>,
899 search_info: Option<search::Info>,
900 search_rewrites: Option<bool>,
901 search_sort_order: Option<search::SortOrder>,
902
903 page_redirects: Option<bool>,
904}
905
906#[derive(Deserialize, Debug)]
907struct UserUiConfig {
908 popup_search_language_changed: Option<bool>,
909 popup_page_language_changed: Option<bool>,
910}
911
912pub fn load_theme() -> Result<Theme> {
913 let mut default_theme = Theme::default();
914 let user_theme = load_user_theme().context("failed loading the user theme")?;
915
916 override_options!(default_theme, user_theme::{
917 bg,
918 fg,
919
920 title,
921
922 selected_bg,
923 selected_fg,
924
925 inactive_fg,
926 highlight_fg,
927
928 border_fg,
929 border_bg,
930 border_type,
931
932 border_highlight_fg,
933 border_highlight_bg,
934
935 scrollbar_track_fg,
936 scrollbar_thumb_fg,
937
938 search_title_fg,
939
940 status_bar_fg,
941 status_bar_bg
942 });
943
944 Ok(default_theme)
945}
946
947fn load_user_theme() -> Result<UserTheme> {
948 let path = config_dir()
949 .context("failed retrieving the config dir")?
950 .join(THEME_FILE_NAME);
951
952 if !path.exists() {
953 std::fs::write(&path, "").context("failed creating the theme config file")?;
954 }
955
956 let user_theme_str =
957 std::fs::read_to_string(&path).context("failed reading the theme config file")?;
958
959 toml::from_str::<UserTheme>(&user_theme_str).context("failed parsing the user theme")
960}
961
962#[derive(Clone)]
963pub struct Theme {
964 pub bg: Color,
965 pub fg: Color,
966
967 pub title: Color,
968
969 pub selected_bg: Color,
970 pub selected_fg: Color,
971
972 pub inactive_fg: Color,
973 pub highlight_fg: Color,
974
975 pub border_fg: Color,
976 pub border_bg: Color,
977 pub border_type: ThemeBorderType,
978
979 pub border_highlight_fg: Color,
980 pub border_highlight_bg: Color,
981
982 pub scrollbar_track_fg: Color,
983 pub scrollbar_thumb_fg: Color,
984
985 pub search_title_fg: Color,
986
987 pub status_bar_fg: Color,
988 pub status_bar_bg: Color,
989}
990
991impl Theme {
992 pub fn new() -> Self {
993 Theme {
994 bg: Color::Reset,
995 fg: Color::Reset,
996
997 title: Color::White,
998
999 selected_bg: Color::DarkGray,
1000 selected_fg: Color::Reset,
1001
1002 inactive_fg: Color::Blue,
1003 highlight_fg: Color::White,
1004
1005 border_fg: Color::White,
1006 border_bg: Color::Reset,
1007 border_type: ThemeBorderType::Rounded,
1008
1009 border_highlight_fg: Color::Yellow,
1010 border_highlight_bg: Color::Reset,
1011
1012 scrollbar_track_fg: Color::Black,
1013 scrollbar_thumb_fg: Color::Blue,
1014
1015 search_title_fg: Color::Red,
1016
1017 status_bar_fg: Color::Reset,
1018 status_bar_bg: Color::DarkGray,
1019 }
1020 }
1021
1022 pub fn default_paragraph<'a, T>(&self, text: T) -> ratatui::widgets::Paragraph<'a>
1024 where
1025 T: Into<ratatui::text::Text<'a>>,
1026 {
1027 ratatui::widgets::Paragraph::new(text).style(Style::default().bg(self.bg).fg(self.fg))
1028 }
1029
1030 pub fn default_block(&self) -> ratatui::widgets::Block<'_> {
1032 ratatui::widgets::Block::default()
1033 .borders(ratatui::widgets::Borders::ALL)
1034 .border_type(self.border_type.clone().into())
1035 .border_style(Style::default().fg(self.border_fg).bg(self.border_bg))
1036 .title_style(Style::default().fg(self.title))
1037 }
1038}
1039
1040impl Default for Theme {
1041 fn default() -> Self {
1042 Theme::new()
1043 }
1044}
1045
1046#[derive(Deserialize, Clone)]
1047pub enum ThemeBorderType {
1048 Plain,
1049 Rounded,
1050 Double,
1051 Thick,
1052 QuadrantInside,
1053 QuadrantOutside,
1054}
1055
1056impl From<ThemeBorderType> for BorderType {
1057 fn from(val: ThemeBorderType) -> Self {
1058 match val {
1059 ThemeBorderType::Plain => BorderType::Plain,
1060 ThemeBorderType::Rounded => BorderType::Rounded,
1061 ThemeBorderType::Double => BorderType::Double,
1062 ThemeBorderType::Thick => BorderType::Thick,
1063 ThemeBorderType::QuadrantInside => BorderType::QuadrantInside,
1064 ThemeBorderType::QuadrantOutside => BorderType::QuadrantOutside,
1065 }
1066 }
1067}
1068
1069#[derive(Deserialize)]
1070struct UserTheme {
1071 bg: Option<Color>,
1072 fg: Option<Color>,
1073
1074 title: Option<Color>,
1075
1076 selected_bg: Option<Color>,
1077 selected_fg: Option<Color>,
1078
1079 inactive_fg: Option<Color>,
1080 highlight_fg: Option<Color>,
1081
1082 border_fg: Option<Color>,
1083 border_bg: Option<Color>,
1084 border_type: Option<ThemeBorderType>,
1085
1086 border_highlight_fg: Option<Color>,
1087 border_highlight_bg: Option<Color>,
1088
1089 scrollbar_track_fg: Option<Color>,
1090 scrollbar_thumb_fg: Option<Color>,
1091
1092 search_title_fg: Option<Color>,
1093
1094 status_bar_fg: Option<Color>,
1095 status_bar_bg: Option<Color>,
1096}