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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_ignored = "0.1"
schemars = { version = "1.2", features = ["derive"], optional = true }
libc = "0.2"
# Securely created, self-deleting temporary files for the OCR engine handoff.
tempfile = "3"
flate2 = "1.0"
zune-jpeg = "0.5"
# Grapheme/word segmentation for the text-editor caret and selection.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +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)

### Sessions and persistence
- Session persistence is enabled by default for boards, undo/redo history, and tool state
Expand Down Expand Up @@ -329,6 +330,9 @@ If you are on a stable NixOS channel and want the newest release, use [the proje
grim
slurp
wl-clipboard

# Optional: copy text from screen (OCR)
(tesseract.override { enableLanguages = [ "eng" ]; })
];
}
```
Expand Down Expand Up @@ -538,6 +542,19 @@ sudo apt-get install wl-clipboard grim slurp # Debian/Ubuntu
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`):

```bash
sudo pacman -S tesseract tesseract-data-eng # Arch/Omarchy
sudo apt-get install tesseract-ocr # Debian/Ubuntu
sudo dnf install tesseract tesseract-langpack-eng # Fedora
```

---

## First launch
Expand Down
13 changes: 13 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ export_all_boards_pdf_file = []
# Open the most recent capture folder
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 = []

# Toggle frozen mode
toggle_frozen_mode = ["Ctrl+Shift+F"]

Expand Down Expand Up @@ -628,6 +632,9 @@ rebind_modifier = "ctrl_shift"
[ui.toolbar.items]
# Checked in the configurator means shown; IDs listed here are hidden.
# The screenshot toolbar button is hidden by default.
# Copy text (OCR) is hidden by a built-in baseline instead of by this list,
# so upgrading a config written before it existed cannot reveal it; enable it
# through toolbar customization, which records an entry in `shown` below.
# Section-level ids (side.group.*) act as explicit overrides that beat the
# layout-mode baseline and survive mode switches.
hidden = [
Expand Down Expand Up @@ -660,6 +667,7 @@ top_controls = [
"top.utility.text",
"top.utility.sticky-note",
"top.utility.screenshot",
"top.utility.ocr",
"top.utility.clear-canvas",
"top.utility.highlight",
]
Expand Down Expand Up @@ -1384,6 +1392,11 @@ copy_to_clipboard = true
# Use --no-exit-after-capture to keep the overlay open for a run.
exit_after_capture = false

# Languages used by "Copy text from screen" (OCR), in Tesseract's
# plus-separated form, for example "eng" or "eng+deu". The matching Tesseract
# language packages must be installed. OCR also needs `enabled = true` above.
ocr_languages = "eng"

# ═══════════════════════════════════════════════════════════════════════════════
# EXPORT SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
Expand Down
14 changes: 14 additions & 0 deletions configurator/src/app/pages/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use crate::models::{
PdfTransparentBackgroundOption, TabId, TextField, ToggleField,
};

use wayscriber::config::validate_ocr_languages;

use super::super::search::SearchArea;
use super::super::state::ConfiguratorApp;
use super::color_rows::{ResolvedColor, color_row};
Expand Down Expand Up @@ -56,6 +58,18 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
"",
|app| app.draft.capture_exit_after,
|value| Message::ToggleChanged(ToggleField::CaptureExitAfter, value),
)
.entry_row_validated(
"OCR languages (e.g. eng, eng+deu \u{2014} needs the matching Tesseract packages)",
|app| app.draft.capture_ocr_languages.clone(),
|value| Message::TextChanged(TextField::CaptureOcrLanguages, value),
|app| {
validate_ocr_languages(&app.draft.capture_ocr_languages)
.err()
.map(|reason| {
format!("{reason}. Use Tesseract codes joined by '+', such as eng+deu.")
})
},
);

page.group_in_area("PDF export", SearchArea::CapturePdf)
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/draft/from_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ impl ConfigDraft {
capture_format: config.capture.format.clone(),
capture_copy_to_clipboard: config.capture.copy_to_clipboard,
capture_exit_after: config.capture.exit_after_capture,
capture_ocr_languages: config.capture.ocr_languages.clone(),
export_pdf_filename_template: config
.export
.pdf
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/draft/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ pub struct ConfigDraft {
pub capture_format: String,
pub capture_copy_to_clipboard: bool,
pub capture_exit_after: bool,
pub capture_ocr_languages: String,
pub export_pdf_filename_template: String,
pub export_pdf_all_boards_filename_template: String,
pub export_pdf_page_size: PdfPageSizeOption,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/setters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ impl ConfigDraft {
TextField::CaptureSaveDirectory => self.capture_save_directory = value,
TextField::CaptureFilename => self.capture_filename_template = value,
TextField::CaptureFormat => self.capture_format = value,
TextField::CaptureOcrLanguages => self.capture_ocr_languages = value,
TextField::ExportPdfFilenameTemplate => self.export_pdf_filename_template = value,
TextField::ExportPdfAllBoardsFilenameTemplate => {
self.export_pdf_all_boards_filename_template = value
Expand Down
11 changes: 9 additions & 2 deletions configurator/src/models/config/to_config/capture.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
use super::super::draft::ConfigDraft;
use crate::models::error::FormError;
use wayscriber::config::Config;
use wayscriber::config::{Config, validate_ocr_languages};

impl ConfigDraft {
pub(super) fn apply_capture(&self, config: &mut Config, _errors: &mut Vec<FormError>) {
pub(super) fn apply_capture(&self, config: &mut Config, errors: &mut Vec<FormError>) {
config.capture.enabled = self.capture_enabled;
config.capture.save_directory = self.capture_save_directory.clone();
config.capture.filename_template = self.capture_filename_template.clone();
config.capture.format = self.capture_format.clone();
config.capture.copy_to_clipboard = self.capture_copy_to_clipboard;
config.capture.exit_after_capture = self.capture_exit_after;
match validate_ocr_languages(&self.capture_ocr_languages) {
Ok(languages) => config.capture.ocr_languages = languages,
Err(reason) => errors.push(FormError::new(
"capture.ocr_languages",
format!("OCR languages: {reason}."),
)),
}
}
}
1 change: 1 addition & 0 deletions configurator/src/models/fields/toggles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub enum TextField {
CaptureSaveDirectory,
CaptureFilename,
CaptureFormat,
CaptureOcrLanguages,
ExportPdfFilenameTemplate,
ExportPdfAllBoardsFilenameTemplate,
ExportPdfCustomWidth,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/keybindings/field/config/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ impl KeybindingField {
Self::ExportBoardPdfFile => &config.capture.export_board_pdf_file,
Self::ExportAllBoardsPdfFile => &config.capture.export_all_boards_pdf_file,
Self::OpenCaptureFolder => &config.capture.open_capture_folder,
Self::CopyTextFromScreen => &config.capture.copy_text_from_screen,
Self::ToggleFrozenMode => &config.zoom.toggle_frozen_mode,
Self::ZoomIn => &config.zoom.zoom_in,
Self::ZoomOut => &config.zoom.zoom_out,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/keybindings/field/config/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ impl KeybindingField {
Self::ExportBoardPdfFile => config.capture.export_board_pdf_file = value,
Self::ExportAllBoardsPdfFile => config.capture.export_all_boards_pdf_file = value,
Self::OpenCaptureFolder => config.capture.open_capture_folder = value,
Self::CopyTextFromScreen => config.capture.copy_text_from_screen = value,
Self::ToggleFrozenMode => config.zoom.toggle_frozen_mode = value,
Self::ZoomIn => config.zoom.zoom_in = value,
Self::ZoomOut => config.zoom.zoom_out = value,
Expand Down
2 changes: 2 additions & 0 deletions configurator/src/models/keybindings/field/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ impl KeybindingField {
Self::ExportBoardPdfFile => "Export board: PDF file",
Self::ExportAllBoardsPdfFile => "Export all boards: PDF file",
Self::OpenCaptureFolder => "Open capture folder",
Self::CopyTextFromScreen => "Copy text from screen (OCR)",
Self::ToggleFrozenMode => "Toggle freeze",
Self::ZoomIn => "Zoom in",
Self::ZoomOut => "Zoom out",
Expand Down Expand Up @@ -281,6 +282,7 @@ impl KeybindingField {
Self::ExportBoardPdfFile => "export_board_pdf_file",
Self::ExportAllBoardsPdfFile => "export_all_boards_pdf_file",
Self::OpenCaptureFolder => "open_capture_folder",
Self::CopyTextFromScreen => "copy_text_from_screen",
Self::ToggleFrozenMode => "toggle_frozen_mode",
Self::ZoomIn => "zoom_in",
Self::ZoomOut => "zoom_out",
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/keybindings/field/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ impl KeybindingField {
Self::ExportBoardPdfFile,
Self::ExportAllBoardsPdfFile,
Self::OpenCaptureFolder,
Self::CopyTextFromScreen,
Self::ToggleFrozenMode,
Self::ZoomIn,
Self::ZoomOut,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/keybindings/field/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub enum KeybindingField {
ExportBoardPdfFile,
ExportAllBoardsPdfFile,
OpenCaptureFolder,
CopyTextFromScreen,
ToggleFrozenMode,
ZoomIn,
ZoomOut,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/keybindings/field/tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ impl KeybindingField {
| Self::ExportBoardPdfFile
| Self::ExportAllBoardsPdfFile
| Self::OpenCaptureFolder
| Self::CopyTextFromScreen
| Self::ToggleFrozenMode
| Self::ZoomIn
| Self::ZoomOut
Expand Down
39 changes: 39 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1380,12 +1380,47 @@ copy_to_clipboard = true
# When false, clipboard-only captures still auto-exit by default.
# Use --no-exit-after-capture to keep the overlay open for a run.
exit_after_capture = false

# Languages for "Copy text from screen" (OCR), in Tesseract's plus-separated
# form. The matching Tesseract language packages must be installed.
ocr_languages = "eng"
```

**Tips:**
- Set `copy_to_clipboard = false` if you prefer file-only captures.
- Clipboard-only shortcuts ignore the save directory automatically.
- `wl-clipboard`, `grim`, and `slurp` are installed automatically by deb/rpm/AUR packages. For source/tarball installs, add them manually; otherwise wayscriber falls back to `xdg-desktop-portal`.

#### Copy text from screen (OCR)

`Copy text from screen` selects a region of the desktop image wayscriber is
already showing, recognizes the text in it with a local Tesseract, and copies
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
`[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.
- `ocr_languages` accepts one language or several joined with `+`
(`eng`, `eng+deu`). Only letters, digits, `_` and `-` are accepted; anything
else falls back to `eng`.
- OCR obeys `enabled` above: with capture disabled, the action reports that and
does nothing.
- On a solid whiteboard or blackboard with no visible screen capture, OCR
refuses rather than reading the board.

**Requirements:** the `tesseract` command plus the language data for every code
in `ocr_languages`, and `wl-copy` (from `wl-clipboard`) for the clipboard write.

| Distribution | Packages |
| --- | --- |
| Arch / Omarchy | `tesseract`, `tesseract-data-eng` |
| Debian / Ubuntu | `tesseract-ocr` (depends on the English data) |
| Fedora | `tesseract`, `tesseract-langpack-eng` |
| Nix | the `tesseract` package/wrapper with `eng` enabled |
- Use `--exit-after-capture` / `--no-exit-after-capture` to override exit behavior per run.

### `[export.pdf]` - PDF Export
Expand Down Expand Up @@ -1839,6 +1874,10 @@ export_all_boards_pdf_file = []
# Open the most recent capture folder
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 = []

# Toggle frozen mode
toggle_frozen_mode = ["Ctrl+Shift+F"]

Expand Down
1 change: 1 addition & 0 deletions docs/codebase-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ Notifications are sent via `notification::send_notification_async`, keeping all
| `src/config/` | Config parsing, defaults, keybinding map. |
| `src/runtime_ui_state/` | Generated UI preference wire format, seed registry, guarded persistence, reset, and recovery state machines. |
| `src/session/` | Configured and named session persistence, snapshots, sidecars, locks, and catalog metadata. |
| `src/ocr/` | Screen text recognition: capacity-one controller, Tesseract adapter, typed outcomes. |
| `src/notification.rs` | Desktop notifications for capture results and the update notice. |
| `src/update_check/` | Opt-outable "a newer release exists" check: version ordering, manifest trust rules, `curl`/`wget` transport, cache. Installs nothing. |
| `src/about_window/` | Standalone About dialog: content/layout/interaction split, update card, diagnostics copy. |
Expand Down
2 changes: 2 additions & 0 deletions packaging/.SRCINFO
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pkgbase = wayscriber
depends = wl-clipboard
depends = grim
depends = slurp
optdepends = tesseract: copy text from screen (OCR)
optdepends = tesseract-data-eng: English language data for OCR
source = wayscriber-0.9.23.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v0.9.23.tar.gz
sha256sums = SKIP

Expand Down
4 changes: 4 additions & 0 deletions packaging/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ depends=(
'grim'
'slurp'
)
optdepends=(
'tesseract: copy text from screen (OCR)'
'tesseract-data-eng: English language data for OCR'
)
makedepends=(
'cargo'
)
Expand Down
7 changes: 7 additions & 0 deletions packaging/package.wayscriber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ overrides:
- wl-clipboard
- grim
- slurp
# Screen text recognition is opt-in (no default shortcut, hidden toolbar
# button), so the engine and its language data are not a hard dependency.
recommends:
- tesseract-ocr
rpm:
depends:
# Keep the glibc floor aligned with MAX_GLIBC_VERSION in tools/package.sh.
Expand All @@ -135,5 +139,8 @@ overrides:
- wl-clipboard
- grim
- slurp
recommends:
- tesseract
- tesseract-langpack-eng
rpm:
arch: x86_64
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- `src/main.rs` is a thin wrapper that returns `wayscriber::run_from_env()`.
- `src/lib.rs` owns logging/error wiring and the canonical module graph; its public entry facade routes CLI outcomes through `src/app/` into `src/daemon/` or the active Wayland backend.
- Reusable modules remain public for integration tests and the configurator, while runtime modules are private library implementation details.
- Major domains are `domain`, `backend`, `input`, `draw`, `ui`, `capture`, `config`, `session`, `daemon`, `canvas_export`, `paths`, `toolbar_icons`, `render_profiles`, and `runtime_capabilities`.
- Major domains are `domain`, `backend`, `input`, `draw`, `ui`, `capture`, `ocr`, `config`, `session`, `daemon`, `canvas_export`, `paths`, `toolbar_icons`, `render_profiles`, and `runtime_capabilities`.

## Invariants
- Keep Wayland/runtime side effects in backend/about-window code; keep reusable data, validation, rendering helpers, and path logic in library modules.
Expand Down
2 changes: 2 additions & 0 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ pub(super) fn handle_pending_actions(
state.poll_hex_copy_completion();
state.poll_text_copy_completion();
state.poll_text_paste_completion();
state.poll_ocr_completion();
state.poll_session_file_dialog_completion(qh);
state.drain_clipboard_requests();
state.handle_pending_eyedropper_toggle();
state.handle_pending_ocr_request();
// Copy/paste-hex requests from the color picker popup's pointer release are
// drained here: unlike the toolbar/key paths, that release has no other
// drain site. An accepted quick-color recolor is queued from the same
Expand Down
2 changes: 2 additions & 0 deletions src/backend/wayland/handlers/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl CompositorHandler for WaylandState {
self.zoom
.handle_resize(phys_w, phys_h, &mut self.input_state);
self.cancel_eyedropper_if_source_missing();
self.cancel_ocr_if_source_missing();
self.toolbar
.maybe_update_scale(self.surface.current_output().as_ref(), scale);
self.toolbar.mark_dirty();
Expand Down Expand Up @@ -170,6 +171,7 @@ impl CompositorHandler for WaylandState {
self.zoom
.handle_resize(phys_w, phys_h, &mut self.input_state);
self.cancel_eyedropper_if_source_missing();
self.cancel_ocr_if_source_missing();

// If freeze-on-start was requested, trigger it once the surface is configured and active.
if self.pending_freeze_on_start() {
Expand Down
10 changes: 10 additions & 0 deletions src/backend/wayland/handlers/keyboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ 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();
if self.input_state.ocr_is_engaged() {
// Every other shortcut is swallowed while the selector is up so a
// key cannot change the active tool mid-drag.
if matches!(key, Key::Escape)
|| self.input_state.action_for_key(key) == Some(Action::CopyTextFromScreen)
{
self.cancel_ocr();
}
return;
}
if self.input_state.eyedropper_is_engaged() {
if matches!(key, Key::Escape)
|| self.input_state.action_for_key(key) == Some(Action::PickScreenColor)
Expand Down
Loading
Loading