pub mod new_task;
pub mod review;
pub mod task;
pub mod tasks;
use std::io::{Stdout, stdout};
use std::time::Duration;
use ratatui::Frame;
use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::prelude::CrosstermBackend;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::daemon::Config;
use crate::domain::TaskState;
use crate::store::Store;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Store(#[from] crate::store::Error),
#[error(transparent)]
Review(#[from] crate::review::Error),
#[error(transparent)]
Tmux(#[from] crate::tmux::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub const TICK: Duration = Duration::from_millis(250);
pub enum Action {
None,
Push(Box<dyn View>),
Pop,
Quit,
}
pub struct Context<'a> {
pub store: &'a mut Store,
pub config: &'a Config,
pub status: &'a mut String,
}
impl Context<'_> {
pub fn say(&mut self, message: impl Into<String>) {
*self.status = message.into();
}
}
pub trait View {
fn title(&self) -> String;
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context);
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action>;
fn tick(&mut self, _ctx: &mut Context) -> Result<()> {
Ok(())
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
Vec::new()
}
fn captures_input(&self) -> bool {
false
}
}
pub fn state_style(state: TaskState) -> Style {
let colour = match state {
TaskState::Queued => Color::DarkGray,
TaskState::Running => Color::Cyan,
TaskState::Blocked => Color::Yellow,
TaskState::AwaitingReview => Color::Green,
TaskState::Committed => Color::Blue,
TaskState::Failed => Color::Red,
TaskState::Cancelled => Color::DarkGray,
};
Style::default().fg(colour)
}
pub struct App {
store: Store,
config: Config,
views: Vec<Box<dyn View>>,
status: String,
quit: bool,
}
impl App {
pub fn new(store: Store, config: Config) -> Self {
Self {
store,
config,
views: vec![Box::new(tasks::TasksView::new())],
status: String::new(),
quit: false,
}
}
pub fn should_quit(&self) -> bool {
self.quit
}
pub fn depth(&self) -> usize {
self.views.len()
}
fn split(&mut self) -> (&mut Box<dyn View>, Context<'_>) {
let view = self.views.last_mut().expect("the stack is never empty");
(
view,
Context {
store: &mut self.store,
config: &self.config,
status: &mut self.status,
},
)
}
pub fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(area);
let title = self.views.last().map(|v| v.title()).unwrap_or_default();
let depth = self.views.len();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" marver ",
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
])),
header,
);
let hints: Vec<(&str, &str)> = self.views.last().map(|v| v.keys()).unwrap_or_default();
let status = self.status.clone();
let (view, mut ctx) = self.split();
view.render(frame, body, &mut ctx);
frame.render_widget(footer_line(&hints, &status, depth), footer);
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Release {
return;
}
self.status.clear();
let (view, mut ctx) = self.split();
let action = match view.handle_key(key, &mut ctx) {
Ok(action) => action,
Err(err) => {
self.status = err.to_string();
return;
}
};
match action {
Action::None => {}
Action::Push(view) => self.views.push(view),
Action::Pop => {
if self.views.len() > 1 {
self.views.pop();
} else {
self.quit = true;
}
}
Action::Quit => self.quit = true,
}
}
pub fn tick(&mut self) {
let (view, mut ctx) = self.split();
if let Err(err) = view.tick(&mut ctx) {
*ctx.status = err.to_string();
}
}
}
fn footer_line<'a>(hints: &[(&'a str, &'a str)], status: &'a str, depth: usize) -> Paragraph<'a> {
if !status.is_empty() {
return Paragraph::new(Line::from(Span::styled(
format!(" {status}"),
Style::default().fg(Color::Yellow),
)));
}
let mut spans = Vec::new();
for (keys, what) in hints {
spans.push(Span::styled(
format!(" {keys}"),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {what} "),
Style::default().fg(Color::DarkGray),
));
}
if depth > 1 {
spans.push(Span::styled(
format!(" ยท depth {depth}"),
Style::default().fg(Color::DarkGray),
));
}
Paragraph::new(Line::from(spans))
}
type Term = ratatui::Terminal<CrosstermBackend<Stdout>>;
fn enter() -> Result<Term> {
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen)?;
Ok(ratatui::Terminal::new(CrosstermBackend::new(out))?)
}
fn leave(terminal: &mut Term) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
pub fn run(store: Store, config: Config) -> Result<()> {
let mut terminal = enter()?;
let result = event_loop(&mut terminal, App::new(store, config));
let restored = leave(&mut terminal);
result.and(restored)
}
fn event_loop(terminal: &mut Term, mut app: App) -> Result<()> {
while !app.should_quit() {
terminal.draw(|frame| app.render(frame))?;
if event::poll(TICK)? {
match event::read()? {
Event::Key(key) => app.handle_key(key),
Event::Resize(_, _) => {}
_ => {}
}
}
app.tick();
}
Ok(())
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
pub fn render_view(
view: &mut dyn View,
store: &mut Store,
width: u16,
height: u16,
) -> Vec<String> {
let config = Config::new("/tmp/marver-test", "/tmp");
let mut status = String::new();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let mut ctx = Context {
store,
config: &config,
status: &mut status,
};
view.render(frame, frame.area(), &mut ctx);
})
.unwrap();
buffer_lines(terminal.backend().buffer(), width, height)
}
pub fn render_app(app: &mut App, width: u16, height: u16) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.render(frame)).unwrap();
buffer_lines(terminal.backend().buffer(), width, height)
}
fn buffer_lines(buffer: &ratatui::buffer::Buffer, width: u16, height: u16) -> Vec<String> {
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
pub fn press(view: &mut dyn View, store: &mut Store, key: KeyEvent) -> Action {
let config = Config::new("/tmp/marver-test", "/tmp");
let mut status = String::new();
let mut ctx = Context {
store,
config: &config,
status: &mut status,
};
view.handle_key(key, &mut ctx).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::testing::*;
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEventState, KeyModifiers};
fn store() -> Store {
Store::open_in_memory().unwrap()
}
fn app() -> App {
App::new(store(), Config::new("/tmp/marver-test", "/tmp"))
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
struct Dummy;
impl View for Dummy {
fn title(&self) -> String {
"dummy".into()
}
fn render(&mut self, frame: &mut Frame, area: Rect, _: &mut Context) {
frame.render_widget(Paragraph::new("DUMMY BODY"), area);
}
fn handle_key(&mut self, _: KeyEvent, _: &mut Context) -> Result<Action> {
Ok(Action::Pop)
}
}
struct Broken;
impl View for Broken {
fn title(&self) -> String {
"broken".into()
}
fn render(&mut self, _: &mut Frame, _: Rect, _: &mut Context) {}
fn handle_key(&mut self, _: KeyEvent, _: &mut Context) -> Result<Action> {
Err(Error::Review(crate::review::Error::NothingStaged))
}
fn tick(&mut self, _: &mut Context) -> Result<()> {
Err(Error::Review(crate::review::Error::NothingStaged))
}
}
#[test]
fn a_view_error_is_reported_rather_than_ending_the_session() {
let mut app = app();
app.views.push(Box::new(Broken));
app.handle_key(key(KeyCode::Char('a')));
assert!(!app.should_quit(), "a git error must not end the session");
assert_eq!(app.depth(), 2, "the stack must survive");
assert!(!app.status.is_empty(), "and the user must be told");
let screen = render_app(&mut app, 60, 10);
assert!(
screen.last().unwrap().contains("nothing is staged"),
"the failure belongs in the status line: {screen:?}"
);
}
#[test]
fn a_failing_tick_is_reported_rather_than_ending_the_session() {
let mut app = app();
app.views.push(Box::new(Broken));
app.tick();
assert!(!app.should_quit());
assert!(!app.status.is_empty());
}
#[test]
fn the_app_starts_on_the_task_list() {
let mut app = app();
assert_eq!(app.depth(), 1);
let screen = render_app(&mut app, 60, 10);
assert!(screen[0].contains("marver"), "{screen:?}");
assert!(screen[0].contains("Tasks"), "{screen:?}");
}
#[test]
fn pushing_and_popping_moves_between_views() {
let mut app = app();
app.views.push(Box::new(Dummy));
assert_eq!(app.depth(), 2);
let screen = render_app(&mut app, 60, 10);
assert!(
screen.iter().any(|l| l.contains("DUMMY BODY")),
"{screen:?}"
);
assert!(
screen.last().unwrap().contains("depth 2"),
"the footer should show how deep we are: {screen:?}"
);
app.handle_key(key(KeyCode::Char('x')));
assert_eq!(app.depth(), 1);
assert!(!app.should_quit());
}
#[test]
fn popping_the_last_view_quits_rather_than_leaving_nothing() {
let mut app = app();
app.views.clear();
app.views.push(Box::new(Dummy));
app.handle_key(key(KeyCode::Char('x')));
assert!(
app.should_quit(),
"an empty stack would have nothing to draw"
);
}
#[test]
fn key_releases_are_ignored() {
let mut app = app();
app.views.push(Box::new(Dummy));
let release = KeyEvent::new_with_kind_and_state(
KeyCode::Char('x'),
KeyModifiers::NONE,
KeyEventKind::Release,
KeyEventState::NONE,
);
app.handle_key(release);
assert_eq!(
app.depth(),
2,
"a release must not act twice with the press"
);
}
#[test]
fn the_status_line_replaces_the_key_hints_and_clears_on_the_next_key() {
let mut app = app();
app.status = "something happened".into();
let screen = render_app(&mut app, 60, 10);
assert!(screen.last().unwrap().contains("something happened"));
app.handle_key(key(KeyCode::Esc));
assert!(app.status.is_empty(), "a stale message must not linger");
}
#[test]
fn every_state_has_a_distinct_enough_colour() {
use std::collections::HashSet;
let colours: HashSet<_> = TaskState::ALL
.iter()
.map(|s| format!("{:?}", state_style(*s).fg))
.collect();
assert_eq!(colours.len(), TaskState::ALL.len() - 1);
}
}