noctalia-plugin: bar widget, toolbar panel and tile, with Luau tests and a flake check
This commit is contained in:
1 parent
c8626dbe3e
commit
127d44040e
15 files changed
+530
No files matched your search
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
Reference in new issue
Block a user