46 KiB
valw Noctalia plugin Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A Noctalia plugin (bar widget, toolbar panel, control-center tile) that drives valw through three new valw toolbar flags, installed by the home-manager module.
Architecture: valw gains valw toolbar --state | --set KEY=VALUE | --run [MODE] over the existing toolbar.toml, so the plugin holds no capture logic. The plugin is plain Luau in noctalia-plugin/, drawn entirely with Noctalia's components and colour roles; its logic is tested with the luau CLI against stubbed Noctalia globals. The nix package ships the folder; programs.valw.noctalia.enable links it into $XDG_DATA_HOME/noctalia/plugins/valw.
Tech Stack: Rust 2024, clap 4 (derive, ValueEnum), serde_json, toml; Luau (Noctalia 5.2, plugin_api = 3), luau/luau-compile 0.738 from nixpkgs; flake-parts (dendritic *.ulu.nix), home-manager module.
Spec: docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md
Global Constraints
- Version control:
jjonly, nevergit. Commit with explicit paths:jj commit -m "<msg>" <paths…>. - Rust checks:
cargo clippy --all-targets -- -D warnings(nevercargo check),cargo nextest run(nevercargo test),cargo fmt --check. - Nix: nix3 commands only (
nix flake check,nix build,nix shell nixpkgs#luau -c …); nofindover/nix/store; never write a manualimports = [ … ]list in a*.ulu.nixfile. - Code, comments, docs and plugin strings in plain English (plus a Turkish translation file); match the surrounding code's comment density and idiom.
- Plugin id
elars/valw,plugin_api = 3,license = "MIT",dependencies = ["valw"], iconcamera; entries: widgetbar, paneltoolbar, shortcuttile. - The plugin never uses hex colours, only Noctalia role names (
primary,on_surface,outline,error, …). - No user text is ever interpolated into a
noctalia.runAsynccommand string; commands are fixed strings plus known mode/key/value tokens. --seterrors exit 1 like every valw error; nothing is written unless every pair is valid.- Mode clicks and the tile wait
sleep 0.3before capturing, so Noctalia's panel or control center is not in the shot. - Anything touching the live session (loading the plugin into the running Noctalia, opening its panel) is announced to the user first. Edits to
~/coplandneed the user's approval; the user runs rebuilds. Never include~/copland/modules/nixos/common/common-packages.ulu.nixin a commit.
Review Focus
- valw not on Noctalia's
PATH(or--statefailing): the panel shows "valw is not available" and an error notification, never a blank or crashed panel. — Luau test in Task 3. - A capture that fails (exit 1) raises a notification with stderr's last line; a cancel (exit 3) stays silent. — Luau tests in Task 3 (panel, bar, tile).
- The panel or control center ending up in the shot: a mode click must close the panel before starting
sleep 0.3 && valw toolbar --run <mode>. — Luau test in Task 3 checks the order and the command. - A missing or broken
toolbar.toml:--stateprints the defaults,--setstarts from the defaults and leaves a valid file. — Rust tests in Task 1. - Anything on stdout besides the JSON line breaks the plugin's
json.decode:valw toolbar --stateprints exactly one JSON line (logs go to stderr and the log file). — Task 2 runs the real binary and parses its stdout.
Task 1: Toolbar state bridge (to_json, apply_sets, set)
Files:
- Modify:
src/toolbar/state.rs(theModederive at the top; new functions aftersave; tests at the bottom)
Interfaces:
-
Consumes: existing
ToolbarState,Mode,TIMERS,defaults,load,saveinsrc/toolbar/state.rs. -
Produces:
Modederivesclap::ValueEnum(valuesscreen,window,region,zoom).pub fn to_json(state: &ToolbarState) -> String— one JSON line:mode,timer,cursor,preview,sound,timers.pub fn apply_sets(state: ToolbarState, sets: &[String]) -> anyhow::Result<ToolbarState>pub fn set(path: &Path, config: &Config, sets: &[String]) -> anyhow::Result<()>
-
Step 1: Write the failing tests
Append inside mod tests at the bottom of src/toolbar/state.rs:
#[test]
fn json_for_the_plugin() {
let state = ToolbarState {
mode: Mode::Zoom,
timer: 5,
cursor: true,
preview: false,
sound: true,
};
let text = to_json(&state);
assert!(!text.contains('\n'), "one line");
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
value,
serde_json::json!({
"mode": "zoom",
"timer": 5,
"cursor": true,
"preview": false,
"sound": true,
"timers": [0, 5, 10],
})
);
}
#[test]
fn sets_apply_every_key_in_order() {
let start = defaults(&Config::default());
let sets: Vec<String> = ["mode=window", "timer=10", "cursor=true", "preview=false", "sound=false"]
.map(String::from)
.into();
assert_eq!(
apply_sets(start, &sets).unwrap(),
ToolbarState {
mode: Mode::Window,
timer: 10,
cursor: true,
preview: false,
sound: false,
}
);
let twice = ["timer=5".to_string(), "timer=0".to_string()];
assert_eq!(apply_sets(start, &twice).unwrap().timer, 0, "the last one wins");
}
#[test]
fn bad_sets_are_rejected_by_name() {
let start = defaults(&Config::default());
for (pair, message) in [
("colour=red", "unknown toolbar key \"colour\""),
("mode=video", "mode must be screen, window, region or zoom"),
("timer=7", "timer must be 0, 5 or 10"),
("timer=soon", "timer must be 0, 5 or 10"),
("cursor=maybe", "cursor must be true or false"),
("cursor", "expected KEY=VALUE"),
] {
let err = format!("{:#}", apply_sets(start, &[pair.to_string()]).unwrap_err());
assert!(err.contains(message), "{pair}: {err}");
}
}
#[test]
fn set_writes_all_or_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("valw/toolbar.toml");
let config = Config::default();
let bad = ["timer=5".to_string(), "mode=video".to_string()];
assert!(set(&path, &config, &bad).is_err());
assert!(!path.exists(), "nothing written");
let good = ["timer=5".to_string(), "sound=false".to_string()];
set(&path, &config, &good).unwrap();
let state = load(&path, &config);
assert_eq!((state.timer, state.sound, state.mode), (5, false, Mode::Region));
std::fs::write(&path, "not toml [").unwrap();
set(&path, &config, &["cursor=true".to_string()]).unwrap();
assert_eq!(
load(&path, &config),
ToolbarState {
cursor: true,
..defaults(&config)
},
"a broken file is replaced, starting from the defaults"
);
}
- Step 2: Run the tests to verify they fail
Run: cargo nextest run -E 'test(toolbar::state)'
Expected: compile error, cannot find function \to_json`(andapply_sets, set`).
- Step 3: Implement
In src/toolbar/state.rs, change the Mode derive line to:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
Add after pub fn save(…):
/// One line of JSON for the Noctalia plugin: the state and the timer choices.
pub fn to_json(state: &ToolbarState) -> String {
serde_json::json!({
"mode": state.mode,
"timer": state.timer,
"cursor": state.cursor,
"preview": state.preview,
"sound": state.sound,
"timers": TIMERS,
})
.to_string()
}
/// `state` with every `KEY=VALUE` applied in order; the first bad pair is
/// an error naming it.
pub fn apply_sets(mut state: ToolbarState, sets: &[String]) -> Result<ToolbarState> {
for pair in sets {
let Some((key, value)) = pair.split_once('=') else {
bail!("expected KEY=VALUE, not {pair:?}");
};
let flag = || match value {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(anyhow::anyhow!("{key} must be true or false, not {value:?}")),
};
match key {
"mode" => {
state.mode = <Mode as clap::ValueEnum>::from_str(value, false).map_err(|_| {
anyhow::anyhow!("mode must be screen, window, region or zoom, not {value:?}")
})?;
}
"timer" => {
state.timer = value
.parse()
.ok()
.filter(|t| TIMERS.contains(t))
.with_context(|| format!("timer must be 0, 5 or 10, not {value:?}"))?;
}
"cursor" => state.cursor = flag()?,
"preview" => state.preview = flag()?,
"sound" => state.sound = flag()?,
_ => bail!("unknown toolbar key {key:?} (mode, timer, cursor, preview or sound)"),
}
}
Ok(state)
}
/// `valw toolbar --set`: `sets` applied to the remembered state and saved;
/// nothing changes if any pair is bad.
pub fn set(path: &Path, config: &Config, sets: &[String]) -> Result<()> {
let state = apply_sets(load(path, config), sets)?;
save(path, &state)
}
- Step 4: Run the tests to verify they pass
Run: cargo fmt && cargo nextest run -E 'test(toolbar::state)'
Expected: PASS, the 5 existing state tests and the 4 new ones.
- Step 5: Commit
cargo clippy --all-targets -- -D warnings
jj commit -m "toolbar: state as JSON and KEY=VALUE sets, for the Noctalia plugin" src/toolbar/state.rs
Task 2: valw toolbar --state | --set | --run
Files:
- Modify:
src/toolbar/mod.rs(Picked,run; newremember,run_remembered; a newtestsmodule at the bottom) - Modify:
src/main.rs(Command::Toolbar, its arm inrun,flag_conflictstest)
Interfaces:
-
Consumes: Task 1's
state::{to_json, set, load, save, default_path},Mode: clap::ValueEnum. -
Produces:
impl From<ToolbarState> for Pickedpub fn remember(path: &Path, config: &Config, mode: Option<Mode>) -> ToolbarStatepub fn run_remembered(config: &Config, mode: Option<Mode>) -> Result<Picked>- CLI:
valw toolbar --state,valw toolbar --set KEY=VALUE(repeatable),valw toolbar --run [MODE], mutually exclusive.
-
Step 1: Write the failing tests
Append to the end of src/toolbar/mod.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pick_carries_the_state_options() {
let state = ToolbarState {
mode: Mode::Window,
timer: 5,
cursor: true,
preview: false,
sound: true,
};
assert_eq!(
Picked::from(state),
Picked {
mode: Mode::Window,
cursor: true,
preview: false,
sound: true,
}
);
}
#[test]
fn run_remembers_a_given_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("toolbar.toml");
let config = Config::default();
state::save(&path, &ToolbarState { timer: 5, ..state::defaults(&config) }).unwrap();
let state = remember(&path, &config, Some(Mode::Zoom));
assert_eq!((state.mode, state.timer), (Mode::Zoom, 5), "the options stay");
assert_eq!(state::load(&path, &config).mode, Mode::Zoom, "saved");
assert_eq!(remember(&path, &config, None).mode, Mode::Zoom, "no mode: the remembered one");
}
#[test]
fn run_without_a_mode_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("toolbar.toml");
let config = Config::default();
assert_eq!(remember(&path, &config, None), state::defaults(&config));
assert!(!path.exists());
}
}
In src/main.rs, inside fn flag_conflicts(), after assert!(parses(&["toolbar"])); add:
assert!(parses(&["toolbar", "--state"]));
assert!(parses(&["toolbar", "--set", "timer=5", "--set", "sound=false"]));
assert!(parses(&["toolbar", "--run"]));
assert!(parses(&["toolbar", "--run", "zoom"]));
assert!(!parses(&["toolbar", "--run", "video"]));
assert!(!parses(&["toolbar", "--state", "--run"]));
assert!(!parses(&["toolbar", "--state", "--set", "timer=5"]));
assert!(!parses(&["toolbar", "--set", "timer=5", "--run", "zoom"]));
- Step 2: Run the tests to verify they fail
Run: cargo nextest run -E 'test(toolbar::tests) | test(flag_conflicts)'
Expected: compile errors: Picked: From<ToolbarState> not implemented, remember not found.
- Step 3: Implement
In src/toolbar/mod.rs: add use std::path::Path; next to the other std import, and after the Picked struct:
impl From<ToolbarState> for Picked {
fn from(s: ToolbarState) -> Self {
Picked {
mode: s.mode,
cursor: s.cursor,
preview: s.preview,
sound: s.sound,
}
}
}
Replace the end of pub fn run (from countdown(…)?; to the closing }) with:
countdown(&mut wl, &output, picked.0.timer)?;
Ok(Picked::from(picked.0))
}
/// The remembered state, with `mode` (if given) remembered as the new mode.
pub fn remember(path: &Path, config: &Config, mode: Option<Mode>) -> ToolbarState {
let mut s = state::load(path, config);
if let Some(mode) = mode {
s.mode = mode;
if let Err(e) = state::save(path, &s) {
tracing::warn!("could not remember the toolbar choice: {e:#}");
}
}
s
}
/// `valw toolbar --run`: the remembered options without the bar (for the
/// Noctalia plugin), then the countdown.
pub fn run_remembered(config: &Config, mode: Option<Mode>) -> Result<Picked> {
let s = remember(&state::default_path(), config, mode);
if s.timer > 0 {
let mut wl = Wayland::connect()?;
let outputs = wl.outputs();
anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs");
let output = outputs[crate::focused_output(&outputs)].clone();
countdown(&mut wl, &output, s.timer)?;
}
Ok(Picked::from(s))
}
(pick returns (ToolbarState { mode, ..state }, mode), so picked.0.mode == picked.1 and Picked::from(picked.0) is what run built before.)
In src/main.rs, replace the Toolbar variant:
/// Pick a mode from a floating bar (Cmd+Shift+5).
Toolbar {
/// Print the remembered choices as one line of JSON (for the Noctalia plugin).
#[arg(long, conflicts_with_all = ["set", "run"])]
state: bool,
/// Change a remembered choice: mode, timer, cursor, preview or sound.
#[arg(long, value_name = "KEY=VALUE", conflicts_with = "run")]
set: Vec<String>,
/// Capture at once with the remembered choices, without the bar.
#[arg(long, value_name = "MODE", num_args = 0..=1)]
run: Option<Option<toolbar::state::Mode>>,
},
and its arm in fn run:
Command::Toolbar { state, set, run } => {
let config = config::load(&config::default_path())?;
let path = toolbar::state::default_path();
if state {
println!("{}", toolbar::state::to_json(&toolbar::state::load(&path, &config)));
return Ok(());
}
if !set.is_empty() {
return toolbar::state::set(&path, &config, &set);
}
let picked = match run {
Some(mode) => toolbar::run_remembered(&config, mode)?,
None => toolbar::run(&config)?,
};
let (mode, common) = from_toolbar(picked);
capture(mode, common)
}
- Step 4: Run the tests to verify they pass
Run: cargo fmt && cargo nextest run -E 'test(toolbar) | test(flag_conflicts) | test(cli_definition)'
Expected: PASS (including cli_definition_is_valid).
- Step 5: Check the real binary's stdout (Review Focus 5)
Run (no surfaces, safe on the live session; uses a throwaway state dir):
export XDG_STATE_HOME=$(mktemp -d)
cargo run -q -- toolbar --state | python3 -c 'import json,sys; lines=sys.stdin.read().splitlines(); assert len(lines)==1, lines; print(json.loads(lines[0]))'
cargo run -q -- toolbar --set timer=10 --set mode=zoom && cargo run -q -- toolbar --state
cargo run -q -- toolbar --set timer=7; echo "exit=$?"
cat $XDG_STATE_HOME/valw/toolbar.toml
unset XDG_STATE_HOME
Expected: the defaults dict printed; then a line with "mode":"zoom" and "timer":10; then an error mentioning timer must be 0, 5 or 10 and exit=1; the file still says timer = 10.
- Step 6: Commit
cargo clippy --all-targets -- -D warnings
jj commit -m "toolbar: --state, --set and --run for the Noctalia plugin" src/toolbar/mod.rs src/main.rs
Task 3: The plugin, its Luau tests, packaging and the flake check
Files:
- Create:
noctalia-plugin/plugin.toml,noctalia-plugin/bar.luau,noctalia-plugin/panel.luau,noctalia-plugin/shortcut.luau,noctalia-plugin/translations/en.json,noctalia-plugin/translations/tr.json,noctalia-plugin/README.md - Create:
noctalia-plugin/tests/stubs.luau,noctalia-plugin/tests/panel_test.luau,noctalia-plugin/tests/bar_test.luau,noctalia-plugin/tests/shortcut_test.luau,noctalia-plugin/tests/run.sh - Modify:
nix/package.ulu.nix(postInstall) - Modify:
nix/checks.ulu.nix(newnoctalia-plugincheck)
Interfaces:
-
Consumes: Task 2's CLI:
valw toolbar --state(JSON{mode,timer,cursor,preview,sound,timers}),valw toolbar --set KEY=VALUE,valw toolbar --run MODE; exit 3 = cancelled. -
Produces:
$out/share/valw/noctalia-plugin/(withouttests/), panel idelars/valw:toolbar, widget typeelars/valw:bar. -
Step 1: Write the test harness and the failing tests
noctalia-plugin/tests/stubs.luau (stands in for Noctalia's globals and records what the plugin does):
--!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
noctalia-plugin/tests/panel_test.luau:
--!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")
noctalia-plugin/tests/bar_test.luau:
--!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")
noctalia-plugin/tests/shortcut_test.luau:
--!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")
noctalia-plugin/tests/run.sh:
#!/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
- Step 2: Run the tests to verify they fail
Run: nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh; echo "exit=$?"
Expected: cat fails on the missing panel.luau (No such file or directory), exit=1.
- Step 3: Write the plugin
noctalia-plugin/plugin.toml:
# 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"
noctalia-plugin/bar.luau:
--!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
noctalia-plugin/panel.luau:
--!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
noctalia-plugin/shortcut.luau:
--!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
noctalia-plugin/translations/en.json:
{
"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" }
}
noctalia-plugin/translations/tr.json:
{
"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" }
}
noctalia-plugin/README.md:
# 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`.
- Step 4: Run the tests to verify they pass
Run: nix shell nixpkgs#luau -c sh -c 'sh noctalia-plugin/tests/run.sh && luau-compile --null noctalia-plugin/*.luau && echo ALL-OK'
Expected: ok - … lines for every test (6 panel, 3 bar, 2 tile), then the compile summary and ALL-OK. A failing assert would print the message and stop with a non-zero exit (check that once: temporarily change "valw region" in bar_test.luau to "valw regio", see it fail, revert).
- Step 5: Package it and add the flake check
In nix/package.ulu.nix, append to postInstall (after the install -Dm644 …LICENSE-DejaVu… line):
# 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
In nix/checks.ulu.nix, add to checks (after home-module-rejects):
# 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
'';
- Step 6: Run the check
Run: nix build .#checks.x86_64-linux.noctalia-plugin -L 2>&1 | tail -20
Expected: the ok - … lines in the log and a successful build.
- Step 7: Commit
jj commit -m "noctalia-plugin: bar widget, toolbar panel and tile, with Luau tests and a flake check" noctalia-plugin nix/package.ulu.nix nix/checks.ulu.nix
Task 4: home-manager option, README and checklist
Files:
- Modify:
nix/home-module.ulu.nix(optionnoctalia.enable,xdg.dataFile) - Modify:
nix/checks.ulu.nix(homeModulehelper takes the wholeprograms.valwconfig;xdg.dataFilestand-in; assertions) - Modify:
README.md(a "Noctalia" section;valw toolbarflags in the commands table) - Modify:
docs/test-checklist.md(a "Noctalia plugin" section)
Interfaces:
-
Consumes: Task 3's
$out/share/valw/noctalia-plugin. -
Produces:
programs.valw.noctalia.enable→xdg.dataFile."noctalia/plugins/valw".source = "${cfg.package}/share/valw/noctalia-plugin". -
Step 1: Write the failing check
In nix/checks.ulu.nix, change the helper's signature and body so callers pass the whole programs.valw config:
homeModule =
valwConfig:
(lib.evalModules {
modules = [
self.homeModules.default
(
{ lib, ... }:
let
files = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule { options.source = lib.mkOption { type = lib.types.path; }; }
);
default = { };
};
in
{
options.home.packages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
};
options.xdg.configFile = files;
options.xdg.dataFile = files;
config._module.args.pkgs = pkgs;
config.programs.valw = {
enable = true;
}
// valwConfig;
}
)
];
}).config;
Update the callers: in home-module, plain = homeModule { };, set = homeModule { settings = { preview.timeout_secs = 3; sound.volume = 0.4; }; };, and add noctalia = homeModule { noctalia.enable = true; }; to its let; in home-module-rejects, fails = settings: pkgs.testers.testBuildFailure (homeModule { inherit settings; }).xdg.configFile."valw/config.toml".source;.
Add these asserts to home-module, next to the existing ones:
assert lib.assertMsg (plain.xdg.dataFile == { }) "no Noctalia plugin unless asked";
assert lib.assertMsg (
noctalia.xdg.dataFile."noctalia/plugins/valw".source == "${valw}/share/valw/noctalia-plugin"
) "the plugin is linked where Noctalia looks";
- Step 2: Run it to verify it fails
Run: nix build .#checks.x86_64-linux.home-module 2>&1 | tail -5
Expected: evaluation error: The option 'programs.valw.noctalia' does not exist.
- Step 3: Implement the option
In nix/home-module.ulu.nix, add to options.programs.valw (after settings):
noctalia.enable = lib.mkEnableOption ''
valw's Noctalia plugin (a bar button, the toolbar as a Noctalia
panel and a control-center tile), linked into
`$XDG_DATA_HOME/noctalia/plugins/valw`. Enable `elars/valw` and
place its widget in Noctalia's own settings
'';
and to config = lib.mkIf cfg.enable { … }:
xdg.dataFile."noctalia/plugins/valw" = lib.mkIf cfg.noctalia.enable {
source = "${cfg.package}/share/valw/noctalia-plugin";
};
- Step 4: Run the checks to verify they pass
Run: nix build .#checks.x86_64-linux.home-module .#checks.x86_64-linux.home-module-rejects 2>&1 | tail -5
Expected: both build.
- Step 5: README and checklist
In README.md, in the commands table change the toolbar row to:
| `valw toolbar` | Pick a mode from a floating bar, with a timer and options; `--state`, `--set KEY=VALUE` and `--run [MODE]` drive it without the bar (the Noctalia plugin uses them) |
and add after the "The overview backdrop" section:
## Noctalia
With [Noctalia](https://noctalia.dev), valw comes as a plugin: a camera
button on the bar (left click: the toolbar, right click: a region shot),
the toolbar as a Noctalia panel and a control-center tile. Noctalia draws
them, so they follow your Noctalia theme.
```nix
programs.valw.noctalia.enable = true;
```
Then enable `elars/valw` in Noctalia, add a widget of type `elars/valw:bar`
to a bar, and bind the toolbar:
```kdl
Mod+Shift+T { spawn "noctalia" "msg" "panel-toggle" "elars/valw:toolbar"; }
```
In docs/test-checklist.md, add a section at the end:
## Noctalia plugin
- [ ] `programs.valw.noctalia.enable` + `elars/valw` enabled: the camera button appears where the widget is placed; its tooltip says Screenshot.
- [ ] Left click opens the toolbar panel next to the bar; it uses Noctalia's colours, font, corners and border; switching the palette or light/dark restyles it.
- [ ] The remembered mode is highlighted; Screen / Region / Window / Zoom each capture, and the panel is never in the shot.
- [ ] Timer and the three toggles persist, and match what `valw toolbar` (the self-drawn bar) shows, both ways; the timer shows valw's countdown.
- [ ] Right click on the button: a region shot (no timer).
- [ ] Control-center tile: click closes the control center, then a region shot; right click opens the toolbar panel.
- [ ] `noctalia msg panel-toggle elars/valw:toolbar` (Mod+Shift+T) opens and closes the panel.
- [ ] With valw off Noctalia's PATH: "valw is not available" and a notification, no crash.
- [ ] Turkish locale: the Turkish strings appear.
- Step 6: Commit
jj commit -m "home-manager: programs.valw.noctalia.enable; README and checklist" nix/home-module.ulu.nix nix/checks.ulu.nix README.md docs/test-checklist.md
Task 5: Load it in the live Noctalia (verify the folder name and the symlink)
Files:
- Possibly modify:
nix/home-module.ulu.nix,nix/checks.ulu.nix,README.md,noctalia-plugin/README.md(only if the folder name or the symlink needs a change)
Interfaces:
-
Consumes: Task 3's plugin folder; Task 4's install path
noctalia/plugins/valw. -
Produces: a verified install path (ledgered as a ruling if it differs).
-
Step 1: Tell the user that the plugin is about to be loaded into their running Noctalia (a symlink in
~/.local/share/noctalia/plugins/,noctalia msg plugins enable elars/valw, the panel opened once), and wait for their go-ahead. -
Step 2: Link the built plugin the way home-manager will (a symlink into the store)
out=$(nix build --no-link --print-out-paths .#default)
mkdir -p ~/.local/share/noctalia/plugins
test ! -e ~/.local/share/noctalia/plugins/valw
ln -s $out/share/valw/noctalia-plugin ~/.local/share/noctalia/plugins/valw
noctalia msg config-reload
noctalia msg plugins list | grep -i valw
Expected: a line elars/valw [...] 0.1.0 disabled (or similar). If it is missing: check ~/.cache/noctalia/noctalia.log for the plugin loader's messages, try the folder names elars-valw and elars/valw (nested), and if a store symlink is refused, a plain copy; ledger the result as Task 5: Ruling: … and apply it to nix/home-module.ulu.nix (recursive = true for a copy, or the new folder name), the home-module check's expected key, and both READMEs.
- Step 3: Enable it and open the panel once
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). 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
noctalia msg plugins disable elars/valw
rm ~/.local/share/noctalia/plugins/valw
(home-manager will own that path after the user's rebuild; a leftover link would collide.)
Task 6: copland wiring (after the final review; needs the user's approval)
Files (in ~/copland, only after the user approves the diff):
-
Modify:
modules/home/home-valw.ulu.nix(programs.valw.noctalia.enable = true;; Mod+Shift+T →noctalia msg panel-toggle elars/valw:toolbar) -
Modify:
modules/home/home-noctalia.ulu.nix("elars/valw"inplugins.enabled;widget.valw = { type = "elars/valw:bar"; };;"valw"in the bar'sendlist or a capsule group, where the user wants it) -
Modify:
flake.lock(nix flake update valw) -
Step 1: Push valw
mainfirst (afternix flake checkpasses), then show the user the copland diff and ask where the button goes on the bar. -
Step 2: On approval: edit,
nix flake update valw,jj commit -m "valw: Noctalia plugin" <the three paths>(nevermodules/nixos/common/common-packages.ulu.nix); the user rebuilds.