1use std::io;
13use std::time::Duration;
14
15use anyhow::Result;
16use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
17use ratatui::prelude::*;
18use ratatui::widgets::*;
19
20use crate::tui::Tui;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Control {
29 Toggle,
31 Number,
33 Adapters,
35 AdapterDays,
41}
42
43#[derive(Debug, Clone)]
45pub struct ConfigRow {
46 pub key: &'static str,
47 pub help: &'static str,
48 pub control: Control,
49 pub value: String,
51 pub original: String,
53 pub is_new: bool,
55}
56
57impl ConfigRow {
58 pub fn changed(&self) -> bool {
59 self.value != self.original
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct DeclarationLine {
66 pub mark: char,
69 pub subject: String,
70 pub state: String,
71}
72
73pub enum Outcome {
75 Save(Vec<ConfigRow>),
77 KeepAll,
79 Cancelled,
81}
82
83pub struct ConfigSession<'a> {
85 pub declaration: Vec<DeclarationLine>,
87 pub standing: String,
89 pub rows: Vec<ConfigRow>,
90 pub adapters: &'a [&'static str],
92 pub opt_in_adapters: &'a [&'static str],
94 pub groups: &'a [(&'static str, &'static [&'static str])],
97 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
100 pub title: &'a str,
102}
103
104fn opening_index(rows: &[ConfigRow]) -> usize {
108 rows.iter().position(|r| r.is_new).unwrap_or(0)
109}
110
111#[derive(Debug, PartialEq, Eq, Clone, Copy)]
112enum Screen {
113 Declaration,
114 Settings,
115 Adapters,
116 Summary,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121enum PickerEntry {
122 Group {
125 label: &'static str,
126 members: Vec<usize>,
127 },
128 Adapter(usize),
130}
131
132fn build_entries(
139 adapters: &[&'static str],
140 groups: &[(&'static str, &'static [&'static str])],
141) -> Vec<PickerEntry> {
142 let mut entries = Vec::new();
143 let mut placed = vec![false; adapters.len()];
144
145 for (label, names) in groups {
146 let members: Vec<usize> = names
147 .iter()
148 .filter_map(|name| adapters.iter().position(|a| a == name))
149 .collect();
150 if members.is_empty() {
151 continue;
152 }
153 for &i in &members {
154 placed[i] = true;
155 }
156 entries.push(PickerEntry::Group {
157 label,
158 members: members.clone(),
159 });
160 entries.extend(members.into_iter().map(PickerEntry::Adapter));
161 }
162
163 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
164 if !rest.is_empty() {
165 entries.push(PickerEntry::Group {
166 label: "Other",
167 members: rest.clone(),
168 });
169 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
170 }
171 entries
172}
173
174fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
176 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
177}
178
179fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
181 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
182 row.value = value;
183 }
184}
185
186struct State<'a> {
187 session: ConfigSession<'a>,
188 screen: Screen,
189 list: ListState,
190 editing: Option<String>,
192 error: Option<String>,
194 picker_active: Vec<bool>,
196 picker_days: Vec<Option<u64>>,
198 picker_entries: Vec<PickerEntry>,
202 picker_editing: Option<String>,
204 picker_list: ListState,
205 decl_list: ListState,
207}
208
209pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
211 if session.rows.is_empty() {
212 return Ok(Outcome::KeepAll);
213 }
214
215 let mut list = ListState::default();
216 list.select(Some(opening_index(&session.rows)));
217
218 let mut picker_list = ListState::default();
219 picker_list.select(Some(0));
220
221 let mut decl_list = ListState::default();
222 decl_list.select(Some(0));
223
224 let mut state = State {
225 picker_active: vec![true; session.adapters.len()],
226 picker_days: vec![None; session.adapters.len()],
227 picker_entries: Vec::new(),
228 picker_editing: None,
229 session,
230 screen: Screen::Declaration,
231 list,
232 editing: None,
233 error: None,
234 picker_list,
235 decl_list,
236 };
237
238 let mut tui = Tui::new()?;
241 tui.drain_stale_input(Duration::from_millis(300));
242 event_loop(&mut tui.terminal, &mut state)
243}
244
245fn event_loop(
246 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
247 state: &mut State<'_>,
248) -> Result<Outcome> {
249 loop {
250 terminal.draw(|frame| render(frame, state))?;
251
252 if !event::poll(Duration::from_millis(100))? {
253 continue;
254 }
255 let Event::Key(key) = event::read()? else {
256 continue;
257 };
258 if key.kind == KeyEventKind::Release {
261 continue;
262 }
263 if key.modifiers.contains(KeyModifiers::CONTROL)
266 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
267 {
268 return Ok(Outcome::Cancelled);
269 }
270
271 if let Some(outcome) = handle_key(state, key.code) {
272 return Ok(outcome);
273 }
274 }
275}
276
277fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
279 match state.screen {
280 Screen::Declaration => declaration_key(state, code),
281 Screen::Settings => settings_key(state, code),
282 Screen::Adapters => adapters_key(state, code),
283 Screen::Summary => summary_key(state, code),
284 }
285}
286
287fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
288 let len = state.session.declaration.len().max(1);
289 let current = state.decl_list.selected().unwrap_or(0);
290 match code {
291 KeyCode::Up | KeyCode::Char('k') => {
293 state.decl_list.select(Some(current.saturating_sub(1)));
294 None
295 }
296 KeyCode::Down | KeyCode::Char('j') => {
297 state.decl_list.select(Some((current + 1).min(len - 1)));
298 None
299 }
300 KeyCode::Char('y') | KeyCode::Char('Y') => Some(Outcome::KeepAll),
303 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
304 state.screen = Screen::Settings;
305 None
306 }
307 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
308 _ => None,
309 }
310}
311
312fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
313 if state.editing.is_some() {
315 return number_edit_key(state, code);
316 }
317
318 let len = state.session.rows.len();
319 let current = state.list.selected().unwrap_or(0);
320 match code {
321 KeyCode::Up | KeyCode::Char('k') => {
322 state.error = None;
323 state
324 .list
325 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
326 }
327 KeyCode::Down | KeyCode::Char('j') => {
328 state.error = None;
329 state
330 .list
331 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
332 }
333 KeyCode::Home | KeyCode::Char('g') => state.list.select(Some(0)),
334 KeyCode::End | KeyCode::Char('G') => state.list.select(Some(len - 1)),
335 KeyCode::Char(' ') | KeyCode::Enter => activate(state, current),
336 KeyCode::Char('r') | KeyCode::Char('R') => {
337 state.error = None;
338 let row = &mut state.session.rows[current];
339 row.value = row.original.clone();
340 }
341 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') | KeyCode::Char('S') => {
342 state.screen = Screen::Summary;
343 }
344 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
345 _ => {}
346 }
347 None
348}
349
350fn activate(state: &mut State<'_>, index: usize) {
352 state.error = None;
353 match state.session.rows[index].control {
354 Control::Toggle => {
355 let row = &mut state.session.rows[index];
356 row.value = if row.value == "true" {
357 "false".to_string()
358 } else {
359 "true".to_string()
360 };
361 }
362 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
363 Control::Adapters | Control::AdapterDays => open_picker(state),
364 }
365}
366
367fn open_picker(state: &mut State<'_>) {
373 let rows = &state.session.rows;
374 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
375 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
376
377 state.picker_active = state
378 .session
379 .adapters
380 .iter()
381 .map(|name| {
382 if disabled.iter().any(|d| d == name) {
383 return false;
384 }
385 if state.session.opt_in_adapters.contains(name) {
389 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
390 }
391 true
392 })
393 .collect();
394 state.picker_days = state
395 .session
396 .adapters
397 .iter()
398 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
399 .collect();
400
401 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
402 state.picker_editing = None;
403 state.picker_list.select(Some(0));
404 state.screen = Screen::Adapters;
405}
406
407fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
408 let index = state.list.selected().unwrap_or(0);
409 match code {
410 KeyCode::Char(c) if c.is_ascii_digit() => {
411 if let Some(buf) = state.editing.as_mut() {
412 buf.push(c);
413 }
414 }
415 KeyCode::Backspace => {
416 if let Some(buf) = state.editing.as_mut() {
417 buf.pop();
418 }
419 }
420 KeyCode::Enter => {
421 let typed = state.editing.clone().unwrap_or_default();
422 let key = state.session.rows[index].key;
423 match (state.session.validate)(key, typed.trim()) {
424 Ok(()) => {
425 state.session.rows[index].value = typed.trim().to_string();
426 state.editing = None;
427 state.error = None;
428 }
429 Err(why) => state.error = Some(why),
432 }
433 }
434 KeyCode::Esc => {
435 state.editing = None;
436 state.error = None;
437 }
438 _ => {}
439 }
440 None
441}
442
443fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
444 if state.picker_editing.is_some() {
445 return picker_days_key(state, code);
446 }
447
448 let len = state.picker_entries.len().max(1);
449 let current = state.picker_list.selected().unwrap_or(0);
450 match code {
451 KeyCode::Up | KeyCode::Char('k') => {
452 state.error = None;
453 state
454 .picker_list
455 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
456 }
457 KeyCode::Down | KeyCode::Char('j') => {
458 state.error = None;
459 state
460 .picker_list
461 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
462 }
463 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
466 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
467 Some(PickerEntry::Group { members, .. }) => {
468 let target = !members.iter().any(|&i| state.picker_active[i]);
469 for i in members {
470 state.picker_active[i] = target;
471 }
472 }
473 None => {}
474 },
475 KeyCode::Char('d') | KeyCode::Char('D') => {
476 state.error = None;
477 let seed = match state.picker_entries.get(current) {
478 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
479 Some(PickerEntry::Group { members, .. }) => {
482 let first = members.first().and_then(|&i| state.picker_days[i]);
483 if members.iter().all(|&i| state.picker_days[i] == first) {
484 first
485 } else {
486 None
487 }
488 }
489 None => None,
490 };
491 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
492 }
493 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
494 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
495 KeyCode::Enter => commit_picker(state),
496 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
497 _ => {}
498 }
499 None
500}
501
502fn picker_days_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
505 let current = state.picker_list.selected().unwrap_or(0);
506 match code {
507 KeyCode::Char(c) if c.is_ascii_digit() => {
508 if let Some(buf) = state.picker_editing.as_mut() {
509 buf.push(c);
510 }
511 }
512 KeyCode::Backspace => {
513 if let Some(buf) = state.picker_editing.as_mut() {
514 buf.pop();
515 }
516 }
517 KeyCode::Enter => {
518 let typed = state.picker_editing.clone().unwrap_or_default();
519 let typed = typed.trim().to_string();
520 let targets: Vec<usize> = match state.picker_entries.get(current) {
521 Some(PickerEntry::Adapter(i)) => vec![*i],
522 Some(PickerEntry::Group { members, .. }) => members.clone(),
523 None => Vec::new(),
524 };
525 let value = if typed.is_empty() {
526 None
527 } else {
528 let Some(&first) = targets.first() else {
529 state.picker_editing = None;
530 return None;
531 };
532 let probe = format!("{}={typed}", state.session.adapters[first]);
533 if let Err(why) = (state.session.validate)("adapter_idle_days", &probe) {
536 state.error = Some(why);
537 return None;
538 }
539 typed.parse::<u64>().ok()
540 };
541 for i in targets {
542 state.picker_days[i] = value;
543 }
544 state.picker_editing = None;
545 state.error = None;
546 }
547 KeyCode::Esc => {
548 state.picker_editing = None;
549 state.error = None;
550 }
551 _ => {}
552 }
553 None
554}
555
556fn commit_picker(state: &mut State<'_>) {
562 let adapters = state.session.adapters;
563 let opt_in = state.session.opt_in_adapters;
564
565 let disabled: Vec<&str> = adapters
566 .iter()
567 .enumerate()
568 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
569 .map(|(_, name)| *name)
570 .collect();
571 let disabled = if disabled.is_empty() {
574 "(none)".to_string()
575 } else {
576 disabled.join(",")
577 };
578
579 let days: Vec<String> = adapters
580 .iter()
581 .enumerate()
582 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
583 .collect();
584 let days = if days.is_empty() {
585 "(none)".to_string()
586 } else {
587 days.join(",")
588 };
589
590 let switches: Vec<(String, String)> = adapters
591 .iter()
592 .enumerate()
593 .filter(|(_, name)| opt_in.contains(name))
594 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
595 .collect();
596
597 let rows = &mut state.session.rows;
598 set_row(rows, "disabled_adapters", disabled);
599 set_row(rows, "adapter_idle_days", days);
600 for (key, value) in switches {
601 set_row(rows, &key, value);
602 }
603 state.screen = Screen::Settings;
604}
605
606fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
607 match code {
608 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') => {
609 let changed: Vec<ConfigRow> = state
610 .session
611 .rows
612 .iter()
613 .filter(|r| r.changed())
614 .cloned()
615 .collect();
616 if changed.is_empty() {
617 Some(Outcome::KeepAll)
618 } else {
619 Some(Outcome::Save(changed))
620 }
621 }
622 KeyCode::Esc | KeyCode::Backspace => {
623 state.screen = Screen::Settings;
624 None
625 }
626 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
627 _ => None,
628 }
629}
630
631fn parse_list(value: &str) -> Vec<String> {
633 if value.trim().eq_ignore_ascii_case("(none)") {
634 return Vec::new();
635 }
636 value
637 .split(',')
638 .map(|s| s.trim().to_lowercase())
639 .filter(|s| !s.is_empty())
640 .collect()
641}
642
643fn parse_days(value: &str) -> Vec<(String, u64)> {
649 if value.trim().eq_ignore_ascii_case("(none)") {
650 return Vec::new();
651 }
652 value
653 .split(',')
654 .filter_map(|entry| {
655 let (name, days) = entry.trim().split_once('=')?;
656 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
657 })
658 .collect()
659}
660
661fn render(frame: &mut Frame, state: &mut State<'_>) {
666 match state.screen {
667 Screen::Declaration => render_declaration(frame, state),
668 Screen::Settings => render_settings(frame, state),
669 Screen::Adapters => render_adapters(frame, state),
670 Screen::Summary => render_summary(frame, state),
671 }
672}
673
674fn dim() -> Style {
675 Style::default().fg(Color::DarkGray)
676}
677
678fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
679 Paragraph::new(vec![
680 Line::from(Span::styled(
681 title.to_string(),
682 Style::default()
683 .fg(Color::Cyan)
684 .add_modifier(Modifier::BOLD),
685 )),
686 Line::from(Span::styled(subtitle.to_string(), dim())),
687 ])
688}
689
690fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
691 let mut spans = Vec::new();
692 for (i, (key, what)) in keys.iter().enumerate() {
693 if i > 0 {
694 spans.push(Span::styled(" ", dim()));
695 }
696 spans.push(Span::styled(
697 key.to_string(),
698 Style::default().add_modifier(Modifier::BOLD),
699 ));
700 spans.push(Span::styled(format!(" {what}"), dim()));
701 }
702 Paragraph::new(Line::from(spans))
703 .block(Block::default().borders(Borders::TOP).border_style(dim()))
704}
705
706fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
707 let chunks = Layout::vertical([
708 Constraint::Length(3),
709 Constraint::Min(5),
710 Constraint::Length(3),
711 Constraint::Length(2),
712 ])
713 .split(frame.area());
714
715 frame.render_widget(
716 header(
717 state.session.title,
718 "What this tool is allowed to do on this machine, before it does any of it.",
719 ),
720 chunks[0],
721 );
722
723 let items: Vec<ListItem> = state
724 .session
725 .declaration
726 .iter()
727 .map(|d| {
728 if d.mark == '#' {
729 return ListItem::new(Line::from(Span::styled(
730 format!(" {}", d.subject),
731 Style::default()
732 .fg(Color::Cyan)
733 .add_modifier(Modifier::BOLD),
734 )));
735 }
736 let (mark_style, symbol) = match d.mark {
737 '!' => (Style::default().fg(Color::Yellow), "!"),
738 '+' => (Style::default().fg(Color::Green), "✓"),
739 _ => (dim(), " "),
740 };
741 ListItem::new(Line::from(vec![
742 Span::styled(format!(" {symbol} "), mark_style),
743 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
744 Span::styled(d.state.clone(), dim()),
745 ]))
746 })
747 .collect();
748
749 frame.render_stateful_widget(
752 List::new(items).block(
753 Block::default()
754 .title(" Declaration ")
755 .borders(Borders::ALL)
756 .border_style(dim()),
757 ),
758 chunks[1],
759 &mut state.decl_list,
760 );
761
762 frame.render_widget(
763 Paragraph::new(Line::from(Span::styled(
764 format!(" {}", state.session.standing),
765 Style::default().fg(Color::Green),
766 )))
767 .block(Block::default().borders(Borders::ALL).border_style(dim())),
768 chunks[2],
769 );
770
771 frame.render_widget(
772 footer(&[
773 ("↑↓", "read"),
774 ("y", "keep all defaults and go"),
775 ("Enter", "configure"),
776 ("q", "cancel"),
777 ]),
778 chunks[3],
779 );
780}
781
782fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
783 let chunks = Layout::vertical([
784 Constraint::Length(3),
785 Constraint::Min(5),
786 Constraint::Length(4),
787 Constraint::Length(2),
788 ])
789 .split(frame.area());
790
791 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
792 let new = state.session.rows.iter().filter(|r| r.is_new).count();
793 let subtitle = match (changed, new) {
794 (0, 0) => "Nothing changed yet.".to_string(),
795 (c, 0) => format!("{c} changed."),
796 (0, n) => format!("{n} new in this version."),
797 (c, n) => format!("{c} changed, {n} new in this version."),
798 };
799 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
800
801 let selected = state.list.selected();
802 let items: Vec<ListItem> = state
803 .session
804 .rows
805 .iter()
806 .enumerate()
807 .map(|(i, row)| {
808 let control = match row.control {
809 Control::Toggle if row.value == "true" => Span::styled(
810 "[x] ",
811 Style::default()
812 .fg(Color::Green)
813 .add_modifier(Modifier::BOLD),
814 ),
815 Control::Toggle => Span::styled("[ ] ", dim()),
816 Control::Number => Span::styled("123 ", dim()),
817 Control::Adapters | Control::AdapterDays => Span::styled("••• ", dim()),
818 };
819
820 let shown = if state.editing.is_some() && selected == Some(i) {
821 format!("{}_", state.editing.clone().unwrap_or_default())
822 } else {
823 row.value.clone()
824 };
825
826 let mut spans = vec![
827 control,
828 Span::styled(
829 crate::output::pad_display(row.key, 28),
830 if selected == Some(i) {
831 Style::default().fg(Color::White)
832 } else {
833 Style::default()
834 },
835 ),
836 Span::styled(
837 crate::output::pad_display(&shown, 20),
838 if row.changed() {
839 Style::default().fg(Color::Yellow)
840 } else {
841 Style::default().fg(Color::Cyan)
842 },
843 ),
844 ];
845 if row.is_new {
846 spans.push(Span::styled(
847 "NEW ",
848 Style::default()
849 .fg(Color::Magenta)
850 .add_modifier(Modifier::BOLD),
851 ));
852 }
853 if row.changed() {
854 spans.push(Span::styled(format!("was {}", row.original), dim()));
855 }
856 ListItem::new(Line::from(spans))
857 })
858 .collect();
859
860 let list = List::new(items)
861 .block(
862 Block::default()
863 .title(" Settings ")
864 .borders(Borders::ALL)
865 .border_style(dim()),
866 )
867 .highlight_style(
868 Style::default()
869 .bg(Color::Rgb(30, 40, 60))
870 .add_modifier(Modifier::BOLD),
871 )
872 .highlight_symbol("▶ ");
873 frame.render_stateful_widget(list, chunks[1], &mut state.list);
874
875 let index = selected.unwrap_or(0);
878 let row = &state.session.rows[index];
879 let mut detail = vec![Line::from(Span::styled(format!(" {}", row.help), dim()))];
880 if row.is_new {
881 detail.push(Line::from(Span::styled(
882 " New in this version — it has been applying its default since the upgrade.",
883 Style::default().fg(Color::Magenta),
884 )));
885 }
886 if let Some(why) = &state.error {
887 detail.push(Line::from(Span::styled(
888 format!(" {why}"),
889 Style::default().fg(Color::Red),
890 )));
891 }
892 frame.render_widget(
893 Paragraph::new(detail)
894 .wrap(Wrap { trim: true })
895 .block(Block::default().borders(Borders::ALL).border_style(dim())),
896 chunks[2],
897 );
898
899 let keys: &[(&str, &str)] = if state.editing.is_some() {
900 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
901 } else {
902 &[
903 ("↑↓", "move"),
904 ("Space", "change"),
905 ("r", "reset"),
906 ("y", "done"),
907 ("q", "cancel"),
908 ]
909 };
910 frame.render_widget(footer(keys), chunks[3]);
911}
912
913fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
914 let chunks = Layout::vertical([
915 Constraint::Length(3),
916 Constraint::Min(5),
917 Constraint::Length(4),
918 Constraint::Length(2),
919 ])
920 .split(frame.area());
921
922 let off = state.picker_active.iter().filter(|a| !**a).count();
923 frame.render_widget(
924 header(
925 "Adapters",
926 &format!(
927 "Unchecked adapters are left alone entirely — not scanned, not counted, \
928 not pruned. {off} off.",
929 ),
930 ),
931 chunks[0],
932 );
933
934 let selected = state.picker_list.selected();
935 let items: Vec<ListItem> = state
936 .picker_entries
937 .iter()
938 .enumerate()
939 .map(|(line, entry)| match entry {
940 PickerEntry::Group { label, members } => {
941 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
942 let mark = if on == members.len() {
943 "[x]"
944 } else if on == 0 {
945 "[ ]"
946 } else {
947 "[-]"
950 };
951 let shared = members.first().and_then(|&i| state.picker_days[i]);
952 let window = if state.picker_editing.is_some() && selected == Some(line) {
955 format!("{}_", state.picker_editing.clone().unwrap_or_default())
956 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
957 shared.map(|d| format!("{d}d")).unwrap_or_default()
958 } else {
959 "mixed".to_string()
960 };
961 ListItem::new(Line::from(vec![
962 Span::styled(
963 format!("{mark} {}", crate::output::pad_display(label, 22)),
964 Style::default()
965 .fg(Color::Cyan)
966 .add_modifier(Modifier::BOLD),
967 ),
968 Span::styled(
969 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
970 dim(),
971 ),
972 Span::styled(window, dim()),
973 ]))
974 }
975 PickerEntry::Adapter(i) => {
976 let name = state.session.adapters[*i];
977 let shown = if state.picker_editing.is_some() && selected == Some(line) {
978 format!("{}_", state.picker_editing.clone().unwrap_or_default())
979 } else {
980 state.picker_days[*i]
981 .map(|d| format!("{d}d"))
982 .unwrap_or_else(|| "default".to_string())
983 };
984 let mut spans = vec![
985 if state.picker_active[*i] {
986 Span::styled(
987 " [x] ",
988 Style::default()
989 .fg(Color::Green)
990 .add_modifier(Modifier::BOLD),
991 )
992 } else {
993 Span::styled(" [ ] ", dim())
994 },
995 Span::styled(crate::output::pad_display(name, 18), Style::default()),
996 Span::styled(
997 crate::output::pad_display(&shown, 10),
998 if state.picker_days[*i].is_some() {
999 Style::default().fg(Color::Yellow)
1000 } else {
1001 dim()
1002 },
1003 ),
1004 ];
1005 if state.session.opt_in_adapters.contains(&name) {
1006 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1010 }
1011 ListItem::new(Line::from(spans))
1012 }
1013 })
1014 .collect();
1015
1016 let list = List::new(items)
1017 .block(
1018 Block::default()
1019 .title(" Checked adapters stay active ")
1020 .borders(Borders::ALL)
1021 .border_style(dim()),
1022 )
1023 .highlight_style(
1024 Style::default()
1025 .bg(Color::Rgb(30, 40, 60))
1026 .add_modifier(Modifier::BOLD),
1027 )
1028 .highlight_symbol("▶ ");
1029 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1030
1031 let mut detail = vec![Line::from(Span::styled(
1032 " Space toggles one adapter, or a whole language from its heading. d sets how \
1033 many days that adapter — or that language — must be idle first; an empty value \
1034 puts it back on the global window.",
1035 dim(),
1036 ))];
1037 if let Some(why) = &state.error {
1038 detail.push(Line::from(Span::styled(
1039 format!(" {why}"),
1040 Style::default().fg(Color::Red),
1041 )));
1042 }
1043 frame.render_widget(
1044 Paragraph::new(detail)
1045 .wrap(Wrap { trim: true })
1046 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1047 chunks[2],
1048 );
1049
1050 let keys: &[(&str, &str)] = if state.picker_editing.is_some() {
1051 &[
1052 ("digits", "days"),
1053 ("Enter", "accept"),
1054 ("empty", "use the global window"),
1055 ("Esc", "abandon"),
1056 ]
1057 } else {
1058 &[
1059 ("↑↓", "move"),
1060 ("Space", "toggle"),
1061 ("d", "idle days"),
1062 ("a", "all on"),
1063 ("n", "all off"),
1064 ("Enter", "accept"),
1065 ("Esc", "back"),
1066 ]
1067 };
1068 frame.render_widget(footer(keys), chunks[3]);
1069}
1070
1071fn render_summary(frame: &mut Frame, state: &State<'_>) {
1072 let chunks = Layout::vertical([
1073 Constraint::Length(3),
1074 Constraint::Min(5),
1075 Constraint::Length(2),
1076 ])
1077 .split(frame.area());
1078
1079 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1080 frame.render_widget(
1081 header(
1082 "Summary",
1083 if changed.is_empty() {
1084 "Nothing changed. The defaults stay in place."
1085 } else {
1086 "These are the only values that will be written."
1087 },
1088 ),
1089 chunks[0],
1090 );
1091
1092 let mut lines: Vec<Line> = changed
1093 .iter()
1094 .map(|row| {
1095 Line::from(vec![
1096 Span::styled(
1097 format!(" {}", crate::output::pad_display(row.key, 28)),
1098 Style::default(),
1099 ),
1100 Span::styled(row.original.clone(), dim()),
1101 Span::styled(" → ", dim()),
1102 Span::styled(
1103 row.value.clone(),
1104 Style::default()
1105 .fg(Color::Yellow)
1106 .add_modifier(Modifier::BOLD),
1107 ),
1108 ])
1109 })
1110 .collect();
1111 if lines.is_empty() {
1112 lines.push(Line::from(Span::styled(
1113 " Every setting is still at the value it had when this opened.",
1114 dim(),
1115 )));
1116 }
1117 lines.push(Line::from(""));
1118 lines.push(Line::from(Span::styled(
1119 format!(" {}", state.session.standing),
1120 Style::default().fg(Color::Green),
1121 )));
1122
1123 frame.render_widget(
1124 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1125 Block::default()
1126 .title(" About to be saved ")
1127 .borders(Borders::ALL)
1128 .border_style(dim()),
1129 ),
1130 chunks[1],
1131 );
1132
1133 frame.render_widget(
1134 footer(&[
1135 ("Enter", "save"),
1136 ("Esc", "back"),
1137 ("q", "discard everything"),
1138 ]),
1139 chunks[2],
1140 );
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146
1147 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1148 ConfigRow {
1149 key,
1150 help: "help",
1151 control,
1152 value: value.to_string(),
1153 original: value.to_string(),
1154 is_new: false,
1155 }
1156 }
1157
1158 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1159 ConfigSession {
1160 declaration: Vec::new(),
1161 standing: String::new(),
1162 rows,
1163 adapters,
1164 opt_in_adapters: &[],
1165 groups: &[("Test", &["npm", "cargo", "go"])],
1166 validate: &|key, v| {
1167 let number = if key == "adapter_idle_days" {
1170 v.split_once('=').map(|(_, d)| d).unwrap_or("")
1171 } else {
1172 v
1173 };
1174 number
1175 .parse::<u64>()
1176 .map(|_| ())
1177 .map_err(|_| "not a number".to_string())
1178 },
1179 title: "test",
1180 }
1181 }
1182
1183 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
1184 let mut list = ListState::default();
1185 list.select(Some(0));
1186 let mut picker_list = ListState::default();
1187 picker_list.select(Some(0));
1188 State {
1189 picker_active: vec![true; s.adapters.len()],
1190 picker_days: vec![None; s.adapters.len()],
1191 picker_entries: build_entries(s.adapters, s.groups),
1192 picker_editing: None,
1193 session: s,
1194 screen: Screen::Settings,
1195 list,
1196 editing: None,
1197 error: None,
1198 picker_list,
1199 decl_list: ListState::default(),
1200 }
1201 }
1202
1203 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
1208 st.screen = screen;
1209 let mut terminal =
1210 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
1211 terminal.draw(|frame| render(frame, st)).expect("draw");
1212 terminal
1213 .backend()
1214 .buffer()
1215 .content()
1216 .iter()
1217 .map(|cell| cell.symbol())
1218 .collect()
1219 }
1220
1221 #[test]
1222 fn every_screen_draws() {
1223 let adapters: &[&'static str] = &["npm", "cargo"];
1224 let mut st = state(session(
1225 vec![
1226 row("idle_days", Control::Number, "14"),
1227 row("disabled_adapters", Control::Adapters, "(none)"),
1228 ],
1229 adapters,
1230 ));
1231 st.session.declaration.push(DeclarationLine {
1232 mark: '+',
1233 subject: "Lockfile verification".to_string(),
1234 state: "Required before every delete".to_string(),
1235 });
1236 st.session.standing = "Nothing has been deleted.".to_string();
1237
1238 let decl = screenshot(&mut st, Screen::Declaration);
1239 assert!(decl.contains("Lockfile verification"));
1240 assert!(decl.contains("Nothing has been deleted."));
1241
1242 let settings = screenshot(&mut st, Screen::Settings);
1243 assert!(settings.contains("idle_days"));
1244
1245 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
1246 st.picker_days[1] = Some(45);
1247 let picker = screenshot(&mut st, Screen::Adapters);
1248 assert!(picker.contains("cargo"));
1249 assert!(picker.contains("Test"), "the language heading is missing");
1250 assert!(picker.contains("45d"), "the idle window is missing");
1251
1252 let summary = screenshot(&mut st, Screen::Summary);
1255 assert!(summary.contains("still at the value"));
1256 }
1257
1258 #[test]
1259 fn y_on_the_declaration_still_means_yes_to_everything() {
1260 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1263 st.screen = Screen::Declaration;
1264 assert!(matches!(
1265 handle_key(&mut st, KeyCode::Char('y')),
1266 Some(Outcome::KeepAll)
1267 ));
1268 }
1269
1270 #[test]
1271 fn a_refused_value_is_not_stored() {
1272 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1273 handle_key(&mut st, KeyCode::Enter); handle_key(&mut st, KeyCode::Backspace);
1275 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
1277 assert_eq!(st.session.rows[0].value, "14");
1278 assert!(st.error.is_some(), "the reason was not shown");
1279 assert!(st.editing.is_some(), "the editor closed on a refusal");
1280 }
1281
1282 #[test]
1283 fn an_accepted_value_replaces_the_old_one() {
1284 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1285 handle_key(&mut st, KeyCode::Enter);
1286 handle_key(&mut st, KeyCode::Backspace);
1287 handle_key(&mut st, KeyCode::Backspace);
1288 handle_key(&mut st, KeyCode::Char('3'));
1289 handle_key(&mut st, KeyCode::Char('0'));
1290 handle_key(&mut st, KeyCode::Enter);
1291 assert_eq!(st.session.rows[0].value, "30");
1292 assert!(st.session.rows[0].changed());
1293 }
1294
1295 #[test]
1296 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
1297 let adapters: &[&'static str] = &["npm", "cargo", "go"];
1298 let mut st = state(session(
1299 vec![row("disabled_adapters", Control::Adapters, "(none)")],
1300 adapters,
1301 ));
1302 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.screen, Screen::Adapters);
1304 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
1307 handle_key(&mut st, KeyCode::Enter);
1308 assert_eq!(st.session.rows[0].value, "cargo");
1309 assert_eq!(st.screen, Screen::Settings);
1310 }
1311
1312 #[test]
1313 fn every_adapter_appears_under_exactly_one_heading() {
1314 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
1317 let groups: &[(&'static str, &'static [&'static str])] =
1318 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1319 let entries = build_entries(adapters, groups);
1320 let headings: Vec<&str> = entries
1321 .iter()
1322 .filter_map(|e| match e {
1323 PickerEntry::Group { label, .. } => Some(*label),
1324 PickerEntry::Adapter(_) => None,
1325 })
1326 .collect();
1327 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
1328
1329 let mut listed: Vec<usize> = entries
1330 .iter()
1331 .filter_map(|e| match e {
1332 PickerEntry::Adapter(i) => Some(*i),
1333 PickerEntry::Group { .. } => None,
1334 })
1335 .collect();
1336 listed.sort_unstable();
1337 assert_eq!(
1338 listed,
1339 vec![0, 1, 2],
1340 "an adapter was dropped from the list"
1341 );
1342 }
1343
1344 #[test]
1345 fn a_heading_turns_its_whole_language_off_in_one_press() {
1346 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1347 let mut st = state(session(
1348 vec![row("disabled_adapters", Control::Adapters, "(none)")],
1349 adapters,
1350 ));
1351 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1352 handle_key(&mut st, KeyCode::Enter);
1353 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
1355 handle_key(&mut st, KeyCode::Char(' '));
1358 assert_eq!(st.picker_active, vec![true, true, true]);
1359 }
1360
1361 #[test]
1362 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
1363 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1364 let mut st = state(session(
1365 vec![
1366 row("disabled_adapters", Control::Adapters, "(none)"),
1367 row("adapter_idle_days", Control::AdapterDays, "(none)"),
1368 ],
1369 adapters,
1370 ));
1371 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1372 handle_key(&mut st, KeyCode::Enter);
1373 handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
1375 handle_key(&mut st, KeyCode::Char('0'));
1376 handle_key(&mut st, KeyCode::Enter);
1377 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
1378
1379 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
1381
1382 handle_key(&mut st, KeyCode::Enter);
1385 handle_key(&mut st, KeyCode::Char('d'));
1386 handle_key(&mut st, KeyCode::Backspace);
1387 handle_key(&mut st, KeyCode::Backspace);
1388 handle_key(&mut st, KeyCode::Enter);
1389 handle_key(&mut st, KeyCode::Enter);
1390 assert_eq!(st.session.rows[1].value, "(none)");
1391 }
1392
1393 #[test]
1394 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
1395 let adapters: &[&'static str] = &["npm", "cargo"];
1398 let opt_in: &[&'static str] = &["cargo"];
1399 let mut st = state(session(
1400 vec![
1401 row("disabled_adapters", Control::Adapters, "(none)"),
1402 row("enable_cargo", Control::Toggle, "false"),
1403 ],
1404 adapters,
1405 ));
1406 st.session.opt_in_adapters = opt_in;
1407 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1408
1409 handle_key(&mut st, KeyCode::Enter);
1410 assert_eq!(st.picker_active, vec![true, false]);
1413 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
1417 handle_key(&mut st, KeyCode::Enter);
1418 assert_eq!(st.session.rows[0].value, "(none)");
1419 assert_eq!(st.session.rows[1].value, "true");
1420 }
1421
1422 #[test]
1423 fn the_checklist_opens_showing_what_is_already_disabled() {
1424 let adapters: &[&'static str] = &["npm", "cargo", "go"];
1427 let mut st = state(session(
1428 vec![row("disabled_adapters", Control::Adapters, "go")],
1429 adapters,
1430 ));
1431 handle_key(&mut st, KeyCode::Enter);
1432 assert_eq!(st.picker_active, vec![true, true, false]);
1433 handle_key(&mut st, KeyCode::Enter);
1434 assert_eq!(st.session.rows[0].value, "go");
1435 }
1436
1437 #[test]
1438 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
1439 let mut st = state(session(
1442 vec![row("auto_update", Control::Toggle, "false")],
1443 &[],
1444 ));
1445 assert!(matches!(
1446 handle_key(&mut st, KeyCode::Char('q')),
1447 Some(Outcome::Cancelled)
1448 ));
1449 }
1450
1451 #[test]
1452 fn only_changed_rows_are_saved() {
1453 let mut st = state(session(
1454 vec![
1455 row("auto_update", Control::Toggle, "false"),
1456 row("auto_config", Control::Toggle, "false"),
1457 ],
1458 &[],
1459 ));
1460 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('y')); let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
1463 panic!("expected a save");
1464 };
1465 assert_eq!(changed.len(), 1);
1466 assert_eq!(changed[0].key, "auto_update");
1467 assert_eq!(changed[0].value, "true");
1468 }
1469
1470 #[test]
1471 fn reset_puts_a_row_back_without_touching_the_others() {
1472 let mut st = state(session(
1473 vec![
1474 row("auto_update", Control::Toggle, "false"),
1475 row("auto_config", Control::Toggle, "true"),
1476 ],
1477 &[],
1478 ));
1479 handle_key(&mut st, KeyCode::Char(' '));
1480 assert!(st.session.rows[0].changed());
1481 handle_key(&mut st, KeyCode::Char('r'));
1482 assert!(!st.session.rows[0].changed());
1483 assert_eq!(st.session.rows[1].value, "true");
1484 }
1485
1486 #[test]
1487 fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
1488 let mut rows = [
1489 row("idle_days", Control::Number, "14"),
1490 row("auto_update", Control::Toggle, "false"),
1491 row("auto_config", Control::Toggle, "false"),
1492 ];
1493 assert_eq!(
1494 opening_index(&rows),
1495 0,
1496 "with nothing new, start at the top"
1497 );
1498 rows[2].is_new = true;
1499 assert_eq!(opening_index(&rows), 2);
1500 }
1501}