From fa2d64cd1f6dd7e0e93bb3e1fcb449a00ae53da2 Mon Sep 17 00:00:00 2001 From: Dawson Matthews Date: Sun, 8 Mar 2026 22:56:19 -0600 Subject: [PATCH 1/3] Has copy command button --- kde-display-profile-manager.py | 25 +++- load-display-profile.sh | 222 --------------------------------- save-display-profile.sh | 10 -- 3 files changed, 24 insertions(+), 233 deletions(-) delete mode 100755 load-display-profile.sh delete mode 100755 save-display-profile.sh diff --git a/kde-display-profile-manager.py b/kde-display-profile-manager.py index c35f13d..119cb2b 100755 --- a/kde-display-profile-manager.py +++ b/kde-display-profile-manager.py @@ -5,6 +5,7 @@ import json import subprocess import sys import os +import shlex from pathlib import Path # Try to import PySide6 for the GUI @@ -206,7 +207,7 @@ class DisplayProfileManagerGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("KDE Display Profile Manager") - self.setMinimumSize(400, 300) + self.setMinimumSize(420, 300) # Ensure default directory exists DEFAULT_PROFILE_DIR.mkdir(parents=True, exist_ok=True) @@ -237,6 +238,11 @@ class DisplayProfileManagerGUI(QMainWindow): self.refresh_btn.clicked.connect(self.refresh_profiles) btn_layout.addWidget(self.refresh_btn) + self.copy_btn = QPushButton("Copy Load Cmd") + self.copy_btn.setMinimumWidth(120) + self.copy_btn.clicked.connect(self.on_copy_clicked) + btn_layout.addWidget(self.copy_btn) + layout.addLayout(btn_layout) def refresh_profiles(self): @@ -291,6 +297,23 @@ class DisplayProfileManagerGUI(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load profile: {e}") + def on_copy_clicked(self): + selected_item = self.profile_list.currentItem() + if not selected_item: + QMessageBox.warning(self, "No Selection", "Please select a profile to copy the command for.") + return + + profile_name = selected_item.text() + profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" + + script_path = os.path.abspath(__file__) + cmd = f"python3 {shlex.quote(script_path)} load {shlex.quote(str(profile_path))}" + + clipboard = QApplication.clipboard() + clipboard.setText(cmd) + + QMessageBox.information(self, "Command Copied", f"The following command has been copied to your clipboard:\n\n{cmd}") + def show_gui(): if not PYSIDE_AVAILABLE: print("Error: PySide6 is not installed. Please install it to use the GUI.", file=sys.stderr) diff --git a/load-display-profile.sh b/load-display-profile.sh deleted file mode 100755 index 8fac973..0000000 --- a/load-display-profile.sh +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env bash - -VERBOSE=1 - - -function log { - if [ $VERBOSE -eq 1 ]; then - echo "$@" - fi -} -set -e - -# Make sure a config file was provided -if [ $# -lt 1 ]; then - echo "Usage: $0 " - exit -fi - -PROFILE=$1 - -if [[ ! -f "$PROFILE" ]]; then - echo "Profile file not found: $PROFILE" >&2 - exit 1 -fi - -if ! command -v jq &>/dev/null; then - echo "jq not installed!" >&2 - exit 1 -fi - -if ! command -v kscreen-doctor &>/dev/null; then - echo "kscreen-doctor not found!" >&2 - exit 1 -fi - -# Extract outputs list -enabled_outputs=$(jq -c '.outputs[] | select(.enabled == true)' "$PROFILE") -disabled_outputs=$(jq -c '.outputs[] | select(.enabled == false)' "$PROFILE") -outputs=$(jq -c '.outputs[]' "$PROFILE") - -# Restore enabled/disabled starting with the enabled monitors -# Starting with a disabled monitor might not work if it was -# previously the only enabled monitor - -function enable_outputs { - local outputs=$1 - - # If empty or only whitespace, return early - [[ -z "$outputs" ]] && return - - while IFS= read -r out; do - id=$(echo "$out" | jq -r '.id') - log "[VAR] id:" $id - name=$(echo "$out" | jq -r '.name') - log "[VAR] name:" $name - enabled=$(echo "$out" | jq -r '.enabled') - log "[VAR] enabled:" $enabled - - # Enable/disable - if [[ "$enabled" == "true" ]]; then - CMD="kscreen-doctor output.$name.enable" - log "[CMD]" $CMD - $CMD - else - CMD="kscreen-doctor output.$name.disable" - log "[CMD]" $CMD - $CMD - fi - done <<< "$outputs" -} - -log "Enabling enabled outputs..." -enable_outputs "$enabled_outputs" -log "Disabling disabled outputs..." -enable_outputs "$disabled_outputs" - -function apply_attribute { - output_id=$1 - attribute=$2 - value=$3 - value_map=$4 - - if [ $value != "null" ]; then - - if [[ -n "$value_map" ]]; then - log "[PREVALUE] Value supplied for $output_id $attribute: $value" - value=${value_map["$value"]} - log "[POSTVALUE] Value output for $output_id $attribute: $value" - fi - - CMD="kscreen-doctor output.$output_id.$attribute.$value" - log "[CMD]" $CMD - $CMD - else - log "Output $output_id has not attribute $attribute, skipping..." - fi -} - -function load_profile_to_outputs { - local outputs=$1 - - # If empty or only whitespace, return early - [[ -z "$outputs" ]] && return - - while IFS= read -r out; do - id=$(echo "$out" | jq -r '.id') - name=$(echo "$out" | jq -r '.name') - posx=$(echo "$out" | jq -r '.pos.x') - posy=$(echo "$out" | jq -r '.pos.y') - rotation=$(echo "$out" | jq -r '.rotation') - scale=$(echo "$out" | jq -r '.scale') - mode_id=$(echo "$out" | jq -r '.currentModeId') - brightness=$(echo "$out" | jq -r '.brightness') - brightness=$(awk "BEGIN {printf \"%d\", $brightness * 100}") - ddcCi=$(echo "$out" | jq -r '.ddcCi') - iccProfilePath=$(echo "$out" | jq -r '.iccProfilePath') - # mode_name=$(echo "$out" | jq -r ".modes[] | select(.id == \"$mode_id\") | .name") - # log "[VAR] mode_name:" $mode_name - refresh_rate=$(echo "$out" | jq -r ".modes[] | select(.id == \"$mode_id\") | .refreshRate") - refresh_rate=$(printf "%.0f" "$refresh_rate") - height=$(echo "$out" | jq -r ".modes[] | select(.id == \"$mode_id\") | .size.height") - width=$(echo "$out" | jq -r ".modes[] | select(.id == \"$mode_id\") | .size.width") - mode="${width}x${height}@${refresh_rate}" - replication_source_id=$(echo "$out" | jq -r ".replicationSource") - hdr=$(echo "$out" | jq -r ".hdr") - maxBpc=$(echo "$out" | jq -r ".maxBpc") - overscan=$(echo "$out" | jq -r ".overscan") - rgbRange=$(echo "$out" | jq -r ".rgbRange") - sdrbrightness=$(echo "$out" | jq -r '."sdr-brightness"') - vrrPolicy=$(echo "$out" | jq -r ".vrrPolicy") - wcg=$(echo "$out" | jq -r ".wcg") - - - declare -A bool_enable_map - bool_enable_map["true"]="enable" - bool_enable_map["false"]="disable" - - declare -A bool_allow_map - bool_allow_map["true"]="allow" - bool_allow_map["false"]="disallow" - - declare -A rgb_range_map - rgb_range_map["0"]="automatic" - rgb_range_map["full"]="full" - rgb_range_map["limited"]="limited" - - declare -A rotation_map - rotation_map["1"]="normal" - rotation_map["2"]="left" - rotation_map["4"]="inverted" - rotation_map["8"]="right" - - apply_attribute $name "wcg" $wcg $bool_enable_map - apply_attribute $name "sdr-brightness" $sdrbrightness - apply_attribute $name "vrrpolicy" $vrrPolicy - apply_attribute $name "rgbrange" $rgbRange $rgb_range_map - apply_attribute $name "overscan" $overscan - apply_attribute $name "hdr" $hdr $bool_enable_map - apply_attribute $name "brightness" $brightness - - if [ "$maxBpc" == 0 ]; then - maxBpc="automatic" - fi - apply_attribute $name "maxbpc" $maxBpc - apply_attribute $name "ddcCi" $ddcCi $bool_allow_map - - - log "[VAR] replication_source_id: $replication_source_id" - if [ $replication_source_id != 0 ]; then - replication_source_name=$(echo "$outputs.[] | select(.id == \"$replication_source_id\" | .name)" ) - apply_attribute $name "mirror" $replication_source_name - else - apply_attribute $name "mirror" "none" - fi - - priority=$(echo "$out" | jq -r '.priority') - - if [ "$iccProfilePath" != "" ]; then - apply_attribute $name "iccProfilePath" $iccProfilePath - fi - - - apply_attribute $name "mode" $mode - - # Position - # CMD="kscreen-doctor output.$name.position.$posx,$posy" - # log "[CMD]" $CMD - # $CMD - fullPosition="$posx,$posy" - apply_attribute $name "position" $fullPosition - - apply_attribute $name "scale" $scale - - # Rotation (map from JSON names to kscreen-doctor options) - # CMD="" - # case "$rotation" in - # "1") CMD="kscreen-doctor output.$name.rotation.normal" ;; - # "2") CMD="kscreen-doctor output.$name.rotation.left" ;; - # "4") CMD="kscreen-doctor output.$name.rotation.inverted" ;; - # "8") CMD="kscreen-doctor output.$name.rotation.right" ;; - # esac - # log "[CMD]" $CMD - # $CMD - apply_attribute $name "rotation" $rotation $rotation_map - - # Primary / Not Primary - if [ $priority -eq 1 ]; then - CMD="kscreen-doctor output.$name.primary" - log "[CMD]" $CMD - $CMD - fi - - CMD="kscreen-doctor output.$name.priority.$priority" - log "[CMD]" $CMD - $CMD - - done <<< "$outputs" -} - -load_profile_to_outputs "$outputs" - -echo "Display configuration restored." \ No newline at end of file diff --git a/save-display-profile.sh b/save-display-profile.sh deleted file mode 100755 index cb1f4d6..0000000 --- a/save-display-profile.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# Check for correct usage -if [ $# -lt 1 ]; then - echo "Usage: $0 " - exit -fi - -OUTPUT_CONFIG_NAME=$1 -kscreen-doctor --json > $OUTPUT_CONFIG_NAME \ No newline at end of file From 03084f1cd5e3a1855ce39682b252df3c1d9374c2 Mon Sep 17 00:00:00 2001 From: Dawson Matthews Date: Sun, 8 Mar 2026 22:58:06 -0600 Subject: [PATCH 2/3] added delete profile button --- kde-display-profile-manager.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/kde-display-profile-manager.py b/kde-display-profile-manager.py index 119cb2b..f45ad9f 100755 --- a/kde-display-profile-manager.py +++ b/kde-display-profile-manager.py @@ -237,6 +237,10 @@ class DisplayProfileManagerGUI(QMainWindow): self.refresh_btn = QPushButton("Refresh") self.refresh_btn.clicked.connect(self.refresh_profiles) btn_layout.addWidget(self.refresh_btn) + + self.delete_btn = QPushButton("Delete Profile") + self.delete_btn.clicked.connect(self.on_delete_clicked) + btn_layout.addWidget(self.delete_btn) self.copy_btn = QPushButton("Copy Load Cmd") self.copy_btn.setMinimumWidth(120) @@ -297,6 +301,27 @@ class DisplayProfileManagerGUI(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load profile: {e}") + def on_delete_clicked(self): + selected_item = self.profile_list.currentItem() + if not selected_item: + QMessageBox.warning(self, "No Selection", "Please select a profile to delete.") + return + + profile_name = selected_item.text() + profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" + + reply = QMessageBox.question(self, "Confirm Delete", + f"Are you sure you want to delete profile '{profile_name}'?", + QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + try: + os.remove(profile_path) + self.refresh_profiles() + QMessageBox.information(self, "Success", f"Profile '{profile_name}' deleted.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete profile: {e}") + def on_copy_clicked(self): selected_item = self.profile_list.currentItem() if not selected_item: From 1d4e419548ae5931d89ba0a05c21e99e60a1a3c4 Mon Sep 17 00:00:00 2001 From: Dawson Matthews Date: Sun, 8 Mar 2026 23:26:59 -0600 Subject: [PATCH 3/3] modified layout --- kde-display-profile-manager.py | 127 +++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 46 deletions(-) diff --git a/kde-display-profile-manager.py b/kde-display-profile-manager.py index f45ad9f..4b39422 100755 --- a/kde-display-profile-manager.py +++ b/kde-display-profile-manager.py @@ -12,10 +12,11 @@ from pathlib import Path try: from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QListWidget, QPushButton, QInputDialog, QMessageBox, QLabel, + QListWidget, QListWidgetItem, QPushButton, QInputDialog, QMessageBox, QLabel, QFileDialog ) - from PySide6.QtCore import Qt + from PySide6.QtGui import QIcon + from PySide6.QtCore import Qt, QSize PYSIDE_AVAILABLE = True except ImportError: PYSIDE_AVAILABLE = False @@ -220,32 +221,35 @@ class DisplayProfileManagerGUI(QMainWindow): self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) - layout.addWidget(QLabel("Available Profiles:")) + # Header layout + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel("Available Profiles:")) + header_layout.addStretch() + + self.refresh_btn = QPushButton() + self.refresh_btn.setIcon(QIcon.fromTheme("view-refresh")) + self.refresh_btn.setIconSize(QSize(20, 20)) + self.refresh_btn.setToolTip("Refresh Profiles") + self.refresh_btn.setFlat(True) + self.refresh_btn.clicked.connect(self.refresh_profiles) + header_layout.addWidget(self.refresh_btn) + + layout.addLayout(header_layout) self.profile_list = QListWidget() + self.profile_list.itemDoubleClicked.connect(self.on_load_clicked) layout.addWidget(self.profile_list) btn_layout = QHBoxLayout() - self.load_btn = QPushButton("Load Profile") - self.load_btn.clicked.connect(self.on_load_clicked) - btn_layout.addWidget(self.load_btn) - self.save_btn = QPushButton("Save Current") self.save_btn.clicked.connect(self.on_save_clicked) btn_layout.addWidget(self.save_btn) - - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.clicked.connect(self.refresh_profiles) - btn_layout.addWidget(self.refresh_btn) - self.delete_btn = QPushButton("Delete Profile") - self.delete_btn.clicked.connect(self.on_delete_clicked) - btn_layout.addWidget(self.delete_btn) + self.load_btn = QPushButton("Load Selected") + self.load_btn.clicked.connect(self.on_load_clicked) + btn_layout.addWidget(self.load_btn) - self.copy_btn = QPushButton("Copy Load Cmd") - self.copy_btn.setMinimumWidth(120) - self.copy_btn.clicked.connect(self.on_copy_clicked) - btn_layout.addWidget(self.copy_btn) + layout.addLayout(btn_layout) @@ -254,7 +258,44 @@ class DisplayProfileManagerGUI(QMainWindow): if DEFAULT_PROFILE_DIR.exists(): profiles = sorted(DEFAULT_PROFILE_DIR.glob("*.json")) for profile in profiles: - self.profile_list.addItem(profile.stem) + name = profile.stem + # Create item without text to prevent double-rendering/blurriness + item = QListWidgetItem(self.profile_list) + item.setData(Qt.UserRole, name) + + # Custom widget for the row + widget = QWidget() + row_layout = QHBoxLayout(widget) + row_layout.setContentsMargins(5, 2, 5, 2) + row_layout.setSpacing(5) + + label = QLabel(name) + # Ensure the label doesn't inherit transparency issues + label.setStyleSheet("background: transparent;") + row_layout.addWidget(label) + row_layout.addStretch() + + # Copy button + copy_btn = QPushButton() + copy_btn.setIcon(QIcon.fromTheme("edit-copy")) + copy_btn.setIconSize(QSize(16, 16)) + copy_btn.setFlat(True) + copy_btn.setToolTip("Copy Load Command") + copy_btn.clicked.connect(lambda checked=False, n=name: self.copy_profile_cmd(n)) + row_layout.addWidget(copy_btn) + + # Delete button + del_btn = QPushButton() + del_btn.setIcon(QIcon.fromTheme("user-trash")) + del_btn.setIconSize(QSize(16, 16)) + del_btn.setFlat(True) + del_btn.setToolTip("Delete Profile") + del_btn.clicked.connect(lambda checked=False, n=name: self.delete_profile(n)) + row_layout.addWidget(del_btn) + + item.setSizeHint(widget.sizeHint()) + self.profile_list.addItem(item) + self.profile_list.setItemWidget(item, widget) def get_default_profile_name(self): existing_names = [] @@ -286,13 +327,16 @@ class DisplayProfileManagerGUI(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save profile: {e}") - def on_load_clicked(self): - selected_item = self.profile_list.currentItem() - if not selected_item: + def on_load_clicked(self, item=None): + if not isinstance(item, QListWidgetItem): + item = self.profile_list.currentItem() + + if not item: QMessageBox.warning(self, "No Selection", "Please select a profile to load.") return - profile_name = selected_item.text() + # Retrieve name from UserRole data instead of item.text() + profile_name = item.data(Qt.UserRole) profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" try: @@ -301,13 +345,17 @@ class DisplayProfileManagerGUI(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load profile: {e}") - def on_delete_clicked(self): - selected_item = self.profile_list.currentItem() - if not selected_item: - QMessageBox.warning(self, "No Selection", "Please select a profile to delete.") - return + def copy_profile_cmd(self, profile_name): + profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" + script_path = os.path.abspath(__file__) + cmd = f"python3 {shlex.quote(script_path)} load {shlex.quote(str(profile_path))}" - profile_name = selected_item.text() + clipboard = QApplication.clipboard() + clipboard.setText(cmd) + + QMessageBox.information(self, "Command Copied", f"The following command has been copied to your clipboard:\n\n{cmd}") + + def delete_profile(self, profile_name): profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" reply = QMessageBox.question(self, "Confirm Delete", @@ -322,28 +370,15 @@ class DisplayProfileManagerGUI(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete profile: {e}") - def on_copy_clicked(self): - selected_item = self.profile_list.currentItem() - if not selected_item: - QMessageBox.warning(self, "No Selection", "Please select a profile to copy the command for.") - return - - profile_name = selected_item.text() - profile_path = DEFAULT_PROFILE_DIR / f"{profile_name}.json" - - script_path = os.path.abspath(__file__) - cmd = f"python3 {shlex.quote(script_path)} load {shlex.quote(str(profile_path))}" - - clipboard = QApplication.clipboard() - clipboard.setText(cmd) - - QMessageBox.information(self, "Command Copied", f"The following command has been copied to your clipboard:\n\n{cmd}") - def show_gui(): if not PYSIDE_AVAILABLE: print("Error: PySide6 is not installed. Please install it to use the GUI.", file=sys.stderr) sys.exit(1) + # Use 'Round' instead of 'PassThrough' as it is often more stable for font rendering + if hasattr(Qt, "HighDpiScaleFactorRoundingPolicy"): + QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.Round) + app = QApplication(sys.argv) window = DisplayProfileManagerGUI() window.show()