# 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: `jj` only, never `git`. Commit with explicit paths: `jj commit -m "" `. - Rust checks: `cargo clippy --all-targets -- -D warnings` (never `cargo check`), `cargo nextest run` (never `cargo test`), `cargo fmt --check`. - Nix: nix3 commands only (`nix flake check`, `nix build`, `nix shell nixpkgs#luau -c …`); no `find` over `/nix/store`; never write a manual `imports = [ … ]` list in a `*.ulu.nix` file. - 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"]`, icon `camera`; entries: widget `bar`, panel `toolbar`, shortcut `tile`. - The plugin never uses hex colours, only Noctalia role names (`primary`, `on_surface`, `outline`, `error`, …). - No user text is ever interpolated into a `noctalia.runAsync` command string; commands are fixed strings plus known mode/key/value tokens. - `--set` errors exit 1 like every valw error; nothing is written unless every pair is valid. - Mode clicks and the tile wait `sleep 0.3` before 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 `~/copland` need the user's approval; the user runs rebuilds. Never include `~/copland/modules/nixos/common/common-packages.ulu.nix` in a commit. ## Review Focus 1. valw not on Noctalia's `PATH` (or `--state` failing): the panel shows "valw is not available" and an error notification, never a blank or crashed panel. — Luau test in Task 3. 2. 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). 3. 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 `. — Luau test in Task 3 checks the order and the command. 4. A missing or broken `toolbar.toml`: `--state` prints the defaults, `--set` starts from the defaults and leaves a valid file. — Rust tests in Task 1. 5. Anything on stdout besides the JSON line breaks the plugin's `json.decode`: `valw toolbar --state` prints 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` (the `Mode` derive at the top; new functions after `save`; tests at the bottom) **Interfaces:** - Consumes: existing `ToolbarState`, `Mode`, `TIMERS`, `defaults`, `load`, `save` in `src/toolbar/state.rs`. - Produces: - `Mode` derives `clap::ValueEnum` (values `screen`, `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` - `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`: ```rust #[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 = ["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\`` (and `apply_sets`, `set`). - [ ] **Step 3: Implement** In `src/toolbar/state.rs`, change the `Mode` derive line to: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] ``` Add after `pub fn save(…)`: ```rust /// 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 { 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 = ::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** ```bash 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`; new `remember`, `run_remembered`; a new `tests` module at the bottom) - Modify: `src/main.rs` (`Command::Toolbar`, its arm in `run`, `flag_conflicts` test) **Interfaces:** - Consumes: Task 1's `state::{to_json, set, load, save, default_path}`, `Mode: clap::ValueEnum`. - Produces: - `impl From for Picked` - `pub fn remember(path: &Path, config: &Config, mode: Option) -> ToolbarState` - `pub fn run_remembered(config: &Config, mode: Option) -> Result` - 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`: ```rust #[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: ```rust 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` 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: ```rust impl From 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: ```rust 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) -> 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) -> Result { 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: ```rust /// 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, /// Capture at once with the remembered choices, without the bar. #[arg(long, value_name = "MODE", num_args = 0..=1)] run: Option>, }, ``` and its arm in `fn run`: ```rust 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): ```bash 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** ```bash 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` (new `noctalia-plugin` check) **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/` (without `tests/`), panel id `elars/valw:toolbar`, widget type `elars/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): ```lua --!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`: ```lua --!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`: ```lua --!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`: ```lua --!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`: ```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`: ```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`: ```lua --!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`: ```lua --!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`: ```lua --!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`: ```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`: ```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`: ```markdown # 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): ```nix # 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`): ```nix # 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** ```bash 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` (option `noctalia.enable`, `xdg.dataFile`) - Modify: `nix/checks.ulu.nix` (`homeModule` helper takes the whole `programs.valw` config; `xdg.dataFile` stand-in; assertions) - Modify: `README.md` (a "Noctalia" section; `valw toolbar` flags 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: ```nix 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: ```nix 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`): ```nix 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 { … }`: ```nix 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: ```markdown | `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: ````markdown ## 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: ```markdown ## 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** ```bash 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)** ```bash 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** ```bash 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** ```bash 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"` in `plugins.enabled`; `widget.valw = { type = "elars/valw:bar"; };`; `"valw"` in the bar's `end` list or a capsule group, where the user wants it) - Modify: `flake.lock` (`nix flake update valw`) - [ ] **Step 1:** Push valw `main` first (after `nix flake check` passes), 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" ` (never `modules/nixos/common/common-packages.ulu.nix`); the user rebuilds.