Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package nwg-displays for openSUSE:Factory checked in at 2026-11-10 14:40:35 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/nwg-displays (Old) and /work/SRC/openSUSE:Factory/.nwg-displays.new.3759290 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "nwg-displays" Tue Nov 10 14:40:35 2026 rev:12 rq:1396415 version:0.5.0 Changes: -------- --- /work/SRC/openSUSE:Factory/nwg-displays/nwg-displays.changes 2026-08-28 19:48:22.486057814 +0200 +++ /work/SRC/openSUSE:Factory/.nwg-displays.new.3759290/nwg-displays.changes 2026-11-10 14:41:56.807402399 +0100 @@ -1,0 +2,7 @@ +Mon Nov 9 17:40:44 UTC 2026 - Nassim Lhadj <[email protected]> + +- Update to version 0.5.0: + * fix: respect existing niri includes with additional properties + * feat: add mangowm support + +------------------------------------------------------------------- Old: ---- nwg-displays-0.4.4.tar.gz New: ---- nwg-displays-0.5.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ nwg-displays.spec ++++++ --- /var/tmp/diff_new_pack.03H5Rh/_old 2026-11-10 14:41:57.748441820 +0100 +++ /var/tmp/diff_new_pack.03H5Rh/_new 2026-11-10 14:41:57.751441946 +0100 @@ -17,7 +17,7 @@ Name: nwg-displays -Version: 0.4.4 +Version: 0.5.0 Release: 0 Summary: Output management utility for sway, Hyprland and Niri License: MIT ++++++ nwg-displays-0.4.4.tar.gz -> nwg-displays-0.5.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/README.md new/nwg-displays-0.5.0/README.md --- old/nwg-displays-0.4.4/README.md 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/README.md 2026-10-20 21:06:49.000000000 +0200 @@ -3,14 +3,14 @@ This application is a part of the [nwg-shell](https://nwg-piotr.github.io/nwg-shell) project. -**Nwg-displays** is an output management utility for [sway](https://github.com/swaywm/sway), [Hyprland](https://github.com/hyprwm/Hyprland) and [Niri](https://github.com/niri-wm/niri) +**Nwg-displays** is an output management utility for [sway](https://github.com/swaywm/sway), [Hyprland](https://github.com/hyprwm/Hyprland), [Niri](https://github.com/niri-wm/niri) and [mangowm](https://github.com/mangowm/mango) Wayland compositor, inspired by wdisplays and wlay. The program is expected to: - provide an intuitive GUI to manage multiple displays; - apply settings; - save outputs configuration to a text file; - save workspace -> output assignments to a text file; -- support sway, Hyprland and Niri only. +- support sway, Hyprland, Niri and mangowm only. <img src="https://user-images.githubusercontent.com/20579136/158013748-5b27f742-0e6a-4d82-a5ac-06368b4df008.png" width=640, alt="screenshot"><br> @@ -130,6 +130,20 @@ **Note:** Niri uses dynamic workspaces, so the workspace assignment feature is not available for Niri. +### mangowm + +[Monitors](https://mangowm.github.io/docs/configuration/monitors): + +**Requires `wlr-randr` to query monitor state.** + +The configuration is saved to `~/.config/mango/monitors.conf`. Make sure your config sources this file, e.g. with: + +```text +source_optional=./monitors.conf +``` + +**Note:** Mango uses dynamic tags, so the workspace assignment feature is not available for Mango. + ## Settings The runtime configuration file is placed in your config directory, like `~/.config/nwg-displays/config`. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/langs/en_US.json new/nwg-displays-0.5.0/nwg_displays/langs/en_US.json --- old/nwg-displays-0.4.4/nwg_displays/langs/en_US.json 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/langs/en_US.json 2026-10-20 21:06:49.000000000 +0200 @@ -51,5 +51,11 @@ "no-profiles": "No profiles available", "load": "Load", "profile-bound-wallpapers": "Profile-bound wallpapers", - "profile-bound-wallpapers-tooltip": "Save and load wallpapers together with display profiles" + "profile-bound-wallpapers-tooltip": "Save and load wallpapers together with display profiles", + "mango-hdr-tooltip": "Enables HDR support. \n'Forced' also enables HDR even when the EDID \ndoes not advertise BT.2020/PQ.", + "mango-primary-tooltip": "Makes this monitor the X11 (RandR) primary \noutput for XWayland.", + "mango-hdr-min-lum-tooltip": "(HDR) Mastering display minimum luminance, cd/m² \n(0 = unset).", + "mango-hdr-max-lum-tooltip": "(HDR) Mastering display peak luminance (max_cll), \ncd/m² (0 = unset).", + "mango-hdr-max-avg-lum-tooltip": "(HDR) Max frame-average light level (max_fall), \ncd/m² (0 = unset).", + "mango-custom-mode-tooltip": "Enables a mode not listed in the available modes. \nNot supported on all displays — may cause \na black screen." } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/langs/pl_PL.json new/nwg-displays-0.5.0/nwg_displays/langs/pl_PL.json --- old/nwg-displays-0.4.4/nwg_displays/langs/pl_PL.json 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/langs/pl_PL.json 2026-10-20 21:06:49.000000000 +0200 @@ -58,5 +58,13 @@ "delete-error-message": "Nie można usunąć profilu: {e}", "load-error-missing-data": "Brak danych do załadowania profilu", "load-error-message": "Nie można załadować profilu", - "current-profile": "Profil" + "current-profile": "Profil", + "profile-bound-wallpapers": "Tapety profilu", + "profile-bound-wallpapers-tooltip": "Zapisuj i wczytuj tapetę razem z profilami wyświetlaczy", + "mango-hdr-tooltip": "Włącza wsparcie HDR. \n'Forced' włącza HDR nawet gdy EDID \ntego nie zaleca BT.2020/PQ.", + "mango-primary-tooltip": "Ustawia monitor jako główny dla wyjścia X11 (RandR) \ndla XWayland.", +"mango-hdr-min-lum-tooltip": "(HDR) Minimalna luminancja wyświetlacza wzorcowego, cd/m² \n(0 = nieustawione).", + "mango-hdr-max-lum-tooltip": "(HDR) Szczytowa luminancja wyświetlacza wzorcowego (max_cll), \ncd/m² (0 = nieustawione).", + "mango-hdr-max-avg-lum-tooltip": "(HDR) Maksymalny średni poziom jasności klatki (max_fall), \ncd/m² (0 = nieustawione).", + "mango-custom-mode-tooltip": "Włącza tryb nie występujący w dostępnych trybach. \nMoże spowodować czarny ekran." } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/main.py new/nwg-displays-0.5.0/nwg_displays/main.py --- old/nwg-displays-0.4.4/nwg_displays/main.py 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/main.py 2026-10-20 21:06:49.000000000 +0200 @@ -43,9 +43,17 @@ from nwg_displays.__about__ import __version__ dir_name = os.path.dirname(__file__) + +# Clear orphaned SWAYSOCK variable if the socket file no longer exists on disk +sway_sock = os.getenv("SWAYSOCK") +if sway_sock and not os.path.exists(sway_sock): + eprint("Removing persistent SWAYSOCK env var") + os.environ.pop("SWAYSOCK", None) + sway = os.getenv("SWAYSOCK") is not None hypr = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") is not None niri = os.getenv("NIRI_SOCKET") is not None +mango = os.getenv("MANGO_INSTANCE_SIGNATURE") is not None config_dir = os.path.join(get_config_home(), "nwg-displays") # This was done by mistake, and the config file need to be migrated to the proper path @@ -77,6 +85,14 @@ ) sys.exit(1) +mango_config_dir = os.path.join(get_config_home(), "mango") +if mango and not os.path.isdir(mango_config_dir): + print( + "[Warning] Couldn't find Mango config directory '{}'".format(mango_config_dir), + file=sys.stderr, + ) + sys.exit(1) + # Create empty files if not found if sway: for name in ["outputs", "workspaces"]: @@ -87,8 +103,11 @@ elif niri: for name in ["monitor.kdl"]: create_empty_file(os.path.join(niri_config_dir, name)) +elif mango: + for name in ["monitors.conf"]: + create_empty_file(os.path.join(mango_config_dir, name)) else: - eprint("[Error] Neither sway, Hyprland nor niri detected, terminating") + eprint("[Error] Neither sway, Hyprland, niri nor Mango detected, terminating") sys.exit(1) config = {} @@ -135,6 +154,11 @@ form_mirror = None form_ten_bit = None form_color_mode = None +form_hdr = None +form_primary = None +form_hdr_min_lum = None +form_hdr_max_lum = None +form_hdr_max_avg_lum = None form_sdr_brightness = None form_sdr_saturation = None form_profile_wallpapers = None @@ -356,6 +380,25 @@ form_refresh.set_value(widget.refresh) if form_ten_bit: form_ten_bit.set_active(widget.ten_bit) + if form_hdr: + hdr_ids = { + "": "disabled", + "hdr": "enabled", + "force": "forced", + "hdredid": "enabled" + } + form_hdr.set_active_id(hdr_ids.get(widget.color_mode, "disabled")) + update_hdr_dependent_widgets() + if form_primary: + form_primary.set_active(getattr(widget, "primary", False)) + if form_hdr_min_lum: + form_hdr_min_lum.set_value(getattr(widget, "hdr_min_lum", 0.0)) + if form_hdr_max_lum: + form_hdr_max_lum.set_value(getattr(widget, "hdr_max_lum", 0.0)) + if form_hdr_max_avg_lum: + form_hdr_max_avg_lum.set_value( + getattr(widget, "hdr_max_avg_lum", 0.0) + ) if form_color_mode: form_color_mode.set_active_id(widget.color_mode if widget.color_mode else "") update_color_dependent_widgets(widget.color_mode) @@ -427,6 +470,10 @@ color_mode="", sdr_brightness=1.0, sdr_saturation=1.0, + primary=False, + hdr_min_lum=0.0, + hdr_max_lum=0.0, + hdr_max_avg_lum=0.0, ): super().__init__() # Output properties @@ -458,6 +505,11 @@ self.color_mode = color_mode self.sdr_brightness = sdr_brightness self.sdr_saturation = sdr_saturation + # HDR / primary (Mango) + self.primary = primary + self.hdr_min_lum = hdr_min_lum + self.hdr_max_lum = hdr_max_lum + self.hdr_max_avg_lum = hdr_max_avg_lum # Button properties self.selected = False @@ -552,6 +604,41 @@ selected_output_button.ten_bit = check_btn.get_active() +def on_hdr_selected(combo): + if selected_output_button: + ids = {"disabled": "", "enabled": "hdr", "forced": "force"} + selected_output_button.color_mode = ids.get(combo.get_active_id(), "") + update_hdr_dependent_widgets() + + +def update_hdr_dependent_widgets(): + """Enable the mastering luminance fields only when HDR is enabled.""" + enabled = bool(form_hdr and form_hdr.get_active_id() != "disabled") + for widget in (form_hdr_min_lum, form_hdr_max_lum, form_hdr_max_avg_lum): + if widget: + widget.set_sensitive(enabled) + + +def on_primary_toggled(check_btn): + if selected_output_button: + selected_output_button.primary = check_btn.get_active() + + +def on_hdr_min_lum_changed(spin): + if selected_output_button: + selected_output_button.hdr_min_lum = spin.get_value() + + +def on_hdr_max_lum_changed(spin): + if selected_output_button: + selected_output_button.hdr_max_lum = spin.get_value() + + +def on_hdr_max_avg_lum_changed(spin): + if selected_output_button: + selected_output_button.hdr_max_avg_lum = spin.get_value() + + def is_hdr_mode(color_mode): return color_mode in ["hdr", "hdredid"] @@ -755,6 +842,10 @@ color_mode=item.get("color_mode", ""), sdr_brightness=item.get("sdr_brightness", 1.0), sdr_saturation=item.get("sdr_saturation", 1.0), + primary=item.get("primary", False), + hdr_min_lum=item.get("hdr_min_lum", 0.0), + hdr_max_lum=item.get("hdr_max_lum", 0.0), + hdr_max_avg_lum=item.get("hdr_max_avg_lum", 0.0), ) display_buttons.append(b) @@ -1059,6 +1150,9 @@ if os.getenv("NIRI_SOCKET"): niri_reload_config() GLib.timeout_add(2000, create_display_buttons) + if os.getenv("MANGO_INSTANCE_SIGNATURE"): + mango_reload_config() + GLib.timeout_add(2000, create_display_buttons) if config_dir and profile_name: if config.get("profile-bound-wallpapers", True): @@ -1119,6 +1213,15 @@ confirm_win.close() niri_reload_config() GLib.timeout_add(2000, create_display_buttons) + elif os.getenv("MANGO_INSTANCE_SIGNATURE"): + # For Mango: backup is a list of lines, restore and reload. + # DPMS power state is runtime-only (not stored in monitors.conf), + # so wake all monitors back on after restoring. + save_list_to_text_file(backup, path) + confirm_win.close() + mango_reload_config() + mango_wakeup_all_monitors() + GLib.timeout_add(2000, create_display_buttons) def main(): @@ -1193,6 +1296,25 @@ help="number of Workspaces in use, default: 10", ) + elif mango: + parser.add_argument( + "-m", + "--monitors_path", + type=str, + default="{}/monitors.conf".format(mango_config_dir), + help="path to save the monitors.conf file to, default: {}".format( + "{}/monitors.conf".format(mango_config_dir) + ), + ) + + parser.add_argument( + "-n", + "--num_ws", + type=int, + default=10, + help="number of Workspaces in use, default: 10", + ) + parser.add_argument( "-v", "--version", @@ -1277,6 +1399,37 @@ else: eprint("niri config directory not found!") outputs_path = "" + elif mango: + if os.path.isdir(mango_config_dir): + outputs_path = args.monitors_path + # Check if file is writable + if os.path.lexists(outputs_path): + is_writable = os.access(outputs_path, os.W_OK) + if os.path.islink(outputs_path) and not is_writable: + eprint(f"INFO: '{outputs_path}' is a read-only symlink. Replacing with a writable file.") + tmp_path = f"{outputs_path}.tmp" + try: + with open(outputs_path, "r") as src_file, open(tmp_path, "w") as tmp_file: + tmp_file.write(src_file.read()) + backup_path = f"{outputs_path}.bkp" + counter = 1 + while os.path.lexists(backup_path): + backup_path = f"{outputs_path}.bkp{counter}" + counter += 1 + eprint(f"INFO: Backing up '{outputs_path}' to '{backup_path}'") + os.rename(outputs_path, backup_path) + os.rename(tmp_path, outputs_path) + except Exception as e: + eprint(f"ERROR: Failed to replace read-only symlink: {e}") + elif not os.path.islink(outputs_path) and not is_writable: + eprint(f"INFO: '{outputs_path}' is a read-only file. Making it writable.") + try: + os.chmod(outputs_path, os.stat(outputs_path).st_mode | stat.S_IWUSR) + except Exception as e: + eprint(f"ERROR: Failed to make file writable: {e}") + else: + eprint("Mango config directory not found!") + outputs_path = "" global num_ws num_ws = args.num_ws @@ -1362,9 +1515,9 @@ global form_adaptive_sync form_adaptive_sync = builder.get_object("adaptive-sync") - # sway, Hyprland (vrr) and niri (variable-refresh-rate) all support per-output - # adaptive sync, so the toggle is available on all three. - if sway or hypr or niri: + # sway, Hyprland (vrr), niri (variable-refresh-rate) and mangowm (vrr) all + # support per-output adaptive sync, so the toggle is available on all. + if sway or hypr or niri or mango: form_adaptive_sync.set_label(voc["adaptive-sync"]) form_adaptive_sync.set_tooltip_text(voc["adaptive-sync-tooltip"]) form_adaptive_sync.connect("toggled", on_adaptive_sync_toggled) @@ -1373,7 +1526,7 @@ global form_custom_mode form_custom_mode = builder.get_object("custom-mode") - if sway: + if sway or mango: form_custom_mode.set_label(voc["custom-mode"]) form_custom_mode.set_tooltip_text(voc["custom-mode-tooltip"]) form_custom_mode.connect("toggled", on_custom_mode_toggle) @@ -1478,6 +1631,10 @@ # Niri uses dynamic workspaces, no need for workspace assignment form_workspaces.set_sensitive(False) form_workspaces.set_tooltip_text("Niri uses dynamic workspaces") + elif mango: + # Mango uses dynamic tags, no need for workspace assignment + form_workspaces.set_sensitive(False) + form_workspaces.set_tooltip_text("Mango uses dynamic tags") global form_close form_close = builder.get_object("close") @@ -1488,7 +1645,7 @@ global form_apply form_apply = builder.get_object("apply") form_apply.set_label(voc["apply"]) - if (sway and sway_config_dir) or (hypr and hypr_config_dir) or (niri and niri_config_dir): + if (sway and sway_config_dir) or (hypr and hypr_config_dir) or (niri and niri_config_dir) or (mango and mango_config_dir): form_apply.connect("clicked", on_apply_button, profile_manager) else: form_apply.set_sensitive(False) @@ -1545,6 +1702,61 @@ form_mirror.connect("changed", on_mirror_selected) grid.attach(form_mirror, 7, 4, 1, 1) + if mango: + grid = builder.get_object("grid") + + global form_hdr + form_hdr = Gtk.ComboBoxText() + form_hdr.append("disabled", "Disabled") + form_hdr.append("enabled", "Enabled") + form_hdr.append("forced", "Forced") + form_hdr.set_active_id("disabled") + form_hdr.set_tooltip_text(voc["mango-hdr-tooltip"]) + form_hdr.connect("changed", on_hdr_selected) + # Insert a row below the Size/Refresh/Modes row for the HDR controls. + grid.insert_row(4) + + lbl = Gtk.Label.new("HDR:") + lbl.set_property("halign", Gtk.Align.END) + grid.attach(lbl, 6, 4, 1, 1) + grid.attach(form_hdr, 7, 4, 1, 1) + + global form_hdr_min_lum, form_hdr_max_lum, form_hdr_max_avg_lum + lbl = Gtk.Label.new("Min lum:") + lbl.set_property("halign", Gtk.Align.END) + grid.attach(lbl, 0, 4, 1, 1) + form_hdr_min_lum = Gtk.SpinButton.new_with_range(0, 10000, 1) + form_hdr_min_lum.set_tooltip_text(voc["mango-hdr-min-lum-tooltip"]) + form_hdr_min_lum.connect("value-changed", on_hdr_min_lum_changed) + grid.attach(form_hdr_min_lum, 1, 4, 1, 1) + + lbl = Gtk.Label.new("Max lum:") + lbl.set_property("halign", Gtk.Align.END) + grid.attach(lbl, 2, 4, 1, 1) + form_hdr_max_lum = Gtk.SpinButton.new_with_range(0, 10000, 1) + form_hdr_max_lum.set_tooltip_text(voc["mango-hdr-max-lum-tooltip"]) + form_hdr_max_lum.connect("value-changed", on_hdr_max_lum_changed) + grid.attach(form_hdr_max_lum, 3, 4, 1, 1) + + lbl = Gtk.Label.new("Avg lum:") + lbl.set_property("halign", Gtk.Align.END) + grid.attach(lbl, 4, 4, 1, 1) + form_hdr_max_avg_lum = Gtk.SpinButton.new_with_range(0, 10000, 1) + form_hdr_max_avg_lum.set_tooltip_text(voc["mango-hdr-max-avg-lum-tooltip"]) + form_hdr_max_avg_lum.connect("value-changed", on_hdr_max_avg_lum_changed) + grid.attach(form_hdr_max_avg_lum, 5, 4, 1, 1) + + update_hdr_dependent_widgets() + + # Appended to the end of the checkbox row + global form_primary + form_primary = Gtk.CheckButton.new_with_label("Primary") + form_primary.set_tooltip_text(voc["mango-primary-tooltip"]) + form_primary.connect("toggled", on_primary_toggled) + grid.attach(form_primary, 5, 5, 2, 1) + + form_custom_mode.set_tooltip_text(voc["mango-custom-mode-tooltip"]) + if hypr: # Color management / HDR controls (Hyprland only) grid.insert_row(5) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/profiles.py new/nwg-displays-0.5.0/nwg_displays/profiles.py --- old/nwg-displays-0.4.4/nwg_displays/profiles.py 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/profiles.py 2026-10-20 21:06:49.000000000 +0200 @@ -316,6 +316,10 @@ "color_mode": getattr(db, "color_mode", ""), "sdr_brightness": getattr(db, "sdr_brightness", 1.0), "sdr_saturation": getattr(db, "sdr_saturation", 1.0), + "primary": getattr(db, "primary", False), + "hdr_min_lum": getattr(db, "hdr_min_lum", 0.0), + "hdr_max_lum": getattr(db, "hdr_max_lum", 0.0), + "hdr_max_avg_lum": getattr(db, "hdr_max_avg_lum", 0.0), "active": db.active, } profile_data["displays"].append(display) @@ -407,6 +411,16 @@ if display.get("sdr_brightness") is not None else 1.0 ) + db.primary = display.get("primary", False) + db.hdr_min_lum = float( + display.get("hdr_min_lum", 0.0) + ) + db.hdr_max_lum = float( + display.get("hdr_max_lum", 0.0) + ) + db.hdr_max_avg_lum = float( + display.get("hdr_max_avg_lum", 0.0) + ) db.sdr_saturation = float( display.get("sdr_saturation") if display.get("sdr_saturation") is not None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/settings_applier/settings_applier.py new/nwg-displays-0.5.0/nwg_displays/settings_applier/settings_applier.py --- old/nwg-displays-0.4.4/nwg_displays/settings_applier/settings_applier.py 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/settings_applier/settings_applier.py 2026-10-20 21:06:49.000000000 +0200 @@ -1,12 +1,15 @@ import os import shutil import datetime +import re import json import time from nwg_displays.tools import ( hyprctl, niri_msg, niri_reload_config, + mmsg, + mango_reload_config, save_list_to_text_file, save_kdl_output, ensure_niri_config_include, @@ -32,6 +35,10 @@ SettingsApplier._apply_niri_json( displays, use_desc, outputs_path, profile_data ) + elif os.getenv("MANGO_INSTANCE_SIGNATURE"): + SettingsApplier._apply_mango_json( + displays, use_desc, outputs_path, profile_data + ) elif os.getenv("HYPRLAND_INSTANCE_SIGNATURE"): SettingsApplier._apply_hyprland_json( @@ -266,6 +273,16 @@ config_dir, profile_name, ) + elif os.getenv("MANGO_INSTANCE_SIGNATURE"): + SettingsApplier._apply_mango_gui( + display_buttons, + outputs_activity, + outputs_path, + use_desc, + create_confirm_win_callback, + config_dir, + profile_name, + ) elif os.getenv("SWAYSOCK"): SettingsApplier._apply_sway_gui( @@ -429,6 +446,138 @@ # Pass backup file path and current file path to confirm window if create_confirm_win_callback: create_confirm_win_callback(backup_path, outputs_path, config_dir, profile_name) + @staticmethod + def _mango_rule_line(name, d): + """Build a monitor_rule line for the Mango monitors.conf file.""" + transforms = { + "normal": 0, + "90": 1, + "180": 2, + "270": 3, + "flipped": 4, + "flipped-90": 5, + "flipped-180": 6, + "flipped-270": 7, + } + + parts = [f"name:^{re.escape(name)}$"] + + if not d["active"]: + parts.append("disable:1") + else: + parts.extend([ + f"width:{d['physical_width']}", + f"height:{d['physical_height']}", + f"refresh:{d['refresh']:g}", + f"x:{d['x']}", + f"y:{d['y']}", + f"scale:{d['scale']:g}", + ]) + + rr = transforms.get(d["transform"], 0) + if rr: + parts.append(f"rr:{rr}") + + parts.append("vrr:1" if d.get("adaptive_sync") else "vrr:0") + if d.get("custom_mode"): + parts.append("custom:1") + + color = (d.get("color_mode") or "") + if color in ("hdr", "hdredid", "force"): + parts.append("hdr:1") + if color == "force": + parts.append("hdr_force:1") + for key, attr in ( + ("min", "hdr_min_lum"), + ("max", "hdr_max_lum"), + ("max_avg", "hdr_max_avg_lum"), + ): + value = d.get(attr) or 0 + if value: + parts.append(f"hdr_{key}_lum:{value:g}") + if d.get("primary"): + parts.append("primary:1") + + return "monitor_rule=" + ",".join(parts) + + @staticmethod + def _apply_mango_json(displays, use_desc, outputs_path, profile_data): + """Applies configuration by writing the Mango monitors.conf file.""" + print(f"[Profile] Applying {len(displays)} displays for Mango...") + + header = SettingsApplier._get_header("Profile Loader") + lines_conf = [] + + for d in displays: + name = d["name"] + lines_conf.append(SettingsApplier._mango_rule_line(name, d)) + + save_list_to_text_file([header] + lines_conf, outputs_path) + + # Reload first so the new monitor rules are applied before changing DPMS state. + mango_reload_config() + + for d in displays: + cmd = "wakeup_monitor" if d["dpms"] else "sleep_monitor" + mmsg(f"dispatch {cmd},{d['name']}") + + config, _ = get_config() + + if "wallpapers" in profile_data and config.get( + "profile-bound-wallpapers", True + ): + print("[Profile] Applying wallpapers...") + time.sleep(1) + WallpaperManager.apply_wallpapers(profile_data["wallpapers"]) + + @staticmethod + def _apply_mango_gui( + display_buttons, + outputs_activity, + outputs_path, + use_desc, + create_confirm_win_callback, + config_dir=None, + profile_name=None, + ): + """Applies configuration from the GUI by writing the Mango monitors.conf file.""" + print(f"[Mango] Applying {len(display_buttons)} displays...") + + backup = [] + if os.path.isfile(outputs_path): + backup = load_text_file(outputs_path).splitlines() + + lines_conf = [SettingsApplier._get_header()] + for db in display_buttons: + d = { + "active": db.name in outputs_activity and outputs_activity[db.name], + "physical_width": db.physical_width, + "physical_height": db.physical_height, + "refresh": db.refresh, + "x": db.x, + "y": db.y, + "scale": db.scale, + "transform": db.transform, + "adaptive_sync": db.adaptive_sync, + "primary": getattr(db, "primary", False), + "hdr_min_lum": getattr(db, "hdr_min_lum", 0.0), + "hdr_max_lum": getattr(db, "hdr_max_lum", 0.0), + "hdr_max_avg_lum": getattr(db, "hdr_max_avg_lum", 0.0), + "color_mode": getattr(db, "color_mode", "") or "" + } + lines_conf.append(SettingsApplier._mango_rule_line(db.name, d)) + + save_list_to_text_file(lines_conf, outputs_path) + + # Reload first so the new monitor rules are applied before changing DPMS state. + mango_reload_config() + + for db in display_buttons: + cmd = "wakeup_monitor" if db.dpms else "sleep_monitor" + mmsg(f"dispatch {cmd},{db.name}") + + if create_confirm_win_callback: + create_confirm_win_callback(backup, outputs_path, config_dir, profile_name) @staticmethod def _apply_hyprland_gui( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/nwg_displays/tools.py new/nwg-displays-0.5.0/nwg_displays/tools.py --- old/nwg-displays-0.4.4/nwg_displays/tools.py 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/nwg_displays/tools.py 2026-10-20 21:06:49.000000000 +0200 @@ -2,6 +2,7 @@ import datetime import json import os +import re import shutil import socket import subprocess @@ -73,6 +74,145 @@ eprint(f"[niri] Failed to reload config: {e}") +def mmsg(cmd): + """Execute mmsg command and return output""" + try: + result = subprocess.run(["mmsg", cmd], capture_output=True, text=True, timeout=5) + return result.stdout.strip() + except Exception as e: + eprint(f"Error communicating with Mango: {e}") + return "" + + +def mango_reload_config(): + """Reload Mango configuration via mmsg dispatch""" + try: + result = subprocess.run( + ["mmsg", "dispatch reload_config"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + print("[Mango] Config reloaded") + else: + eprint(f"[Mango] Failed to reload config: {result.stderr.strip()}") + except Exception as e: + eprint(f"[Mango] Failed to reload config: {e}") + + +def mango_wakeup_all_monitors(): + """Wake up all connected monitors. + + DPMS power state is runtime-only and not exposed by mmsg, so the only + way to guarantee a known state after restoring settings is to + explicitly wake every monitor.""" + try: + data = json.loads(mmsg("get all-monitors")) + except json.JSONDecodeError as e: + eprint(f"[Mango] Failed to parse monitor list: {e}") + return + monitors = data.get("monitors", []) if isinstance(data, dict) else data + for mon in monitors: + name = mon.get("name") + if name: + mmsg(f"dispatch wakeup_monitor,{name}") + + +def parse_wlr_randr(): + """Parse wlr-randr output into a dict keyed by output name. + Provides enabled state, make/model/serial and available modes, + which mmsg does not expose.""" + result = {} + try: + output = subprocess.run( + ["wlr-randr"], capture_output=True, text=True, timeout=5 + ).stdout + except Exception as e: + eprint(f"[Mango] Failed to run wlr-randr: {e}, terminating") + sys.exit(1) + + name = None + for line in output.splitlines(): + stripped = line.strip() + if not stripped: + continue + if not line.startswith(" "): + # New output header, e.g.: DP-1 "Make Model Serial (DP-1)" + match = re.match(r"(\S+) \"([^\"]*)\"", line) + if match: + name = match.group(1) + result[name] = { + "make": "", + "model": "", + "serial": "", + "enabled": False, + "modes": [], + "current_refresh": None, + } + continue + if name is None: + continue + if stripped.startswith("Make:"): + result[name]["make"] = stripped.split(":", 1)[1].strip() + elif stripped.startswith("Model:"): + result[name]["model"] = stripped.split(":", 1)[1].strip() + elif stripped.startswith("Serial:"): + result[name]["serial"] = stripped.split(":", 1)[1].strip() + elif stripped.startswith("Enabled:"): + result[name]["enabled"] = line.strip().endswith("yes") + else: + mode_match = re.match(r"\s*(\d+)x(\d+) px, ([\d.]+) Hz(.*)", line) + if mode_match: + w, h, r, rest = mode_match.groups() + result[name]["modes"].append( + {"width": int(w), "height": int(h), "refresh": float(r) * 1000} + ) + if "(current)" in rest: + result[name]["current_refresh"] = float(r) + return result + + +def parse_mango_monitor_rules(path): + """Parse monitor_rule lines from a mango monitors.conf, keyed by output name. + Used to restore state (transform, disabled) that mmsg does not expose.""" + rules = {} + if not os.path.isfile(path): + return rules + for line in load_text_file(path).splitlines(): + line = line.strip() + if not line or line.startswith("#") or "monitor_rule=" not in line: + continue + value = line.split("=", 1)[1] + # The value is a regex. Capture the value after "name:" up to the next + # comma-separated key or end of string. + name_match = re.search(r"(?:^|,)name:(.*?)(?=,\w+:|$)", value) + if not name_match: + continue + # Inverse of re.escape: drop the backslash from every escaped pair + name = re.sub(r"\\(.)", r"\1", name_match.group(1)).strip() + # Strip regex anchors + name = name.removeprefix("^") + name = name.removesuffix("$") + rr_match = re.search(r"(?<!v)rr:(\d+)", value) + disable_match = re.search(r"disable:1", value) + hdr_match = re.search(r"hdr:1", value) + lum = {} + for key in ("min", "max", "max_avg"): + lum_match = re.search(rf"hdr_{key}_lum:([\d.]+)", value) + lum[f"{key}_lum"] = float(lum_match.group(1)) if lum_match else 0.0 + rules[name] = { + "transform": int(rr_match.group(1)) if rr_match else 0, + "disabled": bool(disable_match), + "hdr": bool(hdr_match), + "force": bool(re.search(r"hdr_force:1", value)), + "primary": bool(re.search(r"primary:1", value)), + "custom": bool(re.search(r"custom:1", value)), + **lum, + } + return rules + + def get_config(): config_dir = get_config_dir() config_file = os.path.join(config_dir, "config") @@ -338,6 +478,104 @@ outputs_dict[m["name"]]["monitor"] = None + elif os.getenv("MANGO_INSTANCE_SIGNATURE"): + outputs_dict = {} + eprint("Running on Mango") + + try: + mmsg_output = mmsg("get all-monitors") + randr = parse_wlr_randr() + monitors_file = os.path.join( + get_config_home(), "mango", "monitors.conf" + ) + rules = parse_mango_monitor_rules(monitors_file) + + if not mmsg_output: + eprint("[Mango] Could not communicate with the compositor") + return {} + + data = json.loads(mmsg_output) + monitors = ( + data.get("monitors", []) if isinstance(data, dict) else data + ) + + transforms = { + 0: "normal", 1: "90", 2: "180", 3: "270", + 4: "flipped", 5: "flipped-90", 6: "flipped-180", 7: "flipped-270", + } + + for mon in monitors: + name = mon.get("name") + randr_mon = randr.get(name, {}) + rule = rules.get(name, {}) + scale = float(mon.get("scale", 1.0)) + + description = " ".join( + part + for part in [ + randr_mon.get("make"), + randr_mon.get("model"), + randr_mon.get("serial"), + ] + if part + ) + + # mmsg reports post-transform dimensions; normalize rotated + # outputs back to native orientation (odd rotations swap WxH) + transform = rule.get("transform", 0) + w, h = int(mon.get("width", 0)), int(mon.get("height", 0)) + if transform % 4 in (1, 3): + w, h = h, w + + outputs_dict[name] = { + "active": bool( + randr_mon.get("enabled", not rule.get("disabled", False)) + ), + # DPMS power state is not exposed by mmsg; assume the + # monitor is powered on. + "dpms": True, + "description": description, + "x": int(mon.get("x", 0)), + "y": int(mon.get("y", 0)), + "logical-width": w, + "logical-height": h, + "physical-width": round(w * scale), + "physical-height": round(h * scale), + "transform": transforms.get(transform, "normal"), + "scale": scale, + "scale_filter": "linear", + "refresh": randr_mon.get("current_refresh") or 60.0, + "modes": randr_mon.get("modes", []), + # mmsg's "active" field is the focused/selected monitor + "focused": mon.get("active", False), + "adaptive_sync_status": "enabled" if mon.get("is_vrr", False) else "disabled", + "mirror": "", + "ten_bit": False, + # HDR is a config-level setting; mmsg's is_hdr reflects + # runtime state which may lag behind the applied rule + "color_mode": ( + "force" + if rule.get("force") + else "hdr" + if rule.get("hdr", False) + else "" + ), + "primary": rule.get("primary", False), + "custom_mode": rule.get("custom", False), + "hdr_min_lum": rule.get("min_lum", 0.0), + "hdr_max_lum": rule.get("max_lum", 0.0), + "hdr_max_avg_lum": rule.get("max_avg_lum", 0.0), + "sdr_brightness": 1.0, + "sdr_saturation": 1.0, + "monitor": None, + } + except json.JSONDecodeError as e: + eprint(f"[Mango] JSON decode error: {e}") + return {} + except Exception as e: + eprint(f"Error parsing Mango outputs: {e}") + return {} + else: eprint("This program only supports sway, Hyprland and niri, and we seem to be elsewhere, terminating.") sys.exit(1) @@ -352,8 +590,8 @@ monitor = display.get_monitor(i) monitors.append(monitor) - if os.getenv("NIRI_SOCKET"): - # Niri: Use position-based matching with Gdk.Monitor geometry + if os.getenv("NIRI_SOCKET") or os.getenv("MANGO_INSTANCE_SIGNATURE"): + # Niri and Mango: Use position-based matching with Gdk.Monitor geometry # This ensures correct matching even when multiple monitors have identical properties for key in outputs_dict: if key in outputs_dict: @@ -421,7 +659,11 @@ for mon in monitors_all: name = mon["name"] - result[name] = True if name in active else False + result[name] = name in active + elif os.getenv("MANGO_INSTANCE_SIGNATURE"): + randr = parse_wlr_randr() + for name in randr: + result[name] = randr[name].get("enabled", True) return result @@ -577,7 +819,16 @@ def ensure_niri_config_include(config_dir, monitors_file): """Ensure that config.kdl includes monitor.kdl""" config_kdl = os.path.join(config_dir, "config.kdl") - monitors_rel_path = "monitor.kdl" + # Derive the file name from monitors_file so custom --monitors_path works, + # fall back to the default when not provided. + if monitors_file: + monitors_filename = os.path.basename(monitors_file) + else: + monitors_filename = "monitor.kdl" + try: + monitors_rel_path = os.path.relpath(monitors_file, config_dir) if monitors_file else monitors_filename + except Exception: + monitors_rel_path = monitors_filename # Check if config.kdl exists if not os.path.isfile(config_kdl): @@ -595,12 +846,17 @@ lines = content.splitlines() - # Check if include already exists - include_pattern = f'include "{monitors_rel_path}"' - include_pattern_single = f"include '{monitors_rel_path}'" + # Check if include already exists, regardless of extra properties + # e.g. `include "monitor.kdl"`, `include optional=true "monitor.kdl"`, + # `include "monitor.kdl" optional=true`, single quotes, absolute paths. + # Commented-out lines (`// include ...`) must NOT count. + include_re = re.compile(r'^\s*include\b.*' + re.escape(monitors_filename) + r'\b') for line in lines: - if include_pattern in line or include_pattern_single in line: + stripped = line.strip() + if not stripped or stripped.startswith("//") or stripped.startswith("/-"): + continue + if include_re.search(line): # Already included return diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nwg-displays-0.4.4/setup.py new/nwg-displays-0.5.0/setup.py --- old/nwg-displays-0.4.4/setup.py 2026-08-24 21:48:09.000000000 +0200 +++ new/nwg-displays-0.5.0/setup.py 2026-10-20 21:06:49.000000000 +0200 @@ -9,7 +9,7 @@ setup( name="nwg-displays", - version="0.4.4", + version="0.5.0", description="nwg-shell output configuration utility", packages=find_packages(), include_package_data=True,
