* feat(panel): remove full mode, rename compact to standard - Remove panel-full entry (660×340) and all full-mode code paths - Rename compact → standard across panel, settings, translations - Restrict tile hover/click to icon+label only (no border hover) - Shrink close button to 24×24 with 12px glyph - Reduce standard section vertical padding to 4px - Standard panel height: 290 → 260 (matches legacy at 260) - Fix widget/shortcut/service toggle to respect panel-mode setting - Update README with correct sizes and behavior * added new thumbnail.webp --------- Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
1000 lines
35 KiB
Luau
1000 lines
35 KiB
Luau
--!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 LEGACY_PANEL_ID = "alexander/screen-toolkit:panel-legacy"
|
|
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
|
|
if outputs and #outputs > 0 then
|
|
local o = outputs[1]
|
|
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
|
|
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)
|
|
publish("activeTool", "annotate")
|
|
publish("annotateResult", { capturePath = 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 overrideAudioOut = nil
|
|
local overrideAudioIn = nil
|
|
|
|
local function getAudioOut()
|
|
if overrideAudioOut ~= nil then return overrideAudioOut end
|
|
return cfg("record-audio-out") == true
|
|
end
|
|
|
|
local function getAudioIn()
|
|
if overrideAudioIn ~= nil then return overrideAudioIn end
|
|
return cfg("record-audio-in") == true
|
|
end
|
|
|
|
local function gsrAudioFlags()
|
|
local audioOut = getAudioOut()
|
|
local audioIn = getAudioIn()
|
|
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, audioIn = getAudioOut(), getAudioIn()
|
|
local isRegion = geom and geom ~= "" and geom ~= "screen"
|
|
local fps = cfg("record-fps") or 60
|
|
local codec = cfg("record-codec") or "h264"
|
|
|
|
if bin == "gpu-screen-recorder" then
|
|
local cursor = cursorHidden and "no" or "yes"
|
|
local target = "-w screen"
|
|
if isRegion then
|
|
local gx, gy, gw, gh = tostring(geom):match("^(%d+),(%d+) (%d+)x(%d+)$")
|
|
if gx then target = string.format("-w region -region %sx%s+%s+%s", gw, gh, gx, gy) end
|
|
end
|
|
return string.format("gpu-screen-recorder %s -f %d -k %s -bm qp -ffmpeg-opts qp=25 -cursor %s -cr limited %s -v no -o %s", target, fps, codec, cursor, gsrAudioFlags(), shellQuote(out))
|
|
end
|
|
|
|
local isWl = bin == "wl-screenrec"
|
|
local parts = { bin }
|
|
if isRegion then table.insert(parts, "-g " .. shellQuote(geom)) end
|
|
table.insert(parts, "-f " .. shellQuote(out))
|
|
|
|
if isWl then
|
|
table.insert(parts, "-m " .. tostring(fps))
|
|
local wlCodec = codec == "h264" and "avc" or codec
|
|
table.insert(parts, "--codec " .. wlCodec)
|
|
if cursorHidden then table.insert(parts, "--no-cursor") end
|
|
else
|
|
table.insert(parts, "-r " .. tostring(fps))
|
|
end
|
|
|
|
if audioOut and audioIn then
|
|
table.insert(parts, isWl and "--audio" or "-a")
|
|
elseif audioOut then
|
|
table.insert(parts, isWl and "--audio --audio-device '$(pactl get-default-sink 2>/dev/null).monitor'" or "-a -C '$(pactl get-default-sink 2>/dev/null).monitor'")
|
|
elseif audioIn then
|
|
table.insert(parts, isWl and "--audio --audio-device '$(pactl get-default-source 2>/dev/null)'" or "-a -C '$(pactl get-default-source 2>/dev/null)'")
|
|
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
|
|
-- clean up old thumb so it doesn't stay stuck in preview
|
|
if recordState ~= "idle" then return end
|
|
rawPath = `/tmp/screen-toolkit-record-{os.time()}.mp4`
|
|
recordFormat = if format == "gif" then "gif" else "mp4"
|
|
publish("recordFormat", recordFormat)
|
|
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)} 2>/dev/null`, function(res)
|
|
local size = trim(res.stdout or "")
|
|
local numSize = tonumber(size)
|
|
if res.exitCode == 0 and numSize and numSize > 0 then
|
|
if previousSize == numSize then
|
|
callback(true)
|
|
return
|
|
end
|
|
noctalia.runAsync("sleep 0.3", function()
|
|
waitForFileStable(path, callback, attempts - 1, numSize)
|
|
end)
|
|
return
|
|
end
|
|
if attempts <= 0 then
|
|
callback(numSize ~= nil and numSize > 0)
|
|
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, 600000)
|
|
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).
|
|
-- use [/]tmp regex trick so pkill doesn't match and kill its own shell process
|
|
local stopCommand = `pkill -INT -f {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(recordFormat, 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
|
|
-- clean up old thumb so it doesn't stay stuck in preview
|
|
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
|
|
startRecord("screen", 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) finalizeRecord((type(payload) == "table" and payload.format or nil) or recordFormat, false) end,
|
|
recordCopy = function() finalizeRecord(recordFormat, true) end, recordDiscard = recordDiscard,
|
|
ocrSearch = ocrSearch, ocrTranslate = ocrTranslate, share = shareFile, clearResult = clearResult, clearHistory = clearHistory, setCursorHidden = setCursorHidden,
|
|
setAudioOut = function(payload) overrideAudioOut = (type(payload) == "table" and payload.value or payload) == true end,
|
|
setAudioIn = function(payload) overrideAudioIn = (type(payload) == "table" and payload.value or payload) == true end,
|
|
toggle = function()
|
|
local mode = cfg("panel-mode")
|
|
local id = mode == "legacy" and LEGACY_PANEL_ID or PANEL_ID
|
|
noctalia.togglePanel(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()
|