pub mod new_task;
pub mod review;
pub mod task;
pub mod tasks;
use std::io::{Stdout, stdout};
use std::time::{Duration, Instant};
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::{Alignment, 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 const LIVE_TICK: Duration = Duration::from_millis(8);
pub const MIN_FRAME: Duration = Duration::from_millis(16);
pub enum Action {
None,
Push(Box<dyn View>),
Pop,
Quit,
}
pub const STATUS_TTL: Duration = Duration::from_millis(500);
#[derive(Debug, Default)]
pub struct Status {
text: String,
shown_at: Option<Instant>,
}
impl Status {
pub fn say(&mut self, message: impl Into<String>) {
self.text = message.into();
self.shown_at = Some(Instant::now());
}
pub fn clear(&mut self) {
self.text.clear();
self.shown_at = None;
}
pub fn text(&self) -> &str {
&self.text
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn expire(&mut self, ttl: Duration) {
if self.shown_at.is_some_and(|shown| shown.elapsed() >= ttl) {
self.clear();
}
}
}
pub struct Context<'a> {
pub store: &'a mut Store,
pub config: &'a Config,
pub status: &'a mut Status,
}
impl Context<'_> {
pub fn say(&mut self, message: impl Into<String>) {
self.status.say(message);
}
}
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
}
fn poll_interval(&self) -> Duration {
TICK
}
fn dirty(&self) -> bool {
true
}
}
pub const DETAIL_WORDS: usize = 8;
pub fn first_words(text: &str, words: usize) -> String {
let mut taken: Vec<&str> = text.split_whitespace().take(words).collect();
let more = text.split_whitespace().nth(words).is_some();
if taken.is_empty() {
return String::new();
}
if more {
taken.push("…");
}
taken.join(" ")
}
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: Status,
quit: bool,
dirty: bool,
}
impl App {
pub fn new(store: Store, config: Config) -> Self {
Self {
store,
config,
views: vec![Box::new(tasks::TasksView::new())],
status: Status::default(),
quit: false,
dirty: true,
}
}
pub fn should_quit(&self) -> bool {
self.quit
}
pub fn poll_interval(&self) -> Duration {
self.views.last().map_or(TICK, |view| view.poll_interval())
}
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
pub fn take_dirty(&mut self) -> bool {
let owed = self.dirty || self.views.last().is_some_and(|view| view.dirty());
self.dirty = false;
owed
}
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.text().to_string();
let (view, mut ctx) = self.split();
view.render(frame, body, &mut ctx);
let [keys, corner] = footer_split(footer, &status);
frame.render_widget(footer_line(&hints, depth), keys);
if !status.is_empty() {
frame.render_widget(status_line(&status), corner);
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Release {
return;
}
self.status.clear();
self.dirty = true;
let (view, mut ctx) = self.split();
let action = match view.handle_key(key, &mut ctx) {
Ok(action) => action,
Err(err) => {
self.status.say(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 had_status = !self.status.is_empty();
self.status.expire(STATUS_TTL);
if had_status && self.status.is_empty() {
self.dirty = true;
}
let (view, mut ctx) = self.split();
if let Err(err) = view.tick(&mut ctx) {
ctx.status.say(err.to_string());
}
}
}
fn footer_split(area: Rect, status: &str) -> [Rect; 2] {
if status.is_empty() {
return [area, Rect::new(area.x, area.y, 0, area.height)];
}
let width = (status.chars().count() as u16 + 2).min(area.width);
Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(area)
}
fn status_line(status: &str) -> Paragraph<'_> {
Paragraph::new(Line::from(Span::styled(
format!("{status} "),
Style::default().fg(Color::Yellow),
)))
.alignment(Alignment::Right)
}
fn footer_line<'a>(hints: &[(&'a str, &'a str)], depth: usize) -> Paragraph<'a> {
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<()> {
terminal.draw(|frame| app.render(frame))?;
let mut drawn = Instant::now();
while !app.should_quit() {
if event::poll(app.poll_interval())? {
match event::read()? {
Event::Key(key) => app.handle_key(key),
Event::Resize(_, _) => app.mark_dirty(),
_ => {}
}
}
app.tick();
if drawn.elapsed() >= MIN_FRAME && app.take_dirty() {
terminal.draw(|frame| app.render(frame))?;
drawn = Instant::now();
}
}
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 = Status::default();
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 tick_view(view: &mut dyn View, store: &mut Store) {
let config = Config::new("/tmp/marver-test", "/tmp");
let mut status = Status::default();
let mut ctx = Context {
store,
config: &config,
status: &mut status,
};
view.tick(&mut ctx).expect("tick");
}
pub fn press(view: &mut dyn View, store: &mut Store, key: KeyEvent) -> Action {
let config = Config::new("/tmp/marver-test", "/tmp");
let mut status = Status::default();
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_sits_in_the_corner_without_taking_the_keymap_away() {
let mut app = app();
app.status.say("refreshed");
let footer = render_app(&mut app, 60, 10).last().unwrap().clone();
assert!(footer.contains("refreshed"), "{footer:?}");
assert!(
footer.contains("new"),
"the keys must survive being spoken over: {footer:?}"
);
let keys = footer.find("new").expect("hints");
let said = footer.find("refreshed").expect("status");
assert!(
said > keys,
"the status belongs after the hints: {footer:?}"
);
app.handle_key(key(KeyCode::Esc));
assert!(app.status.is_empty(), "a stale message must not linger");
}
#[test]
fn a_long_status_cannot_push_the_footer_off_screen() {
let mut app = app();
app.status.say("x".repeat(500));
let screen = render_app(&mut app, 40, 8);
assert_eq!(
screen.last().unwrap().chars().count(),
screen.last().unwrap().trim_end().chars().count(),
"no wrapping past the footer's one line"
);
assert_eq!(screen.len(), 8, "the layout still has exactly one footer");
}
#[test]
fn a_status_message_expires_on_its_own() {
let mut app = app();
app.status.say("refreshed");
app.tick();
assert!(
!app.status.is_empty(),
"it must survive long enough to be read"
);
app.status.expire(Duration::ZERO);
assert!(app.status.is_empty(), "a message with no next key must go");
let footer = render_app(&mut app, 60, 10).last().unwrap().clone();
assert!(
!footer.contains("refreshed"),
"the corner empties again: {footer:?}"
);
assert!(footer.contains("new"), "and the hints stay put: {footer:?}");
}
#[test]
fn the_status_lifetime_is_short_enough_to_stay_out_of_the_way() {
assert!(STATUS_TTL <= Duration::from_millis(500));
assert!(
STATUS_TTL >= TICK,
"shorter than a tick would never be seen"
);
}
#[test]
fn an_expiry_only_starts_when_something_is_said() {
let mut status = Status::default();
status.expire(Duration::ZERO);
assert!(status.is_empty(), "expiring nothing is harmless");
status.say("hello");
status.expire(Duration::from_secs(60));
assert_eq!(status.text(), "hello", "it is nowhere near due");
}
#[test]
fn detail_is_cut_by_words_and_says_when_it_was() {
assert_eq!(first_words("short enough", 8), "short enough");
assert_eq!(first_words("", 8), "", "nothing in, nothing out");
assert_eq!(first_words(" ", 8), "", "and whitespace is nothing");
let long = "one two three four five six seven eight nine ten";
let cut = first_words(long, 8);
assert_eq!(cut, "one two three four five six seven eight …");
assert!(!cut.contains("nine"), "{cut:?}");
}
#[test]
fn a_detail_with_newlines_stays_one_line() {
let cut = first_words("first line\nsecond line\n\nfourth", 20);
assert_eq!(cut, "first line second line fourth");
assert!(!cut.contains('\n'));
}
#[test]
fn cutting_never_leaves_a_dangling_marker() {
assert_eq!(first_words("one two three", 3), "one two three");
assert_eq!(first_words("one two three four", 3), "one two three …");
}
#[test]
fn ctrl_q_leaves_every_screen() {
use crate::tui::{new_task::NewTaskView, review::ReviewView, tasks::TasksView};
let mut store = store();
let chord = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
let mut list = TasksView::new();
assert!(
matches!(press(&mut list, &mut store, chord), Action::Quit),
"the root has nothing under it, so leaving is quitting"
);
let mut new_task = NewTaskView::new();
assert!(matches!(
press(&mut new_task, &mut store, chord),
Action::Pop
));
let mut review = ReviewView::new(1);
assert!(matches!(press(&mut review, &mut store, chord), Action::Pop));
}
#[test]
fn esc_leaves_every_screen_that_is_not_the_agents() {
use crate::tui::{new_task::NewTaskView, review::ReviewView, tasks::TasksView};
let mut store = store();
let esc = key(KeyCode::Esc);
let mut list = TasksView::new();
assert!(matches!(press(&mut list, &mut store, esc), Action::Quit));
let mut new_task = NewTaskView::new();
assert!(matches!(press(&mut new_task, &mut store, esc), Action::Pop));
let mut review = ReviewView::new(1);
assert!(matches!(press(&mut review, &mut store, esc), Action::Pop));
}
#[test]
fn c_never_destroys_and_x_never_commits() {
use crate::tui::{review::ReviewView, tasks::TasksView};
let list = TasksView::new();
let hints = list.keys();
assert!(
hints.iter().any(|(k, what)| *k == "x" && *what == "cancel"),
"the list should offer x to cancel: {hints:?}"
);
assert!(
!hints.iter().any(|(k, _)| *k == "c"),
"and should not bind c at all: {hints:?}"
);
let review = ReviewView::new(1);
let hints = review.keys();
assert!(hints.iter().any(|(k, what)| *k == "c" && *what == "commit"));
assert!(hints.iter().any(|(k, what)| *k == "x" && *what == "reject"));
}
#[test]
fn every_screen_advertises_how_to_leave_it() {
use crate::tui::{new_task::NewTaskView, review::ReviewView, tasks::TasksView};
let screens: Vec<(&str, Vec<(&str, &str)>)> = vec![
("tasks", TasksView::new().keys()),
("new task", NewTaskView::new().keys()),
("review", ReviewView::new(1).keys()),
("agent", crate::tui::task::TaskView::new(1).keys()),
];
for (name, hints) in screens {
assert!(
hints
.iter()
.any(|(k, _)| k.contains("esc") || k.contains('q')),
"{name} does not say how to leave: {hints:?}"
);
}
}
#[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);
}
}