Skip to content
Draft
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ dimmed. The keys also work without opening the menu.
| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit |
| reply review | `S` send and close |
| gated review | `A` approve; `E` send and finish; `q` dismiss |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note |
| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, then `a`, `c` or `d` to act on it, `y` to copy it, or `Enter` for the toolbar; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note |
| toolbar | `a` looks good · `c` comment · `d` delete · `y` copy · `Esc` |
| notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; click a bubble |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
Expand Down Expand Up @@ -278,7 +278,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key
| 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 |
| `word-forward` `word-back` `word-end` | `w` `b` `e` | cursor to the next or previous word's start, or the next word's end |
| `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 |
Expand All @@ -295,10 +295,10 @@ screen show your keys. `plannotator-tui config` prints every action with its key
| `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree |
| `edit` `remove` | `e` `x`, `Delete` | the selected note |
| `attach-image` `remove-image` | `I` `D` | an image on the selected note, or the selected block's |
| `visual` `move-cursor` | `v` `i` | select with the cursor; move it first |
| `visual` `visual-line` `move-cursor` | `v` `V` `i` | select with the cursor; select whole rows; move it first |
| `comment` `clear-block` | `c` `x` | on the block; clear its annotations |
| `clear-all` | `X` | every annotation on the document, after `yes` |
| `looks-good` `delete` | `a` `d` | toolbar, with `comment` |
| `looks-good` `delete` | `a` `d` | toolbar, with `comment`; also on the selected block, unless another document action has the key |
| `copy` | `y` | copy the selection waiting in the toolbar |
| `yes` `no` | `y` `n`, `Y` `N` | the send-before-quitting and clear-all questions |

Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cargo build --release
| Where | Keys |
|---|---|
| anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit |
| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell |
| document | drag with the mouse, or `v` then `hjkl` / `w` `b` `e` / `0` `$` to select; `a`, `c` or `d` acts on it, `y` copies it, `Enter` opens the toolbar · `V` selects whole rows · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell |
| selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears |
| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it |
| reply review | `S` send feedback and quit; stays open if the agent is at a dialog |
Expand Down
3 changes: 1 addition & 2 deletions crates/plannotator-tui/src/app/approve/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,7 @@ fn the_approve_button_sits_on_the_edge_and_approves_when_clicked() {
let screen = draw(&mut app, 100, 12);
let header = screen.lines().next().unwrap_or_default();
assert!(header.trim_end().ends_with("Approve (A)"), "{header}");
let footer = screen.lines().last().unwrap_or_default();
assert!(footer.contains("A approve"), "{footer}");
assert!(screen.contains("A approve"), "{screen}");
let rect = app.geometry.approve_button.expect("drawn");
assert_eq!(rect.right(), 100);
click(&mut app, rect);
Expand Down
125 changes: 104 additions & 21 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ fn priority(kind: Kind) -> u8 {
}
}

const FOOTER_MAX_ROWS: usize = 3;

impl App {
pub(crate) fn draw(&mut self, frame: &mut Frame) {
let area = self.viewport(frame.area());
let [header, body, footer] = Layout::vertical([
Constraint::Length(self.header_height(area.width)),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(self.footer_height(area.width)),
])
.areas(area);

Expand Down Expand Up @@ -77,12 +79,20 @@ impl App {
Constraint::Length(rail_width),
])
.areas(body);
let old_height = self.geometry.doc.height;
self.geometry = Geometry { tree, doc, ..Geometry::default() };

if self.open.layout.width != usize::from(doc.width) {
self.open.layout.reflow(usize::from(doc.width));
// A layout is at least one column wide, so an empty pane must not count as a resize.
let layout_width = usize::from(doc.width).max(1);
if self.open.layout.width != layout_width {
self.open.layout.reflow(layout_width);
self.clear_selection();
self.scroll_by(0);
// Clearing the selection can change the footer's height, so lay out again.
return self.draw(frame);
}
if doc.height < old_height {
self.keep_focus_in_view(old_height);
}

self.draw_header(frame, header);
Expand Down Expand Up @@ -422,16 +432,54 @@ impl App {
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 = self.keys.labelled(Action::UndoFinish, "Undo finish");
let label = format!(" {} ", label.as_deref().unwrap_or("Undo finish"));
let (text, hint_rows) = self.footer_text(area.width);
// A pane too short for any footer has no row to put the button on.
if let Some(label) = self.undo_label().filter(|_| area.height > 0) {
let width = (label.width() as u16).min(area.width);
let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height };
let y = area.bottom().saturating_sub(1);
let rect = Rect { x: area.right() - width, y, width, height: 1 };
frame
.render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(palette().accent)), rect);
self.geometry.undo_button = Some(rect);
area.width = area.width.saturating_sub(width);
}
// Paired from the bottom, so a pane too short for every row keeps the one with the text.
for (index, (hints, y)) in hint_rows.iter().rev().zip((area.y..area.bottom()).rev()).enumerate() {
let row = Rect { y, height: 1, ..area };
let hints = if hints.is_empty() { String::new() } else { format!("{hints} ") };
let help_area = if index == 0 {
let [text_area, help_area] =
Layout::horizontal([Constraint::Min(0), Constraint::Length(hints.width() as u16)])
.areas(row);
frame.render_widget(Paragraph::new(Line::from(Span::raw(text.as_str()).dim())), text_area);
help_area
} else {
row
};
frame
.render_widget(Paragraph::new(Line::from(Span::raw(hints).dim()).right_aligned()), help_area);
}
}

/// The footer's rows at `width`; the questions that take it over need one.
fn footer_height(&self, width: u16) -> u16 {
if matches!(self.mode, Mode::ConfirmQuit | Mode::ConfirmClearAll) {
return 1;
}
self.footer_text(width).1.len() as u16
}

/// The button that undoes a finished review, as drawn at the end of the footer.
fn undo_label(&self) -> Option<String> {
if self.mode != Mode::Browse || self.undo_archive.is_empty() {
return None;
}
let label = self.keys.labelled(Action::UndoFinish, "Undo finish");
Some(format!(" {} ", label.as_deref().unwrap_or("Undo finish")))
}

/// The footer's left text and its key hints in rows; the last row sits beside the text.
fn footer_text(&self, viewport_width: u16) -> (String, Vec<String>) {
let orphans = self.open.store.orphans();
// The status leads: it is the transient half of the line, and the name and counters
// it pushes right are on screen for the whole session anyway.
Expand All @@ -451,26 +499,23 @@ impl App {
None => format!("block {}/{}", self.selected + 1, self.open.doc.blocks.len()),
},
]);
if self.viewport(frame.area()).width < RAIL_MIN_TOTAL_WIDTH {
if viewport_width < RAIL_MIN_TOTAL_WIDTH {
parts.push("rail hidden: widen to ≥80 cols".into());
}
let help = self.footer_help();
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;
let help_width = (help.width() as u16).min(area.width.saturating_sub(status_width.max(10)));
let [left_area, right_area] =
Layout::horizontal([Constraint::Min(10), Constraint::Length(help_width)]).areas(area);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())),
left_area,
);
frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area);
let text = format!(" {}", parts.join(" · "));
let undo = self.undo_label().map_or(0, |label| label.width());
// One column stays free after the hints, and one between them and the text.
let width = usize::from(viewport_width).saturating_sub(undo + 1);
let beside_text = width.saturating_sub(text.width() + 1);
(text, flow_hints(&self.footer_help(), width, beside_text))
}

/// The key help for the focused pane, in the user's keys.
fn footer_help(&self) -> Vec<String> {
// A text box takes every key and its border names the ones it answers.
if matches!(self.mode, Mode::Compose | Mode::Edit(_) | Mode::AttachImage(_)) {
return Vec::new();
}
// 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);
Expand All @@ -493,6 +538,14 @@ impl App {
let note = self.current_note();
let has_images = note.as_deref().is_some_and(|id| self.open.store.has_images(id));
let remove_image = has_images.then(|| key(Action::RemoveImage, "remove image")).flatten();
// One hint for both: the footer has to fit 80 columns.
let marks = [Action::LooksGood, Action::Delete]
.map(|action| self.keys.hint(action))
.into_iter()
.filter(|hint| !hint.is_empty())
.collect::<Vec<_>>()
.join("/");
let marks = bare(marks).map(|keys| format!("{keys} mark"));
let help: Vec<Option<String>> = match self.focus {
_ if self.pending.is_some() => vec![
key(Action::LooksGood, "looks good"),
Expand Down Expand Up @@ -534,6 +587,7 @@ impl App {
key(Action::MoveCursor, "move"),
key(Action::Visual, "select"),
key(Action::Comment, "comment"),
marks,
note.is_some().then(|| key(Action::AttachImage, "image")).flatten(),
remove_image,
key(Action::Send, "send"),
Expand All @@ -552,6 +606,35 @@ impl App {
}
}

/// Hints in the fewest rows that hold them all, up to `FOOTER_MAX_ROWS`; past that, the last
/// ones are dropped. Rows are `width` wide, except the last, which has `last_width`.
fn flow_hints(hints: &[String], width: usize, last_width: usize) -> Vec<String> {
let fill = |rows: usize| {
let mut left = hints.iter().peekable();
let filled: Vec<String> = (1..=rows)
.map(|row| {
let limit = if row == rows { last_width } else { width };
let mut line = String::new();
while let Some(hint) = left.peek() {
let joined =
if line.is_empty() { (*hint).clone() } else { format!("{line} \u{b7} {hint}") };
if joined.width() > limit {
break;
}
line = joined;
left.next();
}
line
})
.collect();
(filled, left.next().is_none())
};
(1..FOOTER_MAX_ROWS)
.map(fill)
.find_map(|(filled, complete)| complete.then_some(filled))
.unwrap_or_else(|| fill(FOOTER_MAX_ROWS).0)
}

/// The tail of an id, enough to tell bubbles apart: `anno_…F0123` → `F0123`.
fn short_id(id: &str) -> String {
let tail: Vec<char> = id.chars().rev().take(5).collect();
Expand Down
Loading