feat(display): per-profile workspace→output pins (item 15c)
All checks were successful
Check / eval (push) Successful in 3m0s
Lock bump / bump (push) Successful in 2m53s

A display profile can now pin workspaces to outputs:

  docked = {
    monitors   = [ … ];
    workspaces = { "1" = "DP-3"; "9" = "eDP-1"; };
  };

The bare list-of-monitors shape still works (either-type; hyprland.nix
normalizes — coercedTo refuses list-of-submodule sources). Pins bake as
Hyprland `workspace` rules from the active profile and apply instantly
on `nomarchy-display-profile apply`: keyword sets the session rule,
moveworkspacetomonitor moves open workspaces over. Pins from a
previously applied profile linger in-session until reload/rebuild
(hyprctl can only add) — inert if the output is absent, noted in the
applier.

V1: flake check green (checks.display-profiles now asserts
workspaceRule) + scratch-downstream eval: baked settings carry both
rule sets, the generated applier has the keyword+dispatch pairs
(bash -n), match/list correct with mixed shapes. V3 queued in
HARDWARE-QUEUE (rides the dock test). Item 15 complete — deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-05 23:09:14 +01:00
parent 096440a7d7
commit edd0bd38ce
9 changed files with 121 additions and 62 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = <ws>, monitor:<output>` 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
<name>`: 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 <name>`:
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.
'';