Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ dimmed. The keys also work without opening the menu.
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
| tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders |

These are the defaults; every key can be rebound under `[keys]` (see [Keys](#keys)).

Hidden folders are out of the tree until `.` asks for them, and `.git`, `.hg`, `.svn`,
`.jj`, `.cache`, `.direnv`, `.venv`, `venv`, `__pycache__`, `node_modules`, `vendor`,
`target`, `build`, `dist` and `out` stay out either way — none of them is read at all.
Expand Down Expand Up @@ -139,6 +141,48 @@ wins over the file, and `plannotator-tui config` prints whichever is in effect.
backgrounds plannotator-tui paints itself change; the document keeps your terminal's own
colours either way.

### Keys

Every key is rebindable under `[keys]`: an action, and the keys that run it.

```toml
[keys]
left = ["j", "Left"] # jkl; instead of hjkl
down = ["k", "Down"]
up = ["l", "Up"]
right = [";", "Right"]
line-start = ["^", "Home"]
```

An entry replaces that action's keys and leaves every other action at its default; `[]`
unbinds it. A key is one character (`G` is Shift+g), `ctrl+<character>`, or one of `Up`,
`Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Enter`, `Esc`, `Tab`,
`Delete`, `Backspace`. An action keeps its meaning everywhere it applies, so `down` moves
down in the document, the tree, the notes and every picker. A key bound to two actions that
are live at the same time is an error that names both, and the hints at the bottom of the
screen show your keys. `plannotator-tui config` prints every action with its keys.

| Action | Default | Does |
|---|---|---|
| `up` `down` `left` `right` | `k` `j` `h` `l`, arrows | move; `left`/`right` in the document start the cursor, `right` in the tree opens |
| `word-forward` `word-back` | `w` `b` | cursor to the next or previous word |
| `line-start` `line-end` | `0` `$`, `Home` `End` | cursor to the start or end of the line |
| `page-down` `page-up` | `ctrl+d` `ctrl+u`, `PageDown` `PageUp` | half a page |
| `top` `bottom` | `g` `G`, `Home` `End` | first or last block |
| `confirm` `back` | `Enter` `Esc` | finish, open, run; cancel, close |
| `quit` `interrupt` | `q` `ctrl+c` | quit |
| `cycle-focus` | `Tab` | tree · document · notes |
| `send` `send-and-close` | `E` `S` | send; send and close a reply review |
| `toggle-tree` `reload` `pick-message` | `t` `r` `p` | show/hide the tree; reload; choose another reply |
| `review-menu` | `m` | the Review menu |
| `resend-all` `finish-review` `undo-finish` `archive` | `R` `F` `U` `H` | file and folder review actions |
| `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree |
| `edit` `remove` | `e` `x`, `Delete` | the selected note |
| `visual` `move-cursor` | `v` `i` | select with the cursor; move it first |
| `comment` `clear-block` | `c` `x` | on the block; clear its annotations |
| `looks-good` `delete` | `a` `d` | toolbar, with `comment` |
| `yes` `no` | `y` `n`, `Y` `N` | the send-before-quitting question |

Actions forwarded by Herdr Mirror default to a split beside the invoking remote
pane. Mirror does not preserve overlay presentation, and Herdr 0.8.2 opens an
overlay in its server's active tab, which can differ from the tab you are viewing.
Expand Down
34 changes: 25 additions & 9 deletions crates/plannotator-tui/src/app/archive_view.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
//! A small archive picker: identify a note by file, quote and body, then restore it.

use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};

use super::draw::picker_help;
use super::{App, Mode, label};
use crate::keys::{self, Action};

impl App {
pub(super) fn archive_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.move_archive_cursor(1),
KeyCode::Char('k') | KeyCode::Up => self.move_archive_cursor(-1),
KeyCode::Enter => self.restore_selected_archived(),
KeyCode::Esc | KeyCode::Char('H' | 'q') => self.mode = Mode::Browse,
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => self.mode = Mode::Browse,
match self.keys.action(&key, keys::ARCHIVE) {
Some(Action::Down) => self.move_archive_cursor(1),
Some(Action::Up) => self.move_archive_cursor(-1),
Some(Action::Confirm) => self.restore_selected_archived(),
Some(Action::Back | Action::Archive | Action::Quit | Action::Interrupt) => {
self.mode = Mode::Browse;
}
_ => {}
}
}
Expand Down Expand Up @@ -62,12 +65,25 @@ impl App {
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(Color::Cyan))
.title(format!(" Archived annotations ({}) ", self.archive_items.len()))
.title_bottom(" ↑↓ select · enter/click restore · esc close ");
.title_bottom(picker_help(
"select",
[
// A click restores too, so it is named even with `confirm` unbound.
Some(match self.keys.hint(Action::Confirm) {
key if key.is_empty() => "click restore".to_owned(),
key => format!("{key}/click restore"),
}),
self.keys.labelled(Action::Back, "close"),
],
));
let inner = block.inner(rect);
frame.render_widget(block, rect);
if self.archive_items.is_empty() {
frame.render_widget(
Paragraph::new("No archived annotations. F finishes sent annotations."),
Paragraph::new(match self.keys.hint(Action::FinishReview) {
key if key.is_empty() => "No archived annotations.".to_owned(),
key => format!("No archived annotations. {key} finishes sent annotations."),
}),
inner,
);
return;
Expand Down
97 changes: 82 additions & 15 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use unicode_width::UnicodeWidthStr;

use super::{App, Focus, GUTTER, Geometry, Mode, TOOLBAR, glyph, label};
use crate::keys::Action;
use crate::theme::palette;
use crate::wrap::wrap_line;

Expand Down Expand Up @@ -234,7 +235,13 @@ impl App {
}

fn draw_toolbar(&mut self, frame: &mut Frame) {
let labels: Vec<String> = TOOLBAR.iter().map(|(g, l, k, _)| format!(" {g} {l} ({k}) ")).collect();
let labels: Vec<String> = TOOLBAR
.iter()
.map(|(g, l, a, _)| match self.keys.hint(*a) {
key if key.is_empty() => format!(" {g} {l} "),
key => format!(" {g} {l} ({key}) "),
})
.collect();
let width: u16 = labels.iter().map(|l| l.width() as u16).sum::<u16>() + 1;
let Some(rect) = self.float_origin(1, width) else { return };
frame.render_widget(Clear, rect);
Expand Down Expand Up @@ -373,18 +380,22 @@ impl App {
if self.mode == Mode::ConfirmQuit {
// The question owns the footer: the browse help would name keys that are not
// live while it is up.
let question = format!(
" send feedback to {} before quitting? y send · n quit · esc cancel",
self.delivery.describe()
);
let answers = [(Action::Yes, "send"), (Action::No, "quit"), (Action::Back, "cancel")]
.into_iter()
.filter_map(|(action, label)| self.keys.labelled(action, label))
.collect::<Vec<_>>()
.join(" · ");
let question =
format!(" send feedback to {} before quitting? {answers}", self.delivery.describe());
frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area);
return;
}
if self.mode == Mode::Browse && !self.undo_archive.is_empty() {
let label = " U Undo finish ";
let label = self.keys.labelled(Action::UndoFinish, "Undo finish");
let label = format!(" {} ", label.as_deref().unwrap_or("Undo finish"));
let width = (label.width() as u16).min(area.width);
let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height };
frame.render_widget(Paragraph::new(label).style(Style::new().fg(Color::Cyan)), rect);
frame.render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(Color::Cyan)), rect);
self.geometry.undo_button = Some(rect);
area.width = area.width.saturating_sub(width);
}
Expand All @@ -410,15 +421,64 @@ impl App {
if frame.area().width < RAIL_MIN_TOTAL_WIDTH {
parts.push("rail hidden: widen to ≥80 cols".into());
}
// `S` is bound only in a reply review, so only a reply review names it.
let quit = if self.is_file_review() { "q quit " } else { "S send+quit · q quit " };
let help = match self.focus {
_ if self.pending.is_some() => "a looks good · c comment · d delete · esc clear ".to_owned(),
Focus::Tree => "j/k · enter open · . hidden · E send · t hide · q quit ".to_owned(),
Focus::Rail => format!("j/k · e edit · x remove · tab · {quit}"),
Focus::Document if self.roam => format!("hjkl move · v select · c comment · esc blocks · {quit}"),
Focus::Document => format!("i move · v select · c comment · E send · tab · {quit}"),
// Each hint names its action's first key; an unbound action drops out of the line.
let key = |action, label| self.keys.labelled(action, label);
let bare = |hint: String| (!hint.is_empty()).then_some(hint);
let up_down = bare(
[Action::Down, Action::Up]
.map(|action| self.keys.hint(action))
.iter()
.filter(|k| !k.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("/"),
);
let tab = bare(self.keys.hint(Action::CycleFocus));
// `send-and-close` is bound only in a reply review, so only a reply review names it.
let send_and_close =
if self.is_file_review() { None } else { key(Action::SendAndClose, "send+quit") };
let quit = [send_and_close, key(Action::Quit, "quit")];
let help: Vec<Option<String>> = match self.focus {
_ if self.pending.is_some() => vec![
key(Action::LooksGood, "looks good"),
key(Action::Comment, "comment"),
key(Action::Delete, "delete"),
key(Action::Back, "clear"),
],
Focus::Tree => vec![
up_down,
key(Action::Confirm, "open"),
key(Action::ToggleHidden, "hidden"),
key(Action::Send, "send"),
key(Action::ToggleTree, "hide"),
key(Action::Quit, "quit"),
],
Focus::Rail => [up_down, key(Action::Edit, "edit"), key(Action::Remove, "remove"), tab]
.into_iter()
.chain(quit)
.collect(),
Focus::Document if self.roam => [
bare(self.keys.directions_hint()).map(|keys| format!("{keys} move")),
key(Action::Visual, "select"),
key(Action::Comment, "comment"),
key(Action::Back, "blocks"),
]
.into_iter()
.chain(quit)
.collect(),
Focus::Document => [
key(Action::MoveCursor, "move"),
key(Action::Visual, "select"),
key(Action::Comment, "comment"),
key(Action::Send, "send"),
tab,
]
.into_iter()
.chain(quit)
.collect(),
};
let help: Vec<String> = help.into_iter().flatten().collect();
let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) };
// The status must stay readable at any width, so the key help yields columns to it
// (and is clipped) rather than the other way round.
let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16;
Expand All @@ -438,3 +498,10 @@ fn short_id(id: &str) -> String {
let tail: Vec<char> = id.chars().rev().take(5).collect();
tail.into_iter().rev().collect()
}

/// A picker's bottom help: the arrows that move its cursor, then the bound keys.
pub(super) fn picker_help(moves: &str, keys: impl IntoIterator<Item = Option<String>>) -> String {
let parts: Vec<String> =
std::iter::once(format!("\u{2191}\u{2193} {moves}")).chain(keys.into_iter().flatten()).collect();
format!(" {} ", parts.join(" \u{b7} "))
}
13 changes: 11 additions & 2 deletions crates/plannotator-tui/src/app/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use unicode_width::UnicodeWidthStr;

use super::App;
use super::send::SendState;
use crate::keys::Action;
use crate::theme::palette;

// These three set their own foreground as well as their background, so they read the same
Expand All @@ -17,7 +18,7 @@ const SEND_BG: Color = Color::Indexed(30);
const SENT_BG: Color = Color::Indexed(22);
const BLOCKED_BG: Color = Color::Indexed(58);

pub(super) const REVIEW_LABEL: &str = "Review \u{25be} (m)";
const REVIEW_LABEL: &str = "Review \u{25be}";

#[derive(Debug, Clone, Copy)]
enum Button {
Expand All @@ -34,7 +35,7 @@ impl App {
}
let mut labels = vec![(Button::Send, self.send_label())];
if self.is_file_review() {
labels.push((Button::Review, REVIEW_LABEL.to_owned()));
labels.push((Button::Review, self.review_label()));
}
let mut right = width;
let mut y = 0;
Expand All @@ -54,6 +55,14 @@ impl App {
.collect()
}

/// The Review button names the key that opens the menu, when it has one.
fn review_label(&self) -> String {
match self.keys.hint(Action::ReviewMenu) {
key if key.is_empty() => REVIEW_LABEL.to_owned(),
key => format!("{REVIEW_LABEL} ({key})"),
}
}

pub(super) fn header_height(&self, width: u16) -> u16 {
self.header_buttons(width).last().map_or(1, |(_, _, rect)| rect.y + 1)
}
Expand Down
Loading
Loading