diff --git a/.gitignore b/.gitignore index c3d1f09..bea6f63 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ !/src/ !/docs/ !/assets/ +!/noctalia-plugin/ !/README.md diff --git a/nix/checks.ulu.nix b/nix/checks.ulu.nix index b657943..36c93a4 100644 --- a/nix/checks.ulu.nix +++ b/nix/checks.ulu.nix @@ -88,6 +88,33 @@ }/testBuildFailure.log 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"; nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline"; diff --git a/nix/package.ulu.nix b/nix/package.ulu.nix index 5f32eed..3dca618 100644 --- a/nix/package.ulu.nix +++ b/nix/package.ulu.nix @@ -48,6 +48,10 @@ ] } 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), diff --git a/noctalia-plugin/README.md b/noctalia-plugin/README.md new file mode 100644 index 0000000..5a1a142 --- /dev/null +++ b/noctalia-plugin/README.md @@ -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`. diff --git a/noctalia-plugin/bar.luau b/noctalia-plugin/bar.luau new file mode 100644 index 0000000..52aaefa --- /dev/null +++ b/noctalia-plugin/bar.luau @@ -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 diff --git a/noctalia-plugin/panel.luau b/noctalia-plugin/panel.luau new file mode 100644 index 0000000..aacaf13 --- /dev/null +++ b/noctalia-plugin/panel.luau @@ -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 diff --git a/noctalia-plugin/plugin.toml b/noctalia-plugin/plugin.toml new file mode 100644 index 0000000..6f7fd39 --- /dev/null +++ b/noctalia-plugin/plugin.toml @@ -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" diff --git a/noctalia-plugin/shortcut.luau b/noctalia-plugin/shortcut.luau new file mode 100644 index 0000000..f74e3e0 --- /dev/null +++ b/noctalia-plugin/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 diff --git a/noctalia-plugin/tests/bar_test.luau b/noctalia-plugin/tests/bar_test.luau new file mode 100644 index 0000000..0e94350 --- /dev/null +++ b/noctalia-plugin/tests/bar_test.luau @@ -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") diff --git a/noctalia-plugin/tests/panel_test.luau b/noctalia-plugin/tests/panel_test.luau new file mode 100644 index 0000000..a607fd2 --- /dev/null +++ b/noctalia-plugin/tests/panel_test.luau @@ -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") diff --git a/noctalia-plugin/tests/run.sh b/noctalia-plugin/tests/run.sh new file mode 100644 index 0000000..a466e09 --- /dev/null +++ b/noctalia-plugin/tests/run.sh @@ -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 diff --git a/noctalia-plugin/tests/shortcut_test.luau b/noctalia-plugin/tests/shortcut_test.luau new file mode 100644 index 0000000..1abd267 --- /dev/null +++ b/noctalia-plugin/tests/shortcut_test.luau @@ -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") diff --git a/noctalia-plugin/tests/stubs.luau b/noctalia-plugin/tests/stubs.luau new file mode 100644 index 0000000..331b073 --- /dev/null +++ b/noctalia-plugin/tests/stubs.luau @@ -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 diff --git a/noctalia-plugin/translations/en.json b/noctalia-plugin/translations/en.json new file mode 100644 index 0000000..6a25c9f --- /dev/null +++ b/noctalia-plugin/translations/en.json @@ -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" } +} diff --git a/noctalia-plugin/translations/tr.json b/noctalia-plugin/translations/tr.json new file mode 100644 index 0000000..1c493e0 --- /dev/null +++ b/noctalia-plugin/translations/tr.json @@ -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" } +}