pax_global_header 0000666 0000000 0000000 00000000064 14625730237 0014523 g ustar 00root root 0000000 0000000 52 comment=d66f1c7a9ee4993823118e0433e7bafa3d825ba6
nwg-displays-0.3.20/ 0000775 0000000 0000000 00000000000 14625730237 0014226 5 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/.github/ 0000775 0000000 0000000 00000000000 14625730237 0015566 5 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/.github/FUNDING.yml 0000664 0000000 0000000 00000000041 14625730237 0017376 0 ustar 00root root 0000000 0000000 github: nwg-piotr
liberapay: nwg
nwg-displays-0.3.20/.gitignore 0000664 0000000 0000000 00000000173 14625730237 0016217 0 ustar 00root root 0000000 0000000 /.idea
/venv
/nwg_displays.egg-info/
/build/
/dist/
/nwg_displays/resources/#main.glade#
/nwg_displays/__pycache__
/result
nwg-displays-0.3.20/LICENSE 0000664 0000000 0000000 00000002055 14625730237 0015235 0 ustar 00root root 0000000 0000000 MIT 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.md 0000664 0000000 0000000 00000007057 14625730237 0015516 0 ustar 00root root 0000000 0000000
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.
## Installation
Install from your linux distribution repository if possible.
[](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.lock 0000664 0000000 0000000 00000003056 14625730237 0016166 0 ustar 00root root 0000000 0000000 {
"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.nix 0000664 0000000 0000000 00000003012 14625730237 0016024 0 ustar 00root root 0000000 0000000 {
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.sh 0000775 0000000 0000000 00000000414 14625730237 0016232 0 ustar 00root root 0000000 0000000 #!/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.md nwg-displays-0.3.20/nwg-displays.desktop 0000664 0000000 0000000 00000000560 14625730237 0020243 0 ustar 00root root 0000000 0000000 [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.svg 0000664 0000000 0000000 00000006365 14625730237 0017402 0 ustar 00root root 0000000 0000000
nwg-displays-0.3.20/nwg_displays/ 0000775 0000000 0000000 00000000000 14625730237 0016731 5 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/nwg_displays/__about__.py 0000664 0000000 0000000 00000000315 14625730237 0021210 0 ustar 00root root 0000000 0000000 try:
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__.py 0000664 0000000 0000000 00000000000 14625730237 0021030 0 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/nwg_displays/langs/ 0000775 0000000 0000000 00000000000 14625730237 0020035 5 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/nwg_displays/langs/en_US.json 0000664 0000000 0000000 00000003270 14625730237 0021743 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000003550 14625730237 0021741 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000005263 14625730237 0021772 0 ustar 00root root 0000000 0000000 {
"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.py 0000664 0000000 0000000 00000130727 14625730237 0020241 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14625730237 0020743 5 ustar 00root root 0000000 0000000 nwg-displays-0.3.20/nwg_displays/resources/main.glade 0000664 0000000 0000000 00000067535 14625730237 0022705 0 ustar 00root root 0000000 0000000
nwg-displays-0.3.20/nwg_displays/resources/style.css 0000664 0000000 0000000 00000001471 14625730237 0022620 0 ustar 00root root 0000000 0000000 #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.py 0000664 0000000 0000000 00000034772 14625730237 0020460 0 ustar 00root root 0000000 0000000 # !/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.py 0000664 0000000 0000000 00000001271 14625730237 0015741 0 ustar 00root root 0000000 0000000 import 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',
]
}
)