From 42801548b72545475c455d03ef5983e6eb14f725 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Mon, 15 Jun 2026 10:22:18 +0100 Subject: [PATCH] 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 --- README.md | 2 ++ docs/ROADMAP.md | 18 ++++++---- modules/home/hyprland.nix | 30 ++++++++++++++-- modules/home/options.nix | 76 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9526f48..49e6e64 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**. | `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming | | `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo | | `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt | +| `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) | +| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback | | `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed | | `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) | | `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 33efced..a231375 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -233,12 +233,18 @@ how to override it. Items marked ✓ are shipped. surface or split (`nomarchy.services.*` system-side vs home-side packages); and how it relates to `nomarchy.apps.*` (opt-out suite). Unfree entries are already covered (`allowUnfree = true`). -- **Display / monitor management:** today Hyprland just uses - `monitor = ,preferred,auto,1` (mkDefault). Add a real external-monitor/dock - story: declarative per-output config, hotplug profiles (kanshi-style, or - Hyprland's own monitor rules), and optionally a GUI arranger (nwg-displays - writes Hyprland monitor config). Decide declarative-only vs GUI, and the - option shape (`nomarchy.monitors` vs plain Hyprland `monitor` lists). +- ✓ **Display / monitor management:** declarative `nomarchy.monitors` (a + per-output schema — name/resolution/position/scale/transform/mirror/ + bitdepth/vrr/disable) generates Hyprland `monitor` rules, keeping the + `,preferred,auto,1` wildcard as the fallback; Hyprland applies them on + hotplug, so a declared external/dock output arranges itself on connect + (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. - **Night light / blue-light filter:** schedulable colour-temperature shift via `hyprsunset` (Hyprland-native; wlsunset/gammastep are alternatives) — sunset/sunrise or a fixed schedule, with a menu + Waybar toggle. diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 13260da..668a4e2 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -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 ]; } diff --git a/modules/home/options.nix b/modules/home/options.nix index 0a328b7..7144989 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -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` | `x@` | `disable`."; + }; + position = lib.mkOption { + type = lib.types.str; + default = "auto"; + description = "`auto` | `auto-right`/`auto-left`/… | `x`."; + }; + 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 0–7 (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 {