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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Executable
+99
@@ -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
|
||||
Executable
+63
@@ -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
|
||||
Executable
+37
@@ -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
|
||||
Executable
+99
@@ -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"
|
||||
Executable
+113
@@ -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
|
||||
Executable
+81
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user