Q2NSViz 0.1.0
Q2NS trace visualizer
Loading...
Searching...
No Matches
panels.py
Go to the documentation of this file.
1# -----------------------------------------------------------------------------
2# Q2NSViz - Quantum Network Trace Visualizer
3# Copyright (c) 2026 QuantumInternet.it
4#
5# This program is released under the MIT License - see LICENSE for details.
6# -----------------------------------------------------------------------------
7
8from typing import Any
9
10from PyQt6.QtCore import Qt, pyqtSignal
11from PyQt6.QtGui import QTextCursor
12from PyQt6.QtWidgets import (
13 QFileDialog,
14 QFrame,
15 QHBoxLayout,
16 QHeaderView,
17 QLabel,
18 QPushButton,
19 QSlider,
20 QTableWidget,
21 QTableWidgetItem,
22 QTabWidget,
23 QTextEdit,
24 QVBoxLayout,
25 QWidget,
26)
27
28from .charts import MATPLOTLIB_AVAILABLE, ChartCanvas
29from .theme import Theme
30
31
32class ControlPanel(QFrame):
33 """Transport controls, time scrubber, and speed slider.
34
35 @ingroup q2nsviz_panels
36 """
37
38 play_clicked = pyqtSignal()
39 pause_clicked = pyqtSignal()
40 step_forward = pyqtSignal()
41 step_backward = pyqtSignal()
42 time_changed = pyqtSignal(int)
43 speed_changed = pyqtSignal(int)
44
45 def __init__(self):
46 super().__init__()
47 self.setObjectName("ControlPanel")
48 self.is_playing = False
49 self._setup_ui()
50
51 def _apply_stylesheet(self):
52 self.setStyleSheet(f"""
53 QFrame#ControlPanel {{
54 background-color: {Theme.BG_MEDIUM.name()};
55 border-radius: 10px;
56 border: none;
57 }}
58 QPushButton {{
59 background-color: {Theme.PRIMARY.name()};
60 color: white;
61 border: none;
62 padding: 10px 20px;
63 border-radius: 4px;
64 font-weight: 500;
65 font-size: 13px;
66 min-width: 90px;
67 }}
68 QPushButton:hover {{
69 background-color: {Theme.PRIMARY_HOVER.name()};
70 }}
71 QPushButton:pressed {{
72 background-color: {Theme.PRIMARY_DARK.name()};
73 }}
74 QPushButton#secondary {{
75 background-color: {Theme.BG_MEDIUM.name()};
76 color: {Theme.TEXT_PRIMARY.name()};
77 border: 1px solid {Theme.BORDER.name()};
78 }}
79 QPushButton#secondary:hover {{
80 background-color: {Theme.BG_LIGHT.name()};
81 border-color: {Theme.PRIMARY.name()};
82 }}
83 QPushButton#secondary:checked {{
84 background-color: {Theme.BG_LIGHT.name()};
85 border-color: {Theme.PRIMARY.name()};
86 color: {Theme.PRIMARY.name()};
87 }}
88 QLabel {{
89 background-color: transparent;
90 color: {Theme.TEXT_SECONDARY.name()};
91 font-size: 12px;
92 }}
93 QSlider::groove:horizontal {{
94 background: {Theme.BG_DARK.name()};
95 height: 6px;
96 border-radius: 3px;
97 border: 1px solid {Theme.BORDER.name()};
98 }}
99 QSlider::handle:horizontal {{
100 background: {Theme.PRIMARY.name()};
101 width: 16px;
102 height: 16px;
103 margin: -5px 0;
104 border-radius: 8px;
105 border: 2px solid {Theme.BG_MEDIUM.name()};
106 }}
107 QSlider::handle:horizontal:hover {{
108 background: {Theme.PRIMARY_HOVER.name()};
109 }}
110 QSlider::sub-page:horizontal {{
111 background: {Theme.PRIMARY.name()};
112 border-radius: 3px;
113 }}
114 """)
115
116 def _setup_ui(self):
117 self._apply_stylesheet()
118 layout = QHBoxLayout(self)
119 layout.setContentsMargins(16, 12, 16, 12)
120 layout.setSpacing(20)
121
122 controls_box = QVBoxLayout()
123 controls_box.setSpacing(8)
124 self.prev_btn = QPushButton("Previous")
125 self.prev_btn.setObjectName("secondary")
126 self.prev_btn.setToolTip("Step backward (←)")
127 self.prev_btn.clicked.connect(self.step_backward.emit)
128 self.play_btn = QPushButton("Play")
129 self.play_btn.setToolTip("Play / Pause (Space)")
130 self.play_btn.clicked.connect(self._toggle_play_toggle_play)
131 self.next_btn = QPushButton("Next")
132 self.next_btn.setObjectName("secondary")
133 self.next_btn.setToolTip("Step forward (→)")
134 self.next_btn.clicked.connect(self.step_forward.emit)
135 controls_box.addWidget(self.play_btn)
136 controls_box.addWidget(self.prev_btn)
137 controls_box.addWidget(self.next_btn)
138 self.loop_btn = QPushButton("Loop")
139 self.loop_btn.setObjectName("secondary")
140 self.loop_btn.setToolTip("Loop playback when reaching the end")
141 self.loop_btn.setCheckable(True)
142 controls_box.addWidget(self.loop_btn)
143 layout.addLayout(controls_box)
144
145 sliders_box = QVBoxLayout()
146 sliders_box.setSpacing(12)
147
148 time_row = QHBoxLayout()
149 time_row.addWidget(QLabel("Time:"))
150 self.time_slider = QSlider(Qt.Orientation.Horizontal)
151 self.time_slider.setMinimum(0)
152 self.time_slider.setMaximum(100)
153 self.time_slider.valueChanged.connect(self.time_changed.emit)
154 time_row.addWidget(self.time_slider, 1)
155 self.time_label = QLabel("0.000 μs")
156 self.time_label.setMinimumWidth(100)
157 time_row.addWidget(self.time_label)
158 sliders_box.addLayout(time_row)
159
160 speed_row = QHBoxLayout()
161 speed_row.addWidget(QLabel("Speed:"))
162 self.speed_slider = QSlider(Qt.Orientation.Horizontal)
163 self.speed_slider.setMinimum(1)
164 self.speed_slider.setMaximum(100)
165 self.speed_slider.setValue(10)
166 self.speed_slider.valueChanged.connect(self.speed_changed.emit)
167 speed_row.addWidget(self.speed_slider, 1)
168 self.speed_label = QLabel("")
169 self.speed_label.setMinimumWidth(60)
170 speed_row.addWidget(self.speed_label)
171 sliders_box.addLayout(speed_row)
172
173 layout.addLayout(sliders_box, 1)
174
175 def _toggle_play(self):
176 """Toggle the play/pause button state and emit the corresponding signal."""
177 self.is_playing = not self.is_playing
178 if self.is_playing:
179 self.play_btn.setText("Pause")
180 self.play_clicked.emit()
181 else:
182 self.play_btn.setText("Play")
183 self.pause_clicked.emit()
184
185 def set_playing(self, playing: bool):
186 self.is_playing = playing
187 self.play_btn.setText("Pause" if playing else "Play")
188
189 def update_time_label(self, time_us: float, step: int, total: int):
190 self.time_label.setText(f"{time_us:.3f} μs ({step}/{total})")
191
192 def update_speed_label(self, label: str):
193 self.speed_label.setText(label)
194
195 def apply_theme(self):
196 """Re-apply the active color palette to all styled elements.
197
198 Call after ``toggle_dark()`` to update the control panel's appearance.
199 """
200 self._apply_stylesheet()
201
202
203class InfoPanel(QFrame):
204 """Tabbed panel with event log, statistics, entangled states, and charts.
205
206 @ingroup q2nsviz_panels
207 """
208
209 def __init__(self):
210 super().__init__()
211 self.setObjectName("InfoPanel")
212 self._save_btn: QPushButton | None = None
213 # Event-log view state: formatted lines cached per loaded trace, plus how
214 # many of them are currently rendered (see update_log).
215 self._log_source: list[dict[str, Any]] | None = None
216 self._log_lines: list[str] = []
217 self._log_visible: int = 0
218 self._setup_ui()
219
220 def _apply_stylesheet(self):
221 self.setStyleSheet(f"""
222 QFrame#InfoPanel {{
223 background-color: {Theme.BG_MEDIUM.name()};
224 border-radius: 10px;
225 padding: 12px;
226 border: none;
227 }}
228 QLabel {{
229 background-color: transparent;
230 color: {Theme.TEXT_PRIMARY.name()};
231 font-size: 12px;
232 }}
233 QTextEdit {{
234 background-color: {Theme.BG_DARK.name()};
235 color: {Theme.TEXT_PRIMARY.name()};
236 border: 1px solid {Theme.BORDER.name()};
237 border-radius: 4px;
238 padding: 8px;
239 font-family: 'Courier New', monospace;
240 font-size: 14px;
241 }}
242 QTableWidget {{
243 background-color: {Theme.BG_DARK.name()};
244 alternate-background-color: {Theme.BG_MEDIUM.name()};
245 color: {Theme.TEXT_PRIMARY.name()};
246 border: 1px solid {Theme.BORDER.name()};
247 border-radius: 4px;
248 gridline-color: {Theme.BORDER.name()};
249 }}
250 QTableWidget::item {{
251 padding: 8px;
252 border-bottom: 1px solid {Theme.BORDER.name()};
253 }}
254 QTableWidget::item:selected {{
255 background-color: {Theme.PRIMARY.name()};
256 color: white;
257 }}
258 QHeaderView::section {{
259 background-color: {Theme.BG_DARK.name()};
260 color: {Theme.TEXT_SECONDARY.name()};
261 padding: 8px;
262 border: none;
263 font-weight: 600;
264 }}
265 QTabWidget::pane {{
266 border: none;
267 }}
268 QTabBar::tab {{
269 background-color: {Theme.BG_LIGHT.name()};
270 color: {Theme.TEXT_SECONDARY.name()};
271 padding: 7px 12px;
272 border: 1px solid {Theme.BORDER.name()};
273 border-bottom: none;
274 border-top-left-radius: 6px;
275 border-top-right-radius: 6px;
276 margin-right: 2px;
277 }}
278 QTabBar::tab:selected {{
279 background-color: {Theme.BG_DARK.name()};
280 color: {Theme.TEXT_PRIMARY.name()};
281 font-weight: 600;
282 }}
283 QTabBar::tab:hover {{
284 background-color: {Theme.BG_MEDIUM.name()};
285 }}
286 """)
287 if self._save_btn is not None:
288 self._save_btn.setStyleSheet(f"""
289 QPushButton {{
290 background-color: {Theme.BG_DARK.name()};
291 color: {Theme.TEXT_PRIMARY.name()};
292 border: 1px solid {Theme.BORDER.name()};
293 padding: 6px 12px;
294 border-radius: 4px;
295 font-size: 12px;
296 }}
297 QPushButton:hover {{
298 border-color: {Theme.PRIMARY.name()};
299 }}
300 """)
301
302 def _setup_ui(self):
303 self._apply_stylesheet()
304 layout = QVBoxLayout(self)
305 layout.setSpacing(5)
306 self.tabs = QTabWidget()
307
308 log_widget = QWidget()
309 log_layout = QVBoxLayout(log_widget)
310 self.log_text = QTextEdit()
311 self.log_text.setReadOnly(True)
312 log_layout.addWidget(self.log_text)
313 self.tabs.addTab(log_widget, "Event Log")
314
315 stats_widget = QWidget()
316 stats_layout = QVBoxLayout(stats_widget)
317 self.stats_table = QTableWidget()
318 self.stats_table.setColumnCount(2)
319 self.stats_table.setHorizontalHeaderLabels(["Property", "Value"])
320 self.stats_table.horizontalHeader().setStretchLastSection(True)
321 self.stats_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
322 self.stats_table.verticalHeader().setVisible(False)
323 self.stats_table.setAlternatingRowColors(True)
324 self.stats_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
325 stats_layout.addWidget(self.stats_table)
326 self.tabs.addTab(stats_widget, "Statistics")
327
328 entangle_widget = QWidget()
329 entangle_layout = QVBoxLayout(entangle_widget)
330 self.entangle_table = QTableWidget()
331 self.entangle_table.setColumnCount(2)
332 self.entangle_table.setHorizontalHeaderLabels(["State", "Qubits"])
333 self.entangle_table.horizontalHeader().setStretchLastSection(True)
334 self.entangle_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
335 self.entangle_table.verticalHeader().setVisible(False)
336 self.entangle_table.setAlternatingRowColors(True)
337 self.entangle_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
338 entangle_layout.addWidget(self.entangle_table)
339 self.tabs.addTab(entangle_widget, "Entangled States")
340
341 if MATPLOTLIB_AVAILABLE:
342 charts_widget = QWidget()
343 charts_layout = QVBoxLayout(charts_widget)
345 charts_layout.addWidget(self.chart_canvas)
346 save_btn = QPushButton("Save chart as PNG/PDF\u2026")
347 self._save_btn = save_btn
348 save_btn.setStyleSheet(f"""
349 QPushButton {{
350 background-color: {Theme.BG_DARK.name()};
351 color: {Theme.TEXT_PRIMARY.name()};
352 border: 1px solid {Theme.BORDER.name()};
353 padding: 6px 12px;
354 border-radius: 4px;
355 font-size: 12px;
356 }}
357 QPushButton:hover {{
358 border-color: {Theme.PRIMARY.name()};
359 }}
360 """)
361 save_btn.clicked.connect(self._save_chart_save_chart)
362 charts_layout.addWidget(save_btn)
363 self.tabs.addTab(charts_widget, "Charts")
364 else:
365 self.chart_canvas = None
366
367 layout.addWidget(self.tabs)
368
369 self._last_chart_state: tuple | None = None
370 self.tabs.currentChanged.connect(self._on_tab_changed_on_tab_changed)
371
372 def _save_chart(self):
373 """Open a save-file dialog and export the chart figure to an image file."""
374 if not self.chart_canvas:
375 return
376 path, _ = QFileDialog.getSaveFileName(
377 self,
378 "Save Chart",
379 "chart.png",
380 "PNG Image (*.png);;PDF Document (*.pdf);;SVG Vector (*.svg);;All Files (*)",
381 )
382 if path:
383 self.chart_canvas.fig.savefig(path, dpi=300, bbox_inches="tight")
384
385 def _on_tab_changed(self, _index: int):
386 """Re-render the chart when the chart tab is brought into view.
387
388 Lazy redraw: the chart is updated only when the tab becomes visible,
389 avoiding unnecessary Matplotlib renders during log or entanglement updates.
390 """
391 if (
392 self.chart_canvas
393 and self.tabs.currentWidget() is self.chart_canvas.parent()
394 and self._last_chart_state is not None
395 ):
397
398 @staticmethod
399 def _format_log_line(event: dict[str, Any]) -> str:
400 t_us = event.get("t_ns", 0) / 1000
401 node = event.get("node", "")
402 prefix = f"[{t_us:8.3f} μs]"
403 return f"{prefix} [{node}] {event.get('text', '')}" if node else f"{prefix} {event.get('text', '')}"
404
405 def update_log(self, log_events: list[dict[str, Any]], visible: int):
406 """Show the first *visible* entries of the trace's ``traceText`` log.
407
408 The log only grows as the clock advances, so stepping forward appends
409 the new lines instead of re-rendering the whole document; the view is
410 rebuilt only when the user scrubs backward or loads a new trace.
411
412 @param log_events ``SimulationStateManager.log_events`` -- every
413 ``traceText`` event of the trace, in time order.
414 @param visible Number of leading entries to display, from
415 ``SimulationStateManager.log_count_at()``.
416 """
417 if log_events is not self._log_source:
418 self._log_source = log_events
419 self._log_lines = [self._format_log_line(event) for event in log_events]
420 self._log_visible = 0
421 self.log_text.clear()
422 if visible == self._log_visible:
423 return
424
425 scrollbar = self.log_text.verticalScrollBar()
426 at_bottom = scrollbar.value() >= scrollbar.maximum() - 4
427 cursor = self.log_text.textCursor()
428 if visible > self._log_visible:
429 cursor.movePosition(QTextCursor.MoveOperation.End)
430 head = "\n" if self._log_visible else ""
431 cursor.insertText(head + "\n".join(self._log_lines[self._log_visible : visible]))
432 elif visible == 0:
433 self.log_text.clear()
434 else:
435 # Scrubbed backward: drop the trailing lines (and the newline that
436 # precedes them) rather than re-rendering the whole document.
437 first_dropped = self.log_text.document().findBlockByNumber(visible)
438 cursor.setPosition(max(first_dropped.position() - 1, 0))
439 cursor.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
440 cursor.removeSelectedText()
441 self._log_visible = visible
442
443 if at_bottom:
444 scrollbar.setValue(scrollbar.maximum())
445
447 self,
448 nodes: int,
449 events: int,
450 max_time: float,
451 qubits: int = 0,
452 entangled: int = 0,
453 measured: int = 0,
454 lost: int = 0,
455 discarded: int = 0,
456 ):
457 """Refresh the statistics table with the given snapshot values.
458
459 @param nodes Total number of network nodes in the simulation.
460 @param events Total number of events in the loaded trace.
461 @param max_time Simulation duration in microseconds.
462 @param qubits Number of live qubits at the current time.
463 @param entangled Number of entangled multi-qubit states.
464 @param measured Number of measured qubits.
465 @param lost Number of lost qubits (removed, neither measured nor discarded).
466 @param discarded Number of qubits removed with ``reason == "discarded"``.
467 """
468 stats = [
469 ("Network Nodes", str(nodes)),
470 ("Simulation Events", str(events)),
471 ("Duration", f"{max_time:.3f} μs"),
472 ("Live Qubits", str(qubits)),
473 ("Measured Qubits", str(measured)),
474 ("Lost Qubits", str(lost)),
475 ("Discarded Qubits", str(discarded)),
476 ("Entangled States (≥2)", str(entangled)),
477 ]
478 self.stats_table.setRowCount(len(stats))
479 for i, (prop, value) in enumerate(stats):
480 self.stats_table.setItem(i, 0, QTableWidgetItem(prop))
481 self.stats_table.setItem(i, 1, QTableWidgetItem(value))
482
483 def update_entanglement(self, states: dict[str, tuple[str, ...]]):
484 """Refresh the entanglement-states tab.
485
486 Rows are sorted by descending component size, then alphabetically,
487 so the largest entangled states appear at the top.
488
489 @param states Multi-qubit entanglement groups from
490 ``Snapshot.entangled_states``.
491 """
492 ordered_states = sorted(
493 (sorted(members) for members in states.values()), key=lambda members: (-len(members), members)
494 )
495 self.entangle_table.setHorizontalHeaderLabels(["State", "Qubits"])
496 self.entangle_table.setRowCount(len(ordered_states))
497 for i, members in enumerate(ordered_states):
498 self.entangle_table.setItem(i, 0, QTableWidgetItem(f"State {i + 1}"))
499 self.entangle_table.setItem(i, 1, QTableWidgetItem(", ".join(members)))
500
501 def update_charts(self, state_manager, snap):
502 """Forward chart data to ``ChartCanvas`` if the Charts tab is visible.
503
504 Stores ``(state_manager, snap)`` so the charts can be redrawn
505 lazily when the user switches to the Charts tab.
506
507 @param state_manager Shared ``SimulationStateManager`` instance.
508 @param snap ``Snapshot`` at the current simulation time,
509 queried by the controller.
510 """
511 self._last_chart_state = (state_manager, snap)
512 if self.chart_canvas and self.tabs.currentWidget() is self.chart_canvas.parent():
513 self.chart_canvas.update_charts(state_manager, snap)
514
515 def apply_theme(self):
516 """Re-apply the active color palette to all styled elements.
517
518 Call after ``toggle_dark()`` to update the info panel's appearance.
519 """
520 self._apply_stylesheet()
521
522
523__all__ = ["ControlPanel", "InfoPanel"]
Matplotlib canvas for time-series and summary charts.
Definition charts.py:108
Transport controls, time scrubber, and speed slider.
Definition panels.py:32
update_time_label(self, float time_us, int step, int total)
Definition panels.py:189
apply_theme(self)
Re-apply the active color palette to all styled elements.
Definition panels.py:195
update_speed_label(self, str label)
Definition panels.py:192
set_playing(self, bool playing)
Definition panels.py:185
Tabbed panel with event log, statistics, entangled states, and charts.
Definition panels.py:203
update_entanglement(self, dict[str, tuple[str,...]] states)
Refresh the entanglement-states tab.
Definition panels.py:483
update_log(self, list[dict[str, Any]] log_events, int visible)
Show the first visible entries of the trace's traceText log.
Definition panels.py:405
update_stats(self, int nodes, int events, float max_time, int qubits=0, int entangled=0, int measured=0, int lost=0, int discarded=0)
Refresh the statistics table with the given snapshot values.
Definition panels.py:456
apply_theme(self)
Re-apply the active color palette to all styled elements.
Definition panels.py:515
update_charts(self, state_manager, snap)
Forward chart data to ChartCanvas if the Charts tab is visible.
Definition panels.py:501