voidremote 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. voidremote/__init__.py +31 -0
  2. voidremote/adb/__init__.py +28 -0
  3. voidremote/adb/client.py +385 -0
  4. voidremote/adb/device_parser.py +286 -0
  5. voidremote/cli/__init__.py +5 -0
  6. voidremote/cli/main.py +987 -0
  7. voidremote/config/__init__.py +37 -0
  8. voidremote/config/settings.py +203 -0
  9. voidremote/controllers/__init__.py +5 -0
  10. voidremote/controllers/app_controller.py +222 -0
  11. voidremote/core/__init__.py +1 -0
  12. voidremote/core/automation.py +184 -0
  13. voidremote/models/__init__.py +31 -0
  14. voidremote/models/device.py +263 -0
  15. voidremote/network/__init__.py +1 -0
  16. voidremote/network/discovery.py +116 -0
  17. voidremote/services/__init__.py +13 -0
  18. voidremote/services/device_service.py +340 -0
  19. voidremote/services/input_service.py +181 -0
  20. voidremote/services/monitor_service.py +198 -0
  21. voidremote/ui/__init__.py +1 -0
  22. voidremote/ui/app.py +70 -0
  23. voidremote/ui/dialogs/__init__.py +6 -0
  24. voidremote/ui/dialogs/connect_dialog.py +149 -0
  25. voidremote/ui/dialogs/pair_dialog.py +189 -0
  26. voidremote/ui/main_window.py +371 -0
  27. voidremote/ui/theme.py +529 -0
  28. voidremote/ui/views/__init__.py +15 -0
  29. voidremote/ui/views/dashboard_view.py +233 -0
  30. voidremote/ui/views/files_view.py +305 -0
  31. voidremote/ui/views/monitor_view.py +236 -0
  32. voidremote/ui/views/settings_view.py +257 -0
  33. voidremote/ui/views/shell_view.py +234 -0
  34. voidremote/ui/widgets/__init__.py +14 -0
  35. voidremote/ui/widgets/device_card.py +259 -0
  36. voidremote/ui/widgets/log_view.py +159 -0
  37. voidremote/ui/widgets/metric_gauge.py +90 -0
  38. voidremote/utils/__init__.py +28 -0
  39. voidremote/utils/logging.py +120 -0
  40. voidremote/utils/security.py +216 -0
  41. voidremote-1.0.0.dist-info/METADATA +578 -0
  42. voidremote-1.0.0.dist-info/RECORD +46 -0
  43. voidremote-1.0.0.dist-info/WHEEL +5 -0
  44. voidremote-1.0.0.dist-info/entry_points.txt +5 -0
  45. voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
  46. voidremote-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,234 @@
1
+ """Embedded ADB shell terminal view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from PySide6.QtCore import Qt, QThread, Signal, Slot
9
+ from PySide6.QtGui import QFont, QKeyEvent, QTextCharFormat, QColor, QTextCursor
10
+ from PySide6.QtWidgets import (
11
+ QComboBox,
12
+ QHBoxLayout,
13
+ QLabel,
14
+ QLineEdit,
15
+ QPlainTextEdit,
16
+ QPushButton,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+ from voidremote.ui.theme import Colors
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ShellWorker(QThread):
27
+ output = Signal(str, bool) # text, is_error
28
+
29
+ def __init__(self, controller: object, serial: str, command: str, parent: Optional[object] = None) -> None:
30
+ super().__init__(parent)
31
+ self._controller = controller
32
+ self._serial = serial
33
+ self._command = command
34
+
35
+ def run(self) -> None:
36
+ try:
37
+ result = self._controller.shell(self._serial, self._command)
38
+ self.output.emit(result, False)
39
+ except Exception as exc:
40
+ self.output.emit(str(exc), True)
41
+
42
+
43
+ class HistoryLineEdit(QLineEdit):
44
+ """Line edit with command history navigation."""
45
+
46
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
47
+ super().__init__(parent)
48
+ self._history: list[str] = []
49
+ self._history_idx = -1
50
+
51
+ def add_to_history(self, cmd: str) -> None:
52
+ if cmd and (not self._history or self._history[-1] != cmd):
53
+ self._history.append(cmd)
54
+ self._history_idx = -1
55
+
56
+ def keyPressEvent(self, event: QKeyEvent) -> None:
57
+ if event.key() == Qt.Key.Key_Up:
58
+ if self._history:
59
+ self._history_idx = min(self._history_idx + 1, len(self._history) - 1)
60
+ self.setText(self._history[-(self._history_idx + 1)])
61
+ return
62
+ if event.key() == Qt.Key.Key_Down:
63
+ if self._history_idx > 0:
64
+ self._history_idx -= 1
65
+ self.setText(self._history[-(self._history_idx + 1)])
66
+ elif self._history_idx == 0:
67
+ self._history_idx = -1
68
+ self.clear()
69
+ return
70
+ super().keyPressEvent(event)
71
+
72
+
73
+ class ShellView(QWidget):
74
+ """
75
+ ADB shell terminal emulator embedded in the GUI.
76
+
77
+ Features:
78
+ - Device selector dropdown
79
+ - Command history (up/down arrows)
80
+ - Colored output (errors in red)
81
+ - Clear and copy buttons
82
+ """
83
+
84
+ def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
85
+ super().__init__(parent)
86
+ self._controller = controller
87
+ self._serial: Optional[str] = None
88
+ self._worker: Optional[ShellWorker] = None
89
+ self._build_ui()
90
+
91
+ def _build_ui(self) -> None:
92
+ root = QVBoxLayout(self)
93
+ root.setContentsMargins(0, 0, 0, 0)
94
+ root.setSpacing(0)
95
+
96
+ # ── Header ──────────────────────────────────────
97
+ header = QHBoxLayout()
98
+ header.setContentsMargins(24, 16, 24, 12)
99
+ header.setSpacing(12)
100
+
101
+ title = QLabel("Shell")
102
+ title.setStyleSheet(f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};")
103
+ header.addWidget(title)
104
+
105
+ header.addStretch()
106
+
107
+ header.addWidget(QLabel("Device:"))
108
+ self._device_combo = QComboBox()
109
+ self._device_combo.setMinimumWidth(200)
110
+ self._device_combo.currentTextChanged.connect(self._on_device_changed)
111
+ header.addWidget(self._device_combo)
112
+
113
+ btn_clear = QPushButton("Clear")
114
+ btn_clear.setFixedWidth(60)
115
+ btn_clear.clicked.connect(self._output.clear if hasattr(self, "_output") else lambda: None)
116
+ header.addWidget(btn_clear)
117
+
118
+ header_w = QWidget()
119
+ header_w.setStyleSheet(
120
+ f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
121
+ )
122
+ header_w.setLayout(header)
123
+ root.addWidget(header_w)
124
+
125
+ # ── Terminal output ──────────────────────────────
126
+ self._output = QPlainTextEdit()
127
+ self._output.setReadOnly(True)
128
+ self._output.setObjectName("LogView")
129
+ mono = QFont("JetBrains Mono, Cascadia Code, Consolas, monospace", 13)
130
+ self._output.setFont(mono)
131
+ self._output.setStyleSheet(
132
+ f"background: {Colors.BG_BASE}; border: none; padding: 16px;"
133
+ )
134
+ root.addWidget(self._output, stretch=1)
135
+
136
+ # Fix clear button now that _output exists
137
+ btn_clear.clicked.disconnect()
138
+ btn_clear.clicked.connect(self._output.clear)
139
+
140
+ # ── Input bar ────────────────────────────────────
141
+ input_bar = QHBoxLayout()
142
+ input_bar.setContentsMargins(16, 10, 16, 12)
143
+ input_bar.setSpacing(8)
144
+
145
+ prompt = QLabel("$")
146
+ prompt.setStyleSheet(
147
+ f"color: {Colors.ACCENT}; font-family: monospace; font-size: 15px; font-weight: 700;"
148
+ )
149
+ input_bar.addWidget(prompt)
150
+
151
+ self._cmd_edit = HistoryLineEdit()
152
+ self._cmd_edit.setPlaceholderText("Enter shell command…")
153
+ self._cmd_edit.setFont(mono)
154
+ self._cmd_edit.returnPressed.connect(self._run_command)
155
+ input_bar.addWidget(self._cmd_edit, stretch=1)
156
+
157
+ self._run_btn = QPushButton("Run")
158
+ self._run_btn.setObjectName("accent")
159
+ self._run_btn.setFixedWidth(60)
160
+ self._run_btn.clicked.connect(self._run_command)
161
+ input_bar.addWidget(self._run_btn)
162
+
163
+ input_bar_w = QWidget()
164
+ input_bar_w.setStyleSheet(
165
+ f"background: {Colors.BG_SURFACE}; border-top: 1px solid {Colors.BORDER};"
166
+ )
167
+ input_bar_w.setLayout(input_bar)
168
+ root.addWidget(input_bar_w)
169
+
170
+ self._print_welcome()
171
+
172
+ def _print_welcome(self) -> None:
173
+ self._append(
174
+ "VoidRemote Shell — ADB shell emulator\n"
175
+ "Select a device above, then type a command and press Enter.\n"
176
+ "Use ↑ / ↓ to navigate command history.\n",
177
+ error=False,
178
+ )
179
+
180
+ def set_devices(self, devices: list) -> None:
181
+ current = self._device_combo.currentText()
182
+ self._device_combo.blockSignals(True)
183
+ self._device_combo.clear()
184
+ for d in devices:
185
+ self._device_combo.addItem(f"{d.info.display_name} ({d.serial})", userData=d.serial)
186
+ idx = self._device_combo.findData(current)
187
+ if idx >= 0:
188
+ self._device_combo.setCurrentIndex(idx)
189
+ elif self._device_combo.count() > 0:
190
+ self._serial = self._device_combo.currentData()
191
+ self._device_combo.blockSignals(False)
192
+
193
+ def _on_device_changed(self) -> None:
194
+ self._serial = self._device_combo.currentData()
195
+
196
+ def _run_command(self) -> None:
197
+ cmd = self._cmd_edit.text().strip()
198
+ if not cmd:
199
+ return
200
+ if not self._serial:
201
+ self._append("No device selected.\n", error=True)
202
+ return
203
+ if self._worker and self._worker.isRunning():
204
+ self._append("Previous command still running…\n", error=True)
205
+ return
206
+
207
+ self._cmd_edit.add_to_history(cmd)
208
+ self._cmd_edit.clear()
209
+ self._run_btn.setEnabled(False)
210
+ self._append(f"$ {cmd}\n", error=False, dim=True)
211
+
212
+ self._worker = ShellWorker(self._controller, self._serial, cmd, self)
213
+ self._worker.output.connect(self._on_output)
214
+ self._worker.finished.connect(lambda: self._run_btn.setEnabled(True))
215
+ self._worker.start()
216
+
217
+ @Slot(str, bool)
218
+ def _on_output(self, text: str, is_error: bool) -> None:
219
+ self._append(text + "\n", error=is_error)
220
+
221
+ def _append(self, text: str, error: bool = False, dim: bool = False) -> None:
222
+ fmt = QTextCharFormat()
223
+ if error:
224
+ fmt.setForeground(QColor(Colors.ERROR))
225
+ elif dim:
226
+ fmt.setForeground(QColor(Colors.TEXT_MUTED))
227
+ else:
228
+ fmt.setForeground(QColor(Colors.TEXT_PRIMARY))
229
+
230
+ cursor = self._output.textCursor()
231
+ cursor.movePosition(QTextCursor.MoveOperation.End)
232
+ cursor.insertText(text, fmt)
233
+ self._output.setTextCursor(cursor)
234
+ self._output.ensureCursorVisible()
@@ -0,0 +1,14 @@
1
+ """VoidRemote UI widgets."""
2
+
3
+ from voidremote.ui.widgets.device_card import DeviceCard, StatusDot, BatteryBar
4
+ from voidremote.ui.widgets.log_view import LogView, QtLogHandler
5
+ from voidremote.ui.widgets.metric_gauge import MetricGauge
6
+
7
+ __all__ = [
8
+ "BatteryBar",
9
+ "DeviceCard",
10
+ "LogView",
11
+ "MetricGauge",
12
+ "QtLogHandler",
13
+ "StatusDot",
14
+ ]
@@ -0,0 +1,259 @@
1
+ """Device card widget for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from PySide6.QtCore import Qt, Signal, QPropertyAnimation, QEasingCurve, Property
8
+ from PySide6.QtGui import QColor, QPainter, QPen, QBrush, QFont
9
+ from PySide6.QtWidgets import (
10
+ QFrame,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QProgressBar,
14
+ QPushButton,
15
+ QVBoxLayout,
16
+ QWidget,
17
+ )
18
+
19
+ from voidremote.models.device import Device, DeviceState
20
+ from voidremote.ui.theme import Colors
21
+
22
+
23
+ class BatteryBar(QWidget):
24
+ """Compact battery level indicator."""
25
+
26
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
27
+ super().__init__(parent)
28
+ self._level = 0
29
+ self._charging = False
30
+ self.setFixedSize(28, 14)
31
+
32
+ def set_level(self, level: int, charging: bool = False) -> None:
33
+ self._level = max(0, min(100, level))
34
+ self._charging = charging
35
+ self.update()
36
+
37
+ def paintEvent(self, event: object) -> None: # type: ignore[override]
38
+ painter = QPainter(self)
39
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
40
+
41
+ # Outer shell
42
+ pen = QPen(QColor(Colors.BORDER))
43
+ pen.setWidth(1)
44
+ painter.setPen(pen)
45
+ painter.setBrush(QBrush(QColor(Colors.BG_ELEVATED)))
46
+ painter.drawRoundedRect(0, 0, 24, 14, 3, 3)
47
+
48
+ # Tip
49
+ painter.setBrush(QBrush(QColor(Colors.BORDER)))
50
+ painter.setPen(Qt.PenStyle.NoPen)
51
+ painter.drawRoundedRect(24, 4, 3, 6, 1, 1)
52
+
53
+ # Fill
54
+ if self._level > 50:
55
+ color = QColor(Colors.BATTERY_FULL)
56
+ elif self._level > 20:
57
+ color = QColor(Colors.BATTERY_MID)
58
+ else:
59
+ color = QColor(Colors.BATTERY_LOW)
60
+
61
+ fill_width = int((22 * self._level) / 100)
62
+ if fill_width > 0:
63
+ painter.setBrush(QBrush(color))
64
+ painter.drawRoundedRect(1, 1, fill_width, 12, 2, 2)
65
+
66
+
67
+ class StatusDot(QWidget):
68
+ """Animated status indicator dot."""
69
+
70
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
71
+ super().__init__(parent)
72
+ self._color = QColor(Colors.TEXT_MUTED)
73
+ self.setFixedSize(10, 10)
74
+
75
+ def set_state(self, state: DeviceState) -> None:
76
+ color_map = {
77
+ DeviceState.ONLINE: Colors.SUCCESS,
78
+ DeviceState.OFFLINE: Colors.ERROR,
79
+ DeviceState.UNAUTHORIZED: Colors.WARNING,
80
+ DeviceState.CONNECTING: Colors.INFO,
81
+ }
82
+ self._color = QColor(color_map.get(state, Colors.TEXT_MUTED))
83
+ self.update()
84
+
85
+ def paintEvent(self, event: object) -> None: # type: ignore[override]
86
+ painter = QPainter(self)
87
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
88
+ painter.setPen(Qt.PenStyle.NoPen)
89
+ painter.setBrush(QBrush(self._color))
90
+ painter.drawEllipse(1, 1, 8, 8)
91
+
92
+
93
+ class DeviceCard(QFrame):
94
+ """
95
+ Device card widget shown in the devices dashboard.
96
+
97
+ Emits:
98
+ connect_requested(serial): User clicked Connect.
99
+ disconnect_requested(serial): User clicked Disconnect.
100
+ mirror_requested(serial): User clicked Mirror.
101
+ info_requested(serial): User clicked Info / device name.
102
+ """
103
+
104
+ connect_requested: Signal = Signal(str)
105
+ disconnect_requested: Signal = Signal(str)
106
+ mirror_requested: Signal = Signal(str)
107
+ info_requested: Signal = Signal(str)
108
+
109
+ def __init__(self, device: Device, parent: Optional[QWidget] = None) -> None:
110
+ super().__init__(parent)
111
+ self._device = device
112
+ self.setObjectName("DeviceCard")
113
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
114
+ self._build_ui()
115
+ self.refresh(device)
116
+
117
+ def _build_ui(self) -> None:
118
+ self.setFrameShape(QFrame.Shape.StyledPanel)
119
+ self.setMinimumHeight(140)
120
+
121
+ root = QVBoxLayout(self)
122
+ root.setContentsMargins(16, 14, 16, 14)
123
+ root.setSpacing(10)
124
+
125
+ # ── Header ──────────────────────────────────────
126
+ header = QHBoxLayout()
127
+ header.setSpacing(8)
128
+
129
+ self._status_dot = StatusDot()
130
+ header.addWidget(self._status_dot)
131
+
132
+ self._name_label = QLabel()
133
+ self._name_label.setStyleSheet(
134
+ f"font-size: 15px; font-weight: 700; color: {Colors.TEXT_PRIMARY};"
135
+ )
136
+ header.addWidget(self._name_label, stretch=1)
137
+
138
+ self._battery_bar = BatteryBar()
139
+ header.addWidget(self._battery_bar)
140
+
141
+ self._battery_label = QLabel()
142
+ self._battery_label.setStyleSheet(
143
+ f"font-size: 11px; color: {Colors.TEXT_SECONDARY}; min-width: 32px;"
144
+ )
145
+ header.addWidget(self._battery_label)
146
+
147
+ root.addLayout(header)
148
+
149
+ # ── Meta row ────────────────────────────────────
150
+ meta = QHBoxLayout()
151
+ meta.setSpacing(16)
152
+
153
+ self._serial_label = QLabel()
154
+ self._serial_label.setStyleSheet(
155
+ f"font-size: 11px; color: {Colors.TEXT_MUTED}; font-family: monospace;"
156
+ )
157
+ meta.addWidget(self._serial_label, stretch=1)
158
+
159
+ self._android_label = QLabel()
160
+ self._android_label.setStyleSheet(
161
+ f"font-size: 11px; color: {Colors.TEXT_SECONDARY};"
162
+ )
163
+ meta.addWidget(self._android_label)
164
+
165
+ root.addLayout(meta)
166
+
167
+ # ── Info row ────────────────────────────────────
168
+ info = QHBoxLayout()
169
+ info.setSpacing(20)
170
+
171
+ self._res_label = self._make_info_label("Resolution", "–")
172
+ self._ip_label = self._make_info_label("IP", "–")
173
+ self._type_label = self._make_info_label("Type", "–")
174
+
175
+ for lbl in (self._res_label, self._ip_label, self._type_label):
176
+ info.addWidget(lbl)
177
+ info.addStretch()
178
+
179
+ root.addLayout(info)
180
+ root.addStretch()
181
+
182
+ # ── Actions ─────────────────────────────────────
183
+ actions = QHBoxLayout()
184
+ actions.setSpacing(8)
185
+ actions.addStretch()
186
+
187
+ self._btn_info = QPushButton("Info")
188
+ self._btn_info.setFixedWidth(60)
189
+ self._btn_info.clicked.connect(lambda: self.info_requested.emit(self._device.serial))
190
+ actions.addWidget(self._btn_info)
191
+
192
+ self._btn_mirror = QPushButton("Mirror")
193
+ self._btn_mirror.setFixedWidth(66)
194
+ self._btn_mirror.clicked.connect(lambda: self.mirror_requested.emit(self._device.serial))
195
+ actions.addWidget(self._btn_mirror)
196
+
197
+ self._btn_connect = QPushButton("Connect")
198
+ self._btn_connect.setObjectName("accent")
199
+ self._btn_connect.setFixedWidth(80)
200
+ self._btn_connect.clicked.connect(self._on_connect_toggle)
201
+ actions.addWidget(self._btn_connect)
202
+
203
+ root.addLayout(actions)
204
+
205
+ def _make_info_label(self, title: str, value: str) -> QLabel:
206
+ label = QLabel(f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>{title}</span>"
207
+ f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{value}</span>")
208
+ label.setTextFormat(Qt.TextFormat.RichText)
209
+ return label
210
+
211
+ def _on_connect_toggle(self) -> None:
212
+ if self._device.is_connected:
213
+ self.disconnect_requested.emit(self._device.serial)
214
+ else:
215
+ self.connect_requested.emit(self._device.serial)
216
+
217
+ def refresh(self, device: Device) -> None:
218
+ """Update card contents from a new Device snapshot."""
219
+ self._device = device
220
+ info = device.info
221
+
222
+ self._status_dot.set_state(device.state)
223
+ self._name_label.setText(device.display_name)
224
+ self._serial_label.setText(device.serial)
225
+ self._android_label.setText(f"Android {info.android_version}")
226
+
227
+ batt = info.battery.level
228
+ self._battery_bar.set_level(batt, info.battery.is_charging)
229
+ batt_color = (
230
+ Colors.BATTERY_FULL if batt > 50
231
+ else Colors.BATTERY_MID if batt > 20
232
+ else Colors.BATTERY_LOW
233
+ )
234
+ self._battery_label.setText(f"<span style='color:{batt_color}'>{batt}%</span>")
235
+ self._battery_label.setTextFormat(Qt.TextFormat.RichText)
236
+
237
+ self._res_label.setText(
238
+ f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>Resolution</span>"
239
+ f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{info.screen_resolution}</span>"
240
+ )
241
+ self._ip_label.setText(
242
+ f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>IP</span>"
243
+ f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{info.network.ip_address or 'N/A'}</span>"
244
+ )
245
+ self._type_label.setText(
246
+ f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>Type</span>"
247
+ f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{device.connection_type.value}</span>"
248
+ )
249
+
250
+ if device.is_connected:
251
+ self._btn_connect.setText("Disconnect")
252
+ self._btn_connect.setObjectName("danger")
253
+ else:
254
+ self._btn_connect.setText("Connect")
255
+ self._btn_connect.setObjectName("accent")
256
+
257
+ # Re-polish to pick up objectName style changes
258
+ self._btn_connect.style().unpolish(self._btn_connect)
259
+ self._btn_connect.style().polish(self._btn_connect)
@@ -0,0 +1,159 @@
1
+ """Live scrolling log viewer widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from PySide6.QtCore import Qt, QObject, Signal, Slot
9
+ from PySide6.QtGui import QTextCharFormat, QColor, QFont, QTextCursor
10
+ from PySide6.QtWidgets import (
11
+ QHBoxLayout,
12
+ QPlainTextEdit,
13
+ QPushButton,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ QComboBox,
17
+ QLabel,
18
+ )
19
+
20
+ from voidremote.ui.theme import Colors
21
+
22
+
23
+ class QtLogHandler(logging.Handler, QObject):
24
+ """
25
+ Logging handler that emits Qt signals so log records can be
26
+ displayed safely in the GUI from any thread.
27
+ """
28
+
29
+ log_record = Signal(int, str)
30
+
31
+ def __init__(self) -> None:
32
+ logging.Handler.__init__(self)
33
+ QObject.__init__(self)
34
+
35
+ def emit(self, record: logging.LogRecord) -> None:
36
+ try:
37
+ msg = self.format(record)
38
+ self.log_record.emit(record.levelno, msg)
39
+ except Exception:
40
+ self.handleError(record)
41
+
42
+
43
+ class LogView(QWidget):
44
+ """
45
+ Embeddable log viewer with level filtering, search, and auto-scroll.
46
+ """
47
+
48
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
49
+ super().__init__(parent)
50
+ self._handler: Optional[QtLogHandler] = None
51
+ self._max_lines = 2000
52
+ self._level_filter = logging.DEBUG
53
+ self._build_ui()
54
+ self._install_handler()
55
+
56
+ def _build_ui(self) -> None:
57
+ layout = QVBoxLayout(self)
58
+ layout.setContentsMargins(0, 0, 0, 0)
59
+ layout.setSpacing(0)
60
+
61
+ # ── Toolbar ─────────────────────────────────────
62
+ toolbar = QHBoxLayout()
63
+ toolbar.setContentsMargins(8, 6, 8, 6)
64
+ toolbar.setSpacing(8)
65
+
66
+ toolbar.addWidget(QLabel("Level:"))
67
+
68
+ self._level_combo = QComboBox()
69
+ self._level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR"])
70
+ self._level_combo.setCurrentText("INFO")
71
+ self._level_combo.setFixedWidth(100)
72
+ self._level_combo.currentTextChanged.connect(self._on_level_changed)
73
+ toolbar.addWidget(self._level_combo)
74
+
75
+ toolbar.addStretch()
76
+
77
+ self._auto_scroll_btn = QPushButton("Auto-scroll: ON")
78
+ self._auto_scroll_btn.setCheckable(True)
79
+ self._auto_scroll_btn.setChecked(True)
80
+ self._auto_scroll_btn.setFixedWidth(120)
81
+ self._auto_scroll_btn.clicked.connect(self._toggle_autoscroll)
82
+ toolbar.addWidget(self._auto_scroll_btn)
83
+
84
+ btn_clear = QPushButton("Clear")
85
+ btn_clear.setFixedWidth(60)
86
+ btn_clear.clicked.connect(self.clear)
87
+ toolbar.addWidget(btn_clear)
88
+
89
+ toolbar_widget = QWidget()
90
+ toolbar_widget.setStyleSheet(
91
+ f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
92
+ )
93
+ toolbar_widget.setLayout(toolbar)
94
+ layout.addWidget(toolbar_widget)
95
+
96
+ # ── Text area ───────────────────────────────────
97
+ self._text = QPlainTextEdit()
98
+ self._text.setObjectName("LogView")
99
+ self._text.setReadOnly(True)
100
+ self._text.setMaximumBlockCount(self._max_lines)
101
+ font = QFont("JetBrains Mono, Cascadia Code, Fira Code, Consolas, monospace", 12)
102
+ self._text.setFont(font)
103
+ layout.addWidget(self._text, stretch=1)
104
+
105
+ self._auto_scroll = True
106
+
107
+ def _install_handler(self) -> None:
108
+ handler = QtLogHandler()
109
+ handler.setFormatter(
110
+ logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s",
111
+ datefmt="%H:%M:%S")
112
+ )
113
+ handler.log_record.connect(self._append_record)
114
+ self._handler = handler
115
+ logging.getLogger().addHandler(handler)
116
+
117
+ def remove_handler(self) -> None:
118
+ if self._handler:
119
+ logging.getLogger().removeHandler(self._handler)
120
+
121
+ @Slot(int, str)
122
+ def _append_record(self, level: int, message: str) -> None:
123
+ if level < self._level_filter:
124
+ return
125
+
126
+ fmt = QTextCharFormat()
127
+ if level >= logging.CRITICAL:
128
+ fmt.setForeground(QColor(Colors.ERROR))
129
+ fmt.setFontWeight(QFont.Weight.Bold)
130
+ elif level >= logging.ERROR:
131
+ fmt.setForeground(QColor(Colors.ERROR))
132
+ elif level >= logging.WARNING:
133
+ fmt.setForeground(QColor(Colors.WARNING))
134
+ elif level >= logging.INFO:
135
+ fmt.setForeground(QColor(Colors.TEXT_PRIMARY))
136
+ else:
137
+ fmt.setForeground(QColor(Colors.TEXT_MUTED))
138
+
139
+ cursor = self._text.textCursor()
140
+ cursor.movePosition(QTextCursor.MoveOperation.End)
141
+ cursor.insertText(message + "\n", fmt)
142
+
143
+ if self._auto_scroll:
144
+ self._text.setTextCursor(cursor)
145
+ self._text.ensureCursorVisible()
146
+
147
+ def _on_level_changed(self, text: str) -> None:
148
+ self._level_filter = getattr(logging, text, logging.DEBUG)
149
+
150
+ def _toggle_autoscroll(self, checked: bool) -> None:
151
+ self._auto_scroll = checked
152
+ self._auto_scroll_btn.setText(f"Auto-scroll: {'ON' if checked else 'OFF'}")
153
+
154
+ def clear(self) -> None:
155
+ self._text.clear()
156
+
157
+ def append(self, message: str, level: int = logging.INFO) -> None:
158
+ """Manually append a message (also respects level filter)."""
159
+ self._append_record(level, message)