# 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. 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 ;; *) icon=microphone-sensitivity-high-symbolic ;; esac ${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 / SUPER+SHIFT+1..9 workspace binds, generated. workspaceBinds = builtins.concatLists (builtins.genList (i: let ws = toString (i + 1); in [ "$mod, ${ws}, workspace, ${ws}" "$mod SHIFT, ${ws}, movetoworkspace, ${ws}" ]) 9); # 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 |base|match ...]" >&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; } prev=$(outputs) while :; do sleep 3 cur=$(outputs) [ "$cur" = "$prev" ] && continue prev=$cur [ -n "$cur" ] || continue auto_on || continue target=$(nomarchy-display-profile match $cur) || continue [ "$target" = "$(nomarchy-display-profile active)" ] && continue nomarchy-display-profile apply "$target" 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[]: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.` (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 = { 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. Class regexes use `(?i)` and tolerate the XWayland # `.…-wrapped` binary-name form. # # Conservative set on purpose (item 41): only windows the distro # actually ships, each an unambiguous dialog. Deliberately NOT # floated yet — needs on-hardware class verification: the polkit auth # prompt (hyprpolkitagent exposes no discoverable class) and GTK # file-chooser portals. A dead rule is harmless, but guessing a class # is worse than leaving it for a verified pass. windowrule = [ # Audio mixer (item 35): right-click the Waybar volume module. "float, class:^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$" "center, class:^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$" # Bluetooth manager (blueman) + CUPS printer admin. "float, class:(?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$" "center, class:(?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-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 ] # 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 ]; }