Skip to main content

max / mountaineer-sysop

5.9 KB · 187 lines History Blame Raw
1 use crate::roles::{self, Role};
2 use anyhow::Result;
3 use crossterm::event::{self, Event, KeyCode};
4 use ratatui::Frame;
5 use ratatui::layout::{Constraint, Layout, Rect};
6 use ratatui::style::Style;
7 use ratatui::text::{Line, Span};
8 use ratatui::widgets::Paragraph;
9 use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
10
11 enum View {
12 List { selected: usize },
13 Detail { idx: usize },
14 }
15
16 pub fn run(initial: Option<String>) -> Result<i32> {
17 let roles = roles::load()?;
18
19 let mut view = match initial.as_deref() {
20 None => View::List { selected: 0 },
21 Some(name) => match roles::find(&roles, name) {
22 Some(_) => View::Detail {
23 idx: roles.iter().position(|r| r.name == name).unwrap(),
24 },
25 None => {
26 eprintln!("sysop: unknown role: {name}");
27 eprintln!(" run `sysop info` for the list");
28 return Ok(1);
29 }
30 },
31 };
32
33 let mut terminal = ratatui::init();
34 let result = event_loop(&mut terminal, &roles, &mut view);
35 ratatui::restore();
36 result.map(|_| 0)
37 }
38
39 fn event_loop(
40 terminal: &mut ratatui::DefaultTerminal,
41 roles: &[Role],
42 view: &mut View,
43 ) -> Result<()> {
44 loop {
45 terminal.draw(|frame| draw(frame, roles, view))?;
46
47 let Event::Key(key) = event::read()? else {
48 continue;
49 };
50 let action = classify(key);
51
52 match view {
53 View::List { selected } => match action {
54 GlobalAction::Quit => return Ok(()),
55 GlobalAction::Passthrough => match key.code {
56 KeyCode::Char('j') | KeyCode::Down if *selected + 1 < roles.len() => {
57 *selected += 1;
58 }
59 KeyCode::Char('k') | KeyCode::Up => {
60 *selected = selected.saturating_sub(1);
61 }
62 KeyCode::Enter => {
63 *view = View::Detail { idx: *selected };
64 }
65 _ => {}
66 },
67 _ => {}
68 },
69 View::Detail { idx } => match action {
70 GlobalAction::Quit => return Ok(()),
71 GlobalAction::Back => {
72 *view = View::List { selected: *idx };
73 }
74 GlobalAction::Passthrough => match key.code {
75 KeyCode::Char('j') | KeyCode::Down if *idx + 1 < roles.len() => {
76 *idx += 1;
77 }
78 KeyCode::Char('k') | KeyCode::Up => {
79 *idx = idx.saturating_sub(1);
80 }
81 _ => {}
82 },
83 _ => {}
84 },
85 }
86 }
87 }
88
89 fn draw(frame: &mut Frame, roles: &[Role], view: &View) {
90 let [body, foot] = layout::split(frame.area());
91 match view {
92 View::List { selected } => {
93 frame.render_widget(list_widget(roles, *selected), body);
94 frame.render_widget(
95 &Footer::new([
96 hint("j/k", "move"),
97 hint("Enter", "details"),
98 hint("q", "quit"),
99 ]),
100 foot,
101 );
102 }
103 View::Detail { idx } => {
104 draw_detail(frame, &roles[*idx], body);
105 frame.render_widget(
106 &Footer::new([
107 hint("j/k", "next/prev role"),
108 hint("Esc", "back"),
109 hint("q", "quit"),
110 ]),
111 foot,
112 );
113 }
114 }
115 }
116
117 fn list_widget(roles: &[Role], selected: usize) -> Paragraph<'_> {
118 let name_w = roles.iter().map(|r| r.name.len()).max().unwrap_or(0);
119 let lines: Vec<Line> = roles
120 .iter()
121 .enumerate()
122 .map(|(i, r)| {
123 let is_sel = i == selected;
124 let marker = if is_sel { selection::MARKER } else { " " };
125 let line = Line::from(vec![
126 Span::raw(format!(" {marker} ")),
127 Span::raw(format!("{:<name_w$}", r.name)),
128 Span::raw(" "),
129 style::dim(r.tool.clone()),
130 ]);
131 if is_sel {
132 line.style(selection::selected_style())
133 } else {
134 line
135 }
136 })
137 .collect();
138 Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
139 }
140
141 fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) {
142 let [_, right] =
143 Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(area);
144 let [_, body] =
145 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(right);
146
147 let label_w = 12;
148 let mut lines: Vec<Line> = Vec::new();
149
150 lines.push(field_line("role", &role.name, label_w));
151 lines.push(field_line("tool", &role.tool, label_w));
152 lines.push(field_line(
153 "packages",
154 &role.packages.join(", "),
155 label_w,
156 ));
157
158 if role.config_paths.is_empty() {
159 lines.push(field_line("config", "(none)", label_w));
160 } else {
161 lines.push(field_line("config", &role.config_paths[0], label_w));
162 for extra in &role.config_paths[1..] {
163 lines.push(continuation_line(extra, label_w));
164 }
165 }
166
167 lines.push(Line::from(""));
168 lines.push(field_line("description", &role.description, label_w));
169
170 let para = Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG));
171 frame.render_widget(para, body);
172 }
173
174 fn field_line(label: &str, value: &str, label_w: usize) -> Line<'static> {
175 Line::from(vec![
176 style::dim(format!("{:<label_w$} ", label)),
177 style::fg(value.to_string()),
178 ])
179 }
180
181 fn continuation_line(value: &str, label_w: usize) -> Line<'static> {
182 Line::from(vec![
183 Span::raw(" ".repeat(label_w + 2)),
184 style::fg(value.to_string()),
185 ])
186 }
187