collective

Browse / github.com/storytold/pdfcraft / patch

M5: E and B swap the Draw tool and the Eraser; holding E is temporary (#3900)

Dual-Behavior Eraser Tool Shortcut pdate the E shortcut key to support dual-mode behavior: • Tap E (Toggle): Pressing and releasing the key permanently switches the tool to the Eraser. Pressing it again (or tapping B) switches back to the Draw tool. • Hold E (Temporary Switch): Holding down the E key temporarily switches to the Eraser. Releasing the key instantly reverts the tool back to the active Draw tool.

Status
open
Score
0.00
Adopters
0 other operators
Author
AdrianTM (imported from pull request #4916, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+232 −2 in 7 files
Review
pending
Shared
2026-10-28
Id
09fb21f1b48d876940360e61

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/ui-egui/src/app.rs crates/ui-egui/src/app/keys.rs crates/ui-egui/src/commands.rs crates/ui-egui/src/lib.rs crates/ui-egui/tests/it/comments.rs parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -745,3 +745,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.  - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M5, #3900):** E and B swap the Draw tool and the Eraser: a tap of E switches to the Eraser and stays (E again or B goes back), and holding E is the Eraser only while held.diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -54,6 +54,7 @@ impl PdfCraftApp {             right_panel_closed: false,             quick_tool: QuickTool::Select,             space_hand: None,+            eraser_key: None,             full_screen_tool: None,             comment_prefs: Default::default(),             theme: ThemeKind::Light,diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs--- a/crates/ui-egui/src/app/keys.rs+++ b/crates/ui-egui/src/app/keys.rs@@ -3,6 +3,7 @@ use super::*; impl PdfCraftApp {     pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {         use egui::Key;+        self.eraser_key_frame(ctx);         let pasted = self.paste_key_released(ctx);         // "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document         // properties; Escape cancels it rather than clearing a selection), and nothing may run@@ -81,7 +82,48 @@ impl PdfCraftApp {     } } +/// How long E must be held for the Eraser to be only temporary (#3900).+use crate::comments::CommentTool;++const ERASER_HOLD_SECS: f64 = 0.35;+const DRAW: QuickTool = QuickTool::Comment(CommentTool::Ink);+const ERASER: QuickTool = QuickTool::Comment(CommentTool::Eraser);+ impl PdfCraftApp {+    /// Make `tool` the active one and the one its quick-bar group shows, as choosing it from the+    /// bar or its command does.+    fn set_drawing_tool(&mut self, tool: CommentTool) {+        self.comment_prefs.group_tool[tool.group()] = tool;+        self.quick_tool = QuickTool::Comment(tool);+    }++    /// The end of a held E (#3900), settled every frame, even under a dialog, so that the+    /// moment the key comes up is the moment it is read. A hold of [`ERASER_HOLD_SECS`] or more+    /// gives the Draw tool back; a tap leaves the Eraser. The hold is over, with nothing given+    /// back, once the user has chosen another tool. Space's temporary Hand may sit on top of the+    /// Eraser meanwhile: it then returns to Draw when Space is let go.+    pub(super) fn eraser_key_frame(&mut self, ctx: &egui::Context) {+        let Some(since) = self.eraser_key else { return };+        let on_top = self.quick_tool == QuickTool::Hand && self.space_hand == Some(ERASER);+        if self.quick_tool != ERASER && !on_top {+            self.eraser_key = None;+            return;+        }+        if ctx.input(|input| input.key_down(egui::Key::E)) {+            return;+        }+        self.eraser_key = None;+        if ctx.input(|input| input.time) - since >= ERASER_HOLD_SECS {+            if on_top {+                // The Hand gives Draw back when Space is let go, and the bar shows it already.+                self.space_hand = Some(DRAW);+                self.comment_prefs.group_tool[CommentTool::Ink.group()] = CommentTool::Ink;+            } else {+                self.set_drawing_tool(CommentTool::Ink);+            }+        }+    }+     /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it     /// is released. Plain letters, so not while a text field, a form field, a dialog or the     /// palette has the keyboard.@@ -100,6 +142,25 @@ impl PdfCraftApp {         if !free || !plain {             return;         }+        // E and B swap the Draw tool and the Eraser (#3900): a tap of E switches to the Eraser and+        // stays, another tap of E or a tap of B goes back; holding E is the Eraser only while held.+        // Only from one of the two, so E means nothing to any other tool.+        let first_press = |key: Key| {+            ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, pressed: true, repeat: false, .. } if *k == key)))+        };+        if first_press(Key::E) {+            if self.quick_tool == ERASER {+                self.set_drawing_tool(CommentTool::Ink);+                self.eraser_key = None;+            } else if self.quick_tool == DRAW {+                self.set_drawing_tool(CommentTool::Eraser);+                self.eraser_key = Some(ctx.input(|input| input.time));+            }+        }+        if first_press(Key::B) && self.quick_tool == ERASER {+            self.set_drawing_tool(CommentTool::Ink);+            self.eraser_key = None;+        }         if ctx.input(|input| input.key_pressed(Key::V)) {             self.quick_tool = QuickTool::Select;             self.space_hand = None;diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs--- a/crates/ui-egui/src/commands.rs+++ b/crates/ui-egui/src/commands.rs@@ -305,6 +305,9 @@ impl PdfCraftApp {                 let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false };                 self.comment_prefs.group_tool[tool.group()] = tool;                 self.quick_tool = crate::QuickTool::Comment(tool);+                // Chosen on purpose, from the bar, a menu or a key: a held E no longer owns the+                // tool, so letting it go does not take the choice back (#3900).+                self.eraser_key = None;                 // Acrobat opens the Comments panel with the commenting tools, unless the user closed it.                 if self.right.is_none() && !self.comments_panel_closed {                     self.choose_right_panel(Some(RightPanel::Comments));diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -269,6 +269,9 @@ pub struct PdfCraftApp {     pub quick_tool: QuickTool,     /// The tool to go back to when Space, held for a temporary Hand, is released.     space_hand: Option<QuickTool>,+    /// When E went down for the Eraser (from the Draw tool), so that holding it gives the Draw+    /// tool back on release while a tap keeps the Eraser (#3900).+    eraser_key: Option<f64>,     /// The tool before full screen, given back on leaving it (see [`Self::apply_full_screen`]).     full_screen_tool: Option<QuickTool>,     /// Comment author, per-tool colours and widths, pin.diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs--- a/crates/ui-egui/tests/it/comments.rs+++ b/crates/ui-egui/tests/it/comments.rs@@ -644,6 +644,167 @@ fn pen_strokes_stay_unselected_so_writing_continues() {     assert_eq!(h.state().views[0].comments.selected, Some((0, 2))); } +/// The Draw and Eraser tools for the E-key tests, and what the quick bar's group shows for them.+mod eraser_key {+    pub use pdfcraft_ui_egui::QuickTool;+    pub use pdfcraft_ui_egui::comments::CommentTool;++    pub const DRAW: QuickTool = QuickTool::Comment(CommentTool::Ink);+    pub const ERASER: QuickTool = QuickTool::Comment(CommentTool::Eraser);+}++/// The active tool, and the tool the quick bar's group shows for it, which must agree.+fn tool_and_bar(h: &Harness<'static, PdfCraftApp>) -> (QuickTool, pdfcraft_ui_egui::comments::CommentTool) {+    let s = h.state();+    (s.quick_tool, s.comment_prefs.group_tool[eraser_key::CommentTool::Ink.group()])+}++fn eraser_harness() -> Harness<'static, PdfCraftApp> {+    let mut h = harness(|app| app.set_option("quick", "ink").unwrap());+    h.state_mut().set_option("panel", "none").unwrap();+    h.run_steps(2);+    h+}++/// #3900: E and B swap the Draw tool and the Eraser. A tap of E stays on the Eraser until E or B+/// is tapped again; holding E is the Eraser only while it is held. The quick bar follows.+#[test]+fn the_e_key_toggles_the_eraser_when_tapped_and_borrows_it_when_held() {+    use eraser_key::*;+    let mut h = eraser_harness();+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink));++    // A tap goes to the Eraser and stays; the next tap of E goes back.+    h.key_press(egui::Key::E);+    h.run_steps(4);+    assert_eq!(tool_and_bar(&h), (ERASER, CommentTool::Eraser), "a tap of E stays on the Eraser, and the bar shows it");+    h.key_press(egui::Key::E);+    h.run_steps(4);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "tapping E again goes back to Draw");++    // B also goes back.+    h.key_press(egui::Key::E);+    h.run_steps(4);+    assert_eq!(tool_and_bar(&h), (ERASER, CommentTool::Eraser));+    h.key_press(egui::Key::B);+    h.run_steps(4);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "B goes back to Draw");++    // Held: the Eraser while the key is down, Draw again the moment it is let go.+    h.key_down(egui::Key::E);+    h.run_steps(2);+    assert_eq!(tool_and_bar(&h), (ERASER, CommentTool::Eraser), "held E is the Eraser");+    h.run_steps(6);+    assert_eq!(h.state().quick_tool, ERASER, "and stays while it is held");+    h.key_up(egui::Key::E);+    h.run_steps(3);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "letting go gives the Draw tool back");++    // No other tool reads E.+    h.state_mut().set_option("quick", "square").unwrap();+    h.run_steps(2);+    h.key_press(egui::Key::E);+    h.run_steps(3);+    assert_eq!(h.state().quick_tool, QuickTool::Comment(CommentTool::Rectangle), "E leaves other tools alone");+}++/// #3900: E let go while a dialog is open is read when it comes up, not when the dialog closes: a+/// tap stays on the Eraser however long the dialog stays, a hold gives Draw back under it.+#[test]+fn the_e_key_is_settled_when_it_comes_up_even_under_a_dialog() {+    use eraser_key::*;+    // A tap whose key comes up under a dialog that then stays open a long time.+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(1);+    assert_eq!(h.state().quick_tool, ERASER);+    h.state_mut().set_option("dialog", "about").unwrap();+    h.key_up(egui::Key::E);+    h.run_steps(1);+    h.run_steps(8);+    h.state_mut().dialog = None;+    h.run_steps(4);+    assert_eq!(tool_and_bar(&h), (ERASER, CommentTool::Eraser), "a tap is still a tap after the dialog");++    // A hold that comes up under a dialog gives Draw back at once.+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(1);+    h.state_mut().set_option("dialog", "about").unwrap();+    h.run_steps(4);+    h.key_up(egui::Key::E);+    h.run_steps(1);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "a long hold ends with the key, dialog or not");++    // With a dialog open already, E starts nothing.+    let mut h = eraser_harness();+    h.state_mut().set_option("dialog", "about").unwrap();+    h.run_steps(3);+    h.key_press(egui::Key::E);+    h.run_steps(3);+    assert_eq!(h.state().quick_tool, DRAW, "a dialog has the keyboard");+}++/// #3900: choosing a tool while E is held ends the hold, even choosing the Eraser itself.+#[test]+fn choosing_a_tool_while_e_is_held_ends_the_hold() {+    use eraser_key::*;+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(2);+    assert_eq!(h.state().quick_tool, ERASER);+    h.state_mut().set_option("quick", "square").unwrap();+    h.run_steps(2);+    h.state_mut().execute("comment.eraser");+    h.run_steps(6);+    assert_eq!(h.state().quick_tool, ERASER, "the Eraser was chosen on purpose");+    h.key_up(egui::Key::E);+    h.run_steps(3);+    assert_eq!(h.state().quick_tool, ERASER, "and a late release does not take it away");++    // Choosing the Eraser itself, with nothing between: it was the Eraser already, but the+    // choice still ends the hold, whether the bar, a menu or a command made it.+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(6);+    assert!(h.state_mut().execute("comment.eraser"));+    h.run_steps(1);+    h.key_up(egui::Key::E);+    h.run_steps(3);+    assert_eq!(tool_and_bar(&h), (ERASER, CommentTool::Eraser), "choosing the Eraser while E is held keeps it");+}++/// #3900: Space's temporary Hand on top of a held E returns to Draw, whichever key comes up first.+#[test]+fn a_held_e_and_a_held_space_unwind_in_either_order() {+    use eraser_key::*;+    // E up first, while Space still holds the Hand.+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(6);+    h.key_down(egui::Key::Space);+    h.run_steps(2);+    assert_eq!(h.state().quick_tool, QuickTool::Hand, "Space is the Hand");+    h.key_up(egui::Key::E);+    h.run_steps(2);+    h.key_up(egui::Key::Space);+    h.run_steps(3);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "E ended first: Draw comes back with the Hand");++    // Space up first: the Eraser is back under the still-held E, then E gives Draw back.+    let mut h = eraser_harness();+    h.key_down(egui::Key::E);+    h.run_steps(6);+    h.key_down(egui::Key::Space);+    h.run_steps(2);+    h.key_up(egui::Key::Space);+    h.run_steps(2);+    assert_eq!(h.state().quick_tool, ERASER, "Space up gives the Eraser back");+    h.key_up(egui::Key::E);+    h.run_steps(3);+    assert_eq!(tool_and_bar(&h), (DRAW, CommentTool::Ink), "and E up gives Draw");+}+ #[test] fn sticky_note_composer_posts_and_returns_to_select() {     let mut h = harness(|app| app.set_option("quick", "note").unwrap());diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -3739,8 +3739,8 @@ milestone = "M5" status = "shipped" commands = ["comment.eraser"] tools = []-evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/it/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/it/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting"]-notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step. Its thickness setting sizes it on screen (3 to 72 pixels across; the default 2 pt is 12), preview included (#3747)"+evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/it/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/it/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting", "crates/ui-egui/tests/it/comments.rs::the_e_key_toggles_the_eraser_when_tapped_and_borrows_it_when_held"]+notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step. Its thickness setting sizes it on screen (3 to 72 pixels across; the default 2 pt is 12), preview included (#3747). E and B swap the Draw tool and the Eraser (#3900): a tap of E (under 0.35 s) switches to the Eraser and stays, a second tap of E or a tap of B goes back to Draw, and holding E is the Eraser only until it is let go. The keys act only from the Draw tool or the Eraser, and not while a text field, a form field, a dialog or the palette has the keyboard; the shortcut is not yet in the shortcuts list and cannot be rebound"  [[feature]] id = "comment.line"