* 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>
528 lines
19 KiB
Luau
528 lines
19 KiB
Luau
--!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)
|