Files
Nomarchy/modules/home/hyprland.nix
Bernardo Magri c840202018
Some checks failed
Check / eval (push) Failing after 1m40s
feat(display): blackout rescue — re-enable a disabled panel on undock
Bernardo's question: with "Laptop screen off" active, does the panel
come back when the dock is yanked? VM answer (new maintainer harness
tools/monitor-fallback.nix, softGL desktop): no — Hyprland 0.55.4
leaves the session with zero active outputs and never re-enables a
soft-disabled monitor. rescue_blackout in the display hotplug watcher
now re-enables every disabled output (immediate, retried, idempotent
keyword + toast) whenever a monitorremoved leaves nothing active;
independent of the profile auto-switch and its settings gate.

Sharp edge documented rather than hidden: the zero-output state itself
crashes Hyprland in the VM ~4/5 runs (aquamarine CBackend::dispatchIdle
ABRT within ~0.5s — faster than any userland rescue; a control run
removing the external with the panel ACTIVE survives 5/5, so the state
is the trigger, not headless removal; crash reproduced with the rescue
provably inert, so not caused by it). Rescue ships as defense-in-depth:
it recovers every surviving case, and the worst case on real DRM is
session-to-greeter on a re-lit panel instead of a black brick. Rider
fix: `grep -c .` exits 1 at count zero — the ||-guard on that pipeline
silently disabled the rescue exactly when it mattered.

Verified: hazard V2 (VM reproduces the dead state); control
discriminator 5/5; flake check green. V3 pending: real-dock
undock-while-off in HARDWARE-QUEUE (run with nothing important open;
crash on real DRM = rework the row to mirror or wait for a Hyprland
bump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:09:17 +01:00

647 lines
31 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hyprland — fully driven by config.nomarchy.theme (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-theme-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;
# 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:
" ${lib.escapeShellArg name})\n"
+ lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
p.monitors
# 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
'';
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
# settings.displayProfileAuto, read LIVE on every output change so the
# toggle is instant, no rebuild). Polls like the keyboard watcher —
# exec-once, not a systemd unit (graphical-session-bound units raced
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
# CHANGES after session start: the baked config already encodes the
# last choice, and login must not fight a deliberate manual pick.
# `match` picks deterministically (exact set, else unambiguous largest
# subset); applying via the tool persists the concrete profile, so the
# next rebuild bakes what auto chose.
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
set -u
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; }
# Blackout rescue the session must never end up with ZERO active
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
# the last active one disconnects (VM-verified, tools/
# monitor-fallback.nix): menu "Laptop screen off" + sudden undock
# left a dead session. On every monitorremoved, if nothing is
# active, re-enable whatever is disabled (preferred/auto the
# declared rule returns at the next reload/relogin). Independent of
# the profile auto-switch and its settings gate: this is a safety
# invariant, not a layout preference.
active_count() {
# NB: `grep -c .` exits 1 when the count is 0 the case this whole
# rescue exists for so never `||`-guard on the pipeline status.
hyprctl monitors -j 2>/dev/null | jq -r '.[].name' | grep -c . || true
}
rescue_blackout() {
# Act IMMEDIATELY and retry: the zero-output state is not just a
# black screen, it can kill the compositor outright in the softGL
# VM harness aquamarine ABRT'd (CBackend::dispatchIdle) within
# ~0.5s of the last output going while it persisted, so shrinking
# the window is the priority; the keyword is idempotent and the
# retries cover a first attempt racing the removal teardown.
tries=0
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \
| while read -r m; do
[ -n "$m" ] || continue
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1
done
tries=$((tries+1))
sleep 1
done
if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then
notify-send "Display" "Screen re-enabled the active output disappeared." 2>/dev/null || true
fi
}
check_monitors() {
cur=$(outputs)
[ "$cur" = "$1" ] && return
[ -n "$cur" ] || return
auto_on || 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
echo "$cur"
}
prev=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
case "$line" in
monitorremoved*)
rescue_blackout
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
monitoradded*)
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
esac
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
);
kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ];
# 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
layouts="${lib.concatStringsSep " " pickerLayouts}"
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
sync=${sync}
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# The remembered map (device-name -> layout) from the LIVE in-flake state;
# an absent key (sparse state) reads as an empty object.
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
# Persist a pick INSTANTLY: merge it into the map and write the whole object
# back at the dot-free parent path, so a device name containing a dot can't
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
remember() {
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
}
# 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
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
done
# Watch for keyboards that connect later.
while :; do
sleep 3
cur=" "
for kb in $(keyboards); do
cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue
s=$(saved_for "$kb")
if [ -n "$s" ]; then
apply "$kb" "$s"
else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
fi
done
prev="$cur"
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-theme-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"
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch";
# ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority:
# change them with `nomarchy-theme-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;
};
# 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): the Waybar clock's on-click runs
# nomarchy-calendar → calcurse in a ghostty window tagged with a
# distinct --class, so it floats centered instead of tiling.
"float 1, match:class ^(com\\.nomarchy\\.calendar)$"
"size 60% 65%, match:class ^(com\\.nomarchy\\.calendar)$"
"center 1, match:class ^(com\\.nomarchy\\.calendar)$"
# Doctor sheet (System Doctor / Waybar custom/doctor click):
# nomarchy-menu doctor → ghostty --class=com.nomarchy.doctor.
"float 1, match:class ^(com\\.nomarchy\\.doctor)$"
"size 55% 70%, match:class ^(com\\.nomarchy\\.doctor)$"
"center 1, match:class ^(com\\.nomarchy\\.doctor)$"
# 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 ]
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
# runnable by name for debugging — Hyprland still exec-once's it by store path.
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
# The display-profile switcher (the menu's Profiles row gates on it)
# + its hotplug watcher, on PATH for debugging like the keyboard one.
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
[ displayProfileTool displayProfileWatch ];
}