Skip to main content

xei_core/
settings.rs

1//! Unified Settings panel (Ctrl+,) — About · Setting · Pet · Help.
2
3use crate::config::{self, Config};
4use crate::theme;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SettingsPage {
8    /// Welcome-like About card.
9    About,
10    /// Appearance + editor config (theme, tab width, …).
11    Setting,
12    /// Desktop pet GIF overlay.
13    Pet,
14    /// Keyboard shortcut reference.
15    Help,
16}
17
18impl SettingsPage {
19    pub fn label(self) -> &'static str {
20        match self {
21            SettingsPage::About => "About",
22            SettingsPage::Setting => "Setting",
23            SettingsPage::Pet => "Pet",
24            SettingsPage::Help => "Help",
25        }
26    }
27
28    pub fn all() -> &'static [SettingsPage] {
29        &[
30            SettingsPage::About,
31            SettingsPage::Setting,
32            SettingsPage::Pet,
33            SettingsPage::Help,
34        ]
35    }
36
37    pub fn next(self) -> Self {
38        match self {
39            SettingsPage::About => SettingsPage::Setting,
40            SettingsPage::Setting => SettingsPage::Pet,
41            SettingsPage::Pet => SettingsPage::Help,
42            SettingsPage::Help => SettingsPage::About,
43        }
44    }
45
46    pub fn prev(self) -> Self {
47        match self {
48            SettingsPage::About => SettingsPage::Help,
49            SettingsPage::Setting => SettingsPage::About,
50            SettingsPage::Pet => SettingsPage::Setting,
51            SettingsPage::Help => SettingsPage::Pet,
52        }
53    }
54}
55
56/// One row on the Help page: key chord + description.
57#[derive(Debug, Clone, Copy)]
58pub struct HelpEntry {
59    pub keys: &'static str,
60    pub desc: &'static str,
61    /// Section header when `keys` is empty.
62    pub is_header: bool,
63}
64
65/// Full shortcut list shown on the Help tab.
66pub fn help_entries() -> &'static [HelpEntry] {
67    &[
68        HelpEntry {
69            keys: "",
70            desc: "General",
71            is_header: true,
72        },
73        HelpEntry {
74            keys: "Ctrl+,",
75            desc: "Settings (About / Setting / Pet / Help)",
76            is_header: false,
77        },
78        HelpEntry {
79            keys: ":screensaver / :ss",
80            desc: "xeifetch splash (clock · weather · Esc exit)",
81            is_header: false,
82        },
83        HelpEntry {
84            keys: ":pet path.gif",
85            desc: "Load desktop pet GIF (Kitty graphics)",
86            is_header: false,
87        },
88        HelpEntry {
89            keys: "Ctrl+S",
90            desc: "Save file",
91            is_header: false,
92        },
93        HelpEntry {
94            keys: "Ctrl+P / Cmd+P",
95            desc: "Quick open files",
96            is_header: false,
97        },
98        HelpEntry {
99            keys: "Ctrl+Shift+P",
100            desc: "Command palette",
101            is_header: false,
102        },
103        HelpEntry {
104            keys: "Ctrl+F",
105            desc: "Toggle file explorer",
106            is_header: false,
107        },
108        HelpEntry {
109            keys: "Ctrl+E",
110            desc: "Toggle XLC command panel",
111            is_header: false,
112        },
113        HelpEntry {
114            keys: "Ctrl+Shift+V",
115            desc: "Pretty preview (Markdown / JSON / CSV / image / audio)",
116            is_header: false,
117        },
118        HelpEntry {
119            keys: "za / zc / zo / zM / zR",
120            desc: "Toggle / close / open fold · close all / open all",
121            is_header: false,
122        },
123        HelpEntry {
124            keys: "Ctrl+B / gb",
125            desc: "Git blame panel (slides in · flame colors)",
126            is_header: false,
127        },
128        HelpEntry {
129            keys: "Space (leader)",
130            desc: "Which-key: f files · g git · l lsp · d debug · w window · …",
131            is_header: false,
132        },
133        HelpEntry {
134            keys: "F5 / Shift+F5",
135            desc: "DAP start/continue · stop session",
136            is_header: false,
137        },
138        HelpEntry {
139            keys: "F9 / F10 / F11 / S-F11",
140            desc: "Toggle breakpoint · step over · into · out",
141            is_header: false,
142        },
143        HelpEntry {
144            keys: "F6",
145            desc: "DAP pause a running program",
146            is_header: false,
147        },
148        HelpEntry {
149            keys: ":bp if expr / :bp log msg",
150            desc: "Conditional breakpoint · logpoint",
151            is_header: false,
152        },
153        HelpEntry {
154            keys: ":pr N",
155            desc: "PR review surface (files · diff · comments)",
156            is_header: false,
157        },
158        HelpEntry {
159            keys: "Ctrl+W v/s ×N · h j k l",
160            desc: "Split again for up to 4 panes · directional focus",
161            is_header: false,
162        },
163        HelpEntry {
164            keys: "zh zl zH zL",
165            desc: "Pan horizontally (wrap_lines = false)",
166            is_header: false,
167        },
168        HelpEntry {
169            keys: "Wheel / PageUp·Down",
170            desc: "Terminal scrollback (badge ↑N · typing snaps live)",
171            is_header: false,
172        },
173        HelpEntry {
174            keys: ":settings / SPC l a",
175            desc: "Settings · code actions (legacy terminals: Ctrl+,/Ctrl+. don't exist)",
176            is_header: false,
177        },
178        HelpEntry {
179            keys: "Ctrl+Shift+D / SPC d",
180            desc: "Debug panel (stack · vars · BPs · console)",
181            is_header: false,
182        },
183        HelpEntry {
184            keys: ":dap / :bp / :launch",
185            desc: "Debug panel · breakpoint · launch program",
186            is_header: false,
187        },
188        HelpEntry {
189            keys: "gC / gI / gH / :calls",
190            desc: "Call hierarchy (incoming / outgoing)",
191            is_header: false,
192        },
193        HelpEntry {
194            keys: ":rebase [N] / SPC g r",
195            desc: "Interactive rebase last N commits",
196            is_header: false,
197        },
198        HelpEntry {
199            keys: ":rebase-abort / :rebase-continue",
200            desc: "Abort or continue in-progress rebase",
201            is_header: false,
202        },
203        HelpEntry {
204            keys: ":codelens / SPC t l",
205            desc: "Toggle LSP code lens (EOL virtual text)",
206            is_header: false,
207        },
208        HelpEntry {
209            keys: "PR Enter / :pr N",
210            desc: "PR review · files + diff + comments",
211            is_header: false,
212        },
213        HelpEntry {
214            keys: "~/.xei/hooks.toml",
215            desc: "Plugin hooks: on_save / on_open / on_quit",
216            is_header: false,
217        },
218        HelpEntry {
219            keys: "]c / [c",
220            desc: "Next / previous git change (gutter hunk)",
221            is_header: false,
222        },
223        HelpEntry {
224            keys: "g / z / d c y / Ctrl+W",
225            desc: "Prefix chords open delayed which-key popup",
226            is_header: false,
227        },
228        HelpEntry {
229            keys: "Tab (Insert)",
230            desc: "Expand snippet (fn, for, if, …) or indent",
231            is_header: false,
232        },
233        HelpEntry {
234            keys: "Live reload",
235            desc: "Auto-reload when file changes on disk",
236            is_header: false,
237        },
238        HelpEntry {
239            keys: "Git · 9 Stash",
240            desc: "Stash list · Enter apply · d drop · p preview",
241            is_header: false,
242        },
243        HelpEntry {
244            keys: "Ctrl+D",
245            desc: "Multi-cursor: add next word match",
246            is_header: false,
247        },
248        HelpEntry {
249            keys: "Ctrl+Alt+j/k",
250            desc: "Multi-cursor: add caret below / above",
251            is_header: false,
252        },
253        HelpEntry {
254            keys: "Esc (multi)",
255            desc: "Clear extra carets (Insert: first Esc)",
256            is_header: false,
257        },
258        HelpEntry {
259            keys: "Explorer · Enter",
260            desc: "Open file · images/csv/npy/audio → preview",
261            is_header: false,
262        },
263        HelpEntry {
264            keys: "Preview ←/→",
265            desc: "Resize image preview",
266            is_header: false,
267        },
268        HelpEntry {
269            keys: "Preview Space",
270            desc: "Play / stop audio",
271            is_header: false,
272        },
273        HelpEntry {
274            keys: "Ctrl+Shift+F",
275            desc: "Find in files (workspace search)",
276            is_header: false,
277        },
278        HelpEntry {
279            keys: "Ctrl+Shift+O / gO",
280            desc: "Document symbols (outline)",
281            is_header: false,
282        },
283        HelpEntry {
284            keys: "Ctrl+.",
285            desc: "Code actions / quick fix",
286            is_header: false,
287        },
288        HelpEntry {
289            keys: "Ctrl+Shift+I",
290            desc: "Format document (LSP)",
291            is_header: false,
292        },
293        HelpEntry {
294            keys: "Cmd+C / V / X",
295            desc: "Copy / paste / cut (system clipboard)",
296            is_header: false,
297        },
298        HelpEntry {
299            keys: "Right-click",
300            desc: "Editor context menu (Insert / Normal / Visual)",
301            is_header: false,
302        },
303        HelpEntry {
304            keys: "",
305            desc: "Terminal",
306            is_header: true,
307        },
308        HelpEntry {
309            keys: "Ctrl+T",
310            desc: "Side terminal panel (Esc closes side term)",
311            is_header: false,
312        },
313        HelpEntry {
314            keys: "Ctrl+Shift+T",
315            desc: "Terminal window in a split pane",
316            is_header: false,
317        },
318        HelpEntry {
319            keys: "Ctrl+Shift+W",
320            desc: "Close terminal window (then y / n)",
321            is_header: false,
322        },
323        HelpEntry {
324            keys: "Esc  (term focused)",
325            desc: "Sent to shell — not editor (vim/opencode exit)",
326            is_header: false,
327        },
328        HelpEntry {
329            keys: "Ctrl+C / D / Z …",
330            desc: "When term focused: real signals to the program",
331            is_header: false,
332        },
333        HelpEntry {
334            keys: "Ctrl+W w",
335            desc: "From terminal pane: focus the other split",
336            is_header: false,
337        },
338        HelpEntry {
339            keys: "F12",
340            desc: "Quick toggle side terminal",
341            is_header: false,
342        },
343        HelpEntry {
344            keys: "",
345            desc: "Splits",
346            is_header: true,
347        },
348        HelpEntry {
349            keys: "Ctrl+W v / s",
350            desc: "Vertical / horizontal split",
351            is_header: false,
352        },
353        HelpEntry {
354            keys: "Ctrl+W w",
355            desc: "Cycle focused split pane",
356            is_header: false,
357        },
358        HelpEntry {
359            keys: "Ctrl+W q",
360            desc: "Close current split",
361            is_header: false,
362        },
363        HelpEntry {
364            keys: "Ctrl+W =",
365            desc: "Equalize split sizes",
366            is_header: false,
367        },
368        HelpEntry {
369            keys: "Ctrl+W h/j/k/l",
370            desc: "Focus left / down / up / right pane",
371            is_header: false,
372        },
373        HelpEntry {
374            keys: "Ctrl+W < / >",
375            desc: "Resize split",
376            is_header: false,
377        },
378        HelpEntry {
379            keys: "Drag split edge",
380            desc: "Mouse-resize panes",
381            is_header: false,
382        },
383        HelpEntry {
384            keys: "",
385            desc: "Git",
386            is_header: true,
387        },
388        HelpEntry {
389            keys: "Ctrl+G",
390            desc: "Light Source Control (stage / commit)",
391            is_header: false,
392        },
393        HelpEntry {
394            keys: "Ctrl+Shift+G",
395            desc: "Full Git workbench (mini GitHub)",
396            is_header: false,
397        },
398        HelpEntry {
399            keys: "1–8  (in Git)",
400            desc: "Status / Log / Branches / Files / Diff / PRs / Issues / Auth",
401            is_header: false,
402        },
403        HelpEntry {
404            keys: "Tab  (in Git)",
405            desc: "Cycle Changes · Log · Files columns",
406            is_header: false,
407        },
408        HelpEntry {
409            keys: "Space / s  (Git Status)",
410            desc: "Stage / unstage selected file",
411            is_header: false,
412        },
413        HelpEntry {
414            keys: "c  (Git Status)",
415            desc: "Edit commit message · Enter commits",
416            is_header: false,
417        },
418        HelpEntry {
419            keys: "Enter  (Git)",
420            desc: "Open diff / commit detail / PR checkout",
421            is_header: false,
422        },
423        HelpEntry {
424            keys: "Right-click commit",
425            desc: "Cherry-pick / revert / copy hash / browse",
426            is_header: false,
427        },
428        HelpEntry {
429            keys: "v  (Git Log)",
430            desc: "Toggle list / graph view",
431            is_header: false,
432        },
433        HelpEntry {
434            keys: "f p u  (Git)",
435            desc: "Fetch / pull / push",
436            is_header: false,
437        },
438        HelpEntry {
439            keys: "r  (Git)",
440            desc: "Refresh current tab",
441            is_header: false,
442        },
443        HelpEntry {
444            keys: ":gh-login",
445            desc: "GitHub CLI auth (web)",
446            is_header: false,
447        },
448        HelpEntry {
449            keys: "",
450            desc: "Modes & editing",
451            is_header: true,
452        },
453        HelpEntry {
454            keys: "i a A o O",
455            desc: "Enter Insert mode",
456            is_header: false,
457        },
458        HelpEntry {
459            keys: "Esc",
460            desc: "Back to Normal mode",
461            is_header: false,
462        },
463        HelpEntry {
464            keys: "v / V / Ctrl+V",
465            desc: "Visual / Visual Line / Visual Block",
466            is_header: false,
467        },
468        HelpEntry {
469            keys: "h j k l · ←↓↑→",
470            desc: "Move cursor",
471            is_header: false,
472        },
473        HelpEntry {
474            keys: "w b e · 0 $ · gg G",
475            desc: "Word / line / file motions",
476            is_header: false,
477        },
478        HelpEntry {
479            keys: "d / c / y + motion",
480            desc: "Delete / change / yank",
481            is_header: false,
482        },
483        HelpEntry {
484            keys: "diw ci\" dib …",
485            desc: "Text objects (inner / around)",
486            is_header: false,
487        },
488        HelpEntry {
489            keys: "u / Ctrl+R",
490            desc: "Undo / Redo",
491            is_header: false,
492        },
493        HelpEntry {
494            keys: ".",
495            desc: "Repeat last change",
496            is_header: false,
497        },
498        HelpEntry {
499            keys: "p P",
500            desc: "Paste after / before",
501            is_header: false,
502        },
503        HelpEntry {
504            keys: "x",
505            desc: "Delete character",
506            is_header: false,
507        },
508        HelpEntry {
509            keys: "\"a  \"+ ",
510            desc: "Registers (named / clipboard)",
511            is_header: false,
512        },
513        HelpEntry {
514            keys: "ma  'a  `a",
515            desc: "Set mark / jump",
516            is_header: false,
517        },
518        HelpEntry {
519            keys: "Ctrl+O / Ctrl+I",
520            desc: "Jumplist back / forward",
521            is_header: false,
522        },
523        HelpEntry {
524            keys: "f t ; ,",
525            desc: "Find char; repeat / reverse",
526            is_header: false,
527        },
528        HelpEntry {
529            keys: "qa … q · @a · @@",
530            desc: "Record / play / replay macro",
531            is_header: false,
532        },
533        HelpEntry {
534            keys: "",
535            desc: "Search",
536            is_header: true,
537        },
538        HelpEntry {
539            keys: "/  ?",
540            desc: "Search forward / reverse",
541            is_header: false,
542        },
543        HelpEntry {
544            keys: "n  N",
545            desc: "Next / previous match",
546            is_header: false,
547        },
548        HelpEntry {
549            keys: "*  #",
550            desc: "Word under cursor (fwd / back)",
551            is_header: false,
552        },
553        HelpEntry {
554            keys: "",
555            desc: "LSP & diagnostics",
556            is_header: true,
557        },
558        HelpEntry {
559            keys: "gd",
560            desc: "Go to definition",
561            is_header: false,
562        },
563        HelpEntry {
564            keys: "gp",
565            desc: "Peek definition",
566            is_header: false,
567        },
568        HelpEntry {
569            keys: "gr",
570            desc: "Find references",
571            is_header: false,
572        },
573        HelpEntry {
574            keys: "K",
575            desc: "Hover documentation",
576            is_header: false,
577        },
578        HelpEntry {
579            keys: "Ctrl+A  (Insert)",
580            desc: "Completions (LSP + keywords)",
581            is_header: false,
582        },
583        HelpEntry {
584            keys: "]d  [d",
585            desc: "Next / prev diagnostic",
586            is_header: false,
587        },
588        HelpEntry {
589            keys: ":Rename name",
590            desc: "LSP rename",
591            is_header: false,
592        },
593        HelpEntry {
594            keys: "",
595            desc: "Tabs & buffers",
596            is_header: true,
597        },
598        HelpEntry {
599            keys: "gt  gT",
600            desc: "Next / previous tab",
601            is_header: false,
602        },
603        HelpEntry {
604            keys: ":e <file>",
605            desc: "Open file (new tab)",
606            is_header: false,
607        },
608        HelpEntry {
609            keys: ":bd",
610            desc: "Close current tab",
611            is_header: false,
612        },
613        HelpEntry {
614            keys: ":w  :q  :wq",
615            desc: "Save / quit / save+quit",
616            is_header: false,
617        },
618        HelpEntry {
619            keys: ":s/pat/repl/g",
620            desc: "Substitute on line / range",
621            is_header: false,
622        },
623        HelpEntry {
624            keys: ":theme <name>",
625            desc: "Switch theme (persists)",
626            is_header: false,
627        },
628        HelpEntry {
629            keys: ":help",
630            desc: "List XLC commands",
631            is_header: false,
632        },
633        HelpEntry {
634            keys: "",
635            desc: "Settings panel",
636            is_header: true,
637        },
638        HelpEntry {
639            keys: "Tab / Shift+Tab",
640            desc: "Next / previous page",
641            is_header: false,
642        },
643        HelpEntry {
644            keys: "1 2 3 4",
645            desc: "Jump About / Setting / Pet / Help",
646            is_header: false,
647        },
648        HelpEntry {
649            keys: "j k · Enter",
650            desc: "Move selection · activate",
651            is_header: false,
652        },
653        HelpEntry {
654            keys: "s",
655            desc: "Save ~/.xei.toml",
656            is_header: false,
657        },
658        HelpEntry {
659            keys: "Esc / q",
660            desc: "Close settings",
661            is_header: false,
662        },
663    ]
664}
665
666/// Setting-page row kinds (theme pickers + editor toggles).
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub enum SettingRow {
669    ThemeHeader,
670    Theme(usize),
671    EditorHeader,
672    TabWidth,
673    RelativeNumber,
674    WrapLines,
675    UndoCaching,
676    ClipboardSync,
677    GpuAcc,
678    GpuGraphics,
679    GpuHyperlinks,
680    KeyHints,
681    LspHeader,
682    LspEnabled,
683    /// Index into `config::lsp_lang_catalog()`
684    LspLang(usize),
685    GitHeader,
686    OpenWorkbench,
687    OpenScm,
688    /// Pet tab rows
689    PetEnabled,
690    PetPath,
691    PetX,
692    PetY,
693    PetWidth,
694    /// Playback speed (slider; h/l or Enter)
695    PetSpeed,
696    PetReload,
697}
698
699fn setting_rows() -> Vec<SettingRow> {
700    let mut rows = vec![SettingRow::ThemeHeader];
701    for i in 0..theme::all_themes().len() {
702        rows.push(SettingRow::Theme(i));
703    }
704    rows.push(SettingRow::EditorHeader);
705    rows.push(SettingRow::TabWidth);
706    rows.push(SettingRow::RelativeNumber);
707    rows.push(SettingRow::WrapLines);
708    rows.push(SettingRow::UndoCaching);
709    rows.push(SettingRow::ClipboardSync);
710    rows.push(SettingRow::GpuAcc);
711    rows.push(SettingRow::GpuGraphics);
712    rows.push(SettingRow::GpuHyperlinks);
713    rows.push(SettingRow::KeyHints);
714    rows.push(SettingRow::LspHeader);
715    rows.push(SettingRow::LspEnabled);
716    for i in 0..config::lsp_lang_catalog().len() {
717        rows.push(SettingRow::LspLang(i));
718    }
719    rows.push(SettingRow::GitHeader);
720    rows.push(SettingRow::OpenWorkbench);
721    rows.push(SettingRow::OpenScm);
722    rows
723}
724
725fn pet_rows() -> Vec<SettingRow> {
726    vec![
727        SettingRow::PetEnabled,
728        SettingRow::PetPath,
729        SettingRow::PetX,
730        SettingRow::PetY,
731        SettingRow::PetWidth,
732        SettingRow::PetSpeed,
733        SettingRow::PetReload,
734    ]
735}
736
737/// Discrete speed stops for Enter-cycle (percent).
738const PET_SPEED_STOPS: &[u16] = &[25, 50, 75, 100, 125, 150, 200, 300, 400];
739
740#[derive(Debug, Clone)]
741pub struct SettingsPanel {
742    pub open: bool,
743    pub page: SettingsPage,
744    /// Row selection within the current page (for toggles/lists).
745    pub selected: usize,
746    /// Working copy of config while the panel is open.
747    pub draft: Config,
748    pub dirty: bool,
749    pub status: Option<String>,
750}
751
752impl Default for SettingsPanel {
753    fn default() -> Self {
754        Self {
755            open: false,
756            page: SettingsPage::About,
757            selected: 0,
758            draft: Config::default(),
759            dirty: false,
760            status: None,
761        }
762    }
763}
764
765impl SettingsPanel {
766    pub fn new() -> Self {
767        Self::default()
768    }
769
770    pub fn open_panel(&mut self) {
771        self.open = true;
772        self.page = SettingsPage::About;
773        self.selected = 0;
774        self.draft = config::load();
775        self.dirty = false;
776        self.status = None;
777    }
778
779    pub fn close(&mut self) {
780        self.open = false;
781        self.status = None;
782    }
783
784    pub fn visible(&self) -> bool {
785        self.open
786    }
787
788    pub fn setting_rows(&self) -> Vec<SettingRow> {
789        setting_rows()
790    }
791
792    pub fn page_item_count(&self) -> usize {
793        match self.page {
794            SettingsPage::About => 0,
795            SettingsPage::Setting => setting_rows().len(),
796            SettingsPage::Pet => pet_rows().len(),
797            SettingsPage::Help => help_entries().len(),
798        }
799    }
800
801    pub fn pet_rows(&self) -> Vec<SettingRow> {
802        pet_rows()
803    }
804
805    /// Skip non-selectable header rows when moving selection on Setting page.
806    pub fn move_sel(&mut self, delta: isize) {
807        let n = self.page_item_count();
808        if n == 0 {
809            self.selected = 0;
810            return;
811        }
812        if self.page == SettingsPage::Setting {
813            let rows = setting_rows();
814            let is_header = |r: &SettingRow| {
815                matches!(
816                    r,
817                    SettingRow::ThemeHeader
818                        | SettingRow::EditorHeader
819                        | SettingRow::LspHeader
820                        | SettingRow::GitHeader
821                )
822            };
823            let mut cur = self.selected as isize;
824            let step = if delta >= 0 { 1 } else { -1 };
825            let mut left = delta.abs().max(1);
826            // If already on a header (shouldn't), step off first.
827            if cur >= 0 && (cur as usize) < rows.len() && is_header(&rows[cur as usize]) {
828                left = left.max(1);
829            }
830            while left > 0 {
831                let next = cur + step;
832                if next < 0 || next >= rows.len() as isize {
833                    // Stay on last valid non-header
834                    break;
835                }
836                cur = next;
837                if !is_header(&rows[cur as usize]) {
838                    left -= 1;
839                }
840            }
841            // Ensure we never rest on a header
842            if (cur as usize) < rows.len() && is_header(&rows[cur as usize]) {
843                if let Some((i, _)) = rows.iter().enumerate().find(|(_, r)| !is_header(r)) {
844                    cur = i as isize;
845                }
846            }
847            self.selected = cur as usize;
848            return;
849        }
850        // Help: skip section headers so selection lands on real shortcuts
851        if self.page == SettingsPage::Help {
852            let entries = help_entries();
853            let mut cur = self.selected as isize;
854            let step = if delta >= 0 { 1 } else { -1 };
855            let mut left = delta.abs().max(1);
856            while left > 0 {
857                let next = cur + step;
858                if next < 0 || next >= entries.len() as isize {
859                    break;
860                }
861                cur = next;
862                if !entries[cur as usize].is_header {
863                    left -= 1;
864                }
865            }
866            if (cur as usize) < entries.len() && entries[cur as usize].is_header {
867                if let Some((i, _)) = entries.iter().enumerate().find(|e| !e.1.is_header) {
868                    cur = i as isize;
869                }
870            }
871            self.selected = cur as usize;
872            return;
873        }
874        let cur = self.selected as isize + delta;
875        self.selected = cur.clamp(0, (n - 1) as isize) as usize;
876    }
877
878    pub fn next_page(&mut self) {
879        self.page = self.page.next();
880        self.selected = self.default_selected_for_page();
881        self.status = None;
882    }
883
884    pub fn prev_page(&mut self) {
885        self.page = self.page.prev();
886        self.selected = self.default_selected_for_page();
887        self.status = None;
888    }
889
890    fn default_selected_for_page(&self) -> usize {
891        match self.page {
892            SettingsPage::Setting => 1, // first theme row
893            SettingsPage::Pet => 0,
894            SettingsPage::Help => {
895                // First real shortcut (skip "General" header)
896                help_entries()
897                    .iter()
898                    .position(|e| !e.is_header)
899                    .unwrap_or(0)
900            }
901            SettingsPage::About => 0,
902        }
903    }
904
905    /// Activate / toggle the selected row. Returns optional UI action.
906    pub fn activate(&mut self) -> SettingsAction {
907        match self.page {
908            SettingsPage::About | SettingsPage::Help => SettingsAction::None,
909            SettingsPage::Pet => {
910                let rows = pet_rows();
911                let Some(row) = rows.get(self.selected).copied() else {
912                    return SettingsAction::None;
913                };
914                match row {
915                    SettingRow::PetEnabled => {
916                        self.draft.pet_enabled = !self.draft.pet_enabled;
917                        self.dirty = true;
918                        self.status = Some(if self.draft.pet_enabled {
919                            "pet = on  (requires gpu_acc + Kitty/Ghostty)".into()
920                        } else {
921                            "pet = off".into()
922                        });
923                        SettingsAction::ApplyPet
924                    }
925                    SettingRow::PetPath => {
926                        self.status = Some(
927                            "Set path: :pet ~/path/to/pet.gif  then s to save".into(),
928                        );
929                        SettingsAction::None
930                    }
931                    SettingRow::PetX => {
932                        // Bounds applied at event layer from terminal size.
933                        self.draft.pet_x = self.draft.pet_x.saturating_add(2);
934                        self.dirty = true;
935                        self.status = Some(format!("pet_x = {}", self.draft.pet_x));
936                        SettingsAction::ApplyPet
937                    }
938                    SettingRow::PetY => {
939                        self.draft.pet_y = self.draft.pet_y.saturating_add(1);
940                        self.dirty = true;
941                        self.status = Some(format!("pet_y = {}", self.draft.pet_y));
942                        SettingsAction::ApplyPet
943                    }
944                    SettingRow::PetWidth => {
945                        self.draft.pet_width_cells = match self.draft.pet_width_cells {
946                            4..=7 => 12,
947                            8..=15 => 20,
948                            _ => 8,
949                        };
950                        self.dirty = true;
951                        self.status =
952                            Some(format!("pet_width_cells = {}", self.draft.pet_width_cells));
953                        SettingsAction::ApplyPet
954                    }
955                    SettingRow::PetSpeed => {
956                        self.draft.pet_speed = next_pet_speed(self.draft.pet_speed);
957                        self.dirty = true;
958                        self.status = Some(format!(
959                            "pet_speed = {} ({})",
960                            self.draft.pet_speed,
961                            crate::pet::PetState::speed_label(self.draft.pet_speed)
962                        ));
963                        SettingsAction::ApplyPet
964                    }
965                    SettingRow::PetReload => {
966                        self.status = Some("Reloading pet…".into());
967                        SettingsAction::ApplyPet
968                    }
969                    _ => SettingsAction::None,
970                }
971            }
972            SettingsPage::Setting => {
973                let rows = setting_rows();
974                let Some(row) = rows.get(self.selected).copied() else {
975                    return SettingsAction::None;
976                };
977                match row {
978                    SettingRow::Theme(i) => {
979                        let themes = theme::all_themes();
980                        if let Some(t) = themes.get(i) {
981                            self.draft.theme = t.name.to_string();
982                            self.dirty = true;
983                            self.status = Some(format!("Theme → {}", t.name));
984                            return SettingsAction::ApplyTheme;
985                        }
986                    }
987                    SettingRow::TabWidth => {
988                        self.draft.tab_width = match self.draft.tab_width {
989                            2 => 4,
990                            4 => 8,
991                            _ => 2,
992                        };
993                        self.dirty = true;
994                        self.status = Some(format!("tab_width = {}", self.draft.tab_width));
995                    }
996                    SettingRow::RelativeNumber => {
997                        self.draft.relative_number = !self.draft.relative_number;
998                        self.dirty = true;
999                        self.status = Some(format!(
1000                            "relative_number = {}",
1001                            self.draft.relative_number
1002                        ));
1003                    }
1004                    SettingRow::UndoCaching => {
1005                        self.draft.undo_caching = !self.draft.undo_caching;
1006                        self.dirty = true;
1007                        self.status = Some(if self.draft.undo_caching {
1008                            "undo_caching = true  (history survives close · ~/.xei/undo)".into()
1009                        } else {
1010                            "undo_caching = false  (history discarded on close)".into()
1011                        });
1012                    }
1013                    SettingRow::WrapLines => {
1014                        self.draft.wrap_lines = !self.draft.wrap_lines;
1015                        self.dirty = true;
1016                        self.status = Some(if self.draft.wrap_lines {
1017                            "wrap_lines = true  (soft-wrap long lines)".into()
1018                        } else {
1019                            "wrap_lines = false  (horizontal scroll · zh/zl pan)".into()
1020                        });
1021                    }
1022                    SettingRow::ClipboardSync => {
1023                        self.draft.clipboard_sync = !self.draft.clipboard_sync;
1024                        self.dirty = true;
1025                        self.status = Some(format!(
1026                            "clipboard_sync = {}",
1027                            self.draft.clipboard_sync
1028                        ));
1029                    }
1030                    SettingRow::GpuAcc => {
1031                        self.draft.gpu_acc = !self.draft.gpu_acc;
1032                        self.dirty = true;
1033                        self.status = Some(if self.draft.gpu_acc {
1034                            "gpu_acc = true  (Ghostty/Kitty enhancements on)".into()
1035                        } else {
1036                            "gpu_acc = false  (plain cell TUI)".into()
1037                        });
1038                        return SettingsAction::ApplyGpuAcc;
1039                    }
1040                    SettingRow::GpuGraphics => {
1041                        self.draft.gpu_graphics = !self.draft.gpu_graphics;
1042                        self.dirty = true;
1043                        self.status = Some(format!("gpu_graphics = {}", self.draft.gpu_graphics));
1044                    }
1045                    SettingRow::GpuHyperlinks => {
1046                        self.draft.gpu_hyperlinks = !self.draft.gpu_hyperlinks;
1047                        self.dirty = true;
1048                        self.status =
1049                            Some(format!("gpu_hyperlinks = {}", self.draft.gpu_hyperlinks));
1050                    }
1051                    SettingRow::KeyHints => {
1052                        self.draft.key_hints = !self.draft.key_hints;
1053                        self.dirty = true;
1054                        self.status = Some(format!(
1055                            "key_hints = {}",
1056                            if self.draft.key_hints { "true" } else { "false" }
1057                        ));
1058                    }
1059                    SettingRow::LspEnabled => {
1060                        self.draft.lsp_enabled = !self.draft.lsp_enabled;
1061                        self.dirty = true;
1062                        self.status = Some(format!(
1063                            "lsp_enabled = {}",
1064                            self.draft.lsp_enabled
1065                        ));
1066                        return SettingsAction::ApplyLsp;
1067                    }
1068                    SettingRow::LspLang(i) => {
1069                        // Cycle: default → off → default
1070                        let catalog = config::lsp_lang_catalog();
1071                        if let Some((key, _label, default_cmd)) = catalog.get(i) {
1072                            let cur = self.draft.lsp_servers.get(*key).cloned();
1073                            match cur.as_deref() {
1074                                None => {
1075                                    // was default → turn off
1076                                    self.draft
1077                                        .lsp_servers
1078                                        .insert((*key).to_string(), String::new());
1079                                    self.status = Some(format!("lsp.{key} = off"));
1080                                }
1081                                Some("") => {
1082                                    // was off → restore default (remove override)
1083                                    self.draft.lsp_servers.remove(*key);
1084                                    self.status =
1085                                        Some(format!("lsp.{key} = default ({default_cmd})"));
1086                                }
1087                                Some(_) => {
1088                                    // custom → off
1089                                    self.draft
1090                                        .lsp_servers
1091                                        .insert((*key).to_string(), String::new());
1092                                    self.status = Some(format!("lsp.{key} = off"));
1093                                }
1094                            }
1095                            self.dirty = true;
1096                            return SettingsAction::ApplyLsp;
1097                        }
1098                    }
1099                    SettingRow::OpenWorkbench => {
1100                        return SettingsAction::OpenWorkbench;
1101                    }
1102                    SettingRow::OpenScm => {
1103                        return SettingsAction::OpenScm;
1104                    }
1105                    SettingRow::ThemeHeader
1106                    | SettingRow::EditorHeader
1107                    | SettingRow::LspHeader
1108                    | SettingRow::GitHeader
1109                    | SettingRow::PetEnabled
1110                    | SettingRow::PetPath
1111                    | SettingRow::PetX
1112                    | SettingRow::PetY
1113                    | SettingRow::PetWidth
1114                    | SettingRow::PetSpeed
1115                    | SettingRow::PetReload => {}
1116                }
1117                SettingsAction::None
1118            }
1119        }
1120    }
1121
1122    pub fn save(&mut self) {
1123        config::save(&self.draft);
1124        self.dirty = false;
1125        self.status = Some("Saved ~/.xei.toml".into());
1126    }
1127
1128    pub fn version_string() -> String {
1129        format!("xei {}", env!("CARGO_PKG_VERSION"))
1130    }
1131}
1132
1133/// Side-effect requested by settings activation (handled in app/event layer).
1134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1135pub enum SettingsAction {
1136    None,
1137    ApplyTheme,
1138    ApplyGpuAcc,
1139    ApplyLsp,
1140    ApplyPet,
1141    OpenWorkbench,
1142    OpenScm,
1143}
1144
1145fn next_pet_speed(cur: u16) -> u16 {
1146    for (i, &s) in PET_SPEED_STOPS.iter().enumerate() {
1147        if cur < s {
1148            return s;
1149        }
1150        if cur == s {
1151            return PET_SPEED_STOPS
1152                .get(i + 1)
1153                .copied()
1154                .unwrap_or(PET_SPEED_STOPS[0]);
1155        }
1156    }
1157    100
1158}
1159
1160fn prev_pet_speed(cur: u16) -> u16 {
1161    let mut prev = *PET_SPEED_STOPS.last().unwrap_or(&100);
1162    for &s in PET_SPEED_STOPS {
1163        if cur <= s {
1164            return prev;
1165        }
1166        prev = s;
1167    }
1168    prev
1169}
1170
1171impl SettingsPanel {
1172    /// Nudge pet position / speed with h/l or ←/→.
1173    /// `max_x` / `max_y` should be terminal size (so pet can reach the edges).
1174    pub fn nudge_pet(&mut self, dir: i16, max_x: u16, max_y: u16) -> SettingsAction {
1175        if self.page != SettingsPage::Pet {
1176            return SettingsAction::None;
1177        }
1178        let rows = pet_rows();
1179        let Some(row) = rows.get(self.selected).copied() else {
1180            return SettingsAction::None;
1181        };
1182        match row {
1183            SettingRow::PetX => {
1184                if dir < 0 {
1185                    self.draft.pet_x = self.draft.pet_x.saturating_sub(1);
1186                } else {
1187                    self.draft.pet_x = self.draft.pet_x.saturating_add(1).min(max_x);
1188                }
1189                self.dirty = true;
1190                self.status = Some(format!("pet_x = {}  (0..{max_x})", self.draft.pet_x));
1191                SettingsAction::ApplyPet
1192            }
1193            SettingRow::PetY => {
1194                if dir < 0 {
1195                    self.draft.pet_y = self.draft.pet_y.saturating_sub(1);
1196                } else {
1197                    self.draft.pet_y = self.draft.pet_y.saturating_add(1).min(max_y);
1198                }
1199                self.dirty = true;
1200                self.status = Some(format!("pet_y = {}  (0..{max_y})", self.draft.pet_y));
1201                SettingsAction::ApplyPet
1202            }
1203            SettingRow::PetWidth => {
1204                if dir < 0 {
1205                    self.draft.pet_width_cells =
1206                        self.draft.pet_width_cells.saturating_sub(1).max(4);
1207                } else {
1208                    self.draft.pet_width_cells =
1209                        self.draft.pet_width_cells.saturating_add(1).min(40);
1210                }
1211                self.dirty = true;
1212                self.status = Some(format!("pet_width_cells = {}", self.draft.pet_width_cells));
1213                SettingsAction::ApplyPet
1214            }
1215            SettingRow::PetSpeed => {
1216                self.draft.pet_speed = if dir < 0 {
1217                    prev_pet_speed(self.draft.pet_speed)
1218                } else {
1219                    next_pet_speed(self.draft.pet_speed)
1220                };
1221                self.dirty = true;
1222                self.status = Some(format!(
1223                    "pet_speed = {} ({})",
1224                    self.draft.pet_speed,
1225                    crate::pet::PetState::speed_label(self.draft.pet_speed)
1226                ));
1227                SettingsAction::ApplyPet
1228            }
1229            _ => SettingsAction::None,
1230        }
1231    }
1232}