feat(display): menu-driven per-output resolution, in-flake
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.<name> <mode> --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 <noreply@anthropic.com>
This commit is contained in:
@@ -592,10 +592,15 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
(no kanshi — it fights Hyprland's own output management). `nwg-displays`
|
(no kanshi — it fights Hyprland's own output management). `nwg-displays`
|
||||||
ships behind `nomarchy.displays.enable` as an interactive arranger (a
|
ships behind `nomarchy.displays.enable` as an interactive arranger (a
|
||||||
helper to find values; the declarative config stays the source of truth —
|
helper to find values; the declarative config stays the source of truth —
|
||||||
its output file isn't sourced). Remaining: true docked/undocked **profile
|
its output file isn't sourced). The System ▸ **Display** menu picks an
|
||||||
switching** of the *same* outputs (Hyprland's per-output rules cover the
|
output's resolution from its advertised modes: applied live via `hyprctl
|
||||||
common "external connects → arrange" case, not multi-layout toggles), and
|
keyword monitor` (current position + scale kept) and persisted to the
|
||||||
optionally workspace-to-monitor binding.
|
in-flake state (`settings.monitors.<name>`, 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) —
|
- ✓ **Night light / blue-light filter:** `nomarchy.nightlight` (opt-in) —
|
||||||
a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native),
|
a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native),
|
||||||
warm at night, identity (no shift) by day. `modules/home/nightlight.nix`
|
warm at night, identity (no shift) by day. `modules/home/nightlight.nix`
|
||||||
|
|||||||
@@ -59,6 +59,32 @@ let
|
|||||||
++ lib.optional (m.extra != "") m.extra
|
++ 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
|
# Candidate layouts offered by the new-keyboard picker: the session
|
||||||
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
||||||
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
|
# 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
|
# and Hyprland applies them on hotplug. mkDefault so a downstream
|
||||||
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
|
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
|
||||||
monitor = lib.mkDefault
|
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
|
# exec-once is a list at normal priority, so downstream additions
|
||||||
# CONCATENATE (your autostart runs alongside ours) rather than
|
# CONCATENATE (your autostart runs alongside ours) rather than
|
||||||
|
|||||||
@@ -280,6 +280,56 @@ ${themeRows}
|
|||||||
*Input*) exec rofi-pulse-select source ;;
|
*Input*) exec rofi-pulse-select source ;;
|
||||||
esac ;;
|
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.<name>, --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)
|
capture)
|
||||||
choice=$( {
|
choice=$( {
|
||||||
row "Region → clipboard" applets-screenshooter
|
row "Region → clipboard" applets-screenshooter
|
||||||
@@ -382,6 +432,7 @@ ${themeRows}
|
|||||||
row "Bluetooth" bluetooth
|
row "Bluetooth" bluetooth
|
||||||
[ -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
|
||||||
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" notification-disabled
|
row "Do Not Disturb" notification-disabled
|
||||||
if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
|
if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
|
||||||
@@ -404,6 +455,7 @@ ${themeRows}
|
|||||||
*VPN*) exec "$0" vpn ;;
|
*VPN*) exec "$0" vpn ;;
|
||||||
*Bluetooth*) exec "$0" bluetooth ;;
|
*Bluetooth*) exec "$0" bluetooth ;;
|
||||||
*Audio*) exec "$0" audio ;;
|
*Audio*) exec "$0" audio ;;
|
||||||
|
*Display*) exec "$0" display ;;
|
||||||
*Printers*) exec "$0" printers ;;
|
*Printers*) exec "$0" printers ;;
|
||||||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||||||
*"Night light"*) exec "$0" nightlight ;;
|
*"Night light"*) exec "$0" nightlight ;;
|
||||||
@@ -434,7 +486,7 @@ ${themeRows}
|
|||||||
esac ;;
|
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 ;;
|
exit 64 ;;
|
||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ let
|
|||||||
# settings.keyboard.devices: per-device layouts the new-keyboard watcher
|
# settings.keyboard.devices: per-device layouts the new-keyboard watcher
|
||||||
# remembers (device-name -> XKB layout), git-tracked instead of
|
# remembers (device-name -> XKB layout), git-tracked instead of
|
||||||
# ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
|
# ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
|
||||||
# rebuild. Defaulted so a sparse/older state file still evaluates;
|
# rebuild. settings.monitors: per-output resolutions the Display menu
|
||||||
# nomarchy.settings exposes them.
|
# 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 = {
|
settings = {
|
||||||
nightlight = { installed = false; on = true; };
|
nightlight = { installed = false; on = true; };
|
||||||
keyboard.devices = { };
|
keyboard.devices = { };
|
||||||
|
monitors = { };
|
||||||
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
|
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
|
||||||
# service, but the flag lives here so both sides read one source — the
|
# service, but the flag lives here so both sides read one source — the
|
||||||
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
||||||
|
|||||||
Reference in New Issue
Block a user