diff --git a/kde-display-profile-manager.py b/kde-display-profile-manager.py new file mode 100755 index 0000000..202cfc7 --- /dev/null +++ b/kde-display-profile-manager.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +import argparse +import json +import subprocess +import sys +import os + +VERBOSE = True + +def log(*args): + if VERBOSE: + print(*args) + +def run_command(cmd): + log(f"[CMD] {cmd}") + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError as e: + log(f"Error executing command: {e}") + +def get_mode_string(output, mode_id): + for mode in output.get('modes', []): + if mode['id'] == mode_id: + width = mode['size']['width'] + height = mode['size']['height'] + refresh_rate = round(mode['refreshRate']) + return f"{width}x{height}@{refresh_rate}" + return None + +def save_profile(profile_path): + log(f"Saving current display profile to {profile_path}...") + try: + with open(profile_path, 'w') as f: + subprocess.run(['kscreen-doctor', '--json'], stdout=f, check=True) + log("Profile saved successfully.") + except Exception as e: + print(f"Error saving profile: {e}", file=sys.stderr) + sys.exit(1) + +def apply_attribute(output_name, attribute, value, value_map=None): + if value is None: + log(f"Output {output_name} has no attribute {attribute}, skipping...") + return + + # If it's a list or dict, something might be wrong or it's a complex structure we don't handle this way + if isinstance(value, (list, dict)): + log(f"Output {output_name} attribute {attribute} has complex value {value}, skipping...") + return + + str_value = str(value).lower() + if value_map: + if str_value in value_map: + value = value_map[str_value] + else: + log(f"Warning: Value {str_value} not found in map for {attribute}, using as is.") + + cmd = f"kscreen-doctor output.{output_name}.{attribute}.{value}" + run_command(cmd) + +def load_profile(profile_path): + log(f"Loading display profile from {profile_path}...") + if not os.path.exists(profile_path): + print(f"Profile file not found: {profile_path}", file=sys.stderr) + sys.exit(1) + + try: + with open(profile_path, 'r') as f: + profile = json.load(f) + except Exception as e: + print(f"Error reading profile JSON: {e}", file=sys.stderr) + sys.exit(1) + + outputs = profile.get('outputs', []) + + # Maps from JSON values to kscreen-doctor options + bool_enable_map = {"true": "enable", "false": "disable"} + bool_allow_map = {"true": "allow", "false": "disallow"} + rgb_range_map = { + "0": "automatic", "1": "full", "2": "limited", + "automatic": "automatic", "full": "full", "limited": "limited" + } + rotation_map = {"1": "normal", "2": "left", "4": "inverted", "8": "right"} + vrr_policy_map = { + "0": "never", "1": "always", "2": "automatic", + "never": "never", "always": "always", "automatic": "automatic" + } + + # 1. Handle Enable/Disable (Enable first to avoid no-output state) + log("Enabling enabled outputs...") + for out in outputs: + if out.get('enabled'): + run_command(f"kscreen-doctor output.{out['name']}.enable") + + log("Disabling disabled outputs...") + for out in outputs: + if not out.get('enabled'): + run_command(f"kscreen-doctor output.{out['name']}.disable") + + # 2. Apply other attributes + for out in outputs: + name = out['name'] + log(f"Configuring output: {name}") + + # WCG + apply_attribute(name, "wcg", out.get('wcg'), bool_enable_map) + + # SDR Brightness + apply_attribute(name, "sdr-brightness", out.get('sdr-brightness')) + + # VRR Policy + apply_attribute(name, "vrrpolicy", out.get('vrrPolicy'), vrr_policy_map) + + # RGB Range + apply_attribute(name, "rgbrange", out.get('rgbRange'), rgb_range_map) + + # Overscan + apply_attribute(name, "overscan", out.get('overscan')) + + # HDR + apply_attribute(name, "hdr", out.get('hdr'), bool_enable_map) + + # Brightness (0.0-1.0 to 0-100) + brightness = out.get('brightness') + if brightness is not None: + apply_attribute(name, "brightness", int(float(brightness) * 100)) + + # Max BPC + max_bpc = out.get('maxBpc') + if max_bpc == 0: + max_bpc = "automatic" + apply_attribute(name, "maxbpc", max_bpc) + + # DDC/CI + apply_attribute(name, "ddcCi", out.get('ddcCiAllowed'), bool_allow_map) + + # Mirroring + replication_source_id = out.get('replicationSource', 0) + if replication_source_id != 0: + # Find the name of the replication source + source_name = "none" + for other_out in outputs: + if other_out.get('id') == replication_source_id: + source_name = other_out['name'] + break + apply_attribute(name, "mirror", source_name) + else: + apply_attribute(name, "mirror", "none") + + # ICC Profile + icc_path = out.get('iccProfilePath') + if icc_path: + apply_attribute(name, "iccProfilePath", icc_path) + + # Mode + mode_id = out.get('currentModeId') + if mode_id: + mode_str = get_mode_string(out, mode_id) + if mode_str: + apply_attribute(name, "mode", mode_str) + + # Position + pos = out.get('pos') + if pos: + apply_attribute(name, "position", f"{pos['x']},{pos['y']}") + + # Scale + apply_attribute(name, "scale", out.get('scale')) + + # Rotation + apply_attribute(name, "rotation", out.get('rotation'), rotation_map) + + # Priority and Primary + priority = out.get('priority') + if priority is not None: + if priority == 1: + run_command(f"kscreen-doctor output.{name}.primary") + run_command(f"kscreen-doctor output.{name}.priority.{priority}") + + log("Display configuration restored.") + +def main(): + parser = argparse.ArgumentParser(description="KDE Display Profile Manager") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Save command + save_parser = subparsers.add_parser("save", help="Save current display configuration to a profile") + save_parser.add_argument("profile", help="Path to the profile file (e.g., profiles/myprofile.json)") + + # Load command + load_parser = subparsers.add_parser("load", help="Load a display configuration from a profile") + load_parser.add_argument("profile", help="Path to the profile file") + + args = parser.parse_args() + + if args.command == "save": + save_profile(args.profile) + elif args.command == "load": + load_profile(args.profile) + +if __name__ == "__main__": + main() diff --git a/src/__pycache__/mainwindow.cpython-314.pyc b/src/__pycache__/mainwindow.cpython-314.pyc deleted file mode 100644 index c1ba515..0000000 Binary files a/src/__pycache__/mainwindow.cpython-314.pyc and /dev/null differ diff --git a/src/__pycache__/profile_manager.cpython-314.pyc b/src/__pycache__/profile_manager.cpython-314.pyc deleted file mode 100644 index 4eba625..0000000 Binary files a/src/__pycache__/profile_manager.cpython-314.pyc and /dev/null differ diff --git a/src/__pycache__/save_dialog.cpython-314.pyc b/src/__pycache__/save_dialog.cpython-314.pyc deleted file mode 100644 index ca8e8bc..0000000 Binary files a/src/__pycache__/save_dialog.cpython-314.pyc and /dev/null differ diff --git a/src/main.py b/src/main.py deleted file mode 100644 index fcda406..0000000 --- a/src/main.py +++ /dev/null @@ -1,21 +0,0 @@ -import sys -from PyQt6.QtWidgets import QApplication -from PyQt6.QtGui import QIcon -from mainwindow import MainWindow - -def main(): - app = QApplication(sys.argv) - app.setApplicationName("Display Profile Manager") - app.setOrganizationName("KDE") - app.setOrganizationDomain("kde.org") - - # Set application icon (we'll add this later) - # app.setWindowIcon(QIcon.fromTheme("video-display")) - - window = MainWindow() - window.show() - - sys.exit(app.exec()) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/mainwindow.py b/src/mainwindow.py deleted file mode 100644 index 840a725..0000000 --- a/src/mainwindow.py +++ /dev/null @@ -1,263 +0,0 @@ -from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QPushButton, QListWidget, QListWidgetItem, - QLabel, QGroupBox, QLineEdit, QMessageBox, - QMenu, QMenuBar) -from PyQt6.QtCore import Qt, QSize -from PyQt6.QtGui import QAction, QKeySequence, QIcon -from save_dialog import SaveProfileDialog -from profile_manager import ProfileManager - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.profile_manager = ProfileManager() - self.init_ui() - self.load_profiles() - - def init_ui(self): - self.setWindowTitle("Display Profile Manager") - self.setMinimumSize(800, 600) - - # Create menu bar - self.create_menu_bar() - - # Create central widget - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main layout - main_layout = QHBoxLayout(central_widget) - - # Left panel - Profile list - left_panel = self.create_left_panel() - main_layout.addWidget(left_panel, 2) - - # Right panel - Profile details - right_panel = self.create_right_panel() - main_layout.addWidget(right_panel, 1) - - def create_menu_bar(self): - menubar = self.menuBar() - - # File menu - file_menu = menubar.addMenu("&File") - - save_action = QAction("&Save Current Display", self) - save_action.setShortcut(QKeySequence("Ctrl+S")) - save_action.triggered.connect(self.save_profile) - file_menu.addAction(save_action) - - file_menu.addSeparator() - - quit_action = QAction("&Quit", self) - quit_action.setShortcut(QKeySequence("Ctrl+Q")) - quit_action.triggered.connect(self.close) - file_menu.addAction(quit_action) - - # Edit menu - edit_menu = menubar.addMenu("&Edit") - - rename_action = QAction("&Rename Profile", self) - rename_action.setShortcut(QKeySequence("F2")) - rename_action.triggered.connect(self.rename_profile) - edit_menu.addAction(rename_action) - - delete_action = QAction("&Delete Profile", self) - delete_action.setShortcut(QKeySequence("Delete")) - delete_action.triggered.connect(self.delete_profile) - edit_menu.addAction(delete_action) - - # Help menu - help_menu = menubar.addMenu("&Help") - - about_action = QAction("&About", self) - about_action.triggered.connect(self.show_about) - help_menu.addAction(about_action) - - def create_left_panel(self): - left_widget = QWidget() - left_layout = QVBoxLayout(left_widget) - - # Title - title_label = QLabel("Saved Profiles") - title_label.setStyleSheet("font-size: 14pt; font-weight: bold;") - left_layout.addWidget(title_label) - - # Profile list - self.profile_list = QListWidget() - self.profile_list.itemSelectionChanged.connect(self.on_profile_selected) - self.profile_list.itemDoubleClicked.connect(self.load_selected_profile) - left_layout.addWidget(self.profile_list) - - # Buttons - button_layout = QHBoxLayout() - - self.save_btn = QPushButton("Save Current") - self.save_btn.clicked.connect(self.save_profile) - button_layout.addWidget(self.save_btn) - - self.load_btn = QPushButton("Load") - self.load_btn.clicked.connect(self.load_selected_profile) - self.load_btn.setEnabled(False) - button_layout.addWidget(self.load_btn) - - self.delete_btn = QPushButton("Delete") - self.delete_btn.clicked.connect(self.delete_profile) - self.delete_btn.setEnabled(False) - button_layout.addWidget(self.delete_btn) - - left_layout.addLayout(button_layout) - - return left_widget - - def create_right_panel(self): - right_widget = QWidget() - right_layout = QVBoxLayout(right_widget) - - # Profile details group - details_group = QGroupBox("Profile Details") - details_layout = QVBoxLayout(details_group) - - # Profile name - name_layout = QHBoxLayout() - name_layout.addWidget(QLabel("Name:")) - self.name_edit = QLineEdit() - self.name_edit.setReadOnly(True) - name_layout.addWidget(self.name_edit) - details_layout.addLayout(name_layout) - - # Keyboard shortcut - shortcut_layout = QHBoxLayout() - shortcut_layout.addWidget(QLabel("Shortcut:")) - self.shortcut_label = QLabel("None") - shortcut_layout.addWidget(self.shortcut_label) - shortcut_layout.addStretch() - self.set_shortcut_btn = QPushButton("Set Shortcut") - self.set_shortcut_btn.setEnabled(False) - self.set_shortcut_btn.clicked.connect(self.set_shortcut) - shortcut_layout.addWidget(self.set_shortcut_btn) - details_layout.addLayout(shortcut_layout) - - # Profile info - self.info_label = QLabel("Select a profile to view details") - self.info_label.setWordWrap(True) - self.info_label.setAlignment(Qt.AlignmentFlag.AlignTop) - details_layout.addWidget(self.info_label) - - details_layout.addStretch() - - right_layout.addWidget(details_group) - right_layout.addStretch() - - return right_widget - - def load_profiles(self): - """Load and display all saved profiles""" - self.profile_list.clear() - profiles = self.profile_manager.list_profiles() - - for profile in profiles: - item = QListWidgetItem(profile['name']) - item.setData(Qt.ItemDataRole.UserRole, profile) - self.profile_list.addItem(item) - - def save_profile(self): - """Show dialog to save current display configuration""" - dialog = SaveProfileDialog(self.profile_manager, self) - if dialog.exec(): - profile_name = dialog.get_profile_name() - try: - self.profile_manager.save_profile(profile_name) - self.load_profiles() - QMessageBox.information(self, "Success", - f"Profile '{profile_name}' saved successfully!") - except Exception as e: - QMessageBox.critical(self, "Error", - f"Failed to save profile: {str(e)}") - - def load_selected_profile(self): - """Load the selected profile""" - current_item = self.profile_list.currentItem() - if not current_item: - return - - profile = current_item.data(Qt.ItemDataRole.UserRole) - try: - self.profile_manager.load_profile(profile['id']) - QMessageBox.information(self, "Success", - f"Profile '{profile['name']}' loaded successfully!") - except Exception as e: - QMessageBox.critical(self, "Error", - f"Failed to load profile: {str(e)}") - - def delete_profile(self): - """Delete the selected profile""" - current_item = self.profile_list.currentItem() - if not current_item: - return - - profile = current_item.data(Qt.ItemDataRole.UserRole) - - reply = QMessageBox.question(self, "Confirm Delete", - f"Are you sure you want to delete '{profile['name']}'?", - QMessageBox.StandardButton.Yes | - QMessageBox.StandardButton.No) - - if reply == QMessageBox.StandardButton.Yes: - try: - self.profile_manager.delete_profile(profile['id']) - self.load_profiles() - QMessageBox.information(self, "Success", - f"Profile '{profile['name']}' deleted successfully!") - except Exception as e: - QMessageBox.critical(self, "Error", - f"Failed to delete profile: {str(e)}") - - def rename_profile(self): - """Rename the selected profile""" - current_item = self.profile_list.currentItem() - if not current_item: - return - - # Make item editable - self.profile_list.editItem(current_item) - - def on_profile_selected(self): - """Handle profile selection""" - current_item = self.profile_list.currentItem() - has_selection = current_item is not None - - self.load_btn.setEnabled(has_selection) - self.delete_btn.setEnabled(has_selection) - self.set_shortcut_btn.setEnabled(has_selection) - - if has_selection: - profile = current_item.data(Qt.ItemDataRole.UserRole) - self.name_edit.setText(profile['name']) - self.name_edit.setReadOnly(False) - - shortcut = profile.get('shortcut', 'None') - self.shortcut_label.setText(shortcut) - - # Display profile info - info_text = f"Created: {profile.get('created', 'Unknown')}\n" - info_text += f"Last Modified: {profile.get('modified', 'Unknown')}" - self.info_label.setText(info_text) - else: - self.name_edit.clear() - self.name_edit.setReadOnly(True) - self.shortcut_label.setText("None") - self.info_label.setText("Select a profile to view details") - - def set_shortcut(self): - """Set keyboard shortcut for selected profile""" - QMessageBox.information(self, "Coming Soon", - "Keyboard shortcut configuration will be implemented next!") - - def show_about(self): - """Show about dialog""" - QMessageBox.about(self, "About Display Profile Manager", - "Display Profile Manager\n\n" - "Manage KDE Plasma display configurations\n\n" - "Save, load, and manage multiple display profiles\n" - "with keyboard shortcuts.") \ No newline at end of file diff --git a/src/profile_manager.py b/src/profile_manager.py deleted file mode 100644 index af6f110..0000000 --- a/src/profile_manager.py +++ /dev/null @@ -1,114 +0,0 @@ -import os -import json -import subprocess -from datetime import datetime -from pathlib import Path -import uuid - -class ProfileManager: - def __init__(self): - self.config_dir = Path.home() / ".local/share/plasma-display-profiles" - self.config_dir.mkdir(parents=True, exist_ok=True) - self.metadata_file = self.config_dir / "profiles.json" - self.script_path = self.find_script() - - # Initialize metadata file if it doesn't exist - if not self.metadata_file.exists(): - self.save_metadata([]) - - def find_script(self): - """Find the bash script for display management""" - return "scripts/" - - def list_profiles(self): - """Return list of all profiles""" - return self.load_metadata() - - def save_profile(self, name): - """Save current display configuration as a new profile""" - profiles = self.load_metadata() - - # Generate unique ID - profile_id = str(uuid.uuid4()) - timestamp = datetime.now().isoformat() - - # Create profile entry - profile = { - 'id': profile_id, - 'name': name, - 'created': timestamp, - 'modified': timestamp, - 'script_file': f"{profile_id}.sh", - 'shortcut': None - } - - # TODO: Call bash script to save display configuration - # For now, create a placeholder script file - script_file = self.config_dir / profile['script_file'] - script_file.write_text(f"#!/bin/bash\n# Display profile: {name}\n# Created: {timestamp}\n") - script_file.chmod(0o755) - - # If script exists, call it - if self.script_path and self.script_path.exists(): - try: - subprocess.run([str(self.script_path), 'save', str(script_file)], - check=True, capture_output=True) - except subprocess.CalledProcessError as e: - raise Exception(f"Script execution failed: {e.stderr.decode()}") - - profiles.append(profile) - self.save_metadata(profiles) - - def load_profile(self, profile_id): - """Load a display profile""" - profiles = self.load_metadata() - profile = next((p for p in profiles if p['id'] == profile_id), None) - - if not profile: - raise Exception(f"Profile not found: {profile_id}") - - script_file = self.config_dir / profile['script_file'] - - if not script_file.exists(): - raise Exception(f"Profile script not found: {script_file}") - - # Execute the profile script - try: - if self.script_path and self.script_path.exists(): - subprocess.run([str(self.script_path), 'load', str(script_file)], - check=True, capture_output=True) - else: - # Fallback: execute script directly - subprocess.run([str(script_file)], check=True, capture_output=True) - except subprocess.CalledProcessError as e: - raise Exception(f"Failed to load profile: {e.stderr.decode()}") - - def delete_profile(self, profile_id): - """Delete a profile""" - profiles = self.load_metadata() - profile = next((p for p in profiles if p['id'] == profile_id), None) - - if not profile: - raise Exception(f"Profile not found: {profile_id}") - - # Remove script file - script_file = self.config_dir / profile['script_file'] - if script_file.exists(): - script_file.unlink() - - # Remove from metadata - profiles = [p for p in profiles if p['id'] != profile_id] - self.save_metadata(profiles) - - def rename_profile(self, profile_id, new_name): - """Rename a profile""" - profiles = self.load_metadata() - profile = next((p for p in profiles if p['id'] == profile_id), None) - - if not profile: - raise Exception(f"Profile not found: {profile_id}") - - profile['name'] = new_name - profile['modified'] = datetime.now().isoformat() - - self.save_metadata(profiles) \ No newline at end of file diff --git a/src/save_dialog.py b/src/save_dialog.py deleted file mode 100644 index ffd78b5..0000000 --- a/src/save_dialog.py +++ /dev/null @@ -1,77 +0,0 @@ -from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QMessageBox) -from PyQt6.QtCore import Qt - -class SaveProfileDialog(QDialog): - def __init__(self, profile_manager, parent=None): - super().__init__(parent) - self.profile_manager = profile_manager - self.init_ui() - - def init_ui(self): - self.setWindowTitle("Save Display Profile") - self.setMinimumWidth(400) - - layout = QVBoxLayout(self) - - # Instruction label - info_label = QLabel("Enter a name for this display profile:") - layout.addWidget(info_label) - - # Profile name input - self.name_input = QLineEdit() - self.name_input.setPlaceholderText(self.get_default_name()) - self.name_input.textChanged.connect(self.validate_input) - layout.addWidget(self.name_input) - - # Hint label - hint_label = QLabel("Leave empty to use default name") - hint_label.setStyleSheet("color: gray; font-size: 9pt;") - layout.addWidget(hint_label) - - # Buttons - button_layout = QHBoxLayout() - - self.save_btn = QPushButton("Save") - self.save_btn.setDefault(True) - self.save_btn.clicked.connect(self.accept) - - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.reject) - - button_layout.addStretch() - button_layout.addWidget(cancel_btn) - button_layout.addWidget(self.save_btn) - - layout.addLayout(button_layout) - - def get_default_name(self): - """Generate default profile name""" - profiles = self.profile_manager.list_profiles() - - # Find the next available number - counter = 1 - while True: - name = f"My Profile {counter}" - if not any(p['name'] == name for p in profiles): - return name - counter += 1 - - def validate_input(self): - """Validate profile name""" - name = self.name_input.text().strip() - if not name: - self.save_btn.setEnabled(True) - return - - # Check for duplicates - profiles = self.profile_manager.list_profiles() - if any(p['name'] == name for p in profiles): - self.save_btn.setEnabled(False) - else: - self.save_btn.setEnabled(True) - - def get_profile_name(self): - """Return the profile name (default or custom)""" - name = self.name_input.text().strip() - return name if name else self.get_default_name() \ No newline at end of file