From 7451740a9dffc3ec1b49ae459e087c075222b6f9 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 2 Aug 2026 21:36:33 +0300 Subject: [PATCH 1/8] screen-toolkit: add Noctalia v5 plugin --- screen-toolkit/README.md | 161 +++++ screen-toolkit/panel.luau | 149 +++++ screen-toolkit/plugin.toml | 195 ++++++ screen-toolkit/result.luau | 334 ++++++++++ screen-toolkit/scripts/capture.sh | 96 +++ screen-toolkit/scripts/color-picker.sh | 61 ++ screen-toolkit/scripts/lens-upload.sh | 32 + screen-toolkit/scripts/ocr.sh | 94 +++ screen-toolkit/scripts/record.sh | 100 +++ screen-toolkit/scripts/share-upload.sh | 81 +++ screen-toolkit/service.luau | 876 +++++++++++++++++++++++++ screen-toolkit/shortcut.luau | 29 + screen-toolkit/thumbnail.webp | Bin 0 -> 6908 bytes screen-toolkit/translations/en.json | 219 +++++++ screen-toolkit/widget.luau | 55 ++ 15 files changed, 2482 insertions(+) create mode 100644 screen-toolkit/README.md create mode 100644 screen-toolkit/panel.luau create mode 100644 screen-toolkit/plugin.toml create mode 100644 screen-toolkit/result.luau create mode 100755 screen-toolkit/scripts/capture.sh create mode 100755 screen-toolkit/scripts/color-picker.sh create mode 100755 screen-toolkit/scripts/lens-upload.sh create mode 100755 screen-toolkit/scripts/ocr.sh create mode 100755 screen-toolkit/scripts/record.sh create mode 100755 screen-toolkit/scripts/share-upload.sh create mode 100644 screen-toolkit/service.luau create mode 100644 screen-toolkit/shortcut.luau create mode 100644 screen-toolkit/thumbnail.webp create mode 100644 screen-toolkit/translations/en.json create mode 100644 screen-toolkit/widget.luau diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md new file mode 100644 index 00000000..6443a564 --- /dev/null +++ b/screen-toolkit/README.md @@ -0,0 +1,161 @@ +# Screen Toolkit + +A unified set of screen utilities for the Noctalia shell: color picking, OCR, +QR/barcode scanning, palette extraction, Google Lens search, annotation, pixel +measuring, screen recording, and image sharing — all in one place. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `alexander/screen-toolkit` | +| Entries | Bar widget: `widget`; control-center shortcut: `toggle`; panels: `panel` (tools), `result` (result view); service: `service` | + +## Requirements + +Install these on `PATH`. Without them the related tools notify you instead of +working. + +- **[slurp](https://github.com/Powerline1/slurp)** — region selection +- **grim** — screen capture +- **hyprpicker** — pixel color picking +- **tesseract** — OCR engine (plus your language packs, e.g. `tesseract-data-eng`) +- **imagemagick** — image processing +- **zbar** — QR / barcode scanning (`zbarimg`) +- **curl** + **jq** — image uploads (uguu.se / x02.me) and Google Lens +- **ffmpeg** — recording / GIF conversion +- **gpu-screen-recorder** — *recommended* for fullscreen recording, especially on NVIDIA (NVENC); falls back to **wl-screenrec** for region capture and **wf-recorder** as a last resort + +Optional: + +- **swappy** / **satty** — annotation editor (Markup tool) +- **translate-shell** (`trans`) — OCR translation +- **hyprctl** — annotate the focused window (Hyprland only) + +Compositor support: region tools, measure, annotate and recording work on any +Wayland compositor with `wlroots` protocols. `Annotate Window` requires Hyprland +(`hyprctl`). + +## Usage + +Add the `Screen Toolkit` widget to a bar, and/or the shortcut tile in +Settings → Control Center shortcuts. Left-click either one opens the main panel +(or stops a recording); right-clicking the bar widget quick-picks a color. While +a recording is active, the widget and shortcut show a pulsing red dot. + +Open the main tools panel: + +```sh +noctalia msg panel-toggle alexander/screen-toolkit:panel +``` + +The tools panel only holds the tools. When a capture tool finishes, a **result +panel** pops up with the output (color formats, OCR text, QR text, palette) and +its actions; close it to return to the tools panel. + +Region tools (Color, OCR, QR, Palette, Lens, Measure, GIF/MP4 record, Markup) +draw a `slurp` crosshair — drag to select a region, then release. Recording +starts immediately and the bar widget shows the pulsing dot; click the dot, the +widget, the shortcut, or the panel's **Stop** button to end it. Unless "Skip +Save Confirmation" is on, the panel then offers **Save MP4**, **Save GIF**, +**Copy**, and **Discard**. + +The `hide-cursor` setting excludes the cursor from **recordings and screenshots** +(default: hidden). On Hyprland, screenshots move the pointer off-screen for the +capture so a software cursor (common on NVIDIA) never lands in the image; +recordings pass `--no-cursor` / `-cursor no` to the recorder. + +- **Markup** captures the region and opens it in `swappy` (or `satty`). Saving + happens in that editor; satty saves to your screenshot path automatically. + **Markup Window** shows a crosshair — click the window you want to annotate + and it captures that window (Hyprland only). +- **Measure** reports the region's pixel size and copies it to the clipboard. +- **OCR** extracts text and copies it to the clipboard. +- **QR** decodes a code and copies the text to the clipboard. +- **Palette** copies the extracted hex colors (one per line) to the clipboard. +- **Share** uploads the current capture and copies the link (uguu.se by default, + or up.x02.me with an API key). + +Results are delivered to the clipboard with a notification — the panel itself +only holds the tools. Results persist across restarts in the plugin's data +directory; the capture previews live in `/tmp` and are only kept for the +session. + +## Settings + +All settings live in Settings → Plugins (gear on the plugin's row). + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `screenshot-path` | `folder` | `~/Pictures/Screenshots` | Where satty saves annotations. | +| `video-path` | `folder` | `~/Videos` | Where recordings are saved. | +| `filename-format` | `string` | `%Y-%m-%d_%H-%M-%S` | Filename template; the extension is added automatically. | +| `selected-ocr-lang` | `string` | `eng` | Tesseract language code; combine with `+` (e.g. `eng+fra`). | +| `search-engine-url` | `string` | *(Google)* | URL template for "Search text"; `{text}` is URL-encoded. | +| `x02-api-key` | `string` | *(empty)* | up.x02.me key for longer-lived, larger uploads. | +| `x02-expiry` | `select` | `7d` | Link lifetime when an x02 key is set: `1h`, `1d`, `7d`, `30d`, or `permanent`. | +| `share-skip-popover` | `bool` | `false` | Copy the share link straight to the clipboard (the panel has no share popover, so links always copy; kept for parity). | +| `record-audio-out` | `bool` | `false` | Record the desktop's audio output. | +| `record-audio-in` | `bool` | `false` | Record the default microphone. | +| `hide-cursor` | `bool` | `true` | Exclude the cursor from recordings and screenshots. On Hyprland, screenshots briefly move the pointer off-screen during capture. | +| `record-codec` | `select` | `h264` | Codec for `gpu-screen-recorder` fullscreen capture: `h264`, `hevc`, or `av1`. `h264` is the safest NVIDIA NVENC default; `av1` needs a recent GPU. | +| `record-fps` | `int` | `60` | Frame rate for `gpu-screen-recorder` fullscreen capture (15–240). | +| `record-skip-confirmation` | `bool` | `false` | Save automatically when a recording ends, skipping the save dialog. | +| `record-copy-to-clipboard` | `bool` | `false` | Finalize to MP4 and copy the file URI when recording ends. | +| `gif-max-seconds` | `int` | `30` | Cap for GIF recordings (1–600 s). | + +## IPC + +The service is a singleton with no output, so the IPC target is `all`: + +```sh +noctalia msg plugin alexander/screen-toolkit:service all toggle +noctalia msg plugin alexander/screen-toolkit:service all colorPicker +noctalia msg plugin alexander/screen-toolkit:service all ocr +noctalia msg plugin alexander/screen-toolkit:service all qr +noctalia msg plugin alexander/screen-toolkit:service all palette +noctalia msg plugin alexander/screen-toolkit:service all lens +noctalia msg plugin alexander/screen-toolkit:service all measure +noctalia msg plugin alexander/screen-toolkit:service all annotate +noctalia msg plugin alexander/screen-toolkit:service all annotateFullscreen +noctalia msg plugin alexander/screen-toolkit:service all annotateWindow +noctalia msg plugin alexander/screen-toolkit:service all record +noctalia msg plugin alexander/screen-toolkit:service all recordMp4 +noctalia msg plugin alexander/screen-toolkit:service all recordFullscreen +noctalia msg plugin alexander/screen-toolkit:service all recordFullscreenMp4 +noctalia msg plugin alexander/screen-toolkit:service all recordStop +noctalia msg plugin alexander/screen-toolkit:service all recordSaveMp4 +noctalia msg plugin alexander/screen-toolkit:service all recordSaveGif +noctalia msg plugin alexander/screen-toolkit:service all recordDiscard +``` + +`ocrTranslate` takes a language code payload: + +```sh +noctalia msg plugin alexander/screen-toolkit:service all ocrTranslate en +``` + +## Notes + +- This is a port of the legacy v4 + [screen-toolkit](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/screen-toolkit) + plugin. Tools that relied on freeform v4 QML overlays are adapted: region + selection uses `slurp`, annotation hands off to `swappy`/`satty`, and measure + reports region dimensions instead of drawing a line overlay. **Pin** (floating + screen overlays) and **Webcam Mirror** could not be ported — the v5 plugin UI + has no canvas or always-on-top surfaces — so they are not included. +- Recording auto-detects its backend: **fullscreen** uses `gpu-screen-recorder` + when installed (NVENC hardware encoding — the best option on NVIDIA GPUs, + where wl-screenrec's VAAPI path is unreliable), falling back to + `wl-screenrec` then `wf-recorder`. **Region** capture uses `wl-screenrec` then + `wf-recorder`, because `gpu-screen-recorder` cannot record an arbitrary + sub-region. Microphone audio is only supported by `wl-screenrec`; with + `wf-recorder` only system audio is available, and gpu-screen-recorder's audio + follows its own source selection. +- Region coordinates are captured in physical pixels; `recordFullscreen` + multiplies the focused output's logical geometry by its scale. +- Files are written to your configured screenshot/video directories and the + plugin's persistent data directory (state, color history). Captures in `/tmp` + are transient. +- Network calls: Google Lens upload (uguu.se), share uploads (uguu.se or + up.x02.me), and `xdg-open` for search/URL results. diff --git a/screen-toolkit/panel.luau b/screen-toolkit/panel.luau new file mode 100644 index 00000000..46f838c5 --- /dev/null +++ b/screen-toolkit/panel.luau @@ -0,0 +1,149 @@ +--!nonstrict +-- Screen Toolkit — main [[panel]]. +-- +-- Thin client for the toolkit: a header plus the tool grid grouped into +-- Capture / Annotate / Record sections. It only dispatches actions to the +-- [[service]] through the "command" state channel. Capture tools close the +-- panel first so the capture never includes it. + +local panelOpen = false + +local recording = noctalia.state.get("recording") == true + +-- ── Helpers ──────────────────────────────────────────────────────────────── + +local function tr(key, subst) + if subst then return noctalia.tr(key, subst) end + return noctalia.tr(key) +end + +local function send(action, payload) + noctalia.state.set("command", { action = action, payload = payload }) +end + +-- Capture tools must not see the panel in the frame, so close it first. +local CAPTURE_ACTIONS = { + colorPicker = true, + ocr = true, + qr = true, + palette = true, + lens = true, + measure = true, + annotate = true, + annotateFullscreen = true, + annotateWindow = true, + record = true, + recordMp4 = true, + recordFullscreen = true, + recordFullscreenMp4 = true, +} + +local function runTool(action) + if CAPTURE_ACTIONS[action] then + panel.close() + end + send(action) +end + +local function refresh() + recording = noctalia.state.get("recording") == true +end + +-- ── Header ───────────────────────────────────────────────────────────────── + +local function header() + return ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("panel.title"), flexGrow = 1, fontSize = 16, fontWeight = "bold", color = "on_surface" }), + ui.button({ glyph = "close", onClick = function() panel.close() end }), + }) +end + +-- ── Tool grid ────────────────────────────────────────────────────────────── + +local TOOLS = { + { action = "colorPicker", label_key = "tools.colorpicker", glyph = "palette" }, + { action = "ocr", label_key = "tools.ocr", glyph = "scan" }, + { action = "qr", label_key = "tools.qr", glyph = "qrcode" }, + { action = "palette", label_key = "tools.palette", glyph = "color-swatch" }, + { action = "lens", label_key = "tools.lens", glyph = "search" }, + { action = "measure", label_key = "tools.measure", glyph = "ruler" }, +} + +local ANNOTATE_TOOLS = { + { action = "annotate", label_key = "tools.annotate", glyph = "pencil" }, + { action = "annotateFullscreen", label_key = "tools.annotate_fullscreen", glyph = "maximize" }, + { action = "annotateWindow", label_key = "tools.annotate_window", glyph = "window" }, +} + +local RECORD_TOOLS = { + { action = "record", label_key = "tools.record_gif", glyph = "gif" }, + { action = "recordMp4", label_key = "tools.record_mp4", glyph = "movie" }, + { action = "recordFullscreen", label_key = "tools.record_fullscreen_gif", glyph = "video" }, + { action = "recordFullscreenMp4", label_key = "tools.record_fullscreen_mp4", glyph = "video" }, +} + +local function toolTile(t) + return ui.column({ + key = "tile-" .. t.action, + flexGrow = 1, + gap = 6, + align = "center", + paddingV = 6, + radius = 10, + fill = "surface_variant/0.28", + borderWidth = 1, + border = "outline", + onClick = function() runTool(t.action) end, + }, { + ui.column({ width = 30, height = 30, radius = 9, fill = "primary/0.12", align = "center", justify = "center" }, { + ui.glyph({ name = t.glyph, size = 16, color = "primary" }), + }), + ui.label({ text = tr(t.label_key), maxWidth = 100, maxLines = 1, textAlign = "center", fontSize = 11, color = "on_surface_variant" }), + }) +end + +local function toolsRow(tools) + local row = {} + for _, t in ipairs(tools) do + row[#row + 1] = toolTile(t) + end + return ui.row({ flexGrow = 1, gap = 6 }, row) +end + +local function sectionLabel(text) + return ui.label({ text = text, fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }) +end + +local function toolGrid() + return ui.column({ flexGrow = 1, gap = 4, justify = "space_between" }, { + sectionLabel(tr("panel.section_capture")), + toolsRow(TOOLS), + sectionLabel(tr("panel.section_annotate")), + toolsRow(ANNOTATE_TOOLS), + sectionLabel(tr("panel.section_record")), + toolsRow(RECORD_TOOLS), + }) +end + +-- ── Main render ──────────────────────────────────────────────────────────── + +local function render() + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + header(), + toolGrid(), + })) +end + +-- ── Lifecycle ────────────────────────────────────────────────────────────── + +function onOpen(_context) + panelOpen = true + refresh() + render() +end + +function onClose() + panelOpen = false +end + +noctalia.state.watch("recording", function(v) recording = v == true; if panelOpen then render() end end) diff --git a/screen-toolkit/plugin.toml b/screen-toolkit/plugin.toml new file mode 100644 index 00000000..39b38924 --- /dev/null +++ b/screen-toolkit/plugin.toml @@ -0,0 +1,195 @@ +# Screen Toolkit — a unified set of screen utilities for the Noctalia shell. +# +# Port of the v4 "screen-toolkit" plugin to the v5 plugin API. All tool logic +# (captures, OCR, palette, QR, lens, recording, sharing) lives in a headless +# [[service]]; the bar [[widget]], control-center [[shortcut]], and the main +# [[panel]] are thin clients that read the published state and drive the service +# through the plugin's "command" state channel. + +id = "alexander/screen-toolkit" +name = "Screen Toolkit" +version = "1.0.0" +plugin_api = 9 +author = "alexander" +license = "MIT" +icon = "crosshair" +description = "Screen tools: color picker, OCR, QR, palette, lens, annotate, measure, and recording." +tags = ["recording", "utility", "bar", "panel", "shortcut", "service", "hyprland", "niri", "sway"] +dependencies = [ + "slurp", + "grim", + "hyprpicker", + "tesseract", + "imagemagick", + "zbar", + "curl", + "ffmpeg", + "jq", +] + +# ── Plugin-level settings (shared by every entry, edited in Settings → Plugins) ── + +[[setting]] +key = "screenshot-path" +type = "folder" +label_key = "settings.screenshot_path.label" +description_key = "settings.screenshot_path.description" +default = "~/Pictures/Screenshots" + +[[setting]] +key = "video-path" +type = "folder" +label_key = "settings.video_path.label" +description_key = "settings.video_path.description" +default = "~/Videos" + +[[setting]] +key = "filename-format" +type = "string" +label_key = "settings.filename_format.label" +description_key = "settings.filename_format.description" +default = "%Y-%m-%d_%H-%M-%S" + +[[setting]] +key = "selected-ocr-lang" +type = "string" +label_key = "settings.selected_ocr_lang.label" +description_key = "settings.selected_ocr_lang.description" +default = "eng" + +[[setting]] +key = "search-engine-url" +type = "string" +label_key = "settings.search_engine_url.label" +description_key = "settings.search_engine_url.description" +default = "" + +[[setting]] +key = "x02-api-key" +type = "string" +label_key = "settings.x02_api_key.label" +description_key = "settings.x02_api_key.description" +default = "" + +[[setting]] +key = "x02-expiry" +type = "select" +label_key = "settings.x02_expiry.label" +description_key = "settings.x02_expiry.description" +default = "7d" +options = [ + { value = "1h", label_key = "settings.x02_expiry.options.1h" }, + { value = "1d", label_key = "settings.x02_expiry.options.1d" }, + { value = "7d", label_key = "settings.x02_expiry.options.7d" }, + { value = "30d", label_key = "settings.x02_expiry.options.30d" }, + { value = "permanent", label_key = "settings.x02_expiry.options.permanent" }, +] + +[[setting]] +key = "share-skip-popover" +type = "bool" +label_key = "settings.share_skip_popover.label" +description_key = "settings.share_skip_popover.description" +default = false + +[[setting]] +key = "record-audio-out" +type = "bool" +label_key = "settings.record_audio_out.label" +description_key = "settings.record_audio_out.description" +default = false + +[[setting]] +key = "record-audio-in" +type = "bool" +label_key = "settings.record_audio_in.label" +description_key = "settings.record_audio_in.description" +default = false + +[[setting]] +key = "hide-cursor" +type = "bool" +label_key = "settings.hide_cursor.label" +description_key = "settings.hide_cursor.description" +default = true + +[[setting]] +key = "record-codec" +type = "select" +label_key = "settings.record_codec.label" +description_key = "settings.record_codec.description" +default = "h264" +options = [ + { value = "h264", label_key = "settings.record_codec.options.h264" }, + { value = "hevc", label_key = "settings.record_codec.options.hevc" }, + { value = "av1", label_key = "settings.record_codec.options.av1" }, +] + +[[setting]] +key = "record-fps" +type = "int" +label_key = "settings.record_fps.label" +description_key = "settings.record_fps.description" +default = 60 +min = 15 +max = 240 + +[[setting]] +key = "record-skip-confirmation" +type = "bool" +label_key = "settings.record_skip_confirmation.label" +description_key = "settings.record_skip_confirmation.description" +default = false + +[[setting]] +key = "record-copy-to-clipboard" +type = "bool" +label_key = "settings.record_copy_to_clipboard.label" +description_key = "settings.record_copy_to_clipboard.description" +default = false + +[[setting]] +key = "gif-max-seconds" +type = "int" +label_key = "settings.gif_max_seconds.label" +description_key = "settings.gif_max_seconds.description" +default = 30 +min = 1 +max = 600 + +# ── Entries ───────────────────────────────────────────────────────────────── + +# Bar widget: crosshair glyph; shows a pulsing red dot while recording. +# Left click toggles the main panel (or stops a recording); right click quick-picks a color. +[[widget]] +id = "widget" +entry = "widget.luau" + +# Control-center quick tile. Mirrors the bar widget. +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +# Main toolkit panel: tool grid + per-tool result views + recording controls. +# Open with: noctalia msg panel-toggle alexander/screen-toolkit:panel +[[panel]] +id = "panel" +entry = "panel.luau" +width = 660 +height = 340 +placement = "floating" +position = "center" + +# Result panel: opened by the service when a capture tool finishes. +[[panel]] +id = "result" +entry = "result.luau" +width = 560 +height = 520 +placement = "floating" +position = "center" + +# Headless engine: owns captures, tool pipelines, recording, and persistence. +[[service]] +id = "service" +entry = "service.luau" diff --git a/screen-toolkit/result.luau b/screen-toolkit/result.luau new file mode 100644 index 00000000..c7b1ab18 --- /dev/null +++ b/screen-toolkit/result.luau @@ -0,0 +1,334 @@ +--!nonstrict +-- Screen Toolkit — result [[panel]]. +-- +-- A separate panel the [[service]] opens when a capture tool finishes. It +-- renders the result (color / OCR / QR / palette) at a comfortable size, with +-- copy / search / translate / share actions. It is a thin client: it reads the +-- published state and dispatches actions through the "command" channel. + +local panelOpen = false + +local recordState = noctalia.state.get("recordState") or "idle" +local activeTool = noctalia.state.get("activeTool") +local colorResult = noctalia.state.get("colorResult") +local colorHistory = noctalia.state.get("colorHistory") or {} +local ocrResult = noctalia.state.get("ocrResult") +local translateResult = noctalia.state.get("translateResult") +local qrResult = noctalia.state.get("qrResult") +local paletteColors = noctalia.state.get("paletteColors") or {} + +-- ── Helpers ──────────────────────────────────────────────────────────────── + +local function tr(key, subst) + if subst then return noctalia.tr(key, subst) end + return noctalia.tr(key) +end + +local function send(action, payload) + noctalia.state.set("command", { action = action, payload = payload }) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", [["'"']]) .. "'" +end + +local function copyText(text, notifyKey) + if not text or text == "" then return end + noctalia.copyToClipboard(text, "text/plain;charset=utf-8") + if notifyKey then noctalia.notify(tr("plugin_name"), tr(notifyKey)) end +end + +local function actionButton(glyph, text, onClick, variant) + return ui.button({ + glyph = glyph, + text = text, + controlSize = "sm", + variant = variant or "default", + onClick = onClick, + }) +end + +local function refresh() + recordState = noctalia.state.get("recordState") or "idle" + activeTool = noctalia.state.get("activeTool") + colorResult = noctalia.state.get("colorResult") + colorHistory = noctalia.state.get("colorHistory") or {} + ocrResult = noctalia.state.get("ocrResult") + translateResult = noctalia.state.get("translateResult") + qrResult = noctalia.state.get("qrResult") + paletteColors = noctalia.state.get("paletteColors") or {} +end + +-- ── Header ───────────────────────────────────────────────────────────────── + +local function header() + local title, glyph = tr("panel.title"), "crosshair" + if recordState == "ready" then + title, glyph = tr("panel.ready"), "video" + elseif activeTool == "colorpicker" then + title, glyph = tr("tools.colorpicker"), "palette" + elseif activeTool == "ocr" then + title, glyph = tr("tools.ocr"), "scan" + elseif activeTool == "qr" then + title, glyph = tr("tools.qr"), "qrcode" + elseif activeTool == "palette" then + title, glyph = tr("tools.palette"), "color-swatch" + end + return ui.row({ align = "center", gap = 10 }, { + ui.column({ width = 36, height = 36, radius = 10, fill = "primary/0.15", align = "center", justify = "center" }, { + ui.glyph({ name = glyph, size = 18, color = "primary" }), + }), + ui.label({ text = title, flexGrow = 1, fontSize = 16, fontWeight = "bold", color = "on_surface" }), + ui.button({ + text = tr("panel.clear"), + variant = "ghost", + controlSize = "sm", + onClick = function() + if recordState == "ready" then send("recordDiscard") else send("clearResult") end + panel.close() + end, + }), + ui.button({ glyph = "close", onClick = function() panel.close() end }), + }) +end + +-- Recording finished: choose how to save it. +local function recordSaveCard() + return ui.column({ flexGrow = 1, gap = 12, justify = "center" }, { + ui.row({ gap = 10, align = "center", paddingH = 12, paddingV = 12, radius = 12, fill = "surface_variant/0.25" }, { + ui.column({ width = 34, height = 34, radius = 10, fill = "primary/0.15", align = "center", justify = "center" }, { + ui.glyph({ name = "video", size = 18, color = "primary" }), + }), + ui.column({ flexGrow = 1, gap = 1 }, { + ui.label({ text = tr("panel.ready"), fontSize = 14, fontWeight = "bold" }), + ui.label({ text = tr("panel.ready_hint"), fontSize = 11, color = "on_surface_variant" }), + }), + }), + ui.row({ gap = 6 }, { + actionButton("movie", tr("panel.save_mp4"), function() send("recordSaveMp4") end, "primary"), + actionButton("gif", tr("panel.save_gif"), function() send("recordSaveGif") end), + actionButton("copy", tr("panel.copy"), function() send("recordCopy") end), + actionButton("trash", tr("panel.discard"), function() send("recordDiscard") end, "ghost"), + }), + }) +end + +-- ── Result views ─────────────────────────────────────────────────────────── + +local function emptyState(glyph, text) + return ui.column({ flexGrow = 1, gap = 10, align = "center", justify = "center" }, { + ui.glyph({ name = glyph, size = 30, color = "outline" }), + ui.label({ text = text, fontSize = 12, color = "on_surface_variant" }), + }) +end + +local function colorFormatRow(name, value, notifyKey) + return ui.row({ gap = 8, align = "center", paddingH = 10, paddingV = 8, radius = 10, fill = "surface_variant/0.25" }, { + ui.label({ text = name, width = 40, fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = value, flexGrow = 1, fontSize = 12 }), + ui.button({ glyph = "copy", variant = "ghost", onClick = function() copyText(value, notifyKey) end }), + }) +end + +local function colorHistoryRow() + if #colorHistory == 0 then + return ui.label({ text = tr("panel.no_history"), fontSize = 12, color = "muted" }) + end + local swatches = {} + for i = #colorHistory, 1, -1 do + local hex = colorHistory[i] + table.insert(swatches, ui.column({ + key = "hist-" .. hex, + flexGrow = 1, + gap = 4, + align = "center", + onClick = function() copyText(hex, "panel.color_copied") end, + }, { + ui.box({ width = 26, height = 26, radius = 8, fill = hex, borderWidth = 1, border = "outline" }), + })) + end + return ui.row({ gap = 8 }, swatches) +end + +local function renderColor() + if not colorResult then + return emptyState("palette", tr("panel.no_color")) + end + local c = colorResult + return ui.column({ flexGrow = 1, gap = 10 }, { + ui.row({ gap = 12, align = "center" }, { + ui.box({ key = "swatch", width = 76, height = 50, radius = 12, fill = c.hex, borderWidth = 2, border = "outline" }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = c.hex, fontSize = 18, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = c.rgb, fontSize = 11, color = "on_surface_variant" }), + }), + ui.column({ align = "end", gap = 4 }, { + actionButton("color-picker", tr("panel.pick_again"), function() send("colorPicker"); panel.close() end), + actionButton("trash", tr("panel.clear"), function() send("clearResult"); panel.close() end, "ghost"), + }), + }), + ui.row({ gap = 6 }, { + actionButton("copy", tr("panel.copy_all"), function() + copyText(string.format("%s\n%s\n%s\n%s", c.hex, c.rgb, c.hsl, c.hsv), "panel.all_formats_copied") + end), + actionButton("share", tr("panel.share"), function() send("share", c.capturePath) end), + }), + ui.separator({ spacing = 4 }), + colorFormatRow("HEX", c.hex, "panel.copy_hex"), + colorFormatRow("RGB", c.rgb, "panel.copy_rgb"), + colorFormatRow("HSL", c.hsl, "panel.copy_hsl"), + colorFormatRow("HSV", c.hsv, "panel.copy_hsv"), + ui.separator({ spacing = 4 }), + ui.column({ gap = 6 }, { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = tr("panel.history"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + ui.button({ text = tr("panel.clear_history"), controlSize = "sm", variant = "ghost", onClick = function() send("clearHistory") end }), + }), + colorHistoryRow(), + }), + }) +end + +local function renderOcr() + if not ocrResult then + return emptyState("scan", tr("panel.no_result")) + end + local scrollChildren = { + ui.label({ text = tr("panel.ocr_text"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = ocrResult.text, fontSize = 13 }), + } + if translateResult and translateResult ~= "" then + table.insert(scrollChildren, ui.separator({ spacing = 6 })) + table.insert(scrollChildren, ui.label({ text = tr("panel.translation"), fontSize = 12, fontWeight = "bold", color = "primary" })) + table.insert(scrollChildren, ui.label({ text = translateResult, fontSize = 13 })) + end + return ui.column({ flexGrow = 1, gap = 8 }, { + ui.row({ gap = 6 }, { + actionButton("copy", tr("panel.copy_text"), function() copyText(ocrResult.text, "panel.copied") end), + actionButton("search", tr("panel.search_text"), function() send("ocrSearch") end), + ui.input({ + key = "ocr-translate-input", + placeholder = "en", + controlSize = "sm", + width = 96, + onSubmit = function(value) send("ocrTranslate", value) end, + }), + actionButton("share", tr("panel.share"), function() send("share", ocrResult.capturePath) end), + }), + ui.scroll({ flexGrow = 1, gap = 8 }, scrollChildren), + }) +end + +local function renderQr() + if not qrResult then + return emptyState("qrcode", tr("panel.no_result")) + end + local text = qrResult.text or "" + local isUrl = text:sub(1, 4) == "http" + local buttons = { + actionButton("copy", tr("panel.copy_text"), function() copyText(text, "panel.copied") end), + } + if isUrl then + table.insert(buttons, actionButton("external-link", tr("panel.open_url"), function() + noctalia.runAsync("xdg-open " .. shellQuote(text)) + end)) + end + table.insert(buttons, actionButton("share", tr("panel.share"), function() send("share", qrResult.capturePath) end)) + return ui.column({ flexGrow = 1, gap = 8 }, { + ui.row({ gap = 6 }, buttons), + ui.scroll({ flexGrow = 1, gap = 8 }, { + ui.image({ path = qrResult.capturePath, height = 110, fit = "contain", radius = 12, borderWidth = 1, border = "outline" }), + ui.label({ text = text, fontSize = 13 }), + }), + }) +end + +local function renderPalette() + if #paletteColors == 0 then + return emptyState("color-swatch", tr("panel.no_result")) + end + local tiles = {} + for i, hex in ipairs(paletteColors) do + table.insert(tiles, ui.column({ + key = "pal-" .. i, + flexGrow = 1, + gap = 5, + align = "center", + paddingV = 8, + radius = 12, + fill = "surface_variant/0.25", + borderWidth = 1, + border = "outline", + onClick = function() copyText(hex, "panel.color_copied") end, + }, { + ui.box({ width = 34, height = 34, radius = 10, fill = hex, borderWidth = 1, border = "outline" }), + ui.label({ text = hex, fontSize = 9, color = "on_surface_variant" }), + })) + end + return ui.column({ flexGrow = 1, gap = 8 }, { + ui.row({ gap = 6 }, { + actionButton("copy", tr("panel.copy_hex_list"), function() + copyText(table.concat(paletteColors, "\n"), "panel.hex_list_copied") + end), + actionButton("code", tr("panel.copy_css_vars"), function() + local buf = {} + for i, hex in ipairs(paletteColors) do buf[#buf + 1] = `--palette-{i}: {hex};` end + copyText(table.concat(buf, "\n"), "panel.css_vars_copied") + end), + actionButton("share", tr("panel.share"), function() send("share", "/tmp/screen-toolkit-palette.png") end), + }), + ui.row({ gap = 6 }, tiles), + }) +end + +-- ── Render ───────────────────────────────────────────────────────────────── + +local function body() + if recordState == "ready" then + return recordSaveCard() + elseif activeTool == "colorpicker" then + return renderColor() + elseif activeTool == "ocr" then + return renderOcr() + elseif activeTool == "qr" then + return renderQr() + elseif activeTool == "palette" then + return renderPalette() + end + return emptyState("crosshair", tr("panel.empty")) +end + +local function render() + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + header(), + body(), + })) +end + +-- ── Lifecycle ────────────────────────────────────────────────────────────── + +function onOpen(_context) + panelOpen = true + refresh() + render() +end + +function onClose() + panelOpen = false +end + +noctalia.state.watch("recordState", function(v) + if recordState == "ready" and v ~= "ready" then + panel.close() + return + end + recordState = v or "idle" + if panelOpen then render() end +end) +noctalia.state.watch("activeTool", function(v) activeTool = v; if panelOpen then render() end end) +noctalia.state.watch("colorResult", function(v) colorResult = v; if panelOpen then render() end end) +noctalia.state.watch("colorHistory", function(v) colorHistory = v or {}; if panelOpen then render() end end) +noctalia.state.watch("ocrResult", function(v) ocrResult = v; if panelOpen then render() end end) +noctalia.state.watch("translateResult", function(v) translateResult = v; if panelOpen then render() end end) +noctalia.state.watch("qrResult", function(v) qrResult = v; if panelOpen then render() end end) +noctalia.state.watch("paletteColors", function(v) paletteColors = v or {}; if panelOpen then render() end end) diff --git a/screen-toolkit/scripts/capture.sh b/screen-toolkit/scripts/capture.sh new file mode 100755 index 00000000..d889ba83 --- /dev/null +++ b/screen-toolkit/scripts/capture.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# capture.sh [args...] +# +# Actions: +# annotate-window — capture the focused Hyprland window with grim +# output: /tmp/screen-toolkit-annotate.png +# stdout: "X,Y WxH" geometry string +# palette — extract 8 dominant hex colours from a captured region +# stdout: one "#RRGGBB" per line +# qr — capture a region and decode any QR / barcode found +# stdout: decoded text +# +# Exit codes: +# 1 — missing / invalid arguments +# 2 — capture or decode failed +# 3 — missing dependency (hyprctl, jq, grim, magick, zbarimg) +# +# Used by: service.luau + +set -euo pipefail + +ACTION="${1:-}" +GRIM_CURSOR_ARGS=() +[ "${SCREEN_TOOLKIT_CAPTURE_CURSOR:-0}" = "1" ] && GRIM_CURSOR_ARGS+=(-c) + +_require() { + command -v "$1" >/dev/null 2>&1 \ + || { echo "ERROR: missing dependency: $1" >&2; exit 3; } +} + +case "$ACTION" in + + annotate-window) + _require slurp + _require hyprctl + _require jq + _require grim + # Crosshair: click the window you want to annotate. slurp blocks until the + # click, so there is no timeout and its overlay is never captured. + PT=$(slurp -p 2>/dev/null) || { echo "ERROR: cancelled" >&2; exit 1; } + X=$(printf '%s' "$PT" | awk -F'[, ]+' '{print int($1)}') + Y=$(printf '%s' "$PT" | awk -F'[, ]+' '{print int($2)}') + # Snap to the smallest mapped window containing the clicked point. + WIN=$(hyprctl clients -j 2>/dev/null | jq -c --argjson x "$X" --argjson y "$Y" ' + [ .[] | select(.mapped == true) + | { at: (.at // [0, 0]), size: (.size // [0, 0]) } + | select(.at[0] <= $x and (.at[0] + .size[0]) >= $x + and .at[1] <= $y and (.at[1] + .size[1]) >= $y) ] + | sort_by(.size[0] * .size[1]) | first' 2>/dev/null) + [ -n "$WIN" ] && [ "$WIN" != "null" ] \ + || { echo "ERROR: no window at that point" >&2; exit 2; } + GEOM=$(printf '%s' "$WIN" | jq -r '"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' 2>/dev/null) + [ -n "$GEOM" ] \ + || { echo "ERROR: could not parse window geometry" >&2; exit 2; } + # grim excludes the cursor by default; moving it would make the picker + # unreliable and is compositor-specific. + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOM" /tmp/screen-toolkit-annotate.png 2>/dev/null \ + || { echo "ERROR: grim capture failed" >&2; exit 2; } + printf '%s\n' "$GEOM" + ;; + + palette) + GEOMETRY="${2:-}" + [ -n "$GEOMETRY" ] || { echo "ERROR: palette: missing " >&2; exit 1; } + _require grim + _require magick + + FILE="/tmp/screen-toolkit-palette.png" + + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" "$FILE" 2>/dev/null \ + || { echo "ERROR: palette: grim capture failed" >&2; exit 2; } + + magick "$FILE" -alpha off +dither -colors 8 -unique-colors txt:- 2>/dev/null \ + | grep -v '^#' \ + | grep -oP '#[0-9a-fA-F]{6}' \ + | head -8 + ;; + + qr) + GEOMETRY="${2:-}" + [ -n "$GEOMETRY" ] || { echo "ERROR: qr: missing " >&2; exit 1; } + _require grim + _require zbarimg + + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" /tmp/screen-toolkit-qr.png 2>/dev/null \ + || { echo "ERROR: qr: grim capture failed" >&2; exit 2; } + + zbarimg -q --raw /tmp/screen-toolkit-qr.png 2>/dev/null + ;; + + *) + echo "ERROR: unknown action '${ACTION}'. Expected: annotate-window | palette | qr" >&2 + exit 1 + ;; + +esac diff --git a/screen-toolkit/scripts/color-picker.sh b/screen-toolkit/scripts/color-picker.sh new file mode 100755 index 00000000..5bd8d2b1 --- /dev/null +++ b/screen-toolkit/scripts/color-picker.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# color-picker.sh +# Picks a color from screen, outputs "R G B" to stdout. +# Uses hyprpicker (preferred) or falls back to slurp+grim. +FILE="$1" +[ -z "$FILE" ] && exit 1 +GRIM_CURSOR_ARGS=() +[ "${SCREEN_TOOLKIT_CAPTURE_CURSOR:-0}" = "1" ] && GRIM_CURSOR_ARGS+=(-c) +# ── hyprpicker ──────────────────────────────────────────────────────────────── +if command -v hyprpicker >/dev/null 2>&1; then + HYPRCTL=$(command -v hyprctl 2>/dev/null || true) + if [ -n "$HYPRCTL" ]; then + PRE_POS=$(hyprctl cursorpos 2>/dev/null) + fi + if hyprpicker --help 2>&1 | grep -q '\-\-radius'; then + HEX=$(hyprpicker --no-fancy --format=hex --radius=65 2>/dev/null) || exit 1 + else + HEX=$(hyprpicker --no-fancy --format=hex 2>/dev/null) || exit 1 + fi + HEX="${HEX#\#}" + [ ${#HEX} -eq 6 ] || exit 1 + R=$((16#${HEX:0:2})) + G=$((16#${HEX:2:2})) + B=$((16#${HEX:4:2})) + if [ -n "$HYPRCTL" ]; then + sleep 0.08 + POST_POS=$(hyprctl cursorpos 2>/dev/null) + fi + CAPTURED=0 + if [ -n "$HYPRCTL" ] && command -v grim >/dev/null 2>&1; then + # If cursor moved, we have the real pick position + # If it didn't move, hyprland restored it — coordinates are useless, skip capture + if [ "$PRE_POS" != "$POST_POS" ] && [ -n "$POST_POS" ]; then + X=$(echo "$POST_POS" | awk -F'[, ]+' '{print int($1)}') + Y=$(echo "$POST_POS" | awk -F'[, ]+' '{print int($2)}') + if [ -n "$X" ] && [ -n "$Y" ] && [ "$X" -ge 0 ] && [ "$Y" -ge 0 ] 2>/dev/null; then + # Capture 21x21 area centered on picked pixel for more context + GX=$((X > 10 ? X - 10 : 0)) + GY=$((Y > 10 ? Y - 10 : 0)) + grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} 21x21" "$FILE" 2>/dev/null && CAPTURED=1 + fi + fi + fi + # Fallback: solid swatch + if [ "$CAPTURED" -eq 0 ] && command -v magick >/dev/null 2>&1; then + magick -size 21x21 "xc:rgb($R,$G,$B)" "$FILE" 2>/dev/null + fi + printf '%d %d %d\n' "$R" "$G" "$B" + exit 0 +fi +# ── fallback: slurp + grim + magick ────────────────────────────────────────── +for dep in slurp grim magick; do + command -v "$dep" >/dev/null 2>&1 || exit 1 +done +COORDS=$(slurp -p 2>/dev/null) || exit 1 +X=${COORDS%%,*}; REST=${COORDS#*,}; Y=${REST%% *} +GX=$((X > 10 ? X - 10 : 0)); GY=$((Y > 10 ? Y - 10 : 0)) +grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} 21x21" "$FILE" 2>/dev/null || exit 1 +magick "$FILE" -alpha off \ + -format '%[fx:int(255*u.p{10,10}.r)] %[fx:int(255*u.p{10,10}.g)] %[fx:int(255*u.p{10,10}.b)]' \ + info:- 2>/dev/null diff --git a/screen-toolkit/scripts/lens-upload.sh b/screen-toolkit/scripts/lens-upload.sh new file mode 100755 index 00000000..88750af2 --- /dev/null +++ b/screen-toolkit/scripts/lens-upload.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +# Args: $1=gx $2=gy $3=gw $4=gh +# Captures a region and opens it in Google Lens via a temporary upload. +# Exit 1 — missing dependency (dep name written to stdout) +# Exit 2 — capture failed +# Exit 3 — upload failed + +GX="$1"; GY="$2"; GW="$3"; GH="$4" +FILE="/tmp/screen-toolkit-lens.png" +GRIM_CURSOR_ARGS=() +[ "${SCREEN_TOOLKIT_CAPTURE_CURSOR:-0}" = "1" ] && GRIM_CURSOR_ARGS+=(-c) + +for dep in grim curl jq xdg-open; do + command -v "$dep" >/dev/null 2>&1 || { echo "$dep"; exit 1; } +done + +grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null || exit 2 + +RESP=$(curl -sS -f -A 'Mozilla/5.0' --connect-timeout 20 --max-time 60 \ + -F "files[]=@$FILE" 'https://uguu.se/upload' 2>/dev/null) || \ +RESP=$(curl -sS -A 'Mozilla/5.0' --connect-timeout 20 --max-time 60 \ + -F "files[]=@$FILE" 'https://uguu.se/upload.php' 2>/dev/null) + +rm -f "$FILE" + +URL=$(printf '%s' "$RESP" | jq -r '.files[0].url // empty' 2>/dev/null) +if [ -n "$URL" ] && [[ "$URL" == http* ]]; then + xdg-open "https://lens.google.com/uploadbyurl?url=$URL" >/dev/null 2>&1 & +else + exit 3 +fi diff --git a/screen-toolkit/scripts/ocr.sh b/screen-toolkit/scripts/ocr.sh new file mode 100755 index 00000000..8f23b455 --- /dev/null +++ b/screen-toolkit/scripts/ocr.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Args: $1=gx $2=gy $3=gw $4=gh $5=lang $6=upscale_flag $7=psm +# +# Exit codes: +# 1 — missing dependency (dep name written to stdout) +# 2 — bad/missing args +# 3 — capture failed +# 4 — image processing failed +# Exit 0 with empty stdout = no text found (service handles this case) + +GX="$1"; GY="$2"; GW="$3"; GH="$4" +RAW_LANG="${5:-eng}" +UPSCALE="$6" +USER_PSM="${7:-3}" +FILE="/tmp/screen-toolkit-ocr.png" +TMP_BASE="/tmp/screen-toolkit-ocr-work-$$" +TMP="${TMP_BASE}.pnm" +TMP_NOISE="${TMP_BASE}-nr.pnm" +GRIM_CURSOR_ARGS=() +[ "${SCREEN_TOOLKIT_CAPTURE_CURSOR:-0}" = "1" ] && GRIM_CURSOR_ARGS+=(-c) + +cleanup() { rm -f "$TMP" "$TMP_NOISE"; } +trap cleanup EXIT + +for dep in grim magick tesseract; do + command -v "$dep" >/dev/null 2>&1 || { echo "$dep"; exit 1; } +done + +[ -z "$GX" ] || [ -z "$GY" ] || [ -z "$GW" ] || [ -z "$GH" ] && exit 2 + +LANG=$(echo "$RAW_LANG" | tr '+' '\n' \ + | grep -v '^osd$' \ + | grep -v '^$' \ + | tr '\n' '+' \ + | sed 's/+$//') +[ -z "$LANG" ] && LANG="eng" + +AVAILABLE=$(tesseract --list-langs 2>/dev/null | tail -n +2) +VALID_LANGS="" +IFS='+' read -ra LANG_PARTS <<< "$LANG" +for l in "${LANG_PARTS[@]}"; do + if echo "$AVAILABLE" | grep -qx "$l"; then + VALID_LANGS="${VALID_LANGS}+${l}" + fi +done +LANG="${VALID_LANGS#+}" +[ -z "$LANG" ] && LANG="eng" + +grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null || exit 3 + +if [ -z "$UPSCALE" ] && [ "$GW" -lt 200 ] 2>/dev/null; then + SCALE=$(awk "BEGIN{printf \"%.0f\", 300 / $GW}") + UPSCALE="-scale ${SCALE}00%" +fi + +magick "$FILE" $UPSCALE \ + -colorspace Gray \ + -normalize \ + -contrast-stretch 2%x1% \ + -sharpen 0x1.5 \ + +repage \ + "$TMP" 2>/dev/null || exit 4 + +MEAN=$(magick "$TMP" -format '%[fx:mean]' info: 2>/dev/null) +if awk "BEGIN{exit !($MEAN < 0.4)}"; then + magick "$TMP" -negate "$TMP" 2>/dev/null +fi +magick "$TMP" -median 1 "$TMP_NOISE" 2>/dev/null + +run_ocr() { tesseract "$1" stdout -l "$LANG" --psm "$2" --oem 1 2>/dev/null; } +count_chars() { printf '%s' "$1" | tr -d '[:space:]' | wc -c; } + +TEXT=$(run_ocr "$TMP" "$USER_PSM") +BEST_LEN=$(count_chars "$TEXT") +BEST_TEXT="$TEXT" + +if [ "$BEST_LEN" -lt 4 ] || [ "$USER_PSM" -ne 6 ]; then + TEXT2=$(run_ocr "$TMP_NOISE" 6) + LEN2=$(count_chars "$TEXT2") + [ "$LEN2" -gt "$BEST_LEN" ] && { BEST_LEN=$LEN2; BEST_TEXT="$TEXT2"; } +fi +if [ "$BEST_LEN" -lt 4 ]; then + TEXT3=$(run_ocr "$TMP_NOISE" 4) + LEN3=$(count_chars "$TEXT3") + [ "$LEN3" -gt "$BEST_LEN" ] && { BEST_LEN=$LEN3; BEST_TEXT="$TEXT3"; } +fi +if [ "$BEST_LEN" -lt 4 ]; then + TEXT4=$(magick "$TMP" -threshold 85% stdout 2>/dev/null \ + | tesseract - stdout -l "$LANG" --psm 11 --oem 1 2>/dev/null) + LEN4=$(count_chars "$TEXT4") + [ "$LEN4" -gt "$BEST_LEN" ] && BEST_TEXT="$TEXT4" +fi + +printf '%s' "$BEST_TEXT" diff --git a/screen-toolkit/scripts/record.sh b/screen-toolkit/scripts/record.sh new file mode 100755 index 00000000..c944c141 --- /dev/null +++ b/screen-toolkit/scripts/record.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# record.sh [args...] +# +# Actions: +# thumb — extract mid-frame thumbnail → /tmp/screen-toolkit-record-thumb.png +# convert-mp4 — finalize MP4: stream-copy (fast path) +# convert-mp4 --recode — finalize MP4: re-encode audio to AAC 128k + faststart +# convert-gif [maxsec] — convert MP4 → palette-optimized GIF at 15 fps +# stop — send SIGINT to the named recorder process +# +# Exit codes: +# 1 — missing / invalid arguments +# 2 — input file not found +# 3 — missing dependency (ffmpeg, ffprobe, pkill) +# 4 — conversion or process command failed +# +# Used by: service.luau +set -euo pipefail +ACTION="${1:-}" +THUMB_OUT="/tmp/screen-toolkit-record-thumb.png" +PALETTE="/tmp/screen-toolkit-record-palette.png" +_require() { + command -v "$1" >/dev/null 2>&1 \ + || { echo "ERROR: missing dependency: $1" >&2; exit 3; } +} +_thumb() { + local src="$1" + _require ffprobe + _require ffmpeg + local dur + dur=$(ffprobe -v error \ + -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 \ + "$src" 2>/dev/null) || dur="" + [[ -z "$dur" || "$dur" == "N/A" ]] && dur=1 + local mid + mid=$(echo "$dur / 2" | bc -l 2>/dev/null) || mid="0.5" + ffmpeg -y -ss "$mid" -i "$src" -frames:v 1 "$THUMB_OUT" 2>/dev/null +} +case "$ACTION" in + thumb) + SRC="${2:-}" + [ -n "$SRC" ] || { echo "ERROR: thumb: missing " >&2; exit 1; } + [ -f "$SRC" ] || { echo "ERROR: thumb: file not found: $SRC" >&2; exit 2; } + _thumb "$SRC" + ;; + convert-mp4) + INPUT="${2:-}" + OUTPUT="${3:-}" + RECODE="${4:-}" + [ -n "$INPUT" ] || { echo "ERROR: convert-mp4: missing " >&2; exit 1; } + [ -n "$OUTPUT" ] || { echo "ERROR: convert-mp4: missing " >&2; exit 1; } + [ -f "$INPUT" ] || { echo "ERROR: convert-mp4: file not found: $INPUT" >&2; exit 2; } + _require ffmpeg + if [ "$RECODE" = "--recode" ]; then + ffmpeg -y -i "$INPUT" \ + -c:v copy -c:a aac -b:a 128k -movflags +faststart \ + "$OUTPUT" 2>/dev/null \ + || { echo "ERROR: convert-mp4: ffmpeg recode failed" >&2; exit 4; } + rm -f "$INPUT" + else + mv "$INPUT" "$OUTPUT" \ + || { echo "ERROR: convert-mp4: mv failed" >&2; exit 4; } + fi + _thumb "$OUTPUT" + ;; + convert-gif) + INPUT="${2:-}" + OUTPUT="${3:-}" + MAXSEC="${4:-}" + [ -n "$INPUT" ] || { echo "ERROR: convert-gif: missing " >&2; exit 1; } + [ -n "$OUTPUT" ] || { echo "ERROR: convert-gif: missing " >&2; exit 1; } + [ -f "$INPUT" ] || { echo "ERROR: convert-gif: file not found: $INPUT" >&2; exit 2; } + _require ffmpeg + DURATION="" + if [ -n "$MAXSEC" ] && [ "$MAXSEC" -gt 0 ] 2>/dev/null; then + DURATION="-t $MAXSEC" + fi + ffmpeg -y $DURATION -i "$INPUT" \ + -vf 'fps=15,scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos,palettegen' \ + "$PALETTE" 2>/dev/null \ + || { echo "ERROR: convert-gif: palettegen pass failed" >&2; exit 4; } + ffmpeg -y $DURATION -i "$INPUT" -i "$PALETTE" \ + -lavfi 'fps=15,scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos[x];[x][1:v]paletteuse' \ + "$OUTPUT" 2>/dev/null \ + || { echo "ERROR: convert-gif: paletteuse pass failed" >&2; exit 4; } + rm -f "$PALETTE" "$INPUT" + _thumb "$OUTPUT" + ;; + stop) + BIN="${2:-}" + [ -n "$BIN" ] || { echo "ERROR: stop: missing " >&2; exit 1; } + _require pkill + pkill -INT "$BIN" 2>/dev/null || true + ;; + *) + echo "ERROR: unknown action '${ACTION}'. Expected: thumb | convert-mp4 | convert-gif | stop" >&2 + exit 1 + ;; +esac diff --git a/screen-toolkit/scripts/share-upload.sh b/screen-toolkit/scripts/share-upload.sh new file mode 100755 index 00000000..a8ec9214 --- /dev/null +++ b/screen-toolkit/scripts/share-upload.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# share-upload.sh [api_key] [expiry] +# api_key: X02 API key — if empty, falls back to uguu.se (anonymous, 3h, 128MB max) +# expiry: 1h | 1d | 7d | 30d | permanent (X02 only, default: 7d) +# Prints URL to stdout on success, exits non-zero on failure +# Exit codes: +# 1 — invalid arguments (no file given) +# 2 — file not found +# 3 — missing dependency +# 4 — upload request failed +# 5 — invalid or empty response +# 6 — file too large +# Used by: service.luau + +set -euo pipefail + +FILE="${1:-}" +API_KEY="${2:-}" +EXPIRY="${3:-7d}" + +UGUU_MAX_BYTES=$((128 * 1024 * 1024)) # 128 MB + +[ -n "$FILE" ] || { echo "ERROR: no file given" >&2; exit 1; } +[ -f "$FILE" ] || { echo "ERROR: file not found: $FILE" >&2; exit 2; } + +command -v curl >/dev/null 2>&1 || { echo "ERROR: missing dependency: curl" >&2; exit 3; } + +# ── X02 (authenticated) ─────────────────────────────────────────────────────── +if [ -n "$API_KEY" ]; then + EXPIRY_FLAG=() + if [ "$EXPIRY" != "permanent" ] && [ -n "$EXPIRY" ]; then + EXPIRY_FLAG=(-F "expiry=${EXPIRY}") + fi + + URL=$(curl -sS -f \ + -X POST "https://up.x02.me/api/upload" \ + -H "x-api-key: ${API_KEY}" \ + -F "file=@${FILE}" \ + "${EXPIRY_FLAG[@]}" \ + --connect-timeout 20 \ + --max-time 120 \ + 2>/dev/null) \ + || { echo "ERROR: X02 upload request failed" >&2; exit 4; } + + if [ -n "$URL" ] && [[ "$URL" == http* ]]; then + printf '%s\n' "$URL" + exit 0 + fi + + echo "ERROR: X02: unexpected response: $URL" >&2 + exit 5 +fi + +# ── uguu.se (anonymous fallback) ────────────────────────────────────────────── +command -v jq >/dev/null 2>&1 || { echo "ERROR: missing dependency: jq" >&2; exit 3; } + +FILE_SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE" 2>/dev/null || echo 0) +if [ "$FILE_SIZE" -gt "$UGUU_MAX_BYTES" ]; then + echo "ERROR: file too large for anonymous upload (128MB max). Add an X02 API key for larger files." >&2 + exit 6 +fi + +RESP=$(curl -sS -f -A 'Mozilla/5.0' \ + --connect-timeout 20 --max-time 60 \ + -F "files[]=@${FILE}" \ + 'https://uguu.se/upload' 2>/dev/null) \ +|| RESP=$(curl -sS -A 'Mozilla/5.0' \ + --connect-timeout 20 --max-time 60 \ + -F "files[]=@${FILE}" \ + 'https://uguu.se/upload.php' 2>/dev/null) \ +|| { echo "ERROR: uguu.se upload request failed" >&2; exit 4; } + +URL=$(printf '%s' "$RESP" | jq -r '.files[0].url // empty' 2>/dev/null) + +if [ -n "$URL" ] && [[ "$URL" == http* ]]; then + printf '%s\n' "$URL" + exit 0 +fi + +echo "ERROR: uguu.se: no valid URL in response" >&2 +exit 5 diff --git a/screen-toolkit/service.luau b/screen-toolkit/service.luau new file mode 100644 index 00000000..4d31826f --- /dev/null +++ b/screen-toolkit/service.luau @@ -0,0 +1,876 @@ +--!nonstrict +-- Screen Toolkit — headless [[service]]. +-- +-- Owns every tool pipeline (color pick, OCR, QR, palette, lens, annotate, +-- measure, recording, sharing) plus state persistence. The bar [[widget]], the +-- control-center [[shortcut]], and the main [[panel]] are thin clients: they +-- read the published state and drive this service by writing to the plugin's +-- "command" state channel (or by IPC). A capture can be started even with no +-- UI entry placed. +-- +-- Region selection shells out to `slurp` (which draws its own crosshair), then +-- `grim` captures the geometry. No custom overlay is possible in the v5 plugin +-- API, so the legacy in-shell region selector is replaced by slurp. +-- +-- Recording runs `wl-screenrec` (preferred) or `wf-recorder`. The raw output +-- lands in /tmp as MP4; GIF is produced on save via record.sh convert-gif. + +local PANEL_ID = "alexander/screen-toolkit:panel" +local RESULT_PANEL_ID = "alexander/screen-toolkit:result" + +local SCRIPT_CAPTURE = noctalia.pluginDir() .. "/scripts/capture.sh" +local SCRIPT_OCR = noctalia.pluginDir() .. "/scripts/ocr.sh" +local SCRIPT_LENS = noctalia.pluginDir() .. "/scripts/lens-upload.sh" +local SCRIPT_RECORD = noctalia.pluginDir() .. "/scripts/record.sh" +local SCRIPT_SHARE = noctalia.pluginDir() .. "/scripts/share-upload.sh" +local SCRIPT_PICKER = noctalia.pluginDir() .. "/scripts/color-picker.sh" + +local COLOR_PNG = "/tmp/screen-toolkit-colorpicker.png" +local OCR_PNG = "/tmp/screen-toolkit-ocr.png" +local QR_PNG = "/tmp/screen-toolkit-qr.png" +local ANNOTATE_PNG = "/tmp/screen-toolkit-annotate.png" + +local MAX_HISTORY = 8 + +-- ── Small helpers ────────────────────────────────────────────────────────── + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function log(msg) + noctalia.log(`screen-toolkit: {msg}`) +end + +local function trim(s) + return tostring(s):gsub("^%s+", ""):gsub("%s+$", "") +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", [["'"']]) .. "'" +end + +local function copyFileUri(path) + local escaped = tostring(path):gsub(" ", "%%20"):gsub("'", "%%27"):gsub('"', "%%22") + noctalia.copyToClipboard(`file://{escaped}`, "text/uri-list") +end + +-- Whether the cursor is excluded from recordings. Grim screenshots exclude the +-- cursor by default, so screenshots do not need a pointer-moving workaround. +-- This also keeps color picking and slurp interaction reliable on Hyprland. +local cursorHidden = true + +-- Grim excludes the cursor unless `-c` is passed. Do not move the +-- pointer: that breaks color picking and is compositor-specific. Recorder +-- backends receive their own cursor flag in buildRecorderCommand below. +local function hideCursorForCapture(cmd, cb) + noctalia.runAsync(cmd, cb) +end + +local function grimCursorFlag() + return if cursorHidden then "" else "-c" +end + +local function captureEnv() + return if cursorHidden then "" else "SCREEN_TOOLKIT_CAPTURE_CURSOR=1 " +end + +local function publish(key, value) + noctalia.state.set(key, value) +end + +-- `noctalia.outputs()` reports logical geometry + a scale; grim wants physical +-- pixels, so multiply every axis by the output scale. +local function focusedGeometry() + local outputs = noctalia.outputs() + for _, o in ipairs(outputs) do + if o.focused then + local scale = o.scale or 1 + return string.format( + "%d,%d %dx%d", + math.floor(o.x * scale), + math.floor(o.y * scale), + math.round(o.width * scale), + math.round(o.height * scale) + ) + end + end + return nil +end + +local function parseGeometry(geom) + local gx, gy, gw, gh = geom:match("^(%d+),(%d+)%s+(%d+)x(%d+)$") + if not gx then return nil end + return tonumber(gx), tonumber(gy), tonumber(gw), tonumber(gh) +end + +local function screenshotDir() + local p = noctalia.expandPath(cfg("screenshot-path") or "") + if p == "" then p = noctalia.expandPath("~/Pictures/Screenshots") end + return p +end + +local function videoDir() + local p = noctalia.expandPath(cfg("video-path") or "") + if p == "" then p = noctalia.expandPath("~/Videos") end + return p +end + +local function buildFilename(tool, ext) + local stem = noctalia.formatTime(cfg("filename-format") or "") + if stem == nil or stem == "" then + stem = tool .. "-" .. noctalia.formatTime("%Y-%m-%d_%H-%M-%S") + end + return stem .. ext +end + +-- ── Region selection (slurp) ─────────────────────────────────────────────── + +local slurping = false + +-- Draws the slurp crosshair after a short delay so the just-closed panel's +-- close animation is not captured. Invokes callback(geom) or nothing on cancel. +local function slurpGeometry(callback) + if slurping then + noctalia.notify(noctalia.tr("panel.running")) + return + end + slurping = true + noctalia.runAsync("sleep 0.3 && slurp -f '%x,%y %wx%h'", function(res) + slurping = false + if res.exitCode ~= 0 then return end -- cancelled + local geom = trim(res.stdout or "") + if geom == "" then return end + callback(geom) + end) +end + +local function grimRegion(geom, dest, cb) + hideCursorForCapture(`grim {grimCursorFlag()} -g {shellQuote(geom)} {shellQuote(dest)}`, cb) +end + +-- ── Color conversions ────────────────────────────────────────────────────── + +local function clamp(n) + return math.max(0, math.min(255, n)) +end + +local function rgbToHsv(r, g, b) + local rn, gn, bn = r / 255, g / 255, b / 255 + local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn) + local d = max - min + local h, sat, val = 0, 0, max + if d ~= 0 then + sat = d / max + if max == rn then + h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6 + elseif max == gn then + h = (bn - rn) / d + 2 + else + h = (rn - gn) / d + 4 + end + h = math.round(h * 60) + end + return h, math.round(sat * 100), math.round(val * 100) +end + +local function rgbToHsl(r, g, b) + local rn, gn, bn = r / 255, g / 255, b / 255 + local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn) + local d = max - min + local h, l = 0, (max + min) / 2 + local s = if d == 0 then 0 else d / (1 - math.abs(2 * l - 1)) + if d ~= 0 then + if max == rn then + h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6 + elseif max == gn then + h = (bn - rn) / d + 2 + else + h = (rn - gn) / d + 4 + end + h = h / 6 + end + return math.floor(h * 360 + 0.5), math.round(s * 100), math.round(l * 100) +end + +-- ── Persistence (survives restarts; plugin data dir is update-safe) ──────── + +local DATA_DIR = noctalia.pluginDataDir() +local RESULTS_FILE = DATA_DIR .. "/results.json" +local HISTORY_FILE = DATA_DIR .. "/color-history.json" + +local function loadState() + local raw = noctalia.readFile(RESULTS_FILE) + if raw then + local d = noctalia.json.decode(raw) + if type(d) == "table" then + if d.activeTool then publish("activeTool", d.activeTool) end + if d.colorResult then publish("colorResult", d.colorResult) end + if d.ocrResult then publish("ocrResult", d.ocrResult) end + if d.qrResult then publish("qrResult", d.qrResult) end + if d.paletteColors then publish("paletteColors", d.paletteColors) end + end + end + local hraw = noctalia.readFile(HISTORY_FILE) + if hraw then + local d = noctalia.json.decode(hraw) + if type(d) == "table" then publish("colorHistory", d) end + end +end + +local function saveResults() + local d = { + activeTool = noctalia.state.get("activeTool"), + colorResult = noctalia.state.get("colorResult"), + ocrResult = noctalia.state.get("ocrResult"), + qrResult = noctalia.state.get("qrResult"), + paletteColors = noctalia.state.get("paletteColors"), + } + local enc = noctalia.json.encode(d) + if enc then noctalia.writeFile(RESULTS_FILE, enc) end + + local h = noctalia.state.get("colorHistory") or {} + local henc = noctalia.json.encode(h) + if henc then noctalia.writeFile(HISTORY_FILE, henc) end +end + +local function pushHistory(hex) + hex = hex:upper() + local history = noctalia.state.get("colorHistory") or {} + for i, c in ipairs(history) do + if c == hex then + table.remove(history, i) + break + end + end + table.insert(history, 1, hex) + while #history > MAX_HISTORY do + table.remove(history) + end + publish("colorHistory", history) +end + +-- ── Tools: color picker ──────────────────────────────────────────────────── + +local function runColorPicker() + noctalia.runAsync(captureEnv() .. SCRIPT_PICKER .. " " .. shellQuote(COLOR_PNG), function(res) + if res.exitCode ~= 0 or trim(res.stdout or "") == "" then + noctalia.notify(noctalia.tr("messages.picker_cancelled")) + return + end + local r, g, b = trim(res.stdout or ""):match("^(%d+)%s+(%d+)%s+(%d+)$") + if not r then return end + r, g, b = clamp(tonumber(r)), clamp(tonumber(g)), clamp(tonumber(b)) + local hex = string.format("#%02X%02X%02X", r, g, b) + local rgb = string.format("rgb(%d, %d, %d)", r, g, b) + local h, s, v = rgbToHsv(r, g, b) + local hh, ss, ll = rgbToHsl(r, g, b) + local hsv = string.format("hsv(%d, %d%%, %d%%)", h, s, v) + local hsl = string.format("hsl(%d, %d%%, %d%%)", hh, ss, ll) + + noctalia.copyToClipboard(hex, "text/plain;charset=utf-8") + pushHistory(hex) + publish("colorResult", { + hex = hex, rgb = rgb, hsv = hsv, hsl = hsl, + capturePath = COLOR_PNG, cacheBust = os.time(), + }) + publish("activeTool", "colorpicker") + saveResults() + noctalia.notify(noctalia.tr("messages.color_picked", { color = hex })) + noctalia.togglePanel(RESULT_PANEL_ID) + end) +end + +-- ── Tools: OCR ───────────────────────────────────────────────────────────── + +local function ocrParams(w, h) + local area = w * h + local upscale = "" + if h < 30 then + upscale = "-resize 400%" + elseif area < 50000 or w < 200 then + upscale = "-resize 200%" + end + local ratio = w / math.max(h, 1) + local psm = "3" + if ratio > 8 then + psm = "7" + elseif area < 60000 then + psm = "6" + elseif h < 40 then + psm = "7" + end + return upscale, psm +end + +local OCR_EXIT_KEYS = { + [2] = "ocr.exit_args", + [3] = "ocr.exit_capture", + [4] = "ocr.exit_process", +} + +local function runOcr() + slurpGeometry(function(geom) + local gx, gy, gw, gh = parseGeometry(geom) + if not gx then return end + local lang = cfg("selected-ocr-lang") or "eng" + local upscale, psm = ocrParams(gw, gh) + local cmd = captureEnv() .. SCRIPT_OCR .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh + .. " " .. shellQuote(lang) .. " " .. shellQuote(upscale) .. " " .. psm + hideCursorForCapture(cmd, function(res) + if res.exitCode == 1 then + noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = trim(res.stdout or "") })) + return + elseif res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr(OCR_EXIT_KEYS[res.exitCode] or "ocr.exit_unknown", { code = tostring(res.exitCode) })) + return + end + local text = trim(res.stdout or "") + if text == "" then + noctalia.notifyError(noctalia.tr("messages.no_text")) + return + end + noctalia.copyToClipboard(text, "text/plain;charset=utf-8") + publish("ocrResult", { text = text, capturePath = OCR_PNG }) + publish("activeTool", "ocr") + saveResults() + noctalia.notify(noctalia.tr("messages.ocr_copied")) + noctalia.togglePanel(RESULT_PANEL_ID) + end) + end) +end + +local function ocrSearch() + local result = noctalia.state.get("ocrResult") + local text = type(result) == "table" and result.text or nil + if not text or text == "" then return end + local url = cfg("search-engine-url") + if url == nil or url == "" then + url = "https://www.google.com/search?q=" + end + local encoded = noctalia.string.urlEncode(text) + if url:find("{text}", 1, true) then + url = url:gsub("{text}", function() return encoded end) + else + url = url .. encoded + end + noctalia.runAsync("xdg-open " .. shellQuote(url)) +end + +local function ocrTranslate(targetLang) + local result = noctalia.state.get("ocrResult") + local text = type(result) == "table" and result.text or nil + if not text or text == "" then return end + if not noctalia.commandExists("trans") then + noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "translate-shell" })) + return + end + publish("translateResult", nil) + local lang = if type(targetLang) == "string" and targetLang ~= "" then targetLang else "en" + noctalia.runAsync(`trans -brief -to {shellQuote(lang)} {shellQuote(text)}`, function(res) + if res.exitCode ~= 0 then + publish("translateResult", noctalia.tr("messages.translate_failed")) + return + end + publish("translateResult", trim(res.stdout or "")) + end) +end + +-- ── Tools: QR / palette / lens ───────────────────────────────────────────── + +local function runQr() + slurpGeometry(function(geom) + hideCursorForCapture(captureEnv() .. SCRIPT_CAPTURE .. " qr " .. shellQuote(geom), function(res) + if res.exitCode ~= 0 or trim(res.stdout or "") == "" then + noctalia.notifyError(noctalia.tr("messages.no_qr")) + return + end + local text = trim(res.stdout or "") + noctalia.copyToClipboard(text, "text/plain;charset=utf-8") + publish("qrResult", { text = text, capturePath = QR_PNG }) + publish("activeTool", "qr") + saveResults() + noctalia.notify(noctalia.tr("messages.qr_copied")) + noctalia.togglePanel(RESULT_PANEL_ID) + end) + end) +end + +local function runPalette() + slurpGeometry(function(geom) + hideCursorForCapture(captureEnv() .. SCRIPT_CAPTURE .. " palette " .. shellQuote(geom), function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.palette_failed")) + return + end + local colors = {} + for line in (res.stdout or ""):gmatch("[^\n]+") do + local c = trim(line) + if c:match("^#%x%x%x%x%x%x$") then + colors[#colors + 1] = c:upper() + end + end + local seen, out = {}, {} + for _, c in ipairs(colors) do + if not seen[c] then + seen[c] = true + out[#out + 1] = c + end + end + if #out == 0 then + noctalia.notifyError(noctalia.tr("messages.palette_failed")) + return + end + noctalia.copyToClipboard(table.concat(out, "\n"), "text/plain;charset=utf-8") + publish("paletteColors", out) + publish("activeTool", "palette") + saveResults() + noctalia.notify(noctalia.tr("messages.palette_copied")) + noctalia.togglePanel(RESULT_PANEL_ID) + end) + end) +end + +local function runLens() + slurpGeometry(function(geom) + local gx, gy, gw, gh = parseGeometry(geom) + if not gx then return end + noctalia.notify(noctalia.tr("messages.lens_uploading")) + noctalia.runAsync(captureEnv() .. SCRIPT_LENS .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh, function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.lens_failed")) + else + noctalia.notify(noctalia.tr("messages.lens_opened")) + end + end) + end) +end + +-- ── Tools: measure ───────────────────────────────────────────────────────── + +local function runMeasure() + slurpGeometry(function(geom) + local gx, gy, gw, gh = parseGeometry(geom) + if not gx or not gw or not gh then return end + local text = string.format("%d x %d px", gw, gh) + noctalia.copyToClipboard(text, "text/plain;charset=utf-8") + publish("measureResult", { width = gw, height = gh, text = text }) + noctalia.notify(noctalia.tr("messages.measure_result", { width = tostring(gw), height = tostring(gh) })) + end) +end + +-- ── Tools: annotate (external editor handoff) ────────────────────────────── + +local function openAnnotator(file) + if noctalia.commandExists("swappy") then + noctalia.runAsync(`swappy -f {shellQuote(file)}`) + elseif noctalia.commandExists("satty") then + local destDir = screenshotDir() + local out = `{destDir}/{buildFilename("annotate", ".png")}` + noctalia.runAsync(`satty --filename {shellQuote(file)} --output-filename {shellQuote(out)}`) + elseif noctalia.commandExists("gimp") then + noctalia.runAsync(`gimp {shellQuote(file)}`) + else + -- No annotation editor installed: hand the capture over via the clipboard + -- so the tool still does something useful. + copyFileUri(file) + noctalia.notify(noctalia.tr("messages.no_annotator"), noctalia.tr("messages.annotate_copied")) + return + end + noctalia.notify(noctalia.tr("messages.annotating")) +end + +local function annotateRegion() + slurpGeometry(function(geom) + grimRegion(geom, ANNOTATE_PNG, function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.capture_failed")) + return + end + openAnnotator(ANNOTATE_PNG) + end) + end) +end + +local function annotateFullscreen() + local out = noctalia.focusedOutputName() + local cmd + -- Give transient UI a moment to clear before the fullscreen grab. + if out and out ~= "" then + cmd = `sleep 0.2 && grim {grimCursorFlag()} -o {shellQuote(out)} {shellQuote(ANNOTATE_PNG)}` + else + cmd = `sleep 0.2 && grim {grimCursorFlag()} {shellQuote(ANNOTATE_PNG)}` + end + hideCursorForCapture(cmd, function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.capture_failed")) + return + end + openAnnotator(ANNOTATE_PNG) + end) +end + +local function annotateWindow() + if not noctalia.commandExists("hyprctl") then + noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "hyprctl" })) + return + end + noctalia.runAsync(captureEnv() .. SCRIPT_CAPTURE .. " annotate-window", function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.capture_failed")) + return + end + openAnnotator(ANNOTATE_PNG) + end) +end + +-- ── Recording ────────────────────────────────────────────────────────────── + +-- Recorder availability. `gpu-screen-recorder` (NVENC) is the best choice on +-- NVIDIA GPUs but can only capture monitors/windows — it cannot record an +-- arbitrary region — so fullscreen capture prefers it while region capture +-- falls back to wl-screenrec / wf-recorder. +local gsrAvailable = false +local wlScreenrecAvailable = false +local wfRecorderAvailable = false +local lastRecorderLog = "" + +local recordState = "idle" -- idle | recording | converting | ready +local recordFormat = "mp4" +local recorderBin = "" -- binary actually running the current recording +local rawPath = "" +local recordPath = "" + +local function refreshRecorders() + gsrAvailable = noctalia.commandExists("gpu-screen-recorder") + wlScreenrecAvailable = noctalia.commandExists("wl-screenrec") + wfRecorderAvailable = noctalia.commandExists("wf-recorder") + local key = string.format( + "%s|%s|%s", tostring(gsrAvailable), tostring(wlScreenrecAvailable), tostring(wfRecorderAvailable) + ) + if key ~= lastRecorderLog then + lastRecorderLog = key + log("recorders: " .. key) + end +end + +local function bestFullscreenRecorder() + if gsrAvailable then return "gpu-screen-recorder" end + if wlScreenrecAvailable then return "wl-screenrec" end + if wfRecorderAvailable then return "wf-recorder" end + return "" +end + +local function bestRegionRecorder() + if wlScreenrecAvailable then return "wl-screenrec" end + if wfRecorderAvailable then return "wf-recorder" end + return "" +end + +local function setRecordState(next) + if recordState == next then return end + recordState = next + publish("recordState", recordState) + publish("recording", recordState == "recording") + publish("recordPath", recordPath) +end + +local function gsrAudioFlags() + local audioOut = cfg("record-audio-out") == true + local audioIn = cfg("record-audio-in") == true + if audioOut and audioIn then + return '-a "default_output|default_input" -ac aac' + elseif audioOut then + return "-a default_output -ac aac" + elseif audioIn then + return "-a default_input -ac aac" + end + return "" +end + +local function buildRecorderCommand(bin, geom, out) + local audioOut = cfg("record-audio-out") == true + local audioIn = cfg("record-audio-in") == true + if bin == "gpu-screen-recorder" then + local codec = cfg("record-codec") or "h264" + local fps = cfg("record-fps") or 60 + local cursor = if cursorHidden then "no" else "yes" + local flags = string.format( + "-w focused -f %d -k %s -bm qp -ffmpeg-video-opts qp=25 -cursor %s -cr limited %s -v no -o %s", + fps, codec, cursor, gsrAudioFlags(), shellQuote(out) + ) + return "gpu-screen-recorder " .. flags + elseif bin == "wl-screenrec" then + local parts = { "wl-screenrec -g", shellQuote(geom), "-f", shellQuote(out) } + if audioOut then table.insert(parts, "--audio") end + if audioIn then table.insert(parts, "--audio-device default") end + if cursorHidden then table.insert(parts, "--no-cursor") end + return table.concat(parts, " ") + end + local parts = { "wf-recorder -g", shellQuote(geom), "-f", shellQuote(out) } + if audioOut then table.insert(parts, "-a") end + return table.concat(parts, " ") +end + +local function startRecord(geom, format, bin) + refreshRecorders() + if bin == "" then + noctalia.notifyError(noctalia.tr("messages.no_recorder")) + return + end + if recordState ~= "idle" then return end + recorderBin = bin + rawPath = `/tmp/screen-toolkit-record-{os.time()}.mp4` + recordFormat = if format == "gif" then "gif" else "mp4" + recordPath = "" + local cmd = buildRecorderCommand(bin, geom, rawPath) + log(`starting recording: {cmd}`) + -- The detached form of runAsync does not report the child exit status. The + -- stop path verifies that the output file was actually created. + noctalia.runAsync(cmd) + setRecordState("recording") + noctalia.notify(noctalia.tr("messages.recording_started")) +end + +local function waitForFile(path, callback, attempts) + attempts = attempts or 60 + noctalia.runAsync(`sleep 0.1; test -s {shellQuote(path)}`, function(res) + if res.exitCode == 0 then + callback(true) + return + end + if attempts <= 0 then + callback(false) + return + end + waitForFile(path, callback, attempts - 1) + end) +end + +local function recordStop() + if recordState ~= "recording" then return end + setRecordState("converting") + noctalia.runAsync(`pkill -INT {shellQuote(recorderBin)}`, function() + waitForFile(rawPath, function(ok) + if not ok then + setRecordState("idle") + noctalia.notifyError(noctalia.tr("messages.recording_failed")) + return + end + local skipConfirm = cfg("record-skip-confirmation") == true + local toClipboard = cfg("record-copy-to-clipboard") == true + if toClipboard then + finalizeRecord("mp4", true) + elseif skipConfirm then + finalizeRecord(recordFormat, false) + else + setRecordState("ready") + noctalia.togglePanel(RESULT_PANEL_ID) + end + end) + end) +end + +local function finalizeRecord(format, copyToClipboard) + if rawPath == "" then return end + local destDir = videoDir() + local ext = if format == "gif" then ".gif" else ".mp4" + local dest = destDir .. "/" .. buildFilename("record", ext) + setRecordState("converting") + local cmd + if format == "gif" then + cmd = SCRIPT_RECORD .. " convert-gif " .. shellQuote(rawPath) .. " " .. shellQuote(dest) + .. " " .. tostring(cfg("gif-max-seconds") or 0) + else + cmd = SCRIPT_RECORD .. " convert-mp4 " .. shellQuote(rawPath) .. " " .. shellQuote(dest) + end + noctalia.runAsync(cmd, function(res) + if res.exitCode ~= 0 then + setRecordState("idle") + noctalia.notifyError(noctalia.tr("messages.recording_failed")) + return + end + recordPath = dest + rawPath = "" + setRecordState("idle") + if copyToClipboard then + copyFileUri(dest) + noctalia.notify(noctalia.tr("panel.copied_to_clipboard"), dest) + else + noctalia.notify(noctalia.tr("messages.saved_to", { path = dest })) + end + end) +end + +local function recordDiscard() + if rawPath ~= "" then + noctalia.runAsync(`rm -f {shellQuote(rawPath)}`) + rawPath = "" + end + setRecordState("idle") +end + +-- ── Sharing ──────────────────────────────────────────────────────────────── + +local function shareFile(file) + if file == nil or file == "" then + noctalia.notifyError(noctalia.tr("messages.share_bad_args")) + return + end + local apiKey = cfg("x02-api-key") or "" + local expiry = cfg("x02-expiry") or "7d" + noctalia.runAsync( + SCRIPT_SHARE .. " " .. shellQuote(file) .. " " .. shellQuote(apiKey) .. " " .. shellQuote(expiry), + function(res) + if res.exitCode ~= 0 then + local msg = SHARE_EXIT_KEYS[res.exitCode] + noctalia.notifyError(if msg then noctalia.tr(msg) else noctalia.tr("messages.share_unknown_error")) + return + end + local url = trim(res.stdout or "") + if url == "" then + noctalia.notifyError(noctalia.tr("messages.share_invalid_response")) + return + end + noctalia.copyToClipboard(url, "text/plain;charset=utf-8") + noctalia.notify(noctalia.tr("panel.share_url"), url) + end + ) +end + +local SHARE_EXIT_KEYS = { + [1] = "messages.share_bad_args", + [2] = "messages.share_file_not_found", + [3] = "messages.share_missing_dep", + [4] = "messages.share_request_failed", + [5] = "messages.share_invalid_response", + [6] = "messages.share_file_too_large", +} + +-- ── Result housekeeping ──────────────────────────────────────────────────── + +local function clearResult() + publish("activeTool", nil) + publish("colorResult", nil) + publish("ocrResult", nil) + publish("translateResult", nil) + publish("qrResult", nil) + publish("paletteColors", nil) + publish("annotateResult", nil) + saveResults() +end + +local function clearHistory() + publish("colorHistory", {}) + saveResults() +end + +local function setCursorHidden(payload) + cursorHidden = payload ~= "false" + publish("cursorHidden", cursorHidden) + log(`cursor hidden: {tostring(cursorHidden)}`) +end + +-- ── Dispatch ─────────────────────────────────────────────────────────────── + +local function runRecordRegion(format) + refreshRecorders() + local bin = bestRegionRecorder() + if bin == "" then + noctalia.notifyError(noctalia.tr("messages.no_recorder")) + return + end + slurpGeometry(function(geom) + startRecord(geom, format, bin) + end) +end + +local function runRecordFullscreen(format) + refreshRecorders() + local bin = bestFullscreenRecorder() + if bin == "" then + noctalia.notifyError(noctalia.tr("messages.no_recorder")) + return + end + local geom = focusedGeometry() + if not geom then + noctalia.notifyError(noctalia.tr("messages.capture_failed")) + return + end + startRecord(geom, format, bin) +end + +local HANDLERS = { + colorPicker = runColorPicker, + ocr = runOcr, + qr = runQr, + palette = runPalette, + lens = runLens, + measure = runMeasure, + annotate = annotateRegion, + annotateFullscreen = annotateFullscreen, + annotateWindow = annotateWindow, + record = function() runRecordRegion("gif") end, + recordMp4 = function() runRecordRegion("mp4") end, + recordFullscreen = function() runRecordFullscreen("gif") end, + recordFullscreenMp4 = function() runRecordFullscreen("mp4") end, + recordStop = recordStop, + recordSaveMp4 = function() finalizeRecord("mp4", false) end, + recordSaveGif = function() finalizeRecord("gif", false) end, + recordCopy = function() finalizeRecord("mp4", true) end, + recordDiscard = recordDiscard, + ocrSearch = ocrSearch, + ocrTranslate = ocrTranslate, + share = shareFile, + clearResult = clearResult, + clearHistory = clearHistory, + setCursorHidden = setCursorHidden, + toggle = function() noctalia.togglePanel(PANEL_ID) end, +} + +local function dispatch(event, payload) + if event == nil then return end + local handler = HANDLERS[event] + if handler then + log(`ipc '{event}'`) + handler(payload) + else + log(`unknown command '{event}'`) + end +end + +-- UI entries drive the service through this channel; the CLI uses onIpc. +noctalia.state.watch("command", function(cmd) + if type(cmd) == "table" then + dispatch(cmd.action, cmd.payload) + elseif type(cmd) == "string" then + dispatch(cmd) + end +end) + +function onIpc(event, payload) + dispatch(event, payload) +end + +-- ── Boot ─────────────────────────────────────────────────────────────────── + +local function boot() + noctalia.mkdirAll(DATA_DIR) + loadState() + cursorHidden = cfg("hide-cursor") ~= false + publish("cursorHidden", cursorHidden) + refreshRecorders() +end + +-- Services stay alive while plugin settings change. Keep capture and recorder +-- flags in sync without requiring the user to restart Noctalia. +function onConfigChanged() + local nextCursorHidden = cfg("hide-cursor") ~= false + if nextCursorHidden ~= cursorHidden then + cursorHidden = nextCursorHidden + publish("cursorHidden", cursorHidden) + end + refreshRecorders() +end + +boot() diff --git a/screen-toolkit/shortcut.luau b/screen-toolkit/shortcut.luau new file mode 100644 index 00000000..9724a493 --- /dev/null +++ b/screen-toolkit/shortcut.luau @@ -0,0 +1,29 @@ +--!nonstrict +-- Screen Toolkit — control-center [[shortcut]] tile. +-- +-- Mirrors the bar widget: shows the recording state and toggles the main +-- panel (or stops a recording). Drives the [[service]] through the "command" +-- state channel — no shell-out, no IPC PATH dependency. + +local recording = false + +local function apply() + shortcut.setLabel(recording and noctalia.tr("shortcut.recording") or noctalia.tr("shortcut.label")) + shortcut.setIcon("crosshair", "crosshair") + shortcut.setActive(recording) +end + +noctalia.state.watch("recording", function(value) + recording = value == true + apply() +end) + +function onClick() + if recording then + noctalia.state.set("command", { action = "recordStop" }) + else + noctalia.togglePanel("alexander/screen-toolkit:panel") + end +end + +apply() diff --git a/screen-toolkit/thumbnail.webp b/screen-toolkit/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..e63e35093df9326eb74b01a58269aa942c6a4c5c GIT binary patch literal 6908 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 oj%(tz2TYfM|BsD}um&r8e37*gL# literal 0 HcmV?d00001 diff --git a/screen-toolkit/translations/en.json b/screen-toolkit/translations/en.json new file mode 100644 index 00000000..32ed225a --- /dev/null +++ b/screen-toolkit/translations/en.json @@ -0,0 +1,219 @@ +{ + "plugin_name": "Screen Toolkit", + "widget": { + "tooltip": "Screen Toolkit", + "recording": "Recording — click to stop" + }, + "shortcut": { + "label": "Screen Toolkit", + "recording": "Stop recording" + }, + "panel": { + "title": "Screen Toolkit", + "subtitle": "Screen utilities", + "section_capture": "Capture", + "section_annotate": "Annotate", + "section_record": "Record", + "no_color": "Pick a color to get started", + "ocr_text": "Detected text", + "ready_hint": "Choose how to save the recording", + "recording_banner": "Recording…", + "tools": "Tools", + "rec": "REC", + "stop": "Stop", + "recording_hint": "Recording — click the bar widget or this button to stop.", + "ready": "Recording finished", + "save_mp4": "Save MP4", + "save_gif": "Save GIF", + "copy": "Copy", + "discard": "Discard", + "saved_to": "Saved: {path}", + "copied_to_clipboard": "Saved and copied to clipboard", + "running": "Running…", + "empty": "Pick a tool. Results appear here.", + "pick_again": "Pick again", + "copy_all": "Copy all formats", + "history": "History", + "clear_history": "Clear history", + "history_cleared": "History cleared", + "clear": "Clear", + "copy_hex": "Copy HEX", + "copy_rgb": "Copy RGB", + "copy_hsl": "Copy HSL", + "copy_hsv": "Copy HSV", + "search_text": "Search text", + "translate_label": "Translate to", + "translate": "Translate", + "translation": "Translation", + "copy_text": "Copy text", + "copy_link": "Copy link", + "open_url": "Open URL", + "copy_hex_list": "Copy hex list", + "copy_css_vars": "Copy as CSS vars", + "no_text": "No text detected.", + "share": "Share", + "sharing": "Uploading…", + "share_url": "Link copied", + "share_failed": "Upload failed", + "no_result": "No result yet.", + "click_to_copy": "Click to copy", + "no_history": "No colors in history yet.", + "copied": "Copied.", + "all_formats_copied": "All formats copied.", + "hex_list_copied": "Hex list copied.", + "css_vars_copied": "CSS variables copied.", + "color_copied": "Color copied.", + "format_copied": "{label} copied." + }, + "tools": { + "colorpicker": "Color", + "annotate": "Markup", + "annotate_fullscreen": "Markup FS", + "annotate_window": "Window", + "ocr": "OCR", + "palette": "Palette", + "measure": "Measure", + "qr": "QR", + "lens": "Lens", + "record": "Record", + "record_gif": "GIF", + "record_mp4": "MP4", + "record_fullscreen_gif": "GIF FS", + "record_fullscreen_mp4": "MP4 FS", + "fullscreen": "Fullscreen", + "window": "Window" + }, + "tooltips": { + "colorpicker": "Pick a color from screen", + "ocr": "Extract text from screen", + "qr": "Scan a QR or barcode", + "palette": "Extract dominant colors from a region", + "lens": "Search image with Google Lens", + "measure": "Measure region size in pixels", + "annotate": "Draw and annotate a region", + "annotate_fullscreen": "Annotate the entire screen", + "annotate_window": "Annotate the focused window (Hyprland)", + "record": "Record a screen region as GIF or MP4", + "record_fullscreen": "Record the entire screen" + }, + "messages": { + "color_copied": "Color {color} copied.", + "color_picked": "Color {color} picked.", + "picker_cancelled": "Color picker cancelled.", + "no_text": "No text detected.", + "no_qr": "No QR code detected.", + "lens_failed": "Google Lens upload failed.", + "lens_opened": "Opened in Google Lens.", + "lens_uploading": "Uploading to Google Lens…", + "capture_failed": "Screen capture failed.", + "palette_failed": "Palette extraction failed.", + "measure_result": "Measured: {width} × {height} px (copied)", + "measure_copied": "Measurement copied.", + "measure_failed": "Measurement failed.", + "translate_failed": "Translation failed.", + "recording_started": "Recording started.", + "recording_stopped": "Recording stopped.", + "recording_failed": "Recording failed or cancelled.", + "no_recorder": "No screen recorder found. Install wl-screenrec or wf-recorder.", + "no_annotator": "No annotation tool found (install swappy or satty)", + "annotate_copied": "The capture was copied to the clipboard instead.", + "ocr_copied": "OCR text copied to clipboard.", + "qr_copied": "QR code copied to clipboard.", + "palette_copied": "Palette copied to clipboard.", + "annotating": "Opened annotation editor.", + "missing_dep": "Missing dependency: {dep}", + "share_bad_args": "Share failed: no file specified.", + "share_file_not_found": "Share failed: file not found.", + "share_missing_dep": "Share failed: missing dependency (curl/jq).", + "share_request_failed": "Upload request failed.", + "share_invalid_response": "Upload failed: unexpected server response.", + "share_file_too_large": "File too large for anonymous upload (128 MB max). Add an X02 API key for larger files.", + "share_unknown_error": "Upload failed with unknown error.", + "saved_to": "Saved: {path}" + }, + "ocr": { + "exit_dep": "Missing dependency: {dep}", + "exit_args": "Invalid capture arguments.", + "exit_capture": "Screen capture failed.", + "exit_process": "Image processing failed.", + "exit_unknown": "Unknown OCR error (code {code})." + }, + "settings": { + "screenshot_path": { + "label": "Screenshot Path", + "description": "Where screenshots are saved. Leave empty for ~/Pictures/Screenshots." + }, + "video_path": { + "label": "Video Path", + "description": "Where recordings are saved. Leave empty for ~/Videos." + }, + "filename_format": { + "label": "Filename Format", + "description": "Template for generated filenames. Extension is added automatically. Tokens: %Y %m %d %H %M %S." + }, + "selected_ocr_lang": { + "label": "OCR Language", + "description": "Tesseract language code (e.g. eng, deu, fra). Can be combined with + (e.g. eng+fra)." + }, + "search_engine_url": { + "label": "Search Engine URL", + "description": "URL used when searching OCR text. Leave empty to use Google. Examples: https://duckduckgo.com/?q= or https://search.brave.com/search?q=" + }, + "x02_api_key": { + "label": "X02 API Key", + "description": "Optional. Get a free key at up.x02.me for permanent links and larger files. Without a key, uploads use uguu.se (anonymous, 3h, 128 MB max)." + }, + "x02_expiry": { + "label": "Link Expiry", + "description": "How long your shared links stay alive. Only applies when using an X02 API key.", + "options": { + "1h": "1 hour", + "1d": "1 day", + "7d": "7 days", + "30d": "30 days", + "permanent": "Permanent" + } + }, + "share_skip_popover": { + "label": "Copy Link Directly", + "description": "Skip the share popover and copy the link straight to clipboard. A notification confirms." + }, + "record_audio_out": { + "label": "Record System Audio", + "description": "Record the desktop's audio output while recording." + }, + "record_audio_in": { + "label": "Record Microphone", + "description": "Record the default microphone while recording." + }, + "hide_cursor": { + "label": "Hide Cursor in Recordings", + "description": "Exclude the cursor from recordings. Screenshots never include the cursor." + }, + "record_codec": { + "label": "Fullscreen Codec", + "description": "Video codec for gpu-screen-recorder fullscreen capture. h264 is the safest NVIDIA NVENC default; av1 needs a recent GPU.", + "options": { + "h264": "H.264", + "hevc": "H.265 / HEVC", + "av1": "AV1" + } + }, + "record_fps": { + "label": "Fullscreen Frame Rate", + "description": "Frame rate for gpu-screen-recorder fullscreen capture (15–240)." + }, + "record_skip_confirmation": { + "label": "Skip Save Confirmation", + "description": "Automatically save to disk when recording finishes, without showing the save dialog." + }, + "record_copy_to_clipboard": { + "label": "Copy to Clipboard", + "description": "Copy the recording to clipboard when done instead of showing the save dialog." + }, + "gif_max_seconds": { + "label": "Max GIF Duration", + "description": "Maximum duration in seconds for GIF recordings." + } + } +} \ No newline at end of file diff --git a/screen-toolkit/widget.luau b/screen-toolkit/widget.luau new file mode 100644 index 00000000..636fdfc7 --- /dev/null +++ b/screen-toolkit/widget.luau @@ -0,0 +1,55 @@ +--!nonstrict +-- Screen Toolkit — bar [[widget]]. +-- +-- Crosshair glyph; a pulsing red dot appears while a recording is active. +-- Left click toggles the main panel (or stops a recording); right click +-- quick-picks a color. Recording logic lives in the [[service]] — this widget +-- only reflects the published state and drives the service through the +-- "command" state channel. + +local recording = false +local phase = 0 + +local function render() + local container = barWidget.isVertical() and ui.column or ui.row + local children = {} + if recording then + table.insert(children, ui.box({ + key = "rec-dot", + width = 7, + height = 7, + radius = 4, + fill = (phase % 2 == 0) and "error" or "error/0.35", + })) + end + table.insert(children, ui.glyph({ name = "crosshair", size = 14 })) + barWidget.render(container({ gap = 6, align = "center" }, children)) + barWidget.setTooltip(recording and noctalia.tr("widget.recording") or noctalia.tr("widget.tooltip")) +end + +-- Pulse the dot: the bar has no frame ticks, so re-render on a half-second cadence. +function update() + noctalia.setUpdateInterval(500) + phase += 1 + render() +end + +noctalia.state.watch("recording", function(value) + recording = value == true + render() +end) + +function onClick() + if recording then + noctalia.state.set("command", { action = "recordStop" }) + else + noctalia.togglePanel("alexander/screen-toolkit:panel") + end +end + +function onRightClick() + -- Quick pick: copies the color and notifies, but never toggles the panel. + noctalia.state.set("command", { action = "colorPicker", payload = { reopen = false } }) +end + +render() From 31087d2ec10b221c7c4b721c4b3a83b81f41b47b Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 2 Aug 2026 21:37:35 +0300 Subject: [PATCH 2/8] screen-toolkit: credit original plugin --- screen-toolkit/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md index 6443a564..6988c012 100644 --- a/screen-toolkit/README.md +++ b/screen-toolkit/README.md @@ -4,6 +4,13 @@ A unified set of screen utilities for the Noctalia shell: color picking, OCR, QR/barcode scanning, palette extraction, Google Lens search, annotation, pixel measuring, screen recording, and image sharing — all in one place. +## Attribution + +Screen Toolkit was originally created for Noctalia v4 by the author(s) of the +[legacy screen-toolkit plugin](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/screen-toolkit). +This repository is an independent Noctalia v5 remake/port by `alexander`; it is +not the original implementation. + ## Plugin | Field | Value | From 240891e57f9d567b6a5252dcd7897aa7f169f4e7 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 00:08:02 +0300 Subject: [PATCH 3/8] screen-toolkit: fix recording, OCR capture, and result panel UX --- screen-toolkit/README.md | 46 +++-- screen-toolkit/panel.luau | 29 ++- screen-toolkit/plugin.toml | 1 + screen-toolkit/result.luau | 275 +++++++++++++++++++++---- screen-toolkit/scripts/capture.sh | 9 +- screen-toolkit/scripts/color-picker.sh | 2 + screen-toolkit/scripts/lens-upload.sh | 7 +- screen-toolkit/scripts/ocr.sh | 7 +- screen-toolkit/scripts/record.sh | 15 +- screen-toolkit/service.luau | 171 +++++++++++---- screen-toolkit/shortcut.luau | 10 +- screen-toolkit/translations/en.json | 33 +-- screen-toolkit/widget.luau | 25 ++- 13 files changed, 476 insertions(+), 154 deletions(-) diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md index 6988c012..9211fd08 100644 --- a/screen-toolkit/README.md +++ b/screen-toolkit/README.md @@ -1,8 +1,8 @@ # Screen Toolkit -A unified set of screen utilities for the Noctalia shell: color picking, OCR, -QR/barcode scanning, palette extraction, Google Lens search, annotation, pixel -measuring, screen recording, and image sharing — all in one place. +A Noctalia v5 plugin for color picking, OCR, QR/barcode scanning, palette +extraction, Google Lens search, annotation, pixel measuring, screen recording, +and image sharing. ## Attribution @@ -20,8 +20,8 @@ not the original implementation. ## Requirements -Install these on `PATH`. Without them the related tools notify you instead of -working. +Install the tools used by the features you want on `PATH`. Missing tools are +reported when that feature is started. - **[slurp](https://github.com/Powerline1/slurp)** — region selection - **grim** — screen capture @@ -31,7 +31,12 @@ working. - **zbar** — QR / barcode scanning (`zbarimg`) - **curl** + **jq** — image uploads (uguu.se / x02.me) and Google Lens - **ffmpeg** — recording / GIF conversion -- **gpu-screen-recorder** — *recommended* for fullscreen recording, especially on NVIDIA (NVENC); falls back to **wl-screenrec** for region capture and **wf-recorder** as a last resort + +Recording requires at least one backend: + +- **gpu-screen-recorder** — recommended for fullscreen recording, especially on NVIDIA (NVENC) +- **wl-screenrec** — preferred for region recording and microphone audio +- **wf-recorder** — fallback recorder for region and fullscreen capture Optional: @@ -56,9 +61,9 @@ Open the main tools panel: noctalia msg panel-toggle alexander/screen-toolkit:panel ``` -The tools panel only holds the tools. When a capture tool finishes, a **result -panel** pops up with the output (color formats, OCR text, QR text, palette) and -its actions; close it to return to the tools panel. +The tools panel contains the capture actions. When a capture tool finishes, a +**result panel** opens with the output and its actions; close it to return to +the tools panel. Region tools (Color, OCR, QR, Palette, Lens, Measure, GIF/MP4 record, Markup) draw a `slurp` crosshair — drag to select a region, then release. Recording @@ -68,16 +73,21 @@ Save Confirmation" is on, the panel then offers **Save MP4**, **Save GIF**, **Copy**, and **Discard**. The `hide-cursor` setting excludes the cursor from **recordings and screenshots** -(default: hidden). On Hyprland, screenshots move the pointer off-screen for the -capture so a software cursor (common on NVIDIA) never lands in the image; -recordings pass `--no-cursor` / `-cursor no` to the recorder. +(default: hidden). Grim excludes the cursor by default; when the setting is +disabled, the plugin passes grim's `-c` flag to include it. gpu-screen-recorder +and wl-screenrec receive their corresponding cursor options. wf-recorder does +not expose a portable cursor flag, so its behavior depends on the compositor. - **Markup** captures the region and opens it in `swappy` (or `satty`). Saving happens in that editor; satty saves to your screenshot path automatically. **Markup Window** shows a crosshair — click the window you want to annotate and it captures that window (Hyprland only). - **Measure** reports the region's pixel size and copies it to the clipboard. -- **OCR** extracts text and copies it to the clipboard. +- **OCR** extracts text and copies it to the clipboard. The result includes the + capture preview and an editable multiline text area, so you can correct, trim, + or extend the OCR output before copying, searching, translating, or sharing + it. Detected URLs can be opened directly and detected email addresses can + open a mail composer. - **QR** decodes a code and copies the text to the clipboard. - **Palette** copies the extracted hex colors (one per line) to the clipboard. - **Share** uploads the current capture and copies the link (uguu.se by default, @@ -98,10 +108,10 @@ All settings live in Settings → Plugins (gear on the plugin's row). | `video-path` | `folder` | `~/Videos` | Where recordings are saved. | | `filename-format` | `string` | `%Y-%m-%d_%H-%M-%S` | Filename template; the extension is added automatically. | | `selected-ocr-lang` | `string` | `eng` | Tesseract language code; combine with `+` (e.g. `eng+fra`). | -| `search-engine-url` | `string` | *(Google)* | URL template for "Search text"; `{text}` is URL-encoded. | +| `search-engine-url` | `string` | *(Google)* | Search URL prefix, or a URL containing `{text}`. The OCR text is URL-encoded. | | `x02-api-key` | `string` | *(empty)* | up.x02.me key for longer-lived, larger uploads. | | `x02-expiry` | `select` | `7d` | Link lifetime when an x02 key is set: `1h`, `1d`, `7d`, `30d`, or `permanent`. | -| `share-skip-popover` | `bool` | `false` | Copy the share link straight to the clipboard (the panel has no share popover, so links always copy; kept for parity). | +| `share-skip-popover` | `bool` | `false` | Compatibility setting retained from v4. The v5 result panel copies share links directly. | | `record-audio-out` | `bool` | `false` | Record the desktop's audio output. | | `record-audio-in` | `bool` | `false` | Record the default microphone. | | `hide-cursor` | `bool` | `true` | Exclude the cursor from recordings and screenshots. On Hyprland, screenshots briefly move the pointer off-screen during capture. | @@ -166,3 +176,9 @@ noctalia msg plugin alexander/screen-toolkit:service all ocrTranslate en are transient. - Network calls: Google Lens upload (uguu.se), share uploads (uguu.se or up.x02.me), and `xdg-open` for search/URL results. + +## License + +This remake is released under the MIT license. It is an independent v5 port of +the original legacy plugin; see [Attribution](#attribution) for the original +project and source. diff --git a/screen-toolkit/panel.luau b/screen-toolkit/panel.luau index 46f838c5..824ef18f 100644 --- a/screen-toolkit/panel.luau +++ b/screen-toolkit/panel.luau @@ -7,7 +7,6 @@ -- panel first so the capture never includes it. local panelOpen = false - local recording = noctalia.state.get("recording") == true -- ── Helpers ──────────────────────────────────────────────────────────────── @@ -45,16 +44,24 @@ local function runTool(action) send(action) end -local function refresh() - recording = noctalia.state.get("recording") == true -end - -- ── Header ───────────────────────────────────────────────────────────────── local function header() + local actions = { + ui.button({ glyph = "close", onClick = function() panel.close() end }), + } + if recording then + table.insert(actions, 1, ui.button({ + glyph = "square", + variant = "destructive", + controlSize = "sm", + tooltip = tr("panel.stop"), + onClick = function() send("recordStop") end, + })) + end return ui.row({ align = "center", gap = 8 }, { ui.label({ text = tr("panel.title"), flexGrow = 1, fontSize = 16, fontWeight = "bold", color = "on_surface" }), - ui.button({ glyph = "close", onClick = function() panel.close() end }), + ui.row({ gap = 4 }, actions), }) end @@ -138,7 +145,6 @@ end function onOpen(_context) panelOpen = true - refresh() render() end @@ -146,4 +152,11 @@ function onClose() panelOpen = false end -noctalia.state.watch("recording", function(v) recording = v == true; if panelOpen then render() end end) +noctalia.state.watch("recording", function(v) + recording = v == true + if panelOpen then render() end +end) + +function update() + noctalia.setUpdateInterval(500) +end diff --git a/screen-toolkit/plugin.toml b/screen-toolkit/plugin.toml index 39b38924..843b5a6c 100644 --- a/screen-toolkit/plugin.toml +++ b/screen-toolkit/plugin.toml @@ -25,6 +25,7 @@ dependencies = [ "curl", "ffmpeg", "jq", + "translate-shell", ] # ── Plugin-level settings (shared by every entry, edited in Settings → Plugins) ── diff --git a/screen-toolkit/result.luau b/screen-toolkit/result.luau index c7b1ab18..48c66a25 100644 --- a/screen-toolkit/result.luau +++ b/screen-toolkit/result.luau @@ -9,10 +9,14 @@ local panelOpen = false local recordState = noctalia.state.get("recordState") or "idle" +local recordFormat = noctalia.state.get("recordFormat") or "mp4" +local recordInfo = noctalia.state.get("recordInfo") +local saveContainerIndex = 0 local activeTool = noctalia.state.get("activeTool") local colorResult = noctalia.state.get("colorResult") local colorHistory = noctalia.state.get("colorHistory") or {} local ocrResult = noctalia.state.get("ocrResult") +local ocrEditText = type(ocrResult) == "table" and ocrResult.text or "" local translateResult = noctalia.state.get("translateResult") local qrResult = noctalia.state.get("qrResult") local paletteColors = noctalia.state.get("paletteColors") or {} @@ -32,10 +36,9 @@ local function shellQuote(value) return "'" .. tostring(value):gsub("'", [["'"']]) .. "'" end -local function copyText(text, notifyKey) +local function copyText(text) if not text or text == "" then return end noctalia.copyToClipboard(text, "text/plain;charset=utf-8") - if notifyKey then noctalia.notify(tr("plugin_name"), tr(notifyKey)) end end local function actionButton(glyph, text, onClick, variant) @@ -48,12 +51,77 @@ local function actionButton(glyph, text, onClick, variant) }) end +-- History only stores hex, so rebuild the other formats here (mirrors the +-- service's conversions so the shown values match a fresh pick). +local function hexToRgb(hex) + local r, g, b = tostring(hex):match("#(%x%x)(%x%x)(%x%x)") + if not r then return 0, 0, 0 end + return tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) +end + +local function rgbToHsv(r, g, b) + local rn, gn, bn = r / 255, g / 255, b / 255 + local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn) + local d = max - min + local h, sat, val = 0, 0, max + if d ~= 0 then + sat = d / max + if max == rn then + h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6 + elseif max == gn then + h = (bn - rn) / d + 2 + else + h = (rn - gn) / d + 4 + end + h = math.round(h * 60) + end + return h, math.round(sat * 100), math.round(val * 100) +end + +local function rgbToHsl(r, g, b) + local rn, gn, bn = r / 255, g / 255, b / 255 + local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn) + local d = max - min + local h, l = 0, (max + min) / 2 + local s = if d == 0 then 0 else d / (1 - math.abs(2 * l - 1)) + if d ~= 0 then + if max == rn then + h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6 + elseif max == gn then + h = (bn - rn) / d + 2 + else + h = (rn - gn) / d + 4 + end + h = h / 6 + end + return math.floor(h * 360 + 0.5), math.round(s * 100), math.round(l * 100) +end + +local function showHistoryColor(hex) + local r, g, b = hexToRgb(hex) + local h, s, v = rgbToHsv(r, g, b) + local hh, ss, ll = rgbToHsl(r, g, b) + -- Publish through state: the colorResult / activeTool watches re-render, so + -- this works without a forward reference to render(). + noctalia.state.set("activeTool", "colorpicker") + noctalia.state.set("colorResult", { + hex = hex, + rgb = string.format("rgb(%d, %d, %d)", r, g, b), + hsv = string.format("hsv(%d, %d%%, %d%%)", h, s, v), + hsl = string.format("hsl(%d, %d%%, %d%%)", hh, ss, ll), + capturePath = nil, + }) +end + local function refresh() recordState = noctalia.state.get("recordState") or "idle" + recordFormat = noctalia.state.get("recordFormat") or "mp4" + recordInfo = noctalia.state.get("recordInfo") activeTool = noctalia.state.get("activeTool") colorResult = noctalia.state.get("colorResult") colorHistory = noctalia.state.get("colorHistory") or {} ocrResult = noctalia.state.get("ocrResult") + ocrEditText = type(ocrResult) == "table" and ocrResult.text or "" translateResult = noctalia.state.get("translateResult") qrResult = noctalia.state.get("qrResult") paletteColors = noctalia.state.get("paletteColors") or {} @@ -92,24 +160,81 @@ local function header() }) end --- Recording finished: choose how to save it. +-- Recording finished: preview the thumbnail, recording info, then save or +-- discard. The format (GIF / MP4) was chosen from the main panel when the +-- recording started; a video can still be finalized as MP4 or MOV. +local RECORD_THUMB_PATH = "/tmp/screen-toolkit-record-thumb.png" + +local function fmtBytes(bytes) + if bytes >= 1048576 then + return string.format("%.1f MB", bytes / 1048576) + end + return string.format("%.0f KB", bytes / 1024) +end + +local function fmtDuration(seconds) + seconds = math.floor(seconds + 0.5) + local h = math.floor(seconds / 3600) + local m = math.floor((seconds % 3600) / 60) + local s = seconds % 60 + if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end + return string.format("%d:%02d", m, s) +end + +local function fmtRatio(w, h) + if not w or not h or w <= 0 or h <= 0 then return "—" end + local a, b = w, h + while b ~= 0 do a, b = b, a % b end + return string.format("%d:%d", w / a, h / a) +end + +local function infoTile(label, value) + return ui.column({ flexGrow = 1, gap = 2, align = "center", paddingH = 6, paddingV = 8, radius = 10, fill = "surface_variant/0.25" }, { + ui.label({ text = label, fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = value or "—", fontSize = 13, fontWeight = "bold" }), + }) +end + local function recordSaveCard() - return ui.column({ flexGrow = 1, gap = 12, justify = "center" }, { - ui.row({ gap = 10, align = "center", paddingH = 12, paddingV = 12, radius = 12, fill = "surface_variant/0.25" }, { - ui.column({ width = 34, height = 34, radius = 10, fill = "primary/0.15", align = "center", justify = "center" }, { - ui.glyph({ name = "video", size = 18, color = "primary" }), - }), - ui.column({ flexGrow = 1, gap = 1 }, { - ui.label({ text = tr("panel.ready"), fontSize = 14, fontWeight = "bold" }), - ui.label({ text = tr("panel.ready_hint"), fontSize = 11, color = "on_surface_variant" }), - }), - }), - ui.row({ gap = 6 }, { - actionButton("movie", tr("panel.save_mp4"), function() send("recordSaveMp4") end, "primary"), - actionButton("gif", tr("panel.save_gif"), function() send("recordSaveGif") end), - actionButton("copy", tr("panel.copy"), function() send("recordCopy") end), - actionButton("trash", tr("panel.discard"), function() send("recordDiscard") end, "ghost"), + local isGif = recordFormat == "gif" + local info = recordInfo or {} + local actions = {} + if isGif then + table.insert(actions, ui.label({ + text = tr("tools.record_gif"), + flexGrow = 1, + fontSize = 12, + color = "on_surface_variant", + })) + else + table.insert(actions, ui.select({ + options = { "MP4", "MOV" }, + selectedIndex = saveContainerIndex, + controlSize = "sm", + width = 96, + onChange = function(value) + saveContainerIndex = tonumber(value) or 0 + end, + })) + end + table.insert(actions, actionButton("download", tr("panel.save"), function() + if isGif then + send("recordSave") + else + send("recordSave", { format = saveContainerIndex == 0 and "mp4" or "mov" }) + end + end, "primary")) + table.insert(actions, actionButton("trash", tr("panel.discard"), function() + send("recordDiscard") + end, "ghost")) + return ui.column({ flexGrow = 1, gap = 10 }, { + ui.image({ path = RECORD_THUMB_PATH, height = 220, fit = "contain", radius = 12, borderWidth = 1, border = "outline" }), + ui.row({ gap = 8 }, { + infoTile(tr("panel.record_size"), fmtBytes(info.size or 0)), + infoTile(tr("panel.record_duration"), fmtDuration(info.duration or 0)), + infoTile(tr("panel.record_ratio"), fmtRatio(info.width, info.height)), }), + ui.row({ gap = 6, align = "center" }, actions), }) end @@ -122,11 +247,11 @@ local function emptyState(glyph, text) }) end -local function colorFormatRow(name, value, notifyKey) +local function colorFormatRow(name, value) return ui.row({ gap = 8, align = "center", paddingH = 10, paddingV = 8, radius = 10, fill = "surface_variant/0.25" }, { ui.label({ text = name, width = 40, fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), ui.label({ text = value, flexGrow = 1, fontSize = 12 }), - ui.button({ glyph = "copy", variant = "ghost", onClick = function() copyText(value, notifyKey) end }), + ui.button({ glyph = "copy", variant = "ghost", onClick = function() copyText(value) end }), }) end @@ -142,7 +267,7 @@ local function colorHistoryRow() flexGrow = 1, gap = 4, align = "center", - onClick = function() copyText(hex, "panel.color_copied") end, + onClick = function() showHistoryColor(hex) end, }, { ui.box({ width = 26, height = 26, radius = 8, fill = hex, borderWidth = 1, border = "outline" }), })) @@ -169,15 +294,15 @@ local function renderColor() }), ui.row({ gap = 6 }, { actionButton("copy", tr("panel.copy_all"), function() - copyText(string.format("%s\n%s\n%s\n%s", c.hex, c.rgb, c.hsl, c.hsv), "panel.all_formats_copied") + copyText(string.format("%s\n%s\n%s\n%s", c.hex, c.rgb, c.hsl, c.hsv)) end), actionButton("share", tr("panel.share"), function() send("share", c.capturePath) end), }), ui.separator({ spacing = 4 }), - colorFormatRow("HEX", c.hex, "panel.copy_hex"), - colorFormatRow("RGB", c.rgb, "panel.copy_rgb"), - colorFormatRow("HSL", c.hsl, "panel.copy_hsl"), - colorFormatRow("HSV", c.hsv, "panel.copy_hsv"), + colorFormatRow("HEX", c.hex), + colorFormatRow("RGB", c.rgb), + colorFormatRow("HSL", c.hsl), + colorFormatRow("HSV", c.hsv), ui.separator({ spacing = 4 }), ui.column({ gap = 6 }, { ui.row({ align = "center", justify = "space_between", gap = 8 }, { @@ -193,29 +318,83 @@ local function renderOcr() if not ocrResult then return emptyState("scan", tr("panel.no_result")) end + local text = ocrEditText or "" + local url = text:match("https?://[^%s]+") + if not url then + local bare = text:match("www%.[%w%-%.]+[^%s]*") + if bare then url = "https://" .. bare end + end + local email = text:match("[%w%._%%+%-]+@[%w%.%-]+%.[%a][%a]+") + local imgW = tonumber(ocrResult.gw) or 0 + local imgH = tonumber(ocrResult.gh) or 0 + local imgHeight = 110 + if imgW > 0 and imgH > 0 then + imgHeight = math.max(90, math.min(200, math.floor(480 * (imgH / imgW)))) + end local scrollChildren = { ui.label({ text = tr("panel.ocr_text"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), - ui.label({ text = ocrResult.text, fontSize = 13 }), } + if ocrResult.capturePath and ocrResult.capturePath ~= "" then + table.insert(scrollChildren, ui.image({ path = ocrResult.capturePath, height = imgHeight, fit = "contain", radius = 12, borderWidth = 1, border = "outline" })) + end + -- ui.input does not support radius / border / padding, so frame the editor + -- in a matching container to align it with the picture and keep long text + -- from touching the edges. + table.insert(scrollChildren, ui.column({ + radius = 12, + borderWidth = 1, + border = "outline", + paddingH = 10, + paddingV = 8, + }, { + ui.input({ + key = "ocr-edit", + value = text, + multiline = true, + height = 220, + flexGrow = 1, + onChange = function(value) ocrEditText = value or "" end, + }), + })) if translateResult and translateResult ~= "" then table.insert(scrollChildren, ui.separator({ spacing = 6 })) table.insert(scrollChildren, ui.label({ text = tr("panel.translation"), fontSize = 12, fontWeight = "bold", color = "primary" })) table.insert(scrollChildren, ui.label({ text = translateResult, fontSize = 13 })) end - return ui.column({ flexGrow = 1, gap = 8 }, { - ui.row({ gap = 6 }, { - actionButton("copy", tr("panel.copy_text"), function() copyText(ocrResult.text, "panel.copied") end), - actionButton("search", tr("panel.search_text"), function() send("ocrSearch") end), - ui.input({ - key = "ocr-translate-input", - placeholder = "en", - controlSize = "sm", - width = 96, - onSubmit = function(value) send("ocrTranslate", value) end, - }), - actionButton("share", tr("panel.share"), function() send("share", ocrResult.capturePath) end), + local buttons = { + ui.button({ + glyph = "copy", + tooltip = tr("panel.copy_text"), + controlSize = "sm", + onClick = function() copyText(ocrEditText) end, }), - ui.scroll({ flexGrow = 1, gap = 8 }, scrollChildren), + } + if url then + table.insert(buttons, actionButton("external-link", tr("panel.open_url"), function() + noctalia.runAsync("xdg-open " .. shellQuote(url)) + end)) + elseif email then + table.insert(buttons, actionButton("mail", tr("panel.compose_mail"), function() + noctalia.runAsync("xdg-open " .. shellQuote("mailto:" .. email)) + end)) + end + table.insert(buttons, actionButton("search", tr("panel.search_text"), function() send("ocrSearch", { text = ocrEditText }) end)) + local translateInput = ui.input({ + key = "ocr-translate-input", + placeholder = "en", + controlSize = "sm", + width = 72, + onSubmit = function(value) send("ocrTranslate", { lang = value, text = ocrEditText }) end, + }) + table.insert(buttons, actionButton("share", tr("panel.share"), function() send("share", ocrResult.capturePath) end)) + table.insert(buttons, ui.spacer({ flexGrow = 1 })) + table.insert(buttons, ui.row({ gap = 4, align = "center" }, { + ui.label({ text = tr("panel.translate_label"), fontSize = 9, color = "on_surface_variant" }), + translateInput, + })) + return ui.column({ flexGrow = 1, gap = 6 }, { + ui.row({ gap = 6 }, buttons), + ui.scroll({ flexGrow = 1, gap = 6 }, scrollChildren), }) end @@ -226,7 +405,7 @@ local function renderQr() local text = qrResult.text or "" local isUrl = text:sub(1, 4) == "http" local buttons = { - actionButton("copy", tr("panel.copy_text"), function() copyText(text, "panel.copied") end), + actionButton("copy", tr("panel.copy_text"), function() copyText(text) end), } if isUrl then table.insert(buttons, actionButton("external-link", tr("panel.open_url"), function() @@ -259,7 +438,7 @@ local function renderPalette() fill = "surface_variant/0.25", borderWidth = 1, border = "outline", - onClick = function() copyText(hex, "panel.color_copied") end, + onClick = function() copyText(hex) end, }, { ui.box({ width = 34, height = 34, radius = 10, fill = hex, borderWidth = 1, border = "outline" }), ui.label({ text = hex, fontSize = 9, color = "on_surface_variant" }), @@ -268,12 +447,12 @@ local function renderPalette() return ui.column({ flexGrow = 1, gap = 8 }, { ui.row({ gap = 6 }, { actionButton("copy", tr("panel.copy_hex_list"), function() - copyText(table.concat(paletteColors, "\n"), "panel.hex_list_copied") + copyText(table.concat(paletteColors, "\n")) end), actionButton("code", tr("panel.copy_css_vars"), function() local buf = {} for i, hex in ipairs(paletteColors) do buf[#buf + 1] = `--palette-{i}: {hex};` end - copyText(table.concat(buf, "\n"), "panel.css_vars_copied") + copyText(table.concat(buf, "\n")) end), actionButton("share", tr("panel.share"), function() send("share", "/tmp/screen-toolkit-palette.png") end), }), @@ -325,10 +504,16 @@ noctalia.state.watch("recordState", function(v) recordState = v or "idle" if panelOpen then render() end end) +noctalia.state.watch("recordFormat", function(v) recordFormat = v or "mp4"; if panelOpen then render() end end) +noctalia.state.watch("recordInfo", function(v) recordInfo = v; if panelOpen then render() end end) noctalia.state.watch("activeTool", function(v) activeTool = v; if panelOpen then render() end end) noctalia.state.watch("colorResult", function(v) colorResult = v; if panelOpen then render() end end) noctalia.state.watch("colorHistory", function(v) colorHistory = v or {}; if panelOpen then render() end end) -noctalia.state.watch("ocrResult", function(v) ocrResult = v; if panelOpen then render() end end) +noctalia.state.watch("ocrResult", function(v) + ocrResult = v + ocrEditText = type(v) == "table" and v.text or "" + if panelOpen then render() end +end) noctalia.state.watch("translateResult", function(v) translateResult = v; if panelOpen then render() end end) noctalia.state.watch("qrResult", function(v) qrResult = v; if panelOpen then render() end end) noctalia.state.watch("paletteColors", function(v) paletteColors = v or {}; if panelOpen then render() end end) diff --git a/screen-toolkit/scripts/capture.sh b/screen-toolkit/scripts/capture.sh index d889ba83..dc730c0e 100755 --- a/screen-toolkit/scripts/capture.sh +++ b/screen-toolkit/scripts/capture.sh @@ -54,7 +54,8 @@ case "$ACTION" in || { echo "ERROR: could not parse window geometry" >&2; exit 2; } # grim excludes the cursor by default; moving it would make the picker # unreliable and is compositor-specific. - grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOM" /tmp/screen-toolkit-annotate.png 2>/dev/null \ + sleep 0.15 + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOM" /tmp/screen-toolkit-annotate.png 2>/dev/null \ || { echo "ERROR: grim capture failed" >&2; exit 2; } printf '%s\n' "$GEOM" ;; @@ -67,7 +68,8 @@ case "$ACTION" in FILE="/tmp/screen-toolkit-palette.png" - grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" "$FILE" 2>/dev/null \ + sleep 0.15 + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" "$FILE" 2>/dev/null \ || { echo "ERROR: palette: grim capture failed" >&2; exit 2; } magick "$FILE" -alpha off +dither -colors 8 -unique-colors txt:- 2>/dev/null \ @@ -82,7 +84,8 @@ case "$ACTION" in _require grim _require zbarimg - grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" /tmp/screen-toolkit-qr.png 2>/dev/null \ + sleep 0.15 + grim "${GRIM_CURSOR_ARGS[@]}" -g "$GEOMETRY" /tmp/screen-toolkit-qr.png 2>/dev/null \ || { echo "ERROR: qr: grim capture failed" >&2; exit 2; } zbarimg -q --raw /tmp/screen-toolkit-qr.png 2>/dev/null diff --git a/screen-toolkit/scripts/color-picker.sh b/screen-toolkit/scripts/color-picker.sh index 5bd8d2b1..fb74f59f 100755 --- a/screen-toolkit/scripts/color-picker.sh +++ b/screen-toolkit/scripts/color-picker.sh @@ -37,6 +37,7 @@ if command -v hyprpicker >/dev/null 2>&1; then # Capture 21x21 area centered on picked pixel for more context GX=$((X > 10 ? X - 10 : 0)) GY=$((Y > 10 ? Y - 10 : 0)) + sleep 0.15 grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} 21x21" "$FILE" 2>/dev/null && CAPTURED=1 fi fi @@ -55,6 +56,7 @@ done COORDS=$(slurp -p 2>/dev/null) || exit 1 X=${COORDS%%,*}; REST=${COORDS#*,}; Y=${REST%% *} GX=$((X > 10 ? X - 10 : 0)); GY=$((Y > 10 ? Y - 10 : 0)) +sleep 0.15 grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} 21x21" "$FILE" 2>/dev/null || exit 1 magick "$FILE" -alpha off \ -format '%[fx:int(255*u.p{10,10}.r)] %[fx:int(255*u.p{10,10}.g)] %[fx:int(255*u.p{10,10}.b)]' \ diff --git a/screen-toolkit/scripts/lens-upload.sh b/screen-toolkit/scripts/lens-upload.sh index 88750af2..1958d431 100755 --- a/screen-toolkit/scripts/lens-upload.sh +++ b/screen-toolkit/scripts/lens-upload.sh @@ -15,7 +15,12 @@ for dep in grim curl jq xdg-open; do command -v "$dep" >/dev/null 2>&1 || { echo "$dep"; exit 1; } done -grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null || exit 2 +sleep 0.15 + +# Capture the requested region. Without this the upload would send whatever +# stale image happens to be left at $FILE (or nothing at all). +grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null \ + || { echo "ERROR: grim capture failed" >&2; exit 2; } RESP=$(curl -sS -f -A 'Mozilla/5.0' --connect-timeout 20 --max-time 60 \ -F "files[]=@$FILE" 'https://uguu.se/upload' 2>/dev/null) || \ diff --git a/screen-toolkit/scripts/ocr.sh b/screen-toolkit/scripts/ocr.sh index 8f23b455..70d0237e 100755 --- a/screen-toolkit/scripts/ocr.sh +++ b/screen-toolkit/scripts/ocr.sh @@ -46,7 +46,12 @@ done LANG="${VALID_LANGS#+}" [ -z "$LANG" ] && LANG="eng" -grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null || exit 3 +sleep 0.15 + +# Capture the requested region. Without this the script would keep re-reading +# whatever stale image happens to be left at $FILE (stale OCR results). +grim "${GRIM_CURSOR_ARGS[@]}" -g "${GX},${GY} ${GW}x${GH}" "$FILE" 2>/dev/null \ + || { echo "ERROR: grim capture failed" >&2; exit 3; } if [ -z "$UPSCALE" ] && [ "$GW" -lt 200 ] 2>/dev/null; then SCALE=$(awk "BEGIN{printf \"%.0f\", 300 / $GW}") diff --git a/screen-toolkit/scripts/record.sh b/screen-toolkit/scripts/record.sh index c944c141..9132d707 100755 --- a/screen-toolkit/scripts/record.sh +++ b/screen-toolkit/scripts/record.sh @@ -6,6 +6,7 @@ # convert-mp4 — finalize MP4: stream-copy (fast path) # convert-mp4 --recode — finalize MP4: re-encode audio to AAC 128k + faststart # convert-gif [maxsec] — convert MP4 → palette-optimized GIF at 15 fps +# convert-mov — remux MP4 → MOV (stream copy) # stop — send SIGINT to the named recorder process # # Exit codes: @@ -87,6 +88,18 @@ case "$ACTION" in rm -f "$PALETTE" "$INPUT" _thumb "$OUTPUT" ;; + convert-mov) + INPUT="${2:-}" + OUTPUT="${3:-}" + [ -n "$INPUT" ] || { echo "ERROR: convert-mov: missing " >&2; exit 1; } + [ -n "$OUTPUT" ] || { echo "ERROR: convert-mov: missing " >&2; exit 1; } + [ -f "$INPUT" ] || { echo "ERROR: convert-mov: file not found: $INPUT" >&2; exit 2; } + _require ffmpeg + ffmpeg -y -i "$INPUT" -c copy -movflags +faststart "$OUTPUT" 2>/dev/null \ + || { echo "ERROR: convert-mov: ffmpeg failed" >&2; exit 4; } + rm -f "$INPUT" + _thumb "$OUTPUT" + ;; stop) BIN="${2:-}" [ -n "$BIN" ] || { echo "ERROR: stop: missing " >&2; exit 1; } @@ -94,7 +107,7 @@ case "$ACTION" in pkill -INT "$BIN" 2>/dev/null || true ;; *) - echo "ERROR: unknown action '${ACTION}'. Expected: thumb | convert-mp4 | convert-gif | stop" >&2 + echo "ERROR: unknown action '${ACTION}'. Expected: thumb | convert-mp4 | convert-gif | convert-mov | stop" >&2 exit 1 ;; esac diff --git a/screen-toolkit/service.luau b/screen-toolkit/service.luau index 4d31826f..2f8aa729 100644 --- a/screen-toolkit/service.luau +++ b/screen-toolkit/service.luau @@ -59,12 +59,13 @@ end -- cursor by default, so screenshots do not need a pointer-moving workaround. -- This also keeps color picking and slurp interaction reliable on Hyprland. local cursorHidden = true +local CAPTURE_DELAY = 0.15 -- Grim excludes the cursor unless `-c` is passed. Do not move the -- pointer: that breaks color picking and is compositor-specific. Recorder -- backends receive their own cursor flag in buildRecorderCommand below. local function hideCursorForCapture(cmd, cb) - noctalia.runAsync(cmd, cb) + noctalia.runAsync(`sleep {CAPTURE_DELAY}; {cmd}`, cb) end local function grimCursorFlag() @@ -136,13 +137,15 @@ local function slurpGeometry(callback) return end slurping = true + -- slurp blocks for as long as the user is dragging the selection. Give it a + -- generous timeout; the default runAsync one cancels a slow/long drag. noctalia.runAsync("sleep 0.3 && slurp -f '%x,%y %wx%h'", function(res) slurping = false if res.exitCode ~= 0 then return end -- cancelled local geom = trim(res.stdout or "") if geom == "" then return end callback(geom) - end) + end, 600000) end local function grimRegion(geom, dest, cb) @@ -253,12 +256,11 @@ end -- ── Tools: color picker ──────────────────────────────────────────────────── local function runColorPicker() - noctalia.runAsync(captureEnv() .. SCRIPT_PICKER .. " " .. shellQuote(COLOR_PNG), function(res) - if res.exitCode ~= 0 or trim(res.stdout or "") == "" then - noctalia.notify(noctalia.tr("messages.picker_cancelled")) - return - end - local r, g, b = trim(res.stdout or ""):match("^(%d+)%s+(%d+)%s+(%d+)$") + -- hyprpicker waits for an interactive pointer selection. Keep it on the + -- long-lived stream API; captured runAsync callbacks can time out while the + -- user is still choosing a color. + noctalia.runStream(`sleep {CAPTURE_DELAY}; {captureEnv()}{SCRIPT_PICKER} {shellQuote(COLOR_PNG)}`, function(line) + local r, g, b = tostring(line):match("^(%d+)%s+(%d+)%s+(%d+)$") if not r then return end r, g, b = clamp(tonumber(r)), clamp(tonumber(g)), clamp(tonumber(b)) local hex = string.format("#%02X%02X%02X", r, g, b) @@ -276,7 +278,6 @@ local function runColorPicker() }) publish("activeTool", "colorpicker") saveResults() - noctalia.notify(noctalia.tr("messages.color_picked", { color = hex })) noctalia.togglePanel(RESULT_PANEL_ID) end) end @@ -331,18 +332,19 @@ local function runOcr() return end noctalia.copyToClipboard(text, "text/plain;charset=utf-8") - publish("ocrResult", { text = text, capturePath = OCR_PNG }) + publish("ocrResult", { text = text, capturePath = OCR_PNG, gw = tonumber(gw), gh = tonumber(gh) }) publish("activeTool", "ocr") saveResults() - noctalia.notify(noctalia.tr("messages.ocr_copied")) noctalia.togglePanel(RESULT_PANEL_ID) end) end) end -local function ocrSearch() +local function ocrSearch(payload) local result = noctalia.state.get("ocrResult") - local text = type(result) == "table" and result.text or nil + local text = if type(payload) == "table" and type(payload.text) == "string" + then payload.text + else type(result) == "table" and result.text or nil if not text or text == "" then return end local url = cfg("search-engine-url") if url == nil or url == "" then @@ -357,15 +359,18 @@ local function ocrSearch() noctalia.runAsync("xdg-open " .. shellQuote(url)) end -local function ocrTranslate(targetLang) +local function ocrTranslate(payload) local result = noctalia.state.get("ocrResult") - local text = type(result) == "table" and result.text or nil + local text = if type(payload) == "table" and type(payload.text) == "string" + then payload.text + else type(result) == "table" and result.text or nil if not text or text == "" then return end if not noctalia.commandExists("trans") then noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "translate-shell" })) return end publish("translateResult", nil) + local targetLang = if type(payload) == "table" then payload.lang else payload local lang = if type(targetLang) == "string" and targetLang ~= "" then targetLang else "en" noctalia.runAsync(`trans -brief -to {shellQuote(lang)} {shellQuote(text)}`, function(res) if res.exitCode ~= 0 then @@ -390,7 +395,6 @@ local function runQr() publish("qrResult", { text = text, capturePath = QR_PNG }) publish("activeTool", "qr") saveResults() - noctalia.notify(noctalia.tr("messages.qr_copied")) noctalia.togglePanel(RESULT_PANEL_ID) end) end) @@ -425,7 +429,6 @@ local function runPalette() publish("paletteColors", out) publish("activeTool", "palette") saveResults() - noctalia.notify(noctalia.tr("messages.palette_copied")) noctalia.togglePanel(RESULT_PANEL_ID) end) end) @@ -435,12 +438,9 @@ local function runLens() slurpGeometry(function(geom) local gx, gy, gw, gh = parseGeometry(geom) if not gx then return end - noctalia.notify(noctalia.tr("messages.lens_uploading")) noctalia.runAsync(captureEnv() .. SCRIPT_LENS .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh, function(res) if res.exitCode ~= 0 then noctalia.notifyError(noctalia.tr("messages.lens_failed")) - else - noctalia.notify(noctalia.tr("messages.lens_opened")) end end) end) @@ -474,10 +474,9 @@ local function openAnnotator(file) -- No annotation editor installed: hand the capture over via the clipboard -- so the tool still does something useful. copyFileUri(file) - noctalia.notify(noctalia.tr("messages.no_annotator"), noctalia.tr("messages.annotate_copied")) + noctalia.notify(noctalia.tr("messages.no_annotator")) return end - noctalia.notify(noctalia.tr("messages.annotating")) end local function annotateRegion() @@ -515,7 +514,7 @@ local function annotateWindow() noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "hyprctl" })) return end - noctalia.runAsync(captureEnv() .. SCRIPT_CAPTURE .. " annotate-window", function(res) + noctalia.runAsync(`sleep {CAPTURE_DELAY}; {captureEnv()}{SCRIPT_CAPTURE} annotate-window`, function(res) if res.exitCode ~= 0 then noctalia.notifyError(noctalia.tr("messages.capture_failed")) return @@ -537,9 +536,9 @@ local lastRecorderLog = "" local recordState = "idle" -- idle | recording | converting | ready local recordFormat = "mp4" -local recorderBin = "" -- binary actually running the current recording local rawPath = "" local recordPath = "" +local recordStartedAt = nil local function refreshRecorders() gsrAvailable = noctalia.commandExists("gpu-screen-recorder") @@ -573,6 +572,8 @@ local function setRecordState(next) publish("recordState", recordState) publish("recording", recordState == "recording") publish("recordPath", recordPath) + publish("recordFormat", recordFormat) + publish("recordStartedAt", if recordState == "recording" then recordStartedAt else nil) end local function gsrAudioFlags() @@ -595,9 +596,14 @@ local function buildRecorderCommand(bin, geom, out) local codec = cfg("record-codec") or "h264" local fps = cfg("record-fps") or 60 local cursor = if cursorHidden then "no" else "yes" + -- focusedOutputName() can return nil (e.g. when triggered from the bar), + -- so capture the focused region by geometry instead of by monitor name. + -- GSR region syntax is "WxH+X+Y", the reverse of our "x,y WxH". + local gx, gy, gw, gh = tostring(geom):match("^(%d+),(%d+) (%d+)x(%d+)$") + if not gx then return "" end local flags = string.format( - "-w focused -f %d -k %s -bm qp -ffmpeg-video-opts qp=25 -cursor %s -cr limited %s -v no -o %s", - fps, codec, cursor, gsrAudioFlags(), shellQuote(out) + "-w region -region %sx%s+%s+%s -f %d -k %s -bm qp -ffmpeg-opts qp=25 -cursor %s -cr limited %s -v no -o %s", + gw, gh, gx, gy, fps, codec, cursor, gsrAudioFlags(), shellQuote(out) ) return "gpu-screen-recorder " .. flags elseif bin == "wl-screenrec" then @@ -619,39 +625,82 @@ local function startRecord(geom, format, bin) return end if recordState ~= "idle" then return end - recorderBin = bin rawPath = `/tmp/screen-toolkit-record-{os.time()}.mp4` recordFormat = if format == "gif" then "gif" else "mp4" recordPath = "" local cmd = buildRecorderCommand(bin, geom, rawPath) log(`starting recording: {cmd}`) - -- The detached form of runAsync does not report the child exit status. The - -- stop path verifies that the output file was actually created. - noctalia.runAsync(cmd) + if cmd == "" then + rawPath = "" + setRecordState("idle") + noctalia.notifyError(noctalia.tr("messages.recording_failed")) + return + end + -- Launch detached (no callback): the recorder keeps running after runAsync + -- returns, exactly like the built-in screen recorder does. A tracked + -- (callback) runAsync owns the command's process group and tears it down + -- when the foreground shell exits, killing a backgrounded recorder. The stop + -- path signals it by matching its unique output path in the command line. + if not noctalia.runAsync(cmd) then + rawPath = "" + setRecordState("idle") + noctalia.notifyError(noctalia.tr("messages.recording_failed")) + return + end + recordStartedAt = os.time() setRecordState("recording") - noctalia.notify(noctalia.tr("messages.recording_started")) + noctalia.notify(noctalia.tr("plugin_name"), noctalia.tr("messages.recording_started")) end -local function waitForFile(path, callback, attempts) +local function waitForFileStable(path, callback, attempts, previousSize) attempts = attempts or 60 - noctalia.runAsync(`sleep 0.1; test -s {shellQuote(path)}`, function(res) - if res.exitCode == 0 then - callback(true) + noctalia.runAsync(`stat -c %s {shellQuote(path)}`, function(res) + local size = trim(res.stdout or "") + if res.exitCode == 0 and tonumber(size) and tonumber(size) > 0 then + if previousSize == size then + callback(true) + return + end + noctalia.runAsync("sleep 0.3", function() + waitForFileStable(path, callback, attempts - 1, size) + end) return end if attempts <= 0 then callback(false) return end - waitForFile(path, callback, attempts - 1) + noctalia.runAsync("sleep 0.2", function() + waitForFileStable(path, callback, attempts - 1, previousSize) + end) end) end +local function parseRecordInfo(raw) + local d, wh, size = tostring(raw):match("([%d%.]+)|(%d+x%d+)|(%d+)") + local w, h = 0, 0 + if wh then + local ww, hh = wh:match("(%d+)x(%d+)") + w, h = tonumber(ww) or 0, tonumber(hh) or 0 + end + return { + duration = tonumber(d) or 0, + width = w, + height = h, + size = tonumber(size) or 0, + } +end + local function recordStop() if recordState ~= "recording" then return end setRecordState("converting") - noctalia.runAsync(`pkill -INT {shellQuote(recorderBin)}`, function() - waitForFile(rawPath, function(ok) + noctalia.notify(noctalia.tr("plugin_name"), noctalia.tr("messages.recording_stopped")) + -- rawPath is unique per recording, so pkill scopes to our recorder only and + -- never touches the built-in screen recorder's process. The transient pkill + -- wrapper matching itself is harmless (it still signals the recorder first). + local stopCommand = `pkill -INT -f {shellQuote(rawPath)}` + noctalia.runAsync(stopCommand, function() + waitForFileStable(rawPath, function(ok) if not ok then setRecordState("idle") noctalia.notifyError(noctalia.tr("messages.recording_failed")) @@ -664,8 +713,21 @@ local function recordStop() elseif skipConfirm then finalizeRecord(recordFormat, false) else - setRecordState("ready") - noctalia.togglePanel(RESULT_PANEL_ID) + -- Extract a mid-frame thumbnail so the result panel can preview the + -- recording, then probe it for size / duration / resolution. + noctalia.runAsync(SCRIPT_RECORD .. " thumb " .. shellQuote(rawPath), function() + local probe = string.format( + "D=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>/dev/null); " + .. "WH=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 %s 2>/dev/null); " + .. "S=$(stat -c %%s %s 2>/dev/null); printf '%%s|%%s|%%s' \"$D\" \"$WH\" \"$S\"", + shellQuote(rawPath), shellQuote(rawPath), shellQuote(rawPath) + ) + noctalia.runAsync(probe, function(res) + publish("recordInfo", parseRecordInfo(res.stdout or "")) + setRecordState("ready") + noctalia.togglePanel(RESULT_PANEL_ID) + end) + end) end end) end) @@ -674,13 +736,16 @@ end local function finalizeRecord(format, copyToClipboard) if rawPath == "" then return end local destDir = videoDir() - local ext = if format == "gif" then ".gif" else ".mp4" + local ext + if format == "gif" then ext = ".gif" elseif format == "mov" then ext = ".mov" else ext = ".mp4" end local dest = destDir .. "/" .. buildFilename("record", ext) setRecordState("converting") local cmd if format == "gif" then cmd = SCRIPT_RECORD .. " convert-gif " .. shellQuote(rawPath) .. " " .. shellQuote(dest) .. " " .. tostring(cfg("gif-max-seconds") or 0) + elseif format == "mov" then + cmd = SCRIPT_RECORD .. " convert-mov " .. shellQuote(rawPath) .. " " .. shellQuote(dest) else cmd = SCRIPT_RECORD .. " convert-mp4 " .. shellQuote(rawPath) .. " " .. shellQuote(dest) end @@ -710,6 +775,14 @@ local function recordDiscard() setRecordState("idle") end +-- A recording that finished but was never saved is superseded the moment the +-- user runs any other capture tool; drop its temp file so the result panel +-- can't get stuck showing the stale "save recording" card. +local function discardPendingRecording() + if recordState ~= "ready" then return end + recordDiscard() +end + -- ── Sharing ──────────────────────────────────────────────────────────────── local function shareFile(file) @@ -815,8 +888,10 @@ local HANDLERS = { recordFullscreen = function() runRecordFullscreen("gif") end, recordFullscreenMp4 = function() runRecordFullscreen("mp4") end, recordStop = recordStop, - recordSaveMp4 = function() finalizeRecord("mp4", false) end, - recordSaveGif = function() finalizeRecord("gif", false) end, + recordSave = function(payload) + local fmt = type(payload) == "table" and payload.format or nil + finalizeRecord(fmt or recordFormat, false) + end, recordCopy = function() finalizeRecord("mp4", true) end, recordDiscard = recordDiscard, ocrSearch = ocrSearch, @@ -828,10 +903,20 @@ local HANDLERS = { toggle = function() noctalia.togglePanel(PANEL_ID) end, } +-- Tools that begin a brand-new capture. Starting one discards any recording +-- that finished but was never saved, so the result panel shows the new tool's +-- result instead of the stale "save recording" card. +local CAPTURE_STARTS = { + colorPicker = true, ocr = true, qr = true, palette = true, lens = true, + measure = true, annotate = true, annotateFullscreen = true, annotateWindow = true, + record = true, recordMp4 = true, recordFullscreen = true, recordFullscreenMp4 = true, +} + local function dispatch(event, payload) if event == nil then return end local handler = HANDLERS[event] if handler then + if CAPTURE_STARTS[event] then discardPendingRecording() end log(`ipc '{event}'`) handler(payload) else diff --git a/screen-toolkit/shortcut.luau b/screen-toolkit/shortcut.luau index 9724a493..75fe0224 100644 --- a/screen-toolkit/shortcut.luau +++ b/screen-toolkit/shortcut.luau @@ -2,7 +2,7 @@ -- Screen Toolkit — control-center [[shortcut]] tile. -- -- Mirrors the bar widget: shows the recording state and toggles the main --- panel (or stops a recording). Drives the [[service]] through the "command" +-- panel. Drives the [[service]] through the "command" -- state channel — no shell-out, no IPC PATH dependency. local recording = false @@ -19,11 +19,9 @@ noctalia.state.watch("recording", function(value) end) function onClick() - if recording then - noctalia.state.set("command", { action = "recordStop" }) - else - noctalia.togglePanel("alexander/screen-toolkit:panel") - end + -- The shortcut tile never stops a recording; it just opens the panel so + -- the stop button (in the panel header, next to the close X) is reachable. + noctalia.togglePanel("alexander/screen-toolkit:panel") end apply() diff --git a/screen-toolkit/translations/en.json b/screen-toolkit/translations/en.json index 32ed225a..fa3d33c1 100644 --- a/screen-toolkit/translations/en.json +++ b/screen-toolkit/translations/en.json @@ -16,17 +16,18 @@ "section_record": "Record", "no_color": "Pick a color to get started", "ocr_text": "Detected text", - "ready_hint": "Choose how to save the recording", + "ready_hint": "Preview the recording, then save or discard", "recording_banner": "Recording…", "tools": "Tools", "rec": "REC", "stop": "Stop", "recording_hint": "Recording — click the bar widget or this button to stop.", "ready": "Recording finished", - "save_mp4": "Save MP4", - "save_gif": "Save GIF", - "copy": "Copy", + "save": "Save", "discard": "Discard", + "record_size": "Size", + "record_duration": "Time", + "record_ratio": "Ratio", "saved_to": "Saved: {path}", "copied_to_clipboard": "Saved and copied to clipboard", "running": "Running…", @@ -37,10 +38,6 @@ "clear_history": "Clear history", "history_cleared": "History cleared", "clear": "Clear", - "copy_hex": "Copy HEX", - "copy_rgb": "Copy RGB", - "copy_hsl": "Copy HSL", - "copy_hsv": "Copy HSV", "search_text": "Search text", "translate_label": "Translate to", "translate": "Translate", @@ -48,6 +45,7 @@ "copy_text": "Copy text", "copy_link": "Copy link", "open_url": "Open URL", + "compose_mail": "Compose mail", "copy_hex_list": "Copy hex list", "copy_css_vars": "Copy as CSS vars", "no_text": "No text detected.", @@ -58,11 +56,6 @@ "no_result": "No result yet.", "click_to_copy": "Click to copy", "no_history": "No colors in history yet.", - "copied": "Copied.", - "all_formats_copied": "All formats copied.", - "hex_list_copied": "Hex list copied.", - "css_vars_copied": "CSS variables copied.", - "color_copied": "Color copied.", "format_copied": "{label} copied." }, "tools": { @@ -97,30 +90,22 @@ "record_fullscreen": "Record the entire screen" }, "messages": { - "color_copied": "Color {color} copied.", "color_picked": "Color {color} picked.", "picker_cancelled": "Color picker cancelled.", "no_text": "No text detected.", "no_qr": "No QR code detected.", "lens_failed": "Google Lens upload failed.", - "lens_opened": "Opened in Google Lens.", - "lens_uploading": "Uploading to Google Lens…", "capture_failed": "Screen capture failed.", "palette_failed": "Palette extraction failed.", "measure_result": "Measured: {width} × {height} px (copied)", "measure_copied": "Measurement copied.", "measure_failed": "Measurement failed.", "translate_failed": "Translation failed.", - "recording_started": "Recording started.", - "recording_stopped": "Recording stopped.", + "recording_started": "Recording started. Use the red REC indicator or shortcut to stop.", + "recording_stopped": "Recording stopped. Preparing save options…", "recording_failed": "Recording failed or cancelled.", "no_recorder": "No screen recorder found. Install wl-screenrec or wf-recorder.", "no_annotator": "No annotation tool found (install swappy or satty)", - "annotate_copied": "The capture was copied to the clipboard instead.", - "ocr_copied": "OCR text copied to clipboard.", - "qr_copied": "QR code copied to clipboard.", - "palette_copied": "Palette copied to clipboard.", - "annotating": "Opened annotation editor.", "missing_dep": "Missing dependency: {dep}", "share_bad_args": "Share failed: no file specified.", "share_file_not_found": "Share failed: file not found.", @@ -216,4 +201,4 @@ "description": "Maximum duration in seconds for GIF recordings." } } -} \ No newline at end of file +} diff --git a/screen-toolkit/widget.luau b/screen-toolkit/widget.luau index 636fdfc7..759e8cdd 100644 --- a/screen-toolkit/widget.luau +++ b/screen-toolkit/widget.luau @@ -2,13 +2,20 @@ -- Screen Toolkit — bar [[widget]]. -- -- Crosshair glyph; a pulsing red dot appears while a recording is active. --- Left click toggles the main panel (or stops a recording); right click --- quick-picks a color. Recording logic lives in the [[service]] — this widget +-- Left click toggles the main panel; right click quick-picks a color. +-- Recording logic lives in the [[service]] — this widget -- only reflects the published state and drives the service through the -- "command" state channel. local recording = false local phase = 0 +local startedAt = nil + +local function elapsed() + if not startedAt then return "00:00" end + local seconds = math.max(0, os.time() - startedAt) + return string.format("%02d:%02d", math.floor(seconds / 60), seconds % 60) +end local function render() local container = barWidget.isVertical() and ui.column or ui.row @@ -21,6 +28,7 @@ local function render() radius = 4, fill = (phase % 2 == 0) and "error" or "error/0.35", })) + table.insert(children, ui.label({ text = `REC {elapsed()}`, fontSize = 11, fontWeight = "bold", color = "error" })) end table.insert(children, ui.glyph({ name = "crosshair", size = 14 })) barWidget.render(container({ gap = 6, align = "center" }, children)) @@ -36,15 +44,18 @@ end noctalia.state.watch("recording", function(value) recording = value == true + startedAt = noctalia.state.get("recordStartedAt") + render() +end) +noctalia.state.watch("recordStartedAt", function(value) + startedAt = value render() end) function onClick() - if recording then - noctalia.state.set("command", { action = "recordStop" }) - else - noctalia.togglePanel("alexander/screen-toolkit:panel") - end + -- The bar icon never stops a recording; it just opens the panel so the + -- stop button (in the panel header, next to the close X) is reachable. + noctalia.togglePanel("alexander/screen-toolkit:panel") end function onRightClick() From bcf81ba2d93c4849d269ac4bc7b6fe028adb5f03 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 00:27:12 +0300 Subject: [PATCH 4/8] screen-toolkit: rename record tool labels to Record / Rec FS --- screen-toolkit/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/screen-toolkit/translations/en.json b/screen-toolkit/translations/en.json index fa3d33c1..fbd1eb96 100644 --- a/screen-toolkit/translations/en.json +++ b/screen-toolkit/translations/en.json @@ -70,9 +70,9 @@ "lens": "Lens", "record": "Record", "record_gif": "GIF", - "record_mp4": "MP4", + "record_mp4": "Record", "record_fullscreen_gif": "GIF FS", - "record_fullscreen_mp4": "MP4 FS", + "record_fullscreen_mp4": "Rec FS", "fullscreen": "Fullscreen", "window": "Window" }, From 5748779b97b4dbd4caa00577de97fa1932c43af2 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 00:30:22 +0300 Subject: [PATCH 5/8] screen-toolkit: update plugin thumbnail --- screen-toolkit/thumbnail.webp | Bin 6908 -> 58626 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/screen-toolkit/thumbnail.webp b/screen-toolkit/thumbnail.webp index e63e35093df9326eb74b01a58269aa942c6a4c5c..7bab42b0f64980a5a1e2bb47b256fd830e83ddf4 100644 GIT binary patch literal 58626 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 literal 6908 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 oj%(tz2TYfM|BsD}um&r8e37*gL# From 824fb5366604c0dbd3a4c6afa4a2a288964eba8f Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 00:47:17 +0300 Subject: [PATCH 6/8] screen-toolkit: fix README validation (backtick deps, result panel toggle) --- screen-toolkit/README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md index 9211fd08..5a0e4b21 100644 --- a/screen-toolkit/README.md +++ b/screen-toolkit/README.md @@ -23,14 +23,14 @@ not the original implementation. Install the tools used by the features you want on `PATH`. Missing tools are reported when that feature is started. -- **[slurp](https://github.com/Powerline1/slurp)** — region selection -- **grim** — screen capture -- **hyprpicker** — pixel color picking -- **tesseract** — OCR engine (plus your language packs, e.g. `tesseract-data-eng`) -- **imagemagick** — image processing -- **zbar** — QR / barcode scanning (`zbarimg`) -- **curl** + **jq** — image uploads (uguu.se / x02.me) and Google Lens -- **ffmpeg** — recording / GIF conversion +- **`slurp`** — region selection +- **`grim`** — screen capture +- **`hyprpicker`** — pixel color picking +- **`tesseract`** — OCR engine (plus your language packs, e.g. `tesseract-data-eng`) +- **`imagemagick`** — image processing +- **`zbar`** — QR / barcode scanning (`zbarimg`) +- **`curl`** + **`jq`** — image uploads (uguu.se / x02.me) and Google Lens +- **`ffmpeg`** — recording / GIF conversion Recording requires at least one backend: @@ -41,7 +41,7 @@ Recording requires at least one backend: Optional: - **swappy** / **satty** — annotation editor (Markup tool) -- **translate-shell** (`trans`) — OCR translation +- **`translate-shell`** (`trans`) — OCR translation - **hyprctl** — annotate the focused window (Hyprland only) Compositor support: region tools, measure, annotate and recording work on any @@ -61,6 +61,12 @@ Open the main tools panel: noctalia msg panel-toggle alexander/screen-toolkit:panel ``` +Open the result panel (shows the last capture/recording output): + +```sh +noctalia msg panel-toggle alexander/screen-toolkit:result +``` + The tools panel contains the capture actions. When a capture tool finishes, a **result panel** opens with the output and its actions; close it to return to the tools panel. From 36ac68cdaefadcefee70ffad338083a8d3fec2ba Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 12:35:49 +0300 Subject: [PATCH 7/8] screen-toolkit: fix OCR results and dependencies --- screen-toolkit/README.md | 10 ++- screen-toolkit/plugin.toml | 12 ++++ screen-toolkit/result.luau | 12 +++- screen-toolkit/scripts/ocr.sh | 4 +- screen-toolkit/service.luau | 111 ++++++++++++++++++++-------------- 5 files changed, 96 insertions(+), 53 deletions(-) diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md index 5a0e4b21..731ccadf 100644 --- a/screen-toolkit/README.md +++ b/screen-toolkit/README.md @@ -30,7 +30,11 @@ reported when that feature is started. - **`imagemagick`** — image processing - **`zbar`** — QR / barcode scanning (`zbarimg`) - **`curl`** + **`jq`** — image uploads (uguu.se / x02.me) and Google Lens -- **`ffmpeg`** — recording / GIF conversion +- **`ffmpeg`** + **`ffprobe`** — recording / GIF conversion and thumbnail generation +- **`bc`** — GIF duration and frame-rate calculations +- **`stat`** — recording file size +- **`pkill`** — stopping active recording backends +- **`xdg-open`** — opening URLs, OCR search results, and shared-link targets Recording requires at least one backend: @@ -41,6 +45,7 @@ Recording requires at least one backend: Optional: - **swappy** / **satty** — annotation editor (Markup tool) +- **gimp** — fallback annotation editor when swappy/satty are missing - **`translate-shell`** (`trans`) — OCR translation - **hyprctl** — annotate the focused window (Hyprland only) @@ -147,8 +152,7 @@ noctalia msg plugin alexander/screen-toolkit:service all recordMp4 noctalia msg plugin alexander/screen-toolkit:service all recordFullscreen noctalia msg plugin alexander/screen-toolkit:service all recordFullscreenMp4 noctalia msg plugin alexander/screen-toolkit:service all recordStop -noctalia msg plugin alexander/screen-toolkit:service all recordSaveMp4 -noctalia msg plugin alexander/screen-toolkit:service all recordSaveGif +noctalia msg plugin alexander/screen-toolkit:service all recordSave noctalia msg plugin alexander/screen-toolkit:service all recordDiscard ``` diff --git a/screen-toolkit/plugin.toml b/screen-toolkit/plugin.toml index 843b5a6c..5c9b00c2 100644 --- a/screen-toolkit/plugin.toml +++ b/screen-toolkit/plugin.toml @@ -24,8 +24,20 @@ dependencies = [ "zbar", "curl", "ffmpeg", + "ffprobe", "jq", "translate-shell", + "bc", + "stat", + "pkill", + "xdg-open", + "hyprctl", + "swappy", + "satty", + "gimp", + "gpu-screen-recorder", + "wl-screenrec", + "wf-recorder", ] # ── Plugin-level settings (shared by every entry, edited in Settings → Plugins) ── diff --git a/screen-toolkit/result.luau b/screen-toolkit/result.luau index 48c66a25..d1e84841 100644 --- a/screen-toolkit/result.luau +++ b/screen-toolkit/result.luau @@ -335,7 +335,15 @@ local function renderOcr() ui.label({ text = tr("panel.ocr_text"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), } if ocrResult.capturePath and ocrResult.capturePath ~= "" then - table.insert(scrollChildren, ui.image({ path = ocrResult.capturePath, height = imgHeight, fit = "contain", radius = 12, borderWidth = 1, border = "outline" })) + table.insert(scrollChildren, ui.image({ + key = "ocr-image-" .. tostring(ocrResult.cacheBust or ocrResult.capturePath), + path = ocrResult.capturePath, + height = imgHeight, + fit = "contain", + radius = 12, + borderWidth = 1, + border = "outline", + })) end -- ui.input does not support radius / border / padding, so frame the editor -- in a matching container to align it with the picture and keep long text @@ -348,7 +356,7 @@ local function renderOcr() paddingV = 8, }, { ui.input({ - key = "ocr-edit", + key = "ocr-edit-" .. tostring(ocrResult.cacheBust or "current"), value = text, multiline = true, height = 220, diff --git a/screen-toolkit/scripts/ocr.sh b/screen-toolkit/scripts/ocr.sh index 70d0237e..2d77a5a9 100755 --- a/screen-toolkit/scripts/ocr.sh +++ b/screen-toolkit/scripts/ocr.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Args: $1=gx $2=gy $3=gw $4=gh $5=lang $6=upscale_flag $7=psm +# Args: $1=gx $2=gy $3=gw $4=gh $5=lang $6=upscale_flag $7=psm $8=output_path # # Exit codes: # 1 — missing dependency (dep name written to stdout) @@ -12,7 +12,7 @@ GX="$1"; GY="$2"; GW="$3"; GH="$4" RAW_LANG="${5:-eng}" UPSCALE="$6" USER_PSM="${7:-3}" -FILE="/tmp/screen-toolkit-ocr.png" +FILE="${8:-/tmp/screen-toolkit-ocr.png}" TMP_BASE="/tmp/screen-toolkit-ocr-work-$$" TMP="${TMP_BASE}.pnm" TMP_NOISE="${TMP_BASE}-nr.pnm" diff --git a/screen-toolkit/service.luau b/screen-toolkit/service.luau index 2f8aa729..8923a058 100644 --- a/screen-toolkit/service.luau +++ b/screen-toolkit/service.luau @@ -26,7 +26,6 @@ local SCRIPT_SHARE = noctalia.pluginDir() .. "/scripts/share-upload.sh" local SCRIPT_PICKER = noctalia.pluginDir() .. "/scripts/color-picker.sh" local COLOR_PNG = "/tmp/screen-toolkit-colorpicker.png" -local OCR_PNG = "/tmp/screen-toolkit-ocr.png" local QR_PNG = "/tmp/screen-toolkit-qr.png" local ANNOTATE_PNG = "/tmp/screen-toolkit-annotate.png" @@ -128,6 +127,8 @@ end -- ── Region selection (slurp) ─────────────────────────────────────────────── local slurping = false +local ocrRunning = false +local ocrSerial = 0 -- Draws the slurp crosshair after a short delay so the just-closed panel's -- close animation is not captured. Invokes callback(geom) or nothing on cancel. @@ -311,14 +312,26 @@ local OCR_EXIT_KEYS = { } local function runOcr() + if ocrRunning then + noctalia.notify(noctalia.tr("panel.running")) + return + end slurpGeometry(function(geom) + ocrRunning = true + ocrSerial = ocrSerial + 1 + local ocrPath = "/tmp/screen-toolkit-ocr-" .. tostring(os.time()) .. "-" .. tostring(ocrSerial) .. ".png" local gx, gy, gw, gh = parseGeometry(geom) - if not gx then return end + if not gx then + ocrRunning = false + return + end local lang = cfg("selected-ocr-lang") or "eng" local upscale, psm = ocrParams(gw, gh) local cmd = captureEnv() .. SCRIPT_OCR .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh .. " " .. shellQuote(lang) .. " " .. shellQuote(upscale) .. " " .. psm + .. " " .. shellQuote(ocrPath) hideCursorForCapture(cmd, function(res) + ocrRunning = false if res.exitCode == 1 then noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = trim(res.stdout or "") })) return @@ -332,7 +345,13 @@ local function runOcr() return end noctalia.copyToClipboard(text, "text/plain;charset=utf-8") - publish("ocrResult", { text = text, capturePath = OCR_PNG, gw = tonumber(gw), gh = tonumber(gh) }) + publish("ocrResult", { + text = text, + capturePath = ocrPath, + cacheBust = tostring(os.time()) .. "-" .. tostring(ocrSerial), + gw = tonumber(gw), + gh = tonumber(gh), + }) publish("activeTool", "ocr") saveResults() noctalia.togglePanel(RESULT_PANEL_ID) @@ -691,6 +710,40 @@ local function parseRecordInfo(raw) } end +local function finalizeRecord(format, copyToClipboard) + if rawPath == "" then return end + local destDir = videoDir() + local ext + if format == "gif" then ext = ".gif" elseif format == "mov" then ext = ".mov" else ext = ".mp4" end + local dest = destDir .. "/" .. buildFilename("record", ext) + setRecordState("converting") + local cmd + if format == "gif" then + cmd = SCRIPT_RECORD .. " convert-gif " .. shellQuote(rawPath) .. " " .. shellQuote(dest) + .. " " .. tostring(cfg("gif-max-seconds") or 0) + elseif format == "mov" then + cmd = SCRIPT_RECORD .. " convert-mov " .. shellQuote(rawPath) .. " " .. shellQuote(dest) + else + cmd = SCRIPT_RECORD .. " convert-mp4 " .. shellQuote(rawPath) .. " " .. shellQuote(dest) + end + noctalia.runAsync(cmd, function(res) + if res.exitCode ~= 0 then + setRecordState("idle") + noctalia.notifyError(noctalia.tr("messages.recording_failed")) + return + end + recordPath = dest + rawPath = "" + setRecordState("idle") + if copyToClipboard then + copyFileUri(dest) + noctalia.notify(noctalia.tr("panel.copied_to_clipboard"), dest) + else + noctalia.notify(noctalia.tr("messages.saved_to", { path = dest })) + end + end) +end + local function recordStop() if recordState ~= "recording" then return end setRecordState("converting") @@ -733,40 +786,6 @@ local function recordStop() end) end -local function finalizeRecord(format, copyToClipboard) - if rawPath == "" then return end - local destDir = videoDir() - local ext - if format == "gif" then ext = ".gif" elseif format == "mov" then ext = ".mov" else ext = ".mp4" end - local dest = destDir .. "/" .. buildFilename("record", ext) - setRecordState("converting") - local cmd - if format == "gif" then - cmd = SCRIPT_RECORD .. " convert-gif " .. shellQuote(rawPath) .. " " .. shellQuote(dest) - .. " " .. tostring(cfg("gif-max-seconds") or 0) - elseif format == "mov" then - cmd = SCRIPT_RECORD .. " convert-mov " .. shellQuote(rawPath) .. " " .. shellQuote(dest) - else - cmd = SCRIPT_RECORD .. " convert-mp4 " .. shellQuote(rawPath) .. " " .. shellQuote(dest) - end - noctalia.runAsync(cmd, function(res) - if res.exitCode ~= 0 then - setRecordState("idle") - noctalia.notifyError(noctalia.tr("messages.recording_failed")) - return - end - recordPath = dest - rawPath = "" - setRecordState("idle") - if copyToClipboard then - copyFileUri(dest) - noctalia.notify(noctalia.tr("panel.copied_to_clipboard"), dest) - else - noctalia.notify(noctalia.tr("messages.saved_to", { path = dest })) - end - end) -end - local function recordDiscard() if rawPath ~= "" then noctalia.runAsync(`rm -f {shellQuote(rawPath)}`) @@ -783,6 +802,15 @@ local function discardPendingRecording() recordDiscard() end +local SHARE_EXIT_KEYS = { + [1] = "messages.share_bad_args", + [2] = "messages.share_file_not_found", + [3] = "messages.share_missing_dep", + [4] = "messages.share_request_failed", + [5] = "messages.share_invalid_response", + [6] = "messages.share_file_too_large", +} + -- ── Sharing ──────────────────────────────────────────────────────────────── local function shareFile(file) @@ -811,15 +839,6 @@ local function shareFile(file) ) end -local SHARE_EXIT_KEYS = { - [1] = "messages.share_bad_args", - [2] = "messages.share_file_not_found", - [3] = "messages.share_missing_dep", - [4] = "messages.share_request_failed", - [5] = "messages.share_invalid_response", - [6] = "messages.share_file_too_large", -} - -- ── Result housekeeping ──────────────────────────────────────────────────── local function clearResult() From c567fff2544698e104c542d29633140ca33f694d Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Mon, 3 Aug 2026 12:41:43 +0300 Subject: [PATCH 8/8] screen-toolkit: backtick recording and annotator deps in README --- screen-toolkit/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md index 731ccadf..6e5b4831 100644 --- a/screen-toolkit/README.md +++ b/screen-toolkit/README.md @@ -38,14 +38,14 @@ reported when that feature is started. Recording requires at least one backend: -- **gpu-screen-recorder** — recommended for fullscreen recording, especially on NVIDIA (NVENC) -- **wl-screenrec** — preferred for region recording and microphone audio -- **wf-recorder** — fallback recorder for region and fullscreen capture +- **`gpu-screen-recorder`** — recommended for fullscreen recording, especially on NVIDIA (NVENC) +- **`wl-screenrec`** — preferred for region recording and microphone audio +- **`wf-recorder`** — fallback recorder for region and fullscreen capture Optional: -- **swappy** / **satty** — annotation editor (Markup tool) -- **gimp** — fallback annotation editor when swappy/satty are missing +- **`swappy`** / **`satty`** — annotation editor (Markup tool) +- **`gimp`** — fallback annotation editor when swappy/satty are missing - **`translate-shell`** (`trans`) — OCR translation - **hyprctl** — annotate the focused window (Hyprland only)