diff --git a/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md b/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md index b6002eb..8f19be3 100644 --- a/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md +++ b/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md @@ -1247,7 +1247,7 @@ noctalia msg plugins enable elars/valw 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** diff --git a/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md b/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md index 93626ed..28fe6a4 100644 --- a/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md +++ b/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md @@ -89,18 +89,23 @@ about 360 × 220. - On open (`onOpen`): `valw toolbar --state` → `noctalia.json.decode` → render. If valw is missing (`noctalia.commandExists`) or the call fails: 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`), Region (`crop`), Window (`app-window`), Zoom (`zoom-in`). The remembered mode is `variant = "primary"`, the others `"outline"`. - A click on a mode: `panel.close()`, then `sleep 0.3 && valw toolbar --run ` (the wait lets the panel - disappear before the screen is frozen or captured). A non-zero exit - other than 3 → `noctalia.notifyError("valw", )`. + disappear before the screen is frozen or captured). Captures run with + `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", )`. - Below: "Timer" `ui.select` with `Off`, `5 s`, `10 s` (from `timers`), and three `ui.toggle`s: "Show cursor", "Show preview", "Play sound". - A change re-renders at once and runs `valw toolbar --set =`; - a failure → `notifyError`. + A change re-renders at once and runs `valw toolbar --set` with every + 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`, `outline`, …); no hex colours anywhere in the plugin. diff --git a/noctalia-plugin/bar.luau b/noctalia-plugin/bar.luau index 52aaefa..a477861 100644 --- a/noctalia-plugin/bar.luau +++ b/noctalia-plugin/bar.luau @@ -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: ", 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 diff --git a/noctalia-plugin/panel.luau b/noctalia-plugin/panel.luau index aacaf13..0723da3 100644 --- a/noctalia-plugin/panel.luau +++ b/noctalia-plugin/panel.luau @@ -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: ", 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) diff --git a/noctalia-plugin/shortcut.luau b/noctalia-plugin/shortcut.luau index f74e3e0..6ee478b 100644 --- a/noctalia-plugin/shortcut.luau +++ b/noctalia-plugin/shortcut.luau @@ -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: ", 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() diff --git a/noctalia-plugin/tests/bar_test.luau b/noctalia-plugin/tests/bar_test.luau index 0e94350..622f6c7 100644 --- a/noctalia-plugin/tests/bar_test.luau +++ b/noctalia-plugin/tests/bar_test.luau @@ -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") diff --git a/noctalia-plugin/tests/panel_test.luau b/noctalia-plugin/tests/panel_test.luau index a607fd2..e512021 100644 --- a/noctalia-plugin/tests/panel_test.luau +++ b/noctalia-plugin/tests/panel_test.luau @@ -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") diff --git a/noctalia-plugin/tests/shortcut_test.luau b/noctalia-plugin/tests/shortcut_test.luau index 1abd267..376d7c8 100644 --- a/noctalia-plugin/tests/shortcut_test.luau +++ b/noctalia-plugin/tests/shortcut_test.luau @@ -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") diff --git a/noctalia-plugin/tests/stubs.luau b/noctalia-plugin/tests/stubs.luau index 331b073..bde40bd 100644 --- a/noctalia-plugin/tests/stubs.luau +++ b/noctalia-plugin/tests/stubs.luau @@ -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) diff --git a/noctalia-plugin/translations/en.json b/noctalia-plugin/translations/en.json index 6a25c9f..8f2d70f 100644 --- a/noctalia-plugin/translations/en.json +++ b/noctalia-plugin/translations/en.json @@ -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" + } } diff --git a/noctalia-plugin/translations/tr.json b/noctalia-plugin/translations/tr.json index 1c493e0..49218c1 100644 --- a/noctalia-plugin/translations/tr.json +++ b/noctalia-plugin/translations/tr.json @@ -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" + } }