diff --git a/screen-toolkit/README.md b/screen-toolkit/README.md new file mode 100644 index 00000000..6e5b4831 --- /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 00000000..824ef18f --- /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 00000000..5c9b00c2 --- /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 00000000..d1e84841 --- /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 00000000..dc730c0e --- /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 00000000..fb74f59f --- /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 00000000..1958d431 --- /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 00000000..2d77a5a9 --- /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 00000000..9132d707 --- /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 00000000..a8ec9214 --- /dev/null +++ b/screen-toolkit/scripts/share-upload.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# share-upload.sh [api_key] [expiry] +# api_key: X02 API key — if empty, falls back to uguu.se (anonymous, 3h, 128MB max) +# expiry: 1h | 1d | 7d | 30d | permanent (X02 only, default: 7d) +# Prints URL to stdout on success, exits non-zero on failure +# Exit codes: +# 1 — invalid arguments (no file given) +# 2 — file not found +# 3 — missing dependency +# 4 — upload request failed +# 5 — invalid or empty response +# 6 — file too large +# Used by: service.luau + +set -euo pipefail + +FILE="${1:-}" +API_KEY="${2:-}" +EXPIRY="${3:-7d}" + +UGUU_MAX_BYTES=$((128 * 1024 * 1024)) # 128 MB + +[ -n "$FILE" ] || { echo "ERROR: no file given" >&2; exit 1; } +[ -f "$FILE" ] || { echo "ERROR: file not found: $FILE" >&2; exit 2; } + +command -v curl >/dev/null 2>&1 || { echo "ERROR: missing dependency: curl" >&2; exit 3; } + +# ── X02 (authenticated) ─────────────────────────────────────────────────────── +if [ -n "$API_KEY" ]; then + EXPIRY_FLAG=() + if [ "$EXPIRY" != "permanent" ] && [ -n "$EXPIRY" ]; then + EXPIRY_FLAG=(-F "expiry=${EXPIRY}") + fi + + URL=$(curl -sS -f \ + -X POST "https://up.x02.me/api/upload" \ + -H "x-api-key: ${API_KEY}" \ + -F "file=@${FILE}" \ + "${EXPIRY_FLAG[@]}" \ + --connect-timeout 20 \ + --max-time 120 \ + 2>/dev/null) \ + || { echo "ERROR: X02 upload request failed" >&2; exit 4; } + + if [ -n "$URL" ] && [[ "$URL" == http* ]]; then + printf '%s\n' "$URL" + exit 0 + fi + + echo "ERROR: X02: unexpected response: $URL" >&2 + exit 5 +fi + +# ── uguu.se (anonymous fallback) ────────────────────────────────────────────── +command -v jq >/dev/null 2>&1 || { echo "ERROR: missing dependency: jq" >&2; exit 3; } + +FILE_SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE" 2>/dev/null || echo 0) +if [ "$FILE_SIZE" -gt "$UGUU_MAX_BYTES" ]; then + echo "ERROR: file too large for anonymous upload (128MB max). Add an X02 API key for larger files." >&2 + exit 6 +fi + +RESP=$(curl -sS -f -A 'Mozilla/5.0' \ + --connect-timeout 20 --max-time 60 \ + -F "files[]=@${FILE}" \ + 'https://uguu.se/upload' 2>/dev/null) \ +|| RESP=$(curl -sS -A 'Mozilla/5.0' \ + --connect-timeout 20 --max-time 60 \ + -F "files[]=@${FILE}" \ + 'https://uguu.se/upload.php' 2>/dev/null) \ +|| { echo "ERROR: uguu.se upload request failed" >&2; exit 4; } + +URL=$(printf '%s' "$RESP" | jq -r '.files[0].url // empty' 2>/dev/null) + +if [ -n "$URL" ] && [[ "$URL" == http* ]]; then + printf '%s\n' "$URL" + exit 0 +fi + +echo "ERROR: uguu.se: no valid URL in response" >&2 +exit 5 diff --git a/screen-toolkit/service.luau b/screen-toolkit/service.luau new file mode 100644 index 00000000..8923a058 --- /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 00000000..75fe0224 --- /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 00000000..7bab42b0 Binary files /dev/null and b/screen-toolkit/thumbnail.webp differ diff --git a/screen-toolkit/translations/en.json b/screen-toolkit/translations/en.json new file mode 100644 index 00000000..fbd1eb96 --- /dev/null +++ b/screen-toolkit/translations/en.json @@ -0,0 +1,204 @@ +{ + "plugin_name": "Screen Toolkit", + "widget": { + "tooltip": "Screen Toolkit", + "recording": "Recording — click to stop" + }, + "shortcut": { + "label": "Screen Toolkit", + "recording": "Stop recording" + }, + "panel": { + "title": "Screen Toolkit", + "subtitle": "Screen utilities", + "section_capture": "Capture", + "section_annotate": "Annotate", + "section_record": "Record", + "no_color": "Pick a color to get started", + "ocr_text": "Detected text", + "ready_hint": "Preview the recording, then save or discard", + "recording_banner": "Recording…", + "tools": "Tools", + "rec": "REC", + "stop": "Stop", + "recording_hint": "Recording — click the bar widget or this button to stop.", + "ready": "Recording finished", + "save": "Save", + "discard": "Discard", + "record_size": "Size", + "record_duration": "Time", + "record_ratio": "Ratio", + "saved_to": "Saved: {path}", + "copied_to_clipboard": "Saved and copied to clipboard", + "running": "Running…", + "empty": "Pick a tool. Results appear here.", + "pick_again": "Pick again", + "copy_all": "Copy all formats", + "history": "History", + "clear_history": "Clear history", + "history_cleared": "History cleared", + "clear": "Clear", + "search_text": "Search text", + "translate_label": "Translate to", + "translate": "Translate", + "translation": "Translation", + "copy_text": "Copy text", + "copy_link": "Copy link", + "open_url": "Open URL", + "compose_mail": "Compose mail", + "copy_hex_list": "Copy hex list", + "copy_css_vars": "Copy as CSS vars", + "no_text": "No text detected.", + "share": "Share", + "sharing": "Uploading…", + "share_url": "Link copied", + "share_failed": "Upload failed", + "no_result": "No result yet.", + "click_to_copy": "Click to copy", + "no_history": "No colors in history yet.", + "format_copied": "{label} copied." + }, + "tools": { + "colorpicker": "Color", + "annotate": "Markup", + "annotate_fullscreen": "Markup FS", + "annotate_window": "Window", + "ocr": "OCR", + "palette": "Palette", + "measure": "Measure", + "qr": "QR", + "lens": "Lens", + "record": "Record", + "record_gif": "GIF", + "record_mp4": "Record", + "record_fullscreen_gif": "GIF FS", + "record_fullscreen_mp4": "Rec FS", + "fullscreen": "Fullscreen", + "window": "Window" + }, + "tooltips": { + "colorpicker": "Pick a color from screen", + "ocr": "Extract text from screen", + "qr": "Scan a QR or barcode", + "palette": "Extract dominant colors from a region", + "lens": "Search image with Google Lens", + "measure": "Measure region size in pixels", + "annotate": "Draw and annotate a region", + "annotate_fullscreen": "Annotate the entire screen", + "annotate_window": "Annotate the focused window (Hyprland)", + "record": "Record a screen region as GIF or MP4", + "record_fullscreen": "Record the entire screen" + }, + "messages": { + "color_picked": "Color {color} picked.", + "picker_cancelled": "Color picker cancelled.", + "no_text": "No text detected.", + "no_qr": "No QR code detected.", + "lens_failed": "Google Lens upload failed.", + "capture_failed": "Screen capture failed.", + "palette_failed": "Palette extraction failed.", + "measure_result": "Measured: {width} × {height} px (copied)", + "measure_copied": "Measurement copied.", + "measure_failed": "Measurement failed.", + "translate_failed": "Translation failed.", + "recording_started": "Recording started. Use the red REC indicator or shortcut to stop.", + "recording_stopped": "Recording stopped. Preparing save options…", + "recording_failed": "Recording failed or cancelled.", + "no_recorder": "No screen recorder found. Install wl-screenrec or wf-recorder.", + "no_annotator": "No annotation tool found (install swappy or satty)", + "missing_dep": "Missing dependency: {dep}", + "share_bad_args": "Share failed: no file specified.", + "share_file_not_found": "Share failed: file not found.", + "share_missing_dep": "Share failed: missing dependency (curl/jq).", + "share_request_failed": "Upload request failed.", + "share_invalid_response": "Upload failed: unexpected server response.", + "share_file_too_large": "File too large for anonymous upload (128 MB max). Add an X02 API key for larger files.", + "share_unknown_error": "Upload failed with unknown error.", + "saved_to": "Saved: {path}" + }, + "ocr": { + "exit_dep": "Missing dependency: {dep}", + "exit_args": "Invalid capture arguments.", + "exit_capture": "Screen capture failed.", + "exit_process": "Image processing failed.", + "exit_unknown": "Unknown OCR error (code {code})." + }, + "settings": { + "screenshot_path": { + "label": "Screenshot Path", + "description": "Where screenshots are saved. Leave empty for ~/Pictures/Screenshots." + }, + "video_path": { + "label": "Video Path", + "description": "Where recordings are saved. Leave empty for ~/Videos." + }, + "filename_format": { + "label": "Filename Format", + "description": "Template for generated filenames. Extension is added automatically. Tokens: %Y %m %d %H %M %S." + }, + "selected_ocr_lang": { + "label": "OCR Language", + "description": "Tesseract language code (e.g. eng, deu, fra). Can be combined with + (e.g. eng+fra)." + }, + "search_engine_url": { + "label": "Search Engine URL", + "description": "URL used when searching OCR text. Leave empty to use Google. Examples: https://duckduckgo.com/?q= or https://search.brave.com/search?q=" + }, + "x02_api_key": { + "label": "X02 API Key", + "description": "Optional. Get a free key at up.x02.me for permanent links and larger files. Without a key, uploads use uguu.se (anonymous, 3h, 128 MB max)." + }, + "x02_expiry": { + "label": "Link Expiry", + "description": "How long your shared links stay alive. Only applies when using an X02 API key.", + "options": { + "1h": "1 hour", + "1d": "1 day", + "7d": "7 days", + "30d": "30 days", + "permanent": "Permanent" + } + }, + "share_skip_popover": { + "label": "Copy Link Directly", + "description": "Skip the share popover and copy the link straight to clipboard. A notification confirms." + }, + "record_audio_out": { + "label": "Record System Audio", + "description": "Record the desktop's audio output while recording." + }, + "record_audio_in": { + "label": "Record Microphone", + "description": "Record the default microphone while recording." + }, + "hide_cursor": { + "label": "Hide Cursor in Recordings", + "description": "Exclude the cursor from recordings. Screenshots never include the cursor." + }, + "record_codec": { + "label": "Fullscreen Codec", + "description": "Video codec for gpu-screen-recorder fullscreen capture. h264 is the safest NVIDIA NVENC default; av1 needs a recent GPU.", + "options": { + "h264": "H.264", + "hevc": "H.265 / HEVC", + "av1": "AV1" + } + }, + "record_fps": { + "label": "Fullscreen Frame Rate", + "description": "Frame rate for gpu-screen-recorder fullscreen capture (15–240)." + }, + "record_skip_confirmation": { + "label": "Skip Save Confirmation", + "description": "Automatically save to disk when recording finishes, without showing the save dialog." + }, + "record_copy_to_clipboard": { + "label": "Copy to Clipboard", + "description": "Copy the recording to clipboard when done instead of showing the save dialog." + }, + "gif_max_seconds": { + "label": "Max GIF Duration", + "description": "Maximum duration in seconds for GIF recordings." + } + } +} diff --git a/screen-toolkit/widget.luau b/screen-toolkit/widget.luau new file mode 100644 index 00000000..759e8cdd --- /dev/null +++ b/screen-toolkit/widget.luau @@ -0,0 +1,66 @@ +--!nonstrict +-- Screen Toolkit — bar [[widget]]. +-- +-- Crosshair glyph; a pulsing red dot appears while a recording is active. +-- Left click toggles the main panel; right click quick-picks a color. +-- Recording logic lives in the [[service]] — this widget +-- only reflects the published state and drives the service through the +-- "command" state channel. + +local recording = false +local phase = 0 +local startedAt = nil + +local function elapsed() + if not startedAt then return "00:00" end + local seconds = math.max(0, os.time() - startedAt) + return string.format("%02d:%02d", math.floor(seconds / 60), seconds % 60) +end + +local function render() + local container = barWidget.isVertical() and ui.column or ui.row + local children = {} + if recording then + table.insert(children, ui.box({ + key = "rec-dot", + width = 7, + height = 7, + radius = 4, + fill = (phase % 2 == 0) and "error" or "error/0.35", + })) + table.insert(children, ui.label({ text = `REC {elapsed()}`, fontSize = 11, fontWeight = "bold", color = "error" })) + end + table.insert(children, ui.glyph({ name = "crosshair", size = 14 })) + barWidget.render(container({ gap = 6, align = "center" }, children)) + barWidget.setTooltip(recording and noctalia.tr("widget.recording") or noctalia.tr("widget.tooltip")) +end + +-- Pulse the dot: the bar has no frame ticks, so re-render on a half-second cadence. +function update() + noctalia.setUpdateInterval(500) + phase += 1 + render() +end + +noctalia.state.watch("recording", function(value) + recording = value == true + startedAt = noctalia.state.get("recordStartedAt") + render() +end) +noctalia.state.watch("recordStartedAt", function(value) + startedAt = value + render() +end) + +function onClick() + -- The bar icon never stops a recording; it just opens the panel so the + -- stop button (in the panel header, next to the close X) is reachable. + noctalia.togglePanel("alexander/screen-toolkit:panel") +end + +function onRightClick() + -- Quick pick: copies the color and notifies, but never toggles the panel. + noctalia.state.set("command", { action = "colorPicker", payload = { reopen = false } }) +end + +render()