From e0da1ea40fff014432e0670388e592459609d82b Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 14 Jul 2026 10:24:48 +0100 Subject: [PATCH] fix(docking): don't lose the hotplug event; one prompt per keyboard; follow the jack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four failures from Bernardo's round-4 dock pass on the AMD dev box, plus a headphone regression, tracked to three causes. The display watcher ran socat with `-T 1`, which closes Hyprland's IPC socket after one second of *inactivity*. A dock is normally plugged into an idle desktop, so the socket was almost always mid-reconnect at that moment and `monitoradded` was lost outright — and the wallpaper repaint, the docking profile, and the dock-audio reprobe all hung off that single event. The reconnect path recovered only the lid inhibitor, so the dock stayed half-configured until something else poked the watcher. Make the connection long-lived and take the periodic tick from a 1s `read` timeout instead, then let the *output set* — not the event — decide: a new `reconcile` diffs it and is called on both ticks and monitor events, so an event now only makes the reaction instant rather than necessary. A newly present external output is as unambiguous a physical plug as the event was, so it may still override a manual sink choice. Wallpaper paints are retried over the first few seconds because awww registers an output on its own schedule, not the compositor's. Hyprland calls every key-capable evdev node a "keyboard": the lid and power buttons, a monitor's control channel, and each extra HID collection of a real keyboard. One dock with one keyboard asked for a layout four times. udev already draws the distinction properly, so take ID_INPUT_KEYBOARD from it, map the names into Hyprland's spelling, and ask once per physical keyboard — applying the answer to every node it brought with it. Finally, a sink WirePlumber has been told to prefer is stored as the configured default and outranks every priority rule. On UCM cards the headphones are a sink of their own rather than a route, so once anything pinned a sink (this feature on a dock plug, or the user in the Audio menu), plugging headphones in could no longer take the audio back. The `watch` loop already subscribed to exactly the right events with an empty handler; finish it. Unplug needs no undo — the pin goes stale and WirePlumber falls back by priority on its own, to the dock when docked and the speakers otherwise. Verified V2: checks.docking-ux and checks.dock-audio pass with new guards against each regression; the keyboard filter and both sink selectors run against the dev box's real devices and sink names. The hotplug paths need hardware — queued as docking round 5. Also queues #107, renaming theme.json now that it holds the system state. Co-Authored-By: Claude Opus 4.8 --- agent/BACKLOG.md | 16 +++ agent/HARDWARE-QUEUE.md | 22 ++++ flake.nix | 26 +++- modules/home/dock-audio.nix | 57 +++++++-- modules/home/hyprland.nix | 229 ++++++++++++++++++++++++------------ 5 files changed, 263 insertions(+), 87 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 76fe3d8..233a10c 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -95,6 +95,22 @@ pre-existing ISO-pinning gap. ## NEXT +### 107. Rename `theme.json` to reflect that it is the system state + +The state file long ago stopped being about themes: it carries night-light, +keyboard memory, display resolution and profiles, auto-commit, services and +more, so `theme.json` now misnames its own contents and misleads anyone +reading the flake. Rename it to `state.json` (Bernardo, 2026-07-14 — +`system.json` is the alternative; pick one and use it everywhere), and carry +the `nomarchy-theme-sync` tool name along with it. + +Standalone task — do not fold it into a feature. Ships with a compatibility +shim that keeps reading an existing `theme.json` so downstream checkouts do +not break on a pull, plus a migration note. Pass = a fresh install and an +existing downstream checkout both work, every in-repo reference (modules, +tools, template, docs) uses the new name, and the shim is documented with the +release it can be dropped in. + ### 103. Live ISO baseline desktop applications Make Chromium and Firefox, `libreoffice-fresh`, GNOME Text Editor, Amberol, and diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index fad118d..5fb24a4 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -32,6 +32,28 @@ the **T14s** (webcam case). nomarchy-battery-charge-limit.service` stays false, `Result=success`, BAT0's `charge_control_end_threshold` still matches the limit, and the journal has no new `start-limit-hit`/`Failed with result` lines. +- [ ] **Docking recovery round 5 (dock/keyboard/audio hotplug, 2026-07-14)** + — round 4 was run on the AMD dev box and **failed on four counts** + (Bernardo): no wallpaper on the external monitor, no automatic dock + mode (System › Display had to be used by hand), audio staying on the + laptop speakers while docked, and one dock + one external keyboard + asking for a layout **four times**. Separately, plugging headphones had + stopped moving audio to them. Causes and fixes in the commit carrying + this entry: the display watcher's `socat -T 1` closed Hyprland's IPC + socket after a second of idle, so a dock plugged into an idle desktop + lost `monitoradded` in the reconnect gap (wallpaper + dock mode + audio + all hang off that one event); Hyprland lists every key-capable evdev + node as a "keyboard"; and a pinned `default.configured.audio.sink` + outranks the priority rules, which kills jack-follow on UCM cards where + the headphones are their own sink. **Re-run round 4 below in full**, + plus: (a) leave the desktop untouched for ~30s, then plug the dock — + wallpaper, dock mode, and audio must all follow with no manual step; + (b) the external keyboard must prompt **exactly once**, and never for + the monitor or the lid/power buttons; (c) docked and undocked, plug and + unplug headphones — audio must move to them and back each time. + `journalctl --user -t nomarchy-display-watch` should show + `outputs-changed added=…`, and `-t nomarchy-dock-audio` + `trigger=jack selected=…` on a headphone plug. - [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after updating to the commit carrying this entry, run `nomarchy-home` and relogin. With the lid open, put visible windows on at least workspaces diff --git a/flake.nix b/flake.nix index d34eebb..277a47e 100644 --- a/flake.nix +++ b/flake.nix @@ -894,13 +894,15 @@ set -euo pipefail menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1) keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1) + kbwatch=$(find ${gen}/home-path/bin -name nomarchy-keyboard-watch | head -1) watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1) transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1) audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1) test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \ - -a -n "$transition" -a -n "$audio" + -a -n "$transition" -a -n "$audio" -a -n "$kbwatch" bash -n "$menu" bash -n "$keyboard" + bash -n "$kbwatch" bash -n "$watcher" bash -n "$transition" bash -n "$audio" @@ -923,9 +925,22 @@ grep -q 'nomarchy-dock-lid-inhibitor' "$watcher" ! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher" grep -Fq 'kill -TERM -"$pid"' "$watcher" - grep -q 'done < <(' "$watcher" + grep -q 'exec 3< <(' "$watcher" grep -q '"$TRANSITION" undock' "$watcher" grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher" + # A dock plugged while the desktop sits idle must still be + # seen: `socat -T` closed the IPC socket after a second of + # quiet and lost the event in the reconnect gap, so the + # output set — not the event — decides, on a read timeout. + ! grep -q 'socat -T' "$watcher" + grep -Fq 'read -r -t 1 line <&3' "$watcher" + grep -q 'reconcile "$hint"' "$watcher" + grep -q 'outputs-changed added=' "$watcher" + # One physical keyboard exposes several key-capable evdev + # nodes; only udev's own classification may raise a prompt. + grep -q 'ID_INPUT_KEYBOARD=1' "$kbwatch" + grep -q 'typing_keyboards' "$kbwatch" + grep -q 'base_name' "$kbwatch" grep -q 'hyprctl --batch' "$transition" grep -q 'keyword monitor.*disable' "$transition" grep -q 'result=no-lid-inhibitor' "$transition" @@ -936,7 +951,12 @@ grep -q 'restart.*pipewire\|pipewire.service' "$audio" grep -q 'set-default-sink' "$audio" grep -q 'subscription-closed' "$audio" - grep -q 'result=no-available-dock-sink' "$audio" + grep -q 'result=no-available-sink' "$audio" + # A pinned default sink outranks every priority rule, so on + # UCM cards (headphones = their own sink) the jack has to be + # followed explicitly or plugging in stops switching output. + grep -q 'headphone_sinks' "$audio" + grep -Fq '"$0" headphones jack' "$audio" ! grep -q 'power_save' "$audio" echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present" touch $out diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix index aa4abc5..6d9a830 100644 --- a/modules/home/dock-audio.nix +++ b/modules/home/dock-audio.nix @@ -15,6 +15,12 @@ let (map (m: lib.removePrefix "~" m."node.name") (lib.concatMap (r: r.matches) rules."monitor.alsa.rules")); + # Cards driven by ALSA UCM expose speakers and headphones as separate + # sinks (HiFi__Headphones__sink), the headphone one existing only while + # the jack is occupied — so on those machines a jack plug is a sink + # appearing, not the route switch WirePlumber handles by itself. + headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*"; + tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" '' set -u PACTL=${pkgs.pulseaudio}/bin/pactl @@ -35,12 +41,12 @@ let return 1 } - # Highest-priority dock-class sink whose active port is not explicitly + # Highest-priority sink matching $1 whose active port is not explicitly # unavailable. With pro-audio profiles a sink may have no ports; its # existence is then the only availability signal PipeWire exposes. - dock_sinks() { + sinks_matching() { "$PACTL" --format=json list sinks 2>/dev/null \ - | "$JQ" -r --arg re '^(${dockSinkRe})$' ' + | "$JQ" -r --arg re "^($1)$" ' [ .[] as $sink | $sink | select(.name | test($re)) @@ -55,12 +61,15 @@ let | sort_by(.priority // 0) | reverse[] | "\(.name)\t\(.description // .name)"' } + dock_sinks() { sinks_matching '${dockSinkRe}'; } + headphone_sinks() { sinks_matching '${headphoneSinkRe}'; } + default_sink() { "$PACTL" get-default-sink 2>/dev/null; } - select_first_dock() { # $1 = observable trigger - trigger="$1" - candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate= + select_first() { # $1 = sink lister, $2 = observable trigger + lister="$1"; trigger="$2" + candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate= if [ -z "$candidate" ]; then - log "trigger=$trigger result=no-available-dock-sink" + log "trigger=$trigger result=no-available-sink" return 1 fi name=''${candidate%%"$TAB"*} @@ -74,6 +83,7 @@ let log "trigger=$trigger result=set-default-failed candidate=$name" return 1 } + select_first_dock() { select_first dock_sinks "$1"; } case "''${1:-watch}" in candidates) @@ -102,10 +112,30 @@ let else log "trigger=$trigger result=pulse-did-not-return" fi ;; + headphones) + # Follow the jack, but only ever *towards* the headphones. On unplug + # the sink disappears and any pin naming it goes stale, which makes + # WirePlumber fall back by priority on its own — to the dock when + # docked, to the speakers otherwise — so there is nothing to undo. + wait_for_pulse || exit 0 + hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp= + [ -n "$hp" ] || exit 0 + name=''${hp%%"$TAB"*} + [ "$(default_sink)" = "$name" ] && exit 0 + select_first headphone_sinks "''${2:-jack}" || true ;; watch) - # Selection is deliberately NOT done at service startup: a restart or - # relogin while docked must not erase a manual speaker choice. Only - # the compositor's fresh monitoradded event calls `reprobe`. + # Dock selection is deliberately NOT done at service startup: a + # restart or relogin while docked must not erase a manual speaker + # choice. Only the compositor's fresh monitoradded event calls + # `reprobe`. + # + # The jack is different, and is why this loop exists. A sink that + # WirePlumber has been told to prefer (by us on a dock plug, or by + # the user in the Audio menu / a mixer) is stored as the configured + # default, and that outranks every priority rule — so on UCM cards, + # where the headphones are a sink of their own rather than a route, + # plugging them in could no longer steal the audio back. Watch for + # that sink appearing and select it explicitly. wait_for_pulse || true # A graph restart closes `pactl subscribe`, sometimes with status 0. # Always resubscribe; log one concise closure line for diagnosis. @@ -113,7 +143,10 @@ let if LC_ALL=C "$PACTL" subscribe 2>/dev/null \ | while IFS= read -r line; do case "$line" in - *"'new' on sink"*|*"'change' on card"*) : ;; + *"'new' on sink"*|*"'change' on card"*) + # Re-entering the tool keeps the decision out of this + # read loop, which must never block on the graph. + "$0" headphones jack || true ;; esac done then status=0; else status=$?; fi @@ -122,7 +155,7 @@ let wait_for_pulse || sleep 1 done ;; *) - echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2 + echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]|headphones [trigger]]" >&2 exit 64 ;; esac ''; diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 6d6e405..8f83ba0 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -325,11 +325,6 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid" awaiting_lid_open= log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; } - is_internal() { - [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ] && [ "$1" = "$NOMARCHY_INTERNAL_OUTPUT" ] \ - && return 0 - printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(eDP|LVDS|DSI)' - } internal_output() { if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return @@ -429,12 +424,17 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList case "$v" in true|True) return 0 ;; *) return 1 ;; esac } outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } + in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; } paint_outputs() { - # awww does not inherit the current image when Hyprland adds an output. - # 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 + # awww does not inherit the current image when Hyprland adds an output, + # and it only paints outputs its daemon has already registered — which + # lags the compositor's own monitoradded by an unpredictable margin. + # Re-apply a few times over the first few seconds rather than betting + # on one delay. Backgrounded so profile matching never waits on paint. + for delay in 0.5 1.5 3; do + sleep "$delay" + ${sync} --quiet wallpaper >/dev/null 2>&1 || true + done } check_monitors() { @@ -454,68 +454,109 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList fi } - # Login/relogin while already docked still needs protection. Do not - # change the layout here: the baked config/manual profile remains SoT. - internal=$(internal_output) - [ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor - - # Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect - # after a compositor reload/socket hiccup. `-T 1` also gives the parent - # shell a periodic chance to observe lid-open and release the inhibitor. - # Process substitution is deliberate: a pipeline `... | while read` - # would run the loop in a subshell and lose inhibitor lifecycle state. - socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" - while :; do - while IFS= read -r line; do - case "$line" in - "monitorremoved>>"*) - removed=''${line#monitorremoved>>} - internal=$(internal_output) - # monitorremoved is emitted while the departing output may still - # be in `monitors all`. Exclude its event name to decide whether - # this was the final external; restore BEFORE teardown settles. - others=$(external_outputs | ${pkgs.gnugrep}/bin/grep -Fxv "$removed" || true) - if ! is_internal "$removed" && [ -z "$others" ] && [ -n "$internal" ]; then - "$TRANSITION" undock "$internal" || true - awaiting_lid_open=1 - log "event=monitorremoved output=$removed transition=undock" - fi - check_monitors - ;; - "monitoradded>>"*) - added=''${line#monitoradded>>} - if ! is_internal "$added"; then - internal=$(internal_output) - [ -z "$internal" ] || acquire_inhibitor - command -v nomarchy-dock-audio >/dev/null 2>&1 \ - && nomarchy-dock-audio reprobe monitoradded & - log "event=monitoradded output=$added audio-reprobe=scheduled" - fi - paint_outputs & - check_monitors - ;; - esac - done < <(${pkgs.socat}/bin/socat -T 1 -U - UNIX-CONNECT:"$socket" 2>/dev/null) - connected_external=$(external_outputs) + # The set of connected outputs — not the IPC event stream — is the source + # of truth. Hyprland's socket can miss or drop events (see the loop + # below), and a lost monitoradded used to mean no docking profile, no + # wallpaper on the new screen and no audio follow until something else + # poked the watcher. `reconcile` diffs the output set against the last + # one it acted on, so an event only makes the reaction *instant* — never + # necessary. Everything here is idempotent and safe to call on a tick. + # + # $1 (optional) = an output Hyprland just announced as removed. It may + # still be listed in `monitors all` while teardown settles, so exclude it + # and restore the internal panel before that lands. + reconcile() { + hint="''${1:-}" + cur=$(outputs) + [ -n "$cur" ] || return internal=$(internal_output) - if [ -n "$internal" ] && [ -n "$connected_external" ]; then - # Socket reconnect may have hidden the add event. Recover the safety - # invariant (but do not call audio: only an observed fresh event is - # allowed to override a manual sink choice). + ext=$(external_outputs) + if [ -n "$hint" ]; then + cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true) + ext=$(printf '%s\n' "$ext" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true) + fi + + if [ "$cur" != "$known_outputs" ]; then + added_ext= + for o in $ext; do + in_list "$known_outputs" "$o" || added_ext="$added_ext $o" + done + gone= + for o in $known_outputs; do + in_list "$cur" "$o" || gone="$gone $o" + done + known_outputs=$cur + + if [ -n "$added_ext" ]; then + [ -z "$internal" ] || acquire_inhibitor + # An external output that was not there a moment ago is an + # unambiguous physical plug whether or not we saw the IPC event, + # so it is allowed to override a manual sink choice. + command -v nomarchy-dock-audio >/dev/null 2>&1 \ + && nomarchy-dock-audio reprobe monitoradded & + paint_outputs & + log "outputs-changed added=$added_ext audio-reprobe=scheduled" + fi + # awaiting_lid_open marks an undock already performed for this + # departure — the guard is what keeps event + tick from doubling up. + if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \ + && [ -z "$awaiting_lid_open" ]; then + "$TRANSITION" undock "$internal" || true + awaiting_lid_open=1 + log "outputs-changed removed=$gone transition=undock" + fi + check_monitors + fi + + # Safety invariants, re-checked regardless of whether the set moved. + if [ -n "$internal" ] && [ -n "$ext" ]; then if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then acquire_inhibitor || true fi - elif [ -n "$inhibitor_pid" ] && [ -z "$awaiting_lid_open" ]; then - # Likewise, reconcile a removal missed during an IPC reconnect. - internal=$(internal_output) - [ -n "$internal" ] && "$TRANSITION" undock "$internal" || true - awaiting_lid_open=1 - log "event=reconcile transition=undock" fi - if [ -n "$awaiting_lid_open" ] && [ -z "$(external_outputs)" ] && lid_open; then + if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then release_inhibitor awaiting_lid_open= fi + } + + # Login/relogin while already docked still needs protection. Do not + # change the layout here: the baked config/manual profile remains SoT, + # so seed known_outputs with what is already connected. + internal=$(internal_output) + [ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor + known_outputs=$(outputs) + + # Listen to Hyprland's IPC socket for instant hotplug reaction, and + # reconnect after a compositor reload/socket hiccup. The connection is + # deliberately long-lived: `socat -T 1` used to close it after one second + # of desktop idle, and a dock plugged while idle — the normal case — + # landed in the reconnect gap and was lost outright. A 1s `read` timeout + # supplies the same periodic tick without ever dropping the socket. + # Process substitution is deliberate: a pipeline `... | while read` would + # run the loop in a subshell and lose inhibitor lifecycle state. + socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + while :; do + exec 3< <(${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" 2>/dev/null) + while :; do + hint= + if IFS= read -r -t 1 line <&3; then + case "$line" in + "monitorremoved>>"*) hint=''${line#monitorremoved>>} ;; + "monitoradded>>"*) : ;; + # socket2 is chatty (focus, workspaces, …); only monitor events + # are worth a reconcile, the idle tick covers everything else. + *) continue ;; + esac + else + # bash returns >128 for the read timeout (our tick); anything else + # is EOF or error, i.e. the socket died and needs a reconnect. + rc=$? + [ "$rc" -gt 128 ] || break + fi + reconcile "$hint" + done + exec 3<&- sleep 0.25 done ''; @@ -616,6 +657,30 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList tool=${keyboardTool}/bin/nomarchy-keyboard-layout keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; } + + # Hyprland calls every evdev node that can emit a key a "keyboard": the + # lid and power buttons, a monitor's control channel, and each extra HID + # collection a real keyboard exposes. One dock with one keyboard on it + # therefore used to ask which layout to use four times, for devices that + # cannot type. udev already draws this distinction properly — it sets + # ID_INPUT_KEYBOARD only for full typing keyboards — so take the answer + # from there and translate the names into Hyprland's own spelling: + # spaces to '-', then lowercase (its deviceNameToInternalString). + typing_keyboards() { + for dev in /sys/class/input/event*; do + [ -r "$dev/device/name" ] || continue + ${pkgs.systemd}/bin/udevadm info -q property -p "$dev" 2>/dev/null \ + | ${pkgs.gnugrep}/bin/grep -q '^ID_INPUT_KEYBOARD=1' || continue + IFS= read -r name < "$dev/device/name" || continue + printf '%s\n' "$name" \ + | ${pkgs.coreutils}/bin/tr ' ' '-' | ${pkgs.coreutils}/bin/tr 'A-Z' 'a-z' + done | ${pkgs.coreutils}/bin/sort -u + } + + # Hyprland disambiguates same-named devices with a -1, -2, … suffix, so a + # device's physical keyboard is its name minus that suffix. + base_name() { printf '%s\n' "$1" | ${pkgs.gnused}/bin/sed -E 's/-[0-9]+$//'; } # Startup: re-apply remembered layouts, never prompt — so the built-in # keyboard just stays on the session default. @@ -630,20 +695,40 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList while :; do sleep 3 cur=" " + new= 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 + new="$new $kb" done prev="$cur" + # Nothing arrived: stay off udev, this is the every-3s common path. + [ -n "$new" ] || continue + + typing=$(typing_keyboards) + handled=" " + for kb in $new; do + b=$(base_name "$kb") + in_list "$typing" "$b" || continue + case "$handled" in *" $b "*) continue ;; esac + handled="$handled$b " + # Ask once for the physical keyboard, then answer for every node it + # brought with it — they are one keyboard and share one layout. + group= + for k in $new; do + [ "$(base_name "$k")" = "$b" ] && group="$group $k" + done + s=$("$tool" saved "$b") + if [ -n "$s" ]; then + for k in $group; do "$tool" restore "$k" || true; done + else + choice=$("$tool" layouts \ + | rofi -monitor -1 -dmenu -i -p "Keyboard layout · $b (e.g. us, gb, pt, br)") + [ -n "$choice" ] || continue + for k in $group; do "$tool" apply "$k" "$choice"; done + fi + done done ''; in