noctalia-plugin: captures as streams (no 5 s timeout), valw's error message in notifications, full state on every --set

This commit is contained in:
elwrcl committed 2026-10-04 06:27:57 +03:00
1 parent 9fa71fd399
commit 619eab9dc9
11 files changed
+234 -81

No files matched your search

+25 -10
View File
@@ -5,18 +5,33 @@
local PANEL = "elars/valw:toolbar"
local function lastLine(text)
local last = ""
for line in string.gmatch(text or "", "[^\n]+") do
last = line
-- valw's stderr on an error starts with "error: <message>", then cause,
-- hint and log lines.
local function errorMessage(lines)
for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
end
return last
return noctalia.tr("error.failed")
end
-- Exit 3 is a cancel (Esc): not an error.
local function report(result)
if result.exitCode ~= 0 and result.exitCode ~= 3 then
noctalia.notifyError("valw", lastLine(result.stderr))
-- A capture lasts as long as the user takes (a region, a zoom, the timer),
-- so it runs as a stream: runAsync with a callback kills its command after
-- 5 s. stderr joins the stream and the exit status comes last.
local function capture(command)
local lines = {}
local started = noctalia.runStream("(" .. command .. ") 2>&1; echo valw-exit:$?", function(line)
local code = string.match(line, "^valw%-exit:(%d+)$")
if code == nil then
table.insert(lines, line)
elseif code ~= "0" and code ~= "3" then
noctalia.notifyError("valw", errorMessage(lines))
end
end)
if not started then
noctalia.notifyError("valw", noctalia.tr("error.failed"))
end
end
@@ -32,5 +47,5 @@ function onClick()
end
function onRightClick()
noctalia.runAsync("valw region", report)
capture("valw region")
end
+49 -15
View File
@@ -11,38 +11,71 @@ local MODES = {
{ id = "zoom", glyph = "zoom-in" },
}
local OPTIONS = { "cursor", "preview", "sound" }
local KEYS = { "mode", "timer", "cursor", "preview", "sound" }
-- The decoded `--state` JSON; nil until it arrives.
local state = nil
local unavailable = false
local function lastLine(text)
local last = ""
for line in string.gmatch(text or "", "[^\n]+") do
last = line
-- valw's stderr on an error starts with "error: <message>", then cause,
-- hint and log lines.
local function errorMessage(lines)
for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
end
return last
return noctalia.tr("error.failed")
end
local function linesOf(text)
local lines = {}
for line in string.gmatch(text or "", "[^\n]+") do
table.insert(lines, line)
end
return lines
end
-- Exit 3 is a cancel (Esc): not an error.
local function report(result)
if result.exitCode ~= 0 and result.exitCode ~= 3 then
noctalia.notifyError("valw", lastLine(result.stderr))
noctalia.notifyError("valw", errorMessage(linesOf(result.stderr)))
end
end
-- A capture lasts as long as the user takes (a region, a zoom, the timer),
-- so it runs as a stream: runAsync with a callback kills its command after
-- 5 s. stderr joins the stream and the exit status comes last.
local function capture(command)
local lines = {}
local started = noctalia.runStream("(" .. command .. ") 2>&1; echo valw-exit:$?", function(line)
local code = string.match(line, "^valw%-exit:(%d+)$")
if code == nil then
table.insert(lines, line)
elseif code ~= "0" and code ~= "3" then
noctalia.notifyError("valw", errorMessage(lines))
end
end)
if not started then
noctalia.notifyError("valw", noctalia.tr("error.failed"))
end
end
local render
local function capture(mode)
panel.close()
noctalia.runAsync("sleep 0.3 && valw toolbar --run " .. mode, report)
end
-- `key` and `value` are always one of our own tokens, never user text.
-- Every choice goes with every --set, not just the changed one: each
-- --set loads, changes and saves the file, so two quick changes sending
-- one key each could undo each other. The values are valw's own tokens
-- (from --state or our lists), never user text.
local function remember(key, value)
state[key] = value
render()
noctalia.runAsync("valw toolbar --set " .. key .. "=" .. tostring(value), report)
local sets = {}
for _, k in ipairs(KEYS) do
table.insert(sets, "--set " .. k .. "=" .. tostring(state[k]))
end
noctalia.runAsync("valw toolbar " .. table.concat(sets, " "), report)
end
local function modeButton(m)
@@ -51,7 +84,8 @@ local function modeButton(m)
glyph = m.glyph,
variant = if state.mode == m.id then "primary" else "outline",
onClick = function()
capture(m.id)
panel.close()
capture("sleep 0.3 && valw toolbar --run " .. m.id)
end,
})
end
@@ -123,7 +157,7 @@ function onOpen(_context)
end
noctalia.runAsync("valw toolbar --state", function(result)
if result.exitCode ~= 0 then
fail(lastLine(result.stderr))
fail(errorMessage(linesOf(result.stderr)))
return
end
local decoded = noctalia.json.decode(result.stdout)
+25 -10
View File
@@ -4,18 +4,33 @@
local PANEL = "elars/valw:toolbar"
local function lastLine(text)
local last = ""
for line in string.gmatch(text or "", "[^\n]+") do
last = line
-- valw's stderr on an error starts with "error: <message>", then cause,
-- hint and log lines.
local function errorMessage(lines)
for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
end
return last
return noctalia.tr("error.failed")
end
-- Exit 3 is a cancel (Esc): not an error.
local function report(result)
if result.exitCode ~= 0 and result.exitCode ~= 3 then
noctalia.notifyError("valw", lastLine(result.stderr))
-- A capture lasts as long as the user takes (a region, a zoom, the timer),
-- so it runs as a stream: runAsync with a callback kills its command after
-- 5 s. stderr joins the stream and the exit status comes last.
local function capture(command)
local lines = {}
local started = noctalia.runStream("(" .. command .. ") 2>&1; echo valw-exit:$?", function(line)
local code = string.match(line, "^valw%-exit:(%d+)$")
if code == nil then
table.insert(lines, line)
elseif code ~= "0" and code ~= "3" then
noctalia.notifyError("valw", errorMessage(lines))
end
end)
if not started then
noctalia.notifyError("valw", noctalia.tr("error.failed"))
end
end
@@ -23,7 +38,7 @@ shortcut.setLabel(noctalia.tr("tile.label"))
shortcut.setIcon("camera", "camera")
function onClick()
noctalia.runAsync("noctalia msg panel-close; sleep 0.3 && valw region", report)
capture("noctalia msg panel-close >/dev/null 2>&1; sleep 0.3 && valw region")
end
function onRightClick()
+6 -5
View File
@@ -7,18 +7,19 @@ ok("renders")
onClick()
assert(calls.toggled[1] == "elars/valw:toolbar", "left click toggles the panel")
assert(#calls.run == 0)
assert(#calls.run == 0 and #calls.stream == 0)
ok("left click")
reset()
onRightClick()
assert(calls.run[1] == "valw region", calls.run[1])
assert(calls.stream[1] == "(valw region) 2>&1; echo valw-exit:$?", calls.stream[1])
assert(#calls.run == 0, "never through runAsync's timeout")
reset()
fake.answers["valw region"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" }
fake.streams["(valw region)"] = valwFailure("busy", 1)
onRightClick()
assert(calls.notify[1][2] == "error: busy", "failure notified")
assert(calls.notify[1][2] == "busy", "failure notified")
reset()
fake.answers["valw region"] = { exitCode = 3, stdout = "", stderr = "" }
fake.streams["(valw region)"] = { "valw-exit:3" }
onRightClick()
assert(#calls.notify == 0, "cancel is silent")
ok("right click")
+31 -17
View File
@@ -12,13 +12,13 @@ assert(#calls.notify == 1, "notified once")
assert(#calls.run == 0, "nothing run")
ok("valw missing")
-- --state failing: the same.
-- --state failing: the same, with valw's error message (not its log line).
reset()
fake.exists = true
fake.answers["valw toolbar --state"] = { exitCode = 1, stdout = "", stderr = "warn\nerror: broken\n" }
fake.answers["valw toolbar --state"] = { exitCode = 1, stdout = "", stderr = valwError("broken config") }
onOpen(nil)
assert(findAll(rendered, function(n) return n.kind == "label" and n.props.text == "panel.unavailable" end)[1], "label")
assert(calls.notify[1][2] == "error: broken", "the stderr's last line: " .. tostring(calls.notify[1][2]))
assert(calls.notify[1][2] == "broken config", "valw's message: " .. tostring(calls.notify[1][2]))
ok("--state failing")
-- A working valw: the remembered mode is primary, the timer selected.
@@ -46,42 +46,56 @@ end)
assert(#hex == 0, "no hex colours")
ok("renders the state")
-- A mode click closes the panel first, then captures after the wait.
-- A mode click closes the panel first, then captures after the wait, as a
-- stream (no timeout: a region or a timer can take longer than 5 s).
reset()
button("mode.region").props.onClick()
assert(calls.order[1] == "close" and calls.order[2] == "run", "close, then run")
assert(calls.run[1] == "sleep 0.3 && valw toolbar --run region", calls.run[1])
assert(calls.order[1] == "close" and calls.order[2] == "stream", "close, then capture")
assert(calls.stream[1] == "(sleep 0.3 && valw toolbar --run region) 2>&1; echo valw-exit:$?", calls.stream[1])
assert(#calls.run == 0, "never through runAsync's timeout")
assert(#calls.notify == 0, "success is silent")
ok("mode click")
-- A failed capture notifies; a cancelled one doesn't.
-- A failed capture notifies with valw's message; a cancel doesn't; a
-- failure without a message gets a generic one.
reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 1, stdout = "", stderr = "error: no outputs\n" }
fake.streams["(sleep 0.3 && valw toolbar --run"] = valwFailure("no outputs", 1)
button("mode.screen").props.onClick()
assert(calls.notify[1][2] == "error: no outputs", "failure notified")
assert(calls.notify[1][2] == "no outputs", "failure notified: " .. tostring(calls.notify[1][2]))
reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 3, stdout = "", stderr = "" }
fake.streams["(sleep 0.3 && valw toolbar --run"] = { "valw-exit:3" }
button("mode.window").props.onClick()
assert(#calls.notify == 0, "cancel is silent")
reset()
fake.streams["(sleep 0.3 && valw toolbar --run"] = { "valw-exit:143" }
button("mode.window").props.onClick()
assert(calls.notify[1][2] == "error.failed", "a generic message")
fake.streams["(sleep 0.3 && valw toolbar --run"] = nil
ok("capture results")
-- The timer and the toggles write through --set and re-render at once.
-- The timer and the toggles re-render at once and send every choice, so
-- two quick changes can't undo each other.
reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = nil
select = findAll(rendered, function(n) return n.kind == "select" end)[1]
select.props.onChange("2")
assert(calls.run[1] == "valw toolbar --set timer=10", calls.run[1])
assert(
calls.run[1] == "valw toolbar --set mode=zoom --set timer=10 --set cursor=false --set preview=true --set sound=true",
calls.run[1]
)
select = findAll(rendered, function(n) return n.kind == "select" end)[1]
assert(select.props.selectedIndex == 2, "re-rendered with 10 s")
reset()
toggles = findAll(rendered, function(n) return n.kind == "toggle" end)
toggles[3].props.onChange("false")
assert(calls.run[1] == "valw toolbar --set sound=false", calls.run[1])
assert(
calls.run[1] == "valw toolbar --set mode=zoom --set timer=10 --set cursor=false --set preview=true --set sound=false",
calls.run[1]
)
toggles = findAll(rendered, function(n) return n.kind == "toggle" end)
assert(toggles[3].props.checked == false, "re-rendered with sound off")
reset()
fake.answers["valw toolbar --set"] = { exitCode = 1, stdout = "", stderr = "error: read-only\n" }
fake.answers["valw toolbar --set"] = { exitCode = 1, stdout = "", stderr = valwError("read-only file system") }
toggles[1].props.onChange("true")
assert(calls.run[1] == "valw toolbar --set cursor=true", calls.run[1])
assert(calls.notify[1][2] == "error: read-only", "a failed --set notifies")
assert(string.find(calls.run[1], "--set cursor=true", 1, true), calls.run[1])
assert(calls.notify[1][2] == "read-only file system", "a failed --set notifies")
ok("timer and toggles")
+8 -4
View File
@@ -2,13 +2,17 @@
-- The control-center tile. Runs after stubs.luau and shortcut.luau.
onClick()
assert(calls.run[1] == "noctalia msg panel-close; sleep 0.3 && valw region", calls.run[1])
assert(
calls.stream[1] == "(noctalia msg panel-close >/dev/null 2>&1; sleep 0.3 && valw region) 2>&1; echo valw-exit:$?",
calls.stream[1]
)
assert(#calls.run == 0, "never through runAsync's timeout")
reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" }
fake.streams["(noctalia msg panel-close"] = valwFailure("busy", 1)
onClick()
assert(calls.notify[1][2] == "error: busy", "failure notified")
assert(calls.notify[1][2] == "busy", "failure notified")
reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 3, stdout = "", stderr = "" }
fake.streams["(noctalia msg panel-close"] = { "valw-exit:3" }
onClick()
assert(#calls.notify == 0, "cancel is silent")
ok("click")
+25 -2
View File
@@ -2,11 +2,13 @@
-- Stand-ins for Noctalia's globals. Each test is run as one chunk:
-- these stubs, then the entry script, then the test (see run.sh).
calls = { run = {}, notify = {}, toggled = {}, closed = 0, order = {} }
calls = { run = {}, stream = {}, notify = {}, toggled = {}, closed = 0, order = {} }
fake = {
exists = true,
-- runAsync answers by command prefix: { exitCode, stdout, stderr }
answers = {},
-- runStream lines by command prefix; default: a clean exit
streams = {},
decoded = nil,
}
rendered = nil
@@ -38,6 +40,17 @@ noctalia = {
end
return true
end,
-- No timeout here (runAsync with a callback kills after 5 s): captures.
runStream = function(command, onLine)
table.insert(calls.stream, command)
table.insert(calls.order, "stream")
local lines = { "valw-exit:0" }
for prefix, l in pairs(fake.streams) do
if string.sub(command, 1, #prefix) == prefix then lines = l end
end
for _, line in ipairs(lines) do onLine(line) end
return true
end,
notifyError = function(title, body) table.insert(calls.notify, { title, body }) end,
togglePanel = function(id) table.insert(calls.toggled, id) end,
json = { decode = function(_text) return fake.decoded end },
@@ -77,7 +90,17 @@ function button(text)
end
function reset()
calls = { run = {}, notify = {}, toggled = {}, closed = 0, order = {} }
calls = { run = {}, stream = {}, notify = {}, toggled = {}, closed = 0, order = {} }
end
-- valw's stderr on an error: the message first, then cause, hint and log.
function valwError(message)
return "error: " .. message .. "\n cause: something\n log: /home/u/.local/state/valw/logs/x.log\n"
end
-- The same as stream lines, ending with the exit status.
function valwFailure(message, code)
return { "error: " .. message, " log: /home/u/.local/state/valw/logs/x.log", "valw-exit:" .. code }
end
function ok(name)
+27 -6
View File
@@ -1,8 +1,29 @@
{
"bar": { "tooltip": "Screenshot" },
"tile": { "label": "Screenshot" },
"panel": { "unavailable": "valw is not available" },
"mode": { "screen": "Screen", "region": "Region", "window": "Window", "zoom": "Zoom" },
"timer": { "label": "Timer", "off": "Off" },
"option": { "cursor": "Show cursor", "preview": "Show preview", "sound": "Play sound" }
"bar": {
"tooltip": "Screenshot"
},
"tile": {
"label": "Screenshot"
},
"panel": {
"unavailable": "valw is not available"
},
"mode": {
"screen": "Screen",
"region": "Region",
"window": "Window",
"zoom": "Zoom"
},
"timer": {
"label": "Timer",
"off": "Off"
},
"option": {
"cursor": "Show cursor",
"preview": "Show preview",
"sound": "Play sound"
},
"error": {
"failed": "valw failed"
}
}
+27 -6
View File
@@ -1,8 +1,29 @@
{
"bar": { "tooltip": "Ekran görüntüsü" },
"tile": { "label": "Ekran görüntüsü" },
"panel": { "unavailable": "valw bulunamadı" },
"mode": { "screen": "Ekran", "region": "Bölge", "window": "Pencere", "zoom": "Yakınlaştır" },
"timer": { "label": "Zamanlayıcı", "off": "Kapalı" },
"option": { "cursor": "İmleci göster", "preview": "Önizlemeyi göster", "sound": "Ses çal" }
"bar": {
"tooltip": "Ekran görüntüsü"
},
"tile": {
"label": "Ekran görüntüsü"
},
"panel": {
"unavailable": "valw bulunamadı"
},
"mode": {
"screen": "Ekran",
"region": "Bölge",
"window": "Pencere",
"zoom": "Yakınlaştır"
},
"timer": {
"label": "Zamanlayıcı",
"off": "Kapalı"
},
"option": {
"cursor": "İmleci göster",
"preview": "Önizlemeyi göster",
"sound": "Ses çal"
},
"error": {
"failed": "valw başarısız oldu"
}
}