Files
Nomarchy/modules/home/hyprland.nix
Bernardo Magri 7ebfab4bd4
All checks were successful
Check / eval (push) Successful in 3m32s
fix(docking): make clamshell hotplug safe and discoverable
Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs.

Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions.

Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md.
2026-07-13 11:53:01 +01:00

715 lines
33 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; }
paint_outputs() {
# awww does not inherit the current image when Hyprland adds an output.
# Let the new output settle, then re-apply the current wallpaper to all
# outputs. Backgrounded so profile matching never waits on animation.
sleep 0.5
${sync} --quiet wallpaper >/dev/null 2>&1 || true
}
# 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): the former panel-off menu action (and still-valid
# named profiles that disable eDP) + 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 rescued=
# monitorremoved can arrive just before the departing output disappears
# from `hyprctl monitors`; keep a short bounded watch instead of deciding
# from that racy first snapshot. The first check is still immediate.
while [ "$tries" -lt 8 ]; do
if [ "$(active_count)" = 0 ]; then
rescued=1
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
fi
tries=$((tries+1))
sleep 0.25
done
if [ -n "$rescued" ] && [ "$(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
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
echo "$cur"
}
prev=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect
# after a compositor reload/socket hiccup instead of silently losing the
# safety listener for the rest of the session.
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
while :; do
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" | while read -r line; do
case "$line" in
monitorremoved*)
rescue_blackout
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
monitoradded*)
paint_outputs &
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
esac
done
sleep 1
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; }
# 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=" "
for kb in $(keyboards); do
cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue
s=$("$tool" saved "$kb")
if [ -n "$s" ]; then
"$tool" restore "$kb" || true
else
choice=$("$tool" layouts \
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $kb (e.g. us, gb, pt, br)")
[ -n "$choice" ] && "$tool" 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"
++ [
"${keyboardWatch}/bin/nomarchy-keyboard-watch"
"${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 ]
# 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 displayProfileWatch ]
++ lib.optional (profiles != { }) displayProfileTool);
}