xrandroll/xrandroll/main.py

495 lines
18 KiB
Python

import os
import shlex
import subprocess
import sys
from copy import deepcopy
from PySide2.QtCore import QFile, QObject
from PySide2.QtUiTools import QUiLoader
from PySide2.QtWidgets import QApplication, QGraphicsScene, QLabel
from .monitor_item import MonitorItem
from . import xrandr
def parse_mode(mode):
return (int(n) for n in mode.split("x"))
def gen_xrandr_from_data(data):
"""Takes monitor data and generates a xrandr command line."""
cli = ["xrandr"]
for name, mon in data.items():
cli.append(f"--output {name}")
if not mon["enabled"]:
cli.append("--off")
else:
cli.append(f'--pos {int(mon["pos_x"])}x{int(mon["pos_y"])}')
cli.append(f'--mode {mon["current_mode"]}')
mod_x, mod_y = parse_mode(mon["current_mode"])
if mon["orientation"] in (1, 3):
mod_x, mod_y = mod_y, mod_x
cli.append(f'--scale {mon["res_x"]/mod_x}x{mon["res_y"]/mod_y}')
cli.append(
f"--rotate {['normal', 'left', 'inverted', 'right'][mon['orientation']]}"
)
if mon["primary"]:
cli.append("--primary")
return " ".join(cli)
def parse_monitor(line):
parts = line.split()
name = parts[0]
primary = "primary" in parts
if "+" in line: # Is enabled
enabled = True
res_x, res_y = [p for p in parts if "x" in p][0].split("+")[0].split("x")
pos_x, pos_y = [p for p in parts if "x" in p][0].split("+")[1:]
w_in_mm, h_in_mm = [p.split("mm")[0] for p in parts if p.endswith("mm")]
else:
enabled = False
res_x = res_y = pos_x = pos_y = w_in_mm = h_in_mm = 0
left_side = line.split(" (normal left inverted ")[0]
orientation = 0
if "left" in left_side:
orientation = 1
elif "inverted" in left_side:
orientation = 2
elif "right" in left_side:
orientation = 3
return (
name,
primary,
int(res_x),
int(res_y),
int(w_in_mm),
int(h_in_mm),
int(pos_x),
int(pos_y),
enabled,
orientation,
)
def is_replica_of(a, b):
"""Return True if monitor a is a replica of b.
Replica means same resolution and position.
"""
return (
a["pos_x"] == b["pos_x"]
and a["pos_y"] == b["pos_y"]
and a["res_x"] == b["res_x"]
and a["res_y"] == b["res_y"]
and b["enabled"]
)
class Window(QObject):
def __init__(self, ui):
super().__init__()
self.ui = ui
ui.show()
self.ui.setWindowTitle("Display Configuration")
self.ui.screenCombo.currentTextChanged.connect(self.monitor_selected)
self.ui.replicaOf.currentTextChanged.connect(self.replica_changed)
self.ui.orientationCombo.currentIndexChanged.connect(self.orientation_changed)
self.xrandr_info = {}
self.get_xrandr_info()
self.orig_xrandr_info = deepcopy(self.xrandr_info)
self.fill_ui()
self.ui.horizontalScale.valueChanged.connect(self.scale_changed)
self.ui.verticalScale.valueChanged.connect(self.scale_changed)
self.ui.modes.currentTextChanged.connect(self.mode_changed)
self.ui.applyButton.clicked.connect(self.do_apply)
self.ui.okButton.clicked.connect(self.do_ok)
self.ui.resetButton.clicked.connect(self.do_reset)
self.ui.cancelButton.clicked.connect(self.ui.reject)
self.ui.scaleModeCombo.currentTextChanged.connect(self.scale_mode_changed)
self.ui.primary.stateChanged.connect(self.primary_changed)
self.ui.enabled.stateChanged.connect(self.enabled_changed)
self.pos_label = QLabel(self.ui.sceneView)
self.pos_label.move(5, 5)
def enabled_changed(self):
mon = self.ui.screenCombo.currentText()
enabled = self.ui.enabled.isChecked()
print(f"Setting {mon} enabled status to {enabled}")
monitor = self.xrandr_info[mon]
monitor["enabled"] = enabled
if enabled and not monitor["current_mode"]:
# Choose a mode
self.ui.modes.setCurrentIndex(0)
self.mode_changed()
self.update_replica_of_data()
for _, mon in self.xrandr_info.items():
mon["item"].update_visuals(mon)
self.adjust_view()
def primary_changed(self):
mon = self.ui.screenCombo.currentText()
primary = self.ui.primary.isChecked()
# Update visuals on all monitos
for name, monitor in self.xrandr_info.items():
if name == mon:
monitor["primary"] = primary
else:
if primary: # There can only be one primary
monitor["primary"] = False
monitor["item"].update_visuals(monitor)
def scale_mode_changed(self):
mon = self.ui.screenCombo.currentText()
scale_mode = self.ui.scaleModeCombo.currentText()
print(f"Set {mon} scale mode to {scale_mode}")
if scale_mode == "Manual":
self.ui.horizontalScale.setEnabled(True)
self.ui.verticalScale.setEnabled(True)
try:
self.ui.horizontalScale.valueChanged.disconnect(
self.ui.verticalScale.setValue
)
except RuntimeError: # Not connected
pass
elif scale_mode == "Disabled (1x1)":
self.ui.verticalScale.setEnabled(False)
self.ui.horizontalScale.setEnabled(False)
self.ui.horizontalScale.setValue(1000)
self.ui.verticalScale.setValue(1000)
try:
self.ui.horizontalScale.valueChanged.disconnect(
self.ui.verticalScale.setValue
)
except RuntimeError: # Not connected
pass
elif scale_mode == "Automatic: physical dimensions":
# Calculate scale factors so that the logical pixels will be the same
# size as in the primary window
if self.ui.primary.isChecked():
print("Has no effect on primary display.")
return
# Find the primary monitor
primary = [k for k in self.xrandr_info if self.xrandr_info[k]["primary"]]
if not primary:
print("Oops, no primary!")
return
primary = self.xrandr_info[primary[0]]
monitor = self.xrandr_info[mon]
prim_density_x = primary["res_x"] / primary["w_in_mm"]
prim_density_y = primary["res_y"] / primary["h_in_mm"]
dens_x = monitor["res_x"] / monitor["w_in_mm"]
dens_y = monitor["res_y"] / monitor["h_in_mm"]
try:
self.ui.horizontalScale.valueChanged.disconnect(
self.ui.verticalScale.setValue
)
except RuntimeError: # Not connected
pass
self.ui.horizontalScale.setEnabled(False)
self.ui.verticalScale.setEnabled(False)
self.ui.horizontalScale.setValue(prim_density_x / dens_x * 1000)
self.ui.verticalScale.setValue(prim_density_y / dens_y * 1000)
elif scale_mode == "Manual, same in both dimensions":
self.ui.horizontalScale.setEnabled(True)
self.ui.verticalScale.setEnabled(False)
self.ui.horizontalScale.valueChanged.connect(self.ui.verticalScale.setValue)
self.ui.verticalScale.setValue(self.ui.horizontalScale.value())
def replica_changed(self):
mon = self.ui.screenCombo.currentText()
replicate = self.ui.replicaOf.currentText()
print(f"Making {mon} a replica of {replicate}")
if replicate in ("None", "", None):
print("TODO: make things non-replicas")
return
mon = self.xrandr_info[mon]
replicate = self.xrandr_info[replicate]
# Making a replica implies:
# Set the same position
mon["pos_x"] = replicate["pos_x"]
mon["pos_y"] = replicate["pos_y"]
# Set the same mode if possible
if replicate["current_mode"] in mon["modes"]:
mon["current_mode"] = replicate["current_mode"]
else:
# Keep the current mode, and change scaling so it
# has the same effective size as the desired mode
mod_x, mod_y = parse_mode(mon["current_mode"])
target_x, target_y = [replicate[x] for x in ["res_x", "res_y"]]
scale_x = 1000 * target_x / mod_x
scale_y = 1000 * target_y / mod_y
self.ui.horizontalScale.setValue(scale_x)
self.ui.verticalScale.setValue(scale_y)
self.update_replica_of_data()
for _, mon in self.xrandr_info.items():
mon["item"].update_visuals(mon)
def do_reset(self):
for n in self.xrandr_info:
self.xrandr_info[n].update(self.orig_xrandr_info[n])
self.fill_ui()
def do_ok(self):
self.do_apply()
self.ui.accept()
def do_apply(self):
cli = gen_xrandr_from_data(self.xrandr_info)
print(cli)
subprocess.check_call(shlex.split(cli))
def fill_ui(self):
"""Load data from xrandr and setup the whole thing."""
self.scene = QGraphicsScene(self)
self.ui.sceneView.setScene(self.scene)
self.ui.screenCombo.clear()
for name, monitor in self.xrandr_info.items():
self.ui.screenCombo.addItem(name)
mon_item = MonitorItem(data=monitor, window=self, name=name,)
self.scene.addItem(mon_item)
monitor["item"] = mon_item
self.ui.screenCombo.setCurrentText(self.choose_a_monitor())
self.adjust_view()
self.scale_changed() # Trigger scale labels update
def detect_scale_mode(self, monitor):
"""Given a monitor's data, try to guess what scaling
mode it's using.
TODO: detect "Automatic: physical dimensions"
"""
if not monitor["current_mode"]: # Disabled, whatever
return None
mod_x, mod_y = parse_mode(monitor["current_mode"])
scale_x = monitor["res_x"] / mod_x
scale_y = monitor["res_y"] / mod_y
if 1 == scale_x == scale_y:
print("Scale mode looks like 1x1")
return "Disabled (1x1)"
elif scale_x == scale_y:
print("Looks like Manual, same in both dimensions")
return "Manual, same in both dimensions"
else:
return "Manual"
def choose_a_monitor(self):
"""Choose what monitor to select by default.
* Not disabled
* Primary, if possible
"""
candidate = None
for name, mon in self.xrandr_info.items():
if not mon["enabled"]:
continue
if mon["primary"]:
return name
candidate = name
return candidate
def orientation_changed(self):
mon = self.ui.screenCombo.currentText()
orientation = self.ui.orientationCombo.currentIndex()
self.xrandr_info[mon]["orientation"] = orientation
self.mode_changed()
def mode_changed(self):
mon = self.ui.screenCombo.currentText()
mode = self.ui.modes.currentText()
if not mode:
return
print(f"Changing {mon} to {mode}")
self.xrandr_info[mon]["current_mode"] = mode
mode_x, mode_y = parse_mode(mode)
# use resolution via scaling
if self.xrandr_info[mon]["orientation"] in (0, 2):
self.xrandr_info[mon]["res_x"] = int(
mode_x * self.ui.horizontalScale.value() / 1000
)
self.xrandr_info[mon]["res_y"] = int(
mode_y * self.ui.verticalScale.value() / 1000
)
else:
self.xrandr_info[mon]["res_x"] = int(
mode_y * self.ui.horizontalScale.value() / 1000
)
self.xrandr_info[mon]["res_y"] = int(
mode_x * self.ui.verticalScale.value() / 1000
)
self.xrandr_info[mon]["item"].update_visuals(self.xrandr_info[mon])
def show_pos(self, x, y):
self.pos_label.setText(f"{x},{y}")
self.pos_label.resize(self.pos_label.sizeHint())
def monitor_moved(self):
"Update xrandr_info with new monitor positions"
for _, mon in self.xrandr_info.items():
item = mon["item"]
mon["pos_x"] = item.x()
mon["pos_y"] = item.y()
self.update_replica_of_data()
for _, mon in self.xrandr_info.items():
mon["item"].update_visuals(mon)
self.adjust_view()
def possible_snaps(self, name):
"""Return two lists of values to which the x and y position
of monitor "name" could snap to."""
snaps_x = []
snaps_y = []
for monitor, data in self.xrandr_info.items():
if monitor == name:
continue
else:
mod_x, mod_y = parse_mode(data["current_mode"])
snaps_x.append(data["pos_x"])
snaps_x.append(data["pos_x"] + mod_x)
snaps_y.append(data["pos_y"])
snaps_y.append(data["pos_y"] + mod_y)
return snaps_x, snaps_y
def adjust_view(self):
self.ui.sceneView.resetTransform()
self.ui.sceneView.ensureVisible(self.scene.sceneRect(), 100, 100)
scale_factor = 0.8 * min(
self.ui.sceneView.width() / self.scene.sceneRect().width(),
self.ui.sceneView.height() / self.scene.sceneRect().height(),
)
self.ui.sceneView.scale(scale_factor, scale_factor)
def get_xrandr_info(self):
self.screen = xrandr.parse_data(xrandr.read_data())
self.screen.update_replica_of()
data = subprocess.check_output(["xrandr"]).decode("utf-8").splitlines()
name = None
for line in data:
if (
line and line[0] not in "S \t" and "disconnected" not in line
): # Output line
(
name,
primary,
res_x,
res_y,
w_in_mm,
h_in_mm,
pos_x,
pos_y,
enabled,
orientation,
) = parse_monitor(line)
self.xrandr_info[name] = dict(
primary=primary,
res_x=res_x,
res_y=res_y,
w_in_mm=w_in_mm,
h_in_mm=h_in_mm,
pos_x=pos_x,
pos_y=pos_y,
modes=[],
current_mode=None,
enabled=enabled,
replica_of=[],
orientation=orientation,
)
elif line[0] == " ": # A mode
mode_name = line.strip().split()[0]
self.xrandr_info[name]["modes"].append(mode_name)
if "*" in line:
print(f"Current mode for {name}: {mode_name}")
self.xrandr_info[name]["current_mode"] = mode_name
self.update_replica_of_data()
def update_replica_of_data(self):
for a in self.xrandr_info:
self.xrandr_info[a]["replica_of"] = []
for b in self.xrandr_info:
if a != b and is_replica_of(self.xrandr_info[a], self.xrandr_info[b]):
self.xrandr_info[a]["replica_of"].append(b)
def monitor_selected(self, name):
if not name:
return
# needed so we don't flip through all modes as they are added
self.ui.modes.blockSignals(True)
# Show modes
self.ui.modes.clear()
for mode in self.xrandr_info[name]["modes"]:
self.ui.modes.addItem(mode)
if (
self.xrandr_info[name]["current_mode"] is None
): # Happens with turned off monitors
self.xrandr_info[name]["enabled"] = False
h_scale = v_scale = 1
else:
self.ui.modes.setCurrentText(self.xrandr_info[name]["current_mode"])
mod_x, mod_y = parse_mode(self.xrandr_info[name]["current_mode"])
if self.xrandr_info[name]["orientation"] in (0, 2):
h_scale = self.xrandr_info[name]["res_x"] / mod_x
v_scale = self.xrandr_info[name]["res_y"] / mod_y
else:
h_scale = self.xrandr_info[name]["res_y"] / mod_x
v_scale = self.xrandr_info[name]["res_x"] / mod_y
self.ui.horizontalScale.setValue(h_scale * 1000)
self.ui.verticalScale.setValue(v_scale * 1000)
self.ui.primary.setChecked(self.xrandr_info[name]["primary"])
self.ui.enabled.setChecked(self.xrandr_info[name]["enabled"])
self.ui.orientationCombo.setCurrentIndex(self.xrandr_info[name]["orientation"])
self.ui.replicaOf.clear()
self.ui.replicaOf.addItem("None")
for mon in self.xrandr_info:
if mon != name:
self.ui.replicaOf.addItem(mon)
if mon in self.xrandr_info[name]["replica_of"]:
self.ui.replicaOf.setCurrentText(mon)
self.ui.modes.blockSignals(False)
guessed_scale_mode = self.detect_scale_mode(self.xrandr_info[name])
self.ui.scaleModeCombo.setCurrentText(guessed_scale_mode)
self.scale_mode_changed()
def scale_changed(self):
self.ui.horizontalScaleLabel.setText(
f"{int(self.ui.horizontalScale.value()/10)}%"
)
self.ui.verticalScaleLabel.setText(f"{int(self.ui.verticalScale.value()/10)}%")
self.mode_changed() # Not really, but it's the same thing
def main():
app = QApplication(sys.argv)
ui_file = QFile(os.path.join(os.path.dirname(__file__), "main.ui"))
ui_file.open(QFile.ReadOnly)
loader = QUiLoader()
Window(loader.load(ui_file))
sys.exit(app.exec_())
if __name__ == "__main__":
main()