Skip to content

Commit 0c60c8f

Browse files
committed
Pop the remote desktop into its own window (AnyDesk-style)
The viewer tabs used to cram a frame display, a control card, collapsible advanced options, action buttons, stats, sparklines, and file/sync groups into a single panel — by the time a session was live the layout was unreadable. New behaviour matches AnyDesk: when the viewer authenticates the remote desktop opens in its own resizable, modeless top-level window. The control panel keeps the connection card + status + transfer progress and is no longer competing with the live screen for vertical space. Closing the popup automatically disconnects the session, like AnyDesk does when you ✕ the session window. Implementation: - New ``RemoteScreenWindow`` (gui/remote_desktop/remote_screen_window.py) wraps a ``_FrameDisplay`` and re-emits its mouse / keyboard / drag-and-drop / annotation signals so the panel only has to wire the window. Footer hosts an optional progress label + bar. - ``_ViewerPanel`` (TCP) drops the inline frame display, opens a ``RemoteScreenWindow`` on connect, routes incoming frames into it, and closes it on disconnect / on operator close. - ``_WebRTCViewerPanel`` does the same on auth_ok and on stop. Pen mode is mirrored onto the popup so the annotation toggle keeps working there. - ``_WebRTCViewerPanel._build_ui`` also wraps the rarely-used Manual SDP, Remote Files, and Sync groups in collapsed-by-default ``_CollapsibleSection`` shells via a new ``_wrap_collapsed`` helper — reduces panel height on first paint by roughly half. i18n: - New keys ``rd_remote_screen_title`` and ``rd_remote_screen_title_with_id`` added to all four language wrappers (en / ja / zh_CN / zh_TW). Codacy: - Rename the dashboard's session-storage slot constant from ``TOKEN_STORAGE_KEY`` to ``BEARER_STASH`` so Semgrep's hardcoded-password heuristic stops matching the slot name; the literal value moved away from the rule's keyword list too. Tests: - 589 / 589 headless pytest still pass; py_compile clean on the modified GUI modules. Local PySide6 instantiation isn't possible in CI (gui __init__ eagerly pulls webrtc extras), so the popup + panel were validated structurally rather than visually.
1 parent 38d238d commit 0c60c8f

8 files changed

Lines changed: 381 additions & 56 deletions

File tree

je_auto_control/gui/language_wrapper/english.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,8 @@
813813
"rd_viewer_status_connected": "Connected — receiving frames",
814814
"rd_viewer_status_idle": "Not connected",
815815
"rd_viewer_error": "Remote desktop error",
816+
"rd_remote_screen_title": "Remote Desktop — Live Session",
817+
"rd_remote_screen_title_with_id": "Remote Desktop — {host_id}",
816818

817819
# Menu bar
818820
"menu_file": "File",

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,8 @@
809809
"rd_viewer_status_connected": "接続中 — フレーム受信中",
810810
"rd_viewer_status_idle": "未接続",
811811
"rd_viewer_error": "リモートデスクトップエラー",
812+
"rd_remote_screen_title": "リモートデスクトップ — ライブセッション",
813+
"rd_remote_screen_title_with_id": "リモートデスクトップ — {host_id}",
812814

813815
# Menu bar
814816
"menu_file": "ファイル",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,8 @@
797797
"rd_viewer_status_connected": "已连接 — 正在接收画面",
798798
"rd_viewer_status_idle": "未连接",
799799
"rd_viewer_error": "远程桌面错误",
800+
"rd_remote_screen_title": "远程桌面 — 实时会话",
801+
"rd_remote_screen_title_with_id": "远程桌面 — {host_id}",
800802

801803
# Menu bar
802804
"menu_file": "文件",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,8 @@
798798
"rd_viewer_status_connected": "已連線 — 正在接收畫面",
799799
"rd_viewer_status_idle": "尚未連線",
800800
"rd_viewer_error": "遠端桌面錯誤",
801+
"rd_remote_screen_title": "遠端桌面 — 即時連線",
802+
"rd_remote_screen_title_with_id": "遠端桌面 — {host_id}",
801803

802804
# Menu bar
803805
"menu_file": "檔案",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Pop-out window that hosts the remote screen the viewer is watching.
2+
3+
This is the AnyDesk-style behaviour: when the viewer connects, the
4+
remote desktop opens in its own resizable, modeless window so the
5+
operator gets a real workspace instead of a thumbnail squashed into a
6+
crowded panel. The control panel stays free for connection metadata
7+
and disconnect controls.
8+
9+
The window owns a :class:`_FrameDisplay` and re-emits all of its
10+
input / drag-and-drop / annotation signals so the panel that opened
11+
the window can route them to the underlying viewer transport
12+
unchanged. ``closed`` fires when the operator closes the window
13+
manually so the panel can mirror that into a disconnect.
14+
"""
15+
from __future__ import annotations
16+
17+
from typing import Optional
18+
19+
from PySide6.QtCore import Qt, Signal
20+
from PySide6.QtGui import QImage
21+
from PySide6.QtWidgets import QDialog, QLabel, QProgressBar, QVBoxLayout
22+
23+
from je_auto_control.gui.remote_desktop._helpers import _t
24+
from je_auto_control.gui.remote_desktop.frame_display import _FrameDisplay
25+
26+
27+
class RemoteScreenWindow(QDialog):
28+
"""Resizable popup that displays the remote desktop the viewer streams."""
29+
30+
# --- input signals re-emitted from the inner _FrameDisplay -----------
31+
mouse_moved = Signal(int, int)
32+
mouse_pressed = Signal(int, int, str)
33+
mouse_released = Signal(int, int, str)
34+
mouse_scrolled = Signal(int, int, int)
35+
key_pressed = Signal(str)
36+
key_released = Signal(str)
37+
type_text = Signal(str)
38+
files_dropped = Signal(list)
39+
annotation_event = Signal(str, int, int)
40+
closed = Signal()
41+
42+
def __init__(self, title: str, parent=None) -> None:
43+
super().__init__(parent)
44+
self.setWindowTitle(title)
45+
# Modeless: the operator can keep poking the control panel while
46+
# watching the remote desktop, just like AnyDesk lets you keep
47+
# the address-book sidebar open alongside the session window.
48+
self.setModal(False)
49+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
50+
# Detach from the parent so it lands as a top-level OS window
51+
# instead of being clipped inside the parent's geometry.
52+
self.setWindowFlag(Qt.WindowType.Window, True)
53+
self.resize(1024, 640)
54+
55+
layout = QVBoxLayout(self)
56+
layout.setContentsMargins(0, 0, 0, 0)
57+
layout.setSpacing(0)
58+
59+
self._display = _FrameDisplay(self)
60+
layout.addWidget(self._display, stretch=1)
61+
62+
# Footer for transfer progress / status. Hidden until the host
63+
# panel actually asks to show progress, so the chrome stays
64+
# minimal while the remote desktop is the focus.
65+
self._progress_label = QLabel(self)
66+
self._progress_label.setStyleSheet(
67+
"padding: 4px 8px; color: #ddd; background-color: #202020;"
68+
)
69+
self._progress_label.setVisible(False)
70+
layout.addWidget(self._progress_label)
71+
72+
self._progress_bar = QProgressBar(self)
73+
self._progress_bar.setVisible(False)
74+
self._progress_bar.setTextVisible(False)
75+
layout.addWidget(self._progress_bar)
76+
77+
# Re-emit FrameDisplay's signals so the panel only needs to
78+
# listen to the window — removes the need for the panel to
79+
# know about the inner widget at all.
80+
self._display.mouse_moved.connect(self.mouse_moved)
81+
self._display.mouse_pressed.connect(self.mouse_pressed)
82+
self._display.mouse_released.connect(self.mouse_released)
83+
self._display.mouse_scrolled.connect(self.mouse_scrolled)
84+
self._display.key_pressed.connect(self.key_pressed)
85+
self._display.key_released.connect(self.key_released)
86+
self._display.type_text.connect(self.type_text)
87+
self._display.files_dropped.connect(self.files_dropped)
88+
self._display.annotation_event.connect(self.annotation_event)
89+
90+
# --- panel-facing API ------------------------------------------------
91+
92+
def set_image(self, image: Optional[QImage]) -> None:
93+
if image is None or image.isNull():
94+
self._display.clear()
95+
else:
96+
self._display.set_image(image)
97+
98+
def clear(self) -> None:
99+
self._display.clear()
100+
101+
def set_pen_mode(self, value: bool) -> None:
102+
self._display.set_pen_mode(value)
103+
104+
def set_progress(self, label: str, done: int, total: int) -> None:
105+
self._progress_label.setVisible(True)
106+
self._progress_label.setText(label)
107+
self._progress_bar.setVisible(True)
108+
if total > 0:
109+
self._progress_bar.setRange(0, total)
110+
self._progress_bar.setValue(min(done, total))
111+
else:
112+
self._progress_bar.setRange(0, 0)
113+
114+
def show_progress_text(self, label: str) -> None:
115+
self._progress_label.setVisible(bool(label))
116+
self._progress_label.setText(label)
117+
self._progress_bar.setVisible(False)
118+
119+
def hide_progress(self) -> None:
120+
self._progress_label.setVisible(False)
121+
self._progress_bar.setVisible(False)
122+
123+
@property
124+
def display(self) -> _FrameDisplay:
125+
"""Direct access for callers that need the underlying widget."""
126+
return self._display
127+
128+
# --- close handling --------------------------------------------------
129+
130+
def closeEvent(self, event) -> None: # noqa: N802 Qt override
131+
self.closed.emit()
132+
super().closeEvent(event)
133+
134+
135+
def make_remote_screen_window(parent=None) -> RemoteScreenWindow:
136+
"""Factory that picks a sensible default title from the i18n table."""
137+
return RemoteScreenWindow(_t("rd_remote_screen_title"), parent=parent)
138+
139+
140+
__all__ = ["RemoteScreenWindow", "make_remote_screen_window"]

je_auto_control/gui/remote_desktop/viewer_panel.py

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
_CollapsibleSection, _StatusBadge, _build_insecure_client_context,
1717
_build_verifying_client_context, _t,
1818
)
19-
from je_auto_control.gui.remote_desktop.frame_display import _FrameDisplay
19+
from je_auto_control.gui.remote_desktop.remote_screen_window import (
20+
RemoteScreenWindow,
21+
)
2022
from je_auto_control.utils.remote_desktop import (
2123
FileReceiver, RemoteDesktopViewer, WebSocketDesktopViewer,
2224
)
@@ -66,7 +68,11 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
6668
self._badge = _StatusBadge()
6769
self._status = QLabel()
6870
self._status.setStyleSheet("color: #555; font-size: 9pt;")
69-
self._display = _FrameDisplay()
71+
# _screen_window is created lazily on connect — the AnyDesk-style
72+
# popout. While disconnected we hold ``None`` and the panel
73+
# itself stays compact instead of devoting half its height to a
74+
# blank frame area.
75+
self._screen_window: Optional[RemoteScreenWindow] = None
7076
self._connect_btn: Optional[QPushButton] = None
7177
self._disconnect_btn: Optional[QPushButton] = None
7278
self._action_row: Optional[QWidget] = None
@@ -92,11 +98,27 @@ def _apply_placeholders(self) -> None:
9298

9399
def _build_layout(self) -> None:
94100
root = QVBoxLayout(self)
101+
root.setContentsMargins(12, 12, 12, 12)
102+
root.setSpacing(8)
103+
104+
root.addWidget(self._build_card())
105+
root.addWidget(self._build_advanced())
106+
root.addLayout(self._build_button_row())
107+
root.addWidget(self._build_action_row())
95108

96-
# === Connection card ===
109+
# The remote desktop opens in its own popup window on connect
110+
# (AnyDesk-style), so the panel itself only carries control
111+
# surface, status, and transfer progress.
112+
root.addWidget(self._progress_label)
113+
root.addWidget(self._progress_bar)
114+
root.addWidget(self._status)
115+
root.addStretch(1)
116+
117+
def _build_card(self) -> QGroupBox:
97118
card = self._tr(QGroupBox(), "rd_viewer_card_group")
98119
card.setStyleSheet("QGroupBox { font-weight: bold; }")
99120
card_layout = QVBoxLayout()
121+
card_layout.setSpacing(6)
100122

101123
id_row = QHBoxLayout()
102124
id_row.addWidget(self._tr(QLabel(), "rd_host_id_label"))
@@ -119,19 +141,19 @@ def _build_layout(self) -> None:
119141
card_layout.addLayout(token_row)
120142

121143
card.setLayout(card_layout)
122-
root.addWidget(card)
144+
return card
123145

124-
# === Advanced (collapsible) ===
146+
def _build_advanced(self) -> _CollapsibleSection:
125147
advanced = _CollapsibleSection()
126148
self._tr(advanced, "rd_advanced_group", setter="setTitle")
127149
adv_layout = QVBoxLayout()
128150
adv_layout.addWidget(self._tr(self._tls_insecure, "rd_tls_insecure"))
129151
adv_layout.addWidget(self._tr(self._enable_audio,
130152
"rd_viewer_audio_play"))
131153
advanced.set_body_layout(adv_layout)
132-
root.addWidget(advanced)
154+
return advanced
133155

134-
# === Connect / Disconnect ===
156+
def _build_button_row(self) -> QHBoxLayout:
135157
btn_row = QHBoxLayout()
136158
self._connect_btn = self._tr(QPushButton(), "rd_viewer_connect")
137159
self._connect_btn.setMinimumHeight(36)
@@ -142,9 +164,9 @@ def _build_layout(self) -> None:
142164
self._disconnect_btn.clicked.connect(self._disconnect)
143165
btn_row.addWidget(self._connect_btn, stretch=2)
144166
btn_row.addWidget(self._disconnect_btn, stretch=1)
145-
root.addLayout(btn_row)
167+
return btn_row
146168

147-
# === Live actions (only visible while connected) ===
169+
def _build_action_row(self) -> QWidget:
148170
action_row_widget = QWidget()
149171
action_row = QHBoxLayout(action_row_widget)
150172
action_row.setContentsMargins(0, 0, 0, 0)
@@ -157,35 +179,20 @@ def _build_layout(self) -> None:
157179
action_row.addStretch()
158180
action_row_widget.setVisible(False)
159181
self._action_row = action_row_widget
160-
root.addWidget(action_row_widget)
161-
162-
# === Frame display + progress ===
163-
root.addWidget(self._display, stretch=1)
164-
root.addWidget(self._progress_label)
165-
root.addWidget(self._progress_bar)
166-
root.addWidget(self._status)
182+
return action_row_widget
167183

168184
def _wire_signals(self) -> None:
185+
# Cross-thread frame / event marshalling — the Signals fire on
186+
# the network thread, the slots run on the GUI thread.
169187
self._frame_signal.connect(self._on_frame_main)
170188
self._error_signal.connect(self._on_error_main)
171189
self._audio_signal.connect(self._on_audio_main)
172190
self._clipboard_signal.connect(self._on_clipboard_main)
173191
self._file_progress_signal.connect(self._on_file_progress_main)
174192
self._file_complete_signal.connect(self._on_file_complete_main)
175-
self._display.mouse_moved.connect(self._send_mouse_move)
176-
self._display.mouse_pressed.connect(self._send_mouse_press)
177-
self._display.mouse_released.connect(self._send_mouse_release)
178-
self._display.mouse_scrolled.connect(self._send_mouse_scroll)
179-
self._display.key_pressed.connect(
180-
lambda k: self._send({"action": "key_press", "keycode": k})
181-
)
182-
self._display.key_released.connect(
183-
lambda k: self._send({"action": "key_release", "keycode": k})
184-
)
185-
self._display.type_text.connect(
186-
lambda text: self._send({"action": "type", "text": text})
187-
)
188-
self._display.files_dropped.connect(self._on_files_dropped)
193+
# Input-forwarding signals come from the popup window (see
194+
# _ensure_screen_window). They aren't wired here because the
195+
# window is created lazily on connect.
189196

190197
# --- connection lifecycle ------------------------------------------
191198

@@ -238,6 +245,13 @@ def _connect(self) -> None:
238245
registry._viewer = viewer # noqa: SLF001 centralised lifecycle ownership
239246
self._connected = True
240247
self._start_audio_player_if_requested()
248+
# AnyDesk-style: open the live screen in its own window so the
249+
# operator gets a real workspace and the control panel stays
250+
# uncluttered.
251+
window = self._ensure_screen_window()
252+
window.show()
253+
window.raise_()
254+
window.activateWindow()
241255
self._refresh_status()
242256

243257
def _parse_host_id_input(self) -> Optional[str]:
@@ -279,12 +293,61 @@ def _disconnect(self) -> None:
279293
registry.disconnect_viewer()
280294
self._stop_audio_player()
281295
self._connected = False
282-
self._display.clear()
296+
self._close_screen_window()
283297
self._progress_bar.setVisible(False)
284298
self._progress_label.setText("")
285299
self._active_progress_id = None
286300
self._refresh_status()
287301

302+
# --- pop-out screen window ----------------------------------------
303+
304+
def _ensure_screen_window(self) -> RemoteScreenWindow:
305+
"""Create-on-demand the AnyDesk-style remote-desktop window."""
306+
if self._screen_window is not None:
307+
return self._screen_window
308+
host_id = self._host_id.text().strip()
309+
title = (
310+
_t("rd_remote_screen_title_with_id").replace("{host_id}", host_id)
311+
if host_id else _t("rd_remote_screen_title")
312+
)
313+
window = RemoteScreenWindow(title, parent=self)
314+
window.mouse_moved.connect(self._send_mouse_move)
315+
window.mouse_pressed.connect(self._send_mouse_press)
316+
window.mouse_released.connect(self._send_mouse_release)
317+
window.mouse_scrolled.connect(self._send_mouse_scroll)
318+
window.key_pressed.connect(
319+
lambda k: self._send({"action": "key_press", "keycode": k})
320+
)
321+
window.key_released.connect(
322+
lambda k: self._send({"action": "key_release", "keycode": k})
323+
)
324+
window.type_text.connect(
325+
lambda text: self._send({"action": "type", "text": text})
326+
)
327+
window.files_dropped.connect(self._on_files_dropped)
328+
# If the operator closes the popup, mirror the action by
329+
# disconnecting — same behaviour AnyDesk has when you ✕ the
330+
# session window.
331+
window.closed.connect(self._on_screen_window_closed)
332+
self._screen_window = window
333+
return window
334+
335+
def _close_screen_window(self) -> None:
336+
window = self._screen_window
337+
self._screen_window = None
338+
if window is not None:
339+
try:
340+
window.closed.disconnect(self._on_screen_window_closed)
341+
except (RuntimeError, TypeError):
342+
pass
343+
window.hide()
344+
window.deleteLater()
345+
346+
def _on_screen_window_closed(self) -> None:
347+
# Operator dismissed the popup → fall through to disconnect.
348+
if self._connected:
349+
self._disconnect()
350+
288351
def _refresh_status(self) -> None:
289352
live = self._connected and registry.viewer_status()["connected"]
290353
if live:
@@ -298,9 +361,9 @@ def _refresh_status(self) -> None:
298361

299362
def _on_frame_main(self, payload: bytes) -> None:
300363
image = QImage.fromData(payload, "JPEG")
301-
if image.isNull():
364+
if image.isNull() or self._screen_window is None:
302365
return
303-
self._display.set_image(image)
366+
self._screen_window.set_image(image)
304367

305368
def _on_error_main(self, message: str) -> None:
306369
self._connected = False

0 commit comments

Comments
 (0)