255 """Top-level application window.
257 Owns the ``SimulationStateManager``, the ``NetworkCanvas``, the
258 ``ControlPanel``, and the ``InfoPanel``. Coordinates playback via a
259 ``PlaybackController`` and propagates time-step changes to all child
260 widgets through ``_update_visualization()``.
262 The single source of truth for the current simulation time is
263 ``playback.current_sim_time_ns``; all other widgets derive their
264 displayed state from that value on each ``_update_visualization()`` call.
273 self.setWindowTitle(
"Q2NSViz - Quantum Network Trace Visualizer")
274 self.resize(1600, 900)
281 self.setStyleSheet(f
"background-color: {Theme.BG_DARK.name()};")
282 self.statusBar().setStyleSheet(f
"""
284 background-color: {Theme.BG_MEDIUM.name()};
285 color: {Theme.TEXT_SECONDARY.name()};
286 border-top: 1px solid {Theme.BORDER.name()};
291 self.statusBar().showMessage(
"Ready \u2014 Load a simulation file to begin")
294 def _center_on_screen(self):
295 screen = QApplication.primaryScreen()
298 geo = screen.availableGeometry()
299 x = (geo.width() - self.width()) // 2
300 y = (geo.height() - self.height()) // 2
301 self.move(geo.left() + x, geo.top() + y)
305 self.setCentralWidget(central)
306 main_layout = QVBoxLayout(central)
307 main_layout.setContentsMargins(10, 10, 10, 10)
308 main_layout.setSpacing(10)
311 main_layout.addWidget(top_bar)
313 content = QHBoxLayout()
314 content.setSpacing(10)
316 content.addWidget(self.
canvas, 2)
318 right_panel = QVBoxLayout()
319 right_panel.setSpacing(10)
322 content.addLayout(right_panel, 1)
323 main_layout.addLayout(content, 1)
328 def _build_topbar_stylesheet(self) -> str:
331 background-color: {Theme.BG_TOPBAR.name()};
336 background-color: {Theme.PRIMARY.name()};
345 background-color: {Theme.PRIMARY_HOVER.name()};
347 QPushButton:pressed {{
348 background-color: {Theme.PRIMARY_DARK.name()};
350 QPushButton#secondary {{
351 background-color: transparent;
352 color: {Theme.TEXT_PRIMARY.name()};
353 border: 1px solid {Theme.BORDER.name()};
355 QPushButton#secondary:hover {{
356 background-color: {Theme.BG_LIGHT.name()};
357 border-color: {Theme.PRIMARY_HOVER.name()};
359 QPushButton#secondary:checked {{
360 background-color: {Theme.PRIMARY.name()};
361 border-color: {Theme.PRIMARY.name()};
365 color: {Theme.TEXT_PRIMARY.name()};
371 def _create_top_bar(self) -> QWidget:
374 layout = QHBoxLayout(bar)
375 layout.setContentsMargins(8, 6, 8, 6)
378 _assets_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"assets")
379 logo_path = os.path.join(_assets_dir,
"logo-qnattynet.png")
381 self.
_logo_label.setStyleSheet(
"background: transparent; padding: 3px 6px;")
382 pix = QPixmap(logo_path)
386 aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio,
387 transformMode=Qt.TransformationMode.SmoothTransformation,
393 layout.addWidget(self.
_logo_label, 0, Qt.AlignmentFlag.AlignVCenter)
394 layout.addSpacing(12)
396 self.
_title_label = QLabel(
"Q2NSViz \u2014 Quantum Network Trace Visualizer")
398 f
"color: {Theme.TEXT_PRIMARY.name()}; font-size: 16px; font-weight: 600; background: transparent;"
401 layout.addSpacing(16)
407 self.
_dark_btn.setObjectName(
"secondary")
408 self.
_dark_btn.setToolTip(
"Switch to dark / light mode")
420 help_btn = QPushButton(
"Shortcuts")
421 help_btn.setObjectName(
"secondary")
422 help_btn.setToolTip(
"Keyboard shortcuts")
424 layout.addWidget(help_btn)
426 layout.addItem(QSpacerItem(20, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum))
428 reload_btn = QPushButton(
"\u21ba\u00a0Reload")
429 reload_btn.setObjectName(
"secondary")
430 reload_btn.setToolTip(
"Reload the last opened trace file")
432 layout.addWidget(reload_btn)
434 reset_btn = QPushButton(
"\u2715\u00a0Reset")
435 reset_btn.setObjectName(
"secondary")
436 reset_btn.setToolTip(
"Reset simulation to the start")
438 layout.addWidget(reset_btn)
440 export_btn = QPushButton(
"Export\u2026")
441 export_btn.setObjectName(
"secondary")
442 export_btn.setToolTip(
"Save the current canvas as an image")
444 layout.addWidget(export_btn)
446 layout.addItem(QSpacerItem(20, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum))
448 load_btn = QPushButton(
"Load Simulation")
449 load_btn.setToolTip(f
"Open a trace file ({_primary_modifier_label()}+O)")
451 layout.addWidget(load_btn)
455 def _setup_shortcuts(self):
456 QShortcut(QKeySequence(
"Space"), self).activated.connect(self.
control_panel.play_btn.click)
457 QShortcut(QKeySequence(
"Right"), self).activated.connect(self.
playback.step_forward)
458 QShortcut(QKeySequence(
"Left"), self).activated.connect(self.
playback.step_backward)
460 QShortcut(QKeySequence(
"Home"), self).activated.connect(self.
playback.jump_to_start)
461 QShortcut(QKeySequence(
"End"), self).activated.connect(self.
playback.jump_to_end)
465 def _load_file(self):
466 filename, _ = QFileDialog.getOpenFileName(
467 self,
"Open Simulation File", _example_traces_dir(),
"JSON Files (*.json *.ndjson);;All Files (*)"
473 """Load a Q2NS simulation trace from *filename* and initialize playback.
475 Calls ``EventFileParser.load_from_file()``, resets the state manager,
476 and repaints all child widgets at simulation time zero. Any parse errors are
477 counted and displayed in the status bar without aborting the load.
479 @param filename Absolute path to the trace file.
480 @warning Displays a ``QMessageBox.critical`` dialog on
481 ``OSError`` or any unexpected exception.
485 events, parse_errors = EventFileParser.load_from_file(filename)
490 status = f
"Loaded: {os.path.basename(filename)} — {len(events)} events"
492 status += f
" | {len(parse_errors)} line(s) skipped (parse errors)"
493 logger.warning(
"Parse errors in %s: %s", filename, parse_errors)
494 self.statusBar().showMessage(status)
495 except Exception
as exc:
496 QMessageBox.critical(self,
"Load Error", f
"Failed to load file:\n{exc}")
497 logger.exception(
"Failed to load file: %s", filename)
499 def _reset_simulation(self):
500 """Stop playback, clear all loaded state, and return the UI to its initial state."""
509 self.statusBar().showMessage(
"Ready \u2014 Load a simulation file to begin")
511 def _update_visualization(self):
512 """Query the replay engine once and dispatch the state to all child widgets.
514 Called after any change to ``current_sim_time_ns``. Performs the
515 single per-frame ``snapshot_at()`` query and pushes the resulting
516 ``Snapshot`` to the network canvas and the information panel; derives
517 the display step index via binary search for the status bar readout.
519 Safe to call on an empty trace: every widget then renders its empty state.
521 t_ns = self.
playback.current_sim_time_ns
523 self.
canvas.set_snapshot(snap)
526 display_step = bisect.bisect_right(ta, t_ns)
527 total_steps = len(ta)
528 self.
control_panel.update_time_label(t_us, display_step, total_steps)
530 self.
info_panel.update_entanglement(snap.entangled_states)
531 live_qubits = len(snap.live_qubit_labels)
537 entangled=len(snap.entangled_states),
538 measured=len(snap.measured_qubits),
539 lost=len(snap.lost_qubits),
540 discarded=len(snap.discarded_qubits),
544 self.statusBar().showMessage(
545 f
"Time: {t_us:.3f}\u202f\u03bcs | Step: {display_step}/{total_steps} | "
546 f
"Qubits: {live_qubits} | Entangled States: {len(snap.entangled_states)}"
549 def _toggle_dark(self):
550 """Switch between light and dark color palettes for this session."""
551 is_now_dark = _theme.toggle_dark()
552 self.
_dark_btn.setText(
"Light" if is_now_dark
else "Dark")
558 def _toggle_legend(self):
559 """Show or hide the canvas legend; syncs the toolbar button state."""
560 visible = self.
canvas.toggle_legend()
563 def _toggle_node_labels(self):
564 """Show or hide node labels on the canvas."""
565 self.
canvas.toggle_node_labels()
567 def _export_canvas(self):
568 """Save the current canvas view as an image file."""
569 path, _ = QFileDialog.getSaveFileName(
570 self,
"Export Canvas",
"canvas.png",
"PNG Image (*.png);;JPEG Image (*.jpg);;All Files (*)"
573 self.
canvas.grab().save(path)
575 def _reload_file(self):
576 """Reload the last opened trace file."""
580 def _show_shortcuts(self):
581 """Display a summary of keyboard shortcuts with styled key badges."""
583 "background:#e8eaed; border:1px solid #c0c3c8;"
584 " border-bottom:2px solid #a0a3a8; border-radius:4px;"
585 " padding:2px 8px; font-family:monospace; font-size:11px;"
588 f
"<table cellpadding='5' cellspacing='0' style='font-size:13px;'>"
589 f
"<tr><td colspan='2' style='padding-top:0;padding-bottom:4px;'><b>Playback</b></td></tr>"
590 f
"<tr><td align='right'><span style='{_kb}'>Space</span></td>"
591 f
" <td style='padding-left:14px;'>Play / Pause</td></tr>"
592 f
"<tr><td align='right'>"
593 f
" <span style='{_kb}'>←</span> "
594 f
" <span style='{_kb}'>→</span></td>"
595 f
" <td style='padding-left:14px;'>Step backward / forward</td></tr>"
596 f
"<tr><td align='right'>"
597 f
" <span style='{_kb}'>Home</span> "
598 f
" <span style='{_kb}'>End</span></td>"
599 f
" <td style='padding-left:14px;'>Jump to start / end</td></tr>"
600 f
"<tr><td colspan='2' style='padding-top:10px;padding-bottom:4px;'><b>File</b></td></tr>"
601 f
"<tr><td align='right'>"
602 f
" <span style='{_kb}'>{_primary_modifier_label()}</span> + "
603 f
" <span style='{_kb}'>O</span></td>"
604 f
" <td style='padding-left:14px;'>Open trace file</td></tr>"
605 f
"<tr><td colspan='2' style='padding-top:10px;padding-bottom:4px;'><b>Display</b></td></tr>"
606 f
"<tr><td align='right'><span style='{_kb}'>L</span></td>"
607 f
" <td style='padding-left:14px;'>Toggle legend</td></tr>"
608 f
"<tr><td align='right'><span style='{_kb}'>N</span></td>"
609 f
" <td style='padding-left:14px;'>Toggle node labels</td></tr>"
612 QMessageBox.information(self,
"Keyboard Shortcuts", html)
614 def _apply_logo(self):
615 """Set the top-bar logo, tinted to a light tone in dark mode for legibility."""
624 """Re-apply the active color palette to the window and all child widgets.
626 Call after ``toggle_dark()`` to propagate palette changes throughout
627 the application. Updates stylesheets and triggers a canvas repaint.
629 self.setStyleSheet(f
"background-color: {Theme.BG_DARK.name()};")
630 self.statusBar().setStyleSheet(f
"""
632 background-color: {Theme.BG_MEDIUM.name()};
633 color: {Theme.TEXT_SECONDARY.name()};
634 border-top: 1px solid {Theme.BORDER.name()};
639 if hasattr(self,
"_top_bar"):
641 for _btn
in self.
_top_bar.findChildren(QPushButton):
642 self.
_top_bar.style().unpolish(_btn)
645 if hasattr(self,
"_title_label"):
647 f
"color: {Theme.TEXT_PRIMARY.name()}; font-size: 16px; font-weight: 600; background: transparent;"
649 if hasattr(self,
"_logo_label"):