mirror of https://github.com/ralsina/xrandroll.git
342 lines
13 KiB
Python
342 lines
13 KiB
Python
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
import parse
|
|
from PySide2.QtCore import QFile, QObject, QTimer
|
|
from PySide2.QtUiTools import QUiLoader
|
|
from PySide2.QtWidgets import QApplication, QGraphicsScene, QLabel
|
|
|
|
from . import xrandr
|
|
from .monitor_item import MonitorItem
|
|
|
|
|
|
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.get_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.screen.monitors[mon]
|
|
monitor.enabled = enabled
|
|
if enabled and not monitor.get_current_mode():
|
|
# Choose a mode
|
|
self.ui.modes.setCurrentText(str(monitor.get_preferred_mode()))
|
|
self.mode_changed()
|
|
self.screen.update_replica_of()
|
|
for mon in self.screen.monitors.values():
|
|
mon.item.update_visuals(mon)
|
|
self.adjust_view()
|
|
|
|
def primary_changed(self):
|
|
mon_name = self.ui.screenCombo.currentText()
|
|
primary = self.ui.primary.isChecked()
|
|
if primary:
|
|
self.screen.set_primary(mon_name)
|
|
else:
|
|
self.screen.set_primary("foobar") # no primary
|
|
|
|
for monitor in self.screen.monitors.values():
|
|
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 = self.screen.get_primary()
|
|
if not primary:
|
|
print("Oops, no primary!")
|
|
return
|
|
monitor = self.screen.monitors[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_name = self.ui.screenCombo.currentText()
|
|
replicate = self.ui.replicaOf.currentText()
|
|
mon = self.screen.monitors[mon_name]
|
|
if replicate in ("None", "", None):
|
|
print(f"Making {mon_name} NOT a replica")
|
|
mon.pos_x += 300
|
|
else:
|
|
replicate = self.screen.monitors[replicate]
|
|
print(f"Making {mon_name} a replica of {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
|
|
matching_mode = mon.get_matching_mode(replicate.get_current_mode())
|
|
if matching_mode:
|
|
mon.set_current_mode(matching_mode.name)
|
|
else:
|
|
# Keep the current mode, and change scaling so it
|
|
# has the same effective size as the desired mode
|
|
c_mode = mon.get_current_mode()
|
|
mod_x, mod_y = c_mode.res_x, c_mode.res_y
|
|
r_mode = replicate.get_current_mode
|
|
target_x, target_y = r_mode.res_x, r_mode.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.screen.update_replica_of()
|
|
for mon in self.screen.monitors.values():
|
|
mon.item.update_visuals(mon)
|
|
|
|
def run(self, commands):
|
|
for i, cmd in enumerate(commands, 1):
|
|
print(f"Running {cmd} [{i}/{len(commands)}]")
|
|
subprocess.check_call(shlex.split(cmd))
|
|
|
|
def do_reset(self):
|
|
self.run(self.reset_screen.generate())
|
|
self.fill_ui()
|
|
|
|
def do_ok(self):
|
|
self.do_apply()
|
|
self.ui.accept()
|
|
|
|
def do_apply(self):
|
|
cli = self.screen.generate()
|
|
self.run(cli)
|
|
|
|
def fill_ui(self):
|
|
"""Configure UI out of our screen data."""
|
|
self.scene = QGraphicsScene(self)
|
|
self.ui.sceneView.setScene(self.scene)
|
|
self.ui.screenCombo.clear()
|
|
|
|
for name, monitor in self.screen.monitors.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.screen.choose_a_monitor())
|
|
self.adjust_view()
|
|
# self.scale_changed() # Trigger scale labels update
|
|
|
|
def orientation_changed(self):
|
|
mon_name = self.ui.screenCombo.currentText()
|
|
orientation = self.ui.orientationCombo.currentText().split()[0].lower()
|
|
self.screen.monitors[mon_name].orientation = orientation
|
|
self.mode_changed()
|
|
|
|
def mode_changed(self):
|
|
mon = self.ui.screenCombo.currentText()
|
|
mode = parse.search("({mode_name})", self.ui.modes.currentText())["mode_name"]
|
|
if not mode:
|
|
return
|
|
print(f"Changing {mon} to {mode}")
|
|
monitor = self.screen.monitors[mon]
|
|
monitor.set_current_mode(mode)
|
|
mode_x, mode_y = (
|
|
monitor.get_current_mode().res_x,
|
|
monitor.get_current_mode().res_y,
|
|
)
|
|
# use resolution via scaling
|
|
if monitor.orientation in ("normal", "inverted"):
|
|
monitor.res_x = int(mode_x * self.ui.horizontalScale.value() / 1000)
|
|
monitor.res_y = int(mode_y * self.ui.verticalScale.value() / 1000)
|
|
else:
|
|
monitor.res_x = int(mode_y * self.ui.horizontalScale.value() / 1000)
|
|
monitor.res_y = int(mode_x * self.ui.verticalScale.value() / 1000)
|
|
monitor.item.update_visuals(monitor)
|
|
|
|
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 screen with new monitor positions"
|
|
for mon in self.screen.monitors.values():
|
|
item = mon.item
|
|
mon.pos_x = item.x()
|
|
mon.pos_y = item.y()
|
|
self.screen.update_replica_of()
|
|
for mon in self.screen.monitors.values():
|
|
mon.item.update_visuals(mon)
|
|
# Adjust view a little later
|
|
QTimer.singleShot(0, 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 output, monitor in self.screen.monitors.items():
|
|
if output == name:
|
|
continue
|
|
else:
|
|
mode = monitor.get_current_mode()
|
|
mod_x, mod_y = mode.res_x, mode.res_y
|
|
snaps_x.append(monitor.pos_x)
|
|
snaps_x.append(monitor.pos_x + mod_x)
|
|
snaps_y.append(monitor.pos_y)
|
|
snaps_y.append(monitor.pos_y + mod_y)
|
|
return snaps_x, snaps_y
|
|
|
|
def adjust_view(self):
|
|
print("Adjusting view")
|
|
self.ui.sceneView.resetTransform()
|
|
self.ui.sceneView.ensureVisible(self.scene.sceneRect(), 100, 100)
|
|
try:
|
|
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)
|
|
except ZeroDivisionError:
|
|
# Don't worry
|
|
pass
|
|
|
|
def get_xrandr_info(self):
|
|
_xrandr_data = xrandr.read_data()
|
|
self.screen = xrandr.parse_data(_xrandr_data)
|
|
self.screen.update_replica_of()
|
|
self.reset_screen = xrandr.parse_data(_xrandr_data)
|
|
|
|
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)
|
|
self.ui.primary.blockSignals(True)
|
|
# Show modes
|
|
self.ui.modes.clear()
|
|
monitor = self.screen.monitors[name]
|
|
for _, mode in monitor.modes.items():
|
|
self.ui.modes.addItem(str(mode))
|
|
|
|
mode = monitor.get_current_mode()
|
|
self.ui.modes.setCurrentText(str(mode))
|
|
if monitor.orientation in ("normal", "inverted"):
|
|
h_scale = monitor.res_x / mode.res_x
|
|
v_scale = monitor.res_y / mode.res_y
|
|
else:
|
|
h_scale = monitor.res_y / mode.res_x
|
|
v_scale = monitor.res_x / mode.res_y
|
|
|
|
self.ui.horizontalScale.setValue(h_scale * 1000)
|
|
self.ui.verticalScale.setValue(v_scale * 1000)
|
|
self.ui.primary.setChecked(monitor.primary)
|
|
self.ui.enabled.setChecked(monitor.enabled)
|
|
self.ui.orientationCombo.setCurrentText(monitor.orientation)
|
|
|
|
self.ui.replicaOf.clear()
|
|
self.ui.replicaOf.addItem("None")
|
|
for mon in self.screen.monitors:
|
|
if mon != name:
|
|
self.ui.replicaOf.addItem(mon)
|
|
if mon in self.screen.monitors[name].replica_of:
|
|
self.ui.replicaOf.setCurrentText(mon)
|
|
self.ui.modes.blockSignals(False)
|
|
self.ui.primary.blockSignals(False)
|
|
|
|
guessed_scale_mode = monitor.guess_scale_mode()
|
|
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()
|