From 4024da791f58643779a727290a24f703c5d16d93 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 30 Jun 2026 23:08:39 +0100 Subject: [PATCH] feat(display): menu-driven per-output resolution, in-flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the in-flake-state philosophy (mirroring night-light + the keyboard-layout watcher): a System ▸ Display menu item picks an output's resolution and persists it into the flake instead of a runtime-only tweak. - rofi.nix: the `display` case reads `hyprctl monitors -j` (auto-skips the chooser with one output), lists the output's advertised modes (rounded refresh, deduped, highest-first) plus the preferred/highres/highrr tokens, applies the pick INSTANTLY via `hyprctl keyword monitor` — keeping the output's current position + scale, so only the resolution changes — then persists it with `nomarchy-theme-sync set settings.monitors. --no-switch` (no rebuild). - theme.nix: seed `settings.monitors = {}` (output-name -> "WxH@R") so a sparse/older state file still evaluates; exposed via nomarchy.settings. - hyprland.nix: `resolvedMonitors` overlays settings.monitors onto nomarchy.monitors by name — a declared output keeps its other fields with only the resolution swapped, an undeclared output (wildcard-only, e.g. the laptop panel) becomes a fresh rule. So a later rebuild bakes the menu pick into the generated monitor rule — the monitor twin of the keyboard graduation. The menu pick wins over a hand-set resolution (it's the explicit live action). Monitor connector names are dot-free, so a direct dotted set-path is safe (no whole-map read-modify-write like the keyboard watcher needs). VM-verified via a headless-Hyprland nixosTest (run + removed; too heavy for a CI gate): the graduation baked the rule into the generated hyprland.conf, the set/get round-trip persisted, and `hyprctl keyword monitor` actually changed the reported resolution. Only the interactive rofi picker isn't headlessly driveable (same caveat as the keyboard watcher). Co-Authored-By: Claude Opus 4.8 --- docs/ROADMAP.md | 13 +++++++--- modules/home/hyprland.nix | 28 +++++++++++++++++++- modules/home/rofi.nix | 54 ++++++++++++++++++++++++++++++++++++++- modules/home/theme.nix | 8 ++++-- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ebb5df1..e8ed14b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -592,10 +592,15 @@ how to override it. Items marked ✓ are shipped. (no kanshi — it fights Hyprland's own output management). `nwg-displays` ships behind `nomarchy.displays.enable` as an interactive arranger (a helper to find values; the declarative config stays the source of truth — - its output file isn't sourced). Remaining: true docked/undocked **profile - switching** of the *same* outputs (Hyprland's per-output rules cover the - common "external connects → arrange" case, not multi-layout toggles), and - optionally workspace-to-monitor binding. + its output file isn't sourced). The System ▸ **Display** menu picks an + output's resolution from its advertised modes: applied live via `hyprctl + keyword monitor` (current position + scale kept) and persisted to the + in-flake state (`settings.monitors.`, no rebuild), overlaid onto + `nomarchy.monitors` by name so the next rebuild bakes it in — the monitor + twin of the keyboard-layout graduation. Remaining: true docked/undocked + **profile switching** of the *same* outputs (Hyprland's per-output rules + cover the common "external connects → arrange" case, not multi-layout + toggles), and optionally workspace-to-monitor binding. - ✓ **Night light / blue-light filter:** `nomarchy.nightlight` (opt-in) — a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native), warm at night, identity (no shift) by day. `modules/home/nightlight.nix` diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 97196ad..f5bf6fc 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -59,6 +59,32 @@ let ++ lib.optional (m.extra != "") m.extra ); + # Menu-remembered per-output resolutions (settings.monitors: output-name -> + # "WxH@R", written instantly by the Display menu — see rofi.nix) overlaid onto + # the declarative nomarchy.monitors by name: a declared output keeps its + # position/scale/etc and only its resolution changes; an output covered solely + # by the `,preferred,auto,1` wildcard (e.g. the laptop's built-in panel) + # becomes a new rule with default position/scale. The menu writes the in-flake + # state with --no-switch (no rebuild) and applies the mode live via hyprctl; + # this bakes the same choice on the next rebuild — the monitor twin of the + # keyboard graduation. The menu pick wins over a hand-set resolution (it's the + # explicit live action); other declared fields are untouched. + resOverrides = config.nomarchy.settings.monitors; + # Skeleton for an output that's only menu-set (mirrors the monitorType defaults). + monitorDefaults = { + resolution = "preferred"; position = "auto"; scale = 1; + transform = null; mirror = null; bitdepth = null; vrr = null; extra = ""; + }; + declaredNames = map (m: m.name) config.nomarchy.monitors; + resolvedMonitors = + map (m: if resOverrides ? ${m.name} + then m // { resolution = resOverrides.${m.name}; } + else m) + config.nomarchy.monitors + ++ lib.mapAttrsToList + (name: res: monitorDefaults // { inherit name; resolution = res; }) + (lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides); + # 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 @@ -163,7 +189,7 @@ in # 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 config.nomarchy.monitors); + ([ ",preferred,auto,1" ] ++ map monitorRule resolvedMonitors); # exec-once is a list at normal priority, so downstream additions # CONCATENATE (your autostart runs alongside ours) rather than diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index bdaaeb4..8ce3b75 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -280,6 +280,56 @@ ${themeRows} *Input*) exec rofi-pulse-select source ;; esac ;; + display) + # Per-output resolution picker. Applies the chosen mode INSTANTLY via + # `hyprctl keyword monitor` — keeping the output's current position and + # scale, so only the resolution changes — and PERSISTS it to the in-flake + # state (settings.monitors., --no-switch = no rebuild), so the + # choice survives logout/reboot. A later rebuild bakes the same + # resolution into the generated monitor rule (hyprland.nix overlays + # settings.monitors onto nomarchy.monitors by name). Self-gated to the + # Hyprland session; guard here too in case it's invoked directly. + command -v hyprctl >/dev/null 2>&1 \ + || { notify-send "Display" "Hyprland is not running."; exit 0; } + mons=$(hyprctl monitors -j) + + # Choose the output (skip the chooser when only one is connected). + if [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then + name=$( { + printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"' + printf '%s\n' "$BACK" + } | rofi -dmenu -p Display ) || exit 0 + [ "$name" = "$BACK" ] && exec "$0" system + name=''${name%% ·*} # strip the " · WxH@R" hint + else + name=$(printf '%s' "$mons" | jq -r '.[0].name') + fi + [ -n "$name" ] || exit 0 + + # Pick a mode: a few Hyprland resolution tokens, then the output's + # advertised modes (rounded refresh rate, deduped, highest first). The + # prompt shows the current mode. + cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \ + '.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"') + mode=$( { + printf 'preferred\nhighres\nhighrr\n' + printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \ + | sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u + printf '%s\n' "$BACK" + } | rofi -dmenu -p "$name (now $cur)" ) || exit 0 + [ "$mode" = "$BACK" ] && exec "$0" display + [ -n "$mode" ] || exit 0 + + # Apply live (keep the output's current position + scale), then persist. + pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"') + scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale') + if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then + nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch + notify-send "Display" "$name → $mode" + else + notify-send "Display" "Could not set $name to $mode." + fi ;; + capture) choice=$( { row "Region → clipboard" applets-screenshooter @@ -382,6 +432,7 @@ ${themeRows} row "Bluetooth" bluetooth [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \ && row "Audio" audio-volume-high + row "Display" video-display command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer row "Do Not Disturb" notification-disabled if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null @@ -404,6 +455,7 @@ ${themeRows} *VPN*) exec "$0" vpn ;; *Bluetooth*) exec "$0" bluetooth ;; *Audio*) exec "$0" audio ;; + *Display*) exec "$0" display ;; *Printers*) exec "$0" printers ;; *"Do Not Disturb"*) exec "$0" dnd ;; *"Night light"*) exec "$0" nightlight ;; @@ -434,7 +486,7 @@ ${themeRows} esac ;; *) - echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|vpn|snapshot]" >&2 + echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|vpn|snapshot]" >&2 exit 64 ;; esac ''; diff --git a/modules/home/theme.nix b/modules/home/theme.nix index b9d9669..4798407 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -61,11 +61,15 @@ let # settings.keyboard.devices: per-device layouts the new-keyboard watcher # remembers (device-name -> XKB layout), git-tracked instead of # ~/.local/state; they graduate into nomarchy.keyboard.devices on the next - # rebuild. Defaulted so a sparse/older state file still evaluates; - # nomarchy.settings exposes them. + # rebuild. settings.monitors: per-output resolutions the Display menu + # remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by + # name in hyprland.nix — the monitor twin of the keyboard graduation. + # Defaulted so a sparse/older state file still evaluates; nomarchy.settings + # exposes them. settings = { nightlight = { installed = false; on = true; }; keyboard.devices = { }; + monitors = { }; # Automatic timezone detection (nomarchy.system.autoTimezone): a system # service, but the flag lives here so both sides read one source — the # home side gates the Waybar-refresh watcher (timezone.nix) on it.