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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ dimmed. The keys also work without opening the menu.
| 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 |
| toolbar | `a` looks good · `c` comment · `d` delete · `Esc` |
| 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) |
| folder review | `Ctrl+P` open a file by name, including in collapsed folders: type, `↑`/`↓` or click, `Enter`, `Esc` |
Expand Down Expand Up @@ -245,6 +245,15 @@ All three default to `0`, which fills the terminal as before. The margin and `ma
yield before the app drops below 80 columns, so a narrow pane keeps its notes rail; a
narrow pane also lets the document shrink below `doc_width`.

A mouse selection is copied to the clipboard when you release the button. To leave the
clipboard alone, as with Herdr's `ui.copy_on_select = false`, turn it off and press `y` on
the selection when you want a copy:

```toml
[ui]
copy_on_select = false
```

### Keys

Every key is rebindable under `[keys]`: an action, and the keys that run it.
Expand Down Expand Up @@ -290,6 +299,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key
| `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` |
| `copy` | `y` | copy the selection waiting in the toolbar |
| `yes` `no` | `y` `n`, `Y` `N` | the send-before-quitting and clear-all questions |

Actions forwarded by Herdr Mirror default to a split beside the invoking remote
Expand Down
1 change: 1 addition & 0 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ impl App {
key(Action::LooksGood, "looks good"),
key(Action::Comment, "comment"),
key(Action::Delete, "delete"),
(!self.ui.copy_on_select).then(|| key(Action::CopySelection, "copy")).flatten(),
key(Action::Back, "clear"),
],
Focus::Tree => vec![
Expand Down
5 changes: 5 additions & 0 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ impl App {
fn document_key(&mut self, key: KeyEvent) -> Result<()> {
let page = i64::from(self.geometry.doc.height.max(1));
// With a selection waiting, the toolbar keys win.
if self.pending.is_some() && self.keys.action(&key, keys::TOOLBAR) == Some(Action::CopySelection) {
self.copy_pending();
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)
Expand Down Expand Up @@ -627,6 +631,7 @@ impl App {
let end_char = source.get(hi..).and_then(|s| s.chars().next()).map_or(1, char::len_utf8);
let range = lo..hi + end_char;
if self.clipboard
&& self.ui.copy_on_select
&& let Some(text) = source.get(range.clone())
{
let _ = crate::delivery::Clipboard.deliver(text);
Expand Down
10 changes: 10 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,16 @@ impl App {
Ok(())
}

/// `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 };
let Some(text) = self.open.doc.source.get(pending.range.clone()) else { return };
if self.clipboard {
let _ = crate::delivery::Clipboard.deliver(text);
}
self.status = Some("copied".to_owned());
}

/// Begin editing the body of the annotation under the rail cursor.
fn edit_selected_annotation(&mut self) {
let placed = self.open.store.placed();
Expand Down
23 changes: 22 additions & 1 deletion crates/plannotator-tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub(crate) struct Config {
}

/// Which palette the app starts from; `[colors]` adjusts individual roles of it.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub(crate) struct UiConfig {
/// `auto` asks the terminal for its background colour; `light` and `dark` skip the
Expand All @@ -37,6 +37,21 @@ pub(crate) struct UiConfig {
/// Widest the document's text gets; spare columns sit between it and the rail. Zero
/// lets the document take every column the rail leaves.
pub(crate) doc_width: u16,
/// Copy a mouse selection to the clipboard on release. Off leaves the clipboard alone
/// until `copy` (`y`) on the waiting selection.
pub(crate) copy_on_select: bool,
}

impl Default for UiConfig {
fn default() -> Self {
Self {
theme: ThemeSetting::default(),
horizontal_margin: 0,
max_width: 0,
doc_width: 0,
copy_on_select: true,
}
}
}

/// How plannotator-tui opens inside Herdr.
Expand Down Expand Up @@ -265,6 +280,12 @@ mod tests {
assert_eq!(Config::parse(&config.to_toml().expect("serializes")).expect("parses"), config);
}

#[test]
fn copy_on_select_defaults_on_and_turns_off() {
assert!(Config::default().ui.copy_on_select);
assert!(!Config::parse("[ui]\ncopy_on_select = false\n").expect("parses").ui.copy_on_select);
}

#[test]
fn roundtrips_through_toml() {
let text = Config::default().to_toml().expect("serializes");
Expand Down
37 changes: 25 additions & 12 deletions crates/plannotator-tui/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,19 @@ pub(crate) enum Action {
ClearAll,
LooksGood,
Delete,
/// Copy the waiting selection to the clipboard.
#[serde(rename = "copy")]
CopySelection,
Yes,
No,
}

use Action::{
Approve, Archive, AttachImage, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CycleFocus, Delete,
Down, Edit, FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, OpenInEditor,
PageDown, PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, RemoveImage, ResendAll, ReviewMenu,
Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, WordForward,
Yes,
Approve, Archive, AttachImage, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CopySelection,
CycleFocus, Delete, Down, Edit, FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor,
No, OpenInEditor, PageDown, PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, RemoveImage, ResendAll,
ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack,
WordForward, Yes,
};

/// Browsing, before the focused pane gets the key.
Expand Down Expand Up @@ -115,7 +118,7 @@ pub(crate) const MOTION: &[Action] = &[Left, Right, Down, Up, WordForward, WordB
/// While a keyboard selection grows; any other key is a motion.
pub(crate) const VISUAL: &[Action] = &[Back, Confirm, Visual];
/// While a finished selection waits for its kind.
pub(crate) const TOOLBAR: &[Action] = &[LooksGood, Comment, Delete];
pub(crate) const TOOLBAR: &[Action] = &[LooksGood, Comment, Delete, CopySelection];
pub(crate) const CONFIRM_QUIT: &[Action] = &[Yes, Confirm, No, Back];
/// Before `clear-all` empties the document. Only `yes` clears: `confirm` would let a slip
/// followed by Enter wipe every note.
Expand Down Expand Up @@ -282,10 +285,15 @@ const DEFAULTS: &[(Action, &[Key])] = &[
(ClearAll, &[ch('X')]),
(LooksGood, &[ch('a')]),
(Delete, &[ch('d')]),
(CopySelection, &[ch('y')]),
(Yes, &[ch('y'), ch('Y')]),
(No, &[ch('n'), ch('N')]),
];

/// Actions added after configs were written: `approve` (live only in a gated review) and
/// `copy` (on a waiting selection).
const LATE: &[Action] = &[Approve, CopySelection];

/// The keys bound to each action. A `[keys]` entry replaces that action's keys and leaves the
/// others at their defaults; an empty list unbinds the action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
Expand All @@ -301,13 +309,17 @@ impl<'de> Deserialize<'de> for Keymap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let mut keymap = Self::default();
let entries = BTreeMap::<Action, Vec<Key>>::deserialize(deserializer)?;
let approve_set = entries.contains_key(&Approve);
let user_keys: Vec<Key> = entries.values().flatten().copied().collect();
let unset: Vec<Action> = LATE.iter().copied().filter(|late| !entries.contains_key(late)).collect();
keymap.0.extend(entries);
// `approve` arrived after configs were written and is live only in a gated review.
// Its default key yields to a config that gives that key to another action, so a
// config that loaded before still loads.
if !approve_set && keymap.check().is_err() {
keymap.0.insert(Approve, Vec::new());
// Their default keys yield to a config that gives those keys to another action, so
// a config that loaded before they existed still loads.
for late in unset {
let taken =
keymap.0.get(&late).is_some_and(|keys| keys.iter().any(|key| user_keys.contains(key)));
if taken && keymap.check().is_err() {
keymap.0.insert(late, Vec::new());
}
}
keymap.check().map_err(serde::de::Error::custom)?;
Ok(keymap)
Expand Down Expand Up @@ -423,6 +435,7 @@ impl Action {
Self::ClearAll => "clear-all",
Self::LooksGood => "looks-good",
Self::Delete => "delete",
Self::CopySelection => "copy",
Self::Yes => "yes",
Self::No => "no",
}
Expand Down
11 changes: 11 additions & 0 deletions crates/plannotator-tui/src/keys/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,14 @@ fn a_config_that_gave_approves_key_away_still_loads() {
assert!(err.message().contains("approve"), "{}", err.message());
assert_eq!(parse("approve = [\"Y\"]\n").expect("rebinds").hint(Approve), "Y");
}

#[test]
fn copy_is_y_on_a_waiting_selection_and_yields_to_an_older_config() {
let keymap = Keymap::default();
assert_eq!(keymap.action(&press(KeyCode::Char('y'), KeyModifiers::NONE), TOOLBAR), Some(CopySelection));
// Written before `copy` existed: `y` is the user's comment key, and copy is left unbound.
let keymap = parse("comment = [\"y\"]\n").expect("an older config keeps loading");
assert_eq!(keymap.hint(CopySelection), "");
assert_eq!(keymap.hint(Approve), "A");
assert_eq!(parse("copy = [\"Y\"]\n").expect("rebinds").hint(CopySelection), "Y");
}
Loading