feat(displays): declarative nomarchy.monitors + nwg-displays arranger

Add a per-output monitor schema (name / resolution / position / scale /
transform / mirror / bitdepth / vrr / disable) that generates Hyprland
`monitor` rules, keeping the ,preferred,auto,1 wildcard as the fallback.
Hyprland applies the rules on hotplug, so a declared external/dock output
arranges itself on connect (no kanshi -- it fights Hyprland's own output
management). Default [] -> wildcard only, so no change for existing setups;
set wayland.windowManager.hyprland.settings.monitor directly to override.

nwg-displays ships behind nomarchy.displays.enable (default true) as an
interactive arranger -- a helper to discover values; the declarative config
stays the source of truth (its output file isn't sourced).

Remaining (roadmap): docked/undocked profile switching, workspace-to-monitor
binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-15 10:22:18 +01:00
parent 1b0eeeaf6c
commit 42801548b7
4 changed files with 117 additions and 9 deletions

View File

@@ -41,6 +41,19 @@ let
# (rofi.nix renders the same list). Edit them in ./keybinds.nix.
keybinds = import ./keybinds.nix;
mkBind = b: "${b.mods}, ${b.key}, ${b.action}";
# A nomarchy.monitors entry -> a Hyprland `monitor` rule. Unset optional
# fields are omitted; `resolution = "disable"` collapses to the short form.
monitorRule = m:
if m.resolution == "disable" then "${m.name}, disable"
else lib.concatStringsSep ", " (
[ m.name m.resolution (toString m.position) (toString m.scale) ]
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ]
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
++ lib.optional (m.extra != "") m.extra
);
in
{
wayland.windowManager.hyprland = lib.mkIf config.nomarchy.hyprland.enable {
@@ -59,9 +72,12 @@ in
"$mod" = "SUPER";
"$terminal" = config.nomarchy.terminal;
# mkDefault so a downstream `monitor = [...]` replaces it with a
# plain assignment (the live ISO uses mkForce for the same reason).
monitor = lib.mkDefault [ ",preferred,auto,1" ];
# `,preferred,auto,1` is the wildcard fallback for any output not in
# nomarchy.monitors; the generated rules are appended and win by name,
# and Hyprland applies them on hotplug. mkDefault so a downstream
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
monitor = lib.mkDefault
([ ",preferred,auto,1" ] ++ map monitorRule config.nomarchy.monitors);
# exec-once is a list at normal priority, so downstream additions
# CONCATENATE (your autostart runs alongside ours) rather than
@@ -183,4 +199,12 @@ in
];
};
};
# nwg-displays: interactive monitor arranger (applies live via hyprctl and
# writes a config file). A helper to find values for nomarchy.monitors —
# the declarative config stays the source of truth (we don't source its
# output). Gated on its toggle; pointless without the Hyprland session.
home.packages = lib.optionals
(config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
[ pkgs.nwg-displays ];
}

View File

@@ -2,6 +2,60 @@
# configure in their home.nix. Kept small on purpose.
{ lib, pkgs, ... }:
let
# One output's layout — turned into a Hyprland `monitor` rule in
# hyprland.nix. Friendlier than raw positional CSV; unset optional fields
# are omitted from the rule.
monitorType = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
example = "HDMI-A-1";
description = "Output name (see `hyprctl monitors`), or a `desc:<>` match.";
};
resolution = lib.mkOption {
type = lib.types.str;
default = "preferred";
description = "`preferred` | `highres` | `highrr` | `<w>x<h>@<hz>` | `disable`.";
};
position = lib.mkOption {
type = lib.types.str;
default = "auto";
description = "`auto` | `auto-right`/`auto-left`/ | `<x>x<y>`.";
};
scale = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.types.int lib.types.float ];
default = 1;
description = "Scale factor (1, 1.5, ) or `auto`.";
};
transform = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Rotation 07 (90°/180°/270° = 1/2/3).";
};
mirror = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Mirror another output by name.";
};
bitdepth = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Colour bit depth (8 or 10).";
};
vrr = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Variable refresh: 0 off, 1 on, 2 fullscreen-only.";
};
extra = lib.mkOption {
type = lib.types.str;
default = "";
description = "Extra comma-separated Hyprland monitor args, appended verbatim.";
};
};
};
in
{
options.nomarchy = {
# ── Required ───────────────────────────────────────────────────
@@ -60,6 +114,27 @@
'';
};
monitors = lib.mkOption {
type = lib.types.listOf monitorType;
default = [ ];
example = lib.literalExpression ''
[
{ name = "eDP-1"; position = "0x0"; }
{ name = "HDMI-A-1"; position = "auto-right"; scale = 1; }
]
'';
description = ''
Declarative per-output monitor layout. Each entry becomes a Hyprland
`monitor` rule; Hyprland applies them on hotplug, so a declared
external/dock output arranges itself when connected. The built-in
`,preferred,auto,1` wildcard stays as the fallback for any output you
don't list. For raw control set
`wayland.windowManager.hyprland.settings.monitor` directly instead
(it replaces this). Run `nwg-displays` (nomarchy.displays.enable) to
find the right values interactively.
'';
};
# ── Component toggles ──────────────────────────────────────────
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
@@ -74,6 +149,7 @@
ghostty.enable = lib.mkEnableOption "Nomarchy's Ghostty configuration" // { default = true; };
btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };
# ── Computed (read-only) ───────────────────────────────────────
theme = lib.mkOption {