Skip to content
Open
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
9 changes: 9 additions & 0 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,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 +542,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 Down
133 changes: 96 additions & 37 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use anyhow::Result;
use plannotator_tui_schema::Kind;
use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;

use super::compose::ComposeAction;
use super::menu::ReviewAction;
Expand All @@ -13,6 +15,12 @@ use crate::delivery::Delivery as _;
use crate::gate::Gate;
use crate::keys::{self, Action};

/// A row's characters indexed by screen column, as the cursor is: a wide one fills two.
fn chars_by_column(line: &Line<'_>) -> Vec<char> {
let text = line.to_string();
text.chars().flat_map(|c| std::iter::repeat_n(c, c.width().unwrap_or(0))).collect()
}

impl App {
pub(crate) fn handle_event(&mut self, event: &Event) -> Result<()> {
match event {
Expand Down Expand Up @@ -239,15 +247,13 @@ impl App {
return Ok(());
}
if self.pending.is_some()
&& let Some(action) = self.keys.action(&key, keys::TOOLBAR)
&& let Some(&(_, _, _, kind)) = TOOLBAR.iter().find(|item| item.2 == action)
&& let Some(kind) = self.toolbar_kind(&key)
{
return self.act(kind);
}
// Visual mode: the selection follows the cursor.
if self.selection.is_some_and(|s| s.dragging) {
self.visual_key(key);
return Ok(());
return self.visual_key(key);
}
// Roaming (`i`): the cursor moves with nothing selected yet, so `v` can start
// anywhere. Other keys fall through to their block-mode meaning.
Expand All @@ -261,23 +267,20 @@ impl App {
return Ok(());
}
}
match self.keys.action(&key, keys::DOCUMENT) {
let action = self
.keys
.action(&key, keys::DOCUMENT)
.or_else(|| self.keys.action_without_ctrl_leak(&key, keys::BLOCK_MARKS));
match action {
Some(Action::Back) => {
if self.pending.is_some() || self.selection.is_some() {
self.clear_selection();
} else {
self.request_quit();
}
}
Some(Action::Visual) => {
self.clear_selection();
self.selection = Some(Selection::start(self.cursor));
self.status = Some(format!(
"visual: move to extend, {} to select, {} to cancel",
self.keys.hint(Action::Confirm),
self.keys.hint(Action::Back)
));
}
Some(Action::Visual) => self.start_visual(Selection::start(self.cursor)),
Some(Action::VisualLine) => self.start_visual(Selection::start_rows(self.cursor)),
Some(Action::MoveCursor) => self.start_roaming(),
Some(Action::Down) => self.select_block(self.selected + 1),
Some(Action::Up) => self.select_block(self.selected.saturating_sub(1)),
Expand All @@ -293,15 +296,10 @@ impl App {
Some(Action::Bottom) => {
self.select_block(self.open.doc.blocks.len().saturating_sub(1));
}
Some(Action::Comment | Action::Confirm) => {
// No selection: comment on the whole selected block.
if let (Some(block), Some(rendered)) =
(self.open.doc.blocks.get(self.selected), self.open.layout.blocks.get(self.selected))
{
self.pending = Some(Pending { range: block.range.clone(), at: (rendered.first_row, 0) });
self.act(Kind::Comment)?;
}
}
// No selection: these act on the whole selected block.
Some(Action::Comment | Action::Confirm) => self.act_on_block(Kind::Comment)?,
Some(Action::LooksGood) => self.act_on_block(Kind::LooksGood)?,
Some(Action::Delete) => self.act_on_block(Kind::Delete)?,
Some(Action::ClearBlock) => {
let removed = self.open.store.remove_in_block(&self.open.doc, self.selected)?;
if removed > 0 {
Expand All @@ -318,18 +316,54 @@ impl App {
Ok(())
}

/// Keys while a keyboard selection is being extended.
fn visual_key(&mut self, key: KeyEvent) {
fn start_visual(&mut self, selection: Selection) {
self.clear_selection();
self.selection = Some(selection);
let copy = (!self.ui.copy_on_select).then(|| self.keys.hint(Action::CopySelection));
let marks = TOOLBAR
.map(|item| self.keys.hint(item.2))
.into_iter()
.chain(copy)
.filter(|hint| !hint.is_empty())
.collect::<Vec<_>>()
.join("/");
self.status = Some(format!(
"visual: move to extend, {marks} to act, {} to select, {} to cancel",
self.keys.hint(Action::Confirm),
self.keys.hint(Action::Back)
));
}

/// Keys while a keyboard selection is being extended. A motion wins over a toolbar key, so
/// a config that gave `a` to a motion keeps it.
fn visual_key(&mut self, key: KeyEvent) -> Result<()> {
match self.keys.action(&key, keys::VISUAL) {
Some(Action::Back) => self.clear_selection(),
Some(Action::Confirm | Action::Visual) => self.finish_selection(),
Some(Action::Confirm | Action::Visual | Action::VisualLine) => self.finish_selection(),
_ if self.motion_key(key) => {
// Only a motion moves the head: during a mouse drag the cursor is not on it.
if let Some(sel) = self.selection.as_mut() {
sel.set_head(self.cursor);
}
}
_ if self.keys.action_without_ctrl_leak(&key, keys::TOOLBAR).is_none() => {}
_ => {
self.motion_key(key);
if self.keys.action(&key, keys::TOOLBAR) == Some(Action::CopySelection) {
self.finish_selection();
self.copy_pending();
} else if let Some(kind) = self.toolbar_kind(&key) {
self.finish_selection();
return self.act(kind);
}
}
}
if let Some(sel) = self.selection.as_mut() {
sel.set_head(self.cursor);
}
Ok(())
}

/// The annotation kind `key` makes from the toolbar.
fn toolbar_kind(&self, key: &KeyEvent) -> Option<Kind> {
let action = self.keys.action(key, keys::TOOLBAR)?;
TOOLBAR.iter().find(|item| item.2 == action).map(|item| item.3)
}

/// Enter roaming: the visual-mode motions move the cursor with nothing selected.
Expand All @@ -338,10 +372,11 @@ impl App {
self.roam = true;
let hint = |action| self.keys.hint(action);
self.status = Some(format!(
"move: {} {} {} {} {} · {} select · {} back to blocks",
"move: {} {} {} {} {} {} · {} select · {} back to blocks",
self.keys.directions_hint(),
hint(Action::WordForward),
hint(Action::WordBack),
hint(Action::WordEnd),
hint(Action::LineStart),
hint(Action::LineEnd),
hint(Action::Visual),
Expand All @@ -358,6 +393,7 @@ impl App {
Some(Action::Up) => self.move_cursor(-1, 0),
Some(Action::WordForward) => self.move_word(1),
Some(Action::WordBack) => self.move_word(-1),
Some(Action::WordEnd) => self.move_word_end(),
Some(Action::LineStart) => self.cursor.1 = 0,
Some(Action::LineEnd) => {
self.cursor.1 =
Expand Down Expand Up @@ -399,14 +435,27 @@ impl App {
self.ensure_cursor_visible();
}

/// Jump to the next word's last character on the current row; past the row's last word,
/// stay put.
fn move_word_end(&mut self) {
let Some(row) = self.open.layout.row(self.cursor.0) else { return };
let chars = chars_by_column(&row.line);
let is_end = |i: usize| {
chars.get(i).is_some_and(|c| !c.is_whitespace())
&& chars.get(i + 1).is_none_or(|c| c.is_whitespace())
};
if let Some(end) = (self.cursor.1 + 1..chars.len()).find(|&i| is_end(i)) {
self.cursor.1 = end;
}
}

/// Jump to the next (+1) or previous (-1) word start on the current row.
fn move_word(&mut self, direction: i64) {
let Some(row) = self.open.layout.row(self.cursor.0) else { return };
let text = row.line.to_string();
let chars: Vec<char> = text.chars().collect();
let chars = chars_by_column(&row.line);
let is_boundary = |i: usize| {
let here = chars.get(i).is_some_and(|c| !c.is_whitespace());
let before = i == 0 || chars.get(i - 1).is_some_and(char::is_ascii_whitespace);
let before = i == 0 || chars.get(i - 1).is_some_and(|c| c.is_whitespace());
here && before
};
let col = self.cursor.1;
Expand All @@ -418,9 +467,17 @@ impl App {
self.cursor.1 = next;
}

/// Leave the comment box without saving.
fn cancel_compose(&mut self) {
self.mode = Mode::Browse;
if self.pending.as_ref().is_some_and(|pending| !pending.has_toolbar) {
self.pending = None;
}
}

fn text_key(&mut self, key: KeyEvent) -> Result<()> {
match self.compose.handle_key(key) {
ComposeAction::Cancel => self.mode = Mode::Browse,
ComposeAction::Cancel => self.cancel_compose(),
ComposeAction::Save => {
let body = self.compose.value().trim().to_owned();
match std::mem::replace(&mut self.mode, Mode::Browse) {
Expand Down Expand Up @@ -511,7 +568,7 @@ impl App {
self.selection = None;
self.cursor = pos;
self.status = None;
self.pending = Some(Pending { range, at: pos });
self.pending = Some(Pending { range, at: pos, has_toolbar: true });
return Ok(());
}
if let Some(block) = self.open.layout.block_at_row(pos.0) {
Expand Down Expand Up @@ -610,6 +667,7 @@ impl App {
sel.dragging = false;
if sel.is_empty() {
self.selection = None;
self.status = None;
return;
}
let sel = *sel;
Expand All @@ -625,6 +683,7 @@ impl App {
}
let Some((lo, hi)) = bounds else {
self.selection = None;
self.status = None;
return;
};
let source = &self.open.doc.source;
Expand All @@ -640,6 +699,6 @@ impl App {
self.selected = block;
}
self.status = None;
self.pending = Some(Pending { range, at: a });
self.pending = Some(Pending { range, at: a, has_toolbar: true });
}
}
27 changes: 25 additions & 2 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ struct Pending {
range: Range<usize>,
/// Document (row, col) where the selection starts; anchors the toolbar and compose box.
at: (usize, usize),
/// Whether leaving the comment box comes back to the toolbar. A block comment opened
/// straight from the document never showed one.
has_toolbar: bool,
}

/// Everything about the open document; swapped wholesale when the tree switches files.
Expand Down Expand Up @@ -480,14 +483,34 @@ impl App {
self.compose = Compose::default();
}
Kind::LooksGood | Kind::Delete => {
self.annotate(pending.range, kind, String::new())?;
let placed = self.open.store.placed();
let repeated = placed.iter().any(|p| p.kind() == kind && *p.range == pending.range);
drop(placed);
let outcome = if repeated {
"already saved"
} else {
self.annotate(pending.range, kind, String::new())?;
"saved"
};
self.clear_selection();
self.status = Some(format!("{} saved", label(kind)));
self.status = Some(format!("{} {outcome}", label(kind)));
}
}
Ok(())
}

/// Apply `kind` to the whole selected block, with no toolbar in between.
fn act_on_block(&mut self, kind: Kind) -> Result<()> {
let (Some(block), Some(rendered)) =
(self.open.doc.blocks.get(self.selected), self.open.layout.blocks.get(self.selected))
else {
return Ok(());
};
self.pending =
Some(Pending { range: block.range.clone(), at: (rendered.first_row, 0), has_toolbar: false });
self.act(kind)
}

/// `copy`: the waiting selection's source text to the clipboard. It stays selected.
fn copy_pending(&mut self) {
let Some(pending) = &self.pending else { return };
Expand Down
Loading