Files
Nomarchy/modules/home/hyprland.nix
Bernardo Magri baf2cd2e13
All checks were successful
Check / eval (push) Successful in 4m29s
refactor(display): extract the shared display-transition + keyboard tools (#150)
The full nomarchy-display-transition (dock/undock/enable — logging, the
lid-inhibitor gate, restore_keyboards, the failure dump) and the
nomarchy-keyboard-layout helper it calls move out of hyprland.nix into
leaf files (modules/home/display-transition.nix, keyboard-tool.nix),
imported by both hyprland.nix and idle.nix.

This kills the lossy ~22-line mini idle.nix carried under the same binary
name to dodge a circular import. The hypridle wake path (after_sleep /
DPMS on-resume) now runs the real undock — restore_keyboards after a
reload and the evidence dump on enable-failure — instead of a bare
keyword+reload, and the two copies can no longer drift.

Behaviour on the hyprland side is unchanged: the built
nomarchy-display-transition and nomarchy-keyboard-layout have
byte-identical store paths on HEAD vs this change (verbatim extraction).

Verify: all four files parse; home activationPackage + system toplevel
build; checks.docking-ux green (bash -n + safety-feature greps on the
extracted scripts). The tools/monitor-fallback.nix dock harness is red on
main already (the #148 reload-mid-dock assertion — identical failure on
clean HEAD; filed BACKLOG #154), so it could not gate this. The idle
wake-path behaviour change (full undock, incl. dock-intent clearing) is
V3 pending: on-hardware suspend/resume + zero-output rescue queued in
HARDWARE-QUEUE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:17:57 +01:00

1047 lines
50 KiB
Nix
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hyprland — fully driven by config.nomarchy.theme (state.json).
# Gaps, borders and colors are baked from the JSON at eval time; theme
# changes arrive via `home-manager switch`.
{ config, lib, pkgs, ... }:
let
t = config.nomarchy.theme;
c = t.colors;
inherit (config.nomarchy.lib) rgb rgba;
# nomarchy-state-sync — the in-flake state writer the keyboard watcher uses
# to remember per-device layouts (same path night-light's toggle takes).
sync = lib.getExe config.nomarchy.package;
# swayosd's `--input-volume mute-toggle` draws the OSD but never flips the
# source mute (verified on hardware: wpctl toggles the state, swayosd's
# input-mute path is a no-op — unlike its working output-mute path). Do the
# real toggle with wpctl, then re-render the same icon + volume-bar OSD the
# native input path would, reading the resulting state back from wpctl:
# --custom-progress is the mic level (0.0-1.0), --custom-icon the
# muted/active glyph. Pure bash builtins for parsing so the script needs no
# PATH beyond the two pinned tools.
#
# Hardware mic-mute LEDs (ThinkPad platform::micmute, HDA *::micmute, …)
# are driven by the kernel's audio-micmute trigger, which tracks ALSA —
# not PipeWire software mute. Sync the LED from the resulting mute state
# when the node is group-writable (udev rule in modules/nixos/default.nix).
micMute = pkgs.writeShellScript "nomarchy-mic-mute" ''
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
case "$out" in
*MUTED*) icon=microphone-sensitivity-muted-symbolic; led=1 ;;
*) icon=microphone-sensitivity-high-symbolic; led=0 ;;
esac
for led_dir in /sys/class/leds/*micmute* /sys/class/leds/*::micmute; do
[ -e "$led_dir/brightness" ] || continue
if [ -w "$led_dir/brightness" ]; then
# Drop the kernel trigger so a stuck audio-micmute state cannot
# override our write (verified: ALSA Capture mute alone does not
# flip platform::micmute on ThinkPad T14s).
[ -w "$led_dir/trigger" ] && echo none > "$led_dir/trigger" 2>/dev/null || true
echo "$led" > "$led_dir/brightness" 2>/dev/null || true
fi
done
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
'';
# Launch-or-focus (nomarchy.launchOrFocus, item 17): focus the app's
# window if one exists (case-insensitive class match), launch it
# otherwise. Self-gates: an uninstalled command notifies instead of
# failing silently, so a bind survives the app being removed from the
# suite. rofi.nix renders the same entries into the SUPER+? cheatsheet.
focusOrLaunch = pkgs.writeShellScriptBin "nomarchy-focus-or-launch" ''
class="''${1:-}"; shift || true
[ -n "$class" ] && [ $# -gt 0 ] || {
echo "usage: nomarchy-focus-or-launch <class> <command...>" >&2; exit 64
}
if hyprctl clients -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg c "$class" \
'any(.[]; (.class | ascii_downcase) == ($c | ascii_downcase))' >/dev/null; then
exec hyprctl dispatch focuswindow "class:(?i)^$class\$"
fi
command -v "$1" >/dev/null 2>&1 \
|| { notify-send "Launch" "$1 is not installed."; exit 0; }
exec "$@"
'';
lofEntries = map
(e: e // {
command = if e.command == "" then lib.toLower e.class else e.command;
})
config.nomarchy.launchOrFocus;
lofBinds = map
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
lofEntries;
# SUPER+1..9,0 / SUPER+SHIFT+1..9,0 workspace binds, generated
# (the 0 key is workspace 10).
workspaceBinds = builtins.concatLists (builtins.genList
(i:
let
ws = toString (i + 1);
key = if i == 9 then "0" else ws;
in [
"$mod, ${key}, workspace, ${ws}"
"$mod SHIFT, ${key}, movetoworkspace, ${ws}"
])
10);
# The keyboard binds — single source shared with the SUPER+? cheatsheet
# (rofi.nix renders the same list). Edit them in ./keybinds.nix.
keybinds = import ./keybinds.nix;
mkBind = b: "${b.mods}, ${b.key}, ${b.action}";
# 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;
# 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 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;
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;
# #127 dump tool first (no transition dep); wake is wired after transition.
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
# The full dock/undock/enable transition primitive lives in its own file
# so idle.nix shares this exact safety-critical copy (#150), not a mini.
displayTransitionTool = import ./display-transition.nix {
inherit pkgs sync keyboardTool displayDumpTool;
};
displayWakeTool =
(import ./display-tools.nix {
inherit pkgs;
displayTransition = displayTransitionTool;
}).displayWakeTool;
# 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: p:
let
isInternal = m: builtins.match "^(eDP|LVDS|DSI).*" m.name != null;
enabled = lib.filter (m: m.resolution != "disable") p.monitors;
disabled = lib.filter (m: m.resolution == "disable") p.monitors;
internalOff = lib.findFirst isInternal null disabled;
dockTarget = lib.findFirst (m: ! isInternal m) null enabled;
safeDock = internalOff != null && dockTarget != null;
ordinaryEnabled = if safeDock
then lib.filter (m: m.name != dockTarget.name) enabled
else enabled;
ordinaryDisabled = if safeDock
then lib.filter (m: m.name != internalOff.name) disabled
else disabled;
in
" ${lib.escapeShellArg name})\n"
# Outputs that will remain active always come first. If this is a
# clamshell profile, the helper performs target-enable + workspace
# handoff + internal-disable as one batch, avoiding the zero-output and
# dangling-workspace states the old per-rule loop could create.
+ lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
ordinaryEnabled
+ lib.optionalString safeDock
("\n ${displayTransitionTool}/bin/nomarchy-display-transition dock "
+ lib.escapeShellArg internalOff.name + " "
+ lib.escapeShellArg dockTarget.name + " "
+ lib.escapeShellArg (monitorRule dockTarget)
+ " >/dev/null 2>&1 || { notify-send \"Display profile\" \"Could not safely disable the laptop panel.\"; exit 1; }")
+ lib.optionalString (ordinaryDisabled != [ ]) "\n"
+ lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
ordinaryDisabled
# 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 ;;
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." ;;
match)
# Which profile fits these connected outputs? (args = output names,
# e.g. `match $(hyprctl monitors all -j | jq -r '.[].name')`.)
# An exact set match wins; else the largest profile whose named
# outputs are all connected; any tie prints nothing (exit 1) the
# auto-switch watcher must never flap between ambiguous layouts.
shift
[ "$#" -gt 0 ] || exit 1
con=" $* " ncon=$#
exact= exactdup= best= bestn=0 dup=
while IFS=: read -r p names; do
[ -n "$p" ] || continue
n=0 ok=1
for o in $names; do
case "$con" in *" $o "*) n=$((n+1)) ;; *) ok=; break ;; esac
done
[ -n "$ok" ] || continue
if [ "$n" -eq "$ncon" ]; then
if [ -n "$exact" ]; then exactdup=1; else exact=$p; fi
fi
if [ "$n" -gt "$bestn" ]; then best=$p bestn=$n dup=
elif [ "$n" -eq "$bestn" ] && [ "$n" -gt 0 ]; then dup=1
fi
done < <(printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg
(lib.mapAttrsToList
(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
exit 1 ;;
*)
echo "usage: nomarchy-display-profile [list|active|apply <name>|base|match <output>...]" >&2
exit 64 ;;
esac
'';
# Hyprland hotplug watcher: owns the safety-critical dock lifecycle plus
# optional profile auto-switching. A low-level logind lid-switch inhibitor
# is acquired as soon as an external appears. On final external removal,
# the internal panel is enabled and workspaces restored before the watcher
# waits for a physically open lid and releases the inhibitor. The normal
# undocked lid-close path is therefore unchanged after release.
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
TRANSITION=${displayTransitionTool}/bin/nomarchy-display-transition
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
inhibitor_pid=
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
awaiting_lid_open=
undock_pending=
undock_tries=0
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
internal_output() {
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return
fi
hyprctl monitors all -j 2>/dev/null \
| jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty'
}
external_outputs() {
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
hyprctl monitors all -j 2>/dev/null \
| jq -r --arg m "$NOMARCHY_INTERNAL_OUTPUT" '.[].name | select(. != $m)'
return
fi
hyprctl monitors all -j 2>/dev/null \
| jq -r '.[].name | select(test("^(eDP|LVDS|DSI)") | not)'
}
lid_open() {
saw=
if [ -n "''${NOMARCHY_LID_STATE_FILE:-}" ]; then
files="$NOMARCHY_LID_STATE_FILE"
else
files=/proc/acpi/button/lid/*/state
fi
for state in $files; do
[ -r "$state" ] || continue
saw=1
${pkgs.gnugrep}/bin/grep -qi open "$state" && return 0
done
# Desktops/VMs without a lid are safe to treat as open.
[ -z "$saw" ]
}
owned_inhibitor_pid() {
pid="''${1:-}"
case "$pid" in *[!0-9]*|"") return 1 ;; esac
[ -r "/proc/$pid/cmdline" ] || return 1
${pkgs.coreutils}/bin/tr '\0' ' ' < "/proc/$pid/cmdline" \
| ${pkgs.gnugrep}/bin/grep -qE \
'nomarchy-dock-lid-inhibitor|Safe dock/undock display transition'
}
stop_inhibitor_group() {
pid="''${1:-}"
owned_inhibitor_pid "$pid" || return 0
# The inhibitor and its sleep command share a private process group,
# so both lose the inhibitor fd even after an unclean watcher death.
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
}
clear_stale_inhibitor() {
[ -r "$inhibitor_file" ] || return
IFS= read -r old < "$inhibitor_file" || old=
if owned_inhibitor_pid "$old"; then
stop_inhibitor_group "$old"
log "lid-inhibitor=stale-cleaned pid=$old"
fi
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
}
acquire_inhibitor() {
if [ -n "$inhibitor_pid" ] && kill -0 "$inhibitor_pid" 2>/dev/null; then return; fi
${pkgs.util-linux}/bin/setsid ${pkgs.systemd}/bin/systemd-inhibit \
--what=handle-lid-switch --mode=block --who=Nomarchy \
--why="Safe dock/undock display transition" \
${pkgs.bash}/bin/bash -c \
'while :; do ${pkgs.coreutils}/bin/sleep 86400; done' \
nomarchy-dock-lid-inhibitor &
inhibitor_pid=$!
printf '%s\n' "$inhibitor_pid" > "$inhibitor_file"
tries=0
while [ "$tries" -lt 20 ]; do
if ! kill -0 "$inhibitor_pid" 2>/dev/null; then break; fi
if ${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
>/dev/null; then
log "lid-inhibitor=acquired pid=$inhibitor_pid"
return 0
fi
tries=$((tries+1)); sleep 0.1
done
log "lid-inhibitor=acquire-failed"
stop_inhibitor_group "$inhibitor_pid"
inhibitor_pid=
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
return 1
}
release_inhibitor() {
[ -n "$inhibitor_pid" ] || return
stop_inhibitor_group "$inhibitor_pid"
wait "$inhibitor_pid" 2>/dev/null || true
log "lid-inhibitor=released"
inhibitor_pid=
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
}
cleanup() { release_inhibitor; }
trap cleanup EXIT INT TERM
clear_stale_inhibitor
auto_on() {
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
paint_outputs() {
# awww does not inherit the current image when Hyprland adds an output,
# and it only paints outputs its daemon has already registered which
# lags the compositor's own monitoradded by an unpredictable margin.
# Re-apply a few times over the first few seconds rather than betting
# on one delay. Backgrounded so profile matching never waits on paint.
for delay in 0.5 1.5 3; do
sleep "$delay"
${sync} --quiet wallpaper >/dev/null 2>&1 || true
done
}
check_monitors() {
cur=$(outputs)
[ -n "$cur" ] || return
auto_on || return
command -v nomarchy-display-profile >/dev/null 2>&1 || return
target=$(nomarchy-display-profile match $cur) || target="base"
if [ "$target" = "base" ]; then
[ "$(nomarchy-display-profile active)" = "none" ] && return
nomarchy-display-profile base
else
[ "$target" = "$(nomarchy-display-profile active)" ] && return
nomarchy-display-profile apply "$target"
fi
}
# Entering dock mode is the mirror of the automatic undock below: the
# session already comes back to the panel by itself when the last
# external leaves, so an arriving external has to take it the other way
# or the pair is only half-automatic and the menu has to finish the job.
# A display profile is a deliberate, more specific layout, so it wins
# the caller only reaches here when none is in effect.
auto_dock() {
internal=$(internal_output)
target=$(external_outputs | ${pkgs.coreutils}/bin/head -n 1)
[ -n "$internal" ] && [ -n "$target" ] || return
# `monitors` (unlike `monitors all`) lists only enabled outputs, so the
# panel still being there is what "not docked yet" means. Re-docking
# an already-docked session would move nothing and disable nothing.
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
if "$TRANSITION" dock "$internal" "$target" "$target, preferred, auto, 1"; then
log "auto-dock internal=$internal target=$target result=ok"
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Display" \
"Docked every laptop workspace moved to $target; laptop panel off." \
2>/dev/null || true
else
log "auto-dock internal=$internal target=$target result=failed"
fi
}
# Dock mode has to be re-asserted, not just entered (#142). `reconcile`
# below diffs the *connected* set (`monitors all`, which lists disabled
# outputs too), so it is blind by construction to a panel being switched
# back **on**: the connected set does not change, and every rebuild does
# exactly that Hyprland reloads the rewritten config, the catch-all
# `monitor=,preferred,auto,1` re-lights the internal, and a workspace lands
# on it. So the invariant is checked on every tick instead of on events:
# if the user's recorded intent is dock and the panel is up while an
# external is present, put it back.
#
# `auto_dock` is already idempotent it returns early unless the internal
# is enabled so this costs one `hyprctl monitors` per tick and does
# nothing in the overwhelmingly common case. `settings.display.dockMode`
# is the user's recorded intent written by the menu and by the dock
# transition itself (`set_dock_intent`, which `auto_dock` above reaches
# via `$TRANSITION dock`) and that alone is what this is gated on. It
# used to also require `auto_on` (`settings.displayProfileAuto`), but that
# toggle only controls *named-profile* auto-switching; gating the dock
# invariant on it meant enforcement never fired for anyone who had not
# separately opted into auto profiles, which is most users. A
# `home-manager` activation's `hyprctl reload` re-applies the catch-all
# monitor rule regardless, so the panel inside a shut lid came back on
# every rebuild while docked (#148). The profile-active check right below
# is the real, narrower guard this needs: a profile is a deliberate, more
# specific layout and outranks the dock heuristic.
enforce_dock_intent() {
[ "$(${sync} get settings.display.dockMode 2>/dev/null)" = true ] || return
[ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ] || return
internal=$(internal_output)
[ -n "$internal" ] || return
# Only with an external actually present: intent must never be able to
# black out a laptop that has nowhere else to draw (#127's brick).
[ -n "$(external_outputs)" ] || return
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
log "dock-intent=true panel=$internal state=re-enabled action=re-dock"
acquire_inhibitor
auto_dock
}
# The set of connected outputs not the IPC event stream is the source
# of truth. Hyprland's socket can miss or drop events (see the loop
# below), and a lost monitoradded used to mean no docking profile, no
# wallpaper on the new screen and no audio follow until something else
# poked the watcher. `reconcile` diffs the output set against the last
# one it acted on, so an event only makes the reaction *instant* never
# necessary. Everything here is idempotent and safe to call on a tick.
#
# $1 (optional) = an output Hyprland just announced as removed. It may
# still be listed in `monitors all` while teardown settles, so exclude it
# and restore the internal panel before that lands.
reconcile() {
hint="''${1:-}"
cur=$(outputs)
[ -n "$cur" ] || return
internal=$(internal_output)
ext=$(external_outputs)
if [ -n "$hint" ]; then
cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
ext=$(printf '%s\n' "$ext" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
fi
if [ "$cur" != "$known_outputs" ]; then
added_ext=
for o in $ext; do
in_list "$known_outputs" "$o" || added_ext="$added_ext $o"
done
gone=
for o in $known_outputs; do
in_list "$cur" "$o" || gone="$gone $o"
done
known_outputs=$cur
if [ -n "$added_ext" ]; then
# A new external supersedes any undock still queued for an older
# departure: the panel is about to be disabled again on purpose.
undock_pending=
# and it supersedes a *completed* one too. Bernardo 2026-07-16:
# undocked, panel stayed dark; re-docked and undocked again and it
# came back because in between he opened the lid. `awaiting_lid_open`
# is set by every successful undock and cleared only when the external
# is gone AND the lid is open, so a clamshell undock latches it; the
# next departure then hits the `[ -z "$awaiting_lid_open" ]` guard
# below, queues nothing, and the panel never returns. His journal shows
# exactly that: no `outputs-changed removed` for the first undock at
# all, then a release the moment he lifted the lid, then a clean
# undock. The latch means "this departure is dealt with" a new
# external ends that departure, so clear it here or it outlives the
# thing it describes.
awaiting_lid_open=
[ -z "$internal" ] || acquire_inhibitor
# An external output that was not there a moment ago is an
# unambiguous physical plug whether or not we saw the IPC event,
# so it is allowed to override a manual sink choice.
command -v nomarchy-dock-audio >/dev/null 2>&1 \
&& nomarchy-dock-audio reprobe monitoradded &
paint_outputs &
log "outputs-changed added=$added_ext audio-reprobe=scheduled"
fi
# awaiting_lid_open marks an undock already completed for this
# departure the guard is what keeps event + tick from doubling up.
# The attempt itself is deliberately NOT made here: it is queued and
# driven by the invariant below, so a single lost or failed attempt
# can never be what costs the panel.
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
&& [ -z "$awaiting_lid_open" ]; then
undock_pending=1
undock_tries=0
log "outputs-changed removed=$gone transition=undock-queued"
fi
check_monitors
if [ -n "$added_ext" ] \
&& [ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ]; then
auto_dock
fi
fi
# Safety invariants, re-checked regardless of whether the set moved.
#
# A dark panel is the worst outcome this watcher can produce, so the
# undock is an invariant driven to completion on the tick not a
# one-shot fired off the removal event, which a dropped IPC event or a
# transient hyprctl failure would then turn into a black laptop. (The
# transition itself no longer *expects* to fail: what used to fail
# every time was an inert keyword, fixed at the source above. This
# stays as the backstop it was always meant to be.)
if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal"; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=ok attempts=$((undock_tries+1))"
else
undock_tries=$((undock_tries+1))
# Bounded: a panel that cannot be enabled at all is a different
# fault, and retrying forever would pin the lid inhibitor with it.
if [ "$undock_tries" -ge 6 ]; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=gave-up attempts=$undock_tries"
fi
fi
fi
if [ -n "$internal" ] && [ -n "$ext" ]; then
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
acquire_inhibitor || true
fi
fi
if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then
release_inhibitor
awaiting_lid_open=
fi
# Last, because it re-derives `internal` and the invariants above must
# see reconcile's own value: a queued undock outranks a stale dock
# intent, and this returns early anyway while no external is present.
enforce_dock_intent
}
# Login/relogin while already docked still needs protection. Do not
# change the layout here: the baked config/manual profile remains SoT,
# so seed known_outputs with what is already connected.
internal=$(internal_output)
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
known_outputs=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction, and
# reconnect after a compositor reload/socket hiccup. The connection is
# deliberately long-lived: `socat -T 1` used to close it after one second
# of desktop idle, and a dock plugged while idle the normal case
# landed in the reconnect gap and was lost outright. A 1s `read` timeout
# supplies the same periodic tick without ever dropping the socket.
# Process substitution is deliberate: a pipeline `... | while read` would
# run the loop in a subshell and lose inhibitor lifecycle state.
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
while :; do
exec 3< <(${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" 2>/dev/null)
while :; do
hint=
if IFS= read -r -t 1 line <&3; then
case "$line" in
"monitorremoved>>"*) hint=''${line#monitorremoved>>} ;;
"monitoradded>>"*) : ;;
# socket2 is chatty (focus, workspaces, ); only monitor events
# are worth a reconcile, the idle tick covers everything else.
*) continue ;;
esac
else
# bash returns >128 for the read timeout (our tick); anything else
# is EOF or error, i.e. the socket died and needs a reconnect.
rc=$?
[ "$rc" -gt 128 ] || break
fi
reconcile "$hint"
done
exec 3<&-
sleep 0.25
done
'';
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
# On a keyboard that connects after login (not declared in
# keyboard.devices, not already remembered), ask for a layout and persist it
# per-device, re-applying silently on later reconnects. Stateful runtime
# piece — the complement to the declarative keyboard.devices. Reliable
# primitives only: poll hyprctl devices, apply with a per-device
# `device[<name>]:kb_layout` keyword — the runtime twin of the declarative
# device blocks, so it isolates that one keyboard and never disturbs the
# built-in board (switchxkblayout flipped the *shared* layout, which leaked
# onto the laptop and stuck after unplug).
#
# The remembered choices live in the git-tracked in-flake state
# (settings.keyboard.devices), NOT ~/.local/state — read live from the
# working tree (so a pick this session is honoured at once) and written
# instantly with `--no-switch` (no rebuild). A later rebuild graduates them
# into nomarchy.keyboard.devices (generated device{} blocks), after which the
# watcher treats them as declared and steps back. NOTE: needs an on-hardware
# test (hotplug isn't verifiable in CI).
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
set -u
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
# Hyprland calls every evdev node that can emit a key a "keyboard": the
# lid and power buttons, a monitor's control channel, and each extra HID
# collection a real keyboard exposes. One dock with one keyboard on it
# therefore used to ask which layout to use four times, for devices that
# cannot type. udev already draws this distinction properly it sets
# ID_INPUT_KEYBOARD only for full typing keyboards so take the answer
# from there and translate the names into Hyprland's own spelling:
# spaces to '-', then lowercase (its deviceNameToInternalString).
typing_keyboards() {
for dev in /sys/class/input/event*; do
[ -r "$dev/device/name" ] || continue
${pkgs.systemd}/bin/udevadm info -q property -p "$dev" 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -q '^ID_INPUT_KEYBOARD=1' || continue
IFS= read -r name < "$dev/device/name" || continue
printf '%s\n' "$name" \
| ${pkgs.coreutils}/bin/tr ' ' '-' | ${pkgs.coreutils}/bin/tr 'A-Z' 'a-z'
done | ${pkgs.coreutils}/bin/sort -u
}
# Hyprland disambiguates same-named devices with a -1, -2, suffix, so a
# device's physical keyboard is its name minus that suffix.
base_name() { printf '%s\n' "$1" | ${pkgs.gnused}/bin/sed -E 's/-[0-9]+$//'; }
# Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default.
prev=" "
for kb in $(keyboards); do
prev="$prev$kb "
is_declared "$kb" && continue
"$tool" restore "$kb" || true
done
# Watch for keyboards that connect later.
while :; do
sleep 3
cur=" "
new=
for kb in $(keyboards); do
cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue
new="$new $kb"
done
prev="$cur"
# Nothing arrived: stay off udev, this is the every-3s common path.
[ -n "$new" ] || continue
typing=$(typing_keyboards)
handled=" "
for kb in $new; do
b=$(base_name "$kb")
in_list "$typing" "$b" || continue
case "$handled" in *" $b "*) continue ;; esac
handled="$handled$b "
# Ask once for the physical keyboard, then answer for every node it
# brought with it they are one keyboard and share one layout.
group=
for k in $new; do
[ "$(base_name "$k")" = "$b" ] && group="$group $k"
done
s=$("$tool" saved "$b")
if [ -n "$s" ]; then
for k in $group; do "$tool" restore "$k" || true; done
else
choice=$("$tool" layouts \
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $b (e.g. us, gb, pt, br)")
[ -n "$choice" ] || continue
for k in $group; do "$tool" apply "$k" "$choice"; done
fi
done
done
'';
in
{
wayland.windowManager.hyprland = lib.mkIf config.nomarchy.hyprland.enable {
enable = true;
# The binary and portal come from the NixOS module
# (programs.hyprland.enable); HM only manages configuration.
package = null;
portalPackage = null;
# HM defaults stateVersion >= 26.05 to the new Lua config and would
# render these hyprlang-style settings as broken `hl.$mod(...)` Lua
# (Hyprland then boots into emergency mode with no binds).
configType = "hyprlang";
# Session bring-up (HM renders these into the first exec-once, after
# its dbus-update-activation-environment). The default is just
# stop/start of hyprland-session.target — not enough after a broken
# logout: if the graphical-session.target *stop* transaction was
# rejected mid-teardown (seen 2026-07-18: "transaction is destructive"
# while easyeffects had a queued start job), the target stays active
# across the logout, its services crash-loop against the dead Wayland
# socket into start-limit-hit, and the next login's plain `start` is a
# no-op — cliphist/swaync/portals stay failed for the whole session
# (doctor all red). So: tear the stale targets down, clear
# failed/start-limit state, then start fresh. All three are no-ops
# after a clean logout or cold boot.
systemd.extraCommands = [
"systemctl --user stop hyprland-session.target graphical-session.target"
"systemctl --user reset-failed"
"systemctl --user start hyprland-session.target"
];
settings = {
"$mod" = "SUPER";
"$terminal" = config.nomarchy.terminal;
# `,preferred,auto,1` is the wildcard fallback for any output not in
# nomarchy.monitors; the generated rules are appended and win by name,
# and Hyprland applies them on hotplug. mkDefault so a downstream
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
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.
exec-once = [
# nixpkgs' swww is awww (renamed fork); the binary is awww-daemon.
"awww-daemon"
# Paint the wallpaper as soon as the session is up (waits for
# the daemon internally).
"nomarchy-state-sync wallpaper"
# Polkit authentication agent — without one, EVERY pkexec/polkit
# prompt in the session fails silently (btrfs-assistant-launcher
# was the discovery case). hyprpolkitagent is Hyprland's own Qt
# agent, so the prompt is Stylix-themed like every other Qt
# surface. Its binary lives in libexec (not bin), hence the path.
"${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent"
# Waybar is launched here rather than as a systemd user service: bound
# to graphical-session.target the unit raced Hyprland's IPC on relogin
# and landed in `failed`; exec-once only fires once the compositor is
# up. Via the nomarchy-waybar supervisor (waybar.nix): a crash — or
# the clean pkill a theme switch now does — respawns the bar instead
# of orphaning the session bar-less.
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
++ [
"${keyboardWatch}/bin/nomarchy-keyboard-watch"
"${displayProfileWatch}/bin/nomarchy-display-profile-watch"
];
# ── Theme-driven look ──────────────────────────────────────────
# These flow from state.json and stay at NORMAL priority:
# change them with `nomarchy-state-sync set ui.<key>` (the intended
# path), or lib.mkForce in home.nix to hardcode against the theme.
# The non-theme knobs around them are lib.mkDefault — override
# those with a plain home.nix assignment. See docs/OVERRIDES.md.
general = {
gaps_in = t.ui.gapsIn;
gaps_out = t.ui.gapsOut;
border_size = t.ui.borderSize;
# Solid borders from theme.border (per-theme, palette-resolved) —
# matches the legacy identity themes, which never used a gradient.
"col.active_border" = rgb t.border.active;
"col.inactive_border" = rgb t.border.inactive;
layout = lib.mkDefault "dwindle";
resize_on_border = lib.mkDefault true;
};
decoration = {
rounding = t.ui.rounding;
active_opacity = t.ui.activeOpacity;
inactive_opacity = t.ui.inactiveOpacity;
blur = {
enabled = t.ui.blur;
size = lib.mkDefault 8;
passes = lib.mkDefault 2;
popups = lib.mkDefault true;
};
shadow = {
enabled = t.ui.shadow;
# The mantle-tinted shadow reads as a dark halo on dark themes
# but as a warm *glow* on light ones (mantle is light there) —
# kept as an identity feature, just tight (a few px) so it never
# bleeds into neighbouring windows or the bar (hardware report
# 2026-07-18); dark themes keep the soft 20px ambient shadow.
range = lib.mkDefault (if t.mode == "light" then 5 else 20);
render_power = lib.mkDefault 3;
color = rgba c.mantle "aa";
};
};
# ── Behaviour (mkDefault → plain home.nix assignment overrides) ──
animations = {
enabled = lib.mkDefault true;
bezier = lib.mkDefault [
"smooth, 0.25, 0.1, 0.25, 1.0"
"snappy, 0.6, 0.0, 0.1, 1.0"
];
animation = lib.mkDefault [
"windows, 1, 4, snappy, popin 85%"
"border, 1, 8, smooth"
"fade, 1, 5, smooth"
"workspaces, 1, 4, snappy, slidefade 15%"
];
};
input = {
# kb_layout/variant come from nomarchy.keyboard.* — set that
# option (the installer writes it; it also drives tty/LUKS). Only the
# session layout(s) land here; the keyboard.layouts pool is applied
# per-device to external boards, never onto the session keyboard.
kb_layout = config.nomarchy.keyboard.layout;
kb_variant = config.nomarchy.keyboard.variant;
follow_mouse = lib.mkDefault 1;
touchpad.natural_scroll = lib.mkDefault true;
};
# Per-device keyboard layouts (nomarchy.keyboard.devices): each
# overrides the session kb_layout for one named keyboard — e.g. an
# external board that's physically a different layout than the laptop's
# built-in one. Hyprland applies them whenever the device connects.
device = lib.mapAttrsToList
(name: d: { inherit name; kb_layout = d.layout; kb_variant = d.variant; })
config.nomarchy.keyboard.devices;
# dwindle:pseudotile was removed in Hyprland 0.55 (the `pseudo`
# dispatcher still exists); setting it aborts config parsing.
dwindle.preserve_split = lib.mkDefault true;
# Hyprland 0.51+ replaced gestures:workspace_swipe with the
# gesture keyword — without this, touchpads have NO gestures.
gesture = lib.mkDefault [
"3, horizontal, workspace"
"4, pinch, fullscreen"
];
misc = {
# Kill the water-drop splash + the yellow "autogenerated config"
# banner. Migrations that log into Hyprland before the first
# home-manager switch still see the banner (no managed conf yet) —
# see docs/MIGRATION.md. Once HM owns hyprland.conf these stay off.
disable_hyprland_logo = lib.mkDefault true;
disable_splash_rendering = lib.mkDefault true;
force_default_wallpaper = lib.mkDefault 0;
# The backdrop when no wallpaper is painted (awww still starting,
# a slow first paint, or a wallpaper-less setup): the theme's base
# instead of compositor black — on light themes the black flash
# reads as a glitch (item 28c, seen in the capture harness).
background_color = rgb c.base;
# #127. Hyprland ships both of these OFF, so nothing in the
# compositor turned a blanked screen back on: hypridle's on-resume
# was the ONLY caller of `dpms on` anywhere in the session — an idle
# daemon as a single point of failure with the display behind it.
# Both facts verified on hardware 2026-07-16, as is the cure: with
# hypridle deliberately stopped and the screen blanked (i.e. the
# brick condition manufactured on purpose), a keypress woke it in 4s
# docked / 6s clamshell. The wake now lives in the compositor where
# no daemon can lose it; a dead hypridle costs auto-lock, not the seat.
#
# Deliberately NOT claimed: that this is what caused the original
# brick. An earlier version of this comment said so, citing a hypridle
# deadlock (hyprwm/hypridle#171) — that was wrong and is retracted:
# hypridle had no disconnect at all around the incident. Removing a
# proven single point of failure needs no such story. The cause is
# still open (BACKLOG #127); the dead Ctrl+Alt+F3 is its lead symptom,
# and neither DPMS-off nor a dead hypridle can produce that.
key_press_enables_dpms = lib.mkDefault true;
mouse_move_enables_dpms = lib.mkDefault true;
};
# First boot of every installed image was showing "Hyprland updated to
# 0.55.x!" over the themed desktop (V2 re-boot of the #94 install disk,
# 2026-07-15). That dialog is Hyprland's update-news popup, not our
# welcome toast — kill it so the first session is ours, not upstream's.
ecosystem = {
no_update_news = lib.mkDefault true;
no_donation_nag = lib.mkDefault true;
};
# Window rules — float + center small config/utility dialogs that
# tile awkwardly. Normal-priority list like `bind`/`exec-once` below,
# so a downstream `windowrule = [...]` concatenates rather than
# replaces. Syntax is the post-0.53 rule rewrite (hyprlang legacy
# parser, `handleWindowrule`): each comma field is `<keyword>
# <value>`, so effects carry a value (`float 1`, `center 1`, `size
# W H`) and matchers take a `match:` prefix (`match:class ^…$`).
# The old rule-first form (`float, class:^…$`) and the
# `windowrulev2` key are both hard errors now (red config-error
# banner on every session start — seen in the capture harness).
# Class regexes use `(?i)` and tolerate the XWayland
# `.…-wrapped` binary-name form.
#
# Conservative float set (item 41 closed): only dialogs we ship or can
# name from package strings / desktop files. Classes: mixer,
# blueman, system-config-printer, calendar, hyprpolkitagent,
# pinentry-qt, and xdg-desktop-portal-gtk (desktop file + libexec
# name; .…-wrapped for the Nix wrapper). SoftGL could not materialize
# portal windows; polkit float confirmed on Latitude 2026-07-10.
windowrule = [
# Audio mixer (item 35): right-click the Waybar volume module.
"float 1, match:class ^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
"center 1, match:class ^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
# Bluetooth manager (blueman) + CUPS printer admin.
"float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
"center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
# Calendar popup (item 42): nomarchy-calendar → calcurse in a
# kitty window tagged with a distinct --class, so it floats centered.
# No `size` rule on purpose: a percentage one is silently ignored here
# (#139, measured on hardware — see term-sheet.nix), and px cannot mean
# "a fraction of *this* monitor". The launcher sizes the window itself.
"float 1, match:class ^(com\\.nomarchy\\.calendar)$"
"center 1, match:class ^(com\\.nomarchy\\.calendar)$"
# Doctor sheet (System Doctor / Waybar custom/doctor click):
# nomarchy-menu doctor → nomarchy-term-sheet, sized the same way.
"float 1, match:class ^(com\\.nomarchy\\.doctor)$"
"center 1, match:class ^(com\\.nomarchy\\.doctor)$"
# Upgrade prompt (Waybar custom/updates click → nomarchy-updates
# upgrade-window): the same classed sheet, smaller — it is a y/N flow.
"float 1, match:class ^(com\\.nomarchy\\.updates)$"
"center 1, match:class ^(com\\.nomarchy\\.updates)$"
# Polkit auth (hyprpolkitagent — app id / binary name in the package)
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
# workspace current: agent was started on ws1 at login; without this
# the dialog opens on that workspace and the current one looks dead
# (hardware: Snapshots menu from ws≠1). stay_focused (0.55 name;
# was stayfocused) so it grabs attention when it lands.
"float 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"center 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"workspace current, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"stay_focused 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"float 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
"center 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
"workspace current, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
# GTK file-chooser portal (xdg-desktop-portal-gtk) — class is the
# binary/desktop name; Nix wrap = .xdg-desktop-portal-gtk-wrapped.
"float 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
"center 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
];
# Rendered from ./keybinds.nix (the cheatsheet reads the same list),
# plus the generated per-workspace binds. Like exec-once above this
# stays a normal-priority list, so a downstream `bind = [...]`
# concatenates rather than replaces. The layout-cycle bind only
# exists when there is something to cycle (same comma condition
# the Waybar language indicator gates on).
bind = map mkBind (keybinds.binds
++ lib.optionals (lib.hasInfix "," config.nomarchy.keyboard.layout)
keybinds.multiLayoutBinds)
++ workspaceBinds
++ lofBinds;
# Media keys via swayosd-client: it performs the action AND shows
# the on-screen display (the nomarchy.osd module). e = repeat,
# l = also on a locked screen — the standard flags for hardware keys.
bindel = [
", XF86AudioRaiseVolume, exec, swayosd-client --output-volume raise"
", XF86AudioLowerVolume, exec, swayosd-client --output-volume lower"
", XF86MonBrightnessUp, exec, swayosd-client --brightness raise"
", XF86MonBrightnessDown, exec, swayosd-client --brightness lower"
];
bindl = [
", XF86AudioMute, exec, swayosd-client --output-volume mute-toggle"
# swayosd's input mute-toggle is broken on 0.2.1 (see micMute above).
", XF86AudioMicMute, exec, ${micMute}"
", XF86AudioPlay, exec, playerctl play-pause"
", XF86AudioPause, exec, playerctl play-pause"
", XF86AudioNext, exec, playerctl next"
", XF86AudioPrev, exec, playerctl previous"
];
bindm = [
"$mod, mouse:272, movewindow"
"$mod, mouse:273, resizewindow"
];
};
};
# Runtime-remembered per-device layouts (settings.keyboard.devices, written
# by the watcher into the in-flake state) graduate into the declarative
# keyboard.devices: each becomes a generated Hyprland device{} block —
# reproducible and applied natively on hotplug — after which the watcher sees
# it as declared and steps back. mkDefault so a hand-written
# keyboard.devices.<name> in home.nix still wins for the same keyboard.
nomarchy.keyboard.devices = lib.mapAttrs
(_name: layout: { layout = lib.mkDefault layout; })
config.nomarchy.settings.keyboard.devices;
# nwg-displays: interactive monitor arranger (applies live via hyprctl and
# writes a config file). A helper to find values for nomarchy.monitors —
# the declarative config stays the source of truth (we don't source its
# output). Gated on its toggle; pointless without the Hyprland session.
home.packages =
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
[ pkgs.nwg-displays ]
# Runtime hardware watchers/helpers stay on PATH for manual recovery and
# debugging. The display-profile switcher itself remains gated so the
# menu does not advertise an empty Profiles submenu.
++ lib.optionals config.nomarchy.hyprland.enable
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch
displayDumpTool displayWakeTool ]
++ lib.optional (profiles != { }) displayProfileTool);
}