# 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 " >&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[]: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 , 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 [external-rule]|undock |enable ]" >&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 |base|match ...]" >&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 |restore |apply |forget ]" >&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[]: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.` (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; # #127, and the whole answer to it. Hyprland ships both of these # OFF, so nothing in the compositor turned a blanked screen back # on: hypridle's on-resume was the ONLY caller of `dpms on` in the # session — an idle daemon as a single point of failure with the # display behind it. hypridle then deadlocks on an unfixed upstream # bug (hyprwm/hypridle#171: "Disconnected from pollfd id 1", open, # not in 0.1.7 or main) and hangs WITHOUT exiting, so Restart=always # never fires and systemd still reports it active. Blank + deadlock # = a black seat no keypress could escape. That was the brick. # These put the wake in the compositor, where no daemon can lose it; # a dead hypridle now costs auto-lock, not the machine. key_press_enables_dpms = lib.mkDefault true; mouse_move_enables_dpms = lib.mkDefault true; }; # First boot of every installed image was showing "Hyprland updated to # 0.55.x!" over the themed desktop (V2 re-boot of the #94 install disk, # 2026-07-15). That dialog is Hyprland's update-news popup, not our # welcome toast — kill it so the first session is ours, not upstream's. ecosystem = { no_update_news = lib.mkDefault true; no_donation_nag = lib.mkDefault true; }; # Window rules — float + center small config/utility dialogs that # tile awkwardly. Normal-priority list like `bind`/`exec-once` below, # so a downstream `windowrule = [...]` concatenates rather than # replaces. Syntax is the post-0.53 rule rewrite (hyprlang legacy # parser, `handleWindowrule`): each comma field is ` # `, 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. 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); }