pax_global_header00006660000000000000000000000064146257302370014523gustar00rootroot0000000000000052 comment=d66f1c7a9ee4993823118e0433e7bafa3d825ba6 nwg-displays-0.3.20/000077500000000000000000000000001462573023700142265ustar00rootroot00000000000000nwg-displays-0.3.20/.github/000077500000000000000000000000001462573023700155665ustar00rootroot00000000000000nwg-displays-0.3.20/.github/FUNDING.yml000066400000000000000000000000411462573023700173760ustar00rootroot00000000000000github: nwg-piotr liberapay: nwg nwg-displays-0.3.20/.gitignore000066400000000000000000000001731462573023700162170ustar00rootroot00000000000000/.idea /venv /nwg_displays.egg-info/ /build/ /dist/ /nwg_displays/resources/#main.glade# /nwg_displays/__pycache__ /result nwg-displays-0.3.20/LICENSE000066400000000000000000000020551462573023700152350ustar00rootroot00000000000000MIT License Copyright (c) 2022 Piotr Miller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. nwg-displays-0.3.20/README.md000066400000000000000000000070571462573023700155160ustar00rootroot00000000000000nwg-shell logo

nwg-displays


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) and [Hyprland](https://github.com/hyprwm/Hyprland) 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 and Hyprland only. screenshot
## Installation Install from your linux distribution repository if possible. [![Packaging status](https://repology.org/badge/vertical-allrepos/nwg-displays.svg)](https://repology.org/project/nwg-displays/versions) Otherwise, clone this repository and run the `install.sh` script. ## Usage ```text $ nwg-displays -h usage: nwg-displays [-h] [-m MONITORS_PATH] [-n NUM_WS] [-v] options: -h, --help show this help message and exit -m MONITORS_PATH, --monitors_path MONITORS_PATH path to save the monitors.conf file to, default: ~/.config/hypr/monitors.conf -n NUM_WS, --num_ws NUM_WS number of Workspaces in use, default: 10 -v, --version display version information ``` ### sway The configuration saved to a file may be easily used in the sway config: ```text ... include ~/.config/sway/outputs ... ``` The program also saves the `~/.config/sway/workspaces` file, which defines the workspace -> output associations. ```text workspace 1 output DP-1 workspace 2 output DP-1 workspace 3 output DP-1 workspace 4 output eDP-1 workspace 5 output eDP-1 workspace 6 output eDP-1 workspace 7 output HDMI-A-1 workspace 8 output HDMI-A-1 ``` You may include it in the sway config file, instead of editing associations manually: ```text ... include ~/.config/sway/workspaces ... ``` Use `--generic_names` if your output names happen to be different on every restart, e.g. when you use Thunderbolt outputs. Use `--num_ws` if you use workspaces in a number other than 8. ### Hyprland [Monitors](https://wiki.hyprland.org/Configuring/Monitors): Instead of configuring as described in Wiki, insert this line: ```text source = ~/.config/hypr/monitors.conf ``` [Default workspace](http://wiki.hyprland.org/Configuring/Monitors/#default-workspace) and [Binding workspaces to a monitor](https://wiki.hyprland.org/Configuring/Monitors/#binding-workspaces-to-a-monitor): Insert: ```text source = ~/.config/hypr/workspaces.conf ``` Do not set `disable_autoreload true` in Hyprland settings, or you'll have to reload Hyprland manually after applying chages. ## Settings The runtime configuration file is placed in your config directory, like `~/.config/nwg-displays/config`. It's a simple `json` file: ```json { "view-scale": 0.15, "snap-threshold": 10, "indicator-timeout": 500 } ``` - `view-scale` does not need to be changed manually. The GUI takes care of that. - `snap-threshold` specifies the flush margin of widgets representing displays. I added this value just in case, as I have no hi DPI display to test the stuff on. - `indicator-timeout` determines how long (in milliseconds) the overlay identifying screens should be visible. Set 0 to turn overlays off. nwg-displays-0.3.20/flake.lock000066400000000000000000000030561462573023700161660ustar00rootroot00000000000000{ "nodes": { "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, "locked": { "lastModified": 1673362319, "narHash": "sha256-Pjp45Vnj7S/b3BRpZEVfdu8sqqA6nvVjvYu59okhOyI=", "owner": "hercules-ci", "repo": "flake-parts", "rev": "82c16f1682cf50c01cb0280b38a1eed202b3fe9f", "type": "github" }, "original": { "owner": "hercules-ci", "repo": "flake-parts", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1698924604, "narHash": "sha256-GCFbkl2tj8fEZBZCw3Tc0AkGo0v+YrQlohhEGJ/X4s0=", "owner": "nixos", "repo": "nixpkgs", "rev": "fa804edfb7869c9fb230e174182a8a1a7e512c40", "type": "github" }, "original": { "owner": "nixos", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs-lib": { "locked": { "dir": "lib", "lastModified": 1672350804, "narHash": "sha256-jo6zkiCabUBn3ObuKXHGqqORUMH27gYDIFFfLq5P4wg=", "owner": "NixOS", "repo": "nixpkgs", "rev": "677ed08a50931e38382dbef01cba08a8f7eac8f6", "type": "github" }, "original": { "dir": "lib", "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flake-parts": "flake-parts", "nixpkgs": "nixpkgs" } } }, "root": "root", "version": 7 } nwg-displays-0.3.20/flake.nix000066400000000000000000000030121462573023700160240ustar00rootroot00000000000000{ description = "An output management utility for the sway Wayland compositor, inspired by wdisplays and wlay."; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; }; outputs = inputs@{ self, flake-parts, nixpkgs, ... }: flake-parts.lib.mkFlake {inherit inputs;} { # TODO: This probably works on more linux architectures but I haven't tested them systems = ["x86_64-linux"]; imports = []; perSystem = { config, inputs', pkgs, system, ... }: { packages = rec { default = nwg-displays; nwg-displays = pkgs.python3Packages.buildPythonApplication rec { pname = "nwg-displays"; version = "0.1.4"; doCheck = false; src = self; nativeBuildInputs = [ pkgs.wrapGAppsHook pkgs.gobject-introspection ]; buildInputs = with pkgs; [ gtk3 ]; propagatedBuildInputs = with pkgs; [ pango gtk-layer-shell gdk-pixbuf atk python3Packages.i3ipc python3Packages.pygobject3 python3Packages.gst-python ]; dontWrapGApps = true; preFixup = '' makeWrapperArgs+=("''${gappsWrapperArgs[@]}"); ''; }; }; }; }; } nwg-displays-0.3.20/install.sh000077500000000000000000000004141462573023700162320ustar00rootroot00000000000000#!/usr/bin/env bash python3 setup.py install --optimize=1 cp nwg-displays.svg /usr/share/pixmaps/ cp nwg-displays.desktop /usr/share/applications/ install -Dm 644 -t "/usr/share/licenses/nwg-displays" LICENSE install -Dm 644 -t "/usr/share/doc/nwg-displays" README.mdnwg-displays-0.3.20/nwg-displays.desktop000066400000000000000000000005601462573023700202430ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Displays Settings Name[pl]=Ustawienia wyświetlaczy GenericName=Output configuration utility GenericName[pl]=Ustawienia wyświetlaczy Comment=nwg-shell tool to configure outputs Comment[pl]=Narzędzie nwg-shell do konfiguracji monitorów Exec=nwg-displays Icon=nwg-displays Terminal=false Categories=Settings;DesktopSettings; nwg-displays-0.3.20/nwg-displays.svg000066400000000000000000000063651462573023700174020ustar00rootroot00000000000000 image/svg+xml nwg-displays-0.3.20/nwg_displays/000077500000000000000000000000001462573023700167315ustar00rootroot00000000000000nwg-displays-0.3.20/nwg_displays/__about__.py000066400000000000000000000003151462573023700212100ustar00rootroot00000000000000try: from importlib import metadata except ImportError: import importlib_metadata as metadata try: __version__ = metadata.version("nwg-displays") except Exception: __version__ = "unknown" nwg-displays-0.3.20/nwg_displays/__init__.py000066400000000000000000000000001462573023700210300ustar00rootroot00000000000000nwg-displays-0.3.20/nwg_displays/langs/000077500000000000000000000000001462573023700200355ustar00rootroot00000000000000nwg-displays-0.3.20/nwg_displays/langs/en_US.json000066400000000000000000000032701462573023700217430ustar00rootroot00000000000000{ "10-bit-support": "10 bit support", "10-bit-support-tooltip": "Enables support for 10-bit color depth.", "active": "Active", "adaptive-sync": "Adaptive sync", "adaptive-sync-tooltip": "Enables or disables adaptive synchronization \n(often referred to as Variable Refresh Rate, \nor by the vendor-specific names FreeSync/G-Sync).", "apply": "Apply", "close": "Close", "custom-mode": "Custom mode", "custom-mode-tooltip": "Adds '--custom' argument to set a mode \nnot listed in the list of available modes.\nUse this ONLY if you know what you're doing.", "dpms-tooltip": "Enables or disables output via DPMS. \nTo turn an output off (ie. blank the screen \nbut keep workspaces as-is), one can set DPMS to off.", "keep": "Keep", "keep-current-settings": "Keep current settings", "modes": "Modes", "modes-tooltip": "Displays a list of available \noutput modes to choose from.", "none": "None", "position-x": "Position X", "refresh": "Refresh", "restore": "Restore", "scale": "Scale", "scale-filter": "Scale filter", "scale-filter-tooltip": "'Linear' is smoother and blurrier, 'nearest' is sharper and blockier.\nSetting 'smart' will apply nearest when the output has an integer\nscale factor, otherwise linear.", "size": "Size", "toggle": "Toggle", "toggle-tooltip": "Enables/disables outputs.", "transform": "Transform", "transform-tooltip": "Sets the background transform to the given value.", "use-desc": "Use description", "use-desc-tooltip": "Use monitor description instead of output name", "view-scale-tooltip": "Outputs preview scale", "workspaces": "Workspaces", "workspaces-tooltip": "Opens Workspace -> Output assignment popup.", "zoom": "Zoom" }nwg-displays-0.3.20/nwg_displays/langs/pl_PL.json000066400000000000000000000035501462573023700217410ustar00rootroot00000000000000{ "10-bit-support": "Obsługa 10 bitów", "10-bit-support-tooltip": "Włącza obsługę 10-bitowej głębi koloru.", "active": "Aktywne", "adaptive-sync": "Synchr. adaptacyjna", "adaptive-sync-tooltip": "Włącza lub wyłącza synchronizację adaptacyjną \n(często określaną jako 'zmienna częstotliwość odświeżania', \nlub według producentów FreeSync/G-Sync).", "apply": "Zastosuj", "close": "Zamknij", "custom-mode": "Tryb niestandardowy", "custom-mode-tooltip": "Dodaje argument '--custom', by ustawić tryb \nnie występujący na liście dostępnych trybów.\nUżywaj TYLKO gdy dobrze wiesz co robisz.", "dpms-tooltip": "Włącza lub wyłącza wyjście przez DPMS.\nAby wyłączyć wyjście (tj. wygasić ekran, ale zachować\nobszary robocze bez zmian), można wyłączyć DPMS.", "keep": "Zachowaj", "keep-current-settings": "Zachować bieżące ustawienia", "modes": "Tryby", "modes-tooltip": "Wyświetla do wyboru listę dostępnych trybów.", "none": "Brak", "position-x": "Pozycja X", "refresh": "Odśwież", "restore": "Przywróć", "scale": "Skala", "scale-filter": "Filtr skalowania", "scale-filter-tooltip": "'Linear' jest gładszy i bardziej rozmyty, 'nearest' jest ostrzejszy i blokowy.\nUstawienie 'smart' zastosuje 'nearest' gdy wyjście na całkowity współczynnik\nskalowania w przeciwnym razie - 'linear'.", "size": "Rozmiar", "toggle": "Przełącz", "toggle-tooltip": "Włącza/wyłącza wyjścia.", "transform": "Transformacja", "transform-tooltip": "Ustawia wartość dla transformacji tła.", "use-desc": "Użyj opisu", "use-desc-tooltip": "Używaj opisu monitora zamiast nazwy wyjścia", "view-scale-tooltip": "Powiększenie podglądu wyjść", "workspaces": "Obszary robocze", "workspaces-tooltip": "Otwiera dialog przyporządkowania\nobszarów roboczych do wyjść.", "zoom": "Powiększenie" }nwg-displays-0.3.20/nwg_displays/langs/ru_RU.json000066400000000000000000000052631462573023700217720ustar00rootroot00000000000000{ "10-bit-support": "Поддержка 10 бит", "10-bit-support-tooltip": "Включает поддержку 10-битной глубины цвета.", "active": "Активный", "adaptive-sync": "Адаптивная синхронизация", "adaptive-sync-tooltip": "Включение или выключение адаптивной синхронизации \n(также известной как Variable Refresh Rate, \nили по вендор-специфичным именам FreeSync/G-Sync).", "apply": "Применить", "close": "Закрыть", "custom-mode": "Пользовательский режим", "custom-mode-tooltip": "Добавляет аргумент '--custom' для указания режима, \nотсутствующего в списке. Используйте эту опцию \nТОЛЬКО если вы знаете, что делаете.", "dpms-tooltip": "Включение или выключение вывода через DPMS. Чтобы отключить вывод \n(т.е. отключить экран, но оставить рабочие пространства без изменений), вы можете отключить DPMS.", "keep": "Сохранить", "keep-current-settings": "Сохранить текущие настройки", "modes": "Режимы", "modes-tooltip": "Список доступных режимов вывода.", "none": "Нет", "position-x": "Позиция X", "refresh": "Частота обновления", "restore": "Восстановить", "scale": "Масштаб", "scale-filter": "Фильтр масштабирования", "scale-filter-tooltip": "'Linear' более мякгий и размытый, 'nearest' более резкий и блочный.\nОпция 'smart' применит nearest при использовании целочисленного \nмасштабирования, в остальных случаях linear.", "size": "Размер", "toggle": "Переключить", "toggle-tooltip": "Включение/выключение вывода изображения.", "transform": "Трансформация", "transform-tooltip": "Устанавливает заданное значение преобразования вывода.", "view-scale-tooltip": "Увеличить предварительный просмотр результатов", "workspaces": "Рабочие пространства", "workspaces-tooltip": "Открывает диалог привязки рабочих пространств \nк устройствам вывода.", "zoom": "Увеличение" } nwg-displays-0.3.20/nwg_displays/main.py000066400000000000000000001307271462573023700202410ustar00rootroot00000000000000#!/usr/bin/env python """ Output management utility for sway Wayland compositor, inspired by wdisplays and wlay Project: https://github.com/nwg-piotr/nwg-displays Author's email: nwg.piotr@gmail.com Copyright (c) 2022-2024 Piotr Miller & Contributors License: MIT Depends on: 'python-i3ipc' 'gtk-layer-shell' All the code below was built around this glorious snippet: https://gist.github.com/KurtJacobson/57679e5036dc78e6a7a3ba5e0155dad1 Thank you, Kurt Jacobson! """ import argparse import os.path import sys import gi gi.require_version('Gtk', '3.0') try: gi.require_version('GtkLayerShell', '0.1') except ValueError: raise RuntimeError('\n\n' + 'If you haven\'t installed GTK Layer Shell, you need to point Python to the\n' + 'library by setting GI_TYPELIB_PATH and LD_LIBRARY_PATH to /src/.\n' + 'For example you might need to run:\n\n' + 'GI_TYPELIB_PATH=build/src LD_LIBRARY_PATH=build/src python3 ' + ' '.join(sys.argv)) from gi.repository import Gtk, GLib, GtkLayerShell from nwg_displays.tools import * from nwg_displays.__about__ import __version__ dir_name = os.path.dirname(__file__) sway = os.getenv("SWAYSOCK") is not None hypr = os.getenv("HYPRLAND_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 old_config_dir = os.path.join(get_config_home(), "nwg-outputs") sway_config_dir = os.path.join(get_config_home(), "sway") if sway and not os.path.isdir(sway_config_dir): print("WARNING: Couldn't find sway config directory '{}'".format(sway_config_dir), file=sys.stderr) sys.exit(1) hypr_config_dir = os.path.join(get_config_home(), "hypr") if hypr and not os.path.isdir(hypr_config_dir): print("WARNING: Couldn't find Hyprland config directory '{}'".format(hypr_config_dir), file=sys.stderr) sys.exit(1) # Create empty files if not found if sway: for name in ["outputs", "workspaces"]: create_empty_file(os.path.join(sway_config_dir, name)) elif hypr: for name in ["monitors.conf", "workspaces.conf"]: create_empty_file(os.path.join(hypr_config_dir, name)) else: eprint("Neither sway nor Hyprland detected, terminating") sys.exit(1) config = {} outputs_path = "" num_ws = 0 """ i3.get_outputs() does not return some output attributes, especially when connected via hdmi. i3.get_tree() on the other hand does not return inactive outputs. So we'll list attributes with .get_tree(), and the add inactive outputs, if any, from what we detect with .get_outputs() """ outputs = {} # Active outputs, listed from the sway tree; stores name and all attributes. outputs_activity = {} # Just a dictionary "name": is_active - from get_outputs() workspaces = {} # "workspace_num": "display_name" display_buttons = [] selected_output_button = None # Glade form fields form_name = None form_description = None form_dpms = None form_adaptive_sync = None form_custom_mode = None form_view_scale = None form_use_desc = None form_x = None form_y = None form_width = None form_height = None form_scale = None form_scale_filter = None form_refresh = None form_modes = None form_transform = None form_wrapper_box = None form_workspaces = None form_close = None form_apply = None form_version = None form_mirror = None form_ten_bit = None dialog_win = None confirm_win = None src_tag = 0 counter = 0 """ We need to rebuild the modes GtkComboBoxText on each DisplayButton click. Unfortunately appending an item fires the "change" event every time (and we have no "value-changed" event here). Setting `on_mode_changed_silent` True will prevent the `on_mode_changed` function from working. """ on_mode_changed_silent = False # Value from config adjusted to current view scale snap_threshold_scaled = None fixed = Gtk.Fixed() SENSITIVITY = 1 EvMask = Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON1_MOTION_MASK offset_x = 0 offset_y = 0 px = 0 py = 0 max_x = 0 max_y = 0 voc = {} def load_vocabulary(): global voc # basic vocabulary (for en_US) voc = load_json(os.path.join(dir_name, "langs", "en_US.json")) if not voc: eprint("Failed loading vocabulary, terminating") sys.exit(1) shell_data = load_shell_data() lang = os.getenv("LANG") if lang is None: lang = "en_US" else: lang = lang.split(".")[0] if not shell_data["interface-locale"] else shell_data["interface-locale"] # translate if translation available if lang != "en_US": loc_file = os.path.join(dir_name, "langs", "{}.json".format(lang)) if os.path.isfile(loc_file): # localized vocabulary loc = load_json(loc_file) if not loc: eprint("Failed loading translation into '{}'".format(lang)) else: for key in loc: voc[key] = loc[key] def on_button_press_event(widget, event): if widget != selected_output_button: widget.indicator.show_up() if event.button == 1: for db in display_buttons: if db.name == widget.name: db.select() else: db.unselect() p = widget.get_parent() # offset == distance of parent widget from edge of screen ... global offset_x, offset_y offset_x, offset_y = p.get_window().get_position() # plus distance from pointer to edge of widget offset_x += event.x offset_y += event.y # max_x, max_y both relative to the parent # note that we're rounding down now so that these max values don't get # rounded upward later and push the widget off the edge of its parent. global max_x, max_y max_x = round_down_to_multiple(p.get_allocation().width - widget.get_allocation().width, SENSITIVITY) max_y = round_down_to_multiple(p.get_allocation().height - widget.get_allocation().height, SENSITIVITY) update_form_from_widget(widget) def on_motion_notify_event(widget, event): # x_root,x_root relative to screen # x,y relative to parent (fixed widget) # px,py stores previous values of x,y global px, py global offset_x, offset_y # get starting values for x,y x = event.x_root - offset_x y = event.y_root - offset_y # make sure the potential coordinates x,y: # 1) will not push any part of the widget outside of its parent container # 2) is a multiple of SENSITIVITY x = round_to_nearest_multiple(max_val(min_val(x, max_x), 0), SENSITIVITY) y = round_to_nearest_multiple(max_val(min_val(y, max_y), 0), SENSITIVITY) if x != px or y != py: px = x py = y snap_x, snap_y = [0], [0] for db in display_buttons: if db.name == widget.name: continue val = db.x * config["view-scale"] if val not in snap_x: snap_x.append(val) val = (db.x + db.logical_width) * config["view-scale"] if val not in snap_x: snap_x.append(val) val = db.y * config["view-scale"] if val not in snap_y: snap_y.append(val) val = (db.y + db.logical_height) * config["view-scale"] if val not in snap_y: snap_y.append(val) snap_h, snap_v = None, None for value in snap_x: if abs(x - value) < snap_threshold_scaled: snap_h = value break for value in snap_x: w = widget.logical_width * config["view-scale"] if abs(w + x - value) < snap_threshold_scaled: snap_h = value - w break for value in snap_y: if abs(y - value) < snap_threshold_scaled: snap_v = value break for value in snap_y: h = widget.logical_height * config["view-scale"] if abs(h + y - value) < snap_threshold_scaled: snap_v = value - h break # Just in case ;) if snap_h and snap_h < 0: snap_h = 0 if snap_v and snap_v < 0: snap_v = 0 if snap_h is None and snap_v is None: fixed.move(widget, x, y) widget.x = round(x / config["view-scale"]) widget.y = round(y / config["view-scale"]) else: if snap_h is not None and snap_v is not None: fixed.move(widget, snap_h, snap_v) widget.x = round(snap_h / config["view-scale"]) widget.y = round(snap_v / config["view-scale"]) elif snap_h is not None: fixed.move(widget, snap_h, y) widget.x = round(snap_h / config["view-scale"]) widget.y = round(y / config["view-scale"]) elif snap_v is not None: fixed.move(widget, x, snap_v) widget.x = round(x / config["view-scale"]) widget.y = round(snap_v / config["view-scale"]) update_form_from_widget(widget) def update_form_from_widget(widget): form_name.set_text(widget.name) if len(widget.description) > 48: form_description.set_text(f"{widget.description[:47]}(…)") else: form_description.set_text(widget.description) form_dpms.set_active(widget.dpms) form_adaptive_sync.set_active(widget.adaptive_sync) form_custom_mode.set_active(widget.custom_mode) form_view_scale.set_value(config["view-scale"]) # not really from the widget, but from the global value form_use_desc.set_active(config["use-desc"]) form_x.set_value(widget.x) form_y.set_value(widget.y) form_width.set_value(widget.physical_width) form_height.set_value(widget.physical_height) form_scale.set_value(widget.scale) form_scale_filter.set_active_id(widget.scale_filter) form_refresh.set_value(widget.refresh) if form_ten_bit: form_ten_bit.set_active(widget.ten_bit) if form_mirror: form_mirror.remove_all() form_mirror.append("", voc["none"]) for key in outputs: if key != widget.name: form_mirror.append(key, key) form_mirror.set_active_id(widget.mirror) form_mirror.show_all() global on_mode_changed_silent on_mode_changed_silent = True form_modes.remove_all() active = "" for mode in widget.modes: m = "{}x{}@{}Hz".format(mode["width"], mode["height"], mode["refresh"] / 1000, mode[ "refresh"] / 1000, widget.refresh) form_modes.append(m, m) # This is just to set active_id if mode["width"] == widget.physical_width and mode["height"] == widget.physical_height and mode[ "refresh"] / 1000 == widget.refresh: active = m if active: form_modes.set_active_id(active) form_transform.set_active_id(widget.transform) on_mode_changed_silent = False class DisplayButton(Gtk.Button): def __init__(self, name, description, x, y, physical_width, physical_height, transform, scale, scale_filter, refresh, modes, active, dpms, adaptive_sync_status, ten_bit, custom_mode_status, focused, monitor, mirror=""): super().__init__() # Output properties self.name = name self.description = description self.x = x self.y = y self.physical_width = physical_width self.physical_height = physical_height self.transform = transform self.scale = scale self.scale_filter = scale_filter self.refresh = refresh self.modes = [] for m in modes: if m not in self.modes: self.modes.append(m) # self.modes = modes self.active = active self.dpms = dpms self.adaptive_sync = adaptive_sync_status == "enabled" # converts "enabled | disabled" to bool self.custom_mode = custom_mode_status self.focused = focused self.mirror = mirror self.ten_bit = ten_bit # Button properties self.selected = False self.set_can_focus(False) self.set_events(EvMask) self.connect("button_press_event", on_button_press_event) self.connect("motion_notify_event", on_motion_notify_event) self.set_always_show_image(True) self.set_label(self.name) self.rescale_transform() self.set_property("name", "output") self.indicator = Indicator(monitor, name, round(self.physical_width * config["view-scale"]), round(self.physical_height * config["view-scale"]), config["indicator-timeout"]) self.show() @property def logical_width(self): if is_rotated(self.transform): return self.physical_height / self.scale else: return self.physical_width / self.scale @property def logical_height(self): if is_rotated(self.transform): return self.physical_width / self.scale else: return self.physical_height / self.scale def select(self): self.selected = True self.set_property("name", "selected-output") global selected_output_button selected_output_button = self def unselect(self): self.set_property("name", "output") def rescale_transform(self): self.set_size_request(round(self.logical_width * config["view-scale"]), round(self.logical_height * config["view-scale"])) def on_active_check_button_toggled(self, w): self.active = w.get_active() if not self.active: self.set_property("name", "inactive-output") else: if self == selected_output_button: self.set_property("name", "selected-output") else: self.set_property("name", "output") def on_view_scale_changed(*args): config["view-scale"] = round(form_view_scale.get_value(), 2) global snap_threshold_scaled snap_threshold_scaled = round(config["snap-threshold"] * config["view-scale"] * 10) for b in display_buttons: b.rescale_transform() fixed.move(b, b.x * config["view-scale"], b.y * config["view-scale"]) save_json(config, os.path.join(config_dir, "config")) def on_transform_changed(*args): if selected_output_button: transform = form_transform.get_active_id() selected_output_button.transform = transform selected_output_button.rescale_transform() def on_ten_bit_toggled(check_btn): if selected_output_button: selected_output_button.ten_bit = check_btn.get_active() def on_dpms_toggled(widget): if selected_output_button: selected_output_button.dpms = widget.get_active() def on_use_desc_toggled(widget): config["use-desc"] = widget.get_active() save_json(config, os.path.join(config_dir, "config")) def on_adaptive_sync_toggled(widget): if selected_output_button: selected_output_button.adaptive_sync = widget.get_active() def on_custom_mode_toggle(widget): if selected_output_button: outputs = set(config["custom-mode"]) turned_on = widget.get_active() selected_output_button.custom_mode = turned_on if turned_on: outputs.add(selected_output_button.name) else: outputs.discard(selected_output_button.name) config["custom-mode"] = tuple(outputs) def on_pos_x_changed(widget): if selected_output_button: selected_output_button.x = round(widget.get_value()) fixed.move(selected_output_button, selected_output_button.x * config["view-scale"], selected_output_button.y * config["view-scale"]) def on_pos_y_changed(widget): if selected_output_button: selected_output_button.y = round(widget.get_value()) fixed.move(selected_output_button, selected_output_button.x * config["view-scale"], selected_output_button.y * config["view-scale"]) def on_width_changed(widget): if selected_output_button: selected_output_button.physical_width = round(widget.get_value()) selected_output_button.rescale_transform() def on_height_changed(widget): if selected_output_button: selected_output_button.physical_height = round(widget.get_value()) selected_output_button.rescale_transform() def on_scale_changed(widget): if selected_output_button: selected_output_button.scale = widget.get_value() selected_output_button.rescale_transform() def on_scale_filter_changed(widget): if selected_output_button: selected_output_button.scale_filter = widget.get_active_id() def on_refresh_changed(widget): if selected_output_button: selected_output_button.refresh = widget.get_value() update_form_from_widget(selected_output_button) def on_mode_changed(widget): if selected_output_button and not on_mode_changed_silent: mode = selected_output_button.modes[widget.get_active()] selected_output_button.physical_width = mode["width"] selected_output_button.physical_height = mode["height"] selected_output_button.refresh = mode["refresh"] / 1000 selected_output_button.rescale_transform() update_form_from_widget(selected_output_button) def on_mirror_selected(widget): if selected_output_button and widget.get_active_id() is not None: selected_output_button.mirror = widget.get_active_id() def on_apply_button(widget): global outputs_activity apply_settings(display_buttons, outputs_activity, outputs_path, use_desc=config["use-desc"]) # save config file save_json(config, os.path.join(config_dir, "config")) def on_output_toggled(check_btn, name): global outputs_activity outputs_activity[name] = check_btn.get_active() def on_toggle_button(btn): i3 = Connection() global outputs_activity for key in outputs_activity: toggle = "enable" if outputs_activity[key] else "disable" cmd = "output {} {}".format(key, toggle) i3.command(cmd) # If the output has just been turned back on, Gdk.Display.get_default() may need some time GLib.timeout_add(1000, create_display_buttons) def create_display_buttons(): global display_buttons for item in display_buttons: item.destroy() display_buttons = [] global outputs outputs = list_outputs() for key in outputs: item = outputs[key] custom_mode = key in config["custom-mode"] b = DisplayButton(key, item["description"], item["x"], item["y"], round(item["physical-width"]), round(item["physical-height"]), item["transform"], item["scale"], item["scale_filter"], item["refresh"], item["modes"], item["active"], item["dpms"], item["adaptive_sync_status"], item["ten_bit"], custom_mode, item["focused"], item["monitor"], mirror=item["mirror"]) display_buttons.append(b) fixed.put(b, round(item["x"] * config["view-scale"]), round(item["y"] * config["view-scale"])) display_buttons[0].select() update_form_from_widget(display_buttons[0]) class Indicator(Gtk.Window): def __init__(self, monitor, name, width, height, timeout): super().__init__() self.timeout = timeout self.monitor = monitor self.set_property("name", "indicator") GtkLayerShell.init_for_window(self) GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY) if monitor: GtkLayerShell.set_monitor(self, monitor) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.add(box) label = Gtk.Label() box.set_property("name", "indicator-label") label.set_text(name) box.pack_start(label, True, True, 10) self.set_size_request(width, height) if self.timeout > 0: self.show_up(self.timeout * 2) def show_up(self, timeout=None): if self.timeout > 0 and self.monitor: self.show_all() if timeout: GLib.timeout_add(timeout, self.hide) else: GLib.timeout_add(self.timeout, self.hide) def handle_keyboard(window, event): if event.type == Gdk.EventType.KEY_RELEASE and event.keyval == Gdk.KEY_Escape: window.close() def create_workspaces_window(btn): global sway_config_dir global workspaces workspaces = load_workspaces(os.path.join(sway_config_dir, "workspaces"), use_desc=config["use-desc"]) old_workspaces = workspaces.copy() global dialog_win if dialog_win: dialog_win.destroy() dialog_win = Gtk.Window() dialog_win.set_resizable(False) dialog_win.set_modal(True) dialog_win.connect("key-release-event", handle_keyboard) grid = Gtk.Grid() for prop in ["margin_start", "margin_end", "margin_top", "margin_bottom"]: grid.set_property(prop, 10) grid.set_column_spacing(12) grid.set_row_spacing(12) dialog_win.add(grid) global num_ws global outputs last_row = 0 for i in range(num_ws): lbl = Gtk.Label() lbl.set_text("workspace {} output ".format(i + 1)) grid.attach(lbl, 0, i, 1, 1) combo = Gtk.ComboBoxText() for key in outputs: if not config["use-desc"]: combo.append(key, key) else: desc = "{}".format(outputs[key]["description"]) combo.append(desc, desc) if i + 1 in workspaces: combo.set_active_id(workspaces[i + 1]) combo.connect("changed", on_ws_combo_changed, i + 1) grid.attach(combo, 1, i, 1, 1) last_row = i box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) grid.attach(box, 0, last_row + 1, 2, 1) btn_apply = Gtk.Button() btn_apply.set_label(voc["apply"]) if sway_config_dir: btn_apply.connect("clicked", on_workspaces_apply_btn, dialog_win, old_workspaces) else: btn_apply.set_sensitive(False) btn_apply.set_tooltip_text("Config dir not found") box.pack_end(btn_apply, False, False, 0) btn_close = Gtk.Button() btn_close.set_label(voc["close"]) btn_close.connect("clicked", close_dialog, dialog_win) box.pack_end(btn_close, False, False, 6) dialog_win.show_all() def create_workspaces_window_hypr(btn): global workspaces workspaces = load_workspaces_hypr( os.path.join(hypr_config_dir, "workspaces.conf"), num_ws=num_ws) eprint("WS->Mon:", workspaces) old_workspaces = workspaces.copy() global dialog_win if dialog_win: dialog_win.destroy() dialog_win = Gtk.Window() dialog_win.set_resizable(False) dialog_win.set_modal(True) dialog_win.connect("key-release-event", handle_keyboard) grid = Gtk.Grid() for prop in ["margin_start", "margin_end", "margin_top", "margin_bottom"]: grid.set_property(prop, 10) grid.set_column_spacing(12) grid.set_row_spacing(6) dialog_win.add(grid) global outputs last_row = 0 for i in range(num_ws): lbl = Gtk.Label() if config["use-desc"]: lbl.set_markup("Workspace rule: workspace={},monitor:desc:".format(i + 1)) else: lbl.set_markup("Workspace rule: workspace={},monitor:".format(i + 1)) lbl.set_property("halign", Gtk.Align.END) grid.attach(lbl, 0, i, 1, 1) combo = Gtk.ComboBoxText() for key in outputs: if not config["use-desc"]: combo.append(key, key) else: desc = "{}".format(outputs[key]["description"]) combo.append(desc, desc) if i + 1 in workspaces: combo.set_active_id(workspaces[i + 1]) combo.connect("changed", on_ws_combo_changed, i + 1) grid.attach(combo, 1, i, 1, 1) last_row += 1 box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) grid.attach(box, 0, last_row + 1, 2, 1) btn_apply = Gtk.Button() btn_apply.set_label(voc["apply"]) if hypr_config_dir: btn_apply.connect("clicked", on_workspaces_apply_btn_hypr, dialog_win, old_workspaces) else: btn_apply.set_sensitive(False) btn_apply.set_tooltip_text("Config dir not found") box.pack_end(btn_apply, False, False, 0) btn_close = Gtk.Button() btn_close.set_label(voc["close"]) btn_close.connect("clicked", close_dialog, dialog_win) box.pack_end(btn_close, False, False, 6) dialog_win.show_all() def on_ws_combo_changed(combo, ws_num): global workspaces workspaces[ws_num] = combo.get_active_id() def close_dialog(w, win): win.close() def on_workspaces_apply_btn(w, win, old_workspaces): global workspaces if workspaces != old_workspaces: save_workspaces(workspaces, os.path.join(sway_config_dir, "workspaces"), use_desc=config["use-desc"]) notify("Workspaces assignment", "Restart sway for changes to take effect") close_dialog(w, win) def on_workspaces_apply_btn_hypr(w, win, old_workspaces): global workspaces if workspaces != old_workspaces: workspace_conf_file = os.path.join(hypr_config_dir, "workspaces.conf") text_file = open(workspace_conf_file, "w") now = datetime.datetime.now() line = "# Generated by nwg-displays on {} at {}. Do not edit manually.\n".format( datetime.datetime.strftime(now, '%Y-%m-%d'), datetime.datetime.strftime(now, '%H:%M:%S')) text_file.write(line + "\n") monitors_with_default_workspace = [] for ws in workspaces: mon = workspaces[ws] if not config["use-desc"]: line = "workspace={},monitor:{}".format(ws, mon) else: line = "workspace={},monitor:desc:{}".format(ws, mon) if mon not in monitors_with_default_workspace: line += ",default:true" monitors_with_default_workspace.append(mon) text_file.write(line + "\n") text_file.close() notify("Workspaces assignment", "Restart Hyprland for changes to take effect") close_dialog(w, win) def apply_settings(display_buttons, outputs_activity, outputs_path, use_desc=False): now = datetime.datetime.now() lines = ["# Generated by nwg-displays on {} at {}. Do not edit manually.\n".format( datetime.datetime.strftime(now, '%Y-%m-%d'), datetime.datetime.strftime(now, '%H:%M:%S'))] cmds = [] db_names = [] # just active outputs have their buttons if os.getenv("SWAYSOCK"): for db in display_buttons: name = db.name if not use_desc else db.description db_names.append(name) lines.append('output "%s" {' % name) cmd = 'output "{}"'.format(name) custom_mode_str = "--custom" if db.custom_mode else "" lines.append( " mode {} {}x{}@{}Hz".format(custom_mode_str, db.physical_width, db.physical_height, db.refresh)) cmd += " mode {} {}x{}@{}Hz".format(custom_mode_str, db.physical_width, db.physical_height, db.refresh) lines.append(" pos {} {}".format(db.x, db.y)) cmd += " pos {} {}".format(db.x, db.y) lines.append(" transform {}".format(db.transform)) cmd += " transform {}".format(db.transform) lines.append(" scale {}".format(db.scale)) cmd += " scale {}".format(db.scale) lines.append(" scale_filter {}".format(db.scale_filter)) cmd += " scale_filter {}".format(db.scale_filter) a_s = "on" if db.adaptive_sync else "off" lines.append(" adaptive_sync {}".format(a_s)) cmd += " adaptive_sync {}".format(a_s) dpms = "on" if db.dpms else "off" lines.append(" dpms {}".format(dpms)) cmd += " dpms {}".format(dpms) lines.append("}") cmds.append(cmd) if not use_desc: for key in outputs_activity: if key not in db_names: lines.append('output "{}" disable'.format(key)) cmds.append('output "{}" disable'.format(key)) else: for key in outputs_activity: desc = inactive_output_description(key) if desc not in db_names: lines.append('output "{}" disable'.format(desc)) cmds.append('output "{}" disable'.format(desc)) print("[Saving]") for line in lines: print(line) # Check if the outputs file exists if os.path.isfile(outputs_path): # Load a backup to restore settings if needed backup = load_text_file(outputs_path).splitlines() else: backup = [] save_list_to_text_file(lines, outputs_path) print("[Executing]") for cmd in cmds: print(cmd) i3 = Connection() for cmd in cmds: i3.command(cmd) create_confirm_win(backup, outputs_path) elif os.getenv("HYPRLAND_INSTANCE_SIGNATURE"): transforms = {"normal": 0, "90": 1, "180": 2, "270": 3, "flipped": 4, "flipped-90": 5, "flipped-180": 6, "flipped-270": 7} for db in display_buttons: name = db.name if not use_desc else "desc:{}".format(db.description) db_names.append(name) line = "monitor={},{}x{}@{},{}x{},{}".format(name, db.physical_width, db.physical_height, db.refresh, db.x, db.y, db.scale) if db.mirror: line += ",mirror,{}".format(db.mirror) if db.ten_bit: line += ",bitdepth,10" lines.append(line) if db.transform != "normal": lines.append("monitor={},transform,{}".format(name, transforms[db.transform])) # avoid looking up the hardware name if db.name in outputs_activity and not outputs_activity[db.name]: lines.append("monitor={},disable".format(name)) cmd = "on" if db.dpms else "off" hyprctl(f"dispatch dpms {cmd} {db.name}") print("[Saving]") for line in lines: print(line) backup = [] if os.path.isfile(outputs_path): backup = load_text_file(outputs_path).splitlines() save_list_to_text_file(lines, outputs_path) create_confirm_win(backup, outputs_path) def create_confirm_win(backup, path): global confirm_win if confirm_win: confirm_win.destroy() confirm_win = Gtk.Window() confirm_win.set_property("name", "popup") GtkLayerShell.init_for_window(confirm_win) GtkLayerShell.set_layer(confirm_win, GtkLayerShell.Layer.OVERLAY) # GtkLayerShell.set_keyboard_mode(confirm_win, GtkLayerShell.KeyboardMode.ON_DEMAND) confirm_win.set_resizable(False) confirm_win.set_modal(True) grid = Gtk.Grid() grid.set_column_spacing(12) grid.set_row_spacing(12) grid.set_column_homogeneous(True) grid.set_property("margin", 12) confirm_win.add(grid) lbl = Gtk.Label.new("{}?".format(voc["keep-current-settings"])) grid.attach(lbl, 0, 0, 2, 1) cnt_lbl = Gtk.Label.new("10") grid.attach(cnt_lbl, 0, 1, 2, 1) btn_restore = Gtk.Button.new_with_label(voc["restore"]) btn_restore.connect("clicked", restore_old_settings, backup, path) grid.attach(btn_restore, 0, 2, 1, 1) btn_keep = Gtk.Button.new_with_label(voc["keep"]) btn_keep.connect("clicked", keep_current_settings) grid.attach(btn_keep, 1, 2, 1, 1) confirm_win.show_all() global counter counter = 10 global src_tag src_tag = GLib.timeout_add_seconds(1, count_down, cnt_lbl, backup, path) def count_down(label, backup, path): global counter if counter > 0: counter -= 1 label.set_text(str(counter)) return True restore_old_settings(None, backup, path) def keep_current_settings(btn): if src_tag > 0: GLib.Source.remove(src_tag) confirm_win.close() def restore_old_settings(btn, backup, path): print("Restoring old settings...") if src_tag > 0: GLib.Source.remove(src_tag) if os.getenv("SWAYSOCK"): save_list_to_text_file(backup, path) # Parse backup file back to commands and execute them single_line = "" # omit comments & empty lines for line in backup: if not line.startswith("#") and line: single_line += line # remove "{" single_line = single_line.replace("{", "") # convert multiple spaces into single single_line = ' '.join(single_line.split()) cmds = single_line.split("}") # execute line by line i3 = Connection() for cmd in cmds: if cmd: i3.command(cmd) confirm_win.close() create_display_buttons() elif os.getenv("HYPRLAND_INSTANCE_SIGNATURE"): save_list_to_text_file(backup, path) confirm_win.close() # Don't execute any command here, just save the file and wait for Hyprland to notice and apply the change. # Let's give it some time to do it before refreshing UI. GLib.timeout_add(2000, create_display_buttons) def main(): GLib.set_prgname('nwg-displays') parser = argparse.ArgumentParser() if sway: parser.add_argument("-o", "--outputs_path", type=str, default="{}/outputs".format(sway_config_dir), help="path to save Outputs config to, default: {}".format( "{}/outputs".format(sway_config_dir))) parser.add_argument("-n", "--num_ws", type=int, default=8, help="number of Workspaces in use, default: 8") elif hypr: parser.add_argument("-m", "--monitors_path", type=str, default="{}/monitors.conf".format(hypr_config_dir), help="path to save the monitors.conf file to, default: {}".format( "{}/monitors.conf".format(hypr_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", action="version", version="%(prog)s version {}".format(__version__), help="display version information") args = parser.parse_args() load_vocabulary() global outputs_path if sway: if os.path.isdir(sway_config_dir): outputs_path = args.outputs_path else: eprint("sway config directory not found!") outputs_path = "" elif hypr: if os.path.isdir(hypr_config_dir): outputs_path = args.monitors_path else: eprint("Hyprland config directory not found!") outputs_path = "" global num_ws num_ws = args.num_ws if sway: print("Number of workspaces: {}".format(num_ws)) config_file = os.path.join(config_dir, "config") global config if not os.path.isfile(config_file): # migrate old config file, if not yet migrated if os.path.isfile(os.path.join(old_config_dir, "config")): print("Migrating config to the proper path...") os.rename(old_config_dir, config_dir) else: if not os.path.isdir(config_dir): os.makedirs(config_dir, exist_ok=True) print("'{}' file not found, creating default".format(config_file)) save_json(config, config_file) else: config = load_json(config_file) if config_keys_missing(config, config_file): config = load_json(config_file) eprint("Settings: {}".format(config)) global snap_threshold_scaled snap_threshold_scaled = config["snap-threshold"] builder = Gtk.Builder() builder.add_from_file(os.path.join(dir_name, "resources/main.glade")) window = builder.get_object("window") screen = Gdk.Screen.get_default() provider = Gtk.CssProvider() style_context = Gtk.StyleContext() style_context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) try: file = os.path.join(dir_name, "resources/style.css") provider.load_from_path(file) except: sys.stderr.write("ERROR: {} file not found, using GTK styling\n".format(os.path.join(dir_name, "resources/style.css"))) window.connect("key-release-event", handle_keyboard) window.connect('destroy', Gtk.main_quit) builder.get_object("lbl-modes").set_label("{}:".format(voc["modes"])) builder.get_object("lbl-position-x").set_label("{}:".format(voc["position-x"])) builder.get_object("lbl-refresh").set_label("{}:".format(voc["refresh"])) builder.get_object("lbl-scale").set_label("{}:".format(voc["scale"])) builder.get_object("lbl-scale-filter").set_label("{}:".format(voc["scale-filter"])) builder.get_object("lbl-size").set_label("{}:".format(voc["size"])) builder.get_object("lbl-transform").set_label("{}:".format(voc["transform"])) builder.get_object("lbl-zoom").set_label("{}:".format(voc["zoom"])) global form_name form_name = builder.get_object("name") global form_description form_description = builder.get_object("description") global form_dpms form_dpms = builder.get_object("dpms") form_dpms.set_tooltip_text(voc["dpms-tooltip"]) form_dpms.connect("toggled", on_dpms_toggled) # if sway: # form_dpms.set_tooltip_text(voc["dpms-tooltip"]) # form_dpms.connect("toggled", on_dpms_toggled) # else: # form_dpms.set_sensitive(False) global form_adaptive_sync form_adaptive_sync = builder.get_object("adaptive-sync") if sway: 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) else: form_adaptive_sync.set_sensitive(False) global form_custom_mode form_custom_mode = builder.get_object("custom-mode") if sway: 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) else: form_custom_mode.set_sensitive(False) global form_view_scale form_view_scale = builder.get_object("view-scale") form_view_scale.set_tooltip_text(voc["view-scale-tooltip"]) adj = Gtk.Adjustment(lower=0.1, upper=0.6, step_increment=0.05, page_increment=0.1, page_size=0.1) form_view_scale.configure(adj, 1, 2) form_view_scale.connect("changed", on_view_scale_changed) global form_x form_x = builder.get_object("x") adj = Gtk.Adjustment(lower=0, upper=60000, step_increment=1, page_increment=10, page_size=1) form_x.configure(adj, 1, 0) form_x.connect("value-changed", on_pos_x_changed) global form_y form_y = builder.get_object("y") adj = Gtk.Adjustment(lower=0, upper=40000, step_increment=1, page_increment=10, page_size=1) form_y.configure(adj, 1, 0) form_y.connect("value-changed", on_pos_y_changed) global form_width form_width = builder.get_object("width") adj = Gtk.Adjustment(lower=0, upper=7680, step_increment=1, page_increment=10, page_size=1) form_width.configure(adj, 1, 0) form_width.connect("value-changed", on_width_changed) global form_height form_height = builder.get_object("height") adj = Gtk.Adjustment(lower=0, upper=4320, step_increment=1, page_increment=10, page_size=1) form_height.configure(adj, 1, 0) form_height.connect("value-changed", on_height_changed) global form_scale form_scale = builder.get_object("scale") adj = Gtk.Adjustment(lower=0.1, upper=10, step_increment=0.1, page_increment=10, page_size=1) form_scale.configure(adj, 0.1, 6) form_scale.connect("value-changed", on_scale_changed) global form_scale_filter form_scale_filter = builder.get_object("scale-filter") if sway: form_scale_filter.set_tooltip_text(voc["scale-filter-tooltip"]) form_scale_filter.connect("changed", on_scale_filter_changed) else: form_scale_filter.set_sensitive(False) global form_refresh form_refresh = builder.get_object("refresh") adj = Gtk.Adjustment(lower=1, upper=1200, step_increment=1, page_increment=10, page_size=1) form_refresh.configure(adj, 1, 3) form_refresh.connect("changed", on_refresh_changed) global form_modes form_modes = builder.get_object("modes") form_modes.set_tooltip_text(voc["modes-tooltip"]) form_modes.connect("changed", on_mode_changed) global form_use_desc form_use_desc = builder.get_object("use-desc") form_use_desc.set_label("{}".format(voc["use-desc"])) form_use_desc.set_tooltip_text("{}".format(voc["use-desc-tooltip"])) form_use_desc.connect("toggled", on_use_desc_toggled) global form_transform form_transform = builder.get_object("transform") form_transform.set_tooltip_text(voc["transform-tooltip"]) form_transform.connect("changed", on_transform_changed) global form_wrapper_box form_wrapper_box = builder.get_object("wrapper-box") global form_workspaces form_workspaces = builder.get_object("workspaces") form_workspaces.set_label(voc["workspaces"]) form_workspaces.set_tooltip_text(voc["workspaces-tooltip"]) if sway: form_workspaces.connect("clicked", create_workspaces_window) elif hypr: form_workspaces.connect("clicked", create_workspaces_window_hypr) global form_close form_close = builder.get_object("close") form_close.set_label(voc["close"]) form_close.connect("clicked", Gtk.main_quit) form_close.grab_focus() 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): form_apply.connect("clicked", on_apply_button) else: form_apply.set_sensitive(False) form_apply.set_tooltip_text("Config dir not found") global form_version form_version = builder.get_object("version") form_version.set_text("v{}".format(__version__)) wrapper = builder.get_object("wrapper") wrapper.set_property("name", "wrapper") global fixed fixed = builder.get_object("fixed") create_display_buttons() global outputs_activity outputs_activity = list_outputs_activity() lbl = Gtk.Label() lbl.set_text("{}:".format(voc["active"])) form_wrapper_box.pack_start(lbl, False, False, 3) for key in outputs_activity: cb = Gtk.CheckButton() cb.set_label(key) cb.set_active(outputs_activity[key]) cb.connect("toggled", on_output_toggled, key) form_wrapper_box.pack_start(cb, False, False, 3) btn = Gtk.Button.new_with_label(voc["toggle"]) if sway: btn.set_tooltip_text(voc["toggle-tooltip"]) btn.connect("clicked", on_toggle_button) form_wrapper_box.pack_start(btn, False, False, 3) else: btn.destroy() if hypr: grid = builder.get_object("grid") global form_ten_bit form_ten_bit = Gtk.CheckButton.new_with_label(voc["10-bit-support"]) form_ten_bit.set_tooltip_text(voc["10-bit-support-tooltip"]) form_ten_bit.connect("toggled", on_ten_bit_toggled) grid.attach(form_ten_bit, 5, 4, 1, 1) lbl = Gtk.Label.new("Mirror:") lbl.set_property("halign", Gtk.Align.END) grid.attach(lbl, 6, 4, 1, 1) global form_mirror form_mirror = Gtk.ComboBoxText() form_mirror.connect("changed", on_mirror_selected) grid.attach(form_mirror, 7, 4, 1, 1) if display_buttons: update_form_from_widget(display_buttons[0]) display_buttons[0].select() screen = Gdk.Screen.get_default() provider = Gtk.CssProvider() style_context = Gtk.StyleContext() style_context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) css = b""" #popup { border-radius: 6px; border: solid 1px; border-color: #f00 } """ provider.load_from_data(css) window.show_all() # Gtk.Fixed does not respect expand properties. That's why we need # to scale the window automagically if opened as a floating_con Gdk.threads_add_timeout(GLib.PRIORITY_LOW, 100, scale_if_floating) Gtk.main() if __name__ == '__main__': sys.exit(main()) nwg-displays-0.3.20/nwg_displays/resources/000077500000000000000000000000001462573023700207435ustar00rootroot00000000000000nwg-displays-0.3.20/nwg_displays/resources/main.glade000066400000000000000000000675351462573023700227050ustar00rootroot00000000000000 False True False True False vertical 12 True False vertical True False True True True True 0 False True 0 True False 10 10 6 10 4 4 True False end Size: 0 3 True True 1 3 True False center x 2 3 True True 3 3 True False end 6 Refresh: 4 3 True True 5 3 True False end Modes: 6 3 True False 6 6 linear nearest smart 7 2 True False end 6 6 Scale filter: 6 2 True True 6 6 5 2 True False end Scale: 4 2 True True 6 6 3 2 True False center Y: 2 2 True True 6 6 1 2 True False end Position X: 0 2 True False end 6 name 0 1 True False normal 90° 180° 270° flipped flipped-90° flipped-180° flipped-270° 7 1 True False end Transform: 6 1 True False end start 6 6 6 Zoom: 0 0 True False start start 1 0 True False end 6 2 0 6 True False start 6 6 True False 6 6 nwg-displays 3 False True 0 True False nwg-displays False True 1 True False 6 version False True 2 True False 6 <a href="https://github.com/nwg-piotr/nwg-displays">GitHub</a> True False True 3 0 5 4 True False False 7 3 True False end center Workspaces True True True center 12 False True 0 Close True True True 6 12 False False 2 Apply True True True end False False 3 3 5 5 DPMS True True False start center True 0 4 True False 6 True False start 6 6 description False True 0 Use desc True True False True False True 1 1 1 5 Custom mode True True False start center True 3 4 2 Adaptive sync True True False start center True 1 4 2 False True 1 True True 0 nwg-displays-0.3.20/nwg_displays/resources/style.css000066400000000000000000000014711462573023700226200ustar00rootroot00000000000000#output { background: rgba(0, 0, 0, 0.4); border-radius: 0; border: 1px solid rgba(200, 200, 200, 0.3); color: #eee; text-shadow: none; box-shadow: none } #output:hover { background: rgba(92, 92, 92, 0.05) } #selected-output { background: rgba(0, 0, 0, 0.4); border-radius: 0; border: 1px solid rgba(200, 200, 255, 0.6); color: #eee; text-shadow: none; box-shadow: none; font-weight: bold } #inactive-output { background: rgba(0, 0, 0, 0.4); border-radius: 0; border: none rgba(200, 200, 255, 0.6); color: #333; text-shadow: none; box-shadow: none; font-weight: bold } #wrapper { background: rgba(0, 0, 0, 0.5); } #indicator { background: rgba(0, 0, 0, 0.8); border-radius: 0; border: 1px solid rgba(200, 200, 255, 0.6); color: #eee } #indicator-label { font-size: 24px }nwg-displays-0.3.20/nwg_displays/tools.py000066400000000000000000000347721462573023700204600ustar00rootroot00000000000000# !/usr/bin/env python3 import datetime import json import os import socket import subprocess import sys import gi gi.require_version('Gdk', '3.0') from gi.repository import Gdk if os.getenv("SWAYSOCK"): from i3ipc import Connection def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) def get_config_home(): xdg_config_home = os.getenv('XDG_CONFIG_HOME') config_home = xdg_config_home if xdg_config_home else os.path.join( os.getenv("HOME"), ".config") return config_home def hyprctl(cmd): # /tmp/hypr moved to $XDG_RUNTIME_DIR/hypr in #5788 xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR") hypr_dir = f"{xdg_runtime_dir}/hypr" if xdg_runtime_dir and os.path.isdir( f"{xdg_runtime_dir}/hypr") else "/tmp/hypr" s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(f"{hypr_dir}/{os.getenv('HYPRLAND_INSTANCE_SIGNATURE')}/.socket.sock") s.send(cmd.encode("utf-8")) output = s.recv(20480).decode('utf-8') s.close() return output def is_command(cmd): cmd = cmd.split()[0] cmd = "command -v {}".format(cmd) try: is_cmd = subprocess.check_output( cmd, shell=True).decode("utf-8").strip() if is_cmd: return True except subprocess.CalledProcessError: return False def list_outputs(): if os.getenv("SWAYSOCK"): outputs_dict = {} eprint("Running on sway") i3 = Connection() tree = i3.get_tree() for item in tree: if item.type == "output" and not item.name.startswith("__"): outputs_dict[item.name] = {"x": item.rect.x, "y": item.rect.y, "logical-width": item.rect.width, "logical-height": item.rect.height, "physical-width": item.ipc_data["current_mode"]["width"], "physical-height": item.ipc_data["current_mode"]["height"]} outputs_dict[item.name]["active"] = item.ipc_data["active"] outputs_dict[item.name]["dpms"] = item.ipc_data["dpms"] outputs_dict[item.name]["transform"] = item.ipc_data[ "transform"] if "transform" in item.ipc_data else None outputs_dict[item.name]["scale"] = float(item.ipc_data["scale"]) if "scale" in item.ipc_data else None outputs_dict[item.name]["scale_filter"] = item.ipc_data["scale_filter"] outputs_dict[item.name]["adaptive_sync_status"] = item.ipc_data["adaptive_sync_status"] outputs_dict[item.name]["refresh"] = \ item.ipc_data["current_mode"]["refresh"] / 1000 if "refresh" in item.ipc_data[ "current_mode"] else None outputs_dict[item.name]["modes"] = item.ipc_data["modes"] if "modes" in item.ipc_data else [] outputs_dict[item.name]["description"] = "{} {} {}".format(item.ipc_data["make"], item.ipc_data["model"], item.ipc_data["serial"]) outputs_dict[item.name]["focused"] = item.ipc_data["focused"] outputs_dict[item.name]["mirror"] = "" # We only use it on Hyprland outputs_dict[item.name]["ten_bit"] = False # We have no way to check it on sway outputs_dict[item.name]["monitor"] = None elif os.getenv("HYPRLAND_INSTANCE_SIGNATURE"): monitors_all = json.loads(hyprctl("j/monitors all")) monitors = json.loads(hyprctl("j/monitors")) active = [] for item in monitors: active.append(item["name"]) outputs_dict = {} for mon in monitors_all: name = mon["name"] outputs_dict[name] = {"active": True} if name in active else {"active": False} eprint("Running on Hyprland") # 1. Mirroring is impossible to check in any way. We need to parse back the monitors.conf file, and it sucks. mirrors = {} hypr_config_dir = os.path.join(get_config_home(), "hypr") monitors_file = os.path.join(hypr_config_dir, "monitors.conf") if os.path.isfile(monitors_file): lines = load_text_file(monitors_file).splitlines() for line in lines: if line and not line.startswith("#"): # skip comments if "mirror" in line: settings = line.split("=")[1].split(",") mirrors[settings[0].strip()] = settings[-1].strip() # 2. This won't work w/ Hyprland <= 0.36.0 output = hyprctl("j/monitors all") monitors = json.loads(output) transforms = {0: "normal", 1: "90", 2: "180", 3: "270", 4: "flipped", 5: "flipped-90", 6: "flipped-180", 7: "flipped-270"} for m in monitors: outputs_dict[m["name"]]["mirror"] = mirrors[name] if name in mirrors else "" outputs_dict[m["name"]]["scale_filter"] = None outputs_dict[m["name"]]["modes"] = [] outputs_dict[m["name"]]["focused"] = m["focused"] outputs_dict[m["name"]]["adaptive_sync_status"] = "enabled" if m["vrr"] else "disabled" outputs_dict[m["name"]]["description"] = f'{m["description"]}' outputs_dict[m["name"]]["x"] = int(m["x"]) outputs_dict[m["name"]]["y"] = int(m["y"]) outputs_dict[m["name"]]["refresh"] = round(m["refreshRate"], 2) outputs_dict[m["name"]]["logical-width"] = m["width"] outputs_dict[m["name"]]["logical-height"] = m["height"] outputs_dict[m["name"]]["physical-width"] = m["width"] / m["scale"] outputs_dict[m["name"]]["physical-height"] = m["height"] / m["scale"] outputs_dict[m["name"]]["transform"] = transforms[m["transform"]] outputs_dict[m["name"]]["scale"] = m["scale"] outputs_dict[m["name"]]["focused"] = m["focused"] outputs_dict[m["name"]]["dpms"] = m["dpmsStatus"] outputs_dict[name]["modes"] = [] for item in m["availableModes"]: line = item[:-2] # split "Hz" w_h, r = line.split("@") w, h = w_h.split("x") try: mode = {"width": int(w), "height": int(h), "refresh": float(r) * 1000} except ValueError as e: eprint(e) outputs_dict[m["name"]]["modes"].append(mode) outputs_dict[m["name"]]["ten_bit"] = True if m["currentFormat"] in ["XRGB2101010", "XBGR2101010"] else False # to identify Gdk.Monitor outputs_dict[m["name"]]["model"] = m["model"] outputs_dict[m["name"]]["monitor"] = None else: eprint("This program only supports sway and Hyprland, and we seem to be elsewhere, terminating.") sys.exit(1) # We used to assign Gdk.Monitor to output on the basis of x and y coordinates, but it no longer works, # starting from gtk3-1:3.24.42: all monitors have x=0, y=0. This is most likely a bug, but from now on # we must rely on gdk monitors order. monitors = [] display = Gdk.Display.get_default() for i in range(display.get_n_monitors()): monitor = display.get_monitor(i) monitors.append(monitor) idx = 0 for key in outputs_dict: try: outputs_dict[key]["monitor"] = monitors[idx] except IndexError: print(f"Couldn't assign a Gdk.Monitor to {outputs_dict[key]}") idx += 1 for key in outputs_dict: eprint(key, outputs_dict[key]) return outputs_dict def list_outputs_activity(): result = {} if os.getenv("SWAYSOCK"): i3 = Connection() outputs = i3.get_outputs() for o in outputs: result[o.name] = o.active elif os.getenv("HYPRLAND_INSTANCE_SIGNATURE"): monitors_all = json.loads(hyprctl("j/monitors all")) monitors = json.loads(hyprctl("j/monitors")) active = [] for item in monitors: active.append(item["name"]) for mon in monitors_all: name = mon["name"] result[name] = True if name in active else False return result def max_window_height(): if os.getenv("SWAYSOCK"): i3 = Connection() outputs = i3.get_outputs() for o in outputs: if o.focused: if o.rect.width > o.rect.height: return o.rect.height * 0.9 else: return o.rect.height / 2 * 0.9 return None def scale_if_floating(): pid = os.getpid() if os.getenv("SWAYSOCK"): i3 = Connection() node = i3.get_tree().find_by_pid(pid)[0] if node.type == "floating_con": h = int(max_window_height()) if h: i3.command("resize set height {}".format(h)) def min_val(a, b): if b < a: return b return a def max_val(a, b): if b > a: return b return a def round_down_to_multiple(i, m): return i / m * m def round_to_nearest_multiple(i, m): if i % m > m / 2: return (i / m + 1) * m return i / m * m def orientation_changed(transform, transform_old): return (is_rotated(transform) and not is_rotated(transform_old)) or ( is_rotated(transform_old) and not is_rotated(transform)) def is_rotated(transform): return "90" in transform or "270" in transform def inactive_output_description(name): if os.getenv("SWAYSOCK"): i3 = Connection() for item in i3.get_outputs(): if item.name == name: return "{} {} {}".format(item.ipc_data["make"], item.ipc_data["model"], item.ipc_data["serial"]) return None def config_keys_missing(config, config_file): key_missing = False defaults = {"view-scale": 0.15, "snap-threshold": 10, "indicator-timeout": 500, "custom-mode": [], "use-desc": False, } for key in defaults: if key not in config: config[key] = defaults[key] print("Added missing config key: '{}'".format(key), file=sys.stderr) key_missing = True if key_missing: save_json(config, config_file) return key_missing def load_json(path): try: with open(path, 'r') as f: return json.load(f) except Exception as e: print("Error loading json: {}".format(e)) return None def save_json(src_dict, path): with open(path, 'w') as f: json.dump(src_dict, f, indent=2) def save_list_to_text_file(data, file_path): text_file = open(file_path, "w") for line in data: text_file.write(line + "\n") text_file.close() def create_empty_file(file_path): if not os.path.isfile(file_path): with open(file_path, "w") as file: pass def load_text_file(path): try: with open(path, 'r') as file: data = file.read() return data except Exception as e: print(e) return None def load_workspaces(path, use_desc=False): result = {} try: with open(path, 'r') as file: data = file.read().splitlines() for i in range(len(data)): if data[i] and not data[i].startswith("#"): # skip comments info = data[i].split("workspace ")[1].split() num = int(info[0]) if not use_desc: result[num] = info[2] else: result[num] = data[i].split("output ")[1][1:-1] return result except Exception as e: print(e) return result # We will read all the meaningful lines if -n argument not given or >= number of lines. def load_workspaces_hypr(path, num_ws=0): ws_binds = {} meaningful_lines_read = 0 try: with open(path, 'r') as file: data = file.read().splitlines() r = len(data) for i in range(r): line = data[i] if line and not line.startswith("#"): # skip comments meaningful_lines_read += 1 # Binding workspaces to a monitor, e.g.: # 'workspace=1,monitor:desc:AOC 2475WR F17H4QA000449' or # 'workspace=1,monitor:HDMI-A-1' ws_num = None parts = line.split(",") try: ws_num = int(parts[0].split("=")[1]) except: pass mon = parts[1].split(":")[-1] if ws_num: ws_binds[ws_num] = mon if num_ws > 0: if meaningful_lines_read == num_ws: break return ws_binds except Exception as e: eprint("Error parsing workspaces.conf file: {}".format(e)) return {} def save_workspaces(data_dict, path, use_desc=False): text_file = open(path, "w") now = datetime.datetime.now() line = "# Generated by nwg-displays on {} at {}. Do not edit manually.\n".format( datetime.datetime.strftime(now, '%Y-%m-%d'), datetime.datetime.strftime(now, '%H:%M:%S')) text_file.write(line + "\n") for key in data_dict: if not use_desc: line = "workspace {} output {}".format(key, data_dict[key]) else: line = "workspace {} output '{}'".format(key, data_dict[key]) text_file.write(line + "\n") text_file.close() def notify(summary, body, timeout=3000): cmd = "notify-send '{}' '{}' -i /usr/share/pixmaps/nwg-displays.svg -t {}".format(summary, body, timeout) subprocess.call(cmd, shell=True) def get_shell_data_dir(): data_dir = "" home = os.getenv("HOME") xdg_data_home = os.getenv("XDG_DATA_HOME") if xdg_data_home: data_dir = os.path.join(xdg_data_home, "nwg-shell/") else: if home: data_dir = os.path.join(home, ".local/share/nwg-shell/") return data_dir def load_shell_data(): shell_data_file = os.path.join(get_shell_data_dir(), "data") shell_data = load_json(shell_data_file) if os.path.isfile(shell_data_file) else {} defaults = { "interface-locale": "" } for key in defaults: if key not in shell_data: shell_data[key] = defaults[key] return shell_data nwg-displays-0.3.20/setup.py000066400000000000000000000012711462573023700157410ustar00rootroot00000000000000import os from setuptools import setup, find_packages def read(f_name): return open(os.path.join(os.path.dirname(__file__), f_name)).read() setup( name='nwg-displays', version='0.3.20', description='nwg-shell output configuration utility', packages=find_packages(), include_package_data=True, package_data={ "": ["resources/*", "langs/*"] }, url='https://github.com/nwg-piotr/nwg-displays', license='MIT', author='Piotr Miller', author_email='nwg.piotr@gmail.com', python_requires='>=3.6.0', install_requires=[], entry_points={ 'gui_scripts': [ 'nwg-displays = nwg_displays.main:main', ] } )