feat(displays): display profiles — docked/undocked switching (item 15a)
All checks were successful
Check / eval (push) Successful in 3m4s

nomarchy.displayProfiles: named layouts for the same outputs, each a
list of nomarchy.monitors-style entries. The active profile lives in
the in-flake state (settings.displayProfile) on the night-light
pattern: nomarchy-display-profile apply <name> fires the baked hyprctl
rules instantly and writes the state with --no-switch; the next rebuild
bakes the profile's entries over nomarchy.monitors whole-by-name.
Monitor-rule composition is extracted to pure monitor-rules.nix
(renderer + 3-layer resolve, incl. a disable-guard so a stale menu
resolution pick can't resurrect a profile-disabled panel, and junk
state degrading to base config instead of an eval error) — because a
writeText/toFile state fixture is unreadable at flake-check eval time,
the new checks.display-profiles unit-tests the pure function directly.
Menu: System › Display gains a self-gated "Profiles ›" row (●/○ active
mark, Base layout to clear); commented template example + README row.

Verified: V0 (flake check, forcing the new check's asserts); V1 — a
scratch downstream through lib.mkFlake builds: the generated tool
passes bash -n with correct rules (disable/vrr/float scale), the baked
hyprland.conf shows the exact expected overlay, the menu renders, and
list/usage smoke-test clean. Remains: V3 live switching on a dock
(HARDWARE-QUEUE). Slices b (hotplug auto-switch) + c (workspace
binding) stay in item 15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-05 17:52:19 +01:00
parent e02b4d8200
commit 9df18261f9
11 changed files with 271 additions and 43 deletions

View File

@@ -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 <name>|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;
}

View File

@@ -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);
}

View File

@@ -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
<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.
'';
};
# ── Component toggles ──────────────────────────────────────────
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };

View File

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