noctalia-plugin: bar widget, toolbar panel and tile, with Luau tests and a flake check

This commit is contained in:
elwrcl committed 2026-10-04 05:58:08 +03:00
1 parent c8626dbe3e
commit 127d44040e
15 files changed
+530

No files matched your search

+1
View File
@@ -9,4 +9,5 @@
!/src/ !/src/
!/docs/ !/docs/
!/assets/ !/assets/
!/noctalia-plugin/
!/README.md !/README.md
+27
View File
@@ -88,6 +88,33 @@
}/testBuildFailure.log }/testBuildFailure.log
touch $out touch $out
''; '';
# The plugin as packaged: manifest, every entry present, Luau that
# compiles, translations that parse; and its logic against stubbed
# Noctalia globals.
noctalia-plugin =
pkgs.runCommand "valw-noctalia-plugin"
{
nativeBuildInputs = [
pkgs.jq
pkgs.luau
];
}
''
dir=${valw}/share/valw/noctalia-plugin
grep -qx 'id = "elars/valw"' $dir/plugin.toml
test "$(grep -c '^entry = ' $dir/plugin.toml)" -eq 3
for entry in $(sed -n 's/^entry = "\(.*\)"$/\1/p' $dir/plugin.toml); do
test -f $dir/$entry
done
test ! -e $dir/tests
luau-compile --null $dir/*.luau
for f in $dir/translations/*.json; do jq empty $f; done
cp -r ${../noctalia-plugin} plugin
chmod -R u+w plugin
sh plugin/tests/run.sh
touch $out
'';
clippy = cargoCheck "clippy" [ pkgs.clippy ] "cargo clippy --all-targets --offline -- -D warnings"; clippy = cargoCheck "clippy" [ pkgs.clippy ] "cargo clippy --all-targets --offline -- -D warnings";
nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline"; nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline";
+4
View File
@@ -48,6 +48,10 @@
] ]
} }
install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu
# The Noctalia plugin (programs.valw.noctalia), without its tests.
mkdir -p $out/share/valw
cp -r noctalia-plugin $out/share/valw/noctalia-plugin
rm -r $out/share/valw/noctalia-plugin/tests
''; '';
# dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit), # dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit),
+16
View File
@@ -0,0 +1,16 @@
# valw for Noctalia
A bar button, the valw toolbar as a Noctalia panel, and a control-center
tile. Noctalia draws everything, so it follows your Noctalia theme.
- Bar button: left click opens the toolbar, right click takes a region shot.
- Toolbar: click a mode to capture; the timer and options are remembered
(shared with `valw toolbar`).
- Tile: click for a region shot, right click for the toolbar.
Install it with the home-manager module (`programs.valw.noctalia.enable`),
then enable `elars/valw` in Noctalia and place the widget (type
`elars/valw:bar`). Toggle the toolbar from a keybind with
`noctalia msg panel-toggle elars/valw:toolbar`.
Tests: `nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh`.
+36
View File
@@ -0,0 +1,36 @@
--!nonstrict
-- valw bar widget: a camera glyph. Left click opens the toolbar panel,
-- right click takes a region shot at once (plain `valw region`, like the
-- keybind: the toolbar's timer and options don't apply).
local PANEL = "elars/valw:toolbar"
local function lastLine(text)
local last = ""
for line in string.gmatch(text or "", "[^\n]+") do
last = line
end
return last
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))
end
end
function update()
barWidget.render(ui.row({ align = "center" }, {
ui.glyph({ name = "camera" }),
}))
barWidget.setTooltip(noctalia.tr("bar.tooltip"))
end
function onClick()
noctalia.togglePanel(PANEL)
end
function onRightClick()
noctalia.runAsync("valw region", report)
end
+137
View File
@@ -0,0 +1,137 @@
--!nonstrict
-- valw toolbar panel: the four modes, the timer and the options, drawn
-- with Noctalia's own components. The choices live in valw's toolbar.toml:
-- read with `valw toolbar --state`, changed with `valw toolbar --set`.
-- A mode click closes the panel, waits for it to go, then captures.
local MODES = {
{ id = "screen", glyph = "device-desktop" },
{ id = "region", glyph = "crop" },
{ id = "window", glyph = "app-window" },
{ id = "zoom", glyph = "zoom-in" },
}
local OPTIONS = { "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
end
return last
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))
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.
local function remember(key, value)
state[key] = value
render()
noctalia.runAsync("valw toolbar --set " .. key .. "=" .. tostring(value), report)
end
local function modeButton(m)
return ui.button({
text = noctalia.tr("mode." .. m.id),
glyph = m.glyph,
variant = if state.mode == m.id then "primary" else "outline",
onClick = function()
capture(m.id)
end,
})
end
local function optionRow(key)
return ui.row({ gap = 8, align = "center", justify = "space_between" }, {
ui.label({ text = noctalia.tr("option." .. key), color = "on_surface" }),
ui.toggle({
checked = state[key],
onChange = function(value)
remember(key, value == "true")
end,
}),
})
end
render = function()
if unavailable then
panel.render(ui.column({ padding = 16 }, {
ui.label({ text = noctalia.tr("panel.unavailable"), color = "error" }),
}))
return
end
if state == nil then
return
end
local modes = {}
for _, m in ipairs(MODES) do
table.insert(modes, modeButton(m))
end
local timers, selected = {}, 0
for i, t in ipairs(state.timers) do
table.insert(timers, if t == 0 then noctalia.tr("timer.off") else tostring(t) .. " s")
if t == state.timer then
selected = i - 1
end
end
local rows = {
ui.row({ gap = 8, justify = "space_between" }, modes),
ui.separator({}),
ui.row({ gap = 8, align = "center", justify = "space_between" }, {
ui.label({ text = noctalia.tr("timer.label"), color = "on_surface" }),
ui.select({
options = timers,
selectedIndex = selected,
onChange = function(index)
remember("timer", state.timers[tonumber(index) + 1])
end,
}),
}),
}
for _, key in ipairs(OPTIONS) do
table.insert(rows, optionRow(key))
end
panel.render(ui.column({ gap = 12, padding = 16 }, rows))
end
local function fail(message)
unavailable = true
noctalia.notifyError("valw", message)
render()
end
function onOpen(_context)
unavailable = false
if not noctalia.commandExists("valw") then
fail(noctalia.tr("panel.unavailable"))
return
end
noctalia.runAsync("valw toolbar --state", function(result)
if result.exitCode ~= 0 then
fail(lastLine(result.stderr))
return
end
local decoded = noctalia.json.decode(result.stdout)
if type(decoded) ~= "table" then
fail(noctalia.tr("panel.unavailable"))
return
end
state = decoded
render()
end)
end
+34
View File
@@ -0,0 +1,34 @@
# valw for Noctalia: a bar button, the toolbar as a Noctalia panel and a
# control-center tile. Thin clients of the valw CLI: the choices live in
# valw's own toolbar.toml (`valw toolbar --state` / `--set`), captures run
# through `valw toolbar --run` and `valw region`.
id = "elars/valw"
name = "valw"
version = "0.1.0"
plugin_api = 3
author = "elars"
license = "MIT"
dependencies = ["valw"]
tags = ["screenshot", "capture"]
icon = "camera"
description = "macOS-style screenshots with valw: a bar button, a toolbar panel and a control-center tile."
# Left click: the toolbar panel. Right click: a region shot.
[[widget]]
id = "bar"
entry = "bar.luau"
[[panel]]
id = "toolbar"
entry = "panel.luau"
width = 360
height = 220
placement = "attached"
position = "auto"
open_near_click = true
# Click: a region shot. Right click: the toolbar panel.
[[shortcut]]
id = "tile"
entry = "shortcut.luau"
+31
View File
@@ -0,0 +1,31 @@
--!nonstrict
-- valw control-center tile. Click: the control center closes, then a
-- region shot (so it isn't in it). Right click: the toolbar panel.
local PANEL = "elars/valw:toolbar"
local function lastLine(text)
local last = ""
for line in string.gmatch(text or "", "[^\n]+") do
last = line
end
return last
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))
end
end
shortcut.setLabel(noctalia.tr("tile.label"))
shortcut.setIcon("camera", "camera")
function onClick()
noctalia.runAsync("noctalia msg panel-close; sleep 0.3 && valw region", report)
end
function onRightClick()
noctalia.togglePanel(PANEL)
end
+24
View File
@@ -0,0 +1,24 @@
--!nonstrict
-- The bar widget. Runs after stubs.luau and bar.luau.
update()
assert(findAll(rendered, function(n) return n.kind == "glyph" and n.props.name == "camera" end)[1], "camera glyph")
ok("renders")
onClick()
assert(calls.toggled[1] == "elars/valw:toolbar", "left click toggles the panel")
assert(#calls.run == 0)
ok("left click")
reset()
onRightClick()
assert(calls.run[1] == "valw region", calls.run[1])
reset()
fake.answers["valw region"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" }
onRightClick()
assert(calls.notify[1][2] == "error: busy", "failure notified")
reset()
fake.answers["valw region"] = { exitCode = 3, stdout = "", stderr = "" }
onRightClick()
assert(#calls.notify == 0, "cancel is silent")
ok("right click")
+87
View File
@@ -0,0 +1,87 @@
--!nonstrict
-- The toolbar panel. Runs after stubs.luau and panel.luau.
local STATE = { mode = "zoom", timer = 5, cursor = false, preview = true, sound = true, timers = { 0, 5, 10 } }
-- valw missing from Noctalia's PATH: a label and a notification, no panel.
fake.exists = false
onOpen(nil)
local labels = findAll(rendered, function(n) return n.kind == "label" end)
assert(#labels == 1 and labels[1].props.text == "panel.unavailable", "unavailable label")
assert(#calls.notify == 1, "notified once")
assert(#calls.run == 0, "nothing run")
ok("valw missing")
-- --state failing: the same.
reset()
fake.exists = true
fake.answers["valw toolbar --state"] = { exitCode = 1, stdout = "", stderr = "warn\nerror: broken\n" }
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]))
ok("--state failing")
-- A working valw: the remembered mode is primary, the timer selected.
reset()
fake.answers["valw toolbar --state"] = { exitCode = 0, stdout = "{}", stderr = "" }
fake.decoded = STATE
onOpen(nil)
assert(calls.run[1] == "valw toolbar --state", calls.run[1])
assert(button("mode.zoom").props.variant == "primary", "zoom is primary")
for _, m in ipairs({ "mode.screen", "mode.region", "mode.window" }) do
assert(button(m).props.variant == "outline", m .. " is outline")
end
local select = findAll(rendered, function(n) return n.kind == "select" end)[1]
assert(select.props.selectedIndex == 1, "5 s is index 1 (0-based)")
assert(select.props.options[1] == "timer.off" and select.props.options[2] == "5 s", "timer labels")
local toggles = findAll(rendered, function(n) return n.kind == "toggle" end)
assert(#toggles == 3, "three toggles")
assert(toggles[1].props.checked == false and toggles[2].props.checked == true, "cursor off, preview on")
local hex = findAll(rendered, function(n)
for _, v in pairs(n.props) do
if type(v) == "string" and string.find(v, "^#") then return true end
end
return false
end)
assert(#hex == 0, "no hex colours")
ok("renders the state")
-- A mode click closes the panel first, then captures after the wait.
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.notify == 0, "success is silent")
ok("mode click")
-- A failed capture notifies; a cancelled one doesn't.
reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 1, stdout = "", stderr = "error: no outputs\n" }
button("mode.screen").props.onClick()
assert(calls.notify[1][2] == "error: no outputs", "failure notified")
reset()
fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 3, stdout = "", stderr = "" }
button("mode.window").props.onClick()
assert(#calls.notify == 0, "cancel is silent")
ok("capture results")
-- The timer and the toggles write through --set and re-render at once.
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])
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])
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" }
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")
ok("timer and toggles")
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Runs each plugin test as one chunk: the stubs, the entry script, the test.
# Needs `luau` (nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh).
set -eu
dir=$(dirname "$0")
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for pair in panel:panel_test bar:bar_test shortcut:shortcut_test; do
entry=${pair%%:*}
test=${pair##*:}
cat "$dir/stubs.luau" "$dir/../$entry.luau" "$dir/$test.luau" > "$tmp/$test.luau"
luau "$tmp/$test.luau"
done
+19
View File
@@ -0,0 +1,19 @@
--!nonstrict
-- 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])
reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" }
onClick()
assert(calls.notify[1][2] == "error: busy", "failure notified")
reset()
fake.answers["noctalia msg panel-close"] = { exitCode = 3, stdout = "", stderr = "" }
onClick()
assert(#calls.notify == 0, "cancel is silent")
ok("click")
reset()
onRightClick()
assert(calls.toggled[1] == "elars/valw:toolbar", "right click toggles the panel")
ok("right click")
+85
View File
@@ -0,0 +1,85 @@
--!nonstrict
-- 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 = {} }
fake = {
exists = true,
-- runAsync answers by command prefix: { exitCode, stdout, stderr }
answers = {},
decoded = nil,
}
rendered = nil
local function node(kind)
return function(props, children)
return { kind = kind, props = props or {}, children = children or {} }
end
end
ui = {
row = node("row"), column = node("column"), label = node("label"),
button = node("button"), glyph = node("glyph"), toggle = node("toggle"),
select = node("select"), separator = node("separator"), spacer = node("spacer"),
}
noctalia = {
tr = function(key) return key end,
commandExists = function(_name) return fake.exists end,
runAsync = function(command, callback)
table.insert(calls.run, command)
table.insert(calls.order, "run")
if callback then
local answer = { exitCode = 0, stdout = "", stderr = "" }
for prefix, a in pairs(fake.answers) do
if string.sub(command, 1, #prefix) == prefix then answer = a end
end
callback(answer)
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 },
}
panel = {
render = function(tree) rendered = tree end,
close = function()
calls.closed += 1
table.insert(calls.order, "close")
end,
}
barWidget = {
render = function(tree) rendered = tree end,
setTooltip = function(_text) end,
}
shortcut = {
setLabel = function(_text) end,
setIcon = function(_a, _b) end,
}
-- Every node of `tree` that `match` accepts, depth first.
function findAll(tree, match, out)
out = out or {}
if type(tree) ~= "table" then return out end
if tree.kind and match(tree) then table.insert(out, tree) end
for _, child in ipairs(tree.children or {}) do findAll(child, match, out) end
return out
end
function button(text)
local found = findAll(rendered, function(n) return n.kind == "button" and n.props.text == text end)
assert(#found == 1, "one button " .. text .. ", found " .. #found)
return found[1]
end
function reset()
calls = { run = {}, notify = {}, toggled = {}, closed = 0, order = {} }
end
function ok(name)
print("ok - " .. name)
end
+8
View File
@@ -0,0 +1,8 @@
{
"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" }
}
+8
View File
@@ -0,0 +1,8 @@
{
"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" }
}