diff --git a/README.md b/README.md index 8d5644a..72f5f22 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ two tables below are split along exactly that line. | `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv | | `nomarchy.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite | | `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback | +| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System › Display › Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild | | `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed | | `nomarchy.package` | overlay's `nomarchy-theme-sync` | The theme/state tool package, overridable if you fork it | diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index a48ed05..94b7a11 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -68,9 +68,10 @@ the *same* outputs. **Plan (designed 2026-07-05, iteration #27):** `hyprctl keyword monitor ` per entry (rules baked into the dispatcher at build, like everything else in rofi.nix) + state write + toast. Outputs the profile doesn't name keep their base rules. -- **Slices:** (a) option + overlay + menu picker + template example - (opt-in ⇒ commented `templates/downstream/home.nix` block) — V1 + - a checks.* grep of the rendered hyprland conf, V3 real dock; (b) +- **Slices:** (a) ✓ shipped 2026-07-05 (iteration #28): option + + overlay (pure `modules/home/monitor-rules.nix`, unit-tested by + checks.display-profiles) + `nomarchy-display-profile` applier + menu + Profiles row + template example — V3 (real dock) queued; (b) opt-in hotplug auto-switch: a socket2 watcher on monitoradded/ removed picks the profile whose named-output set matches connected outputs (exact match, else largest subset, ties = no switch) — diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index a99369b..1ed5122 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -140,6 +140,14 @@ QA machine), the **T14s** (webcam case). now reflects the real state (the `= true` comparison fix). — 2026-07-04 Bernardo: PASS — theme changes committed and the toggle self-committed. +- [ ] **Display profiles, slice a (item 15)** — declare two + `nomarchy.displayProfiles` (e.g. docked disables eDP-1 + arranges + the externals; undocked re-enables it), `home-update`, then: + System › Display › Profiles lists them (● marks active); applying + is instant (no rebuild) and survives a relogin (the baked + overlay); "Base layout" restores; a menu resolution pick made + earlier must NOT re-enable a profile-disabled panel after the + next rebuild. - [ ] **Color picker (item 13, final slice)** — SUPER+CTRL+P (or Tools › Color picker): hyprpicker's zoom loupe appears; click a pixel → toast shows the hex and `wl-paste` yields it; Esc cancels with no diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index be78a68..8628cb6 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,32 @@ Template: --- +## 2026-07-05 — display profiles slice a (iteration #28, item 15a) +- **Task:** BACKLOG NEXT#15 slice a, per the #27 design: option + + overlay + applier + menu + template example. +- **Did:** nomarchy.displayProfiles (attrsOf (listOf monitorType)); + monitor logic extracted to pure modules/home/monitor-rules.nix + (rule renderer + 3-layer resolve: profile replaces whole-by-name → + resolution picks field-level with the disable-guard → wildcard), + hyprland.nix consumes it and gains nomarchy-display-profile + (list/active/apply/base; apply = baked hyprctl rules + state write + --no-switch, base = clear + hyprctl reload) on PATH only when + profiles exist; menu: Display gains a gated "Profiles ›" row + new + display-profile mode (●/○ active mark, Base layout row); template + example + README row. checks.display-profiles unit-tests resolve + (incl. junk-state degradation) — the first HM-eval fixture attempt + hit "path not valid" (eval store won't realise writeText/toFile + under flake check), hence the pure-function extraction. +- **Verified:** V0 (check's asserts force at eval); V1 — scratch + downstream through lib.mkFlake builds: tool bash -n + correct baked + rules (disable, vrr, floats), hyprland.conf shows the exact overlay + (wildcard + kept base + disabled panel + profile output), menu bash + -n, list/usage smoke. Live switching needs a session+dock → V3. +- **Pending:** V3 queued. Slices b (hotplug auto-switch) and c + (workspace binding) remain in the item. +- **Next suggestion:** #16 greeter theming, or 15b if momentum on + displays is preferred. + ## 2026-07-05 — display-profiles design pass (iteration #27, item 15, no code) - **Task:** BACKLOG NEXT#15 — the entry itself demands "design first, write the plan into this entry before coding". Backlog-only iteration. diff --git a/agent/MEMORY.md b/agent/MEMORY.md index 90a50c0..e14849b 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -7,6 +7,11 @@ here the moment a debugging session teaches you something a future iteration would otherwise rediscover. ## Testing & VM recipes +- A checks.* fixture CANNOT be a writeText/toFile state file read at + eval time ("path … is not valid" — flake check's eval store won't + realise it): extract the logic into a pure importable file and + unit-test THAT (monitor-rules.nix / checks.display-profiles is the + pattern). - CI (`.gitea/workflows/check.yml`) is **eval-tier only**: the act_runner is a docker container (no systemd, no /dev/kvm). Container gotchas are documented in the workflow header (single-user Nix + nixbld users, diff --git a/flake.nix b/flake.nix index b644813..702d147 100644 --- a/flake.nix +++ b/flake.nix @@ -332,6 +332,49 @@ ''; }; + # Unit test of the monitor-rule overlay (modules/home/ + # monitor-rules.nix — pure, imported directly, no HM eval): the + # active profile must replace base entries whole by name, leave + # unnamed base outputs alone, and the disable-guard must stop a + # stale menu resolution pick (settings.monitors) resurrecting a + # panel the profile disables. Junk in settings.displayProfile + # degrades to base config, never an eval error. + display-profiles = + let + inherit (nixpkgs) lib; + monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; }; + entry = a: monitorLib.defaults // a; + fixture = { + base = [ + (entry { name = "eDP-1"; position = "0x0"; }) + (entry { name = "HDMI-A-1"; position = "auto-right"; }) + ]; + profiles.docked = [ + (entry { name = "eDP-1"; resolution = "disable"; }) + (entry { name = "DP-3"; position = "0x0"; }) + ]; + resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead + }; + rulesFor = active: map monitorLib.rule + (monitorLib.resolve (fixture // { inherit active; })); + docked = rulesFor "docked"; + junk = rulesFor 42; # hand-edited state (validator only warns) + expect = cond: msg: lib.assertMsg cond + "display-profiles: ${msg} (rendered: ${builtins.toJSON docked})"; + ok = + expect (lib.elem "eDP-1, disable" docked) + "the profile did not disable eDP-1" + && expect (! lib.any (lib.hasInfix "1920x1080") docked) + "a stale resolution pick resurrected the disabled panel" + && expect (lib.elem "DP-3, preferred, 0x0, 1" docked) + "the profile-only output's rule is missing" + && expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked) + "an unnamed base output lost its rule" + && expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk) + "junk active profile must degrade to base config (with the pick applied)"; + in + assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out"; + # The low-battery watcher's crossing logic: one toast per downward # crossing (normal at 25%, critical at 10%), re-armed by charging. # test_power fakes a battery whose capacity/status we script; diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 42120b2..06a17d2 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -46,44 +46,59 @@ let keybinds = import ./keybinds.nix; mkBind = b: "${b.mods}, ${b.key}, ${b.action}"; - # A nomarchy.monitors entry -> a Hyprland `monitor` rule. Unset optional - # fields are omitted; `resolution = "disable"` collapses to the short form. - monitorRule = m: - if m.resolution == "disable" then "${m.name}, disable" - else lib.concatStringsSep ", " ( - [ m.name m.resolution (toString m.position) (toString m.scale) ] - ++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ] - ++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ] - ++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ] - ++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ] - ++ lib.optional (m.extra != "") m.extra - ); + # Monitor-rule composition (rendering + the display-profile/resolution + # overlays) lives in ./monitor-rules.nix — pure, so + # checks.display-profiles unit-tests the overlay semantics directly. + # The layering story (profiles replace whole-by-name, menu resolution + # picks apply field-level, disable-guard) is documented there. + monitorLib = import ./monitor-rules.nix { inherit lib; }; + monitorRule = monitorLib.rule; - # Menu-remembered per-output resolutions (settings.monitors: output-name -> - # "WxH@R", written instantly by the Display menu — see rofi.nix) overlaid onto - # the declarative nomarchy.monitors by name: a declared output keeps its - # position/scale/etc and only its resolution changes; an output covered solely - # by the `,preferred,auto,1` wildcard (e.g. the laptop's built-in panel) - # becomes a new rule with default position/scale. The menu writes the in-flake - # state with --no-switch (no rebuild) and applies the mode live via hyprctl; - # this bakes the same choice on the next rebuild — the monitor twin of the - # keyboard graduation. The menu pick wins over a hand-set resolution (it's the - # explicit live action); other declared fields are untouched. - resOverrides = config.nomarchy.settings.monitors; - # Skeleton for an output that's only menu-set (mirrors the monitorType defaults). - monitorDefaults = { - resolution = "preferred"; position = "auto"; scale = 1; - transform = null; mirror = null; bitdepth = null; vrr = null; extra = ""; + # The night-light pattern: nomarchy-display-profile applies rules live + # via hyprctl and writes settings.displayProfile with --no-switch; + # resolve bakes the same choice here at the next rebuild. + profiles = config.nomarchy.displayProfiles; + resolvedMonitors = monitorLib.resolve { + base = config.nomarchy.monitors; + inherit profiles; + active = config.nomarchy.settings.displayProfile or ""; + resOverrides = config.nomarchy.settings.monitors; }; - declaredNames = map (m: m.name) config.nomarchy.monitors; - resolvedMonitors = - map (m: if resOverrides ? ${m.name} - then m // { resolution = resOverrides.${m.name}; } - else m) - config.nomarchy.monitors - ++ lib.mapAttrsToList - (name: res: monitorDefaults // { inherit name; resolution = res; }) - (lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides); + + # Profile applier — on PATH only when profiles are declared (the menu + # row self-gates on it). apply: the profile's rules live via hyprctl + + # the in-flake state write; base: clear the state, then hyprctl reload + # re-applies the generated config's rules. + displayProfileTool = pkgs.writeShellScriptBin "nomarchy-display-profile" '' + case "''${1:-}" in + list) +${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.attrNames profiles)} ;; + active) + cur=$(${sync} get settings.displayProfile 2>/dev/null) || cur= + echo "''${cur:-none}" ;; + apply) + case "''${2:-}" in +${lib.concatStringsSep "\n" (lib.mapAttrsToList + (name: entries: + " ${lib.escapeShellArg name})\n" + + lib.concatMapStringsSep "\n" + (m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1") + entries + + " ;;") + profiles)} + *) echo "unknown profile '$2' — try: nomarchy-display-profile list" >&2; exit 64 ;; + esac + ${sync} --quiet set settings.displayProfile "$2" --no-switch + notify-send "Display profile" "Applied: $2" ;; + base) + ${sync} --quiet set settings.displayProfile "" --no-switch + hyprctl reload >/dev/null 2>&1 + notify-send "Display profile" "Base layout restored." ;; + *) + echo "usage: nomarchy-display-profile [list|active|apply |base]" >&2 + exit 64 ;; + esac + ''; # Candidate layouts offered by the new-keyboard picker: the session # layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout @@ -359,5 +374,7 @@ in [ pkgs.nwg-displays ] # The new-keyboard watcher on PATH (when enabled) so it's discoverable and # runnable by name for debugging — Hyprland still exec-once's it by store path. - ++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch; + ++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch + # The display-profile switcher (the menu's Profiles row gates on it). + ++ lib.optional (config.nomarchy.hyprland.enable && profiles != { }) displayProfileTool; } diff --git a/modules/home/monitor-rules.nix b/modules/home/monitor-rules.nix new file mode 100644 index 0000000..b440234 --- /dev/null +++ b/modules/home/monitor-rules.nix @@ -0,0 +1,67 @@ +# Pure monitor-rule composition, split out of hyprland.nix so +# checks.display-profiles can exercise the overlay semantics directly — +# no Home Manager evaluation, no state-file fixture. +{ lib }: + +rec { + # Skeleton for an entry built outside the option type (mirrors the + # monitorType defaults in options.nix). + defaults = { + resolution = "preferred"; position = "auto"; scale = 1; + transform = null; mirror = null; bitdepth = null; vrr = null; extra = ""; + }; + + # An entry -> a Hyprland `monitor` rule. Unset optional fields are + # omitted; `resolution = "disable"` collapses to the short form. + rule = m: + if m.resolution == "disable" then "${m.name}, disable" + else lib.concatStringsSep ", " ( + [ m.name m.resolution (toString m.position) (toString m.scale) ] + ++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ] + ++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ] + ++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ] + ++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ] + ++ lib.optional (m.extra != "") m.extra + ); + + # The full overlay, three layers: + # + # 1. base (nomarchy.monitors) with the ACTIVE display profile's entries + # (settings.displayProfile naming a nomarchy.displayProfiles key) + # replacing base entries WHOLE, by name — a profile entry is a + # complete statement about that output. Outputs a profile doesn't + # name keep their base rules; a cleared/unknown/non-string value + # ("" after `nomarchy-display-profile base`, or hand-edited junk — + # the validator only warns) means base config only. + # + # 2. Menu-remembered per-output resolutions (settings.monitors: + # output-name -> "WxH@R", written instantly by the Display menu) + # overlaid field-level by name: a declared output keeps its + # position/scale/etc and only its resolution changes; an output + # covered solely by the `,preferred,auto,1` wildcard (e.g. the + # laptop's built-in panel) becomes a new rule with default + # position/scale. The menu pick wins over a hand-set resolution + # (it's the explicit live action) — EXCEPT onto disabled entries: a + # stale pick, made while the output was enabled, must not resurrect + # a panel the active profile (or the base config) disables. + # + # 3. Anything still uncovered falls to the wildcard (hyprland.nix + # prepends it). + resolve = { base, profiles, active, resOverrides }: + let + activeName = if builtins.isString active then active else ""; + profileEntries = profiles.${activeName} or [ ]; + profileNames = map (m: m.name) profileEntries; + baseMonitors = + lib.filter (m: ! lib.elem m.name profileNames) base + ++ profileEntries; + declaredNames = map (m: m.name) baseMonitors; + in + map (m: if resOverrides ? ${m.name} && m.resolution != "disable" + then m // { resolution = resOverrides.${m.name}; } + else m) + baseMonitors + ++ lib.mapAttrsToList + (name: res: defaults // { inherit name; resolution = res; }) + (lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides); +} diff --git a/modules/home/options.nix b/modules/home/options.nix index 10dd1f2..577b8d0 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -237,6 +237,31 @@ in ''; }; + displayProfiles = lib.mkOption { + type = lib.types.attrsOf (lib.types.listOf monitorType); + default = { }; + example = lib.literalExpression '' + { + docked = [ + { name = "eDP-1"; resolution = "disable"; } + { name = "DP-3"; position = "0x0"; } + { name = "DP-4"; position = "auto-right"; } + ]; + undocked = [ { name = "eDP-1"; position = "0x0"; } ]; + } + ''; + description = '' + Named display layouts for the same outputs (docked, undocked, …), + each a list of nomarchy.monitors-style entries. Switch from the menu + (System › Display › Profiles) or `nomarchy-display-profile apply + `: the profile's rules apply instantly via hyprctl and the + choice persists in the in-flake state (settings.displayProfile), so + the next rebuild bakes the active profile's entries over + nomarchy.monitors by name. Outputs a profile doesn't name keep + their base rules. + ''; + }; + # ── Component toggles ────────────────────────────────────────── hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; }; waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; }; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index bb592df..2d08ab7 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -303,13 +303,17 @@ ${themeRows} || { notify-send "Display" "Hyprland is not running."; exit 0; } mons=$(hyprctl monitors -j) - # Choose the output (skip the chooser when only one is connected). - if [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then + # Choose the output (skip the chooser when only one is connected and + # there are no profiles — the Profiles row needs the chooser to live in). + profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1 + if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then name=$( { + [ -n "$profs" ] && printf 'Profiles ›\n' printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"' printf '%s\n' "$BACK" } | rofi -dmenu -p Display ) || exit 0 [ "$name" = "$BACK" ] && exec "$0" system + [ "$name" = "Profiles ›" ] && exec "$0" display-profile name=''${name%% ·*} # strip the " · WxH@R" hint else name=$(printf '%s' "$mons" | jq -r '.[0].name') @@ -377,6 +381,27 @@ ${themeRows} *"Record screen"*) exec nomarchy-record start screen ;; esac ;; + display-profile) + # Named multi-output layouts (nomarchy.displayProfiles). Applying is + # instant (hyprctl per entry, via nomarchy-display-profile) and + # persisted in-flake (settings.displayProfile) so the next rebuild + # bakes it. "Base layout" clears the state and hyprctl-reloads. + command -v nomarchy-display-profile >/dev/null 2>&1 \ + || { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; } + active=$(nomarchy-display-profile active) + choice=$( { + nomarchy-display-profile list | while IFS= read -r p; do + if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi + done + printf 'Base layout\n' + printf '%s\n' "$BACK" + } | rofi -dmenu -p Profiles ) || exit 0 + case "$choice" in + "$BACK") exec "$0" display ;; + "Base layout") exec nomarchy-display-profile base ;; + *) exec nomarchy-display-profile apply "''${choice#[●○] }" ;; + esac ;; + colorpicker) # Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex # in the confirmation toast. Esc/cancel exits silently. Self-gated @@ -610,7 +635,7 @@ ${themeRows} esac ;; *) - echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2 + echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2 exit 64 ;; esac ''; diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index b655b9c..f289a1f 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -34,6 +34,16 @@ # { name = "HDMI-A-1"; position = "auto-right"; } # ]; # names from `hyprctl devices` # + # nomarchy.displayProfiles = { # named layouts for the SAME outputs — + # docked = [ # switch: System › Display › Profiles + # { name = "eDP-1"; resolution = "disable"; } + # { name = "DP-3"; position = "0x0"; } + # { name = "DP-4"; position = "auto-right"; } + # ]; + # undocked = [ { name = "eDP-1"; position = "0x0"; } ]; + # }; # applied instantly + remembered in the + # # flake state (settings.displayProfile) + # # nomarchy.keyboard.devices = { # a specific keyboard's own layout # "keychron-keychron-k2" = { layout = "de"; }; # };