Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1 from RivoLink/feat/custom-themes

feat: custom themes

authored by

Rivo Link and committed by
GitHub
82573ec8 ff1ba049

+1038 -149
+2 -1
README.md
··· 62 62 | `g` / Home | Top | 63 63 | `G` / End | Bottom | 64 64 | `t` | Toggle TOC sidebar | 65 + | `T` | Open theme picker | 65 66 | `1`–`9` | Jump to TOC section N | 66 67 | `/` | Search | 67 68 | `n` / `N` | Next / prev match | ··· 92 93 93 94 ## Roadmap 94 95 95 - - [ ] Themes (light / custom) 96 + - [x] Themes (light / custom) 96 97 - [ ] Copy code block `y` 97 98 - [ ] Improve search performance on large files
+196 -16
src/app.rs
··· 1 1 use crate::{ 2 2 markdown::{build_plain_lines, hash_file_contents, hash_str, parse_markdown, read_file_state}, 3 3 render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 4 + theme::{ 5 + current_syntect_theme, current_theme_preset, set_theme_preset, theme_preset_index, 6 + ThemePreset, THEME_PRESETS, 7 + }, 4 8 }; 5 9 use ratatui::text::Line; 6 10 use std::{ 7 11 path::PathBuf, 8 12 time::{Duration, Instant, SystemTime}, 9 13 }; 10 - use syntect::{highlighting::Theme, parsing::SyntaxSet}; 14 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 11 15 12 16 #[derive(Clone)] 13 17 pub(crate) struct TocEntry { ··· 42 46 pub(crate) flash_active: bool, 43 47 } 44 48 49 + #[derive(Clone)] 50 + pub(crate) struct ThemePreviewCacheEntry { 51 + pub(crate) lines: Vec<Line<'static>>, 52 + pub(crate) toc: Vec<TocEntry>, 53 + } 54 + 45 55 pub(crate) struct App { 46 56 pub(crate) lines: Vec<Line<'static>>, 47 57 pub(crate) plain_lines: Vec<String>, ··· 56 66 pub(crate) search_idx: usize, 57 67 pub(crate) debug_input: bool, 58 68 pub(crate) filename: String, 69 + pub(crate) source: String, 59 70 pub(crate) watch: bool, 60 71 pub(crate) filepath: Option<PathBuf>, 61 72 pub(crate) last_file_state: Option<FileState>, ··· 68 79 pub(crate) toc_active_idx: Option<usize>, 69 80 pub(crate) status_line: Line<'static>, 70 81 pub(crate) status_cache_key: Option<StatusCacheKey>, 82 + pub(crate) theme_picker_open: bool, 83 + pub(crate) theme_picker_index: usize, 84 + pub(crate) theme_picker_original: Option<ThemePreset>, 85 + pub(crate) theme_preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 71 86 } 72 87 73 88 impl App { 89 + #[cfg(test)] 74 90 pub(crate) fn new( 75 91 lines: Vec<Line<'static>>, 76 92 toc: Vec<TocEntry>, ··· 80 96 filepath: Option<PathBuf>, 81 97 last_file_state: Option<FileState>, 82 98 ) -> Self { 99 + let source = lines 100 + .iter() 101 + .map(|line| { 102 + line.spans 103 + .iter() 104 + .map(|s| s.content.as_ref()) 105 + .collect::<String>() 106 + }) 107 + .collect::<Vec<_>>() 108 + .join("\n"); 109 + Self::new_with_source( 110 + lines, 111 + toc, 112 + filename, 113 + source, 114 + debug_input, 115 + watch, 116 + filepath, 117 + last_file_state, 118 + ) 119 + } 120 + 121 + #[allow(clippy::too_many_arguments)] 122 + pub(crate) fn new_with_source( 123 + lines: Vec<Line<'static>>, 124 + toc: Vec<TocEntry>, 125 + filename: String, 126 + source: String, 127 + debug_input: bool, 128 + watch: bool, 129 + filepath: Option<PathBuf>, 130 + last_file_state: Option<FileState>, 131 + ) -> Self { 83 132 let plain_lines = build_plain_lines(&lines); 84 133 let mut app = Self { 85 134 lines, ··· 95 144 search_idx: 0, 96 145 debug_input, 97 146 filename, 147 + source, 98 148 watch, 99 149 filepath, 100 150 last_file_state, ··· 107 157 toc_active_idx: None, 108 158 status_line: Line::default(), 109 159 status_cache_key: None, 160 + theme_picker_open: false, 161 + theme_picker_index: theme_preset_index(current_theme_preset()), 162 + theme_picker_original: None, 163 + theme_preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 110 164 }; 165 + app.store_current_theme_preview(); 111 166 app.refresh_static_caches(); 112 167 app 113 168 } ··· 126 181 self.lines = lines; 127 182 self.toc = toc; 128 183 self.highlighted_line_cache = None; 184 + self.toc_header_line = toc_header_line(); 129 185 self.refresh_static_caches(); 130 186 } 131 187 ··· 252 308 self.status_cache_key = None; 253 309 } 254 310 311 + pub(crate) fn invalidate_theme_preview_cache(&mut self) { 312 + self.theme_preview_cache.fill(None); 313 + } 314 + 315 + fn store_theme_preview( 316 + &mut self, 317 + preset: ThemePreset, 318 + lines: &[Line<'static>], 319 + toc: &[TocEntry], 320 + ) { 321 + let idx = theme_preset_index(preset); 322 + if let Some(slot) = self.theme_preview_cache.get_mut(idx) { 323 + *slot = Some(ThemePreviewCacheEntry { 324 + lines: lines.to_vec(), 325 + toc: toc.to_vec(), 326 + }); 327 + } 328 + } 329 + 330 + fn store_current_theme_preview(&mut self) { 331 + let preset = current_theme_preset(); 332 + let lines = self.lines.clone(); 333 + let toc = self.toc.clone(); 334 + self.store_theme_preview(preset, &lines, &toc); 335 + } 336 + 337 + pub(crate) fn open_theme_picker(&mut self) { 338 + self.theme_picker_open = true; 339 + let current = current_theme_preset(); 340 + self.theme_picker_index = theme_preset_index(current); 341 + self.theme_picker_original = Some(current); 342 + self.store_current_theme_preview(); 343 + } 344 + 345 + pub(crate) fn close_theme_picker(&mut self) { 346 + self.theme_picker_open = false; 347 + self.theme_picker_original = None; 348 + } 349 + 350 + pub(crate) fn is_theme_picker_open(&self) -> bool { 351 + self.theme_picker_open 352 + } 353 + 354 + pub(crate) fn theme_picker_index(&self) -> usize { 355 + self.theme_picker_index 356 + } 357 + 358 + pub(crate) fn theme_picker_reference_preset(&self) -> ThemePreset { 359 + self.theme_picker_original.unwrap_or(current_theme_preset()) 360 + } 361 + 362 + pub(crate) fn move_theme_picker_up(&mut self) { 363 + let total = THEME_PRESETS.len(); 364 + if total == 0 { 365 + return; 366 + } 367 + if self.theme_picker_index == 0 { 368 + self.theme_picker_index = total - 1; 369 + } else { 370 + self.theme_picker_index -= 1; 371 + } 372 + } 373 + 374 + pub(crate) fn move_theme_picker_down(&mut self) { 375 + let total = THEME_PRESETS.len(); 376 + if total == 0 { 377 + return; 378 + } 379 + self.theme_picker_index = (self.theme_picker_index + 1) % total; 380 + } 381 + 382 + pub(crate) fn set_theme_picker_index(&mut self, idx: usize) -> bool { 383 + if idx < THEME_PRESETS.len() { 384 + self.theme_picker_index = idx; 385 + true 386 + } else { 387 + false 388 + } 389 + } 390 + 391 + pub(crate) fn selected_theme_preset(&self) -> Option<ThemePreset> { 392 + THEME_PRESETS.get(self.theme_picker_index).copied() 393 + } 394 + 395 + pub(crate) fn preview_theme_preset( 396 + &mut self, 397 + preset: ThemePreset, 398 + ss: &SyntaxSet, 399 + themes: &ThemeSet, 400 + ) { 401 + if current_theme_preset() == preset { 402 + return; 403 + } 404 + set_theme_preset(preset); 405 + let cached = self 406 + .theme_preview_cache 407 + .get(theme_preset_index(preset)) 408 + .and_then(|entry| entry.as_ref()) 409 + .cloned(); 410 + if let Some(entry) = cached { 411 + self.replace_content(entry.lines, entry.toc); 412 + return; 413 + } 414 + 415 + let theme = current_syntect_theme(themes); 416 + let (new_lines, new_toc) = parse_markdown(&self.source, ss, theme); 417 + self.store_theme_preview(preset, &new_lines, &new_toc); 418 + self.replace_content(new_lines, new_toc); 419 + } 420 + 421 + pub(crate) fn restore_theme_picker_preview(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 422 + if let Some(original) = self.theme_picker_original { 423 + self.preview_theme_preset(original, ss, themes); 424 + } 425 + self.close_theme_picker(); 426 + } 427 + 255 428 pub(crate) fn scroll_down(&mut self, n: usize) { 256 429 self.scroll = (self.scroll + n).min(self.total().saturating_sub(1)); 257 430 } ··· 405 578 } 406 579 } 407 580 408 - pub(crate) fn reload(&mut self, ss: &SyntaxSet, theme: &Theme) -> bool { 581 + pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 582 + let theme = current_syntect_theme(themes); 583 + let old_total = self.total(); 584 + let (new_lines, new_toc) = parse_markdown(&self.source, ss, theme); 585 + let new_total = new_lines.len(); 586 + 587 + if old_total > 0 { 588 + self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; 589 + self.scroll = self.scroll.min(new_total.saturating_sub(1)); 590 + } 591 + 592 + self.invalidate_theme_preview_cache(); 593 + self.store_theme_preview(current_theme_preset(), &new_lines, &new_toc); 594 + self.replace_content(new_lines, new_toc); 595 + if !self.search_query.is_empty() && !self.search_mode { 596 + self.run_search(); 597 + } 598 + } 599 + 600 + pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 409 601 let path = match &self.filepath { 410 602 Some(p) => p, 411 603 None => return false, ··· 416 608 }; 417 609 let file_state = read_file_state(path); 418 610 let content_hash = hash_str(&src); 419 - 420 - let old_total = self.total(); 421 - let (new_lines, new_toc) = parse_markdown(&src, ss, theme); 422 - let new_total = new_lines.len(); 423 - 424 - if old_total > 0 { 425 - self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; 426 - self.scroll = self.scroll.min(new_total.saturating_sub(1)); 427 - } 611 + self.source = src; 428 612 429 - self.replace_content(new_lines, new_toc); 613 + self.reparse_source(ss, themes); 430 614 self.last_file_state = file_state; 431 615 self.last_content_hash = content_hash; 432 616 self.last_hash_check = Some(Instant::now()); 433 617 self.reload_flash = Some(Instant::now()); 434 - 435 - if !self.search_query.is_empty() && !self.search_mode { 436 - self.run_search(); 437 - } 438 618 true 439 619 } 440 620 }
+18 -2
src/cli.rs
··· 1 1 use anyhow::Result; 2 2 3 + use crate::theme::{parse_theme_preset, ThemePreset}; 4 + 3 5 #[derive(Debug, Default, PartialEq, Eq)] 4 6 pub(crate) struct CliOptions { 5 7 pub(crate) watch: bool, ··· 7 9 pub(crate) print_help: bool, 8 10 pub(crate) print_version: bool, 9 11 pub(crate) file_arg: Option<String>, 12 + pub(crate) theme: ThemePreset, 10 13 } 11 14 12 15 pub(crate) fn usage_text() -> &'static str { 13 - "Usage: leaf [--watch] <file.md>\n echo '# Hello' | leaf" 16 + "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] <file.md>\n echo '# Hello' | leaf" 14 17 } 15 18 16 19 pub(crate) fn version_text() -> &'static str { ··· 28 31 pub(crate) fn parse_cli(args: &[String]) -> Result<CliOptions> { 29 32 let mut options = CliOptions::default(); 30 33 let mut positional_only = false; 34 + let mut iter = args.iter().skip(1); 31 35 32 - for arg in args.iter().skip(1) { 36 + while let Some(arg) = iter.next() { 33 37 if positional_only { 34 38 if options.file_arg.is_none() { 35 39 options.file_arg = Some(arg.clone()); ··· 44 48 "--debug-input" => options.debug_input = true, 45 49 "--help" | "-h" => options.print_help = true, 46 50 "--version" | "-V" => options.print_version = true, 51 + "--theme" => { 52 + let Some(name) = iter.next() else { 53 + anyhow::bail!("Missing value for --theme"); 54 + }; 55 + options.theme = parse_theme_preset(name) 56 + .ok_or_else(|| anyhow::anyhow!("Unknown theme: {name}"))?; 57 + } 58 + _ if arg.starts_with("--theme=") => { 59 + let name = &arg["--theme=".len()..]; 60 + options.theme = parse_theme_preset(name) 61 + .ok_or_else(|| anyhow::anyhow!("Unknown theme: {name}"))?; 62 + } 47 63 "--" => positional_only = true, 48 64 _ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"), 49 65 _ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()),
+10 -3
src/main.rs
··· 11 11 mod terminal; 12 12 #[cfg(test)] 13 13 mod tests; 14 + mod theme; 14 15 15 16 use app::App; 16 17 use cli::{parse_cli, print_usage, print_version, CliOptions}; 17 18 use markdown::{hash_str, parse_markdown, read_file_state}; 18 19 use runtime::run; 19 20 use terminal::{finish_with_restore, TerminalSession}; 21 + use theme::{current_syntect_theme, set_theme_preset}; 20 22 21 23 #[cfg(test)] 22 24 pub(crate) use app::{ ··· 26 28 pub(crate) use markdown::{display_width, line_plain_text}; 27 29 #[cfg(test)] 28 30 pub(crate) use runtime::should_handle_key; 31 + #[cfg(test)] 32 + pub(crate) use theme::{parse_theme_preset, theme_preset_label, ThemePreset, THEME_PRESETS}; 29 33 30 34 fn main() -> Result<()> { 31 35 let args: Vec<String> = std::env::args().collect(); ··· 43 47 watch, 44 48 debug_input, 45 49 file_arg, 50 + theme, 46 51 .. 47 52 } = options; 53 + set_theme_preset(theme); 48 54 49 55 if debug_input { 50 56 let mut file = OpenOptions::new() ··· 83 89 84 90 let ss = SyntaxSet::load_defaults_newlines(); 85 91 let ts = ThemeSet::load_defaults(); 86 - let theme = ts.themes["base16-ocean.dark"].clone(); 92 + let theme = current_syntect_theme(&ts).clone(); 87 93 88 94 let last_file_state = filepath.as_ref().and_then(read_file_state); 89 95 let last_content_hash = hash_str(&src); 90 96 91 97 let (lines, toc) = parse_markdown(&src, &ss, &theme); 92 - let mut app = App::new( 98 + let mut app = App::new_with_source( 93 99 lines, 94 100 toc, 95 101 filename, 102 + src, 96 103 debug_input, 97 104 watch, 98 105 filepath, ··· 103 110 let mut stdout = io::stdout(); 104 111 let mut session = TerminalSession::enter(&mut stdout)?; 105 112 let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; 106 - let run_result = run(&mut terminal, &mut app, &ss, &theme); 113 + let run_result = run(&mut terminal, &mut app, &ss, &ts); 107 114 let restore_result = session.restore(&mut terminal); 108 115 finish_with_restore(run_result, restore_result) 109 116 }
+45 -31
src/markdown.rs
··· 1 - use crate::app::{normalize_toc, TocEntry}; 1 + use crate::{ 2 + app::{normalize_toc, TocEntry}, 3 + theme::app_theme, 4 + }; 2 5 use pulldown_cmark::{ 3 6 Alignment, CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd, 4 7 }; ··· 124 127 } 125 128 126 129 pub(crate) fn highlight_line<'a>(line: &Line<'a>) -> Line<'a> { 130 + let theme = &app_theme().markdown; 127 131 Line::from( 128 132 line.spans 129 133 .iter() 130 - .map(|span| Span::styled(span.content.clone(), span.style.bg(Color::Rgb(72, 62, 16)))) 134 + .map(|span| { 135 + Span::styled( 136 + span.content.clone(), 137 + span.style.bg(theme.search_highlight_bg), 138 + ) 139 + }) 131 140 .collect::<Vec<_>>(), 132 141 ) 133 142 } ··· 200 209 ss: &SyntaxSet, 201 210 theme: &Theme, 202 211 ) -> (Vec<Line<'static>>, usize) { 212 + let theme_colors = &app_theme().markdown; 203 213 let syntax = resolve_syntax(lang, ss); 204 214 let mut hl = HighlightLines::new(syntax, theme); 205 - let gutter = Style::default().fg(Color::Rgb(40, 48, 68)); 215 + let gutter = Style::default().fg(theme_colors.code_gutter); 206 216 207 217 let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 208 218 for line_str in LinesWithEndings::from(code) { ··· 255 265 } 256 266 257 267 fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 268 + let theme = &app_theme().markdown; 258 269 if in_bq { 259 270 vec![Span::styled( 260 271 " ▏ ", 261 - Style::default().fg(Color::Rgb(75, 80, 148)), 272 + Style::default().fg(theme.blockquote_marker), 262 273 )] 263 274 } else { 264 275 vec![Span::raw(" ")] ··· 270 281 list_stack: &[ListKind], 271 282 item_stack: &mut [ItemState], 272 283 ) -> Vec<Span<'static>> { 284 + let theme = &app_theme().markdown; 273 285 let mut prefix = block_prefix(in_bq); 274 286 let Some(item) = item_stack.last_mut() else { 275 287 return prefix; ··· 296 308 297 309 let marker_style = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 298 310 ListKind::Unordered => match depth { 299 - 1 => Style::default().fg(Color::Rgb(95, 200, 148)), 300 - 2 => Style::default().fg(Color::Rgb(138, 155, 200)), 301 - _ => Style::default().fg(Color::Rgb(168, 168, 185)), 311 + 1 => Style::default().fg(theme.list_level_1), 312 + 2 => Style::default().fg(theme.list_level_2), 313 + _ => Style::default().fg(theme.list_level_3), 302 314 }, 303 - ListKind::Ordered(_) => Style::default().fg(Color::Rgb(95, 200, 148)), 315 + ListKind::Ordered(_) => Style::default().fg(theme.ordered_list), 304 316 }; 305 317 prefix.push(Span::styled(marker, marker_style)); 306 318 prefix ··· 337 349 } 338 350 339 351 fn render(&self) -> Vec<Line<'static>> { 352 + let theme = &app_theme().markdown; 340 353 if self.rows.is_empty() { 341 354 return vec![]; 342 355 } ··· 354 367 } 355 368 } 356 369 357 - let border = Style::default().fg(Color::Rgb(65, 75, 108)); 358 - let sep = Style::default().fg(Color::Rgb(55, 65, 95)); 370 + let border = Style::default().fg(theme.table_border); 371 + let sep = Style::default().fg(theme.table_separator); 359 372 let header = Style::default() 360 - .fg(Color::Rgb(140, 190, 255)) 373 + .fg(theme.table_header) 361 374 .add_modifier(Modifier::BOLD); 362 - let cell = Style::default().fg(Color::Rgb(205, 208, 218)); 375 + let cell = Style::default().fg(theme.table_cell); 363 376 let ind = " "; 364 377 365 378 let mut out: Vec<Line<'static>> = vec![Line::from("")]; ··· 476 489 ss: &SyntaxSet, 477 490 theme: &Theme, 478 491 ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 492 + let theme_colors = &app_theme().markdown; 479 493 let src = strip_frontmatter(src); 480 494 let mut lines: Vec<Line<'static>> = Vec::new(); 481 495 let mut toc: Vec<TocEntry> = Vec::new(); ··· 568 582 MdEvent::End(TagEnd::Heading(_)) => { 569 583 let lvl = in_heading.unwrap_or(1); 570 584 let (color, marker): (Color, &str) = match lvl { 571 - 1 => (Color::Rgb(140, 190, 255), "█ "), 572 - 2 => (Color::Rgb(120, 210, 170), "▌ "), 573 - 3 => (Color::Rgb(210, 180, 120), "▎ "), 574 - _ => (Color::Rgb(180, 180, 190), " "), 585 + 1 => (theme_colors.heading_1, "█ "), 586 + 2 => (theme_colors.heading_2, "▌ "), 587 + 3 => (theme_colors.heading_3, "▎ "), 588 + _ => (theme_colors.heading_other, " "), 575 589 }; 576 590 let style = Style::default().fg(color).add_modifier(Modifier::BOLD); 577 591 let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); ··· 584 598 Span::raw(" "), 585 599 Span::styled( 586 600 marker.to_string(), 587 - Style::default().fg(Color::Rgb(55, 75, 115)), 601 + Style::default().fg(theme_colors.heading_marker), 588 602 ), 589 603 ]; 590 604 all.extend(spans.drain(..).map(|s| Span::styled(s.content, style))); ··· 592 606 if lvl == 1 { 593 607 lines.push(Line::from(Span::styled( 594 608 format!(" {}", "─".repeat((display_width(&title) + 4).min(68))), 595 - Style::default().fg(Color::Rgb(40, 50, 75)), 609 + Style::default().fg(theme_colors.heading_underline), 596 610 ))); 597 611 } 598 612 lines.push(Line::from("")); ··· 630 644 Span::raw(" "), 631 645 Span::styled( 632 646 "╭─ ".to_string(), 633 - Style::default().fg(Color::Rgb(40, 48, 68)), 647 + Style::default().fg(theme_colors.code_frame), 634 648 ), 635 649 Span::styled( 636 650 format!("{} ", ld), 637 - Style::default().fg(Color::Rgb(95, 110, 145)), 651 + Style::default().fg(theme_colors.code_label), 638 652 ), 639 653 Span::styled( 640 654 format!("{}╮", top_bar), 641 - Style::default().fg(Color::Rgb(40, 48, 68)), 655 + Style::default().fg(theme_colors.code_frame), 642 656 ), 643 657 ])); 644 658 lines.extend(code_lines); 645 659 lines.push(Line::from(Span::styled( 646 660 format!(" ╰{}╯", "─".repeat(inner_width)), 647 - Style::default().fg(Color::Rgb(40, 48, 68)), 661 + Style::default().fg(theme_colors.code_frame), 648 662 ))); 649 663 lines.push(Line::from("")); 650 664 code_lang.clear(); ··· 654 668 spans.push(Span::styled( 655 669 format!(" {} ", text.as_ref()), 656 670 Style::default() 657 - .fg(Color::Rgb(235, 155, 115)) 658 - .bg(Color::Rgb(40, 30, 28)), 671 + .fg(theme_colors.inline_code_fg) 672 + .bg(theme_colors.inline_code_bg), 659 673 )); 660 674 } 661 675 MdEvent::Start(Tag::BlockQuote(_)) => { ··· 664 678 MdEvent::End(TagEnd::BlockQuote(_)) => { 665 679 flush!(vec![Span::styled( 666 680 " ▏ ", 667 - Style::default().fg(Color::Rgb(75, 80, 148)) 681 + Style::default().fg(theme_colors.blockquote_marker) 668 682 )]); 669 683 blockquote_depth = blockquote_depth.saturating_sub(1); 670 684 lines.push(Line::from("")); ··· 703 717 lines.push(Line::from("")); 704 718 lines.push(Line::from(Span::styled( 705 719 format!(" {}", "─".repeat(62)), 706 - Style::default().fg(Color::Rgb(48, 56, 76)), 720 + Style::default().fg(theme_colors.rule), 707 721 ))); 708 722 lines.push(Line::from("")); 709 723 } ··· 717 731 in_link = true; 718 732 spans.push(Span::styled( 719 733 "⌗", 720 - Style::default().fg(Color::Rgb(85, 148, 235)), 734 + Style::default().fg(theme_colors.link_icon), 721 735 )); 722 736 } 723 737 MdEvent::End(TagEnd::Link) => in_link = false, ··· 728 742 let content = text.to_string(); 729 743 let mut style = if blockquote_depth > 0 { 730 744 Style::default() 731 - .fg(Color::Rgb(148, 148, 195)) 745 + .fg(theme_colors.blockquote_text) 732 746 .add_modifier(Modifier::ITALIC) 733 747 } else if in_link { 734 - Style::default().fg(Color::Rgb(88, 152, 238)) 748 + Style::default().fg(theme_colors.link_text) 735 749 } else { 736 - Style::default().fg(Color::Rgb(208, 210, 218)) 750 + Style::default().fg(theme_colors.text) 737 751 }; 738 752 if in_strong { 739 753 style = style 740 - .fg(Color::Rgb(245, 245, 255)) 754 + .fg(theme_colors.strong_text) 741 755 .add_modifier(Modifier::BOLD); 742 756 } 743 757 if in_em {
+213 -82
src/render.rs
··· 1 - use crate::app::App; 1 + use crate::{ 2 + app::App, 3 + theme::{app_theme, current_theme_preset, theme_preset_label, THEME_PRESETS}, 4 + }; 2 5 use ratatui::{ 3 6 layout::{Constraint, Direction, Layout, Rect}, 4 7 style::{Color, Modifier, Style}, 5 8 text::{Line, Span}, 6 - widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, 9 + widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, 7 10 Frame, 8 11 }; 9 12 ··· 25 28 }; 26 29 27 30 if let Some(ta) = toc_area { 28 - app.refresh_toc_cache(); 29 - let toc_chunks = Layout::default() 30 - .direction(Direction::Vertical) 31 - .constraints([Constraint::Length(3), Constraint::Min(0)]) 32 - .split(ta); 31 + render_toc_panel(f, app, ta); 32 + } 33 33 34 - f.render_widget( 35 - Paragraph::new("") 36 - .style(Style::default().bg(Color::Rgb(18, 18, 22))) 37 - .block( 38 - Block::default() 39 - .borders(Borders::RIGHT | Borders::BOTTOM) 40 - .border_style(Style::default().fg(Color::Rgb(52, 52, 58))) 41 - .style(Style::default().bg(Color::Rgb(18, 18, 22))), 42 - ), 43 - toc_chunks[0], 44 - ); 45 - f.render_widget( 46 - Paragraph::new(app.toc_display_lines.clone()) 47 - .style(Style::default().bg(Color::Rgb(18, 18, 22))) 48 - .block( 49 - Block::default() 50 - .borders(Borders::RIGHT) 51 - .border_style(Style::default().fg(Color::Rgb(52, 52, 58))) 52 - .style(Style::default().bg(Color::Rgb(18, 18, 22))), 53 - ), 54 - toc_chunks[1], 55 - ); 56 - f.render_widget( 57 - Paragraph::new(vec![app.toc_header_line.clone()]) 58 - .style(Style::default().bg(Color::Rgb(18, 18, 22))), 59 - Rect { 60 - x: toc_chunks[0].x, 61 - y: toc_chunks[0].y.saturating_add(1), 62 - width: toc_chunks[0].width.saturating_sub(1), 63 - height: 1, 64 - }, 65 - ); 34 + let viewport_height = content_area.height as usize; 35 + render_content_panel(f, app, content_area, viewport_height); 36 + render_status_bar(f, app, root[1], viewport_height); 37 + 38 + if app.is_theme_picker_open() { 39 + render_theme_picker(f, app); 66 40 } 41 + } 67 42 68 - let vh = content_area.height as usize; 43 + fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { 44 + let theme = app_theme(); 45 + app.refresh_toc_cache(); 46 + let toc_chunks = Layout::default() 47 + .direction(Direction::Vertical) 48 + .constraints([Constraint::Length(3), Constraint::Min(0)]) 49 + .split(area); 50 + 51 + f.render_widget( 52 + Paragraph::new("") 53 + .style(Style::default().bg(theme.ui.toc_bg)) 54 + .block( 55 + Block::default() 56 + .borders(Borders::RIGHT | Borders::BOTTOM) 57 + .border_style(Style::default().fg(theme.ui.toc_border)) 58 + .style(Style::default().bg(theme.ui.toc_bg)), 59 + ), 60 + toc_chunks[0], 61 + ); 62 + f.render_widget( 63 + Paragraph::new(app.toc_display_lines.clone()) 64 + .style(Style::default().bg(theme.ui.toc_bg)) 65 + .block( 66 + Block::default() 67 + .borders(Borders::RIGHT) 68 + .border_style(Style::default().fg(theme.ui.toc_border)) 69 + .style(Style::default().bg(theme.ui.toc_bg)), 70 + ), 71 + toc_chunks[1], 72 + ); 73 + f.render_widget( 74 + Paragraph::new(vec![app.toc_header_line.clone()]) 75 + .style(Style::default().bg(theme.ui.toc_bg)), 76 + Rect { 77 + x: toc_chunks[0].x, 78 + y: toc_chunks[0].y.saturating_add(1), 79 + width: toc_chunks[0].width.saturating_sub(1), 80 + height: 1, 81 + }, 82 + ); 83 + } 84 + 85 + fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 86 + let theme = app_theme(); 69 87 let scroll = app.scroll; 70 88 let active_highlight_line = app.active_highlight_line(); 71 89 if let Some(line_idx) = active_highlight_line { 72 90 let _ = app.refresh_highlighted_line_cache(line_idx); 73 91 } 74 - let render_lines = &app.lines; 75 - let visible_end = (scroll + vh).min(render_lines.len()); 76 - let mut visible_lines = render_lines[scroll..visible_end].to_vec(); 92 + 93 + let visible_end = (scroll + viewport_height).min(app.lines.len()); 94 + let mut visible_lines = app.lines[scroll..visible_end].to_vec(); 77 95 78 96 if let Some(line_idx) = active_highlight_line { 79 97 if (scroll..visible_end).contains(&line_idx) { ··· 84 102 } 85 103 86 104 f.render_widget( 87 - Paragraph::new(visible_lines).style(Style::default().bg(Color::Rgb(18, 20, 28))), 88 - content_area, 105 + Paragraph::new(visible_lines).style(Style::default().bg(theme.ui.content_bg)), 106 + area, 89 107 ); 90 108 91 - let mut ss_state = ScrollbarState::new(app.total()).position(app.scroll); 109 + let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll); 92 110 f.render_stateful_widget( 93 111 Scrollbar::new(ScrollbarOrientation::VerticalRight) 94 112 .begin_symbol(None) 95 113 .end_symbol(None) 96 114 .track_symbol(Some("│")) 97 115 .thumb_symbol("█"), 98 - content_area, 99 - &mut ss_state, 116 + area, 117 + &mut scrollbar_state, 100 118 ); 119 + } 101 120 102 - let pct = app.scroll_percent(vh); 121 + fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 122 + let pct = app.scroll_percent(viewport_height); 103 123 let bar_bg = status_bar_bg(); 104 124 app.refresh_status_cache(pct); 105 125 106 126 f.render_widget( 107 127 Paragraph::new(vec![app.status_line.clone()]).style(Style::default().bg(bar_bg)), 108 - root[1], 128 + area, 109 129 ); 110 130 } 111 131 112 132 pub(crate) fn status_bar_bg() -> Color { 113 - Color::Rgb(18, 20, 32) 133 + app_theme().ui.status_bg 114 134 } 115 135 116 136 pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 117 - Style::default().fg(Color::Rgb(116, 126, 156)).bg(bar_bg) 137 + Style::default() 138 + .fg(app_theme().ui.status_separator) 139 + .bg(bar_bg) 118 140 } 119 141 120 142 pub(crate) fn join_span_sections( ··· 132 154 } 133 155 134 156 pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 157 + let theme = app_theme(); 135 158 vec![Span::styled( 136 159 " leaf ", 137 160 Style::default() 138 - .fg(Color::Rgb(16, 18, 26)) 139 - .bg(Color::Rgb(105, 178, 218)) 161 + .fg(theme.ui.status_brand_fg) 162 + .bg(theme.ui.status_brand_bg) 140 163 .add_modifier(Modifier::BOLD), 141 164 )] 142 165 } 143 166 144 167 pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 168 + let theme = app_theme(); 145 169 vec![Span::styled( 146 170 format!(" {} ", filename), 147 171 Style::default() 148 - .fg(Color::Rgb(162, 192, 222)) 149 - .bg(Color::Rgb(24, 28, 44)), 172 + .fg(theme.ui.status_filename_fg) 173 + .bg(theme.ui.status_filename_bg), 150 174 )] 151 175 } 152 176 153 177 pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 178 + let theme = app_theme(); 154 179 if !app.watch { 155 180 return None; 156 181 } ··· 163 188 Span::styled( 164 189 " ⟳ reloaded ", 165 190 Style::default() 166 - .fg(Color::Rgb(16, 18, 26)) 167 - .bg(Color::Rgb(95, 200, 148)) 191 + .fg(theme.ui.status_reloaded_fg) 192 + .bg(theme.ui.status_reloaded_bg) 168 193 .add_modifier(Modifier::BOLD), 169 194 ) 170 195 } else { 171 196 Span::styled( 172 197 " ⟳ watch ", 173 198 Style::default() 174 - .fg(Color::Rgb(95, 200, 148)) 175 - .bg(Color::Rgb(18, 30, 24)), 199 + .fg(theme.ui.status_watch_fg) 200 + .bg(theme.ui.status_watch_bg), 176 201 ) 177 202 }; 178 203 Some(vec![span]) 179 204 } 180 205 181 206 pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 207 + let theme = app_theme(); 182 208 if app.search_mode { 183 209 return Some(vec![Span::styled( 184 210 format!(" /{}", app.search_draft), 185 211 Style::default() 186 - .fg(Color::Rgb(240, 210, 95)) 187 - .bg(Color::Rgb(26, 28, 42)), 212 + .fg(theme.ui.status_search_fg) 213 + .bg(theme.ui.status_search_bg), 188 214 )]); 189 215 } 190 216 ··· 196 222 Span::styled( 197 223 format!(" ✗ {} ", app.search_query), 198 224 Style::default() 199 - .fg(Color::Rgb(218, 95, 95)) 200 - .bg(Color::Rgb(26, 28, 42)), 225 + .fg(theme.ui.status_search_error_fg) 226 + .bg(theme.ui.status_search_bg), 201 227 ) 202 228 } else { 203 229 Span::styled( 204 230 format!(" {}/{} ", app.search_idx + 1, app.search_matches.len()), 205 231 Style::default() 206 - .fg(Color::Rgb(115, 208, 148)) 207 - .bg(Color::Rgb(26, 28, 42)), 232 + .fg(theme.ui.status_search_match_fg) 233 + .bg(theme.ui.status_search_bg), 208 234 ) 209 235 }; 210 236 Some(vec![span]) ··· 213 239 pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 214 240 if app.search_mode { 215 241 &["enter confirm", "esc cancel"] 242 + } else if app.theme_picker_open { 243 + &["j/k preview", "enter keep", "esc restore"] 216 244 } else if app.has_active_search() { 217 245 &[ 218 246 "enter next", 219 247 "n/N next/prev", 220 248 "/ search", 249 + "T theme", 221 250 "esc clear", 222 251 "q quit", 223 252 ] ··· 226 255 "j/k scroll", 227 256 "g/G top/bot", 228 257 "t toc", 258 + "T theme", 229 259 "/ search", 230 260 "n/N next/prev", 231 261 "q quit", ··· 233 263 } 234 264 } 235 265 266 + fn render_theme_picker(f: &mut Frame, app: &App) { 267 + let theme = app_theme(); 268 + let area = centered_rect(34, 9, f.area()); 269 + let active = current_theme_preset(); 270 + let current = app.theme_picker_reference_preset(); 271 + 272 + let mut lines = Vec::new(); 273 + for (idx, preset) in THEME_PRESETS.iter().enumerate() { 274 + let selected = idx == app.theme_picker_index(); 275 + let is_active = *preset == active; 276 + let bg = if selected { 277 + theme.ui.toc_active_bg 278 + } else { 279 + theme.ui.toc_bg 280 + }; 281 + let marker = if selected { "▸ " } else { " " }; 282 + let name = if is_active { 283 + format!("{} ✓", theme_preset_label(*preset)) 284 + } else { 285 + theme_preset_label(*preset).to_string() 286 + }; 287 + lines.push(Line::from(vec![ 288 + Span::styled( 289 + marker, 290 + Style::default() 291 + .fg(theme.ui.toc_accent) 292 + .bg(bg) 293 + .add_modifier(if selected { 294 + Modifier::BOLD 295 + } else { 296 + Modifier::empty() 297 + }), 298 + ), 299 + Span::styled( 300 + name, 301 + Style::default() 302 + .fg(if selected { 303 + theme.ui.toc_primary_active 304 + } else { 305 + theme.ui.toc_primary_inactive 306 + }) 307 + .bg(bg) 308 + .add_modifier(if is_active || selected { 309 + Modifier::BOLD 310 + } else { 311 + Modifier::empty() 312 + }), 313 + ), 314 + ])); 315 + } 316 + lines.push(Line::from("")); 317 + lines.push(Line::from(vec![ 318 + Span::styled( 319 + " Current: ", 320 + Style::default() 321 + .fg(theme.ui.status_shortcut_fg) 322 + .bg(theme.ui.toc_bg), 323 + ), 324 + Span::styled( 325 + theme_preset_label(current).to_string(), 326 + Style::default() 327 + .fg(theme.ui.toc_accent) 328 + .bg(theme.ui.toc_bg) 329 + .add_modifier(Modifier::BOLD), 330 + ), 331 + ])); 332 + lines.push(Line::from(vec![Span::styled( 333 + " Enter keep • Esc restore ", 334 + Style::default() 335 + .fg(theme.ui.status_shortcut_fg) 336 + .bg(theme.ui.toc_bg), 337 + )])); 338 + 339 + f.render_widget(Clear, area); 340 + f.render_widget( 341 + Paragraph::new(lines).block( 342 + Block::default() 343 + .title(" Theme ") 344 + .borders(Borders::ALL) 345 + .border_style(Style::default().fg(theme.ui.toc_border)) 346 + .style(Style::default().bg(theme.ui.toc_bg)), 347 + ), 348 + area, 349 + ); 350 + } 351 + 352 + fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { 353 + let popup_width = width.min(area.width.saturating_sub(2)).max(1); 354 + let popup_height = height.min(area.height.saturating_sub(2)).max(1); 355 + Rect { 356 + x: area.x + area.width.saturating_sub(popup_width) / 2, 357 + y: area.y + area.height.saturating_sub(popup_height) / 2, 358 + width: popup_width, 359 + height: popup_height, 360 + } 361 + } 362 + 236 363 pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 364 + let theme = app_theme(); 237 365 let separator = Span::styled(" · ", status_separator_style(bar_bg)); 238 366 let sections = status_hint_segments(app) 239 367 .iter() 240 368 .map(|segment| { 241 369 vec![Span::styled( 242 370 (*segment).to_string(), 243 - Style::default().fg(Color::Rgb(58, 68, 98)).bg(bar_bg), 371 + Style::default().fg(theme.ui.status_shortcut_fg).bg(bar_bg), 244 372 )] 245 373 }) 246 374 .collect(); ··· 248 376 } 249 377 250 378 pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 379 + let theme = app_theme(); 251 380 vec![Span::styled( 252 381 format!("{:>3}% ", pct), 253 - Style::default().fg(Color::Rgb(105, 178, 218)).bg(bar_bg), 382 + Style::default().fg(theme.ui.status_percent_fg).bg(bar_bg), 254 383 )] 255 384 } 256 385 ··· 279 408 } 280 409 281 410 pub(crate) fn toc_header_line() -> Line<'static> { 411 + let theme = app_theme(); 282 412 Line::from(vec![Span::styled( 283 413 " TABLE OF CONTENTS", 284 414 Style::default() 285 - .fg(Color::Rgb(88, 88, 96)) 286 - .bg(Color::Rgb(18, 18, 22)) 415 + .fg(theme.ui.toc_header_fg) 416 + .bg(theme.ui.toc_bg) 287 417 .add_modifier(Modifier::BOLD), 288 418 )]) 289 419 } ··· 294 424 top_level_index: Option<usize>, 295 425 active: bool, 296 426 ) -> Line<'static> { 297 - let active_bg = Color::Rgb(42, 40, 46); 298 - let inactive_bg = Color::Rgb(18, 18, 22); 427 + let theme = app_theme(); 428 + let active_bg = theme.ui.toc_active_bg; 429 + let inactive_bg = theme.ui.toc_inactive_bg; 299 430 300 431 match display_level { 301 432 1 => { ··· 305 436 Line::from(vec![ 306 437 Span::styled( 307 438 if active { "▎" } else { " " }, 308 - Style::default().fg(Color::Rgb(123, 109, 255)).bg(bg), 439 + Style::default().fg(theme.ui.toc_accent).bg(bg), 309 440 ), 310 441 Span::styled(" ", Style::default().bg(bg)), 311 442 Span::styled( 312 443 format!("{index:02}"), 313 444 Style::default() 314 445 .fg(if active { 315 - Color::Rgb(123, 109, 255) 446 + theme.ui.toc_accent 316 447 } else { 317 - Color::Rgb(60, 60, 66) 448 + theme.ui.toc_index_inactive 318 449 }) 319 450 .bg(bg) 320 451 .add_modifier(Modifier::BOLD), ··· 324 455 title, 325 456 Style::default() 326 457 .fg(if active { 327 - Color::Rgb(224, 224, 228) 458 + theme.ui.toc_primary_active 328 459 } else { 329 - Color::Rgb(136, 136, 142) 460 + theme.ui.toc_primary_inactive 330 461 }) 331 462 .bg(bg) 332 463 .add_modifier(Modifier::BOLD), ··· 336 467 _ => Line::from(vec![ 337 468 Span::styled( 338 469 if active { "▎" } else { " " }, 339 - Style::default().fg(Color::Rgb(123, 109, 255)), 470 + Style::default().fg(theme.ui.toc_accent), 340 471 ), 341 472 Span::raw(" "), 342 473 Span::styled( 343 474 "•", 344 475 Style::default().fg(if active { 345 - Color::Rgb(123, 109, 255) 476 + theme.ui.toc_accent 346 477 } else { 347 - Color::Rgb(62, 62, 68) 478 + theme.ui.toc_secondary_inactive 348 479 }), 349 480 ), 350 481 Span::raw(" "), ··· 352 483 crate::markdown::truncate_display_width(&entry.title, 18), 353 484 Style::default() 354 485 .fg(if active { 355 - Color::Rgb(224, 224, 228) 486 + theme.ui.toc_secondary_text_active 356 487 } else { 357 - Color::Rgb(102, 102, 108) 488 + theme.ui.toc_secondary_text_inactive 358 489 }) 359 490 .add_modifier(if active { 360 491 Modifier::BOLD
+50 -14
src/runtime.rs
··· 6 6 use crossterm::event::{self, poll, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; 7 7 use ratatui::{backend::CrosstermBackend, Terminal}; 8 8 use std::{fs::OpenOptions, io, io::Write, time::Duration}; 9 - use syntect::{highlighting::Theme, parsing::SyntaxSet}; 9 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 10 11 11 pub(crate) fn should_handle_key(kind: KeyEventKind) -> bool { 12 12 !matches!(kind, KeyEventKind::Release) ··· 29 29 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 30 30 app: &mut App, 31 31 ss: &SyntaxSet, 32 - theme: &Theme, 32 + themes: &ThemeSet, 33 33 ) -> Result<()> { 34 34 const WATCH_INTERVAL: Duration = Duration::from_millis(250); 35 35 const FLASH_DURATION: Duration = Duration::from_millis(1500); ··· 77 77 continue; 78 78 } 79 79 let mut state_changed = true; 80 - if app.search_mode { 80 + if app.is_theme_picker_open() { 81 + match key.code { 82 + KeyCode::Esc => { 83 + app.restore_theme_picker_preview(ss, themes); 84 + needs_redraw = true; 85 + state_changed = false; 86 + } 87 + KeyCode::Enter => app.close_theme_picker(), 88 + KeyCode::Char('j') | KeyCode::Down => { 89 + app.move_theme_picker_down(); 90 + } 91 + KeyCode::Char('k') | KeyCode::Up => { 92 + app.move_theme_picker_up(); 93 + } 94 + KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { 95 + if let Some(n) = c.to_digit(10) { 96 + let idx = n as usize - 1; 97 + if !app.set_theme_picker_index(idx) { 98 + state_changed = false; 99 + } 100 + } 101 + } 102 + _ => state_changed = false, 103 + } 104 + if state_changed { 105 + if let Some(preset) = app.selected_theme_preset() { 106 + app.preview_theme_preset(preset, ss, themes); 107 + } 108 + } 109 + } else if app.search_mode { 81 110 match key.code { 82 111 KeyCode::Esc => app.cancel_search(), 83 112 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { ··· 117 146 KeyCode::Char('t') => { 118 147 app.toc_visible = !app.toc_visible; 119 148 } 149 + KeyCode::Char('T') => { 150 + app.open_theme_picker(); 151 + } 120 152 KeyCode::Char('r') if app.watch => { 121 153 app.last_file_state = None; 122 - app.reload(ss, theme); 154 + app.reload(ss, themes); 123 155 } 124 156 KeyCode::Char('/') => app.begin_search(), 125 157 KeyCode::Char('n') => app.next_match(), ··· 137 169 } 138 170 } 139 171 Event::Mouse(mouse) => { 140 - let state_changed = match mouse.kind { 141 - MouseEventKind::ScrollUp => { 142 - app.scroll_up(MOUSE_SCROLL_STEP); 143 - true 144 - } 145 - MouseEventKind::ScrollDown => { 146 - app.scroll_down(MOUSE_SCROLL_STEP); 147 - true 172 + let state_changed = if app.is_theme_picker_open() { 173 + false 174 + } else { 175 + match mouse.kind { 176 + MouseEventKind::ScrollUp => { 177 + app.scroll_up(MOUSE_SCROLL_STEP); 178 + true 179 + } 180 + MouseEventKind::ScrollDown => { 181 + app.scroll_down(MOUSE_SCROLL_STEP); 182 + true 183 + } 184 + _ => false, 148 185 } 149 - _ => false, 150 186 }; 151 187 if state_changed { 152 188 needs_redraw = true; ··· 160 196 if app.watch { 161 197 if let Some(change) = app.check_modified() { 162 198 std::thread::sleep(Duration::from_millis(50)); 163 - if app.reload(ss, theme) { 199 + if app.reload(ss, themes) { 164 200 app.last_file_state = Some(match change { 165 201 FileChange::Metadata(state) | FileChange::Content(state) => state, 166 202 });
+96
src/tests.rs
··· 1 + use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 1 2 use crate::*; 2 3 use crossterm::event::KeyEventKind; 3 4 use ratatui::backend::TestBackend; 4 5 use ratatui::{text::Line, widgets::Paragraph, Terminal}; 6 + use std::sync::{Mutex, MutexGuard}; 5 7 use syntect::{ 6 8 highlighting::{Theme, ThemeSet}, 7 9 parsing::SyntaxSet, 8 10 }; 11 + 12 + static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); 9 13 10 14 fn test_assets() -> (SyntaxSet, Theme) { 11 15 let ss = SyntaxSet::load_defaults_newlines(); ··· 58 62 .map(line_plain_text) 59 63 .filter(|line| !line.is_empty()) 60 64 .collect() 65 + } 66 + 67 + fn lock_theme_test_state() -> MutexGuard<'static, ()> { 68 + THEME_TEST_MUTEX.lock().unwrap() 61 69 } 62 70 63 71 #[test] ··· 449 457 assert_eq!(normalized[0].level, 2); 450 458 assert_eq!(normalized[1].level, 3); 451 459 } 460 + 461 + #[test] 462 + fn parse_theme_preset_supports_ocean_and_forest() { 463 + assert_eq!(parse_theme_preset("arctic"), Some(ThemePreset::Arctic)); 464 + assert_eq!(parse_theme_preset("ocean"), Some(ThemePreset::OceanDark)); 465 + assert_eq!(parse_theme_preset("forest"), Some(ThemePreset::Forest)); 466 + assert_eq!( 467 + parse_theme_preset("solarized-dark"), 468 + Some(ThemePreset::SolarizedDark) 469 + ); 470 + assert_eq!(parse_theme_preset("nope"), None); 471 + } 472 + 473 + #[test] 474 + fn theme_presets_are_in_alphabetical_order() { 475 + let labels: Vec<_> = THEME_PRESETS 476 + .iter() 477 + .map(|preset| theme_preset_label(*preset)) 478 + .collect(); 479 + let mut sorted = labels.clone(); 480 + sorted.sort(); 481 + assert_eq!(labels, sorted); 482 + } 483 + 484 + #[test] 485 + fn theme_picker_restores_original_preset_on_escape() { 486 + let _guard = lock_theme_test_state(); 487 + let (ss, theme) = test_assets(); 488 + let ts = ThemeSet::load_defaults(); 489 + let (lines, toc) = parse_markdown("# Demo\n", &ss, &theme); 490 + let mut app = App::new_with_source( 491 + lines, 492 + toc, 493 + "stdin".to_string(), 494 + "# Demo\n".to_string(), 495 + false, 496 + false, 497 + None, 498 + None, 499 + ); 500 + 501 + let original = current_theme_preset(); 502 + set_theme_preset(ThemePreset::OceanDark); 503 + app.open_theme_picker(); 504 + app.theme_picker_index = theme_preset_index(ThemePreset::Forest); 505 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 506 + 507 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 508 + 509 + app.restore_theme_picker_preview(&ss, &ts); 510 + 511 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 512 + assert!(!app.theme_picker_open); 513 + assert_eq!(app.theme_picker_original, None); 514 + set_theme_preset(original); 515 + } 516 + 517 + #[test] 518 + fn theme_picker_caches_previewed_themes_for_reuse() { 519 + let _guard = lock_theme_test_state(); 520 + let (ss, theme) = test_assets(); 521 + let ts = ThemeSet::load_defaults(); 522 + let (lines, toc) = parse_markdown("# Demo\n\n```rs\nfn main() {}\n```\n", &ss, &theme); 523 + let mut app = App::new_with_source( 524 + lines, 525 + toc, 526 + "stdin".to_string(), 527 + "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 528 + false, 529 + false, 530 + None, 531 + None, 532 + ); 533 + 534 + let original = current_theme_preset(); 535 + set_theme_preset(ThemePreset::OceanDark); 536 + app.open_theme_picker(); 537 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 538 + 539 + let cached = app.theme_preview_cache[theme_preset_index(ThemePreset::Forest)].as_ref(); 540 + assert!(cached.is_some()); 541 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 542 + 543 + app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 544 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 545 + assert!(app.theme_preview_cache[theme_preset_index(ThemePreset::OceanDark)].is_some()); 546 + set_theme_preset(original); 547 + }
+408
src/theme.rs
··· 1 + use ratatui::style::Color; 2 + use std::sync::atomic::{AtomicU8, Ordering}; 3 + use syntect::{highlighting::Theme, highlighting::ThemeSet}; 4 + 5 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 6 + #[repr(u8)] 7 + pub(crate) enum ThemePreset { 8 + Arctic = 0, 9 + Forest = 1, 10 + OceanDark = 2, 11 + SolarizedDark = 3, 12 + } 13 + 14 + impl Default for ThemePreset { 15 + fn default() -> Self { 16 + DEFAULT_PRESET 17 + } 18 + } 19 + 20 + pub(crate) struct AppTheme { 21 + pub(crate) syntax_theme_name: &'static str, 22 + pub(crate) ui: UiTheme, 23 + pub(crate) markdown: MarkdownTheme, 24 + } 25 + 26 + pub(crate) struct UiTheme { 27 + pub(crate) toc_bg: Color, 28 + pub(crate) toc_border: Color, 29 + pub(crate) content_bg: Color, 30 + pub(crate) status_bg: Color, 31 + pub(crate) status_separator: Color, 32 + pub(crate) status_brand_fg: Color, 33 + pub(crate) status_brand_bg: Color, 34 + pub(crate) status_filename_fg: Color, 35 + pub(crate) status_filename_bg: Color, 36 + pub(crate) status_watch_fg: Color, 37 + pub(crate) status_watch_bg: Color, 38 + pub(crate) status_reloaded_fg: Color, 39 + pub(crate) status_reloaded_bg: Color, 40 + pub(crate) status_search_fg: Color, 41 + pub(crate) status_search_bg: Color, 42 + pub(crate) status_search_error_fg: Color, 43 + pub(crate) status_search_match_fg: Color, 44 + pub(crate) status_shortcut_fg: Color, 45 + pub(crate) status_percent_fg: Color, 46 + pub(crate) toc_header_fg: Color, 47 + pub(crate) toc_active_bg: Color, 48 + pub(crate) toc_inactive_bg: Color, 49 + pub(crate) toc_accent: Color, 50 + pub(crate) toc_index_inactive: Color, 51 + pub(crate) toc_primary_active: Color, 52 + pub(crate) toc_primary_inactive: Color, 53 + pub(crate) toc_secondary_inactive: Color, 54 + pub(crate) toc_secondary_text_active: Color, 55 + pub(crate) toc_secondary_text_inactive: Color, 56 + } 57 + 58 + pub(crate) struct MarkdownTheme { 59 + pub(crate) search_highlight_bg: Color, 60 + pub(crate) code_gutter: Color, 61 + pub(crate) blockquote_marker: Color, 62 + pub(crate) list_level_1: Color, 63 + pub(crate) list_level_2: Color, 64 + pub(crate) list_level_3: Color, 65 + pub(crate) ordered_list: Color, 66 + pub(crate) table_border: Color, 67 + pub(crate) table_separator: Color, 68 + pub(crate) table_header: Color, 69 + pub(crate) table_cell: Color, 70 + pub(crate) heading_1: Color, 71 + pub(crate) heading_2: Color, 72 + pub(crate) heading_3: Color, 73 + pub(crate) heading_other: Color, 74 + pub(crate) heading_marker: Color, 75 + pub(crate) heading_underline: Color, 76 + pub(crate) code_frame: Color, 77 + pub(crate) code_label: Color, 78 + pub(crate) inline_code_fg: Color, 79 + pub(crate) inline_code_bg: Color, 80 + pub(crate) rule: Color, 81 + pub(crate) link_icon: Color, 82 + pub(crate) link_text: Color, 83 + pub(crate) blockquote_text: Color, 84 + pub(crate) text: Color, 85 + pub(crate) strong_text: Color, 86 + } 87 + 88 + pub(crate) const ARCTIC_THEME: AppTheme = AppTheme { 89 + syntax_theme_name: "base16-ocean.light", 90 + ui: UiTheme { 91 + toc_bg: Color::Rgb(232, 239, 245), 92 + toc_border: Color::Rgb(170, 182, 194), 93 + content_bg: Color::Rgb(242, 247, 250), 94 + status_bg: Color::Rgb(224, 233, 240), 95 + status_separator: Color::Rgb(108, 126, 144), 96 + status_brand_fg: Color::Rgb(245, 248, 250), 97 + status_brand_bg: Color::Rgb(76, 122, 168), 98 + status_filename_fg: Color::Rgb(58, 84, 110), 99 + status_filename_bg: Color::Rgb(209, 221, 232), 100 + status_watch_fg: Color::Rgb(48, 140, 98), 101 + status_watch_bg: Color::Rgb(212, 234, 222), 102 + status_reloaded_fg: Color::Rgb(245, 248, 250), 103 + status_reloaded_bg: Color::Rgb(58, 168, 116), 104 + status_search_fg: Color::Rgb(142, 114, 24), 105 + status_search_bg: Color::Rgb(235, 238, 226), 106 + status_search_error_fg: Color::Rgb(188, 74, 74), 107 + status_search_match_fg: Color::Rgb(48, 140, 98), 108 + status_shortcut_fg: Color::Rgb(98, 116, 134), 109 + status_percent_fg: Color::Rgb(76, 122, 168), 110 + toc_header_fg: Color::Rgb(92, 108, 126), 111 + toc_active_bg: Color::Rgb(214, 224, 233), 112 + toc_inactive_bg: Color::Rgb(232, 239, 245), 113 + toc_accent: Color::Rgb(76, 122, 168), 114 + toc_index_inactive: Color::Rgb(126, 138, 152), 115 + toc_primary_active: Color::Rgb(34, 42, 52), 116 + toc_primary_inactive: Color::Rgb(82, 96, 110), 117 + toc_secondary_inactive: Color::Rgb(126, 138, 152), 118 + toc_secondary_text_active: Color::Rgb(48, 58, 68), 119 + toc_secondary_text_inactive: Color::Rgb(98, 112, 126), 120 + }, 121 + markdown: MarkdownTheme { 122 + search_highlight_bg: Color::Rgb(232, 223, 164), 123 + code_gutter: Color::Rgb(132, 148, 164), 124 + blockquote_marker: Color::Rgb(124, 134, 184), 125 + list_level_1: Color::Rgb(48, 140, 98), 126 + list_level_2: Color::Rgb(90, 118, 188), 127 + list_level_3: Color::Rgb(128, 132, 148), 128 + ordered_list: Color::Rgb(48, 140, 98), 129 + table_border: Color::Rgb(138, 154, 172), 130 + table_separator: Color::Rgb(158, 170, 184), 131 + table_header: Color::Rgb(58, 108, 168), 132 + table_cell: Color::Rgb(58, 68, 78), 133 + heading_1: Color::Rgb(58, 108, 168), 134 + heading_2: Color::Rgb(48, 140, 98), 135 + heading_3: Color::Rgb(176, 128, 48), 136 + heading_other: Color::Rgb(108, 116, 126), 137 + heading_marker: Color::Rgb(116, 132, 152), 138 + heading_underline: Color::Rgb(160, 176, 194), 139 + code_frame: Color::Rgb(132, 148, 164), 140 + code_label: Color::Rgb(92, 116, 140), 141 + inline_code_fg: Color::Rgb(184, 112, 72), 142 + inline_code_bg: Color::Rgb(242, 232, 226), 143 + rule: Color::Rgb(180, 192, 204), 144 + link_icon: Color::Rgb(62, 124, 188), 145 + link_text: Color::Rgb(62, 124, 188), 146 + blockquote_text: Color::Rgb(114, 116, 158), 147 + text: Color::Rgb(58, 68, 78), 148 + strong_text: Color::Rgb(26, 32, 40), 149 + }, 150 + }; 151 + 152 + pub(crate) const FOREST_THEME: AppTheme = AppTheme { 153 + syntax_theme_name: "InspiredGitHub", 154 + ui: UiTheme { 155 + toc_bg: Color::Rgb(16, 22, 18), 156 + toc_border: Color::Rgb(50, 66, 54), 157 + content_bg: Color::Rgb(19, 26, 22), 158 + status_bg: Color::Rgb(18, 27, 24), 159 + status_separator: Color::Rgb(112, 141, 126), 160 + status_brand_fg: Color::Rgb(14, 21, 18), 161 + status_brand_bg: Color::Rgb(120, 198, 148), 162 + status_filename_fg: Color::Rgb(184, 214, 196), 163 + status_filename_bg: Color::Rgb(26, 40, 32), 164 + status_watch_fg: Color::Rgb(132, 214, 154), 165 + status_watch_bg: Color::Rgb(19, 36, 28), 166 + status_reloaded_fg: Color::Rgb(14, 21, 18), 167 + status_reloaded_bg: Color::Rgb(132, 214, 154), 168 + status_search_fg: Color::Rgb(236, 214, 123), 169 + status_search_bg: Color::Rgb(30, 36, 34), 170 + status_search_error_fg: Color::Rgb(224, 120, 120), 171 + status_search_match_fg: Color::Rgb(132, 214, 154), 172 + status_shortcut_fg: Color::Rgb(82, 104, 92), 173 + status_percent_fg: Color::Rgb(126, 198, 170), 174 + toc_header_fg: Color::Rgb(102, 118, 106), 175 + toc_active_bg: Color::Rgb(34, 46, 38), 176 + toc_inactive_bg: Color::Rgb(16, 22, 18), 177 + toc_accent: Color::Rgb(127, 179, 255), 178 + toc_index_inactive: Color::Rgb(70, 82, 72), 179 + toc_primary_active: Color::Rgb(228, 234, 228), 180 + toc_primary_inactive: Color::Rgb(146, 156, 148), 181 + toc_secondary_inactive: Color::Rgb(76, 88, 80), 182 + toc_secondary_text_active: Color::Rgb(216, 224, 216), 183 + toc_secondary_text_inactive: Color::Rgb(112, 122, 114), 184 + }, 185 + markdown: MarkdownTheme { 186 + search_highlight_bg: Color::Rgb(74, 78, 32), 187 + code_gutter: Color::Rgb(50, 66, 60), 188 + blockquote_marker: Color::Rgb(98, 124, 118), 189 + list_level_1: Color::Rgb(120, 198, 148), 190 + list_level_2: Color::Rgb(127, 179, 255), 191 + list_level_3: Color::Rgb(184, 190, 170), 192 + ordered_list: Color::Rgb(120, 198, 148), 193 + table_border: Color::Rgb(74, 92, 82), 194 + table_separator: Color::Rgb(60, 76, 68), 195 + table_header: Color::Rgb(148, 204, 255), 196 + table_cell: Color::Rgb(212, 218, 212), 197 + heading_1: Color::Rgb(148, 204, 255), 198 + heading_2: Color::Rgb(120, 214, 170), 199 + heading_3: Color::Rgb(224, 190, 126), 200 + heading_other: Color::Rgb(188, 194, 188), 201 + heading_marker: Color::Rgb(74, 98, 90), 202 + heading_underline: Color::Rgb(52, 68, 60), 203 + code_frame: Color::Rgb(50, 66, 60), 204 + code_label: Color::Rgb(128, 158, 142), 205 + inline_code_fg: Color::Rgb(238, 176, 130), 206 + inline_code_bg: Color::Rgb(46, 36, 30), 207 + rule: Color::Rgb(56, 70, 62), 208 + link_icon: Color::Rgb(102, 170, 255), 209 + link_text: Color::Rgb(110, 182, 255), 210 + blockquote_text: Color::Rgb(160, 168, 188), 211 + text: Color::Rgb(212, 218, 212), 212 + strong_text: Color::Rgb(246, 248, 246), 213 + }, 214 + }; 215 + 216 + pub(crate) const OCEAN_DARK_THEME: AppTheme = AppTheme { 217 + syntax_theme_name: "base16-ocean.dark", 218 + ui: UiTheme { 219 + toc_bg: Color::Rgb(18, 18, 22), 220 + toc_border: Color::Rgb(52, 52, 58), 221 + content_bg: Color::Rgb(18, 20, 28), 222 + status_bg: Color::Rgb(18, 20, 32), 223 + status_separator: Color::Rgb(116, 126, 156), 224 + status_brand_fg: Color::Rgb(16, 18, 26), 225 + status_brand_bg: Color::Rgb(105, 178, 218), 226 + status_filename_fg: Color::Rgb(162, 192, 222), 227 + status_filename_bg: Color::Rgb(24, 28, 44), 228 + status_watch_fg: Color::Rgb(95, 200, 148), 229 + status_watch_bg: Color::Rgb(18, 30, 24), 230 + status_reloaded_fg: Color::Rgb(16, 18, 26), 231 + status_reloaded_bg: Color::Rgb(95, 200, 148), 232 + status_search_fg: Color::Rgb(240, 210, 95), 233 + status_search_bg: Color::Rgb(26, 28, 42), 234 + status_search_error_fg: Color::Rgb(218, 95, 95), 235 + status_search_match_fg: Color::Rgb(115, 208, 148), 236 + status_shortcut_fg: Color::Rgb(58, 68, 98), 237 + status_percent_fg: Color::Rgb(105, 178, 218), 238 + toc_header_fg: Color::Rgb(88, 88, 96), 239 + toc_active_bg: Color::Rgb(42, 40, 46), 240 + toc_inactive_bg: Color::Rgb(18, 18, 22), 241 + toc_accent: Color::Rgb(123, 109, 255), 242 + toc_index_inactive: Color::Rgb(60, 60, 66), 243 + toc_primary_active: Color::Rgb(224, 224, 228), 244 + toc_primary_inactive: Color::Rgb(136, 136, 142), 245 + toc_secondary_inactive: Color::Rgb(62, 62, 68), 246 + toc_secondary_text_active: Color::Rgb(224, 224, 228), 247 + toc_secondary_text_inactive: Color::Rgb(102, 102, 108), 248 + }, 249 + markdown: MarkdownTheme { 250 + search_highlight_bg: Color::Rgb(72, 62, 16), 251 + code_gutter: Color::Rgb(40, 48, 68), 252 + blockquote_marker: Color::Rgb(75, 80, 148), 253 + list_level_1: Color::Rgb(95, 200, 148), 254 + list_level_2: Color::Rgb(138, 155, 200), 255 + list_level_3: Color::Rgb(168, 168, 185), 256 + ordered_list: Color::Rgb(95, 200, 148), 257 + table_border: Color::Rgb(65, 75, 108), 258 + table_separator: Color::Rgb(55, 65, 95), 259 + table_header: Color::Rgb(140, 190, 255), 260 + table_cell: Color::Rgb(205, 208, 218), 261 + heading_1: Color::Rgb(140, 190, 255), 262 + heading_2: Color::Rgb(120, 210, 170), 263 + heading_3: Color::Rgb(210, 180, 120), 264 + heading_other: Color::Rgb(180, 180, 190), 265 + heading_marker: Color::Rgb(55, 75, 115), 266 + heading_underline: Color::Rgb(40, 50, 75), 267 + code_frame: Color::Rgb(40, 48, 68), 268 + code_label: Color::Rgb(95, 110, 145), 269 + inline_code_fg: Color::Rgb(235, 155, 115), 270 + inline_code_bg: Color::Rgb(40, 30, 28), 271 + rule: Color::Rgb(48, 56, 76), 272 + link_icon: Color::Rgb(85, 148, 235), 273 + link_text: Color::Rgb(88, 152, 238), 274 + blockquote_text: Color::Rgb(148, 148, 195), 275 + text: Color::Rgb(208, 210, 218), 276 + strong_text: Color::Rgb(245, 245, 255), 277 + }, 278 + }; 279 + 280 + pub(crate) const SOLARIZED_DARK_THEME: AppTheme = AppTheme { 281 + syntax_theme_name: "Solarized (dark)", 282 + ui: UiTheme { 283 + toc_bg: Color::Rgb(7, 54, 66), 284 + toc_border: Color::Rgb(88, 110, 117), 285 + content_bg: Color::Rgb(0, 43, 54), 286 + status_bg: Color::Rgb(0, 43, 54), 287 + status_separator: Color::Rgb(101, 123, 131), 288 + status_brand_fg: Color::Rgb(0, 43, 54), 289 + status_brand_bg: Color::Rgb(42, 161, 152), 290 + status_filename_fg: Color::Rgb(147, 161, 161), 291 + status_filename_bg: Color::Rgb(7, 54, 66), 292 + status_watch_fg: Color::Rgb(133, 153, 0), 293 + status_watch_bg: Color::Rgb(18, 58, 34), 294 + status_reloaded_fg: Color::Rgb(0, 43, 54), 295 + status_reloaded_bg: Color::Rgb(133, 153, 0), 296 + status_search_fg: Color::Rgb(181, 137, 0), 297 + status_search_bg: Color::Rgb(12, 54, 62), 298 + status_search_error_fg: Color::Rgb(220, 50, 47), 299 + status_search_match_fg: Color::Rgb(133, 153, 0), 300 + status_shortcut_fg: Color::Rgb(88, 110, 117), 301 + status_percent_fg: Color::Rgb(42, 161, 152), 302 + toc_header_fg: Color::Rgb(101, 123, 131), 303 + toc_active_bg: Color::Rgb(17, 67, 80), 304 + toc_inactive_bg: Color::Rgb(7, 54, 66), 305 + toc_accent: Color::Rgb(38, 139, 210), 306 + toc_index_inactive: Color::Rgb(88, 110, 117), 307 + toc_primary_active: Color::Rgb(238, 232, 213), 308 + toc_primary_inactive: Color::Rgb(147, 161, 161), 309 + toc_secondary_inactive: Color::Rgb(88, 110, 117), 310 + toc_secondary_text_active: Color::Rgb(238, 232, 213), 311 + toc_secondary_text_inactive: Color::Rgb(131, 148, 150), 312 + }, 313 + markdown: MarkdownTheme { 314 + search_highlight_bg: Color::Rgb(92, 74, 22), 315 + code_gutter: Color::Rgb(88, 110, 117), 316 + blockquote_marker: Color::Rgb(108, 113, 196), 317 + list_level_1: Color::Rgb(133, 153, 0), 318 + list_level_2: Color::Rgb(38, 139, 210), 319 + list_level_3: Color::Rgb(147, 161, 161), 320 + ordered_list: Color::Rgb(133, 153, 0), 321 + table_border: Color::Rgb(88, 110, 117), 322 + table_separator: Color::Rgb(101, 123, 131), 323 + table_header: Color::Rgb(38, 139, 210), 324 + table_cell: Color::Rgb(147, 161, 161), 325 + heading_1: Color::Rgb(38, 139, 210), 326 + heading_2: Color::Rgb(42, 161, 152), 327 + heading_3: Color::Rgb(181, 137, 0), 328 + heading_other: Color::Rgb(147, 161, 161), 329 + heading_marker: Color::Rgb(88, 110, 117), 330 + heading_underline: Color::Rgb(88, 110, 117), 331 + code_frame: Color::Rgb(88, 110, 117), 332 + code_label: Color::Rgb(131, 148, 150), 333 + inline_code_fg: Color::Rgb(203, 75, 22), 334 + inline_code_bg: Color::Rgb(18, 52, 58), 335 + rule: Color::Rgb(88, 110, 117), 336 + link_icon: Color::Rgb(38, 139, 210), 337 + link_text: Color::Rgb(38, 139, 210), 338 + blockquote_text: Color::Rgb(131, 148, 150), 339 + text: Color::Rgb(147, 161, 161), 340 + strong_text: Color::Rgb(238, 232, 213), 341 + }, 342 + }; 343 + 344 + pub(crate) const DEFAULT_PRESET: ThemePreset = ThemePreset::OceanDark; 345 + pub(crate) const THEME_PRESETS: [ThemePreset; 4] = [ 346 + ThemePreset::Arctic, 347 + ThemePreset::Forest, 348 + ThemePreset::OceanDark, 349 + ThemePreset::SolarizedDark, 350 + ]; 351 + static CURRENT_PRESET: AtomicU8 = AtomicU8::new(DEFAULT_PRESET as u8); 352 + 353 + pub(crate) fn parse_theme_preset(name: &str) -> Option<ThemePreset> { 354 + match name { 355 + "arctic" => Some(ThemePreset::Arctic), 356 + "ocean" | "ocean-dark" | "dark" => Some(ThemePreset::OceanDark), 357 + "forest" => Some(ThemePreset::Forest), 358 + "solarized" | "solarized-dark" => Some(ThemePreset::SolarizedDark), 359 + _ => None, 360 + } 361 + } 362 + 363 + pub(crate) fn theme_preset_label(preset: ThemePreset) -> &'static str { 364 + match preset { 365 + ThemePreset::Arctic => "Arctic", 366 + ThemePreset::OceanDark => "Ocean Dark", 367 + ThemePreset::Forest => "Forest", 368 + ThemePreset::SolarizedDark => "Solarized Dark", 369 + } 370 + } 371 + 372 + pub(crate) fn theme_preset_index(preset: ThemePreset) -> usize { 373 + THEME_PRESETS 374 + .iter() 375 + .position(|candidate| *candidate == preset) 376 + .unwrap_or(0) 377 + } 378 + 379 + pub(crate) fn theme_by_preset(preset: ThemePreset) -> &'static AppTheme { 380 + match preset { 381 + ThemePreset::Arctic => &ARCTIC_THEME, 382 + ThemePreset::OceanDark => &OCEAN_DARK_THEME, 383 + ThemePreset::Forest => &FOREST_THEME, 384 + ThemePreset::SolarizedDark => &SOLARIZED_DARK_THEME, 385 + } 386 + } 387 + 388 + pub(crate) fn set_theme_preset(preset: ThemePreset) { 389 + CURRENT_PRESET.store(preset as u8, Ordering::Relaxed); 390 + } 391 + 392 + pub(crate) fn current_theme_preset() -> ThemePreset { 393 + match CURRENT_PRESET.load(Ordering::Relaxed) { 394 + 0 => ThemePreset::Arctic, 395 + 1 => ThemePreset::Forest, 396 + 2 => ThemePreset::OceanDark, 397 + 3 => ThemePreset::SolarizedDark, 398 + _ => DEFAULT_PRESET, 399 + } 400 + } 401 + 402 + pub(crate) fn app_theme() -> &'static AppTheme { 403 + theme_by_preset(current_theme_preset()) 404 + } 405 + 406 + pub(crate) fn current_syntect_theme(themes: &ThemeSet) -> &Theme { 407 + &themes.themes[app_theme().syntax_theme_name] 408 + }