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

@@ -1247,7 +1247,7 @@ noctalia msg plugins enable elars/valw
noctalia msg panel-toggle elars/valw:toolbar noctalia msg panel-toggle elars/valw:toolbar
``` ```
Expected: the panel appears near the bar with the four modes, the timer and three toggles, in Noctalia's style. Ask the user to confirm what they see, then close it (`noctalia msg panel-close`). Check `grep -i valw ~/.cache/noctalia/noctalia.log | tail -20` for Luau errors (unknown glyph names, bad props); fix any in the plugin, rerun Task 3's tests, and commit (`jj commit -m "noctalia-plugin: fixes from the live Noctalia" noctalia-plugin`). Expected: the panel appears near the bar with the four modes, the timer and three toggles, in Noctalia's style. Ask the user to confirm what they see, then close it (`noctalia msg panel-close`). With the user's go-ahead, also take two real shots from the panel: one with the timer at 5 s (the countdown runs, the shot lands, no error notification) and one Region shot held open for more than 6 s before selecting (it must not be killed). Check that nothing is clipped at 360×220 (the "Play sound" row, the four mode buttons in Turkish); adjust `width`/`height` in `plugin.toml` if needed. Check `grep -i valw ~/.cache/noctalia/noctalia.log | tail -20` for Luau errors (unknown glyph names, bad props); fix any in the plugin, rerun Task 3's tests, and commit (`jj commit -m "noctalia-plugin: fixes from the live Noctalia" noctalia-plugin`).
- [ ] **Step 4: Undo the dev install** - [ ] **Step 4: Undo the dev install**
@@ -89,18 +89,23 @@ about 360 × 220.
- On open (`onOpen`): `valw toolbar --state` → `noctalia.json.decode` → - On open (`onOpen`): `valw toolbar --state` → `noctalia.json.decode` →
render. If valw is missing (`noctalia.commandExists`) or the call fails: render. If valw is missing (`noctalia.commandExists`) or the call fails:
a single label "valw is not available" and `noctalia.notifyError` with a single label "valw is not available" and `noctalia.notifyError` with
the stderr's last line. valw's error message (its `error:` line).
- A row of four buttons with glyphs and labels: Screen (`device-desktop`), - A row of four buttons with glyphs and labels: Screen (`device-desktop`),
Region (`crop`), Window (`app-window`), Zoom (`zoom-in`). The Region (`crop`), Window (`app-window`), Zoom (`zoom-in`). The
remembered mode is `variant = "primary"`, the others `"outline"`. remembered mode is `variant = "primary"`, the others `"outline"`.
- A click on a mode: `panel.close()`, then - A click on a mode: `panel.close()`, then
`sleep 0.3 && valw toolbar --run <mode>` (the wait lets the panel `sleep 0.3 && valw toolbar --run <mode>` (the wait lets the panel
disappear before the screen is frozen or captured). A non-zero exit disappear before the screen is frozen or captured). Captures run with
other than 3 → `noctalia.notifyError("valw", <stderr's last line>)`. `noctalia.runStream` (stderr folded in, the exit status echoed last):
`runAsync` with a callback kills its command after 5 s, and a region,
a zoom or the timer can take longer. A non-zero exit other than 3 →
`noctalia.notifyError("valw", <valw's error: line>)`.
- Below: "Timer" `ui.select` with `Off`, `5 s`, `10 s` (from `timers`), - Below: "Timer" `ui.select` with `Off`, `5 s`, `10 s` (from `timers`),
and three `ui.toggle`s: "Show cursor", "Show preview", "Play sound". and three `ui.toggle`s: "Show cursor", "Show preview", "Play sound".
A change re-renders at once and runs `valw toolbar --set <key>=<value>`; A change re-renders at once and runs `valw toolbar --set` with every
a failure → `notifyError`. choice (so two quick changes can't undo each other); a failure →
`notifyError`. `--state` and `--set` start no run log (they would push
the capture logs out).
- Colours only by Noctalia role names (`primary`, `on_surface`, - Colours only by Noctalia role names (`primary`, `on_surface`,
`outline`, …); no hex colours anywhere in the plugin. `outline`, …); no hex colours anywhere in the plugin.
+25 -10
View File
@@ -5,18 +5,33 @@
local PANEL = "elars/valw:toolbar" local PANEL = "elars/valw:toolbar"
local function lastLine(text) -- valw's stderr on an error starts with "error: <message>", then cause,
local last = "" -- hint and log lines.
for line in string.gmatch(text or "", "[^\n]+") do local function errorMessage(lines)
last = line for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
end end
return last return noctalia.tr("error.failed")
end end
-- Exit 3 is a cancel (Esc): not an error. -- A capture lasts as long as the user takes (a region, a zoom, the timer),
local function report(result) -- so it runs as a stream: runAsync with a callback kills its command after
if result.exitCode ~= 0 and result.exitCode ~= 3 then -- 5 s. stderr joins the stream and the exit status comes last.
noctalia.notifyError("valw", lastLine(result.stderr)) 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
end end
@@ -32,5 +47,5 @@ function onClick()
end end
function onRightClick() function onRightClick()
noctalia.runAsync("valw region", report) capture("valw region")
end end
+49 -15
View File
@@ -11,38 +11,71 @@ local MODES = {
{ id = "zoom", glyph = "zoom-in" }, { id = "zoom", glyph = "zoom-in" },
} }
local OPTIONS = { "cursor", "preview", "sound" } local OPTIONS = { "cursor", "preview", "sound" }
local KEYS = { "mode", "timer", "cursor", "preview", "sound" }
-- The decoded `--state` JSON; nil until it arrives. -- The decoded `--state` JSON; nil until it arrives.
local state = nil local state = nil
local unavailable = false local unavailable = false
local function lastLine(text) -- valw's stderr on an error starts with "error: <message>", then cause,
local last = "" -- hint and log lines.
for line in string.gmatch(text or "", "[^\n]+") do local function errorMessage(lines)
last = line for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
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 end
-- Exit 3 is a cancel (Esc): not an error. -- Exit 3 is a cancel (Esc): not an error.
local function report(result) local function report(result)
if result.exitCode ~= 0 and result.exitCode ~= 3 then 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
end end
local render local render
local function capture(mode) -- Every choice goes with every --set, not just the changed one: each
panel.close() -- --set loads, changes and saves the file, so two quick changes sending
noctalia.runAsync("sleep 0.3 && valw toolbar --run " .. mode, report) -- one key each could undo each other. The values are valw's own tokens
end -- (from --state or our lists), never user text.
-- `key` and `value` are always one of our own tokens, never user text.
local function remember(key, value) local function remember(key, value)
state[key] = value state[key] = value
render() 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 end
local function modeButton(m) local function modeButton(m)
@@ -51,7 +84,8 @@ local function modeButton(m)
glyph = m.glyph, glyph = m.glyph,
variant = if state.mode == m.id then "primary" else "outline", variant = if state.mode == m.id then "primary" else "outline",
onClick = function() onClick = function()
capture(m.id) panel.close()
capture("sleep 0.3 && valw toolbar --run " .. m.id)
end, end,
}) })
end end
@@ -123,7 +157,7 @@ function onOpen(_context)
end end
noctalia.runAsync("valw toolbar --state", function(result) noctalia.runAsync("valw toolbar --state", function(result)
if result.exitCode ~= 0 then if result.exitCode ~= 0 then
fail(lastLine(result.stderr)) fail(errorMessage(linesOf(result.stderr)))
return return
end end
local decoded = noctalia.json.decode(result.stdout) local decoded = noctalia.json.decode(result.stdout)
+25 -10
View File
@@ -4,18 +4,33 @@
local PANEL = "elars/valw:toolbar" local PANEL = "elars/valw:toolbar"
local function lastLine(text) -- valw's stderr on an error starts with "error: <message>", then cause,
local last = "" -- hint and log lines.
for line in string.gmatch(text or "", "[^\n]+") do local function errorMessage(lines)
last = line for _, line in ipairs(lines) do
local message = string.match(line, "^error: (.+)$")
if message then
return message
end
end end
return last return noctalia.tr("error.failed")
end end
-- Exit 3 is a cancel (Esc): not an error. -- A capture lasts as long as the user takes (a region, a zoom, the timer),
local function report(result) -- so it runs as a stream: runAsync with a callback kills its command after
if result.exitCode ~= 0 and result.exitCode ~= 3 then -- 5 s. stderr joins the stream and the exit status comes last.
noctalia.notifyError("valw", lastLine(result.stderr)) 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
end end
@@ -23,7 +38,7 @@ shortcut.setLabel(noctalia.tr("tile.label"))
shortcut.setIcon("camera", "camera") shortcut.setIcon("camera", "camera")
function onClick() 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 end
function onRightClick() function onRightClick()
+6 -5
View File
@@ -7,18 +7,19 @@ ok("renders")
onClick() onClick()
assert(calls.toggled[1] == "elars/valw:toolbar", "left click toggles the panel") 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") ok("left click")
reset() reset()
onRightClick() 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() reset()
fake.answers["valw region"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" } fake.streams["(valw region)"] = valwFailure("busy", 1)
onRightClick() onRightClick()
assert(calls.notify[1][2] == "error: busy", "failure notified") assert(calls.notify[1][2] == "busy", "failure notified")
reset() reset()
fake.answers["valw region"] = { exitCode = 3, stdout = "", stderr = "" } fake.streams["(valw region)"] = { "valw-exit:3" }
onRightClick() onRightClick()
assert(#calls.notify == 0, "cancel is silent") assert(#calls.notify == 0, "cancel is silent")
ok("right click") ok("right click")
+31 -17
View File
@@ -12,13 +12,13 @@ assert(#calls.notify == 1, "notified once")
assert(#calls.run == 0, "nothing run") assert(#calls.run == 0, "nothing run")
ok("valw missing") ok("valw missing")
-- --state failing: the same. -- --state failing: the same, with valw's error message (not its log line).
reset() reset()
fake.exists = true 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) onOpen(nil)
assert(findAll(rendered, function(n) return n.kind == "label" and n.props.text == "panel.unavailable" end)[1], "label") 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") ok("--state failing")
-- A working valw: the remembered mode is primary, the timer selected. -- A working valw: the remembered mode is primary, the timer selected.
@@ -46,42 +46,56 @@ end)
assert(#hex == 0, "no hex colours") assert(#hex == 0, "no hex colours")
ok("renders the state") 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() reset()
button("mode.region").props.onClick() button("mode.region").props.onClick()
assert(calls.order[1] == "close" and calls.order[2] == "run", "close, then run") assert(calls.order[1] == "close" and calls.order[2] == "stream", "close, then capture")
assert(calls.run[1] == "sleep 0.3 && valw toolbar --run region", calls.run[1]) 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") assert(#calls.notify == 0, "success is silent")
ok("mode click") 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() 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() 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() 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() button("mode.window").props.onClick()
assert(#calls.notify == 0, "cancel is silent") 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") 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() reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = nil
select = findAll(rendered, function(n) return n.kind == "select" end)[1] select = findAll(rendered, function(n) return n.kind == "select" end)[1]
select.props.onChange("2") 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] select = findAll(rendered, function(n) return n.kind == "select" end)[1]
assert(select.props.selectedIndex == 2, "re-rendered with 10 s") assert(select.props.selectedIndex == 2, "re-rendered with 10 s")
reset() reset()
toggles = findAll(rendered, function(n) return n.kind == "toggle" end) toggles = findAll(rendered, function(n) return n.kind == "toggle" end)
toggles[3].props.onChange("false") 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) toggles = findAll(rendered, function(n) return n.kind == "toggle" end)
assert(toggles[3].props.checked == false, "re-rendered with sound off") assert(toggles[3].props.checked == false, "re-rendered with sound off")
reset() 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") toggles[1].props.onChange("true")
assert(calls.run[1] == "valw toolbar --set cursor=true", calls.run[1]) assert(string.find(calls.run[1], "--set cursor=true", 1, true), calls.run[1])
assert(calls.notify[1][2] == "error: read-only", "a failed --set notifies") assert(calls.notify[1][2] == "read-only file system", "a failed --set notifies")
ok("timer and toggles") ok("timer and toggles")
+8 -4
View File
@@ -2,13 +2,17 @@
-- The control-center tile. Runs after stubs.luau and shortcut.luau. -- The control-center tile. Runs after stubs.luau and shortcut.luau.
onClick() 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() reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" } fake.streams["(noctalia msg panel-close"] = valwFailure("busy", 1)
onClick() onClick()
assert(calls.notify[1][2] == "error: busy", "failure notified") assert(calls.notify[1][2] == "busy", "failure notified")
reset() reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 3, stdout = "", stderr = "" } fake.streams["(noctalia msg panel-close"] = { "valw-exit:3" }
onClick() onClick()
assert(#calls.notify == 0, "cancel is silent") assert(#calls.notify == 0, "cancel is silent")
ok("click") ok("click")
+25 -2
View File
@@ -2,11 +2,13 @@
-- Stand-ins for Noctalia's globals. Each test is run as one chunk: -- 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). -- 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 = { fake = {
exists = true, exists = true,
-- runAsync answers by command prefix: { exitCode, stdout, stderr } -- runAsync answers by command prefix: { exitCode, stdout, stderr }
answers = {}, answers = {},
-- runStream lines by command prefix; default: a clean exit
streams = {},
decoded = nil, decoded = nil,
} }
rendered = nil rendered = nil
@@ -38,6 +40,17 @@ noctalia = {
end end
return true return true
end, 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, notifyError = function(title, body) table.insert(calls.notify, { title, body }) end,
togglePanel = function(id) table.insert(calls.toggled, id) end, togglePanel = function(id) table.insert(calls.toggled, id) end,
json = { decode = function(_text) return fake.decoded end }, json = { decode = function(_text) return fake.decoded end },
@@ -77,7 +90,17 @@ function button(text)
end end
function reset() 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 end
function ok(name) function ok(name)
+27 -6
View File
@@ -1,8 +1,29 @@
{ {
"bar": { "tooltip": "Screenshot" }, "bar": {
"tile": { "label": "Screenshot" }, "tooltip": "Screenshot"
"panel": { "unavailable": "valw is not available" }, },
"mode": { "screen": "Screen", "region": "Region", "window": "Window", "zoom": "Zoom" }, "tile": {
"timer": { "label": "Timer", "off": "Off" }, "label": "Screenshot"
"option": { "cursor": "Show cursor", "preview": "Show preview", "sound": "Play sound" } },
"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ü" }, "bar": {
"tile": { "label": "Ekran görüntüsü" }, "tooltip": "Ekran görüntüsü"
"panel": { "unavailable": "valw bulunamadı" }, },
"mode": { "screen": "Ekran", "region": "Bölge", "window": "Pencere", "zoom": "Yakınlaştır" }, "tile": {
"timer": { "label": "Zamanlayıcı", "off": "Kapalı" }, "label": "Ekran görüntüsü"
"option": { "cursor": "İmleci göster", "preview": "Önizlemeyi göster", "sound": "Ses çal" } },
"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"
}
} }