All checks were successful
Check / eval (push) Successful in 3m48s
Every rebuild while docked re-lit the panel and threw a workspace onto it. Not forgetfulness: a rebuild PERFORMS an undock. Dock mode lived only in the compositor (`keyword monitor <internal>, disable`, nothing written down), the config's only monitor line is the catch-all `monitor=,preferred,auto,1`, and rewriting the config makes Hyprland reload it — re-enabling every output. Our own undock path already uses `hyprctl reload` as exactly that hammer (hardware-proven 2026-07-14). The watcher could not rescue it: reconcile diffs `monitors all`, which lists disabled outputs, so the connected set is identical across the flip — structurally blind, the blind spot of rounds 4-5's "the output set is the truth". So the intent is recorded where every other setting lives — settings.display.dockMode, in-flake, menu-writable — written at the one choke point both menu and watcher pass through (transition: dock → true, undock → false, `enable` of an internal → false, because "Screen on" IS leaving dock mode and must not be undone a second later). The watcher re-asserts it as a tick invariant rather than an event reaction, which is what makes the reload case work; auto_dock was already idempotent, so it costs one `hyprctl monitors` per tick. The trap avoided: baking the disable into the config would survive a reload and hand a user with no external a zero-output session at login — the #100/#127 brick. The config cannot know what is plugged in, so it keeps defaulting to "everything on" and the re-assert is gated on an external being present. Verified: 9/9 gates unit-tested by driving the REAL generated function against stubs (incl. no-external → never re-dock, profile active → yield, already-docked → no churn), plus the state write/read round-trip on a throwaway checkout so the live flake was untouched. V3 queued — needs a relogin, the watcher is exec-once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1228 lines
58 KiB
Nix
1228 lines
58 KiB
Nix
# 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;
|
||
|
||
# One transition primitive for the interactive Dock mode, abrupt undock,
|
||
# and named profiles that disable the internal panel. Dock is one Hyprland
|
||
# batch: enable the target, move every internal workspace, focus it, then
|
||
# disable the panel. Undock deliberately enables the panel FIRST, waits
|
||
# for it to become an active target, then restores every workspace.
|
||
displayTransitionTool = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
|
||
set -u
|
||
LOGGER=${pkgs.util-linux}/bin/logger
|
||
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
|
||
valid_output() {
|
||
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
|
||
}
|
||
workspaces_on() {
|
||
hyprctl workspaces -j 2>/dev/null \
|
||
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
|
||
}
|
||
all_workspaces() {
|
||
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
|
||
}
|
||
lid_inhibitor_held() {
|
||
${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
|
||
}
|
||
batch_moves() { # stdin workspace ids, $1 target
|
||
target="$1"; commands=
|
||
while IFS= read -r ws; do
|
||
case "$ws" in *[!0-9]*|"") continue ;; esac
|
||
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
|
||
done
|
||
printf '%s' "$commands"
|
||
}
|
||
# `monitors` (enabled-only) listing the output is the ONLY proof it is
|
||
# really on: every path here issues keywords hyprctl answers "ok" to
|
||
# without acting. $1 = output, $2 = polls of 0.2s.
|
||
output_enabled() {
|
||
tries=0
|
||
while [ "$tries" -lt "$2" ]; do
|
||
hyprctl monitors -j 2>/dev/null \
|
||
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
|
||
tries=$((tries+1)); sleep 0.2
|
||
done
|
||
return 1
|
||
}
|
||
# `hyprctl reload` re-reads the config, and every runtime keyword dies
|
||
# with it — including the `device[<name>]:kb_layout` the keyboard watcher
|
||
# applies to an external board. Measured on hardware 2026-07-14: a bare
|
||
# reload took a keyboard remembered as `us` straight back to the session's
|
||
# `gb`, so without this the undock's escalation would silently break the
|
||
# layout of any keyboard that stays behind when the dock leaves (a
|
||
# wireless receiver in the laptop; only boards on the dock's own hub are
|
||
# unaffected, and only because they leave with it). The remembered
|
||
# choices live in the in-flake state, so `restore` is authoritative.
|
||
restore_keyboards() {
|
||
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
|
||
| while IFS= read -r kb; do
|
||
[ -n "$kb" ] || continue
|
||
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
|
||
>/dev/null 2>&1 || true
|
||
done
|
||
}
|
||
|
||
# Dock mode is an *intent*, and until #142 it lived only in the compositor:
|
||
# `keyword monitor <internal>, disable` and nothing written down. Any config
|
||
# reload re-applies the catch-all `monitor=,preferred,auto,1` and re-lights
|
||
# the panel — which is exactly why `undock` below uses `hyprctl reload` as
|
||
# its hammer — so every `nomarchy-home` silently performed an undock and the
|
||
# user re-picked Dock mode by hand.
|
||
#
|
||
# So record the intent where every other setting lives (the in-flake state,
|
||
# menu-writable), and let the watcher re-assert it. `--no-switch`: the
|
||
# watcher reads this at run time; a rebuild here would be both pointless and
|
||
# recursive. Failure to write is never fatal — a session that cannot reach
|
||
# the state file must still dock.
|
||
set_dock_intent() { # $1 = true|false
|
||
${sync} --quiet set settings.display.dockMode "$1" --no-switch \
|
||
>/dev/null 2>&1 || log "dock-intent=$1 result=state-write-failed"
|
||
}
|
||
|
||
case "''${1:-}" in
|
||
dock)
|
||
internal="''${2:-}"; external="''${3:-}"
|
||
rule="''${4:-$external, preferred, auto, 1}"
|
||
valid_output "$internal" && valid_output "$external" \
|
||
|| { echo "invalid monitor name" >&2; exit 64; }
|
||
# Fail closed: disabling the panel without this lock recreates the
|
||
# exact cable-removal → logind suspend race #100 is fixing.
|
||
lid_inhibitor_held \
|
||
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
|
||
moves=$(workspaces_on "$internal" | batch_moves "$external")
|
||
if hyprctl --batch \
|
||
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
|
||
>/dev/null 2>&1; then
|
||
set_dock_intent true
|
||
log "transition=dock internal=$internal external=$external result=ok"
|
||
else
|
||
log "transition=dock internal=$internal external=$external result=failed"
|
||
exit 1
|
||
fi ;;
|
||
undock)
|
||
internal="''${2:-}"
|
||
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
|
||
# Clear the intent *first*: this path runs when the external is already
|
||
# gone, and if anything below fails the last thing the session needs is
|
||
# a watcher that keeps trying to dock to a monitor that left.
|
||
set_dock_intent false
|
||
# Safety-critical ordering: never remove/move away from an external
|
||
# before the internal panel is active and can receive workspaces.
|
||
#
|
||
# With ZERO enabled outputs — an abrupt undock, the panel already
|
||
# disabled by the dock — Hyprland 0.55.4 accepts `keyword monitor`
|
||
# (prints "ok", exits 0) and then never flushes it: the rule is held
|
||
# until some DRM event arrives, and the panel stays dark. This is
|
||
# deterministic, not a race. Probed on hardware 2026-07-14: the
|
||
# keyword was inert across 4s and 5s windows in two separate runs,
|
||
# `dispatch forcerendererreload` did not flush it either, and only
|
||
# `hyprctl reload` did — in 99ms and 289ms, with the cable out.
|
||
#
|
||
# Round 6 read the same symptom as a teardown race and re-issued the
|
||
# keyword 25 times a poll. Retrying an inert command cannot work: it
|
||
# bought 30s of black screen and every `result=ok` in that journal was
|
||
# really the user plugging the cable back in, whose hotplug flushed
|
||
# the queued rule and let the next poll take the credit.
|
||
#
|
||
# So issue the keyword once — enough whenever another output is still
|
||
# enabled, e.g. the menu's Dock mode toggle — and escalate to the
|
||
# reload hammer only when it proves inert.
|
||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||
if ! output_enabled "$internal" 5; then
|
||
log "transition=undock internal=$internal keyword=inert escalate=reload"
|
||
hyprctl reload >/dev/null 2>&1
|
||
if ! output_enabled "$internal" 25; then
|
||
log "transition=undock internal=$internal result=enable-failed"
|
||
# #127: leave evidence on disk without SSH (TTY or next login).
|
||
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
|
||
>/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
# The config's monitor rules own the panel now. Re-assert the
|
||
# runtime rule — which flushes normally, an output being enabled
|
||
# again — so a config that parks the panel off cannot undo the
|
||
# undock, and give the keyboards back what the reload took.
|
||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||
restore_keyboards
|
||
log "transition=undock internal=$internal enable=via-reload"
|
||
fi
|
||
moves=$(all_workspaces | batch_moves "$internal")
|
||
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|
||
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
|
||
# #127: undock after idle DPMS left the seat black even when the
|
||
# panel was re-enabled — always light the chain after enable.
|
||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||
log "transition=undock internal=$internal result=ok" ;;
|
||
enable)
|
||
internal="''${2:-}"
|
||
valid_output "$internal" || exit 64
|
||
# Menu ▸ Display ▸ "Screen on". Turning the laptop panel back on *is*
|
||
# leaving dock mode, and saying so is what stops the watcher from
|
||
# re-docking the panel out from under the user a second later (#142).
|
||
# Only the internal clears the intent: lighting some third monitor is
|
||
# not a statement about the lid.
|
||
case "$internal" in
|
||
eDP*|LVDS*|DSI*) set_dock_intent false ;;
|
||
esac
|
||
# Reachable only from the menu, i.e. with a screen already on, so the
|
||
# keyword flushes and no reload escalation is needed — but still make
|
||
# `monitors` the proof, or the menu's "back on" notification cheers
|
||
# for a keyword hyprctl merely said "ok" to.
|
||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||
if output_enabled "$internal" 10; then
|
||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||
true
|
||
else
|
||
false
|
||
fi ;;
|
||
*)
|
||
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
|
||
exit 64 ;;
|
||
esac
|
||
'';
|
||
|
||
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. Gated on the same display
|
||
# profile check as the event path: 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
|
||
auto_on || 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=
|
||
[ -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
|
||
'';
|
||
|
||
# Candidate layouts offered by the new-keyboard picker: the session
|
||
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
||
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
|
||
# deliberately NOT merged into input.kb_layout — those candidates are for
|
||
# *external* keyboards and get applied per-device by the watcher's apply().
|
||
# Loading them onto the session keyboard is what let a global switch flip
|
||
# the built-in board to the wrong layout.
|
||
pickerLayouts = lib.unique (
|
||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||
++ config.nomarchy.keyboard.layouts
|
||
);
|
||
|
||
# Per-device keyboard helper shared by the hotplug watcher and the manual
|
||
# System › Keyboard menu. The configured candidates are listed first, then
|
||
# every XKB layout known to the system, so the feature works on a default
|
||
# install instead of silently disappearing until home.nix is edited.
|
||
keyboardTool = pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
|
||
set -u
|
||
sync=${sync}
|
||
|
||
layouts() {
|
||
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
|
||
${pkgs.gawk}/bin/awk '
|
||
/^! layout/ { in_layouts=1; next }
|
||
/^!/ && in_layouts { exit }
|
||
in_layouts && NF { print $1 }
|
||
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
|
||
}
|
||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||
|
||
case "''${1:-}" in
|
||
layouts)
|
||
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
|
||
devices)
|
||
hyprctl devices -j 2>/dev/null \
|
||
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
|
||
saved)
|
||
[ -n "''${2:-}" ] || exit 64
|
||
saved_for "$2" ;;
|
||
restore)
|
||
[ -n "''${2:-}" ] || exit 64
|
||
layout=$(saved_for "$2")
|
||
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
|
||
apply)
|
||
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
|
||
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|
||
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
|
||
if apply_runtime "$2" "$3"; then
|
||
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
|
||
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
|
||
notify-send "Keyboard" "$2 → $3"
|
||
else
|
||
notify-send "Keyboard" "$2 → $3 for this session, but the choice could not be saved."
|
||
exit 1
|
||
fi
|
||
else
|
||
notify-send "Keyboard" "Could not apply $3 to $2."
|
||
exit 1
|
||
fi ;;
|
||
forget)
|
||
[ -n "''${2:-}" ] || exit 64
|
||
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
|
||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
|
||
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
|
||
notify-send "Keyboard" "$2 now follows the session layout." ;;
|
||
*)
|
||
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
|
||
exit 64 ;;
|
||
esac
|
||
'';
|
||
|
||
# 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";
|
||
|
||
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;
|
||
range = lib.mkDefault 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;
|
||
};
|
||
|
||
# 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);
|
||
}
|