diff --git a/rdp_client.py b/rdp_client.py index 2748296..7c04672 100755 --- a/rdp_client.py +++ b/rdp_client.py @@ -324,6 +324,60 @@ class RDPClient: pass 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): """Get available multi-monitor options based on system""" monitor_count = self._get_available_monitors_count() @@ -334,6 +388,7 @@ class RDPClient: options.append(f"{i} Monitors") if monitor_count > 1: + options.append("Custom...") options.extend(["All Monitors", "Span"]) return options @@ -641,9 +696,6 @@ class RDPClient: # Add tooltip information for shortcuts self._add_keyboard_shortcuts_info() - # Add tooltip information for shortcuts - self._add_keyboard_shortcuts_info() - def _add_keyboard_shortcuts_info(self): """Add keyboard shortcuts information to the status bar or help""" def show_shortcuts_hint(event): @@ -687,8 +739,9 @@ Tips: Multi-Monitor Support: • "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) +• "Custom..." - Choose specific monitors via dialog • "All Monitors" - Use all available monitors • "Span" - Span desktop across monitors • "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["resolution"].config(text=conn.get("resolution", "1920x1080")) 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["clipboard"].config(text=conn.get("clipboard", "Yes")) 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 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 monitor_list = None 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) elif multimon == "3 Monitors": monitor_list = self._get_best_monitor_selection(3) @@ -1354,6 +1412,73 @@ Multi-Monitor Support: 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: def __init__(self, parent, title, initial_data=None, rdp_client=None): self.result = None @@ -1449,15 +1574,31 @@ class ConnectionDialog: ("Gateway Mode:", "gateway", "combo", ["Auto", "RPC", "HTTP"]), ("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): 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)) - + var = tk.StringVar(value=self.initial_data.get(field, values[0])) widget = ttk.Combobox(advanced_frame, textvariable=var, values=values, width=37, state="readonly") widget.grid(row=row, column=1, sticky=tk.W, pady=5, padx=(5, 10)) 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("<>", self._on_multimon_changed) # Performance fields performance_fields = [ @@ -1488,26 +1629,69 @@ class ConnectionDialog: 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) + 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): """Save the connection""" # Validate required fields if not self.fields["name"].get().strip(): messagebox.showerror("Error", "Connection name is required.") return - + if not self.fields["server"].get().strip(): messagebox.showerror("Error", "Server/IP is required.") return - + if not self.fields["username"].get().strip(): messagebox.showerror("Error", "Username is required.") 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 self.result = {} for field, var in self.fields.items(): self.result[field] = var.get().strip() - + self.dialog.destroy() def _cancel(self):