diff --git a/README.md b/README.md index 49bfd6d..b2d1bb3 100644 --- a/README.md +++ b/README.md @@ -228,7 +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. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile automatically | +| `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. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile automatically. A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) — moved instantly on switch, baked as Hyprland `workspace` rules | | `nomarchy.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently | | `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 282e558..609fd18 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -201,43 +201,6 @@ label `nix-kvm`), then an agent uncomments the workflow's `vm-checks` job: the `checks.*` VM suite + real toplevel/HM builds on every push (also upgrades item 14's bump gate from eval-only to the full suite). -### 15. Display profiles — docked/undocked switching -ROADMAP § Display / monitor management, remaining: profile switching of -the *same* outputs. **Plan (designed 2026-07-05, iteration #27):** -- **Option:** `nomarchy.displayProfiles` (HM) = `attrsOf (listOf - monitorType)` — each profile is a named list reusing the existing - monitor submodule verbatim (no second schema). Example: `docked = - [ { name="eDP-1"; resolution="disable"; } { name="DP-3"; … } ]`. -- **State:** `settings.displayProfile = ""` (absent = base - config), menu-written with `--no-switch` — the night-light pattern: - instant now, baked at the next rebuild. Add the key to - nomarchy-theme-sync's known-settings list (item 11's validator warns - on unknowns). -- **Rebuild overlay** (hyprland.nix): active profile's entries replace - base `nomarchy.monitors` entries whole, by name; the existing - `settings.monitors` resolution overlay then applies field-level on - top, EXCEPT onto entries whose profile resolution = "disable" (a - stale resolution pick must not resurrect a disabled panel). -- **Instant apply** (menu): System › Display gains a self-gated - "Profile: " row (only when profiles are declared) → pick → - `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) ✓ 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) ✓ - shipped 2026-07-05 (iteration #29): hotplug auto-switch — `match` - subcommand (exact set, else unambiguous largest subset, ties = - none) + a poll watcher (exec-once, the keyboard-watcher pattern) - gated live on `settings.displayProfileAuto` (a separate boolean, - NOT an "auto" pseudo-profile, so auto-picks persist concrete - profiles that rebuilds bake) + menu Auto-switch row — V3 rides the - slice-a dock test; (c) optional per-profile - `workspaces = { "1" = "DP-3"; }` → Hyprland `workspace` keywords. -- **Non-goals:** no kanshi (fights Hyprland's output management — - decided in ROADMAP), nwg-displays stays a find-the-values helper. - ### 18. "nomarchy" control center + first-boot welcome `[big]` ROADMAP § control center. A single front-end over the common toggles on the same `nomarchy-theme-sync` surface, + a first-boot guided diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 6139718..e737093 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -153,6 +153,13 @@ QA machine), the **T14s** (webcam case). overlay); "Base layout" restores; a menu resolution pick made earlier must NOT re-enable a profile-disabled panel after the next rebuild. +- [ ] **Display profiles workspace pins, slice c (item 15)** — give the + docked profile `workspaces = { "1" = ""; "9" = "eDP-1"; }`: + applying the profile moves open workspaces 1/9 to those outputs + immediately, new visits land there too, and the pins survive a + relogin (baked `workspace` rules). Switching to a profile without + pins leaves workspaces where they are (stale session pins are + expected until reload/rebuild — noted in the applier). - [ ] **Display profiles auto-switch, slice b (item 15)** — same setup, then menu › Profiles › Auto-switch on: plugging the dock/monitor applies the matching profile within ~3s (toast names it); diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 2ff9754..6c1e021 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,27 @@ Template: --- +## 2026-07-05 — display-profile workspace pins (iteration #47, item 15c) +- **Task:** item 15 slice (c), the last one — per-profile workspace → + output pinning. Item 15 deleted (a/b/c all shipped; V3s queued). +- **Did:** a profile is now `{ monitors; workspaces = { "1" = "DP-3"; }; }` + (bare list still accepted — either-type + normalization in + hyprland.nix; coercedTo refuses list-of-submodule sources). Pins bake + as Hyprland `workspace` rules and apply instantly on profile switch + (`hyprctl keyword workspace` + `dispatch moveworkspacetomonitor`). + monitor-rules.nix gained pure `workspaceRule`; template + README + updated. +- **Verified:** V1 flake check green (incl. the extended + checks.display-profiles assert) + scratch-downstream eval: baked + settings render both rule sets, the generated applier carries the + keyword+dispatch pairs (bash -n OK), match/list still correct with + mixed profile shapes. +- **Pending:** V3 queued (workspace-pins entry in HARDWARE-QUEUE, rides + the slice-a dock test). Stale-pin note documented in the applier. +- **Next suggestion:** backlog is now human/event-gated (28 taste + calls, 14 Monday, 18 Decision, 20 KVM) — idle the loop or triage + PROPOSED. + ## 2026-07-05 — icon-weight A/B for the human call (iteration #46, item 28) - **Task:** item 28's last `[human]` blocker — make the color-vs- symbolic menu-icon decision cheap. diff --git a/flake.nix b/flake.nix index 702d147..57889e7 100644 --- a/flake.nix +++ b/flake.nix @@ -371,7 +371,9 @@ && 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)"; + "junk active profile must degrade to base config (with the pick applied)" + && expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3") + "workspace pin did not render as a Hyprland workspace rule"; in assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out"; diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 98f3026..10b6f98 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -85,14 +85,27 @@ let # 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; + # Profiles normalized to { monitors; workspaces; } — the option also + # accepts the original bare-list-of-monitors shape. + profiles = lib.mapAttrs + (_: p: if builtins.isList p then { monitors = p; workspaces = { }; } else p) + config.nomarchy.displayProfiles; resolvedMonitors = monitorLib.resolve { base = config.nomarchy.monitors; - inherit profiles; + profiles = lib.mapAttrs (_: p: p.monitors) profiles; active = config.nomarchy.settings.displayProfile or ""; resOverrides = config.nomarchy.settings.monitors; }; + # The active profile's workspace→output pins, baked as `workspace` + # rules below (same rebuild path as the monitor overlay; the applier + # covers the instant half). Same junk-state guard as resolve. + activeProfile = + let a = config.nomarchy.settings.displayProfile or ""; + in profiles.${if builtins.isString a then a else ""} or { workspaces = { }; }; + profileWorkspaceRules = + lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces; + # 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 @@ -107,11 +120,20 @@ ${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib. apply) case "''${2:-}" in ${lib.concatStringsSep "\n" (lib.mapAttrsToList - (name: entries: + (name: p: " ${lib.escapeShellArg name})\n" + lib.concatMapStringsSep "\n" (m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1") - entries + p.monitors + # Workspace pins: the keyword sets the session rule, the dispatch + # moves an already-open workspace over. Pins from a previously + # applied profile linger until reload/rebuild (hyprctl can only + # add) — harmless, a rule naming an absent output is inert. + + lib.concatStrings (lib.mapAttrsToList + (ws: out: + "\n hyprctl keyword workspace ${lib.escapeShellArg (monitorLib.workspaceRule ws out)} >/dev/null 2>&1" + + "\n hyprctl dispatch moveworkspacetomonitor ${lib.escapeShellArg ws} ${lib.escapeShellArg out} >/dev/null 2>&1") + p.workspaces) + " ;;") profiles)} *) echo "unknown profile '$2' — try: nomarchy-display-profile list" >&2; exit 64 ;; @@ -147,7 +169,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList fi done < <(printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg (lib.mapAttrsToList - (name: entries: "${name}:${lib.concatMapStringsSep " " (m: m.name) entries}") + (name: p: "${name}:${lib.concatMapStringsSep " " (m: m.name) p.monitors}") profiles)}) if [ -n "$exact" ] && [ -z "$exactdup" ]; then echo "$exact"; exit 0; fi if [ -n "$best" ] && [ -z "$dup" ]; then echo "$best"; exit 0; fi @@ -295,6 +317,10 @@ in monitor = lib.mkDefault ([ ",preferred,auto,1" ] ++ map monitorRule resolvedMonitors); + # The active display profile's workspace→output pins (a list, so + # downstream `workspace = [...]` rules concatenate). + workspace = profileWorkspaceRules; + # exec-once is a list at normal priority, so downstream additions # CONCATENATE (your autostart runs alongside ours) rather than # replace. Same for the bind* lists below. diff --git a/modules/home/monitor-rules.nix b/modules/home/monitor-rules.nix index b440234..44ef1bc 100644 --- a/modules/home/monitor-rules.nix +++ b/modules/home/monitor-rules.nix @@ -24,6 +24,11 @@ rec { ++ lib.optional (m.extra != "") m.extra ); + # A profile's workspace pinning ({ "1" = "DP-3"; }) -> a Hyprland + # `workspace` rule. Only the active profile's pins are rendered + # (hyprland.nix); a workspace rule naming an absent output is inert. + workspaceRule = ws: out: "${ws}, monitor:${out}"; + # The full overlay, three layers: # # 1. base (nomarchy.monitors) with the ACTIVE display profile's entries diff --git a/modules/home/options.nix b/modules/home/options.nix index 3066bc8..be198dc 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -55,6 +55,33 @@ let }; }; }; + + # One display profile: a monitor layout plus optional workspace→output + # pinning. A bare list of monitor entries still works (the original + # shape) — hyprland.nix normalizes it to { monitors = […]; }. + # (either, not coercedTo: coercedTo refuses list-of-submodule sources.) + displayProfileType = lib.types.either + (lib.types.listOf monitorType) + (lib.types.submodule { + options = { + monitors = lib.mkOption { + type = lib.types.listOf monitorType; + default = [ ]; + description = "nomarchy.monitors-style entries; each replaces the base entry for the same output whole."; + }; + workspaces = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { "1" = "DP-3"; "9" = "eDP-1"; }; + description = '' + Workspace → output pinning while this profile is active + (Hyprland `workspace = , monitor:` rules). + Applied instantly on profile switch (existing workspaces are + moved over) and baked at the next rebuild. + ''; + }; + }; + }); in { options.nomarchy = { @@ -284,25 +311,30 @@ in }; displayProfiles = lib.mkOption { - type = lib.types.attrsOf (lib.types.listOf monitorType); + type = lib.types.attrsOf displayProfileType; default = { }; example = lib.literalExpression '' { - docked = [ - { name = "eDP-1"; resolution = "disable"; } - { name = "DP-3"; position = "0x0"; } - { name = "DP-4"; position = "auto-right"; } - ]; + docked = { + monitors = [ + { name = "eDP-1"; resolution = "disable"; } + { name = "DP-3"; position = "0x0"; } + { name = "DP-4"; position = "auto-right"; } + ]; + workspaces = { "1" = "DP-3"; "9" = "DP-4"; }; + }; 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 + Named display layouts for the same outputs (docked, undocked, …): + a list of nomarchy.monitors-style entries, or an attrset with + `monitors` plus optional `workspaces` (workspace → output pinning + while the profile is active). 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. ''; diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index cdce838..a28a862 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -40,12 +40,15 @@ # ]; # (class from `hyprctl clients`) # # 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"; } ]; + # docked = { # switch: System › Display › Profiles + # monitors = [ + # { name = "eDP-1"; resolution = "disable"; } + # { name = "DP-3"; position = "0x0"; } + # { name = "DP-4"; position = "auto-right"; } + # ]; + # workspaces = { "1" = "DP-3"; "9" = "DP-4"; }; # optional ws→output pins + # }; + # undocked = [ { name = "eDP-1"; position = "0x0"; } ]; # bare list = monitors only # }; # applied instantly + remembered in the # # flake state (settings.displayProfile); # # the menu's Auto-switch row makes dock/