| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
use std::process::Command; |
| 10 |
use std::time::Duration; |
| 11 |
|
| 12 |
use alloy_tui::keys::{Action, classify}; |
| 13 |
use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout}; |
| 14 |
use anyhow::Result; |
| 15 |
use ratatui::Frame; |
| 16 |
use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind}; |
| 17 |
use ratatui::layout::Rect; |
| 18 |
|
| 19 |
use crate::cli::CommandLog; |
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
#[allow(dead_code)] |
| 33 |
#[derive(Debug)] |
| 34 |
pub enum Flow { |
| 35 |
Continue, |
| 36 |
Exit, |
| 37 |
|
| 38 |
|
| 39 |
Confirm(Confirm), |
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
Suspend(Command), |
| 45 |
} |
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
#[derive(Debug)] |
| 53 |
pub struct Confirm { |
| 54 |
pub title: String, |
| 55 |
pub message: String, |
| 56 |
pub severity: Severity, |
| 57 |
} |
| 58 |
|
| 59 |
impl Confirm { |
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
#[allow(dead_code)] |
| 64 |
pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self { |
| 65 |
Self { |
| 66 |
title: title.into(), |
| 67 |
message: message.into(), |
| 68 |
severity: Severity::Error, |
| 69 |
} |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
pub trait View { |
| 76 |
|
| 77 |
fn title(&self) -> String; |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
fn hints(&self) -> Vec<Hint>; |
| 82 |
|
| 83 |
|
| 84 |
fn status(&self) -> Option<(Severity, String)> { |
| 85 |
None |
| 86 |
} |
| 87 |
|
| 88 |
|
| 89 |
fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme); |
| 90 |
|
| 91 |
|
| 92 |
fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow; |
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
fn confirmed(&mut self, _log: &mut CommandLog) {} |
| 99 |
|
| 100 |
|
| 101 |
|
| 102 |
|
| 103 |
fn cancelled(&mut self) {} |
| 104 |
|
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
fn tick(&mut self, _log: &mut CommandLog) {} |
| 114 |
} |
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
|
| 120 |
|
| 121 |
|
| 122 |
pub const TICK: Duration = Duration::from_secs(1); |
| 123 |
|
| 124 |
|
| 125 |
|
| 126 |
|
| 127 |
|
| 128 |
pub fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> { |
| 129 |
let mut terminal = ratatui::init(); |
| 130 |
let result = event_loop(&mut terminal, theme, view, log); |
| 131 |
ratatui::restore(); |
| 132 |
result |
| 133 |
} |
| 134 |
|
| 135 |
fn event_loop( |
| 136 |
terminal: &mut ratatui::DefaultTerminal, |
| 137 |
theme: &Theme, |
| 138 |
view: &mut dyn View, |
| 139 |
log: &mut CommandLog, |
| 140 |
) -> Result<()> { |
| 141 |
|
| 142 |
|
| 143 |
|
| 144 |
let mut modal: Option<Confirm> = None; |
| 145 |
|
| 146 |
loop { |
| 147 |
terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?; |
| 148 |
|
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
if !event::poll(TICK)? { |
| 153 |
view.tick(log); |
| 154 |
continue; |
| 155 |
} |
| 156 |
|
| 157 |
let Event::Key(key) = event::read()? else { |
| 158 |
continue; |
| 159 |
}; |
| 160 |
|
| 161 |
|
| 162 |
if key.kind != KeyEventKind::Press { |
| 163 |
continue; |
| 164 |
} |
| 165 |
|
| 166 |
let action = classify(key); |
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
|
| 171 |
if modal.is_some() { |
| 172 |
match modal_key(action) { |
| 173 |
ModalOutcome::Confirmed => { |
| 174 |
modal = None; |
| 175 |
view.confirmed(log); |
| 176 |
} |
| 177 |
ModalOutcome::Cancelled => { |
| 178 |
modal = None; |
| 179 |
view.cancelled(); |
| 180 |
} |
| 181 |
ModalOutcome::Ignored => {} |
| 182 |
} |
| 183 |
continue; |
| 184 |
} |
| 185 |
|
| 186 |
match action { |
| 187 |
Action::Quit | Action::Cancel => return Ok(()), |
| 188 |
_ => match view.handle(key, log) { |
| 189 |
Flow::Exit => return Ok(()), |
| 190 |
Flow::Continue => {} |
| 191 |
Flow::Confirm(confirm) => modal = Some(confirm), |
| 192 |
Flow::Suspend(command) => suspend(terminal, view, log, command)?, |
| 193 |
}, |
| 194 |
} |
| 195 |
} |
| 196 |
} |
| 197 |
|
| 198 |
|
| 199 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 200 |
enum ModalOutcome { |
| 201 |
Confirmed, |
| 202 |
Cancelled, |
| 203 |
Ignored, |
| 204 |
} |
| 205 |
|
| 206 |
|
| 207 |
|
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
|
| 216 |
|
| 217 |
const fn modal_key(action: Action) -> ModalOutcome { |
| 218 |
match action { |
| 219 |
Action::Activate => ModalOutcome::Confirmed, |
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
Action::Cancel | Action::Quit => ModalOutcome::Cancelled, |
| 224 |
_ => ModalOutcome::Ignored, |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
|
| 229 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
|
| 238 |
|
| 239 |
fn suspend( |
| 240 |
terminal: &mut ratatui::DefaultTerminal, |
| 241 |
view: &mut dyn View, |
| 242 |
log: &mut CommandLog, |
| 243 |
mut command: Command, |
| 244 |
) -> Result<()> { |
| 245 |
ratatui::restore(); |
| 246 |
let status = command.status(); |
| 247 |
*terminal = ratatui::init(); |
| 248 |
terminal.clear()?; |
| 249 |
|
| 250 |
|
| 251 |
|
| 252 |
|
| 253 |
view.tick(log); |
| 254 |
|
| 255 |
status?; |
| 256 |
Ok(()) |
| 257 |
} |
| 258 |
|
| 259 |
fn draw( |
| 260 |
frame: &mut Frame, |
| 261 |
theme: &Theme, |
| 262 |
view: &dyn View, |
| 263 |
log: &mut CommandLog, |
| 264 |
modal: Option<&Confirm>, |
| 265 |
) { |
| 266 |
let areas = layout::console(frame.area()); |
| 267 |
|
| 268 |
view.render(frame, areas.body, theme); |
| 269 |
frame.render_widget(AlloyLog::new(theme, log.entries()), areas.log); |
| 270 |
|
| 271 |
let mut hints = view.hints(); |
| 272 |
hints.push(hint("q", "quit")); |
| 273 |
let mut status_bar = AlloyStatusBar::new(theme, hints); |
| 274 |
if let Some((severity, message)) = view.status() { |
| 275 |
status_bar = status_bar.status(severity, message); |
| 276 |
} |
| 277 |
frame.render_widget(status_bar, areas.footer); |
| 278 |
|
| 279 |
|
| 280 |
|
| 281 |
|
| 282 |
if let Some(confirm) = modal { |
| 283 |
let area = layout::centered(areas.body, MODAL_WIDTH, MODAL_HEIGHT); |
| 284 |
frame.render_widget( |
| 285 |
AlloyModal::new(theme, &confirm.title, &confirm.message).severity(confirm.severity), |
| 286 |
area, |
| 287 |
); |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
|
| 292 |
|
| 293 |
const MODAL_WIDTH: u16 = 54; |
| 294 |
const MODAL_HEIGHT: u16 = 7; |
| 295 |
|
| 296 |
|
| 297 |
|
| 298 |
pub fn block_title(title: &str) -> String { |
| 299 |
format!(" {title} ") |
| 300 |
} |
| 301 |
|
| 302 |
#[cfg(test)] |
| 303 |
mod tests { |
| 304 |
use super::*; |
| 305 |
use ratatui::layout::Rect; |
| 306 |
|
| 307 |
|
| 308 |
|
| 309 |
#[derive(Default)] |
| 310 |
struct StubView { |
| 311 |
confirmed: usize, |
| 312 |
cancelled: usize, |
| 313 |
} |
| 314 |
|
| 315 |
impl View for StubView { |
| 316 |
fn title(&self) -> String { |
| 317 |
"stub".into() |
| 318 |
} |
| 319 |
fn hints(&self) -> Vec<Hint> { |
| 320 |
Vec::new() |
| 321 |
} |
| 322 |
fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {} |
| 323 |
fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow { |
| 324 |
Flow::Continue |
| 325 |
} |
| 326 |
fn confirmed(&mut self, _log: &mut CommandLog) { |
| 327 |
self.confirmed += 1; |
| 328 |
} |
| 329 |
fn cancelled(&mut self) { |
| 330 |
self.cancelled += 1; |
| 331 |
} |
| 332 |
} |
| 333 |
|
| 334 |
#[test] |
| 335 |
fn enter_confirms_and_esc_cancels() { |
| 336 |
assert_eq!(modal_key(Action::Activate), ModalOutcome::Confirmed); |
| 337 |
assert_eq!(modal_key(Action::Cancel), ModalOutcome::Cancelled); |
| 338 |
} |
| 339 |
|
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
#[test] |
| 344 |
fn q_cancels_the_modal_rather_than_quitting_the_console() { |
| 345 |
assert_eq!(modal_key(Action::Quit), ModalOutcome::Cancelled); |
| 346 |
} |
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
|
| 351 |
#[test] |
| 352 |
fn keys_that_are_not_an_answer_do_nothing() { |
| 353 |
for action in [ |
| 354 |
Action::NextFocus, |
| 355 |
Action::PrevFocus, |
| 356 |
Action::NextTab, |
| 357 |
Action::PrevTab, |
| 358 |
Action::Save, |
| 359 |
Action::Filter, |
| 360 |
Action::Command, |
| 361 |
Action::Help, |
| 362 |
Action::Passthrough, |
| 363 |
] { |
| 364 |
assert_eq!( |
| 365 |
modal_key(action), |
| 366 |
ModalOutcome::Ignored, |
| 367 |
"{action:?} must not answer a modal" |
| 368 |
); |
| 369 |
} |
| 370 |
} |
| 371 |
|
| 372 |
#[test] |
| 373 |
fn confirm_and_cancel_reach_the_view() { |
| 374 |
let mut view = StubView::default(); |
| 375 |
let mut log = CommandLog::new(); |
| 376 |
|
| 377 |
view.confirmed(&mut log); |
| 378 |
view.cancelled(); |
| 379 |
|
| 380 |
assert_eq!(view.confirmed, 1); |
| 381 |
assert_eq!(view.cancelled, 1); |
| 382 |
} |
| 383 |
|
| 384 |
|
| 385 |
#[test] |
| 386 |
fn confirm_hooks_default_to_nothing() { |
| 387 |
struct Bare; |
| 388 |
impl View for Bare { |
| 389 |
fn title(&self) -> String { |
| 390 |
"bare".into() |
| 391 |
} |
| 392 |
fn hints(&self) -> Vec<Hint> { |
| 393 |
Vec::new() |
| 394 |
} |
| 395 |
fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {} |
| 396 |
fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow { |
| 397 |
Flow::Continue |
| 398 |
} |
| 399 |
} |
| 400 |
|
| 401 |
let mut log = CommandLog::new(); |
| 402 |
Bare.confirmed(&mut log); |
| 403 |
Bare.cancelled(); |
| 404 |
} |
| 405 |
|
| 406 |
#[test] |
| 407 |
fn a_destructive_confirm_carries_the_error_accent() { |
| 408 |
let confirm = Confirm::destructive("remove", "Remove tailscale?"); |
| 409 |
assert_eq!(confirm.severity, Severity::Error); |
| 410 |
assert_eq!(confirm.title, "remove"); |
| 411 |
} |
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
|
| 416 |
#[test] |
| 417 |
fn flow_carries_a_confirm_and_a_suspendable_command() { |
| 418 |
let flow = Flow::Confirm(Confirm::destructive("remove", "Remove tailscale?")); |
| 419 |
assert!(matches!(flow, Flow::Confirm(_))); |
| 420 |
|
| 421 |
let flow = Flow::Suspend(Command::new("distrobox")); |
| 422 |
assert!(matches!(flow, Flow::Suspend(_))); |
| 423 |
|
| 424 |
assert!(matches!(Flow::Exit, Flow::Exit)); |
| 425 |
} |
| 426 |
} |
| 427 |
|