| 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 |
|