From 636317303c6e863c6d4e3b31cfbf75fec1308f40 Mon Sep 17 00:00:00 2001 From: Ahmed Emad <122463699+Ahmed5Emad@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:22:08 +0300 Subject: [PATCH] screen-toolkit : full screen utility (#221) * screen-toolkit: add Noctalia v5 plugin * screen-toolkit: credit original plugin * screen-toolkit: fix recording, OCR capture, and result panel UX * screen-toolkit: rename record tool labels to Record / Rec FS * screen-toolkit: update plugin thumbnail * screen-toolkit: fix README validation (backtick deps, result panel toggle) * screen-toolkit: fix OCR results and dependencies * screen-toolkit: backtick recording and annotator deps in README --------- Co-authored-by: Ahmed5Emad --- screen-toolkit/README.md | 194 +++++ screen-toolkit/panel.luau | 162 ++++ screen-toolkit/plugin.toml | 208 ++++++ screen-toolkit/result.luau | 527 +++++++++++++ screen-toolkit/scripts/capture.sh | 99 +++ screen-toolkit/scripts/color-picker.sh | 63 ++ screen-toolkit/scripts/lens-upload.sh | 37 + screen-toolkit/scripts/ocr.sh | 99 +++ screen-toolkit/scripts/record.sh | 113 +++ screen-toolkit/scripts/share-upload.sh | 81 ++ screen-toolkit/service.luau | 980 +++++++++++++++++++++++++ screen-toolkit/shortcut.luau | 27 + screen-toolkit/thumbnail.webp | Bin 0 -> 58626 bytes screen-toolkit/translations/en.json | 204 +++++ screen-toolkit/widget.luau | 66 ++ 15 files changed, 2860 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 0000000..6e5b483 --- /dev/null +++ b/screen-toolkit/README.md @@ -0,0 +1,194 @@ +# Screen Toolkit + +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 + +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 | +| --- | --- | +| ID | `alexander/screen-toolkit` | +| Entries | Bar widget: `widget`; control-center shortcut: `toggle`; panels: `panel` (tools), `result` (result view); service: `service` | + +## Requirements + +Install the tools used by the features you want on `PATH`. Missing tools are +reported when that feature is started. + +- **`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`** + **`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: + +- **`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 +- **`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 +``` + +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. + +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). 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. 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, + 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)* | 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` | 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. | +| `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 recordSave +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. + +## 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 new file mode 100644 index 0000000..824ef18 --- /dev/null +++ b/screen-toolkit/panel.luau @@ -0,0 +1,162 @@ +--!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 + +-- ── 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.row({ gap = 4 }, actions), + }) +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 + render() +end + +function onClose() + panelOpen = false +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 new file mode 100644 index 0000000..5c9b00c --- /dev/null +++ b/screen-toolkit/plugin.toml @@ -0,0 +1,208 @@ +# 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", + "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) ── + +[[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 0000000..d1e8484 --- /dev/null +++ b/screen-toolkit/result.luau @@ -0,0 +1,527 @@ +--!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 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 {} + +-- ── 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) + if not text or text == "" then return end + noctalia.copyToClipboard(text, "text/plain;charset=utf-8") +end + +local function actionButton(glyph, text, onClick, variant) + return ui.button({ + glyph = glyph, + text = text, + controlSize = "sm", + variant = variant or "default", + onClick = onClick, + }) +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 {} +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: 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() + 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 + +-- ── 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) + 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) 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() showHistoryColor(hex) 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)) + end), + actionButton("share", tr("panel.share"), function() send("share", c.capturePath) end), + }), + ui.separator({ spacing = 4 }), + 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 }, { + 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 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" }), + } + if ocrResult.capturePath and ocrResult.capturePath ~= "" then + 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 + -- from touching the edges. + table.insert(scrollChildren, ui.column({ + radius = 12, + borderWidth = 1, + border = "outline", + paddingH = 10, + paddingV = 8, + }, { + ui.input({ + key = "ocr-edit-" .. tostring(ocrResult.cacheBust or "current"), + 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 + local buttons = { + ui.button({ + glyph = "copy", + tooltip = tr("panel.copy_text"), + controlSize = "sm", + onClick = function() copyText(ocrEditText) end, + }), + } + 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 + +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) 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) 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")) + 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")) + 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("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 + 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 new file mode 100755 index 0000000..dc730c0 --- /dev/null +++ b/screen-toolkit/scripts/capture.sh @@ -0,0 +1,99 @@ +#!/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. + 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" + ;; + + palette) + GEOMETRY="${2:-}" + [ -n "$GEOMETRY" ] || { echo "ERROR: palette: missing " >&2; exit 1; } + _require grim + _require magick + + FILE="/tmp/screen-toolkit-palette.png" + + 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 \ + | 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 + + 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 + ;; + + *) + 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 0000000..fb74f59 --- /dev/null +++ b/screen-toolkit/scripts/color-picker.sh @@ -0,0 +1,63 @@ +#!/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)) + sleep 0.15 + 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)) +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)]' \ + 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 0000000..1958d43 --- /dev/null +++ b/screen-toolkit/scripts/lens-upload.sh @@ -0,0 +1,37 @@ +#!/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 + +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) || \ +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 0000000..2d77a5a --- /dev/null +++ b/screen-toolkit/scripts/ocr.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# 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) +# 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="${8:-/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" + +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}") + 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 0000000..9132d70 --- /dev/null +++ b/screen-toolkit/scripts/record.sh @@ -0,0 +1,113 @@ +#!/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 +# convert-mov — remux MP4 → MOV (stream copy) +# 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" + ;; + 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; } + _require pkill + pkill -INT "$BIN" 2>/dev/null || true + ;; + *) + echo "ERROR: unknown action '${ACTION}'. Expected: thumb | convert-mp4 | convert-gif | convert-mov | 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 0000000..a8ec921 --- /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 0000000..8923a05 --- /dev/null +++ b/screen-toolkit/service.luau @@ -0,0 +1,980 @@ +--!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 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 +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(`sleep {CAPTURE_DELAY}; {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 +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. +local function slurpGeometry(callback) + if slurping then + noctalia.notify(noctalia.tr("panel.running")) + 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, 600000) +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() + -- 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) + 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.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() + 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 + 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 + 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 = ocrPath, + cacheBust = tostring(os.time()) .. "-" .. tostring(ocrSerial), + gw = tonumber(gw), + gh = tonumber(gh), + }) + publish("activeTool", "ocr") + saveResults() + noctalia.togglePanel(RESULT_PANEL_ID) + end) + end) +end + +local function ocrSearch(payload) + local result = noctalia.state.get("ocrResult") + 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 + 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(payload) + local result = noctalia.state.get("ocrResult") + 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 + 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.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.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.runAsync(captureEnv() .. SCRIPT_LENS .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh, function(res) + if res.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("messages.lens_failed")) + 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")) + return + end +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(`sleep {CAPTURE_DELAY}; {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 rawPath = "" +local recordPath = "" +local recordStartedAt = nil + +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) + publish("recordFormat", recordFormat) + publish("recordStartedAt", if recordState == "recording" then recordStartedAt else nil) +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" + -- 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 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 + 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 + 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}`) + 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("plugin_name"), noctalia.tr("messages.recording_started")) +end + +local function waitForFileStable(path, callback, attempts, previousSize) + attempts = attempts or 60 + 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 + 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 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") + 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")) + 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 + -- 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) +end + +local function recordDiscard() + if rawPath ~= "" then + noctalia.runAsync(`rm -f {shellQuote(rawPath)}`) + rawPath = "" + end + 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 + +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) + 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 + +-- ── 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, + 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, + ocrTranslate = ocrTranslate, + share = shareFile, + clearResult = clearResult, + clearHistory = clearHistory, + setCursorHidden = setCursorHidden, + 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 + 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 0000000..75fe022 --- /dev/null +++ b/screen-toolkit/shortcut.luau @@ -0,0 +1,27 @@ +--!nonstrict +-- Screen Toolkit — control-center [[shortcut]] tile. +-- +-- Mirrors the bar widget: shows the recording state and toggles the main +-- panel. 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() + -- 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/thumbnail.webp b/screen-toolkit/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..7bab42b0f64980a5a1e2bb47b256fd830e83ddf4 GIT binary patch literal 58626 zcmWIYbaVUlgn=R4)hQq>z(V2O69$G0f0^boY8_yfVY1_7YIJC{Wnd7PR(32)d+mMm zRsH-ti!Yi8IXp}7Z+-m7chB?R)Am(~?z;BDY~R0+N8?!}!ym|B`E&ixreC~I?z7jv zeC&VP{=odV|8Ksz{ObSf%D2Vlzw1Bz-(~;O{{R0MzqkJn{^$Nv{%Sd;aR2-MpYj|3zqMoj@AU8f|KdN#->pAu{=5Fq z|A)1A{}=z){Ga}N{}28N_Alb^**~jJs=xUE0Ae`Eiq{zv_$|LOl9zX<yZ~U+PqWiV|ruzE73IE>yd;I76eKF~O@$ct4>3^?(?f)(R*Zzxq!M=C@MeBFg@BDB0;r?p*6Z224-w=P?zOUZy-`QWA|Mvge z{rP{+s(7E3st=r=d|{Id{WF~RywPB&F|?x zriHf+e(+Vy%hQ?taS^g)yPiE0%GygT`_IzLYm-1U9` z%>(_vj5Ycry{nS8FRZ?=yq@j#vHRg1<*r9JZd}4p9#h$HFypbd{4Z&P@a2D)jpSI? zxUew^&pmpkI5qv1Ma|^z_kEJQ~vd*^}0-2u>8~mvy-p)&0SM%%*i#usL}9f zkf^lYUpAnTzJeX`tW&DEU)e}CFbsnmVURz%JV*^pOZ;XPk3_M z$kHOwvOmiMA`lG%{Rw>=*wmNlKd=N^=GSZYP~vfq1FKk;ES-ElrnY1W>_ zb0zGrvP3+O*g8S~o$>tT_xGDIJ+R;?%rm}J72*HyA=B)iw(f6cERB_YBx`f_rg7H+ zJ=OH{ldkXjTJuVUODfSuC6IB=obTFu!@lg_TfXnkM`!Vr{pZ(iJCpS5-kVd3r}vaD(-*vQ zpdxJNo2q{^ZrRM(-q1W+zfspcWh?ir5^3$605mx z7bq|66+AlK`7_6=%lD%=bevM27>FHf|8&S$qiUnt+rL{q?XHCEe_1N`XZ^9o{(n!a z?-QGnRH*CJxXHTmko3VE_ak~!x2G`5I@QNMEH0CCv3|8}azpl7jsuYnQ5%`HHx=2n z%_uq~vBZ6jLx;2*cZ`_szDFFCyVB9w zd-rMYbTp7q>}B70WYcG_JfXYq&V60{#{Ko&`iGs|H4RyRH>$g@{}sit>O_Ux?!aHW zeR?O`e_@`GH`Qrje)`R|JAOV=>6))t$#>ztPn7@1jT82scC)X3&iA$C!L#WF3yAec!n+UxK!L-z4Mf=rwt-IQygcenue%0Dc5cD*ia$*}JY(ID4x2}*$$YQ;{M(ABsCPe& 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