gini-toolkit 6.0.1.dev0__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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Four toolbar pills for GINI: the current MODE, the AI MODEL, the ACTIVITY, and YOU.
|
|
2
|
+
|
|
3
|
+
* Mode pill — which interaction mode is active (Chat / Explain / Wizard / Coach),
|
|
4
|
+
coloured per mode.
|
|
5
|
+
* Model pill — the connected local model's name + status (green = reachable, amber =
|
|
6
|
+
set but not responding, grey = none). Click it to open Settings → LLM.
|
|
7
|
+
* Activity pill — a rotating spinner while the assistant is thinking, else a steady Idle.
|
|
8
|
+
* User pill — your Teaching Center enrolment: who you're signed in as, whether the course
|
|
9
|
+
server is reachable, and **how many assigned missions are still due** (0 is a
|
|
10
|
+
perfectly good answer, and worth showing — "nothing due" is information).
|
|
11
|
+
gBuilder is fully usable signed OUT, so the signed-out state is calm and grey,
|
|
12
|
+
not an error. Click it to sign in (Settings) or to jump to your assigned work.
|
|
13
|
+
|
|
14
|
+
One widget paints them all so the toolbar stays tidy; the Model and User pills are click targets.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QRectF, QSize, Qt, QTimer, Signal
|
|
19
|
+
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
|
20
|
+
from PySide6.QtWidgets import QWidget
|
|
21
|
+
|
|
22
|
+
_GAP = 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModeIndicator(QWidget):
|
|
26
|
+
model_clicked = Signal() # the Model pill was clicked (open Settings → LLM)
|
|
27
|
+
user_clicked = Signal() # the User pill was clicked (sign in, or go to my missions)
|
|
28
|
+
|
|
29
|
+
def __init__(self, theme, parent=None) -> None:
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self.theme = theme
|
|
32
|
+
self._mode = "Chat"
|
|
33
|
+
self._busy = False
|
|
34
|
+
self._angle = 0
|
|
35
|
+
self._model_name = "" # "" = no model connected
|
|
36
|
+
self._model_ok = False # reachable?
|
|
37
|
+
self._student = "" # "" = not enrolled in any course
|
|
38
|
+
self._tc_ok = False # course server reachable?
|
|
39
|
+
self._is_teacher = False # signed in with a teacher role?
|
|
40
|
+
self._due = 0 # assigned missions not yet completed
|
|
41
|
+
self.setFixedHeight(26)
|
|
42
|
+
self.setMouseTracking(True)
|
|
43
|
+
self._timer = QTimer(self)
|
|
44
|
+
self._timer.setInterval(55)
|
|
45
|
+
self._timer.timeout.connect(self._spin)
|
|
46
|
+
|
|
47
|
+
# -- state from the assistant / main window ----------------------------- #
|
|
48
|
+
def set_status(self, label: str, busy: bool) -> None:
|
|
49
|
+
self._mode = (label or "Chat").replace(" mode", "") or "Chat"
|
|
50
|
+
self._busy = busy
|
|
51
|
+
if busy and not self._timer.isActive():
|
|
52
|
+
self._timer.start()
|
|
53
|
+
elif not busy and self._timer.isActive():
|
|
54
|
+
self._timer.stop(); self._angle = 0
|
|
55
|
+
self.updateGeometry(); self.update()
|
|
56
|
+
|
|
57
|
+
def set_model(self, name: str, connected: bool) -> None:
|
|
58
|
+
"""name='' → 'no model'; name + connected → green; name + not connected → amber."""
|
|
59
|
+
self._model_name = name or ""
|
|
60
|
+
self._model_ok = bool(connected)
|
|
61
|
+
self.setToolTip("Local model connected — Wizard, Coach and open-ended Q&A are on."
|
|
62
|
+
if (self._model_name and self._model_ok) else
|
|
63
|
+
f"'{self._model_name}' is set but not responding — click to check Settings."
|
|
64
|
+
if self._model_name else
|
|
65
|
+
"No local model — click to connect one (Settings → LLM). Explain and "
|
|
66
|
+
"build commands still work without it.")
|
|
67
|
+
self.updateGeometry(); self.update()
|
|
68
|
+
|
|
69
|
+
def set_enrolment(self, student: str, online: bool, due: int = 0) -> None:
|
|
70
|
+
"""student='' → signed out (grey, and that is FINE); signed in + reachable → green with the
|
|
71
|
+
count of missions still due; signed in + unreachable → amber. `due < 0` = a TEACHER: no
|
|
72
|
+
assignments-due notion applies, so the pill shows the role instead of a count."""
|
|
73
|
+
self._student = student or ""
|
|
74
|
+
self._tc_ok = bool(online)
|
|
75
|
+
self._is_teacher = int(due) < 0
|
|
76
|
+
self._due = max(0, int(due))
|
|
77
|
+
if not self._student:
|
|
78
|
+
tip = ("Not signed in to a course. gBuilder works fully without one — click to enrol "
|
|
79
|
+
"in a Teaching Center (Settings → Teaching Center).")
|
|
80
|
+
elif self._is_teacher:
|
|
81
|
+
tip = f"Signed in as {self._student} (teacher). Click for teacher tools."
|
|
82
|
+
elif not self._tc_ok:
|
|
83
|
+
tip = (f"Signed in as {self._student}, but the course server isn't reachable. "
|
|
84
|
+
f"Showing your cached assignments; results will sync when it's back.")
|
|
85
|
+
elif self._due:
|
|
86
|
+
tip = (f"Signed in as {self._student} — {self._due} assigned mission"
|
|
87
|
+
f"{'s' if self._due != 1 else ''} still to do. Click to open them.")
|
|
88
|
+
else:
|
|
89
|
+
tip = f"Signed in as {self._student} — nothing due. Click to browse missions."
|
|
90
|
+
self.setToolTip(tip)
|
|
91
|
+
self.updateGeometry(); self.update()
|
|
92
|
+
|
|
93
|
+
def _user_text(self) -> str:
|
|
94
|
+
if not self._student:
|
|
95
|
+
return "sign in"
|
|
96
|
+
if getattr(self, "_is_teacher", False):
|
|
97
|
+
return f"{self._student} · teacher"
|
|
98
|
+
if not self._tc_ok:
|
|
99
|
+
return f"{self._student} · offline"
|
|
100
|
+
return f"{self._student} · {self._due} due" if self._due else f"{self._student} · clear"
|
|
101
|
+
|
|
102
|
+
def _user_color(self) -> QColor:
|
|
103
|
+
t = self.theme.theme
|
|
104
|
+
if not self._student:
|
|
105
|
+
return QColor(t.muted) # signed out is not an error state
|
|
106
|
+
if not self._tc_ok:
|
|
107
|
+
return QColor(t.warning)
|
|
108
|
+
return QColor(t.accent) if self._due else QColor(t.success)
|
|
109
|
+
|
|
110
|
+
def _spin(self) -> None:
|
|
111
|
+
self._angle = (self._angle + 24) % 360
|
|
112
|
+
self.update()
|
|
113
|
+
|
|
114
|
+
# -- geometry ----------------------------------------------------------- #
|
|
115
|
+
def _font(self) -> QFont:
|
|
116
|
+
from .theme.manager import sp
|
|
117
|
+
f = QFont(); f.setPointSize(sp(9)); f.setBold(True)
|
|
118
|
+
return f
|
|
119
|
+
|
|
120
|
+
def _mode_color(self) -> QColor:
|
|
121
|
+
t = self.theme.theme
|
|
122
|
+
return {"Explain": QColor(t.accent), "Wizard": QColor(t.accent2),
|
|
123
|
+
"Coach": QColor(t.accent2), "Tutor": QColor(t.success)}.get(
|
|
124
|
+
self._mode, QColor(t.muted))
|
|
125
|
+
|
|
126
|
+
def _model_color(self) -> QColor:
|
|
127
|
+
t = self.theme.theme
|
|
128
|
+
if not self._model_name:
|
|
129
|
+
return QColor(t.muted)
|
|
130
|
+
return QColor(t.success) if self._model_ok else QColor(t.warning)
|
|
131
|
+
|
|
132
|
+
def _pills(self):
|
|
133
|
+
"""(kind, text, colour, width) for each pill, left to right."""
|
|
134
|
+
fm = QFontMetrics(self._font())
|
|
135
|
+
t = self.theme.theme
|
|
136
|
+
|
|
137
|
+
def w(text):
|
|
138
|
+
return 30 + fm.horizontalAdvance(text)
|
|
139
|
+
|
|
140
|
+
model_txt = self._model_name or "no model"
|
|
141
|
+
act_txt = "Thinking…" if self._busy else "Idle"
|
|
142
|
+
act_col = QColor(t.warning) if self._busy else QColor(t.muted)
|
|
143
|
+
user_txt = self._user_text()
|
|
144
|
+
return [
|
|
145
|
+
("mode", self._mode, self._mode_color(), w(self._mode)),
|
|
146
|
+
("model", model_txt, self._model_color(), w(model_txt)),
|
|
147
|
+
("activity", act_txt, act_col, w(act_txt)),
|
|
148
|
+
("user", user_txt, self._user_color(), w(user_txt)),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def _ranges(self) -> dict[str, tuple[float, float]]:
|
|
152
|
+
"""Each pill's x-span. Derived from the layout, NOT recorded during paint — otherwise the
|
|
153
|
+
click targets don't exist until the widget has been painted once."""
|
|
154
|
+
out: dict[str, tuple[float, float]] = {}
|
|
155
|
+
x = 1.5
|
|
156
|
+
for kind, _t, _c, w in self._pills():
|
|
157
|
+
out[kind] = (x, x + w)
|
|
158
|
+
x += w + _GAP
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def sizeHint(self) -> QSize:
|
|
162
|
+
total = sum(p[3] for p in self._pills()) + _GAP * (len(self._pills()) - 1) + 3
|
|
163
|
+
return QSize(int(total), 26)
|
|
164
|
+
|
|
165
|
+
def minimumSizeHint(self) -> QSize:
|
|
166
|
+
return self.sizeHint()
|
|
167
|
+
|
|
168
|
+
# -- paint -------------------------------------------------------------- #
|
|
169
|
+
def _pill_bg(self, p: QPainter, x: float, w: float, col: QColor) -> None:
|
|
170
|
+
bg = QColor(col); bg.setAlpha(38)
|
|
171
|
+
p.setBrush(bg); p.setPen(QPen(col, 1.4))
|
|
172
|
+
p.drawRoundedRect(QRectF(x, 1.5, w, self.height() - 3), 12, 12)
|
|
173
|
+
|
|
174
|
+
def paintEvent(self, _e) -> None:
|
|
175
|
+
p = QPainter(self)
|
|
176
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
177
|
+
p.setFont(self._font())
|
|
178
|
+
h = self.height()
|
|
179
|
+
x = 1.5
|
|
180
|
+
for kind, text, col, w in self._pills():
|
|
181
|
+
self._pill_bg(p, x, w, col)
|
|
182
|
+
if kind == "activity" and self._busy:
|
|
183
|
+
p.save(); p.translate(x + 13, h / 2.0); p.rotate(self._angle)
|
|
184
|
+
pen = QPen(col, 2.4); pen.setCapStyle(Qt.RoundCap); p.setPen(pen)
|
|
185
|
+
p.drawArc(QRectF(-6, -6, 12, 12), 0, 290 * 16); p.restore()
|
|
186
|
+
elif kind == "user":
|
|
187
|
+
self._person(p, x + 13, h / 2.0, col) # a person, not a status dot
|
|
188
|
+
else:
|
|
189
|
+
p.setBrush(col); p.setPen(Qt.NoPen)
|
|
190
|
+
p.drawEllipse(QRectF(x + 9, h / 2.0 - 4, 8, 8))
|
|
191
|
+
p.setPen(col)
|
|
192
|
+
p.drawText(QRectF(x + 22, 0, w - 20, h), Qt.AlignVCenter | Qt.AlignLeft, text)
|
|
193
|
+
x += w + _GAP
|
|
194
|
+
p.end()
|
|
195
|
+
|
|
196
|
+
def _person(self, p: QPainter, cx: float, cy: float, col: QColor) -> None:
|
|
197
|
+
"""A little head-and-shoulders — the pill reads as YOU at a glance, not as another status
|
|
198
|
+
light. Hollow when signed out; filled once you're enrolled."""
|
|
199
|
+
p.save()
|
|
200
|
+
signed_in = bool(self._student)
|
|
201
|
+
p.setPen(QPen(col, 1.4))
|
|
202
|
+
p.setBrush(col if signed_in else Qt.NoBrush)
|
|
203
|
+
p.drawEllipse(QRectF(cx - 3.2, cy - 6.0, 6.4, 6.4)) # head
|
|
204
|
+
p.drawChord(QRectF(cx - 5.4, cy - 1.4, 10.8, 9.6), 0, 180 * 16) # shoulders
|
|
205
|
+
p.restore()
|
|
206
|
+
|
|
207
|
+
# -- the Model and User pills are click targets -------------------------- #
|
|
208
|
+
def _hit(self, x: float) -> str:
|
|
209
|
+
for kind in ("model", "user"):
|
|
210
|
+
x0, x1 = self._ranges().get(kind, (0.0, 0.0))
|
|
211
|
+
if x0 <= x <= x1:
|
|
212
|
+
return kind
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
def mousePressEvent(self, e) -> None:
|
|
216
|
+
hit = self._hit(e.position().x())
|
|
217
|
+
if hit == "model":
|
|
218
|
+
self.model_clicked.emit()
|
|
219
|
+
elif hit == "user":
|
|
220
|
+
self.user_clicked.emit()
|
|
221
|
+
else:
|
|
222
|
+
super().mousePressEvent(e)
|
|
223
|
+
|
|
224
|
+
def mouseMoveEvent(self, e) -> None:
|
|
225
|
+
self.setCursor(Qt.PointingHandCursor if self._hit(e.position().x())
|
|
226
|
+
else Qt.ArrowCursor)
|
|
227
|
+
super().mouseMoveEvent(e)
|
gini/ui/palette.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Searchable, categorized device palette. Items drag onto the canvas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from PySide6.QtCore import QMimeData, QSize, Qt, Signal
|
|
5
|
+
from PySide6.QtGui import QDrag
|
|
6
|
+
from PySide6.QtWidgets import (
|
|
7
|
+
QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from ..domain import by_category
|
|
11
|
+
from .canvas import MIME
|
|
12
|
+
from .theme import ThemeManager, icons
|
|
13
|
+
|
|
14
|
+
KEY_ROLE = Qt.UserRole + 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Tree(QTreeWidget):
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.setHeaderHidden(True)
|
|
21
|
+
self.setIndentation(10)
|
|
22
|
+
self.setRootIsDecorated(True)
|
|
23
|
+
self.setDragEnabled(True)
|
|
24
|
+
self.setIconSize(QSize(22, 22))
|
|
25
|
+
self.setExpandsOnDoubleClick(False)
|
|
26
|
+
|
|
27
|
+
def startDrag(self, actions) -> None:
|
|
28
|
+
item = self.currentItem()
|
|
29
|
+
if item is None:
|
|
30
|
+
return
|
|
31
|
+
key = item.data(0, KEY_ROLE)
|
|
32
|
+
if not key:
|
|
33
|
+
return
|
|
34
|
+
mime = QMimeData()
|
|
35
|
+
mime.setData(MIME, key.encode())
|
|
36
|
+
drag = QDrag(self)
|
|
37
|
+
drag.setMimeData(mime)
|
|
38
|
+
ic = item.icon(0)
|
|
39
|
+
if not ic.isNull():
|
|
40
|
+
drag.setPixmap(ic.pixmap(28, 28))
|
|
41
|
+
drag.exec(Qt.CopyAction)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Palette(QWidget):
|
|
45
|
+
element_selected = Signal(str) # type_key clicked (for explain mode)
|
|
46
|
+
|
|
47
|
+
def __init__(self, theme: ThemeManager) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.setObjectName("Sidebar")
|
|
50
|
+
self.theme = theme
|
|
51
|
+
lay = QVBoxLayout(self)
|
|
52
|
+
lay.setContentsMargins(10, 12, 10, 10)
|
|
53
|
+
lay.setSpacing(8)
|
|
54
|
+
|
|
55
|
+
self.search = QLineEdit()
|
|
56
|
+
self.search.setPlaceholderText("Search devices…")
|
|
57
|
+
self.search.addAction(
|
|
58
|
+
icons.icon("search", theme.theme.faint, 16),
|
|
59
|
+
QLineEdit.LeadingPosition,
|
|
60
|
+
)
|
|
61
|
+
self.search.textChanged.connect(self._filter)
|
|
62
|
+
lay.addWidget(self.search)
|
|
63
|
+
|
|
64
|
+
self.tree = _Tree()
|
|
65
|
+
self.tree.itemClicked.connect(self._on_item_clicked)
|
|
66
|
+
lay.addWidget(self.tree, 1)
|
|
67
|
+
self._populate()
|
|
68
|
+
theme.themeChanged.connect(self._restyle)
|
|
69
|
+
|
|
70
|
+
def _on_item_clicked(self, item, _col) -> None:
|
|
71
|
+
key = item.data(0, KEY_ROLE)
|
|
72
|
+
if key:
|
|
73
|
+
self.element_selected.emit(key)
|
|
74
|
+
|
|
75
|
+
def _populate(self) -> None:
|
|
76
|
+
self.tree.clear()
|
|
77
|
+
t = self.theme.theme
|
|
78
|
+
for cat, items in by_category().items():
|
|
79
|
+
top = QTreeWidgetItem([cat.value])
|
|
80
|
+
top.setFlags(Qt.ItemIsEnabled)
|
|
81
|
+
from .theme.manager import sp
|
|
82
|
+
f = top.font(0); f.setPointSize(sp(10)); f.setBold(True); top.setFont(0, f)
|
|
83
|
+
self.tree.addTopLevelItem(top)
|
|
84
|
+
top.setExpanded(True)
|
|
85
|
+
for d in items:
|
|
86
|
+
child = QTreeWidgetItem([d.label])
|
|
87
|
+
child.setData(0, KEY_ROLE, d.key)
|
|
88
|
+
child.setIcon(0, icons.icon(d.icon, t.accent_for(d.accent.value), 22))
|
|
89
|
+
child.setToolTip(0, d.description)
|
|
90
|
+
top.addChild(child)
|
|
91
|
+
|
|
92
|
+
def _filter(self, text: str) -> None:
|
|
93
|
+
text = text.strip().lower()
|
|
94
|
+
for i in range(self.tree.topLevelItemCount()):
|
|
95
|
+
top = self.tree.topLevelItem(i)
|
|
96
|
+
visible_children = 0
|
|
97
|
+
for j in range(top.childCount()):
|
|
98
|
+
ch = top.child(j)
|
|
99
|
+
show = text in ch.text(0).lower() or text in (ch.toolTip(0) or "").lower()
|
|
100
|
+
ch.setHidden(bool(text) and not show)
|
|
101
|
+
visible_children += 0 if ch.isHidden() else 1
|
|
102
|
+
top.setHidden(bool(text) and visible_children == 0)
|
|
103
|
+
if text:
|
|
104
|
+
top.setExpanded(True)
|
|
105
|
+
|
|
106
|
+
def _restyle(self, _name: str) -> None:
|
|
107
|
+
self._populate()
|
|
108
|
+
self.search.actions() and self.search.removeAction(self.search.actions()[0])
|
|
109
|
+
self.search.addAction(
|
|
110
|
+
icons.icon("search", self.theme.theme.faint, 16),
|
|
111
|
+
QLineEdit.LeadingPosition,
|
|
112
|
+
)
|
gini/ui/peripherals.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""xv6 Terminal — the console as a single software peripheral you wire on the canvas.
|
|
2
|
+
|
|
3
|
+
xv6 has no networking, so instead of switches/routers you attach peripherals to the Machine.
|
|
4
|
+
The **Terminal** is the console: one shell view (a screen and keyboard in one, like a real tty),
|
|
5
|
+
because xv6's console is a single bidirectional UART. Double-clicking a Terminal on the canvas
|
|
6
|
+
opens this view, bound to its linked xv6 Machine's live provider.
|
|
7
|
+
|
|
8
|
+
It talks to the same provider the Machine Lab uses. Output STREAMS in append-only via
|
|
9
|
+
`console_since(cursor)` (so kernel output interleaves with your echoed input exactly like a real
|
|
10
|
+
terminal); input goes out via `send_input()`. A few conveniences are handled terminal-side — the
|
|
11
|
+
kind of thing a real terminal + readline do for you, not the shell: up/down-arrow command history,
|
|
12
|
+
and the built-ins `help`, `clear`, `history`, and `ps` (xv6 has no `ps` program — it's Ctrl-P,
|
|
13
|
+
which we surface under a friendly name). Everything else is passed straight to xv6's real `sh`.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import Qt, QTimer, Signal
|
|
18
|
+
from PySide6.QtWidgets import (
|
|
19
|
+
QDialog, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .theme import ThemeManager
|
|
23
|
+
from .theme.manager import scale_css as _scss
|
|
24
|
+
|
|
25
|
+
# terminal-side built-ins (handled here, not sent to xv6 sh) and the common real xv6 programs the
|
|
26
|
+
# `help` text advertises — so students know what they can actually run.
|
|
27
|
+
_BUILTINS = ("help", "clear", "history", "ps")
|
|
28
|
+
_XV6_PROGRAMS = ("ls", "cat", "echo", "grep", "wc", "mkdir", "rm", "ln", "kill",
|
|
29
|
+
"spin [secs]", "busy [secs]", "alloc", "writer", "forktest", "grind", "usertests")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TerminalView(QDialog):
|
|
33
|
+
"""A bash-like console for an xv6 Machine: streaming output + a prompt line with history."""
|
|
34
|
+
|
|
35
|
+
delta_ready = Signal(str, int) # (new console text, new cursor)
|
|
36
|
+
|
|
37
|
+
def __init__(self, parent, theme: ThemeManager, provider, device=None) -> None:
|
|
38
|
+
super().__init__(parent)
|
|
39
|
+
self.theme = theme
|
|
40
|
+
self.provider = provider
|
|
41
|
+
self._cursor = 0 # console byte offset we've displayed up to
|
|
42
|
+
self._fetching = False # one console read in flight — never overlap them
|
|
43
|
+
self._history: list[str] = []
|
|
44
|
+
self._hist_idx = 0 # points one past the last entry (i.e. "new line")
|
|
45
|
+
t = theme.theme
|
|
46
|
+
self.setWindowTitle(f"Terminal — {getattr(device, 'name', 'xv6')}")
|
|
47
|
+
self.resize(760, 480)
|
|
48
|
+
self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
|
|
49
|
+
v = QVBoxLayout(self)
|
|
50
|
+
v.setSpacing(6)
|
|
51
|
+
self.view = QPlainTextEdit()
|
|
52
|
+
self.view.setReadOnly(True)
|
|
53
|
+
self.view.setStyleSheet(
|
|
54
|
+
f"QPlainTextEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
|
|
55
|
+
"border-radius:6px;font-family:monospace;font-size:12px;}")
|
|
56
|
+
v.addWidget(self.view, 1)
|
|
57
|
+
# prompt row: a "$" marker + the input line, styled as one terminal strip
|
|
58
|
+
row = QHBoxLayout()
|
|
59
|
+
row.setSpacing(6)
|
|
60
|
+
prompt = QLabel("$")
|
|
61
|
+
prompt.setStyleSheet(_scss(f"color:{t.accent};font-family:monospace;font-size:13px;"))
|
|
62
|
+
row.addWidget(prompt)
|
|
63
|
+
self.input = QLineEdit()
|
|
64
|
+
self.input.setPlaceholderText("type a command — e.g. spin 10 & · ls · help")
|
|
65
|
+
self.input.setStyleSheet(
|
|
66
|
+
f"QLineEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
|
|
67
|
+
"border-radius:6px;padding:8px;font-family:monospace;font-size:13px;}")
|
|
68
|
+
self.input.returnPressed.connect(self._submit)
|
|
69
|
+
self.input.installEventFilter(self) # up/down = history
|
|
70
|
+
row.addWidget(self.input, 1)
|
|
71
|
+
# xv6 has no Ctrl-C / SIGINT, so a foreground program (e.g. `spin` with no &) blocks the
|
|
72
|
+
# shell. Break kills the most-recent user process via the kernel's console handler, so the
|
|
73
|
+
# prompt comes back even though sh isn't reading input.
|
|
74
|
+
brk = QPushButton("Break ⌃C")
|
|
75
|
+
brk.setToolTip("Interrupt a hung foreground command (xv6 has no Ctrl-C — this kills the "
|
|
76
|
+
"most-recently started program so the shell prompt returns)")
|
|
77
|
+
# CRITICAL: a dialog push button is auto-default, so Enter in the input line would ALSO
|
|
78
|
+
# click it — every submitted command would fire a Break and kill a process. Opt out so
|
|
79
|
+
# Enter only submits.
|
|
80
|
+
brk.setAutoDefault(False)
|
|
81
|
+
brk.setDefault(False)
|
|
82
|
+
brk.setStyleSheet(
|
|
83
|
+
f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
|
|
84
|
+
f"border-radius:6px;padding:6px 12px;font-family:monospace;}}"
|
|
85
|
+
f"QPushButton:hover{{border-color:{t.accent};}}")
|
|
86
|
+
brk.clicked.connect(self._break)
|
|
87
|
+
self._break_btn = brk
|
|
88
|
+
row.addWidget(brk)
|
|
89
|
+
v.addLayout(row)
|
|
90
|
+
hint = QLabel("Programs take arguments (spin 10 & runs 10s in the background); ↑ recalls "
|
|
91
|
+
"history; type help to see what you can run.")
|
|
92
|
+
hint.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
|
|
93
|
+
v.addWidget(hint)
|
|
94
|
+
self._streaming = hasattr(provider, "console_since")
|
|
95
|
+
self.delta_ready.connect(self._append_stream)
|
|
96
|
+
self._poll = QTimer(self)
|
|
97
|
+
self._poll.timeout.connect(self._refresh)
|
|
98
|
+
self._poll.start(350)
|
|
99
|
+
self.input.setFocus()
|
|
100
|
+
self._append("Connected. Type help to get started.\n")
|
|
101
|
+
self._refresh()
|
|
102
|
+
|
|
103
|
+
# -- output --------------------------------------------------------------- #
|
|
104
|
+
def _bg(self, fn):
|
|
105
|
+
import threading
|
|
106
|
+
threading.Thread(target=fn, daemon=True).start()
|
|
107
|
+
|
|
108
|
+
def _refresh(self):
|
|
109
|
+
# Never overlap reads: a slow agent + a fixed timer would stack background reads that all
|
|
110
|
+
# fetch from the SAME cursor, duplicating output (and echoed commands). One in flight only.
|
|
111
|
+
if self._fetching:
|
|
112
|
+
return
|
|
113
|
+
self._fetching = True
|
|
114
|
+
cur = self._cursor
|
|
115
|
+
def work():
|
|
116
|
+
try:
|
|
117
|
+
if self._streaming:
|
|
118
|
+
text, nxt = self.provider.console_since(cur)
|
|
119
|
+
else: # provider without streaming -> full tail
|
|
120
|
+
text, nxt = "\x00" + (self.provider.console() or ""), cur
|
|
121
|
+
except Exception:
|
|
122
|
+
text, nxt = "", cur # a failed read: skip, keep what we have
|
|
123
|
+
self.delta_ready.emit(text, nxt)
|
|
124
|
+
self._bg(work)
|
|
125
|
+
|
|
126
|
+
def _append_stream(self, text: str, nxt: int):
|
|
127
|
+
self._fetching = False # read done — the next tick may fetch again
|
|
128
|
+
self._cursor = nxt # advance the cursor on the GUI thread (safe)
|
|
129
|
+
if text.startswith("\x00"): # non-streaming fallback: replace the pane
|
|
130
|
+
body = text[1:]
|
|
131
|
+
if body and body != self.view.toPlainText():
|
|
132
|
+
self._set_text(body)
|
|
133
|
+
return
|
|
134
|
+
if text:
|
|
135
|
+
self._append(text)
|
|
136
|
+
|
|
137
|
+
def _append(self, text: str):
|
|
138
|
+
sb = self.view.verticalScrollBar()
|
|
139
|
+
at_bottom = sb.value() >= sb.maximum() - 4
|
|
140
|
+
cur = self.view.textCursor()
|
|
141
|
+
cur.movePosition(cur.MoveOperation.End)
|
|
142
|
+
cur.insertText(text)
|
|
143
|
+
if at_bottom:
|
|
144
|
+
sb.setValue(sb.maximum())
|
|
145
|
+
|
|
146
|
+
def _set_text(self, text: str):
|
|
147
|
+
sb = self.view.verticalScrollBar()
|
|
148
|
+
at_bottom = sb.value() >= sb.maximum() - 4
|
|
149
|
+
self.view.setPlainText(text)
|
|
150
|
+
if at_bottom:
|
|
151
|
+
sb.setValue(sb.maximum())
|
|
152
|
+
|
|
153
|
+
# -- input ---------------------------------------------------------------- #
|
|
154
|
+
def _submit(self):
|
|
155
|
+
cmd = self.input.text().strip()
|
|
156
|
+
self.input.clear()
|
|
157
|
+
if cmd:
|
|
158
|
+
self._history.append(cmd)
|
|
159
|
+
self._hist_idx = len(self._history)
|
|
160
|
+
if cmd in _BUILTINS:
|
|
161
|
+
self._builtin(cmd) # handled terminal-side, echoed locally
|
|
162
|
+
return
|
|
163
|
+
# a real xv6 command: xv6's console echoes it back, so it appears in the stream itself
|
|
164
|
+
self._bg(lambda: self.provider.send_input(cmd + "\n"))
|
|
165
|
+
|
|
166
|
+
def _builtin(self, cmd: str):
|
|
167
|
+
self._append(f"$ {cmd}\n") # local echo (xv6 never sees these)
|
|
168
|
+
if cmd == "help":
|
|
169
|
+
self._append(self._help_text())
|
|
170
|
+
elif cmd == "clear":
|
|
171
|
+
if hasattr(self.provider, "clear_console"):
|
|
172
|
+
self._bg(self.provider.clear_console)
|
|
173
|
+
self.view.clear()
|
|
174
|
+
elif cmd == "history":
|
|
175
|
+
for i, h in enumerate(self._history, 1):
|
|
176
|
+
self._append(f"{i:>4} {h}\n")
|
|
177
|
+
elif cmd == "ps":
|
|
178
|
+
# xv6 has no `ps` program — Ctrl-P triggers the kernel's native procdump, which prints
|
|
179
|
+
# to the console and streams back in like any other output.
|
|
180
|
+
self._append("(xv6 process table — Ctrl-P)\n")
|
|
181
|
+
self._bg(lambda: self.provider.send_input("\x10"))
|
|
182
|
+
|
|
183
|
+
def _break(self):
|
|
184
|
+
# send the kernel's break control-char (Ctrl-C). The console driver handles it directly —
|
|
185
|
+
# NOT sh — so it works even while a foreground program has the shell blocked in wait().
|
|
186
|
+
self._append("^C\n")
|
|
187
|
+
if hasattr(self.provider, "interrupt"):
|
|
188
|
+
self._bg(self.provider.interrupt)
|
|
189
|
+
else: # older provider: fall back to raw Ctrl-C
|
|
190
|
+
self._bg(lambda: self.provider.send_input("\x03"))
|
|
191
|
+
|
|
192
|
+
def _help_text(self) -> str:
|
|
193
|
+
return (
|
|
194
|
+
"terminal built-ins: " + ", ".join(_BUILTINS) + "\n"
|
|
195
|
+
"xv6 programs: " + ", ".join(_XV6_PROGRAMS) + "\n"
|
|
196
|
+
"tips: add & to run in the background (spin 10 &); ↑/↓ recall history; "
|
|
197
|
+
"programs take arguments.\n")
|
|
198
|
+
|
|
199
|
+
def eventFilter(self, obj, ev): # noqa: N802
|
|
200
|
+
if obj is self.input and ev.type() == ev.Type.KeyPress:
|
|
201
|
+
if ev.key() == Qt.Key_Up:
|
|
202
|
+
self._recall(-1)
|
|
203
|
+
return True
|
|
204
|
+
if ev.key() == Qt.Key_Down:
|
|
205
|
+
self._recall(+1)
|
|
206
|
+
return True
|
|
207
|
+
return super().eventFilter(obj, ev)
|
|
208
|
+
|
|
209
|
+
def _recall(self, step: int):
|
|
210
|
+
if not self._history:
|
|
211
|
+
return
|
|
212
|
+
self._hist_idx = max(0, min(len(self._history), self._hist_idx + step))
|
|
213
|
+
self.input.setText(self._history[self._hist_idx] if self._hist_idx < len(self._history)
|
|
214
|
+
else "")
|
|
215
|
+
|
|
216
|
+
def closeEvent(self, e): # noqa: N802
|
|
217
|
+
self._poll.stop()
|
|
218
|
+
super().closeEvent(e)
|