screen-toolkit : full screen utility (#221)

* screen-toolkit: add Noctalia v5 plugin

* screen-toolkit: credit original plugin

* screen-toolkit: fix recording, OCR capture, and result panel UX

* screen-toolkit: rename record tool labels to Record / Rec FS

* screen-toolkit: update plugin thumbnail

* screen-toolkit: fix README validation (backtick deps, result panel toggle)

* screen-toolkit: fix OCR results and dependencies

* screen-toolkit: backtick recording and annotator deps in README

---------

Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
This commit is contained in:
Ahmed Emad
2026-08-03 08:22:08 -04:00
committed by GitHub
co-authored by Ahmed5Emad
parent 57227f8b51
commit 636317303c
15 changed files with 2860 additions and 0 deletions
+194
View File
@@ -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.
+162
View File
@@ -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
+208
View File
@@ -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"
+527
View File
@@ -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)
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# capture.sh <action> [args...]
#
# Actions:
# annotate-window — capture the focused Hyprland window with grim
# output: /tmp/screen-toolkit-annotate.png
# stdout: "X,Y WxH" geometry string
# palette <geometry> — extract 8 dominant hex colours from a captured region
# stdout: one "#RRGGBB" per line
# qr <geometry> — 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 <geometry>" >&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 <geometry>" >&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
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# color-picker.sh <output-png>
# 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
+37
View File
@@ -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
+99
View File
@@ -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"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# record.sh <action> [args...]
#
# Actions:
# thumb <src> — extract mid-frame thumbnail → /tmp/screen-toolkit-record-thumb.png
# convert-mp4 <input> <output> — finalize MP4: stream-copy (fast path)
# convert-mp4 <input> <output> --recode — finalize MP4: re-encode audio to AAC 128k + faststart
# convert-gif <input> <output> [maxsec] — convert MP4 → palette-optimized GIF at 15 fps
# convert-mov <input> <output> — remux MP4 → MOV (stream copy)
# stop <recorder-bin> — 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 <src>" >&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 <input>" >&2; exit 1; }
[ -n "$OUTPUT" ] || { echo "ERROR: convert-mp4: missing <output>" >&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 <input>" >&2; exit 1; }
[ -n "$OUTPUT" ] || { echo "ERROR: convert-gif: missing <output>" >&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 <input>" >&2; exit 1; }
[ -n "$OUTPUT" ] || { echo "ERROR: convert-mov: missing <output>" >&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 <recorder-bin>" >&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
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# share-upload.sh <file> [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
+980
View File
@@ -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()
+27
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+204
View File
@@ -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."
}
}
}
+66
View File
@@ -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()