From b2db6e516f7fffaff7c532f4a4f4866243821614 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 06:02:46 -0400 Subject: [PATCH 1/2] feat(ui): copy_on_select setting and a copy key for the waiting selection [ui] copy_on_select = false stops a mouse selection overwriting the clipboard on release; y (copy) copies the waiting selection. The default keeps copying on release. copy's default key yields to an older config that gave y to another action, as approve's does. refs #124 --- README.md | 12 +++++++- crates/plannotator-tui/src/app/draw.rs | 1 + crates/plannotator-tui/src/app/input.rs | 5 ++++ crates/plannotator-tui/src/app/mod.rs | 10 +++++++ crates/plannotator-tui/src/config.rs | 23 ++++++++++++++- crates/plannotator-tui/src/keys.rs | 37 ++++++++++++++++-------- crates/plannotator-tui/src/keys/tests.rs | 11 +++++++ 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2f8280e..d4f139f 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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. @@ -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 diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index c9c8639..a2c4002 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -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![ diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 5088e1f..72bcd14 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -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) @@ -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); diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 4ad0f30..c09a9ff 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -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 clipboard".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(); diff --git a/crates/plannotator-tui/src/config.rs b/crates/plannotator-tui/src/config.rs index 3aa41ae..389bc79 100644 --- a/crates/plannotator-tui/src/config.rs +++ b/crates/plannotator-tui/src/config.rs @@ -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 @@ -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. @@ -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"); diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index a1bce00..00ee8fe 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -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. @@ -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. @@ -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)] @@ -301,13 +309,17 @@ impl<'de> Deserialize<'de> for Keymap { fn deserialize>(deserializer: D) -> Result { let mut keymap = Self::default(); let entries = BTreeMap::>::deserialize(deserializer)?; - let approve_set = entries.contains_key(&Approve); + let user_keys: Vec = entries.values().flatten().copied().collect(); + let unset: Vec = 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) @@ -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", } diff --git a/crates/plannotator-tui/src/keys/tests.rs b/crates/plannotator-tui/src/keys/tests.rs index 8655541..a8cd797 100644 --- a/crates/plannotator-tui/src/keys/tests.rs +++ b/crates/plannotator-tui/src/keys/tests.rs @@ -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"); +} From de191c7d61fb82c5c7fbb8c525b690c27f52677f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 06:03:04 -0400 Subject: [PATCH 2/2] fix(ui): keep the copy status short so the footer keeps its hints --- crates/plannotator-tui/src/app/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index c09a9ff..37e02f8 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -495,7 +495,7 @@ impl App { if self.clipboard { let _ = crate::delivery::Clipboard.deliver(text); } - self.status = Some("copied to clipboard".to_owned()); + self.status = Some("copied".to_owned()); } /// Begin editing the body of the annotation under the rail cursor.