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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 —
- Full-screen saves, active-window grabs, region capture
- Copy to clipboard or save to file
- Uses `grim`, `slurp`, `wl-clipboard` (installed automatically by deb/rpm/AUR packages; fallback: xdg-desktop-portal)
- Copy text from screen (OCR): drag a region of the shown desktop and get its text on the clipboard (needs `tesseract`; no default shortcut)
- Copy text from screen (OCR): <kbd>Ctrl+Shift+X</kbd>, then drag a region of the shown desktop — or <kbd>Ctrl+A</kbd> for all of it — and get its text on the clipboard (needs `tesseract`)

### Sessions and persistence
- Session persistence is enabled by default for boards, undo/redo history, and tool state
Expand Down Expand Up @@ -552,10 +552,13 @@ sudo dnf install wl-clipboard grim slurp # Fedora

### Copy text from screen (OCR)

`Copy text from screen` recognizes the text in a dragged screen region and copies
it to the clipboard. It is optional: the action has no default shortcut and its
toolbar button is hidden until you turn it on. Install Tesseract and the language
data you configure in `[capture].ocr_languages` (default `eng`):
`Copy text from screen` (<kbd>Ctrl+Shift+X</kbd>) recognizes the text in a
dragged screen region and copies it to the clipboard; <kbd>Ctrl+A</kbd> inside
the selector reads the whole screen instead. A band sweeps the region while
Tesseract runs, and a card reports the outcome — never the recognized text,
which goes only to the clipboard. Its toolbar button is hidden until you turn it
on. Install Tesseract and the language data you configure in
`[capture].ocr_languages` (default `eng`):

An active OCR selection cancels if the displayed screen image changes, so the
selected coordinates can never be applied to replacement freeze or zoom pixels.
Expand Down
4 changes: 2 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ export_all_boards_pdf_file = []
open_capture_folder = ["Ctrl+Alt+O"]

# Select a screen region and copy the text recognized in it (needs Tesseract).
# Unbound by default: "O" is already the orange quick color.
copy_text_from_screen = []
# Ctrl+A inside the selector reads the whole screen instead.
copy_text_from_screen = ["Ctrl+Shift+X"]

# Toggle frozen mode
toggle_frozen_mode = ["Ctrl+Shift+F"]
Expand Down
12 changes: 6 additions & 6 deletions configurator/src/app/update/shortcuts/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,10 +672,10 @@ fn conflict_review_queue_arms_the_next_conflict_after_replace() {
app.is_loading = false;
app.draft
.keybindings
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
app.draft
.keybindings
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
app.draft
.keybindings
.set(KeybindingField::Undo, "Ctrl+Alt+Shift+Y".to_string());
Expand Down Expand Up @@ -713,10 +713,10 @@ fn conflict_review_cancel_stops_the_queue() {
let (mut app, _effects) = ConfiguratorApp::new_app();
app.draft
.keybindings
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
app.draft
.keybindings
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
let _ = app.handle_shortcut_conflict_review_started();
let _ = app.handle_shortcut_conflict_canceled();
assert!(!app.shortcut_conflict_review);
Expand All @@ -728,10 +728,10 @@ fn jump_to_conflict_selects_the_other_claimant() {
let (mut app, _effects) = ConfiguratorApp::new_app();
app.draft
.keybindings
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
app.draft
.keybindings
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
let _ = app.handle_shortcut_conflict_review_started();
let jump = app
.pending_shortcut_conflict
Expand Down
25 changes: 17 additions & 8 deletions configurator/src/models/keybindings/conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,11 +389,20 @@ mod tests {

#[test]
fn conflict_lookup_returns_all_claimants() {
// This and the other generic multi-claimant fixtures use a chord no
// shipped default claims. Asserted rather than assumed: a future default
// taking `Ctrl+Shift+Q` should fail here saying why, not as an off-by-one
// somewhere else.
let binding = Shortcut::parse("Ctrl+Shift+Q").expect("parses");
assert!(
claimants_for(&draft(), &binding).is_empty(),
"the fixture chord must stay unbound by default"
);

let mut draft = draft();
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::Undo, "ctrl+shift+x".to_string());
let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses");
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
draft.set(KeybindingField::Undo, "ctrl+shift+q".to_string());
let claimants = claimants_for(&draft, &binding);
let fields: Vec<_> = claimants.iter().map(|claim| claim.field).collect();
assert!(fields.contains(&Some(KeybindingField::ClearCanvas)));
Expand Down Expand Up @@ -431,10 +440,10 @@ mod tests {
let mut draft = draft();
draft.set(
KeybindingField::ToggleHelp,
"F10, F1, Ctrl+Shift+X".to_string(),
"F10, F1, Ctrl+Shift+Q".to_string(),
);
draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+X".to_string());
let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses");
draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+Q".to_string());
let binding = Shortcut::parse("Ctrl+Shift+Q").expect("parses");
let claimants = other_claimants(&draft, KeybindingField::ClearCanvas, &binding);
apply_recorded_replace(
&mut draft,
Expand All @@ -450,7 +459,7 @@ mod tests {
assert_eq!(draft.value_for(KeybindingField::Undo), Some("Ctrl+Z"));
assert_eq!(
draft.value_for(KeybindingField::ClearCanvas),
Some("E, Ctrl+Shift+X")
Some("E, Ctrl+Shift+Q")
);
}

Expand Down
10 changes: 5 additions & 5 deletions configurator/src/models/keybindings/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,8 @@ mod tests {
#[test]
fn conflict_filter_includes_every_claimant() {
let (mut draft, defaults) = drafts();
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults);
let visible = summary.visible_fields(
ShortcutManagerFilter::Conflicts,
Expand Down Expand Up @@ -651,10 +651,10 @@ mod tests {
#[test]
fn next_review_conflict_names_the_other_claimant() {
let (mut draft, _defaults) = drafts();
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string());
draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string());
draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string());
let (field, binding, claimants) = next_review_conflict(&draft).expect("conflict");
assert_eq!(binding.to_string(), "Ctrl+Shift+X");
assert_eq!(binding.to_string(), "Ctrl+Shift+Q");
assert!(claimants.iter().any(|claim| claim.field == Some(field)
|| matches!(
claim.field,
Expand Down
23 changes: 19 additions & 4 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1554,8 +1554,9 @@ the result to the clipboard. It reads the underlying screen capture only —
never your annotations, the toolbars, or any other wayscriber chrome — and it
does not change the active tool, the drawing history, or the board.

- The action is `copy_text_from_screen`. It has **no default shortcut**, because
`O` is already the orange quick color; bind one in the configurator or in
- The action is `copy_text_from_screen`, bound to <kbd>Ctrl+Shift+X</kbd>
("extract text"). `O` belongs to the orange quick color, so it takes a letter
the rest of the capture family had left; rebind it in the configurator or in
`[keybindings.capture]`.
- It is also in the command palette (search for "OCR"), and as an optional top
toolbar button (`top.utility.ocr`), hidden by default like Screenshot.
Expand All @@ -1566,6 +1567,20 @@ does not change the active tool, the drawing history, or the board.
does nothing.
- On a solid whiteboard or blackboard with no visible screen capture, OCR
refuses rather than reading the board.
- <kbd>Ctrl+A</kbd> reads the whole displayed image, so a full screen of text
does not need a drag across the whole output. The selector says so along the
top until your first drag, the same hint strip the region picker uses and
under the same `[capture.region] show_legend` setting.
- While recognition runs, a band sweeps the region being read. When it finishes,
a short card beside the region says what happened — copied and how many
characters, no text found, or that recognition failed — and fades after a few
seconds. Any click, touch, stylus press or key dismisses the card early; a
sweep still waiting on the recognizer is left alone, so a stray keystroke
cannot discard a result that is about to arrive. The card never shows the
recognized text: Wayscriber keeps screen contents out of its own UI, and the
text goes only to the clipboard. With `[ui] reduced_motion` set, the region is
marked with a static tint instead of a moving band and the card appears as
soon as the result does.
- An active OCR selection cancels if the displayed screen image changes, the
zoom level or pan changes, or freeze, output, scale, or display layout state
is replaced.
Expand Down Expand Up @@ -2067,8 +2082,8 @@ export_all_boards_pdf_file = []
open_capture_folder = ["Ctrl+Alt+O"]

# Select a screen region and copy the text recognized in it (needs Tesseract).
# Unbound by default: "O" is already the orange quick color.
copy_text_from_screen = []
# Ctrl+A inside the selector reads the whole screen instead.
copy_text_from_screen = ["Ctrl+Shift+X"]

# Toggle frozen mode
toggle_frozen_mode = ["Ctrl+Shift+F"]
Expand Down
14 changes: 11 additions & 3 deletions src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,15 @@ pub(super) fn run_event_loop(
// settled fade contributes nothing.
let animation_timeout = min_timeout(
min_timeout(
state.ui_animation_timeout(now),
state.top_strip_fade_timeout(now),
min_timeout(
state.ui_animation_timeout(now),
state.top_strip_fade_timeout(now),
),
state.inline_toolbar_tooltip_timeout(now),
),
state.inline_toolbar_tooltip_timeout(now),
// A still OCR card under reduced motion asks for no frames, so it
// needs one deadline to be taken away on.
state.input_state.ocr_scan_wake_after(now),
);
let toolbar_handoff_timeout = state.toolbar_drag_handoff_timeout(now);
let autosave_timeout = session_save::autosave_timeout(state, now);
Expand Down Expand Up @@ -260,6 +265,9 @@ pub(super) fn run_event_loop(
if !capture_active && state.ui_animation_due(std::time::Instant::now()) {
state.input_state.needs_redraw = true;
}
if state.input_state.ocr_scan_due(std::time::Instant::now()) {
state.input_state.needs_redraw = true;
}

// When the radial paint deadline passes, this requests the redraw
// that paints the menu (no-op before the deadline and after paint).
Expand Down
86 changes: 52 additions & 34 deletions src/backend/wayland/handlers/keyboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ impl KeyboardHandler for WaylandState {
// Any fresh key press ends the previous auto-repeat; a repeatable one
// re-arms it at the end of this handler.
self.clear_key_repeat();
// A finished scan card is transient chrome: the next interaction of any
// kind takes it away rather than making the user wait it out.
self.input_state.dismiss_ocr_scan_result();
if self.input_state.region_is_engaged() {
if matches!(key, Key::Space) && self.toggle_region_window_snap() {
self.update_pointer_cursor(false, conn);
Expand Down Expand Up @@ -228,7 +231,7 @@ impl KeyboardHandler for WaylandState {
}
return;
}
if region_capture_select_all_pressed(
if region_select_all_pressed(
self.input_state.region_is_active(),
self.input_state.region_state().purpose(),
self.input_state.modifiers.ctrl,
Expand Down Expand Up @@ -567,14 +570,20 @@ fn screen_modal_swallows_key_release(
region_selector_engaged || eyedropper_engaged
}

fn region_capture_select_all_pressed(
/// `Ctrl+A` takes the whole displayed image. Recognition wants it as much as
/// capture does — reading a full screen of text should not need a drag across
/// the whole output — so Measure is the only purpose left out, having nothing
/// to submit.
fn region_select_all_pressed(
region_active: bool,
purpose: Option<crate::input::state::RegionPurposeTag>,
ctrl: bool,
key: Key,
) -> bool {
use crate::input::state::RegionPurposeTag;

region_active
&& purpose.is_some_and(crate::input::state::RegionPurposeTag::is_capture)
&& purpose.is_some_and(|purpose| purpose.is_capture() || purpose == RegionPurposeTag::Ocr)
&& ctrl
&& matches!(key, Key::Char('a' | 'A'))
}
Expand Down Expand Up @@ -666,39 +675,48 @@ mod tests {
}

#[test]
fn ctrl_a_selects_all_only_for_an_active_capture_picker() {
fn ctrl_a_selects_all_for_capture_and_recognition_but_not_measure() {
use crate::input::state::RegionPurposeTag;

assert!(region_capture_select_all_pressed(
true,
Some(RegionPurposeTag::CaptureDeliver),
true,
Key::Char('a'),
));
assert!(region_capture_select_all_pressed(
true,
Some(RegionPurposeTag::CaptureInteractive),
true,
Key::Char('A'),
));
assert!(!region_capture_select_all_pressed(
false,
Some(RegionPurposeTag::CaptureDeliver),
true,
Key::Char('a'),
));
assert!(!region_capture_select_all_pressed(
true,
Some(RegionPurposeTag::Ocr),
true,
Key::Char('a'),
));
assert!(!region_capture_select_all_pressed(
true,
Some(RegionPurposeTag::CaptureDeliver),
false,
Key::Char('a'),
));
for purpose in [
RegionPurposeTag::CaptureDeliver,
RegionPurposeTag::CaptureInteractive,
RegionPurposeTag::Ocr,
] {
assert!(
region_select_all_pressed(true, Some(purpose), true, Key::Char('a')),
"{purpose:?} can submit the whole image"
);
assert!(region_select_all_pressed(
true,
Some(purpose),
true,
Key::Char('A')
));
}

assert!(
!region_select_all_pressed(true, Some(RegionPurposeTag::Measure), true, Key::Char('a')),
"measure has nothing to submit"
);
assert!(
!region_select_all_pressed(
false,
Some(RegionPurposeTag::CaptureDeliver),
true,
Key::Char('a')
),
"no selector open"
);
assert!(
!region_select_all_pressed(
true,
Some(RegionPurposeTag::CaptureDeliver),
false,
Key::Char('a')
),
"plain A is a colour, not select-all"
);
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions src/backend/wayland/handlers/pointer/press.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ impl WaylandState {
.note_input_hud_mouse(&input_hud_button_label(button), self.input_state.modifiers);
}

// A finished scan card is transient chrome: the next interaction of any
// kind takes it away rather than making the user wait it out.
self.input_state.dismiss_ocr_scan_result();

let help_press_source = HelpOverlayPressSource::Pointer(button);
if !self.input_state.show_help {
// A new press proves any older help-owned sequence for this button
Expand Down
4 changes: 4 additions & 0 deletions src/backend/wayland/handlers/tablet/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ impl Dispatch<ZwpTabletToolV2, ()> for WaylandState {
if state.stylus_contact_retired {
return;
}
// Before any surface-specific routing: a press on the Review
// bar, a toolbar or an eyedropper never reaches the frame
// commit, and the card has to go for all of them.
state.input_state.dismiss_ocr_scan_result();
if state.input_state.region_is_active() {
if state.stylus_on_toolbar {
state.cancel_region_for_toolbar_interaction();
Expand Down
3 changes: 3 additions & 0 deletions src/backend/wayland/handlers/touch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ impl WaylandState {
surface: &wl_surface::WlSurface,
position: (f64, f64),
) -> TouchTarget {
// A finished scan card is transient chrome: the next interaction of any
// kind takes it away rather than making the user wait it out.
self.input_state.dismiss_ocr_scan_result();
let target = self.classify_touch_surface(surface);
let Some(screen_position) = self.touch_screen_position(surface, position, target) else {
return TouchTarget::Other;
Expand Down
3 changes: 3 additions & 0 deletions src/backend/wayland/state/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ pub struct StateData {
pub(super) prev_color_picker_damage: Option<crate::util::Rect>,
pub(super) prev_tool_preview_damage: Option<crate::util::Rect>,
pub(super) prev_shape_measure_badge_damage: Option<crate::util::Rect>,
/// Union the OCR scan overlay covered last frame, so its sweep is cleared.
pub(super) prev_ocr_scan_damage: Option<crate::util::Rect>,
/// Previous-frame strips for Measure Mode's crosshair, frame, and readout.
pub(super) prev_measure_picker_damage: Vec<crate::util::Rect>,
/// Idle-fade engine for the top-strip islands; its value is published
Expand Down Expand Up @@ -334,6 +336,7 @@ impl StateData {
prev_color_picker_damage: None,
prev_tool_preview_damage: None,
prev_shape_measure_badge_damage: None,
prev_ocr_scan_damage: None,
prev_measure_picker_damage: Vec::new(),
top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade::new(),
shortcut_coach: super::onboarding::ShortcutCoachSession::default(),
Expand Down
Loading
Loading