fix(docking): make clamshell hotplug safe and discoverable
All checks were successful
Check / eval (push) Successful in 3m32s
All checks were successful
Check / eval (push) Successful in 3m32s
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.
This commit is contained in:
10
README.md
10
README.md
@@ -237,7 +237,7 @@ two tables below are split along exactly that line.
|
|||||||
| `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) |
|
| `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) |
|
||||||
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
|
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
|
||||||
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
|
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
|
||||||
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
|
| `nomarchy.keyboard.layouts` | `[]` | Optional layouts to put first in the new-keyboard picker; the watcher runs by default, offers every installed XKB layout, and remembers the choice per-device. Pick/change one anytime at System › Keyboard |
|
||||||
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; set BOTH `.latitude`/`.longitude` for **geo mode** (wlsunset computes sunrise/sunset from your location; the fixed times are ignored); off by default — enable it from the menu (System › Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible |
|
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; set BOTH `.latitude`/`.longitude` for **geo mode** (wlsunset computes sunrise/sunset from your location; the fixed times are ignored); off by default — enable it from the menu (System › Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible |
|
||||||
| `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
|
| `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
|
||||||
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
||||||
@@ -245,7 +245,7 @@ two tables below are split along exactly that line.
|
|||||||
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
||||||
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
||||||
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
|
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
|
||||||
| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on hotplug, with a toast; EasyEffects follows along; unplug falls back to built-in |
|
| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on new-sink or card/port-availability hotplug events, with a toast; EasyEffects follows along; unplug falls back to built-in. Manual route: System › Audio |
|
||||||
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout |
|
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout |
|
||||||
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) |
|
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) |
|
||||||
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
||||||
@@ -370,9 +370,9 @@ generations` is also your theme history. Waybar even restyles in place: it
|
|||||||
re-reads `style.css` when the symlink flips.
|
re-reads `style.css` when the symlink flips.
|
||||||
|
|
||||||
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
|
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
|
||||||
imperative; nothing in Nix consumes the path): applied at session start and
|
imperative; nothing in Nix consumes the path): applied at session start,
|
||||||
after every switch via a tiny activation hook, cycled instantly with
|
after every switch via a tiny activation hook, and again when a monitor is
|
||||||
`bg next`.
|
hotplugged; cycled instantly with `bg next`.
|
||||||
|
|
||||||
### Config the menu writes (not just themes)
|
### Config the menu writes (not just themes)
|
||||||
|
|
||||||
|
|||||||
@@ -11,54 +11,27 @@ the **T14s** (webcam case).
|
|||||||
|
|
||||||
## Any machine (dev box is fine)
|
## Any machine (dev box is fine)
|
||||||
|
|
||||||
- [ ] **#86 clamshell lid + external display** — V2 asserts
|
- [ ] **Docking correction round 3 (2026-07-13 hardware-QA follow-up)** —
|
||||||
`HandleLidSwitchDocked=ignore` in logind.conf. On a laptop with a
|
prior result: lid-close stayed awake, but SUPER+M rendered on the
|
||||||
real external monitor: open a session, plug external, close lid —
|
hidden laptop workspace; the keyboard never prompted; audio stayed
|
||||||
machine must **stay awake** (no suspend); external panel remains
|
on speakers; the old laptop-off action lost some applications and
|
||||||
usable (or wakes with keyboard if DPMS). Unplug external, close lid
|
abrupt undock left eDP black. After updating to the commit carrying
|
||||||
— should suspend as usual. Optional: with a docked display-profile
|
this entry and rebuilding Home Manager, start with the laptop open
|
||||||
(`eDP-1` disable), confirm clamshell layout still looks right after
|
and play audio. Close the lid and plug the monitor/keyboard: SUPER+M
|
||||||
lid close. Pass = no surprise suspend when docked.
|
must render on the external focused workspace; the new keyboard must
|
||||||
- [ ] **#87 round 2: dock audio auto-follow (watcher)** — round 1 result
|
ask for a searchable XKB layout (or set/change it at System ›
|
||||||
(Newton, 2026-07-12): audio stayed on speakers when docking; the
|
Keyboard); audio must toast and move to the monitor. System › Audio ›
|
||||||
running generation had no `90-nomarchy-dock-audio` rules yet, and
|
Output device remains the manual fallback; the current wallpaper must
|
||||||
live testing showed the priority rules alone can never win anyway —
|
cover the external output/workspaces within ~2s. Open Display and choose
|
||||||
WirePlumber's stored default (`default-nodes` state, written by any
|
**Dock mode**: all eDP workspaces/windows move intact to the external
|
||||||
past explicit pick) outranks `priority.session`, and this machine
|
and focus follows; no terminal/application exits. Yank the cable: the
|
||||||
(like most) has one. Fix shipped: the `nomarchy-dock-audio` user
|
internal panel is already the logical fallback, lights immediately,
|
||||||
service `pactl set-default-sink`s dock-class sinks on hotplug;
|
and the moved workspaces/windows survive. Replug then manually choose
|
||||||
EasyEffects follows the default (verified live: EE's output links
|
speakers: volume/mute changes must not force the monitor again; only a
|
||||||
moved to a fake dock-named null sink mid-playback). Retest after
|
fresh replug should. If audio still misses, capture `pactl subscribe`
|
||||||
pull+rebuild+home on ≥ this rev: play audio, plug the dock →
|
during plug plus `pactl --format=json list sinks` while docked. If the
|
||||||
toast "Audio → <monitor/dock>" and sound moves with EE effects
|
menu is still misplaced, capture `hyprctl monitors -j` immediately
|
||||||
still active; unplug → sound returns to speakers; while docked,
|
before SUPER+M (the `.focused` field is the discriminator).
|
||||||
picking speakers in the Audio menu sticks until the next replug.
|
|
||||||
If it does NOT switch, capture for diagnosis: `pactl subscribe`
|
|
||||||
output during the plug, `pactl --format=json list sinks` while
|
|
||||||
docked (sink names may not match the dock regexes, or the sink
|
|
||||||
node may pre-exist and only flip route availability — the watcher
|
|
||||||
keys on new-sink events).
|
|
||||||
- [ ] **Docked display/audio menu rows** (2026-07-12) — with a real
|
|
||||||
external monitor: Display menu shows "Laptop screen off ·
|
|
||||||
everything → <ext>" (only with >1 active output), "Screen on ·
|
|
||||||
<name>" after a disable, "Move workspace · → next monitor", "Swap
|
|
||||||
workspaces" (exactly 2 outputs). Laptop-off must move its
|
|
||||||
workspaces to the external; Screen on restores it (preferred/auto —
|
|
||||||
declared scale/pos return at the next relogin/switch). Audio menu
|
|
||||||
shows "Send output → <dock sink>" when one exists and isn't
|
|
||||||
default; picking it moves output + toasts. Pass = rows appear/act
|
|
||||||
as described, and none of them show on a single-display session.
|
|
||||||
Also — **run this one with nothing important open**: with the
|
|
||||||
laptop screen off via the menu, physically unplug the dock. The
|
|
||||||
panel should re-enable within ~2s (`rescue_blackout` in the
|
|
||||||
display watcher). Known risk from the VM harness
|
|
||||||
(tools/monitor-fallback.nix): on Hyprland 0.55.4 the zero-output
|
|
||||||
state itself can crash the compositor faster than any rescue
|
|
||||||
(softGL VM: 4/5 runs; control with the panel active survives
|
|
||||||
always) — a crash on real DRM ends the session to the greeter on
|
|
||||||
the re-lit panel. If that happens on hardware, the "Laptop screen
|
|
||||||
off" row needs a rework (mirror instead of disable, or drop until
|
|
||||||
a Hyprland bump) — file it as a NOW bug with the coredump.
|
|
||||||
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
|
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
|
||||||
box, 2026-07-12) — with a finger enrolled and
|
box, 2026-07-12) — with a finger enrolled and
|
||||||
`fingerprint.pam = true` (parallel is the default): `sudo -k true`
|
`fingerprint.pam = true` (parallel is the default): `sudo -k true`
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ Template:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-13 — Docking hardware-QA corrections (interactive feedback)
|
||||||
|
- **Task:** Bernardo's real clamshell QA: menu on hidden eDP, no keyboard
|
||||||
|
layout route, audio stayed on speakers, laptop-off lost apps, abrupt
|
||||||
|
undock never restored eDP.
|
||||||
|
- **Did:** Rofi → focused output; replaced eDP disable with safe Dock mode
|
||||||
|
(move workspaces/focus, keep fallback); display watcher now unconditional,
|
||||||
|
reconnecting and race-tolerant. Keyboard watcher runs by default with all
|
||||||
|
XKB layouts + System › Keyboard. Audio also reacts to card/port changes;
|
||||||
|
monitor-added re-applies the current awww wallpaper to the new output.
|
||||||
|
Corrected the fallback harness assumption; added `checks.docking-ux` and
|
||||||
|
synced README/template/ROADMAP/hardware queue.
|
||||||
|
- **Verified:** V1 — `nix flake check --no-build`; `checks.docking-ux`
|
||||||
|
built the real HM generation and passed syntax/invariant/layout-catalog
|
||||||
|
assertions; option docs 90/90, template SoT 4/4; Nix parse + Python
|
||||||
|
compile + diff check. V2 unavailable (no readable KVM).
|
||||||
|
- **Pending:** hardware retest in HARDWARE-QUEUE (docking correction round 3).
|
||||||
|
- **Next suggestion:** hardware retest this exact dock sequence.
|
||||||
|
|
||||||
## 2026-07-12 — #89 slice 3: Documented Signal tray icon limitation (no commit)
|
## 2026-07-12 — #89 slice 3: Documented Signal tray icon limitation (no commit)
|
||||||
- **Task:** BACKLOG #89 slice 3 — Signal's tray icon is full-colour blue and doesn't match the monochrome bar.
|
- **Task:** BACKLOG #89 slice 3 — Signal's tray icon is full-colour blue and doesn't match the monochrome bar.
|
||||||
- **Did:** Researched Signal's Linux tray icon behavior. Found it is a hardcoded, dynamically generated pixmap (`Signal_status_icon_1`) driven by its Electron wrapper with no built-in toggle for a monochrome icon. Added an entry to `docs/ROADMAP.md`'s known issues noting that certain Electron apps cannot be natively themed. Closed out #89 from the backlog without introducing brittle `app.asar` repacking hacks.
|
- **Did:** Researched Signal's Linux tray icon behavior. Found it is a hardcoded, dynamically generated pixmap (`Signal_status_icon_1`) driven by its Electron wrapper with no built-in toggle for a monochrome icon. Added an entry to `docs/ROADMAP.md`'s known issues noting that certain Electron apps cannot be natively themed. Closed out #89 from the backlog without introducing brittle `app.asar` repacking hacks.
|
||||||
@@ -608,4 +626,3 @@ Template:
|
|||||||
- **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2)
|
- **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2)
|
||||||
and 3 (Look & Feel menu) remain under #79.
|
and 3 (Look & Feel menu) remain under #79.
|
||||||
- **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest).
|
- **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest).
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ iteration would otherwise rediscover.
|
|||||||
- Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar
|
- Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar
|
||||||
underscore effects); `stayfocused 1` is `invalid field type` at parse
|
underscore effects); `stayfocused 1` is `invalid field type` at parse
|
||||||
(§ polkit workspace rules, 2026-07-10 hardware).
|
(§ polkit workspace rules, 2026-07-10 hardware).
|
||||||
|
- Never gate a safety listener behind the optional feature it also serves:
|
||||||
|
the display menu existed with no profiles while its blackout rescue did
|
||||||
|
not. Rofi defaults to mouse-pointer output (`monitor=-5`), which is stale
|
||||||
|
in clamshell mode; use focused output (`-1`) for keyboard-launched UI.
|
||||||
- Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
|
- Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
|
||||||
the old rule-first `float, class:^…$` no longer parses — both surface a
|
the old rule-first `float, class:^…$` no longer parses — both surface a
|
||||||
red config-error banner on the default desktop. Hyprlang legacy form is
|
red config-error banner on the default desktop. Hyprlang legacy form is
|
||||||
|
|||||||
@@ -416,14 +416,9 @@ Design/decision records and a running log of shipped work (items marked
|
|||||||
`useDefaultOutputDevice` defaults true and verified live — with a
|
`useDefaultOutputDevice` defaults true and verified live — with a
|
||||||
stream playing, EE's output links moved to a fake dock-named null sink
|
stream playing, EE's output links moved to a fake dock-named null sink
|
||||||
and back on unload. Unplug = WirePlumber priority fallback to built-in.
|
and back on unload. Unplug = WirePlumber priority fallback to built-in.
|
||||||
Docked menu rows shipped alongside: Display gains "Laptop screen off ·
|
Docked menu rows shipped alongside (the original panel-disable action is
|
||||||
everything → <ext>" (live-only `keyword monitor <eDP>,disable` — a
|
superseded by the hardware-QA correction below); Audio has a self-gated
|
||||||
persisted disable could black-screen an undocked boot), "Screen on ·
|
"Send output → <dock sink>" quick row plus the full output picker.
|
||||||
<name>", "Move workspace · → next monitor"
|
|
||||||
(`movecurrentworkspacetomonitor +1`), "Swap workspaces"
|
|
||||||
(`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated
|
|
||||||
"Send output → <dock sink>" quick row (the watcher's manual override).
|
|
||||||
Real-dock V3 for both in HARDWARE-QUEUE.
|
|
||||||
✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the
|
✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the
|
||||||
panel comes back if you undock while "Laptop screen off" — VM answer
|
panel comes back if you undock while "Laptop screen off" — VM answer
|
||||||
(tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland
|
(tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland
|
||||||
@@ -439,10 +434,28 @@ Design/decision records and a running log of shipped work (items marked
|
|||||||
survives 5/5, so it's the state, not headless removal; monitor-
|
survives 5/5, so it's the state, not headless removal; monitor-
|
||||||
disconnect crashes are a known upstream class). Posture: rescue ships
|
disconnect crashes are a known upstream class). Posture: rescue ships
|
||||||
as defense-in-depth (recovers every surviving case; worst case on real
|
as defense-in-depth (recovers every surviving case; worst case on real
|
||||||
DRM is session-to-greeter on the re-lit panel, not a black brick);
|
DRM is session-to-greeter on the re-lit panel, not a black brick).
|
||||||
V3 decides if the menu row needs a mirror-based rework. Also fixed in
|
Also fixed in the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
|
||||||
the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
|
|
||||||
pipeline silently disabled the rescue exactly when it mattered.
|
pipeline silently disabled the rescue exactly when it mattered.
|
||||||
|
✓ **Docking hardware-QA correction (2026-07-13):** real clamshell QA
|
||||||
|
confirmed every warned residual: Rofi's default `monitor=-5` followed
|
||||||
|
the mouse pointer onto the hidden laptop workspace; the profile watcher
|
||||||
|
(and blackout rescue) was not launched with default
|
||||||
|
`displayProfiles = {}`; disabling eDP then unplugging failed to restore
|
||||||
|
it, with application loss consistent with the known zero-output crash;
|
||||||
|
audio did not arrive as a new sink; and the keyboard picker was disabled
|
||||||
|
by an empty optional-candidate list. Correction: Rofi targets the focused
|
||||||
|
output (`monitor=-1`); interactive **Dock mode** explicitly moves eDP
|
||||||
|
workspaces/focus to the external but keeps eDP logically active as the
|
||||||
|
unplug fallback; the reconnecting display watcher runs unconditionally
|
||||||
|
and watches through the racy removal snapshot; keyboard hotplug runs by
|
||||||
|
default, searches the pinned XKB catalog without D-Bus, persists
|
||||||
|
per-device, and has a manual System › Keyboard path; dock audio handles new sinks and
|
||||||
|
card/profile/port-availability changes while ignoring volume/default
|
||||||
|
changes so manual picks stick; and monitor-added re-applies the current
|
||||||
|
awww wallpaper after the output settles (new outputs do not inherit the
|
||||||
|
existing image). `checks.docking-ux` guards the generated default-session
|
||||||
|
artifacts; the corrected real sequence remains V3.
|
||||||
✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted
|
✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted
|
||||||
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
|
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
|
||||||
**true** — the better UX is what opting into fingerprint PAM gets you;
|
**true** — the better UX is what opting into fingerprint PAM gets you;
|
||||||
|
|||||||
44
flake.nix
44
flake.nix
@@ -779,6 +779,50 @@
|
|||||||
in
|
in
|
||||||
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
|
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
|
||||||
|
|
||||||
|
# Docking artifact guard: default/no-profile Home Manager output
|
||||||
|
# must still launch both hotplug watchers, put Rofi on the focused
|
||||||
|
# monitor, expose the manual keyboard picker, and never ship the
|
||||||
|
# zero-output-prone "Laptop screen off" menu action again.
|
||||||
|
docking-ux =
|
||||||
|
let
|
||||||
|
hm = self.nixosConfigurations.nomarchy-live.config
|
||||||
|
.home-manager.users.${username};
|
||||||
|
gen = hm.home.activationPackage;
|
||||||
|
hyprconf = hm.xdg.configFile."hypr/hyprland.conf".source;
|
||||||
|
audio = toString hm.systemd.user.services.nomarchy-dock-audio.Service.ExecStart;
|
||||||
|
in
|
||||||
|
assert hm.programs.rofi.extraConfig.monitor == -1;
|
||||||
|
pkgs.runCommand "nomarchy-docking-ux"
|
||||||
|
{ nativeBuildInputs = [ pkgs.bash pkgs.findutils pkgs.gnugrep ]; }
|
||||||
|
''
|
||||||
|
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)
|
||||||
|
watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1)
|
||||||
|
test -n "$menu" -a -n "$keyboard" -a -n "$watcher"
|
||||||
|
bash -n "$menu"
|
||||||
|
bash -n "$keyboard"
|
||||||
|
bash -n "$watcher"
|
||||||
|
bash -n ${audio}
|
||||||
|
"$keyboard" layouts > layouts.txt
|
||||||
|
test "$(wc -l < layouts.txt)" -gt 50
|
||||||
|
grep -qx us layouts.txt
|
||||||
|
grep -qx gb layouts.txt
|
||||||
|
grep -qx pt layouts.txt
|
||||||
|
grep -q 'nomarchy-keyboard-watch' ${hyprconf}
|
||||||
|
grep -q 'nomarchy-menu keyboard' ${hyprconf}
|
||||||
|
grep -q 'nomarchy-display-profile-watch' ${hyprconf}
|
||||||
|
grep -q -- '--quiet wallpaper' "$watcher"
|
||||||
|
grep -q 'Dock mode' "$menu"
|
||||||
|
grep -q 'moveworkspacetomonitor' "$menu"
|
||||||
|
grep -q 'nomarchy-keyboard-layout' "$menu"
|
||||||
|
! grep -q 'Laptop screen off' "$menu"
|
||||||
|
grep -q "'change':card" ${audio}
|
||||||
|
grep -q 'availability' ${audio}
|
||||||
|
echo "docking-ux: focused menu + safe display + keyboard/audio watchers present"
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
# Night-light geo mode: with BOTH lat/long set the backing unit
|
# Night-light geo mode: with BOTH lat/long set the backing unit
|
||||||
# flips hyprsunset → wlsunset (location-computed sunrise/sunset)
|
# flips hyprsunset → wlsunset (location-computed sunrise/sunset)
|
||||||
# and the live-state ExecCondition gate survives the swap (the
|
# and the live-state ExecCondition gate survives the swap (the
|
||||||
|
|||||||
@@ -46,28 +46,47 @@ let
|
|||||||
"Output → ''${2:-$1}" 2>/dev/null || true
|
"Output → ''${2:-$1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Return dock-class sinks whose current port is usable. PipeWire often
|
||||||
|
# creates the HDMI/DP node before the cable is plugged and later flips its
|
||||||
|
# card/port availability, so presence alone is not a hotplug signal.
|
||||||
|
dock_sinks() {
|
||||||
|
$PACTL --format=json list sinks \
|
||||||
|
| $JQ -r --arg re '^(${dockSinkRe})$' '
|
||||||
|
.[]
|
||||||
|
| select(.name | test($re))
|
||||||
|
| select(
|
||||||
|
((.ports // []) | length) == 0
|
||||||
|
or any(.ports[]?; (.availability // "unknown") != "not available")
|
||||||
|
)
|
||||||
|
| "\(.name)\t\(.description)"'
|
||||||
|
}
|
||||||
|
|
||||||
|
adopt_first_dock() {
|
||||||
|
dock_sinks | while IFS="$TAB" read -r name desc; do
|
||||||
|
[ -n "$name" ] || continue
|
||||||
|
switch_to "$name" "$desc"
|
||||||
|
break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Startup sweep (login/relogin while already docked): adopt a dock
|
# Startup sweep (login/relogin while already docked): adopt a dock
|
||||||
# sink only when the current default is NOT one — a service restart
|
# sink only when the current default is NOT one — a service restart
|
||||||
# must not override a manual in-dock pick.
|
# must not override a manual in-dock pick.
|
||||||
if ! is_dock "$($PACTL get-default-sink)"; then
|
if ! is_dock "$($PACTL get-default-sink)"; then
|
||||||
$PACTL --format=json list sinks \
|
adopt_first_dock
|
||||||
| $JQ -r '.[] | "\(.name)\t\(.description)"' \
|
|
||||||
| while IFS="$TAB" read -r name desc; do
|
|
||||||
if is_dock "$name"; then switch_to "$name" "$desc"; break; fi
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Event loop: `pactl subscribe` prints "Event 'new' on sink #NN".
|
# Event loop: handle both a genuinely new sink and the common case where
|
||||||
|
# an existing HDMI/DP card changes profile/port availability. Deliberately
|
||||||
|
# ignore ordinary sink "change" events (volume/mute) and server-default
|
||||||
|
# changes, so a manual speaker pick while docked remains sticky.
|
||||||
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
|
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
|
||||||
[ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue
|
case "$ev:$kind" in
|
||||||
id=''${id#\#}
|
"'new':sink"|"'new':card"|"'change':card") ;;
|
||||||
case "$id" in *[!0-9]*|"") continue ;; esac
|
*) continue ;;
|
||||||
sleep 0.5 # let the node's description/routes settle
|
esac
|
||||||
line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \
|
sleep 0.75 # let PipeWire finish the profile/route transition
|
||||||
'.[] | select(.index == $i) | "\(.name)\t\(.description)"')
|
adopt_first_dock
|
||||||
[ -n "$line" ] || continue
|
|
||||||
name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"}
|
|
||||||
if is_dock "$name"; then switch_to "$name" "$desc"; fi
|
|
||||||
done
|
done
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -215,13 +215,21 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
}
|
}
|
||||||
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
|
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
|
# Blackout rescue — the session must never end up with ZERO active
|
||||||
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
|
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
|
||||||
# the last active one disconnects (VM-verified, tools/
|
# the last active one disconnects (VM-verified, tools/
|
||||||
# monitor-fallback.nix): menu "Laptop screen off" + sudden undock
|
# monitor-fallback.nix): the former panel-off menu action (and still-valid
|
||||||
# left a dead session. On every monitorremoved, if nothing is
|
# named profiles that disable eDP) + sudden undock left a dead session.
|
||||||
# active, re-enable whatever is disabled (preferred/auto — the
|
# 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
|
# declared rule returns at the next reload/relogin). Independent of
|
||||||
# the profile auto-switch and its settings gate: this is a safety
|
# the profile auto-switch and its settings gate: this is a safety
|
||||||
# invariant, not a layout preference.
|
# invariant, not a layout preference.
|
||||||
@@ -237,17 +245,23 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
# ~0.5s of the last output going while it persisted, so shrinking
|
# ~0.5s of the last output going while it persisted, so shrinking
|
||||||
# the window is the priority; the keyword is idempotent and the
|
# the window is the priority; the keyword is idempotent and the
|
||||||
# retries cover a first attempt racing the removal teardown.
|
# retries cover a first attempt racing the removal teardown.
|
||||||
tries=0
|
tries=0 rescued=
|
||||||
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do
|
# monitorremoved can arrive just before the departing output disappears
|
||||||
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \
|
# from `hyprctl monitors`; keep a short bounded watch instead of deciding
|
||||||
| while read -r m; do
|
# from that racy first snapshot. The first check is still immediate.
|
||||||
[ -n "$m" ] || continue
|
while [ "$tries" -lt 8 ]; do
|
||||||
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1
|
if [ "$(active_count)" = 0 ]; then
|
||||||
done
|
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))
|
tries=$((tries+1))
|
||||||
sleep 1
|
sleep 0.25
|
||||||
done
|
done
|
||||||
if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then
|
if [ -n "$rescued" ] && [ "$(active_count)" != 0 ]; then
|
||||||
notify-send "Display" "Screen re-enabled — the active output disappeared." 2>/dev/null || true
|
notify-send "Display" "Screen re-enabled — the active output disappeared." 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -257,6 +271,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
[ "$cur" = "$1" ] && return
|
[ "$cur" = "$1" ] && return
|
||||||
[ -n "$cur" ] || return
|
[ -n "$cur" ] || return
|
||||||
auto_on || return
|
auto_on || return
|
||||||
|
command -v nomarchy-display-profile >/dev/null 2>&1 || return
|
||||||
|
|
||||||
target=$(nomarchy-display-profile match $cur) || target="base"
|
target=$(nomarchy-display-profile match $cur) || target="base"
|
||||||
|
|
||||||
@@ -271,20 +286,27 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
}
|
}
|
||||||
|
|
||||||
prev=$(outputs)
|
prev=$(outputs)
|
||||||
|
|
||||||
# Listen to Hyprland's IPC socket for instant hotplug reaction
|
# Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect
|
||||||
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
|
# after a compositor reload/socket hiccup instead of silently losing the
|
||||||
case "$line" in
|
# safety listener for the rest of the session.
|
||||||
monitorremoved*)
|
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
||||||
rescue_blackout
|
while :; do
|
||||||
res=$(check_monitors "$prev")
|
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" | while read -r line; do
|
||||||
[ -n "$res" ] && prev="$res"
|
case "$line" in
|
||||||
;;
|
monitorremoved*)
|
||||||
monitoradded*)
|
rescue_blackout
|
||||||
res=$(check_monitors "$prev")
|
res=$(check_monitors "$prev")
|
||||||
[ -n "$res" ] && prev="$res"
|
[ -n "$res" ] && prev="$res"
|
||||||
;;
|
;;
|
||||||
esac
|
monitoradded*)
|
||||||
|
paint_outputs &
|
||||||
|
res=$(check_monitors "$prev")
|
||||||
|
[ -n "$res" ] && prev="$res"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
sleep 1
|
||||||
done
|
done
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -299,7 +321,67 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||||||
++ config.nomarchy.keyboard.layouts
|
++ config.nomarchy.keyboard.layouts
|
||||||
);
|
);
|
||||||
kbAutoSwitch = 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
|
# On a keyboard that connects after login (not declared in
|
||||||
# keyboard.devices, not already remembered), ask for a layout and persist it
|
# keyboard.devices, not already remembered), ask for a layout and persist it
|
||||||
@@ -320,34 +402,18 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
# test (hotplug isn't verifiable in CI).
|
# test (hotplug isn't verifiable in CI).
|
||||||
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
||||||
set -u
|
set -u
|
||||||
layouts="${lib.concatStringsSep " " pickerLayouts}"
|
|
||||||
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
||||||
sync=${sync}
|
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
|
||||||
|
|
||||||
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
|
||||||
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
||||||
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
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
|
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||||
# keyboard just stays on the session default.
|
# keyboard just stays on the session default.
|
||||||
prev=" "
|
prev=" "
|
||||||
for kb in $(keyboards); do
|
for kb in $(keyboards); do
|
||||||
prev="$prev$kb "
|
prev="$prev$kb "
|
||||||
is_declared "$kb" && continue
|
is_declared "$kb" && continue
|
||||||
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
|
"$tool" restore "$kb" || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Watch for keyboards that connect later.
|
# Watch for keyboards that connect later.
|
||||||
@@ -358,12 +424,13 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
cur="$cur$kb "
|
cur="$cur$kb "
|
||||||
case "$prev" in *" $kb "*) continue ;; esac
|
case "$prev" in *" $kb "*) continue ;; esac
|
||||||
is_declared "$kb" && continue
|
is_declared "$kb" && continue
|
||||||
s=$(saved_for "$kb")
|
s=$("$tool" saved "$kb")
|
||||||
if [ -n "$s" ]; then
|
if [ -n "$s" ]; then
|
||||||
apply "$kb" "$s"
|
"$tool" restore "$kb" || true
|
||||||
else
|
else
|
||||||
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
|
choice=$("$tool" layouts \
|
||||||
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
|
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $kb (e.g. us, gb, pt, br)")
|
||||||
|
[ -n "$choice" ] && "$tool" apply "$kb" "$choice"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
prev="$cur"
|
prev="$cur"
|
||||||
@@ -420,8 +487,10 @@ in
|
|||||||
# the clean pkill a theme switch now does — respawns the bar instead
|
# the clean pkill a theme switch now does — respawns the bar instead
|
||||||
# of orphaning the session bar-less.
|
# of orphaning the session bar-less.
|
||||||
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
|
] ++ 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";
|
"${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||||
|
"${displayProfileWatch}/bin/nomarchy-display-profile-watch"
|
||||||
|
];
|
||||||
|
|
||||||
# ── Theme-driven look ──────────────────────────────────────────
|
# ── Theme-driven look ──────────────────────────────────────────
|
||||||
# These flow from theme-state.json and stay at NORMAL priority:
|
# These flow from theme-state.json and stay at NORMAL priority:
|
||||||
@@ -636,11 +705,10 @@ in
|
|||||||
home.packages =
|
home.packages =
|
||||||
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
||||||
[ pkgs.nwg-displays ]
|
[ pkgs.nwg-displays ]
|
||||||
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
|
# Runtime hardware watchers/helpers stay on PATH for manual recovery and
|
||||||
# runnable by name for debugging — Hyprland still exec-once's it by store path.
|
# debugging. The display-profile switcher itself remains gated so the
|
||||||
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
|
# menu does not advertise an empty Profiles submenu.
|
||||||
# The display-profile switcher (the menu's Profiles row gates on it)
|
++ lib.optionals config.nomarchy.hyprland.enable
|
||||||
# + its hotplug watcher, on PATH for debugging like the keyboard one.
|
([ keyboardTool keyboardWatch displayProfileWatch ]
|
||||||
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
|
++ lib.optional (profiles != { }) displayProfileTool);
|
||||||
[ displayProfileTool displayProfileWatch ];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; }
|
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; }
|
||||||
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; }
|
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; }
|
||||||
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
|
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
|
||||||
|
{ mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; }
|
||||||
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
||||||
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; }
|
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; }
|
||||||
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
||||||
|
|||||||
@@ -128,13 +128,13 @@ in
|
|||||||
default = [ ];
|
default = [ ];
|
||||||
example = [ "de" "fr" ];
|
example = [ "de" "fr" ];
|
||||||
description = ''
|
description = ''
|
||||||
Extra candidate layouts offered by the interactive new-keyboard
|
Extra candidate layouts shown first by the interactive new-keyboard
|
||||||
picker. When non-empty, a small watcher runs in the session: when a
|
picker (all installed XKB layouts remain searchable). A small watcher
|
||||||
keyboard connects that isn't covered by nomarchy.keyboard.devices and
|
always runs in the Hyprland session: when a keyboard connects that
|
||||||
hasn't been chosen before, it pops a rofi picker (these layouts plus
|
isn't covered by nomarchy.keyboard.devices and hasn't been chosen
|
||||||
the primary nomarchy.keyboard.layout), applies the choice to that
|
before, it pops a rofi picker, applies the choice to that keyboard only
|
||||||
keyboard only (a per-device kb_layout, so the built-in board is left
|
(a per-device kb_layout, so the built-in board is left alone), and
|
||||||
alone), and remembers it in the git-tracked in-flake state
|
remembers it in the git-tracked in-flake state
|
||||||
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
||||||
automatically on later reconnects and across reboots. Each remembered
|
automatically on later reconnects and across reboots. Each remembered
|
||||||
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ let
|
|||||||
# one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
|
# one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
|
||||||
# collide with clipboard or filename content.
|
# collide with clipboard or filename content.
|
||||||
BACK="↩ Back"
|
BACK="↩ Back"
|
||||||
|
TAB=$(printf '\t')
|
||||||
back() { printf '%s\n' "$BACK"; }
|
back() { printf '%s\n' "$BACK"; }
|
||||||
|
|
||||||
# dmenu mode is case-sensitive by default (dmenu compatibility), even
|
# dmenu mode is case-sensitive by default (dmenu compatibility), even
|
||||||
@@ -515,7 +516,12 @@ ${themeRows}
|
|||||||
sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true)
|
sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true)
|
||||||
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
|
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
|
||||||
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \
|
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \
|
||||||
'[.[] | select((.name | test("^(" + $re + ")$")) and .name != $cur)][0].name // empty')
|
'[.[]
|
||||||
|
| select((.name | test("^(" + $re + ")$")) and .name != $cur)
|
||||||
|
| select(
|
||||||
|
((.ports // []) | length) == 0
|
||||||
|
or any(.ports[]?; (.availability // "unknown") != "not available")
|
||||||
|
)][0].name // empty')
|
||||||
dockdesc=
|
dockdesc=
|
||||||
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
|
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
|
||||||
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
|
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
|
||||||
@@ -534,6 +540,33 @@ ${themeRows}
|
|||||||
*Input*) exec rofi-pulse-select source ;;
|
*Input*) exec rofi-pulse-select source ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
|
keyboard)
|
||||||
|
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Keyboard" "Per-device layout helper is unavailable."; exit 0; }
|
||||||
|
devices=$(nomarchy-keyboard-layout devices)
|
||||||
|
[ -n "$devices" ] \
|
||||||
|
|| { notify-send "Keyboard" "No keyboards reported by Hyprland."; exit 0; }
|
||||||
|
picked=$( {
|
||||||
|
printf '%s\n' "$devices" | while IFS="$TAB" read -r dev active; do
|
||||||
|
printf '%s · %s\n' "$dev" "$active"
|
||||||
|
done
|
||||||
|
back
|
||||||
|
} | rofi_menu -p "Keyboard") || exit 0
|
||||||
|
[ "$picked" = "$BACK" ] && exec "$0" system
|
||||||
|
device=''${picked%% ·*}
|
||||||
|
saved=$(nomarchy-keyboard-layout saved "$device")
|
||||||
|
layout=$( {
|
||||||
|
[ -n "$saved" ] && printf 'Use session default · forget %s\n' "$saved"
|
||||||
|
nomarchy-keyboard-layout layouts
|
||||||
|
back
|
||||||
|
} | rofi_menu -p "Layout · $device (now: ''${saved:-session default})") || exit 0
|
||||||
|
case "$layout" in
|
||||||
|
"$BACK") exec "$0" keyboard ;;
|
||||||
|
"Use session default"*) exec nomarchy-keyboard-layout forget "$device" ;;
|
||||||
|
"") exit 0 ;;
|
||||||
|
*) exec nomarchy-keyboard-layout apply "$device" "$layout" ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
display)
|
display)
|
||||||
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
|
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
|
||||||
# `hyprctl keyword monitor` — keeping the output's current position and
|
# `hyprctl keyword monitor` — keeping the output's current position and
|
||||||
@@ -566,11 +599,12 @@ ${themeRows}
|
|||||||
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
|
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
|
||||||
name=$( {
|
name=$( {
|
||||||
[ -n "$profs" ] && printf 'Profiles ›\n'
|
[ -n "$profs" ] && printf 'Profiles ›\n'
|
||||||
# Docked rows — self-gated: never offer to disable the only
|
# Dock mode deliberately keeps the internal panel logically
|
||||||
# active display; the re-enable row appears whenever an
|
# active as the unplug fallback. Soft-disabling it can leave
|
||||||
# output is soft-off (so an undocked laptop can recover too).
|
# Hyprland with zero outputs and kill the whole session before a
|
||||||
|
# user-space rescue runs. Move its workspaces and focus instead.
|
||||||
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
|
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
|
||||||
&& printf 'Laptop screen off · everything → %s\n' "$external"
|
&& printf 'Dock mode · everything → %s (safe unplug)\n' "$external"
|
||||||
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
|
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
|
||||||
[ "$nActive" -gt 1 ] && printf 'Move workspace · → next monitor\n'
|
[ "$nActive" -gt 1 ] && printf 'Move workspace · → next monitor\n'
|
||||||
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s ⇄ %s\n' \
|
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s ⇄ %s\n' \
|
||||||
@@ -582,16 +616,16 @@ ${themeRows}
|
|||||||
[ "$name" = "$BACK" ] && exec "$0" system
|
[ "$name" = "$BACK" ] && exec "$0" system
|
||||||
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
||||||
case "$name" in
|
case "$name" in
|
||||||
"Laptop screen off"*)
|
"Dock mode"*)
|
||||||
# Live-only on purpose: Hyprland moves the panel's
|
moved=0
|
||||||
# workspaces to the remaining output on disable; persisting
|
while read -r ws; do
|
||||||
# a disable could black-screen an undocked boot, so the
|
[ -n "$ws" ] || continue
|
||||||
# declared rule returns at the next relogin/switch.
|
hyprctl dispatch moveworkspacetomonitor "$ws" "$external" >/dev/null 2>&1 \
|
||||||
if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then
|
&& moved=$((moved+1))
|
||||||
notify-send "Display" "Laptop screen off — workspaces moved to $external (this session; turn it back on from this menu)."
|
done < <(hyprctl workspaces -j | jq -r --arg m "$internal" \
|
||||||
else
|
'.[] | select(.monitor == $m and .id > 0) | .id')
|
||||||
notify-send "Display" "Could not disable $internal."
|
hyprctl dispatch focusmonitor "$external" >/dev/null 2>&1
|
||||||
fi
|
notify-send "Display" "Dock mode — $moved workspace(s) moved to $external; laptop panel kept as the unplug fallback."
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
"Screen on"*)
|
"Screen on"*)
|
||||||
# preferred/auto/auto is good enough live; the declared
|
# preferred/auto/auto is good enough live; the declared
|
||||||
@@ -1106,6 +1140,8 @@ ${themeRows}
|
|||||||
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
|
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
|
||||||
&& row "Audio" audio-volume-high
|
&& row "Audio" audio-volume-high
|
||||||
row "Display" video-display
|
row "Display" video-display
|
||||||
|
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||||||
|
&& row "Keyboard${menuHint "keyboard"}" preferences-desktop-keyboard
|
||||||
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
|
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
|
||||||
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
|
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
|
||||||
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
|
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
|
||||||
@@ -1145,6 +1181,7 @@ ${themeRows}
|
|||||||
*Bluetooth*) exec "$0" bluetooth ;;
|
*Bluetooth*) exec "$0" bluetooth ;;
|
||||||
*Audio*) exec "$0" audio ;;
|
*Audio*) exec "$0" audio ;;
|
||||||
*Display*) exec "$0" display ;;
|
*Display*) exec "$0" display ;;
|
||||||
|
*Keyboard*) exec "$0" keyboard ;;
|
||||||
*Printers*) exec "$0" printers ;;
|
*Printers*) exec "$0" printers ;;
|
||||||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||||||
*"Auto timezone"*) exec "$0" autotimezone ;;
|
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||||||
@@ -1262,7 +1299,7 @@ ${themeRows}
|
|||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2
|
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2
|
||||||
exit 64 ;;
|
exit 64 ;;
|
||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
@@ -1477,6 +1514,10 @@ in
|
|||||||
|
|
||||||
extraConfig = {
|
extraConfig = {
|
||||||
modi = "drun,run";
|
modi = "drun,run";
|
||||||
|
# Rofi defaults to the output containing the mouse pointer (-5).
|
||||||
|
# After docking with the lid shut that pointer can remain on the hidden
|
||||||
|
# laptop workspace while keyboard focus is on the external display.
|
||||||
|
monitor = -1; # Hyprland's currently focused output
|
||||||
show-icons = true; # app icons via the theme's icon set
|
show-icons = true; # app icons via the theme's icon set
|
||||||
icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme)
|
icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme)
|
||||||
drun-display-format = "{name}";
|
drun-display-format = "{name}";
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
# history is generation history.
|
# history is generation history.
|
||||||
#
|
#
|
||||||
# The one runtime exception is the wallpaper (swww is imperative by
|
# The one runtime exception is the wallpaper (swww is imperative by
|
||||||
# nature): applied at session start, after every switch (hook below),
|
# nature): applied at session start, after every switch (hook below), and
|
||||||
# and cycled instantly with `nomarchy-theme-sync bg next`.
|
# after output hotplug (hyprland.nix); cycled instantly with
|
||||||
|
# `nomarchy-theme-sync bg next`.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ def cmd_get(args) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_wallpaper(_args) -> None:
|
def cmd_wallpaper(_args) -> None:
|
||||||
"""Apply the current wallpaper (session start, post-switch hook)."""
|
"""Apply the current wallpaper (session start, switch, output hotplug)."""
|
||||||
apply_wallpaper(load_state(), wait=True)
|
apply_wallpaper(load_state(), wait=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,9 @@
|
|||||||
# "keychron-keychron-k2" = { layout = "de"; };
|
# "keychron-keychron-k2" = { layout = "de"; };
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new
|
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # optional: put these first in the
|
||||||
# # keyboard connects + remember it in the
|
# # new-keyboard picker (all installed XKB
|
||||||
# # flake state (graduates to .devices above)
|
# # layouts are searchable without this)
|
||||||
|
|
||||||
# ── Application suite ───────────────────────────────────────────────
|
# ── Application suite ───────────────────────────────────────────────
|
||||||
# A starter complete-workstation set, installed for your user — yours to
|
# A starter complete-workstation set, installed for your user — yours to
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Undock blackout-rescue harness — boots the full desktop headlessly
|
# Undock blackout-rescue harness — boots the full desktop headlessly
|
||||||
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the
|
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the
|
||||||
# dangerous sequence: add a headless "external", soft-disable the
|
# dangerous profile edge: add a headless "external", soft-disable the
|
||||||
# internal panel (the Display menu's "Laptop screen off" command), then
|
# internal panel (a named profile can still do this), then remove the
|
||||||
# remove the external. Hyprland does NOT re-enable a disabled monitor on
|
# external. The interactive Display menu no longer disables the panel: its
|
||||||
|
# safe Dock mode moves workspaces/focus but keeps eDP as the unplug fallback.
|
||||||
|
# Hyprland does NOT re-enable a profile-disabled monitor on
|
||||||
# its own (verified 2026-07-12 — the session ends with zero active
|
# its own (verified 2026-07-12 — the session ends with zero active
|
||||||
# outputs), so the display-profile watcher's rescue_blackout
|
# outputs), so the display-profile watcher's rescue_blackout
|
||||||
# (modules/home/hyprland.nix) must bring the panel back; this asserts it
|
# (modules/home/hyprland.nix) must bring the panel back; this asserts it
|
||||||
@@ -63,6 +65,9 @@ pkgs.testers.runNixOSTest {
|
|||||||
machine.wait_for_unit("multi-user.target")
|
machine.wait_for_unit("multi-user.target")
|
||||||
machine.wait_for_file("/run/user/1000/hypr", 180)
|
machine.wait_for_file("/run/user/1000/hypr", 180)
|
||||||
machine.sleep(20) # softGL first paint settle
|
machine.sleep(20) # softGL first paint settle
|
||||||
|
machine.wait_until_succeeds(
|
||||||
|
"pgrep -af nomarchy-display-profile-watch >/dev/null", timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
|
hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
|
||||||
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ")
|
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ")
|
||||||
@@ -85,7 +90,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
ext = [n for n in names if n != internal][0]
|
ext = [n for n in names if n != internal][0]
|
||||||
print(f"external (headless): {ext}")
|
print(f"external (headless): {ext}")
|
||||||
|
|
||||||
# Menu action: "Laptop screen off" — the exact command the row runs.
|
# Named display-profile edge: eDP is disabled before an abrupt undock.
|
||||||
machine.succeed(hy + f"keyword monitor {internal},disable'")
|
machine.succeed(hy + f"keyword monitor {internal},disable'")
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
if [x["name"] for x in mons()] == [ext]: break
|
if [x["name"] for x in mons()] == [ext]: break
|
||||||
|
|||||||
Reference in New Issue
Block a user