feat: Custom Monitor-Auswahl im RDP Client

Neue "Custom..." Option im Multi-Monitor-Dropdown, die einen
Checkbox-Dialog öffnet zur manuellen Auswahl einzelner Monitore.
Löst das Problem, dass bei "2 Monitors" immer die linkesten
Monitore gewählt wurden statt der gewünschten.

Neue Komponenten:
- _get_monitor_details(): Erkennt Monitore via xfreerdp + xrandr
- MonitorSelectionDialog: Checkbox-Dialog für Monitor-Auswahl
- monitor_ids wird pro Verbindung gespeichert

Bugfix: Doppelter _add_keyboard_shortcuts_info() Aufruf entfernt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-03 09:33:06 +01:00
parent cb073786b3
commit 3a01218bee

View File

@@ -324,6 +324,60 @@ class RDPClient:
pass pass
return 1 # Default to 1 monitor if detection fails return 1 # Default to 1 monitor if detection fails
def _get_monitor_details(self):
"""Get detailed information about all available monitors using xfreerdp and xrandr"""
monitors = []
# Get xrandr output names for better readability
xrandr_names = {}
try:
result = subprocess.run(['xrandr', '--listmonitors'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
for line in result.stdout.strip().split('\n')[1:]: # Skip "Monitors: N" header
# Format: " 0: +*eDP-1 1920/344x1200/215+0+0 eDP-1"
parts = line.strip().split()
if len(parts) >= 3:
idx = int(parts[0].rstrip(':'))
# Output name is after the +/* prefix
name_part = parts[1]
# Strip leading +, * characters
output_name = name_part.lstrip('+*')
xrandr_names[idx] = output_name
except Exception as e:
self.logger.warning(f"Could not get xrandr monitor names: {e}")
# Get xfreerdp monitor list for IDs, resolutions, positions
try:
result = subprocess.run(['xfreerdp', '/monitor-list'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
cleaned_line = line.strip()
is_primary = cleaned_line.startswith('*')
cleaned_line = cleaned_line.replace('*', '').strip()
if '[' in cleaned_line and ']' in cleaned_line and 'x' in cleaned_line and '+' in cleaned_line:
parts = cleaned_line.split()
if len(parts) >= 3:
id_part = parts[0] # [0]
res_part = parts[1] # 1920x1080
pos_part = parts[2] # +3840+0
if '[' in id_part and ']' in id_part:
monitor_id = int(id_part.strip('[]'))
monitors.append({
'id': monitor_id,
'name': xrandr_names.get(monitor_id, f"Monitor {monitor_id}"),
'resolution': res_part,
'position': pos_part,
'is_primary': is_primary,
})
except Exception as e:
self.logger.error(f"Could not get xfreerdp monitor list: {e}")
return monitors
def _get_multimon_options(self): def _get_multimon_options(self):
"""Get available multi-monitor options based on system""" """Get available multi-monitor options based on system"""
monitor_count = self._get_available_monitors_count() monitor_count = self._get_available_monitors_count()
@@ -334,6 +388,7 @@ class RDPClient:
options.append(f"{i} Monitors") options.append(f"{i} Monitors")
if monitor_count > 1: if monitor_count > 1:
options.append("Custom...")
options.extend(["All Monitors", "Span"]) options.extend(["All Monitors", "Span"])
return options return options
@@ -641,9 +696,6 @@ class RDPClient:
# Add tooltip information for shortcuts # Add tooltip information for shortcuts
self._add_keyboard_shortcuts_info() self._add_keyboard_shortcuts_info()
# Add tooltip information for shortcuts
self._add_keyboard_shortcuts_info()
def _add_keyboard_shortcuts_info(self): def _add_keyboard_shortcuts_info(self):
"""Add keyboard shortcuts information to the status bar or help""" """Add keyboard shortcuts information to the status bar or help"""
def show_shortcuts_hint(event): def show_shortcuts_hint(event):
@@ -689,6 +741,7 @@ Multi-Monitor Support:
"2 Monitors" - Use first 2 monitors (0,1) "2 Monitors" - Use first 2 monitors (0,1)
"3 Monitors" - Use first 3 monitors (0,1,2) "3 Monitors" - Use first 3 monitors (0,1,2)
"4 Monitors" - Use first 4 monitors (0,1,2,3) "4 Monitors" - Use first 4 monitors (0,1,2,3)
"Custom..." - Choose specific monitors via dialog
"All Monitors" - Use all available monitors "All Monitors" - Use all available monitors
"Span" - Span desktop across monitors "Span" - Span desktop across monitors
"No" - Single monitor only""" "No" - Single monitor only"""
@@ -718,7 +771,10 @@ Multi-Monitor Support:
self.details_labels["domain"].config(text=conn.get("domain", "N/A")) self.details_labels["domain"].config(text=conn.get("domain", "N/A"))
self.details_labels["resolution"].config(text=conn.get("resolution", "1920x1080")) self.details_labels["resolution"].config(text=conn.get("resolution", "1920x1080"))
self.details_labels["color_depth"].config(text=f"{conn.get('color_depth', 32)}-bit") self.details_labels["color_depth"].config(text=f"{conn.get('color_depth', 32)}-bit")
self.details_labels["multimon"].config(text=conn.get("multimon", "No")) multimon_text = conn.get("multimon", "No")
if multimon_text == "Custom..." and conn.get("monitor_ids"):
multimon_text = f"Custom (Monitors: {conn['monitor_ids']})"
self.details_labels["multimon"].config(text=multimon_text)
self.details_labels["sound"].config(text=conn.get("sound", "Yes")) self.details_labels["sound"].config(text=conn.get("sound", "Yes"))
self.details_labels["clipboard"].config(text=conn.get("clipboard", "Yes")) self.details_labels["clipboard"].config(text=conn.get("clipboard", "Yes"))
self.details_labels["drives"].config(text=conn.get("drives", "No")) self.details_labels["drives"].config(text=conn.get("drives", "No"))
@@ -928,12 +984,14 @@ Multi-Monitor Support:
# Check monitor configuration first to determine resolution handling # Check monitor configuration first to determine resolution handling
multimon = conn.get("multimon", "No") multimon = conn.get("multimon", "No")
use_specific_monitors = multimon in ["2 Monitors", "3 Monitors", "4 Monitors"] use_specific_monitors = multimon in ["2 Monitors", "3 Monitors", "4 Monitors", "Custom..."]
# Get monitor list if needed # Get monitor list if needed
monitor_list = None monitor_list = None
if use_specific_monitors: if use_specific_monitors:
if multimon == "2 Monitors": if multimon == "Custom...":
monitor_list = conn.get("monitor_ids", "0")
elif multimon == "2 Monitors":
monitor_list = self._get_best_monitor_selection(2) monitor_list = self._get_best_monitor_selection(2)
elif multimon == "3 Monitors": elif multimon == "3 Monitors":
monitor_list = self._get_best_monitor_selection(3) monitor_list = self._get_best_monitor_selection(3)
@@ -1354,6 +1412,73 @@ Multi-Monitor Support:
self.root.mainloop() self.root.mainloop()
class MonitorSelectionDialog:
"""Dialog for selecting specific monitors via checkboxes"""
def __init__(self, parent, monitors, preselected_ids=None):
"""
Args:
parent: Parent window
monitors: List of dicts with keys: id, name, resolution, position, is_primary
preselected_ids: List of monitor IDs to pre-check, or None for all
"""
self.result = None # Will be set to comma-separated IDs string on OK
self.dialog = tk.Toplevel(parent)
self.dialog.title("Select Monitors")
self.dialog.resizable(False, False)
self.dialog.transient(parent)
self.dialog.grab_set()
main_frame = ttk.Frame(self.dialog, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(main_frame, text="Select which monitors to use:",
font=('Segoe UI', 10, 'bold')).pack(anchor=tk.W, pady=(0, 10))
# Build checkboxes for each monitor
self.check_vars = {}
for mon in monitors:
mid = mon['id']
primary_tag = " (primary)" if mon['is_primary'] else ""
label = f"Monitor {mid}: {mon['name']} {mon['resolution']} {mon['position']}{primary_tag}"
var = tk.BooleanVar(value=(preselected_ids is None or mid in preselected_ids))
self.check_vars[mid] = var
cb = ttk.Checkbutton(main_frame, text=label, variable=var)
cb.pack(anchor=tk.W, pady=2)
# Buttons
btn_frame = ttk.Frame(main_frame)
btn_frame.pack(fill=tk.X, pady=(15, 0))
ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side=tk.RIGHT, padx=(10, 0))
ttk.Button(btn_frame, text="OK", command=self._ok).pack(side=tk.RIGHT)
# Size and center dialog
self.dialog.update_idletasks()
w = self.dialog.winfo_reqwidth() + 40
h = self.dialog.winfo_reqheight() + 20
x = (self.dialog.winfo_screenwidth() // 2) - (w // 2)
y = (self.dialog.winfo_screenheight() // 2) - (h // 2)
self.dialog.geometry(f"{w}x{h}+{x}+{y}")
self.dialog.wait_window()
def _ok(self):
selected = [mid for mid, var in self.check_vars.items() if var.get()]
if not selected:
messagebox.showwarning("No Selection", "Please select at least one monitor.",
parent=self.dialog)
return
self.result = ','.join(str(m) for m in sorted(selected))
self.dialog.destroy()
def _cancel(self):
self.dialog.destroy()
class ConnectionDialog: class ConnectionDialog:
def __init__(self, parent, title, initial_data=None, rdp_client=None): def __init__(self, parent, title, initial_data=None, rdp_client=None):
self.result = None self.result = None
@@ -1450,6 +1575,12 @@ class ConnectionDialog:
("Network Detection:", "network_auto_detect", "combo", ["Yes", "No"]), ("Network Detection:", "network_auto_detect", "combo", ["Yes", "No"]),
] ]
# Hidden field for custom monitor IDs
self.fields["monitor_ids"] = tk.StringVar(value=self.initial_data.get("monitor_ids", ""))
self._multimon_combo = None
self._monitor_ids_label = None
for i, (label, field, widget_type, values) in enumerate(advanced_fields): for i, (label, field, widget_type, values) in enumerate(advanced_fields):
row = i + 1 # Offset by 1 for description label row = i + 1 # Offset by 1 for description label
ttk.Label(advanced_frame, text=label).grid(row=row, column=0, sticky=tk.W, pady=5, padx=(10, 5)) ttk.Label(advanced_frame, text=label).grid(row=row, column=0, sticky=tk.W, pady=5, padx=(10, 5))
@@ -1459,6 +1590,16 @@ class ConnectionDialog:
widget.grid(row=row, column=1, sticky=tk.W, pady=5, padx=(5, 10)) widget.grid(row=row, column=1, sticky=tk.W, pady=5, padx=(5, 10))
self.fields[field] = var self.fields[field] = var
if field == "multimon":
self._multimon_combo = widget
# Add label to show current custom monitor selection
self._monitor_ids_label = ttk.Label(advanced_frame, text="", font=('Segoe UI', 8, 'italic'))
self._monitor_ids_label.grid(row=row, column=2, sticky=tk.W, padx=(5, 0))
# Update label if initial data has Custom...
if self.initial_data.get("multimon") == "Custom..." and self.initial_data.get("monitor_ids"):
self._monitor_ids_label.config(text=f"Monitors: {self.initial_data['monitor_ids']}")
widget.bind("<<ComboboxSelected>>", self._on_multimon_changed)
# Performance fields # Performance fields
performance_fields = [ performance_fields = [
("Compression:", "compression", "combo", ["Yes", "No"]), ("Compression:", "compression", "combo", ["Yes", "No"]),
@@ -1488,6 +1629,44 @@ class ConnectionDialog:
ttk.Button(button_frame, text="Cancel", command=self._cancel).pack(side=tk.RIGHT, padx=(10, 0)) ttk.Button(button_frame, text="Cancel", command=self._cancel).pack(side=tk.RIGHT, padx=(10, 0))
ttk.Button(button_frame, text="Save", command=self._save).pack(side=tk.RIGHT) ttk.Button(button_frame, text="Save", command=self._save).pack(side=tk.RIGHT)
def _on_multimon_changed(self, event=None):
"""Handle multimon dropdown changes - open monitor dialog for Custom..."""
value = self.fields["multimon"].get()
if value == "Custom...":
if not self.rdp_client:
messagebox.showwarning("Error", "Cannot detect monitors.", parent=self.dialog)
return
monitors = self.rdp_client._get_monitor_details()
if not monitors:
messagebox.showwarning("No Monitors", "Could not detect any monitors.", parent=self.dialog)
self.fields["multimon"].set("No")
return
# Parse preselected IDs from stored value
preselected = None
stored = self.fields["monitor_ids"].get()
if stored:
try:
preselected = [int(x.strip()) for x in stored.split(',')]
except ValueError:
preselected = None
dlg = MonitorSelectionDialog(self.dialog, monitors, preselected)
if dlg.result:
self.fields["monitor_ids"].set(dlg.result)
self._monitor_ids_label.config(text=f"Monitors: {dlg.result}")
else:
# User cancelled - revert to previous selection if no monitor_ids stored
if not self.fields["monitor_ids"].get():
self.fields["multimon"].set("No")
self._monitor_ids_label.config(text="")
else:
# Clear monitor_ids and label for non-custom selections
self.fields["monitor_ids"].set("")
if self._monitor_ids_label:
self._monitor_ids_label.config(text="")
def _save(self): def _save(self):
"""Save the connection""" """Save the connection"""
# Validate required fields # Validate required fields
@@ -1503,6 +1682,11 @@ class ConnectionDialog:
messagebox.showerror("Error", "Username is required.") messagebox.showerror("Error", "Username is required.")
return return
# Validate Custom... has monitor_ids
if self.fields["multimon"].get() == "Custom..." and not self.fields["monitor_ids"].get():
messagebox.showerror("Error", "Please select monitors for Custom... mode.", parent=self.dialog)
return
# Collect data # Collect data
self.result = {} self.result = {}
for field, var in self.fields.items(): for field, var in self.fields.items():