Skip to main content

wiki_tui/
config.rs

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        // we need to manually parse the level
79        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    // we need to manually build the endpoint
214    {
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)] // since we cannot implement From for an external type, we need to
314                                 // ignore the warning
315impl 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    /// Returns a Paragraph with the background and foreground colors set
1023    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    /// Returns a Block with Borders::ALL, BorderType::Rounded and bg and fg colors set
1031    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}