All checks were successful
Check / eval (push) Successful in 3m47s
Bernardo, live, right after #127 TEST A: undocked → the panel stayed dark; re-docked and undocked again → it came back. Re-docking is not what fixed it. OPENING THE LID was. 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. Re-docking clears its sibling undock_pending ("a new external supersedes any undock still queued for an older departure") but not this one, and the clear cannot fire while an external is present. The next departure then hits the `[ -z "$awaiting_lid_open" ]` guard, queues nothing, and the panel never returns. His journal is unambiguous — the first undock logged NOTHING (no outputs-changed removed, no transition), then: 14:42:31 lid-inhibitor=released <- he opened the lid: latch cleared 14:42:40 outputs-changed added= DP-2 <- re-dock 14:42:47 outputs-changed removed= DP-2 transition=undock-queued 14:42:49 transition=undock result=ok Fix: clear the latch where undock_pending is already cleared — a new external ends the departure the latch describes. Replaying his sequence with the lid never opened: old = 1 of 3 undocks run, new = 3 of 3. This is very likely #127's SECOND symptom ("unplugging did not turn the laptop panel back on") and it has nothing to do with DPMS. Which raises the odds that the shipped mitigation is treating the wrong cause — TEST B now decides that, and with this fix an undock is a real escape hatch during it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1241 lines
59 KiB
Nix
1241 lines
59 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=
|
||
# …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
|
||
'';
|
||
|
||
# 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);
|
||
}
|