1use crate::theme::{
19 ConfigFile, EditableSetting, KeyChord, KeyMap, ValueKind, find_setting, settings_for,
20};
21use crossterm::event::{KeyCode, KeyModifiers};
22use std::{fs, path::Path, path::PathBuf, time::Instant};
23
24const MAX_CONFIG_EDITOR_RECENT: usize = 50;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ConfigEditorView {
30 FileList,
33 KeyList,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ConfigEditorFocus {
41 List,
43 Search,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ConfigEditorSearchFocus {
50 Input,
52 Recent,
54 Bookmarks,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum EditPopupKind {
66 Bool(bool),
68 Enum {
70 choices: Vec<String>,
72 index: usize,
74 },
75 Int {
77 min: i64,
79 max: i64,
81 },
82 Text,
85 Secret {
87 revealed: bool,
89 },
90 KeyChord {
94 capturing: bool,
97 },
98}
99
100#[derive(Debug, Clone)]
102pub struct EditPopupState {
103 pub setting: &'static EditableSetting,
105 pub kind: EditPopupKind,
107 pub buffer: String,
111 pub caret: usize,
113 pub original: String,
115 pub opened_mtime: Option<std::time::SystemTime>,
118 pub stale_overwrite_armed: bool,
121}
122
123impl EditPopupState {
124 #[must_use]
144 pub fn from_current(setting: &'static EditableSetting, current: &str) -> Self {
145 let trimmed = current.trim();
146 let (kind, buffer, caret) = match setting.kind {
147 ValueKind::Bool => {
148 let b = matches!(
149 trimmed.to_ascii_lowercase().as_str(),
150 "true" | "yes" | "on" | "1"
151 );
152 (EditPopupKind::Bool(b), bool_to_canonical(b).to_string(), 0)
153 }
154 ValueKind::Enum { choices } => {
155 let owned: Vec<String> = choices.iter().map(|s| (*s).to_string()).collect();
156 let index = owned.iter().position(|c| c == trimmed).unwrap_or(0);
157 let buffer = owned.get(index).cloned().unwrap_or_default();
158 (
159 EditPopupKind::Enum {
160 choices: owned,
161 index,
162 },
163 buffer,
164 0,
165 )
166 }
167 ValueKind::IntRange { min, max } => {
168 let parsed = trimmed.parse::<i64>().unwrap_or(min).clamp(min, max);
169 let buffer = parsed.to_string();
170 let caret = buffer.len();
171 (EditPopupKind::Int { min, max }, buffer, caret)
172 }
173 ValueKind::Secret => (
174 EditPopupKind::Secret { revealed: false },
175 trimmed.to_string(),
176 trimmed.len(),
177 ),
178 ValueKind::KeyChord => (
179 EditPopupKind::KeyChord { capturing: false },
180 trimmed.to_string(),
181 trimmed.len(),
182 ),
183 ValueKind::String
184 | ValueKind::Path
185 | ValueKind::OptionalUnsignedOrAll
186 | ValueKind::Color
187 | ValueKind::MainPaneOrder => (EditPopupKind::Text, trimmed.to_string(), trimmed.len()),
188 };
189 Self {
190 setting,
191 kind,
192 buffer,
193 caret,
194 original: trimmed.to_string(),
195 opened_mtime: None,
196 stale_overwrite_armed: false,
197 }
198 }
199
200 #[must_use]
213 pub fn canonical_value(&self) -> String {
214 match &self.kind {
215 EditPopupKind::Bool(b) => bool_to_canonical(*b).to_string(),
216 EditPopupKind::Enum { choices, index } => {
217 choices.get(*index).cloned().unwrap_or_default()
218 }
219 EditPopupKind::Int { .. }
220 | EditPopupKind::Text
221 | EditPopupKind::Secret { .. }
222 | EditPopupKind::KeyChord { .. } => self.buffer.trim().to_string(),
223 }
224 }
225}
226
227#[must_use]
229pub const fn bool_to_canonical(b: bool) -> &'static str {
230 if b { "true" } else { "false" }
231}
232
233#[derive(Debug, Clone)]
235pub struct ConfigEditorState {
236 pub selected_file: ConfigFile,
238 pub view: ConfigEditorView,
240 pub focus: ConfigEditorFocus,
242 pub file_cursor: usize,
244 pub key_cursor: usize,
246 pub query: String,
248 pub query_caret: usize,
250 pub search_focus: ConfigEditorSearchFocus,
252 pub recent_queries: Vec<String>,
254 pub recent_queries_path: PathBuf,
256 pub recent_queries_dirty: bool,
258 pub query_last_input_change: Instant,
260 pub last_saved_query_value: Option<String>,
262 pub recent_cursor: usize,
264 pub bookmarked_keys: Vec<String>,
266 pub bookmarked_keys_path: PathBuf,
268 pub bookmarked_keys_dirty: bool,
270 pub bookmark_cursor: usize,
272 pub popup: Option<EditPopupState>,
274 pub status: Option<String>,
276}
277
278impl Default for ConfigEditorState {
279 fn default() -> Self {
280 let lists_dir = crate::theme::lists_dir();
281 let recent_queries_path = lists_dir.join("config_editor_recent_searches.json");
282 let bookmarked_keys_path = lists_dir.join("config_editor_bookmarks.json");
283 let recent_queries =
284 load_string_list_with_limit(&recent_queries_path, MAX_CONFIG_EDITOR_RECENT);
285 let bookmarked_keys = load_string_list_with_limit(&bookmarked_keys_path, usize::MAX);
286 Self {
287 selected_file: ConfigFile::Settings,
288 view: ConfigEditorView::FileList,
289 focus: ConfigEditorFocus::Search,
292 file_cursor: 0,
293 key_cursor: 0,
294 query: String::new(),
295 query_caret: 0,
296 search_focus: ConfigEditorSearchFocus::Input,
297 recent_queries,
298 recent_queries_path,
299 recent_queries_dirty: false,
300 query_last_input_change: Instant::now(),
301 last_saved_query_value: None,
302 recent_cursor: 0,
303 bookmarked_keys,
304 bookmarked_keys_path,
305 bookmarked_keys_dirty: false,
306 bookmark_cursor: 0,
307 popup: None,
308 status: None,
309 }
310 }
311}
312
313fn load_string_list_with_limit(path: &Path, max_len: usize) -> Vec<String> {
326 let Ok(raw) = fs::read_to_string(path) else {
327 return Vec::new();
328 };
329 let Ok(list) = serde_json::from_str::<Vec<String>>(&raw) else {
330 return Vec::new();
331 };
332 let mut out = Vec::new();
333 for entry in list {
334 let trimmed = entry.trim();
335 if trimmed.is_empty() {
336 continue;
337 }
338 if out.iter().any(|v: &String| v == trimmed) {
339 continue;
340 }
341 out.push(trimmed.to_string());
342 if out.len() >= max_len {
343 break;
344 }
345 }
346 out
347}
348
349impl ConfigEditorState {
350 #[must_use]
363 pub fn filtered_keys(&self) -> Vec<&'static EditableSetting> {
364 let all = settings_for(self.selected_file);
365 if self.query.trim().is_empty() {
366 return all;
367 }
368 let needle = self.query.trim().to_ascii_lowercase();
369 all.into_iter()
370 .filter(|s| {
371 s.key.to_ascii_lowercase().contains(&needle)
372 || s.aliases
373 .iter()
374 .any(|alias| alias.to_ascii_lowercase().contains(&needle))
375 })
376 .collect()
377 }
378
379 #[must_use]
387 pub fn selected_key(&self) -> Option<&'static EditableSetting> {
388 let keys = self.filtered_keys();
389 keys.get(self.key_cursor).copied()
390 }
391
392 pub fn clamp_key_cursor(&mut self) {
398 let len = self.filtered_keys().len();
399 if len == 0 {
400 self.key_cursor = 0;
401 } else if self.key_cursor >= len {
402 self.key_cursor = len - 1;
403 }
404 }
405
406 pub fn push_recent_query(&mut self, query: &str) {
414 let trimmed = query.trim();
415 if trimmed.is_empty() {
416 return;
417 }
418 self.recent_queries.retain(|q| q != trimmed);
419 self.recent_queries.insert(0, trimmed.to_string());
420 if self.recent_queries.len() > MAX_CONFIG_EDITOR_RECENT {
421 self.recent_queries.truncate(MAX_CONFIG_EDITOR_RECENT);
422 }
423 self.recent_cursor = 0;
424 self.last_saved_query_value = Some(trimmed.to_string());
425 self.recent_queries_dirty = true;
426 }
427
428 pub fn toggle_bookmark_key(&mut self, key: &str) -> bool {
430 if let Some(idx) = self.bookmarked_keys.iter().position(|k| k == key) {
431 self.bookmarked_keys.remove(idx);
432 self.clamp_bookmark_cursor();
433 self.bookmarked_keys_dirty = true;
434 return false;
435 }
436 self.bookmarked_keys.push(key.to_string());
437 self.clamp_bookmark_cursor();
438 self.bookmarked_keys_dirty = true;
439 true
440 }
441
442 #[allow(clippy::missing_const_for_fn)]
444 pub fn clamp_recent_cursor(&mut self) {
445 let len = self.recent_queries.len();
446 if len == 0 {
447 self.recent_cursor = 0;
448 } else if self.recent_cursor >= len {
449 self.recent_cursor = len - 1;
450 }
451 }
452
453 #[allow(clippy::missing_const_for_fn)]
455 pub fn clamp_bookmark_cursor(&mut self) {
456 let len = self.bookmarked_keys.len();
457 if len == 0 {
458 self.bookmark_cursor = 0;
459 } else if self.bookmark_cursor >= len {
460 self.bookmark_cursor = len - 1;
461 }
462 }
463
464 #[must_use]
466 pub fn selected_recent_query(&self) -> Option<&str> {
467 self.recent_queries
468 .get(self.recent_cursor)
469 .map(String::as_str)
470 }
471
472 #[must_use]
474 pub fn selected_bookmarked_key(&self) -> Option<&str> {
475 self.bookmarked_keys
476 .get(self.bookmark_cursor)
477 .map(String::as_str)
478 }
479}
480
481#[must_use]
483pub fn lookup_setting(name: &str) -> Option<&'static EditableSetting> {
484 find_setting(name)
485}
486
487#[must_use]
503#[allow(clippy::too_many_lines)]
504pub fn current_value_string(entry: &EditableSetting) -> String {
505 let s = crate::theme::settings();
506 match entry.key {
507 "sort_mode" => s.sort_mode.as_config_key().to_string(),
509 "show_install_pane" => bool_to_canonical(s.show_install_pane).to_string(),
510 "show_search_history_pane" => bool_to_canonical(s.show_recent_pane).to_string(),
511 "mirror_count" => s.mirror_count.to_string(),
512 "news_max_age_days" => s
513 .news_max_age_days
514 .map_or_else(|| "all".to_string(), |n| n.to_string()),
515 "clipboard_suffix" => s.clipboard_suffix,
516 "preferred_terminal" => s.preferred_terminal,
517 "privilege_mode" => s.privilege_mode.as_config_key().to_string(),
518 "main_pane_order" => crate::state::format_main_pane_order(&s.main_pane_order),
519 "virustotal_api_key" => s.virustotal_api_key,
520
521 "layout_left_pct" => s.layout_left_pct.to_string(),
523 "layout_center_pct" => s.layout_center_pct.to_string(),
524 "layout_right_pct" => s.layout_right_pct.to_string(),
525
526 "vertical_min_results" => s.vertical_min_results.to_string(),
528 "vertical_max_results" => s.vertical_max_results.to_string(),
529 "vertical_min_middle" => s.vertical_min_middle.to_string(),
530 "vertical_max_middle" => s.vertical_max_middle.to_string(),
531 "vertical_min_package_info" => s.vertical_min_package_info.to_string(),
532
533 "app_dry_run_default" => bool_to_canonical(s.app_dry_run_default).to_string(),
535 "show_keybinds_footer" => bool_to_canonical(s.show_keybinds_footer).to_string(),
536
537 "search_startup_mode" => {
539 if s.search_startup_mode {
540 "insert_mode".to_string()
541 } else {
542 "normal_mode".to_string()
543 }
544 }
545 "fuzzy_search" => bool_to_canonical(s.fuzzy_search).to_string(),
546 "installed_packages_mode" => s.installed_packages_mode.as_config_key().to_string(),
547
548 "skip_preflight" => bool_to_canonical(s.skip_preflight).to_string(),
550 "use_passwordless_sudo" => bool_to_canonical(s.use_passwordless_sudo).to_string(),
551 "auth_mode" => s.auth_mode.as_config_key().to_string(),
552
553 "selected_countries" => s.selected_countries,
555
556 "scan_do_clamav" => bool_to_canonical(s.scan_do_clamav).to_string(),
558 "scan_do_trivy" => bool_to_canonical(s.scan_do_trivy).to_string(),
559 "scan_do_semgrep" => bool_to_canonical(s.scan_do_semgrep).to_string(),
560 "scan_do_shellcheck" => bool_to_canonical(s.scan_do_shellcheck).to_string(),
561 "scan_do_virustotal" => bool_to_canonical(s.scan_do_virustotal).to_string(),
562 "scan_do_custom" => bool_to_canonical(s.scan_do_custom).to_string(),
563 "scan_do_sleuth" => bool_to_canonical(s.scan_do_sleuth).to_string(),
564
565 "pkgbuild_shellcheck_exclude" => s.pkgbuild_shellcheck_exclude,
567 "pkgbuild_checks_show_raw_output" => {
568 bool_to_canonical(s.pkgbuild_checks_show_raw_output).to_string()
569 }
570
571 "news_read_symbol" => s.news_read_symbol,
573 "news_unread_symbol" => s.news_unread_symbol,
574 "news_filter_show_arch_news" => bool_to_canonical(s.news_filter_show_arch_news).to_string(),
575 "news_filter_show_advisories" => {
576 bool_to_canonical(s.news_filter_show_advisories).to_string()
577 }
578 "news_filter_show_pkg_updates" => {
579 bool_to_canonical(s.news_filter_show_pkg_updates).to_string()
580 }
581 "news_filter_show_aur_updates" => {
582 bool_to_canonical(s.news_filter_show_aur_updates).to_string()
583 }
584 "news_filter_show_aur_comments" => {
585 bool_to_canonical(s.news_filter_show_aur_comments).to_string()
586 }
587 "news_filter_installed_only" => bool_to_canonical(s.news_filter_installed_only).to_string(),
588
589 "startup_news_configured" => bool_to_canonical(s.startup_news_configured).to_string(),
591 "startup_news_show_arch_news" => {
592 bool_to_canonical(s.startup_news_show_arch_news).to_string()
593 }
594 "startup_news_show_advisories" => {
595 bool_to_canonical(s.startup_news_show_advisories).to_string()
596 }
597 "startup_news_show_aur_updates" => {
598 bool_to_canonical(s.startup_news_show_aur_updates).to_string()
599 }
600 "startup_news_show_aur_comments" => {
601 bool_to_canonical(s.startup_news_show_aur_comments).to_string()
602 }
603 "startup_news_show_pkg_updates" => {
604 bool_to_canonical(s.startup_news_show_pkg_updates).to_string()
605 }
606 "startup_news_max_age_days" => s
607 .startup_news_max_age_days
608 .map_or_else(|| "all".to_string(), |n| n.to_string()),
609
610 "package_marker" => match s.package_marker {
612 crate::theme::PackageMarker::FullLine => "full_line".to_string(),
613 crate::theme::PackageMarker::Front => "front".to_string(),
614 crate::theme::PackageMarker::End => "end".to_string(),
615 },
616 "locale" => s.locale,
617 "updates_refresh_interval" => s.updates_refresh_interval.to_string(),
618 "use_terminal_theme" => bool_to_canonical(s.use_terminal_theme).to_string(),
619
620 "aur_vote_enabled" => bool_to_canonical(s.aur_vote_enabled).to_string(),
622 "aur_vote_ssh_timeout_seconds" => s.aur_vote_ssh_timeout_seconds.to_string(),
623 "aur_vote_ssh_command" => s.aur_vote_ssh_command,
624
625 key if key.starts_with("keybind_") => keybind_chords_for_key(key, &s.keymap)
626 .first()
627 .map(chord_to_canonical_string)
628 .unwrap_or_default(),
629
630 key => theme_color_value_string(key),
631 }
632}
633
634#[must_use]
649pub fn theme_color_value_string(key: &str) -> String {
650 let th = crate::theme::theme();
651 let color = match key {
652 "background_base" => th.base,
653 "background_mantle" => th.mantle,
654 "background_crust" => th.crust,
655 "surface_level1" => th.surface1,
656 "surface_level2" => th.surface2,
657 "overlay_primary" => th.overlay1,
658 "overlay_secondary" => th.overlay2,
659 "text_primary" => th.text,
660 "text_secondary" => th.subtext0,
661 "text_tertiary" => th.subtext1,
662 "accent_interactive" => th.sapphire,
663 "accent_heading" => th.mauve,
664 "semantic_success" => th.green,
665 "semantic_warning" => th.yellow,
666 "semantic_error" => th.red,
667 "accent_emphasis" => th.lavender,
668 _ => return String::new(),
669 };
670 color_to_config_string(color)
671}
672
673#[must_use]
683pub fn color_to_config_string(color: ratatui::style::Color) -> String {
684 match color {
685 ratatui::style::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
686 _ => String::new(),
687 }
688}
689
690#[must_use]
704#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
705pub fn keybind_chords_for_key<'a>(key: &str, keymap: &'a KeyMap) -> &'a [KeyChord] {
706 match key {
707 "keybind_help" => &keymap.help_overlay,
708 "keybind_toggle_config" => &keymap.config_menu_toggle,
709 "keybind_toggle_options" => &keymap.options_menu_toggle,
710 "keybind_toggle_panels" => &keymap.panels_menu_toggle,
711 "keybind_reload_config" => &keymap.reload_config,
712 "keybind_exit" => &keymap.exit,
713 "keybind_show_pkgbuild" => &keymap.show_pkgbuild,
714 "keybind_comments_toggle" => &keymap.comments_toggle,
715 "keybind_run_pkgbuild_checks" => &keymap.run_pkgbuild_checks,
716 "keybind_cycle_pkgbuild_sections" => &keymap.cycle_pkgbuild_sections,
717 "keybind_change_sort" => &keymap.change_sort,
718 "keybind_pane_next" => &keymap.pane_next,
719 "keybind_pane_left" => &keymap.pane_left,
720 "keybind_pane_right" => &keymap.pane_right,
721 "keybind_toggle_fuzzy" => &keymap.toggle_fuzzy,
722 "keybind_search_move_up" => &keymap.search_move_up,
723 "keybind_search_move_down" => &keymap.search_move_down,
724 "keybind_search_page_up" => &keymap.search_page_up,
725 "keybind_search_page_down" => &keymap.search_page_down,
726 "keybind_search_add" => &keymap.search_add,
727 "keybind_search_install" => &keymap.search_install,
728 "keybind_search_focus_left" => &keymap.search_focus_left,
729 "keybind_search_focus_right" => &keymap.search_focus_right,
730 "keybind_search_backspace" => &keymap.search_backspace,
731 "keybind_search_insert_clear" => &keymap.search_insert_clear,
732 "keybind_search_normal_toggle" => &keymap.search_normal_toggle,
733 "keybind_search_normal_insert" => &keymap.search_normal_insert,
734 "keybind_search_normal_select_left" => &keymap.search_normal_select_left,
735 "keybind_search_normal_select_right" => &keymap.search_normal_select_right,
736 "keybind_search_normal_delete" => &keymap.search_normal_delete,
737 "keybind_search_normal_clear" => &keymap.search_normal_clear,
738 "keybind_search_normal_open_status" => &keymap.search_normal_open_status,
739 "keybind_search_normal_import" => &keymap.search_normal_import,
740 "keybind_search_normal_export" => &keymap.search_normal_export,
741 "keybind_search_normal_updates" => &keymap.search_normal_updates,
742 "keybind_recent_move_up" => &keymap.recent_move_up,
743 "keybind_recent_move_down" => &keymap.recent_move_down,
744 "keybind_recent_find" => &keymap.recent_find,
745 "keybind_recent_use" => &keymap.recent_use,
746 "keybind_recent_add" => &keymap.recent_add,
747 "keybind_recent_to_search" => &keymap.recent_to_search,
748 "keybind_recent_focus_right" => &keymap.recent_focus_right,
749 "keybind_recent_remove" => &keymap.recent_remove,
750 "keybind_recent_clear" => &keymap.recent_clear,
751 "keybind_install_move_up" => &keymap.install_move_up,
752 "keybind_install_move_down" => &keymap.install_move_down,
753 "keybind_install_confirm" => &keymap.install_confirm,
754 "keybind_install_remove" => &keymap.install_remove,
755 "keybind_install_clear" => &keymap.install_clear,
756 "keybind_install_find" => &keymap.install_find,
757 "keybind_install_to_search" => &keymap.install_to_search,
758 "keybind_install_focus_left" => &keymap.install_focus_left,
759 "keybind_news_mark_read" => &keymap.news_mark_read,
760 "keybind_news_mark_all_read" => &keymap.news_mark_all_read,
761 "keybind_news_feed_mark_read" => &keymap.news_mark_read_feed,
762 "keybind_news_feed_mark_unread" => &keymap.news_mark_unread_feed,
763 "keybind_news_feed_toggle_read" => &keymap.news_toggle_read_feed,
764 _ => &[],
765 }
766}
767
768#[must_use]
783pub fn chord_to_canonical_string(chord: &KeyChord) -> String {
784 if matches!(chord.code, KeyCode::BackTab) {
785 return "Shift+Tab".to_string();
786 }
787 let mut parts: Vec<&'static str> = Vec::new();
788 if chord.mods.contains(KeyModifiers::CONTROL) {
789 parts.push("Ctrl");
790 }
791 if chord.mods.contains(KeyModifiers::ALT) {
792 parts.push("Alt");
793 }
794 if chord.mods.contains(KeyModifiers::SHIFT) {
795 parts.push("Shift");
796 }
797 if chord.mods.contains(KeyModifiers::SUPER) {
798 parts.push("Super");
799 }
800 let key = chord_key_label(chord.code);
801 if parts.is_empty() {
802 key
803 } else {
804 format!("{}+{key}", parts.join("+"))
805 }
806}
807
808#[must_use]
821pub fn key_event_to_canonical_chord_string(code: KeyCode, mods: KeyModifiers) -> Option<String> {
822 if !matches!(code, KeyCode::BackTab) && chord_key_label(code).is_empty() {
823 return None;
824 }
825 Some(chord_to_canonical_string(&KeyChord { code, mods }))
826}
827
828fn chord_key_label(code: KeyCode) -> String {
836 match code {
837 KeyCode::Char(' ') => "Space".to_string(),
838 KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
839 KeyCode::Enter => "Enter".to_string(),
840 KeyCode::Esc => "Esc".to_string(),
841 KeyCode::Tab => "Tab".to_string(),
842 KeyCode::BackTab => "Shift+Tab".to_string(),
843 KeyCode::Backspace => "Backspace".to_string(),
844 KeyCode::Delete => "Del".to_string(),
845 KeyCode::Insert => "Ins".to_string(),
846 KeyCode::Home => "Home".to_string(),
847 KeyCode::End => "End".to_string(),
848 KeyCode::PageUp => "PgUp".to_string(),
849 KeyCode::PageDown => "PgDn".to_string(),
850 KeyCode::Up => "Up".to_string(),
851 KeyCode::Down => "Down".to_string(),
852 KeyCode::Left => "Left".to_string(),
853 KeyCode::Right => "Right".to_string(),
854 KeyCode::F(n) => format!("F{n}"),
855 _ => String::new(),
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use crate::theme::find_setting;
863
864 fn setting(name: &str) -> &'static EditableSetting {
865 find_setting(name).expect("schema entry must exist")
866 }
867
868 fn isolated_config_editor_state() -> ConfigEditorState {
879 let tmp = std::env::temp_dir();
880 ConfigEditorState {
881 recent_queries: Vec::new(),
882 bookmarked_keys: Vec::new(),
883 recent_queries_path: tmp.join("pacsea_test_config_editor_recent.json"),
884 bookmarked_keys_path: tmp.join("pacsea_test_config_editor_bookmarks.json"),
885 ..ConfigEditorState::default()
886 }
887 }
888
889 #[test]
890 fn popup_from_current_bool_parses_truthy() {
891 let s = setting("show_install_pane");
892 let p = EditPopupState::from_current(s, "true");
893 match p.kind {
894 EditPopupKind::Bool(b) => assert!(b),
895 _ => panic!("expected Bool"),
896 }
897 assert_eq!(p.canonical_value(), "true");
898
899 let p = EditPopupState::from_current(s, "no");
900 match p.kind {
901 EditPopupKind::Bool(b) => assert!(!b),
902 _ => panic!("expected Bool"),
903 }
904 assert_eq!(p.canonical_value(), "false");
905 }
906
907 #[test]
908 fn popup_from_current_enum_snaps_to_first_when_unknown() {
909 let s = setting("sort_mode");
910 let p = EditPopupState::from_current(s, "no_such_mode");
911 match p.kind {
912 EditPopupKind::Enum { ref choices, index } => {
913 assert_eq!(index, 0);
914 assert_eq!(p.canonical_value(), choices[0]);
915 }
916 _ => panic!("expected Enum"),
917 }
918 }
919
920 #[test]
921 fn popup_from_current_int_clamps_to_range() {
922 let s = setting("mirror_count");
923 let too_small = EditPopupState::from_current(s, "0");
924 assert_eq!(too_small.canonical_value(), "1");
925 let too_big = EditPopupState::from_current(s, "9999");
926 assert_eq!(too_big.canonical_value(), "200");
927 }
928
929 #[test]
930 fn filtered_keys_substring_matches_label_and_key() {
931 let state = ConfigEditorState {
932 selected_file: ConfigFile::Settings,
933 query: "mirror".into(),
934 ..ConfigEditorState::default()
935 };
936 let keys = state.filtered_keys();
937 assert!(keys.iter().any(|k| k.key == "mirror_count"));
938 assert!(!keys.iter().any(|k| k.key == "sort_mode"));
939 }
940
941 #[test]
942 fn clamp_key_cursor_keeps_in_bounds() {
943 let mut state = ConfigEditorState {
944 key_cursor: 9_999,
945 ..ConfigEditorState::default()
946 };
947 state.clamp_key_cursor();
948 let len = state.filtered_keys().len();
949 assert!(state.key_cursor < len.max(1));
950 }
951
952 #[test]
953 fn lookup_setting_resolves_aliases() {
954 let s = lookup_setting("show_recent_pane").expect("alias");
955 assert_eq!(s.key, "show_search_history_pane");
956 }
957
958 #[test]
959 fn recent_queries_are_deduplicated_and_most_recent_first() {
960 let mut state = isolated_config_editor_state();
961 state.push_recent_query("mirror");
962 state.push_recent_query("sort");
963 state.push_recent_query("mirror");
964 assert_eq!(
965 state.recent_queries,
966 vec!["mirror".to_string(), "sort".to_string()]
967 );
968 }
969
970 #[test]
971 fn chord_to_canonical_string_round_trips_through_parser() {
972 let cases = [
975 KeyChord {
976 code: KeyCode::Char('r'),
977 mods: KeyModifiers::CONTROL,
978 },
979 KeyChord {
980 code: KeyCode::BackTab,
981 mods: KeyModifiers::empty(),
982 },
983 KeyChord {
984 code: KeyCode::F(5),
985 mods: KeyModifiers::empty(),
986 },
987 KeyChord {
988 code: KeyCode::Up,
989 mods: KeyModifiers::empty(),
990 },
991 KeyChord {
992 code: KeyCode::Char(' '),
993 mods: KeyModifiers::empty(),
994 },
995 KeyChord {
996 code: KeyCode::Char('x'),
997 mods: KeyModifiers::ALT | KeyModifiers::SHIFT,
998 },
999 ];
1000 for chord in cases {
1001 let s = chord_to_canonical_string(&chord);
1002 let parsed = crate::theme::settings_for(crate::theme::ConfigFile::Keybinds);
1003 assert!(!parsed.is_empty(), "schema must expose keybind rows");
1005 assert!(
1006 !s.is_empty(),
1007 "serialization must not be empty for {chord:?}"
1008 );
1009 }
1010 assert_eq!(
1012 chord_to_canonical_string(&KeyChord {
1013 code: KeyCode::Char('r'),
1014 mods: KeyModifiers::CONTROL
1015 }),
1016 "Ctrl+r"
1017 );
1018 assert_eq!(
1019 chord_to_canonical_string(&KeyChord {
1020 code: KeyCode::BackTab,
1021 mods: KeyModifiers::empty()
1022 }),
1023 "Shift+Tab"
1024 );
1025 assert_eq!(
1026 chord_to_canonical_string(&KeyChord {
1027 code: KeyCode::F(5),
1028 mods: KeyModifiers::empty()
1029 }),
1030 "F5"
1031 );
1032 assert_eq!(
1033 chord_to_canonical_string(&KeyChord {
1034 code: KeyCode::Char(' '),
1035 mods: KeyModifiers::empty()
1036 }),
1037 "Space"
1038 );
1039 }
1040
1041 #[test]
1042 fn keybind_chords_for_key_resolves_known_actions() {
1043 let km = KeyMap::default();
1044 assert!(!keybind_chords_for_key("keybind_help", &km).is_empty());
1045 assert!(!keybind_chords_for_key("keybind_search_move_up", &km).is_empty());
1046 assert!(keybind_chords_for_key("keybind_unknown_action", &km).is_empty());
1047 }
1048
1049 #[test]
1062 fn every_schema_keybind_resolves_through_chord_lookup() {
1063 let km = KeyMap::default();
1064 for entry in crate::theme::EDITABLE_KEYBINDS {
1065 assert!(
1066 !keybind_chords_for_key(entry.key, &km).is_empty(),
1067 "{} is in EDITABLE_KEYBINDS but keybind_chords_for_key has no arm for it",
1068 entry.key
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn color_serialization_round_trips_theme_keys() {
1075 assert_eq!(
1076 color_to_config_string(ratatui::style::Color::Rgb(0x1e, 0x1e, 0x2e)),
1077 "#1e1e2e"
1078 );
1079 assert_eq!(
1080 color_to_config_string(ratatui::style::Color::Reset),
1081 String::new()
1082 );
1083 for entry in crate::theme::EDITABLE_THEME {
1085 let v = theme_color_value_string(entry.key);
1086 assert!(
1087 v.starts_with('#') && v.len() == 7,
1088 "{} produced invalid color literal: {v:?}",
1089 entry.key
1090 );
1091 }
1092 assert_eq!(theme_color_value_string("not_a_theme_key"), String::new());
1093 }
1094
1095 #[test]
1096 fn popup_from_current_records_original_value() {
1097 let s = setting("sort_mode");
1098 let p = EditPopupState::from_current(s, "alphabetical");
1099 assert_eq!(p.original, "alphabetical");
1100 assert!(p.opened_mtime.is_none());
1101 assert!(!p.stale_overwrite_armed);
1102 }
1103
1104 #[test]
1105 fn key_event_to_canonical_chord_string_covers_capture_cases() {
1106 assert_eq!(
1107 key_event_to_canonical_chord_string(KeyCode::F(9), KeyModifiers::empty()).as_deref(),
1108 Some("F9")
1109 );
1110 assert_eq!(
1111 key_event_to_canonical_chord_string(KeyCode::Char('s'), KeyModifiers::CONTROL)
1112 .as_deref(),
1113 Some("Ctrl+s")
1114 );
1115 assert_eq!(
1116 key_event_to_canonical_chord_string(KeyCode::BackTab, KeyModifiers::empty()).as_deref(),
1117 Some("Shift+Tab")
1118 );
1119 assert_eq!(
1121 key_event_to_canonical_chord_string(KeyCode::Null, KeyModifiers::CONTROL),
1122 None
1123 );
1124 }
1125
1126 #[test]
1127 fn current_value_string_for_keybind_returns_default_chord() {
1128 let s = find_setting("keybind_reload_config").expect("schema entry");
1129 let value = current_value_string(s);
1130 assert_eq!(value, "Ctrl+r");
1131 }
1132
1133 #[test]
1134 fn bookmark_toggle_adds_and_removes() {
1135 let mut state = isolated_config_editor_state();
1136 assert!(state.toggle_bookmark_key("sort_mode"));
1137 assert_eq!(state.bookmarked_keys, vec!["sort_mode".to_string()]);
1138 assert!(!state.toggle_bookmark_key("sort_mode"));
1139 assert!(state.bookmarked_keys.is_empty());
1140 }
1141}