33 """Custom widget that renders the network topology and quantum state.
35 Paint order (back to front)
36 ---------------------------
39 3. Node boxes (each with its resident qubits and classical bits)
41 5. Measured-qubit markers
42 6. In-flight qubits (colored circles on the channel lane)
43 7. In-flight classical packets (``sendPacket``)
44 8. In-flight classical bits (``sendCbit``)
45 9. Entanglement links (colored lines between qubit circles)
46 10. Legend overlay (optional)
47 11. Current-time ``traceText`` overlay
49 A qubit inside an operation window wears a ring whose style names the
50 operation: dashed single for ``entangle``, solid single for ``measure``,
51 solid double for ``graphMeasure``.
53 The widget owns no state of its own beyond the ``Snapshot`` pushed by
54 the ``QuantumVisualizerWindow`` controller via ``set_snapshot()`` and
55 the qubit sets derived from it. The shared ``state_manager`` is read
56 only for the static topology and the raw event list (in-flight
57 animation geometry and ``traceText`` overlays). The widget is **not**
58 thread-safe; call only from the Qt main thread.
59 @ingroup q2nsviz_canvas
66 CHANNEL_WIDTH: int = 6
67 LAYOUT_MARGIN_X: int = 100
68 LAYOUT_MARGIN_Y: int = 75
69 LEGEND_RESERVE: int = 210
71 def __init__(self, state_manager: SimulationStateManager):
74 self.
_snap: Snapshot |
None =
None
87 self.setMinimumSize(600, 400)
88 self.setMouseTracking(
True)
92 def _events_of(self, event_type: str) -> list[dict]:
93 """Return the loaded events of one type, from the manager's load-time index."""
97 """Display the state carried by *snap* and repaint.
99 Called by the ``QuantumVisualizerWindow`` controller, which performs
100 the per-frame ``snapshot_at()`` query and pushes the resulting
101 ``Snapshot`` here. Unpacks the inflight, removed, and
102 in-progress-operation qubit sets, then triggers a full repaint.
103 Must be called from the Qt main thread.
105 @param snap ``Snapshot`` of the network state to render.
121 """Clear all transient qubit sets and repaint an empty canvas.
123 Called when the user loads a new file or the application starts.
143 """Hit-test nodes and in-flight qubits on hover and update the tooltip.
145 Tests each node bounding box first; on a miss, scans in-flight qubits.
146 Updates the Qt tooltip text so the OS displays it near the cursor.
148 @param event Qt mouse-move event providing the current cursor position.
150 pos = event.position()
151 snap_qubits = self.
_snap.qubits
if self.
_snap else {}
152 snap_cbits = self.
_snap.cbits
if self.
_snap else {}
156 1
for q
in snap_qubits.values()
if q.node == node_label
and q.label
not in self.
removed_qubits
159 1
for c
in snap_cbits.values()
if c.node == node_label
and c.label
not in self.
removed_cbits
161 tip = f
"{node_label} | qubits: {qubit_count} cbits: {cbit_count}"
162 QToolTip.showText(event.globalPosition().toPoint(), tip, self)
166 dx = pos.x() - qpos.x()
167 dy = pos.y() - qpos.y()
168 if math.sqrt(dx * dx + dy * dy) <= hit_radius:
169 QToolTip.showText(event.globalPosition().toPoint(), label, self)
175 """Render one frame of the network visualization.
177 Computes node and qubit screen positions once, then executes the
178 layered draw passes listed in the class docstring. Caches qubit
179 positions in ``_qubit_positions_cache`` for use by the tooltip
180 hit-test in ``mouseMoveEvent``.
182 painter = QPainter(self)
183 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
184 painter.fillRect(self.rect(), Theme.BG_DARK)
204 def _draw_empty_state(self, painter: QPainter):
205 painter.setPen(QPen(Theme.TEXT_MUTED))
206 painter.setFont(QFont(ui_font_family(), 14))
207 painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter,
"Load a simulation file to begin")
209 def _calculate_positions(self) -> dict[str, QPointF]:
210 """Compute screen centre coordinates for each node.
212 Nodes with explicit (x, y) percentages from ``createNode`` events
213 are placed using those percentages relative to the layout area.
214 All other nodes are arranged on a circle centred on the widget.
216 When the legend is visible, the layout area is inset from the left by
217 ``LEGEND_RESERVE`` so that nodes never render underneath the legend panel.
219 @returns ``{node_label: QPointF}`` for every node in
220 ``state_manager.nodes``.
222 positions: dict[str, QPointF] = {}
229 num_nodes = len(nodes)
230 for idx, (label, node)
in enumerate(nodes):
231 if node.has_explicit_position:
235 angle = (2 * math.pi * idx / num_nodes) - math.pi / 2
236 x = self.width() / 2 + left_reserve / 2 + (width / 2.5) * math.cos(angle)
237 y = self.height() / 2 + (height / 2.5) * math.sin(angle)
238 positions[label] = QPointF(x, y)
241 def _draw_channels(self, painter: QPainter, positions: dict[str, QPointF]):
242 """Render quantum and classical channel lines with perpendicular lane offset.
244 Node pairs that share both a quantum and a classical channel receive
245 parallel lanes offset perpendicularly so neither line obscures the other.
246 Duplicate channel definitions are de-duplicated at load time, so any that
247 slip through here are silently discarded.
249 @param painter Active ``QPainter`` for the canvas widget.
250 @param positions Mapping of node label to widget-space centre point.
252 channel_pairs: dict[tuple, dict] = {}
254 if channel.from_node
not in positions
or channel.to_node
not in positions:
256 pair = tuple(sorted([channel.from_node, channel.to_node]))
257 if pair
not in channel_pairs:
258 channel_pairs[pair] = {
"quantum":
None,
"classical":
None}
259 if channel_pairs[pair].get(channel.kind)
is not None:
261 channel_pairs[pair][channel.kind] = channel
263 for (node1, node2), channels
in channel_pairs.items():
264 start = positions[node1]
265 end = positions[node2]
266 dx = end.x() - start.x()
267 dy = end.y() - start.y()
268 length = math.sqrt(dx * dx + dy * dy)
or 1
269 perp_x = -dy / length
272 if channels[
"quantum"]:
280 pen.setCapStyle(Qt.PenCapStyle.FlatCap)
282 painter.drawLine(q_start, q_end)
284 if channels[
"classical"]:
292 pen.setCapStyle(Qt.PenCapStyle.FlatCap)
294 painter.drawLine(c_start, c_end)
296 def _draw_entanglement(self, painter: QPainter, qubit_positions: dict[str, QPointF]):
297 """Draw entanglement edges between qubit circles.
299 Each edge of the snapshot's ``ent_graph`` that connects two qubits
300 with known screen positions is rendered as a colored line. Qubits
301 in ``removed_qubits`` are excluded to avoid artifacts from stale
302 edges that have not yet been cleaned up by a ``removeQubit`` event.
304 @param painter Active ``QPainter`` in the correct state.
305 @param qubit_positions Screen positions returned by
306 ``_get_qubit_positions()``.
308 pen = QPen(Theme.ENTANGLEMENT, 2.5)
309 pen.setStyle(Qt.PenStyle.SolidLine)
310 pen.setCapStyle(Qt.PenCapStyle.RoundCap)
312 painter.setOpacity(0.85)
315 ent_graph = self.
_snap.ent_graph
if self.
_snap else {}
316 for qubit1, neighbors
in ent_graph.items():
317 if qubit1
in self.
removed_qubits or qubit1
not in qubit_positions:
320 for qubit2
in neighbors:
321 if qubit2
in self.
removed_qubits or qubit2
not in qubit_positions:
324 pair = tuple(sorted([qubit1, qubit2]))
328 painter.drawLine(qubit_positions[qubit1], qubit_positions[qubit2])
329 painter.setOpacity(1.0)
331 def _draw_lost_qubits(self, painter: QPainter, positions: dict[str, QPointF]):
334 loss_by_node: dict[str, list[str]] = {}
335 snap_qubits = self.
_snap.qubits
if self.
_snap else {}
337 qubit = snap_qubits.get(label)
338 if qubit
and qubit.node
and qubit.node
in positions:
339 loss_by_node.setdefault(qubit.node, []).append(label)
341 fill = QColor(Theme.QUBIT_LOST)
343 cross_pen = QPen(QColor(Theme.QUBIT_LOST).darker(140), 1.5)
346 for node_label, lost_labels
in loss_by_node.items():
347 center = positions[node_label]
348 num = len(lost_labels)
350 start_x = center.x() - (num - 1) * spacing / 2
353 pos = QPointF(start_x + i * spacing, y)
354 painter.setPen(Qt.PenStyle.NoPen)
355 painter.setBrush(QBrush(fill))
356 painter.drawEllipse(pos, r, r)
358 painter.setPen(cross_pen)
359 painter.drawLine(QPointF(pos.x() - d, pos.y() - d), QPointF(pos.x() + d, pos.y() + d))
360 painter.drawLine(QPointF(pos.x() + d, pos.y() - d), QPointF(pos.x() - d, pos.y() + d))
362 def _draw_measured_qubits(self, painter: QPainter, positions: dict[str, QPointF]):
363 """Render all measured qubits as gray circles below their node.
365 Drawn whether or not a ``removeQubit`` event also fired, so a measurement
366 always leaves a marker distinct from the lost-qubit cross.
368 @param painter Active ``QPainter``.
369 @param positions Node screen positions from ``_calculate_positions()``.
371 measured = set(self.
_snap.measured_qubits)
if self.
_snap else set()
374 by_node: dict[str, list[str]] = {}
375 for label
in measured:
376 qubit = self.
_snap.qubits.get(label)
377 if qubit
and qubit.node
and qubit.node
in positions:
378 by_node.setdefault(qubit.node, []).append(label)
380 fill = QColor(Theme.QUBIT_MEASURED)
382 tick_pen = QPen(QColor(Theme.QUBIT_MEASURED).darker(120), 1.5)
385 for node_label, labels
in by_node.items():
386 center = positions[node_label]
390 start_x = center.x() - (num - 1) * spacing / 2
391 y = center.y() + self.
NODE_HEIGHT / 2 + r + 6 + (r * 2 + 4)
393 pos = QPointF(start_x + i * spacing, y)
394 painter.setPen(Qt.PenStyle.NoPen)
395 painter.setBrush(QBrush(fill))
396 painter.drawEllipse(pos, r, r)
397 painter.setPen(tick_pen)
399 QPointF(pos.x() - r * 0.45, pos.y()), QPointF(pos.x() - r * 0.1, pos.y() + r * 0.45)
402 QPointF(pos.x() - r * 0.1, pos.y() + r * 0.45), QPointF(pos.x() + r * 0.5, pos.y() - r * 0.4)
406 def _draw_soft_shadow(painter: QPainter, rect: QRectF, radius: float):
407 """Draw a soft drop shadow beneath a rounded-rect card."""
409 painter.setPen(Qt.PenStyle.NoPen)
411 for i
in range(layers):
412 frac = (i + 1) / layers
413 spread = 7.0 * (1.0 - frac)
414 painter.setBrush(QColor(15, 23, 42, int(9 * frac)))
416 rect.left() - spread,
417 rect.top() - spread + 2.5,
418 rect.width() + 2 * spread,
419 rect.height() + 2 * spread,
421 painter.drawRoundedRect(shadow, radius + spread, radius + spread)
424 def _draw_nodes(self, painter: QPainter, positions: dict[str, QPointF]):
425 """Render each network node as a rounded card.
427 Draws qubits and classical bits stacked beneath the node box via
428 ``_draw_node_qubits`` and ``_draw_node_cbits``. Node labels are
429 elided to fit the node width when ``_show_node_labels`` is set.
431 @param painter Active ``QPainter`` for the canvas widget.
432 @param positions Mapping of node label to widget-space centre point.
435 for label, pos
in positions.items():
440 painter.setPen(QPen(Theme.BORDER, 1.0))
441 painter.setBrush(QBrush(Theme.BG_MEDIUM))
442 painter.drawRoundedRect(rect, radius, radius)
444 painter.setPen(QPen(QColor(Theme.NODE_TEXT)))
445 painter.setFont(QFont(ui_font_family(), 11, QFont.Weight.Medium))
446 fm = QFontMetrics(painter.font())
448 painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, elided)
452 def _get_live_qubit_label_set(self) -> set[str]:
453 """Return the set of qubit labels that are alive (neither measured nor removed).
455 @returns Live qubit labels from the current snapshot.
457 return set(self.
_snap.live_qubit_labels)
if self.
_snap else set()
459 def _get_live_cbit_label_set(self) -> set[str]:
460 """Return the set of classical bit labels that are not yet removed."""
461 if self.
_snap is None:
465 def _get_node_qubit_positions(
466 self, node_label: str, center: QPointF, live_labels: set[str]
467 ) -> dict[str, QPointF]:
468 snap_qubits = self.
_snap.qubits
if self.
_snap else {}
471 for label, qubit
in snap_qubits.items()
472 if qubit.node == node_label
and label
in live_labels
and label
not in self.
inflight_qubits
476 num_qubits = len(qubits)
478 start_x = center.x() - (num_qubits - 1) * spacing / 2
480 return {qubit_label: QPointF(start_x + index * spacing, y)
for index, qubit_label
in enumerate(qubits)}
482 def _get_node_cbit_positions(
483 self, node_label: str, center: QPointF, live_labels: set[str]
484 ) -> dict[str, QPointF]:
485 """Return screen positions for classical bits stored at *node_label*."""
486 snap_cbits = self.
_snap.cbits
if self.
_snap else {}
489 for label, cbit
in snap_cbits.items()
490 if cbit.node == node_label
and label
in live_labels
and label
not in self.
inflight_cbits
494 num_cbits = len(cbits)
496 start_x = center.x() - (num_cbits - 1) * spacing / 2
499 return {cbit_label: QPointF(start_x + index * spacing, y)
for index, cbit_label
in enumerate(cbits)}
501 def _draw_node_cbits(self, painter: QPainter, node_label: str, center: QPointF):
502 """Render classical bits stored at *node_label* as small squares."""
504 if not cbit_positions:
507 for _cbit_label, pos
in cbit_positions.items():
508 color = QColor(Theme.CBIT)
509 painter.setPen(Qt.PenStyle.NoPen)
510 painter.setBrush(QColor(15, 23, 42, 40))
511 painter.drawRoundedRect(QRectF(pos.x() - half, pos.y() - half + 1.0, half * 2, half * 2), 2, 2)
512 painter.setPen(QPen(color.darker(118), 1.0))
513 painter.setBrush(QBrush(color))
514 painter.drawRoundedRect(QRectF(pos.x() - half, pos.y() - half, half * 2, half * 2), 2, 2)
516 def _draw_node_qubits(self, painter: QPainter, node_label: str, center: QPointF):
517 """Render qubits stored at *node_label* as small circles."""
519 if not qubit_positions:
521 for qubit_label, pos
in qubit_positions.items():
522 color = Theme.QUBIT_ENTANGLED
if qubit_label
in self.
entangled_qubits else Theme.QUBIT_PRODUCT
523 painter.setPen(Qt.PenStyle.NoPen)
524 painter.setBrush(QColor(15, 23, 42, 45))
526 painter.setPen(QPen(QColor(color).darker(115), 1.0))
527 painter.setBrush(QBrush(color))
530 gate_pen = QPen(QColor(Theme.HALO_GATE), 1.5, Qt.PenStyle.DashLine)
531 painter.setPen(gate_pen)
532 painter.setBrush(Qt.BrushStyle.NoBrush)
535 painter.setPen(QPen(QColor(Theme.HALO_MEASURE), 1.5))
536 painter.setBrush(Qt.BrushStyle.NoBrush)
539 gm_color = QColor(Theme.HALO_GRAPH_MEASURE)
540 painter.setPen(QPen(gm_color, 1.5))
541 painter.setBrush(Qt.BrushStyle.NoBrush)
545 def _draw_inflight_cbits(self, painter: QPainter, positions: dict[str, QPointF]):
546 """Animate in-flight cbits as classical packet diamonds."""
548 t0 = event.get(
"t0_ns", 0)
549 t1 = event.get(
"t1_ns", 0)
552 from_node = event.get(
"from")
553 to_node = event.get(
"to")
554 if from_node
not in positions
or to_node
not in positions:
556 progress = (self.
current_time - t0) / (t1 - t0)
if t1 > t0
else 0
557 start = positions[from_node]
558 end = positions[to_node]
559 dx = end.x() - start.x()
560 dy = end.y() - start.y()
561 length = math.sqrt(dx * dx + dy * dy)
or 1
562 perp_x = -dy / length
565 if sorted([from_node, to_node])[0] == from_node:
566 lane_x, lane_y = -perp_x * 3, -perp_y * 3
568 lane_x, lane_y = perp_x * 3, perp_y * 3
569 x = start.x() + dx * progress + lane_x
570 y = start.y() + dy * progress + lane_y
571 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 1.5))
572 painter.setBrush(QBrush(QColor(Theme.PACKET_CLASSICAL)))
573 painter.drawPath(_diamond_path(x, y, 7))
575 def _get_qubit_positions(self, positions: dict[str, QPointF]) -> dict[str, QPointF]:
576 """Return screen positions for all qubits, including in-flight ones."""
579 for node_label, center
in positions.items():
583 t0 = event.get(
"t0_ns", 0)
584 t1 = event.get(
"t1_ns", 0)
587 qubit_label = event.get(
"bit")
588 from_node = event.get(
"from")
589 to_node = event.get(
"to")
590 if from_node
not in positions
or to_node
not in positions:
592 progress = (self.
current_time - t0) / (t1 - t0)
if t1 > t0
else 0
593 start = positions[from_node]
594 end = positions[to_node]
595 dx = end.x() - start.x()
596 dy = end.y() - start.y()
597 length = math.sqrt(dx * dx + dy * dy)
or 1
598 perp_x = -dy / length
602 if sorted([from_node, to_node])[0] == from_node:
603 lane_x, lane_y = perp_x * 3, perp_y * 3
605 lane_x, lane_y = -perp_x * 3, -perp_y * 3
606 qubit_positions[qubit_label] = QPointF(
607 start.x() + dx * progress + lane_x, start.y() + dy * progress + lane_y
609 return qubit_positions
611 def _draw_inflight_qubits(self, painter: QPainter, qubit_positions: dict[str, QPointF]):
612 """Render in-flight qubits as filled circles on the channel lane.
614 Only qubits currently in ``inflight_qubits`` are drawn. Their
615 color is determined by the qubit's semantic state (product vs.
616 entangled) as defined by the active color palette.
618 @param painter Active ``QPainter``.
619 @param qubit_positions Screen positions returned by
620 ``_get_qubit_positions()``, which places every
621 in-flight qubit on its channel lane.
624 pos = qubit_positions.get(qubit_label)
627 color = Theme.QUBIT_ENTANGLED
if qubit_label
in self.
entangled_qubits else Theme.QUBIT_PRODUCT
628 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 2))
629 painter.setBrush(QBrush(color))
632 def _draw_inflight_packets(self, painter: QPainter, positions: dict[str, QPointF]):
633 """Render classical network packets in flight between nodes.
635 Iterates ``sendPacket`` events and draws a diamond marker at the
636 proportional position along the source-to-destination vector for any
637 packet whose transit window ``[t0_ns, t1_ns)`` contains the current
640 @param painter Active ``QPainter`` for the canvas widget.
641 @param positions Mapping of node label to widget-space centre point.
644 t0 = event.get(
"t0_ns", 0)
645 t1 = event.get(
"t1_ns", 0)
648 from_node = event.get(
"from")
649 to_node = event.get(
"to")
650 label = event.get(
"label",
"")
651 if from_node
not in positions
or to_node
not in positions:
653 progress = (self.
current_time - t0) / (t1 - t0)
if t1 > t0
else 0
654 start = positions[from_node]
655 end = positions[to_node]
656 dx = end.x() - start.x()
657 dy = end.y() - start.y()
658 length = math.sqrt(dx * dx + dy * dy)
or 1
659 perp_x = -dy / length
663 if sorted([from_node, to_node])[0] == from_node:
664 lane_x, lane_y = -perp_x * 3, -perp_y * 3
666 lane_x, lane_y = perp_x * 3, perp_y * 3
667 x = start.x() + dx * progress + lane_x
668 y = start.y() + dy * progress + lane_y
669 color = {
"tcp": Theme.PACKET_TCP,
"udp": Theme.PACKET_UDP}.get(
670 event.get(
"protocol",
"").lower(), Theme.PACKET_CLASSICAL
673 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 1.5))
674 painter.setBrush(QBrush(color))
675 painter.drawPath(_diamond_path(x, y, size))
677 short_label = label[:18] + (
"…" if len(label) > 18
else "")
678 painter.setPen(QPen(Theme.TEXT_SECONDARY))
679 painter.setFont(QFont(ui_font_family(), 8))
680 painter.drawText(QPointF(x + size + 4, y + 4), short_label)
683 """Toggle legend visibility and repaint. Returns the new visible state."""
689 """Toggle node label visibility and repaint. Returns the new visible state."""
694 def _draw_legend(self, painter: QPainter):
695 """Render a grouped color-legend card in the bottom-left corner.
697 Entries are organised into categories separated by hairline spacing.
698 Each row pairs a graphical icon with a text label; the icon style
699 matches what is rendered on the canvas so the legend stays consistent.
706 TEXT_X = ICON_CX + HALO_R + 9
713 (
"link", Theme.ENTANGLEMENT, 2.5,
"Entanglement link"),
716 (
"qubit", Theme.QUBIT_PRODUCT, 0,
"Qubit (product state)"),
717 (
"qubit", Theme.QUBIT_ENTANGLED, 0,
"Qubit (entangled)"),
718 (
"cbit", Theme.CBIT, 0,
"Classical bit"),
719 (
"lost", Theme.QUBIT_LOST, 0,
"Lost qubit"),
720 (
"measured", Theme.QUBIT_MEASURED, 0,
"Measured qubit"),
723 (
"halo_gate", Theme.HALO_GATE, 0,
"Gate (processing)"),
724 (
"halo_measure", Theme.HALO_MEASURE, 0,
"Measuring"),
725 (
"halo_graph", Theme.HALO_GRAPH_MEASURE, 0,
"Graph-measuring"),
728 (
"packet", Theme.PACKET_CLASSICAL, 0,
"Classical packet"),
729 (
"packet", Theme.PACKET_TCP, 0,
"TCP packet"),
730 (
"packet", Theme.PACKET_UDP, 0,
"UDP packet"),
733 entries = [entry
for group
in groups
for entry
in group]
737 _fm = QFontMetrics(QFont(ui_font_family(), FONT_SIZE))
738 hdr_font = QFont(ui_font_family(), FONT_SIZE - 1)
739 hdr_font.setWeight(QFont.Weight.DemiBold)
740 hdr_font.setCapitalization(QFont.Capitalization.AllUppercase)
741 hdr_font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.2)
742 _fm_hdr = QFontMetrics(hdr_font)
744 TEXT_X + max(_fm.horizontalAdvance(text)
for *_, text
in entries) + 16,
745 _fm_hdr.horizontalAdvance(
"LEGEND") + pad * 2 + 4,
747 panel_h = HEADER_H + len(entries) * ROW_H + (len(groups) - 1) * GROUP_GAP + pad
748 if panel_h + 18 > self.height()
or panel_w + 18 > self.width():
751 panel_y = self.height() - panel_h - 10
752 card = QRectF(panel_x, panel_y, panel_w, panel_h)
754 bg = QColor(Theme.BG_MEDIUM)
756 painter.setPen(QPen(QColor(Theme.BORDER), 1))
757 painter.setBrush(QBrush(bg))
758 painter.drawRoundedRect(card, 12, 12)
760 painter.setFont(hdr_font)
761 painter.setPen(QPen(QColor(Theme.TEXT_MUTED)))
762 hdr_y = panel_y + (HEADER_H - _fm_hdr.height()) / 2 + _fm_hdr.ascent()
763 painter.drawText(QPointF(panel_x + pad + 2, hdr_y),
"Legend")
765 painter.setFont(QFont(ui_font_family(), FONT_SIZE))
766 cy = panel_y + HEADER_H + ROW_H * 0.5
767 for gi, group
in enumerate(groups):
770 for kind, color, line_w, text
in group:
772 cx_ = panel_x + ICON_CX
773 if kind
in (
"channel",
"link"):
774 pen = QPen(c, min(line_w, 3.0))
775 pen.setCapStyle(Qt.PenCapStyle.RoundCap)
777 painter.setBrush(Qt.BrushStyle.NoBrush)
778 painter.drawLine(QPointF(panel_x + 6, cy), QPointF(panel_x + TEXT_X - 9, cy))
779 elif kind ==
"qubit":
780 painter.setPen(QPen(QColor(c).darker(115), 1.0))
781 painter.setBrush(QBrush(c))
782 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
785 painter.setPen(QPen(QColor(c).darker(118), 1.0))
786 painter.setBrush(QBrush(c))
787 painter.drawRoundedRect(QRectF(cx_ - half, cy - half, half * 2, half * 2), 2, 2)
791 painter.setPen(Qt.PenStyle.NoPen)
792 painter.setBrush(QBrush(fill))
793 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
795 painter.setPen(QPen(QColor(c).darker(140), 2.0))
796 painter.drawLine(QPointF(cx_ - d, cy - d), QPointF(cx_ + d, cy + d))
797 painter.drawLine(QPointF(cx_ + d, cy - d), QPointF(cx_ - d, cy + d))
798 elif kind ==
"measured":
801 painter.setPen(Qt.PenStyle.NoPen)
802 painter.setBrush(QBrush(fill))
803 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
805 painter.setPen(QPen(QColor(c).darker(120), 2.0))
806 painter.drawLine(QPointF(cx_ - r * 0.45, cy), QPointF(cx_ - r * 0.1, cy + r * 0.45))
807 painter.drawLine(QPointF(cx_ - r * 0.1, cy + r * 0.45), QPointF(cx_ + r * 0.5, cy - r * 0.4))
808 elif kind ==
"halo_gate":
809 painter.setPen(Qt.PenStyle.NoPen)
810 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
811 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
812 painter.setPen(QPen(QColor(c), 1.5, Qt.PenStyle.DashLine))
813 painter.setBrush(Qt.BrushStyle.NoBrush)
814 painter.drawEllipse(QPointF(cx_, cy), HALO_R, HALO_R)
815 elif kind ==
"halo_measure":
816 painter.setPen(Qt.PenStyle.NoPen)
817 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
818 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
819 painter.setPen(QPen(QColor(c), 1.5))
820 painter.setBrush(Qt.BrushStyle.NoBrush)
821 painter.drawEllipse(QPointF(cx_, cy), HALO_R, HALO_R)
822 elif kind ==
"halo_graph":
823 painter.setPen(Qt.PenStyle.NoPen)
824 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
825 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
826 painter.setPen(QPen(QColor(c), 1.5))
827 painter.setBrush(Qt.BrushStyle.NoBrush)
828 painter.drawEllipse(QPointF(cx_, cy), HALO_R - 2, HALO_R - 2)
829 painter.drawEllipse(QPointF(cx_, cy), HALO_R + 1, HALO_R + 1)
830 elif kind ==
"packet":
832 diamond = QPainterPath()
833 diamond.moveTo(cx_, cy - s)
834 diamond.lineTo(cx_ + s, cy)
835 diamond.lineTo(cx_, cy + s)
836 diamond.lineTo(cx_ - s, cy)
837 diamond.closeSubpath()
838 painter.setPen(QPen(QColor(c).darker(118), 1.0))
839 painter.setBrush(QBrush(c))
840 painter.drawPath(diamond)
841 painter.setPen(QPen(Theme.TEXT_SECONDARY))
843 QRectF(panel_x + TEXT_X, cy - ROW_H / 2, panel_w - TEXT_X - 6, ROW_H),
844 Qt.AlignmentFlag.AlignVCenter,
849 def _draw_event_overlay(self, painter: QPainter):
850 """Draw traceText events for the current timestamp in the bottom-right corner.
852 Renders a semi-transparent panel listing every ``traceText`` event
853 whose timestamp exactly matches the current slider position. At most
854 five events are shown; a "… +N more" line is appended when there are
860 current_events = log[lo:hi]
861 if not current_events:
865 font_hdr = QFont(ui_font_family(), 11)
866 font_hdr.setWeight(QFont.Weight.Bold)
867 font_body = QFont(
"Courier New", 11)
868 fm_h = QFontMetrics(font_hdr)
869 fm_b = QFontMetrics(font_body)
870 line_h = fm_b.height() + 3
873 header = f
"@ {t_us:.3f} \u03bcs"
875 body_lines: list[str] = []
876 for ev
in current_events[:MAX_BODY]:
877 node = ev.get(
"node",
"")
878 text = ev.get(
"text",
"")
879 body_lines.append(f
"[{node}] {text}" if node
else text)
880 extra = len(current_events) - MAX_BODY
882 body_lines.append(f
"\u2026 +{extra} more")
885 all_widths = [fm_h.horizontalAdvance(header)] + [fm_b.horizontalAdvance(ln)
for ln
in body_lines]
886 panel_w = max(all_widths) + PAD_X * 2
887 panel_h = PAD_Y + fm_h.height() + len(body_lines) * line_h + PAD_Y
890 px = self.width() - panel_w - margin
891 py = self.height() - panel_h - margin
894 bg = QColor(Theme.BG_MEDIUM)
896 painter.setBrush(QBrush(bg))
897 painter.setPen(QPen(QColor(Theme.BORDER), 1))
898 painter.drawRoundedRect(px, py, panel_w, panel_h, 6, 6)
900 painter.setFont(font_hdr)
901 painter.setPen(QPen(QColor(Theme.PRIMARY)))
902 painter.drawText(px + PAD_X, py + PAD_Y + fm_h.ascent(), header)
904 painter.setFont(font_body)
905 painter.setPen(QPen(QColor(Theme.TEXT_PRIMARY)))
906 y0 = py + PAD_Y + fm_h.height() + 2
907 for i, line
in enumerate(body_lines):
908 painter.drawText(px + PAD_X, y0 + i * line_h + fm_b.ascent(), line)
913 """Repaint the canvas with the currently active color palette.
915 Call after ``toggle_dark()`` to reflect palette changes.