# 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 <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 / 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 <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; }
    
    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
        monitoradded*|monitorremoved*)
          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 = {
        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 set (item 41): only dialogs we ship or can name from
      # package strings / desktop files. SoftGL capture harness could not
      # materialize polkit/pinentry windows (empty hyprctl clients) — classes
      # below are from binary/app-id strings (hyprpolkitagent, pinentry-qt).
      # Remaining: GTK file-chooser portal — class still unknown headlessly;
      # HARDWARE-QUEUE for live hyprctl clients confirmation.
      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)$"

        # Polkit auth (hyprpolkitagent — app id / binary name in the package)
        # and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
        "float 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
        "center 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)$"
      ];

      # 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 ];
}
