karcytics-sdk 2.2.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {karcytics_sdk-2.2.0/src/karcytics_sdk.egg-info → karcytics_sdk-2.4.0}/PKG-INFO +1 -1
  2. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/pyproject.toml +1 -1
  3. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/academy.py +67 -1
  4. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/academy_driver.py +66 -5
  5. karcytics_sdk-2.4.0/src/karcytics_sdk/plugin/question_panel.py +226 -0
  6. karcytics_sdk-2.4.0/src/karcytics_sdk/plugin/tutorial_highlight.py +84 -0
  7. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/tutorial_models.py +87 -7
  8. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/tutorial_overlay.py +69 -0
  9. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0/src/karcytics_sdk.egg-info}/PKG-INFO +1 -1
  10. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk.egg-info/SOURCES.txt +2 -0
  11. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/LICENSE +0 -0
  12. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/README.md +0 -0
  13. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/setup.cfg +0 -0
  14. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/__init__.py +0 -0
  15. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/__init__.py +0 -0
  16. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/commands/__init__.py +0 -0
  17. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/commands/diagnostics.py +0 -0
  18. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/commands/migrate.py +0 -0
  19. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/commands/scaffold.py +0 -0
  20. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/commands/security.py +0 -0
  21. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/cli/main.py +0 -0
  22. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/contrib/__init__.py +0 -0
  23. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/contrib/image_utils.py +0 -0
  24. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/__init__.py +0 -0
  25. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/ai.py +0 -0
  26. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/core_services.py +0 -0
  27. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/docs.py +0 -0
  28. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/marketplace_cache.py +0 -0
  29. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/module_status_widget.py +0 -0
  30. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/qt_bridge.py +0 -0
  31. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/sign_plugin.py +0 -0
  32. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/trust_manager.py +0 -0
  33. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/trust_overrides.py +0 -0
  34. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/trust_path.py +0 -0
  35. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/host/trust_storage.py +0 -0
  36. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/interfaces/__init__.py +0 -0
  37. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/interfaces/i_crash_reporter.py +0 -0
  38. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/interfaces/i_event_bus.py +0 -0
  39. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/interfaces/i_logger.py +0 -0
  40. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/interfaces/i_task_scheduler.py +0 -0
  41. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/__init__.py +0 -0
  42. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/academy_window.py +0 -0
  43. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/analysis.py +0 -0
  44. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/autosave.py +0 -0
  45. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/base.py +0 -0
  46. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/components.py +0 -0
  47. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/context.py +0 -0
  48. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/course_complete_overlay.py +0 -0
  49. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/cyto_character.py +0 -0
  50. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/cyto_costumes.py +0 -0
  51. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/daemon.py +0 -0
  52. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/dialogs.py +0 -0
  53. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/draggable.py +0 -0
  54. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/effects.py +0 -0
  55. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/events.py +0 -0
  56. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/galactic_loader.py +0 -0
  57. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/galactic_loader.qml +0 -0
  58. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/history.py +0 -0
  59. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/interfaces.py +0 -0
  60. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/io.py +0 -0
  61. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/logging.py +0 -0
  62. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/managed_task.py +0 -0
  63. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/manifest.py +0 -0
  64. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/manifest_parser.py +0 -0
  65. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/menu_builder.py +0 -0
  66. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/preferences.py +0 -0
  67. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/rendering/__init__.py +0 -0
  68. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/rendering/graphics_scene.py +0 -0
  69. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/rendering/lock.py +0 -0
  70. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/rendering/mpl_canvas.py +0 -0
  71. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/rendering/pipeline.py +0 -0
  72. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/ribbon.py +0 -0
  73. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/runtime_services.py +0 -0
  74. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/security_parser.py +0 -0
  75. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/signals.py +0 -0
  76. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/state.py +0 -0
  77. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/theme_fallback.py +0 -0
  78. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/toast.py +0 -0
  79. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/ui_daemon_runtime.py +0 -0
  80. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/ui_preferences.py +0 -0
  81. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/update_check.py +0 -0
  82. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/validation.py +0 -0
  83. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/wizard.py +0 -0
  84. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/worker_thread.py +0 -0
  85. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/plugin/workflow.py +0 -0
  86. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/py.typed +0 -0
  87. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/sdk_cli.py +0 -0
  88. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/testing/__init__.py +0 -0
  89. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk/testing/contract.py +0 -0
  90. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk.egg-info/dependency_links.txt +0 -0
  91. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk.egg-info/entry_points.txt +0 -0
  92. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk.egg-info/requires.txt +0 -0
  93. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/src/karcytics_sdk.egg-info/top_level.txt +0 -0
  94. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_host_ai.py +0 -0
  95. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_host_security.py +0 -0
  96. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_image_utils.py +0 -0
  97. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_marketplace_cache.py +0 -0
  98. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_namespaces.py +0 -0
  99. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_plugin_architecture.py +0 -0
  100. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_plugin_ribbon.py +0 -0
  101. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_plugin_state.py +0 -0
  102. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_plugin_ui.py +0 -0
  103. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_sdk_cli.py +0 -0
  104. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_sign_plugin.py +0 -0
  105. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_testing_contract.py +0 -0
  106. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_theme_fallback.py +0 -0
  107. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_ui_daemon_runtime.py +0 -0
  108. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_workflow_context.py +0 -0
  109. {karcytics_sdk-2.2.0 → karcytics_sdk-2.4.0}/tests/test_workflow_dialogs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karcytics-sdk
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Software Development Kit and CLI for Karcytics.
5
5
  Author: Kalaimaran Balasothy
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "karcytics-sdk"
7
- version = "2.2.0"
7
+ version = "2.4.0"
8
8
  description = "Software Development Kit and CLI for Karcytics."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -21,16 +21,27 @@ string directly as its topic key.
21
21
  from __future__ import annotations
22
22
 
23
23
  import logging
24
+ import re
24
25
  from collections.abc import Callable
25
26
  from datetime import UTC, datetime
26
27
  from pathlib import Path
27
28
  from typing import Any, Protocol, runtime_checkable
28
29
 
29
30
  from .io import load_json, save_json
30
- from .tutorial_models import BaseStep, Course, ForcedInteractionStep, WaitForEventStep
31
+ from .tutorial_models import (
32
+ QUESTION_CHECK,
33
+ BaseStep,
34
+ Course,
35
+ ForcedInteractionStep,
36
+ QuestionStep,
37
+ WaitForEventStep,
38
+ )
31
39
 
32
40
  logger = logging.getLogger(__name__)
33
41
 
42
+ # `{answer:<question_id>}` in a step's text → the learner's recorded answer.
43
+ _ANSWER_PLACEHOLDER = re.compile(r"\{answer:([A-Za-z0-9_.-]+)\}")
44
+
34
45
  # Canonical topic names. These match the Hub's `KarcyticsEvent` enum member
35
46
  # names exactly (see `karcytics.core.event_bus`) so a Hub-side event bus
36
47
  # adapter can resolve them with a plain `KarcyticsEvent[topic]` lookup.
@@ -90,6 +101,13 @@ class AcademyManager:
90
101
  self.completed_courses: list[str] = []
91
102
  self.badges: list[dict[str, Any]] = []
92
103
  self.prerequisites_met: dict[str, str] = {} # course_id -> workflow_hash
104
+ # course_id -> question_id -> {"choices": [...], "attempts": n, "correct": bool}.
105
+ # Local only (progress.json); lets a later step — even in a later
106
+ # course — reveal a prediction the learner made.
107
+ self.answers: dict[str, dict[str, dict[str, Any]]] = {}
108
+ # Whether the live QuestionStep has been answered (correctly, for a
109
+ # "check" question); next_step() won't leave it otherwise.
110
+ self._question_answered = False
93
111
 
94
112
  # State tracking
95
113
  self.active_course: Course | None = None
@@ -121,6 +139,7 @@ class AcademyManager:
121
139
  self.completed_courses = data.get("completed_courses", [])
122
140
  self.badges = data.get("badges", [])
123
141
  self.prerequisites_met = data.get("prerequisites_met", {})
142
+ self.answers = data.get("answers", {})
124
143
 
125
144
  def _save_progress(self) -> None:
126
145
  """Saves current progress to disk."""
@@ -128,6 +147,7 @@ class AcademyManager:
128
147
  "completed_courses": self.completed_courses,
129
148
  "badges": self.badges,
130
149
  "prerequisites_met": self.prerequisites_met,
150
+ "answers": self.answers,
131
151
  }
132
152
  try:
133
153
  save_json(str(self.progress_file), data)
@@ -167,6 +187,7 @@ class AcademyManager:
167
187
 
168
188
  if course.steps:
169
189
  self.current_step = course.steps[0]
190
+ self._question_answered = False
170
191
  self.active_subtask_progress = {}
171
192
  # Subscribe immediately if the first step is a WaitForEventStep
172
193
  if isinstance(self.current_step, WaitForEventStep):
@@ -202,6 +223,10 @@ class AcademyManager:
202
223
  logger.warning("Cannot advance: not all sub-tasks completed.")
203
224
  return
204
225
 
226
+ if isinstance(self.current_step, QuestionStep) and not self._question_answered:
227
+ logger.warning("Cannot advance: question not answered yet.")
228
+ return
229
+
205
230
  if isinstance(self.current_step, WaitForEventStep) and not _internal_force and specific_step_id is None:
206
231
  logger.warning(f"Cannot advance: waiting for event {self.current_step.event_name}")
207
232
  return
@@ -222,6 +247,7 @@ class AcademyManager:
222
247
  self.abandon_course()
223
248
  return
224
249
 
250
+ self._question_answered = False
225
251
  if next_id and next_id != "__complete__":
226
252
  self.current_step = self.active_course.get_step(next_id) if self.active_course else None
227
253
  if self.current_step:
@@ -237,6 +263,46 @@ class AcademyManager:
237
263
  self.current_step = None
238
264
  self._emit_step_changed()
239
265
 
266
+ # ── Questions ──────────────────────────────────────────────────────────
267
+
268
+ def record_answer(self, selected: list[int], attempts: int) -> bool:
269
+ """Records the learner's answer to the live `QuestionStep`.
270
+
271
+ Returns whether it answers the question (any selection answers a
272
+ prediction). Only a correct answer unlocks `next_step()`; every
273
+ answer is saved, so attempts count how many tries it took.
274
+ """
275
+ step = self.current_step
276
+ if not self.active_course or not isinstance(step, QuestionStep) or self.is_reviewing:
277
+ return False
278
+ correct = step.is_correct(set(selected))
279
+ if correct:
280
+ self._question_answered = True
281
+ if step.question_id:
282
+ self.answers.setdefault(self.active_course.id, {})[step.question_id] = {
283
+ "choices": [step.choices[i].text for i in sorted(selected)],
284
+ "attempts": attempts,
285
+ "correct": correct if step.kind == QUESTION_CHECK else None,
286
+ }
287
+ self._save_progress()
288
+ return correct
289
+
290
+ def recorded_answer(self, question_id: str) -> str | None:
291
+ """The learner's recorded answer to `question_id`, from any course."""
292
+ for per_course in self.answers.values():
293
+ entry = per_course.get(question_id)
294
+ if entry and entry.get("choices"):
295
+ return " / ".join(entry["choices"])
296
+ return None
297
+
298
+ def format_step_text(self, text: str) -> str:
299
+ """Replaces each `{answer:<question_id>}` with the recorded answer.
300
+
301
+ An unanswered placeholder becomes "(not answered)" rather than
302
+ leaking the raw placeholder into the bubble.
303
+ """
304
+ return _ANSWER_PLACEHOLDER.sub(lambda m: self.recorded_answer(m.group(1)) or "(not answered)", text)
305
+
240
306
  def complete_subtask(self, subtask_id: str) -> None:
241
307
  """Marks a sub-task as complete for the current step."""
242
308
  if not self.active_course or not isinstance(self.current_step, ForcedInteractionStep):
@@ -24,6 +24,7 @@ from PyQt6.QtCore import QObject, QRect, QTimer
24
24
  from PyQt6.QtWidgets import QApplication, QScrollArea, QWidget
25
25
 
26
26
  from .academy import AcademyManager
27
+ from .tutorial_highlight import TutorialHighlight
27
28
  from .tutorial_models import (
28
29
  ActionStep,
29
30
  BranchingStep,
@@ -86,6 +87,10 @@ class AcademyStepDriver(QObject):
86
87
  self._failure_hint_shown = False
87
88
  self._was_reviewing = False
88
89
  self._last_canvas_guide_active = False
90
+ # In-window spotlights for targets in other windows (see _spotlight_rects).
91
+ self._window_highlights: dict[QWidget, TutorialHighlight] = {}
92
+ # Step whose targets were last scrolled into view (see _update_targets).
93
+ self._revealed_step_id: str | None = None
89
94
 
90
95
  self._timer = QTimer(self)
91
96
  self._timer.timeout.connect(self._tick)
@@ -468,11 +473,16 @@ class AcademyStepDriver(QObject):
468
473
  if w and w.isVisible():
469
474
  targets.append(w)
470
475
 
471
- rects = []
472
- for w in targets:
473
- global_pos = w.mapToGlobal(w.rect().topLeft())
474
- local_pos = self._overlay.mapFromGlobal(global_pos)
475
- rects.append(QRect(local_pos, w.size()))
476
+ # A step's first tick with targets: scroll them into view. Otherwise one
477
+ # scrolled out of a sidebar is spotlit off-screen, Cyto and the bubble
478
+ # follow it there, and the learner sees only the dim. Once per step,
479
+ # so they can still scroll away afterwards. (Not before the targets
480
+ # exist: a popup the step points into may open a few ticks later.)
481
+ if targets and getattr(step, "id", None) != self._revealed_step_id:
482
+ self._revealed_step_id = getattr(step, "id", None)
483
+ _scroll_into_view(targets)
484
+
485
+ rects = self._spotlight_rects(targets)
476
486
  rects.extend(self._collect_canvas_target_rects(step))
477
487
  self._overlay.set_targets(rects)
478
488
 
@@ -488,6 +498,47 @@ class AcademyStepDriver(QObject):
488
498
  scroll_target = scroll_area.viewport()
489
499
  self._overlay.set_scroll_target(scroll_target)
490
500
 
501
+ def _spotlight_rects(self, targets: list[QWidget]) -> list[QRect]:
502
+ """Overlay rects for `targets`; targets in other windows are framed there.
503
+
504
+ A target in the overlay's own window gets a spotlight hole as usual.
505
+ One inside another window — a dialog or popup, even one parented to
506
+ the panel (so `findChildren()` finds it) — would otherwise be
507
+ spotlit on the main window *underneath* that window. Those are framed
508
+ inside their own window by a `TutorialHighlight` instead, and that
509
+ window's frame becomes the overlay rect, so Cyto and the bubble steer
510
+ clear of the whole window rather than sitting behind it.
511
+ """
512
+ from PyQt6 import sip
513
+
514
+ # Forget windows that have since been destroyed (a closed dialog).
515
+ self._window_highlights = {w: h for w, h in self._window_highlights.items() if not sip.isdeleted(w)}
516
+ own_window = self._overlay.window()
517
+ rects: list[QRect] = []
518
+ elsewhere: dict[QWidget, list[QWidget]] = {}
519
+ for w in targets:
520
+ window = w.window()
521
+ if window is None or window is own_window:
522
+ local_pos = self._overlay.mapFromGlobal(w.mapToGlobal(w.rect().topLeft()))
523
+ rects.append(QRect(local_pos, w.size()))
524
+ else:
525
+ inner = elsewhere.setdefault(window, [])
526
+ if w is not window and w not in inner:
527
+ inner.append(w)
528
+
529
+ for window, widgets in elsewhere.items():
530
+ highlight = self._window_highlights.get(window)
531
+ if highlight is None:
532
+ highlight = TutorialHighlight(window)
533
+ self._window_highlights[window] = highlight
534
+ highlight.show_on(widgets)
535
+ frame = window.frameGeometry()
536
+ rects.append(QRect(self._overlay.mapFromGlobal(frame.topLeft()), frame.size()))
537
+ for window, highlight in self._window_highlights.items():
538
+ if window not in elsewhere:
539
+ highlight.clear()
540
+ return rects
541
+
491
542
  def _find_in_other_windows(self, name: str) -> list[QWidget]:
492
543
  """Visible widgets named `name` in this process's *other* top-level
493
544
  windows — e.g. the SDK Preferences dialog, a separate window parented
@@ -549,6 +600,16 @@ class AcademyStepDriver(QObject):
549
600
  return None
550
601
 
551
602
 
603
+ def _scroll_into_view(widgets: list[QWidget]) -> None:
604
+ """Scrolls each widget's nearest `QScrollArea` so it shows — last
605
+ first, so when they don't all fit, the first-named one wins.
606
+ """
607
+ for w in reversed(widgets):
608
+ area = AcademyStepDriver._find_scroll_area_ancestor(w)
609
+ if area is not None:
610
+ area.ensureWidgetVisible(w, 0, 0)
611
+
612
+
552
613
  # ── Generic Academy launch — shared by any isolated plugin's own UI trigger ──
553
614
  #
554
615
  # Originally private to `ui_daemon_runtime.py`'s Help-menu wiring. Promoted
@@ -0,0 +1,226 @@
1
+ """The answer area of a `QuestionStep`, shown inside Cyto's bubble.
2
+
3
+ Kept out of `tutorial_overlay.py` (already the overlay's layout, masking and
4
+ placement engine): this is only the choices, the feedback line, and the
5
+ check → retry → reveal logic. The overlay supplies `check`, which records the
6
+ answer with the `AcademyManager` and says whether it was correct, and turns
7
+ the footer's Check button into Continue when `answered` fires.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+
14
+ from PyQt6.QtCore import Qt, pyqtSignal
15
+ from PyQt6.QtWidgets import (
16
+ QAbstractButton,
17
+ QButtonGroup,
18
+ QCheckBox,
19
+ QFrame,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QRadioButton,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from .theme_fallback import theme_manager
28
+ from .tutorial_models import QUESTION_PREDICT, QuestionStep
29
+
30
+ _ROW_QSS = (
31
+ "QFrame#QuestionChoice {"
32
+ " background-color: {BG_MEDIUM}; border: 1px solid {BORDER}; border-radius: 6px;"
33
+ "}"
34
+ 'QFrame#QuestionChoice[state="selected"] { border: 1px solid {ACCENT_PRIMARY}; }'
35
+ 'QFrame#QuestionChoice[state="wrong"] { border: 1px solid {ACCENT_DANGER}; }'
36
+ 'QFrame#QuestionChoice[state="reveal"] { border: 2px dashed {ACCENT_SUCCESS}; }'
37
+ 'QFrame#QuestionChoice[state="correct"] { border: 2px solid {ACCENT_SUCCESS}; }'
38
+ "QFrame#QuestionChoice QLabel { color: {FG_PRIMARY}; background: transparent; border: none; }"
39
+ )
40
+ _FEEDBACK_QSS = (
41
+ "QLabel#QuestionFeedback { padding: 6px 2px; color: {FG_PRIMARY}; }"
42
+ 'QLabel#QuestionFeedback[state="wrong"] { color: {ACCENT_DANGER}; }'
43
+ 'QLabel#QuestionFeedback[state="correct"] { color: {ACCENT_SUCCESS}; }'
44
+ )
45
+ _HINT = "Choose an answer first."
46
+ _REVEAL_NOTE = "The outlined answer is correct — select it to continue."
47
+ _MULTI_MISSING = "Not quite — at least one correct answer is still unselected."
48
+
49
+
50
+ def _claim_wrapped_height(label: QLabel, width: int) -> None:
51
+ """Makes a word-wrapped label ask for all its lines at `width`.
52
+
53
+ A wrapped QLabel's sizeHint() assumes its own preferred width, not the
54
+ fixed one it's given here, and the bubble is sized from sizeHint() — so
55
+ the last line or two (a long explanation) would be clipped.
56
+ """
57
+ label.setMinimumHeight(label.heightForWidth(width))
58
+
59
+
60
+ class _ChoiceRow(QFrame):
61
+ """One answer: an indicator plus a word-wrapped label; the whole row clicks."""
62
+
63
+ def __init__(self, text: str, indicator: QAbstractButton, width: int) -> None:
64
+ super().__init__()
65
+ self.setObjectName("QuestionChoice")
66
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
67
+ self.indicator = indicator
68
+ self.label = QLabel(text)
69
+ self.label.setWordWrap(True)
70
+ self.label.setTextFormat(Qt.TextFormat.RichText)
71
+ row = QHBoxLayout(self)
72
+ row.setContentsMargins(10, 8, 10, 8)
73
+ row.setSpacing(8)
74
+ row.addWidget(indicator, 0, Qt.AlignmentFlag.AlignTop)
75
+ row.addWidget(self.label, 1)
76
+ self.setFixedWidth(width)
77
+ theme_manager.apply_style(self, _ROW_QSS)
78
+ _claim_wrapped_height(self.label, width - 20 - 8 - indicator.sizeHint().width())
79
+
80
+ def mousePressEvent(self, event) -> None: # noqa: N802
81
+ if self.indicator.isEnabled():
82
+ # A radio button's toggle() is a no-op when already checked;
83
+ # click() matches what clicking the indicator itself does.
84
+ self.indicator.click()
85
+ super().mousePressEvent(event)
86
+
87
+ def set_state(self, state: str) -> None:
88
+ self.setProperty("state", state)
89
+ style = self.style()
90
+ if style is not None: # re-evaluate [state=...] selectors
91
+ style.unpolish(self)
92
+ style.polish(self)
93
+
94
+
95
+ class QuestionPanel(QWidget):
96
+ """Choices + feedback for one `QuestionStep`.
97
+
98
+ Signals:
99
+ answered: the question is answered (correctly, for a "check"
100
+ question) — the overlay swaps Check for Continue.
101
+ content_changed: feedback/reveal changed the panel's height.
102
+ """
103
+
104
+ answered = pyqtSignal()
105
+ content_changed = pyqtSignal()
106
+
107
+ def __init__(
108
+ self,
109
+ step: QuestionStep,
110
+ check: Callable[[list[int], int], bool],
111
+ width: int,
112
+ parent: QWidget | None = None,
113
+ ) -> None:
114
+ super().__init__(parent)
115
+ self.setObjectName("QuestionPanel")
116
+ self._step = step
117
+ self._check = check
118
+ self.attempts = 0
119
+ self.is_answered = False
120
+
121
+ layout = QVBoxLayout(self)
122
+ layout.setContentsMargins(0, 0, 0, 0)
123
+ layout.setSpacing(6)
124
+
125
+ self._group = QButtonGroup(self)
126
+ self._group.setExclusive(not step.multi_select)
127
+ self.rows: list[_ChoiceRow] = []
128
+ for i, choice in enumerate(step.choices):
129
+ indicator: QAbstractButton = QCheckBox() if step.multi_select else QRadioButton()
130
+ indicator.setObjectName(f"QuestionChoiceIndicator_{i}")
131
+ self._group.addButton(indicator, i)
132
+ row = _ChoiceRow(choice.text, indicator, width)
133
+ indicator.toggled.connect(self._on_selection_changed)
134
+ self.rows.append(row)
135
+ layout.addWidget(row)
136
+
137
+ if step.multi_select:
138
+ hint = QLabel("Select all that apply.")
139
+ hint.setObjectName("QuestionMultiHint")
140
+ theme_manager.apply_style(hint, "color: {FG_SECONDARY}; font-size: 12px;")
141
+ layout.insertWidget(0, hint)
142
+
143
+ self.feedback = QLabel("")
144
+ self.feedback.setObjectName("QuestionFeedback")
145
+ self.feedback.setWordWrap(True)
146
+ self.feedback.setTextFormat(Qt.TextFormat.RichText)
147
+ self.feedback.setFixedWidth(width)
148
+ self.feedback.hide()
149
+ theme_manager.apply_style(self.feedback, _FEEDBACK_QSS)
150
+ layout.addWidget(self.feedback)
151
+
152
+ # ── Public API ──────────────────────────────────────────────────────
153
+
154
+ def selected(self) -> list[int]:
155
+ return [i for i, row in enumerate(self.rows) if row.indicator.isChecked()]
156
+
157
+ def submit(self) -> None:
158
+ """The footer's Check button: grade the current selection."""
159
+ if self.is_answered:
160
+ return
161
+ selection = self.selected()
162
+ if not selection:
163
+ self._show_feedback(_HINT, "")
164
+ return
165
+ self.attempts += 1
166
+ if self._check(selection, self.attempts):
167
+ self._on_answered(selection)
168
+ else:
169
+ self._on_wrong(selection)
170
+
171
+ # ── Internals ───────────────────────────────────────────────────────
172
+
173
+ def _on_selection_changed(self) -> None:
174
+ if self.is_answered:
175
+ return
176
+ chosen = set(self.selected())
177
+ revealed = self.attempts >= self._step.reveal_after_attempts
178
+ for i, row in enumerate(self.rows):
179
+ if i in chosen:
180
+ row.set_state("selected")
181
+ elif revealed and i in self._step.correct_indices:
182
+ row.set_state("reveal")
183
+ else:
184
+ row.set_state("")
185
+
186
+ def _on_answered(self, selection: list[int]) -> None:
187
+ self.is_answered = True
188
+ predict = self._step.kind == QUESTION_PREDICT
189
+ for i, row in enumerate(self.rows):
190
+ row.indicator.setEnabled(False)
191
+ row.setCursor(Qt.CursorShape.ArrowCursor)
192
+ if i in selection:
193
+ row.set_state("selected" if predict else "correct")
194
+ parts = [self._step.choices[i].feedback for i in selection if self._step.choices[i].feedback]
195
+ if self._step.explanation:
196
+ parts.append(self._step.explanation)
197
+ prefix = "" if predict else "✓ Correct. "
198
+ self._show_feedback(prefix + " ".join(parts), "" if predict else "correct")
199
+ self.answered.emit()
200
+
201
+ def _on_wrong(self, selection: list[int]) -> None:
202
+ correct = self._step.correct_indices
203
+ wrong_picks = [i for i in selection if i not in correct]
204
+ for i in wrong_picks:
205
+ self.rows[i].set_state("wrong")
206
+ if wrong_picks:
207
+ message = self._step.choices[wrong_picks[0]].feedback
208
+ else: # multi-select with only correct picks, but not all of them
209
+ message = _MULTI_MISSING
210
+ if self.attempts >= self._step.reveal_after_attempts:
211
+ for i in correct:
212
+ if i not in selection:
213
+ self.rows[i].set_state("reveal")
214
+ message = f"{message}<br><br><b>{_REVEAL_NOTE}</b>"
215
+ self._show_feedback(f"✗ {message}", "wrong")
216
+
217
+ def _show_feedback(self, text: str, state: str) -> None:
218
+ self.feedback.setText(text)
219
+ _claim_wrapped_height(self.feedback, self.feedback.width())
220
+ self.feedback.setProperty("state", state)
221
+ style = self.feedback.style()
222
+ if style is not None:
223
+ style.unpolish(self.feedback)
224
+ style.polish(self.feedback)
225
+ self.feedback.show()
226
+ self.content_changed.emit()
@@ -0,0 +1,84 @@
1
+ """Academy spotlight drawn inside a window other than the overlay's own.
2
+
3
+ The `TutorialOverlay` is a child of the plugin's main window and can't paint
4
+ over a separate window. A step's target inside a dialog or popup (a Tool
5
+ window, a non-modal QDialog…) would otherwise be spotlit on the main window
6
+ *underneath* it — wherever the target happens to sit on screen. The
7
+ `AcademyStepDriver` instead gives each such window one of these, which draws
8
+ accent frames over the targets inside that window itself.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from PyQt6.QtCore import QPoint, QRect, Qt, QTimer
14
+ from PyQt6.QtWidgets import QFrame, QWidget
15
+
16
+ from .theme_fallback import theme_manager
17
+
18
+ # The driver refreshes every tick (100 ms); once it stops (step changed,
19
+ # window closed, Academy hidden) the frames disappear on their own.
20
+ _EXPIRE_MS = 400
21
+ _PAD = 3
22
+
23
+
24
+ def _visible_rect(widget: QWidget) -> QRect:
25
+ """`widget`'s own rect clipped by every ancestor (e.g. a scroll viewport)."""
26
+ rect = widget.rect()
27
+ child, parent = widget, widget.parentWidget()
28
+ while parent is not None and not child.isWindow():
29
+ offset = widget.mapTo(parent, QPoint(0, 0))
30
+ rect = rect.intersected(parent.rect().translated(-offset))
31
+ child, parent = parent, parent.parentWidget()
32
+ return rect
33
+
34
+
35
+ class TutorialHighlight:
36
+ """Accent frames over widgets inside `host` (a top-level window)."""
37
+
38
+ def __init__(self, host: QWidget) -> None:
39
+ self.host = host
40
+ self._frames: list[QFrame] = []
41
+ self._expiry = QTimer(host)
42
+ self._expiry.setSingleShot(True)
43
+ self._expiry.timeout.connect(self.clear)
44
+
45
+ @property
46
+ def visible_count(self) -> int:
47
+ return sum(1 for f in self._frames if f.isVisible())
48
+
49
+ def show_on(self, widgets: list[QWidget]) -> None:
50
+ """Frames exactly `widgets` (descendants of `host`); hides the rest.
51
+
52
+ Only the part of each widget that's actually on screen is framed —
53
+ one scrolled out of its scroll area gets no frame, rather than one
54
+ drawn over whatever sits where it would be (a dialog's buttons).
55
+ """
56
+ shown = [(w, r) for w in widgets if not (r := _visible_rect(w)).isEmpty()]
57
+ while len(self._frames) < len(shown):
58
+ self._frames.append(self._new_frame())
59
+ for frame, (widget, rect) in zip(self._frames, shown, strict=False):
60
+ top_left = widget.mapTo(self.host, rect.topLeft())
61
+ frame.setGeometry(QRect(top_left, rect.size()).adjusted(-_PAD, -_PAD, _PAD, _PAD))
62
+ frame.show()
63
+ frame.raise_()
64
+ for frame in self._frames[len(shown) :]:
65
+ frame.hide()
66
+ self._expiry.start(_EXPIRE_MS)
67
+
68
+ def clear(self) -> None:
69
+ for frame in self._frames:
70
+ frame.hide()
71
+
72
+ def _new_frame(self) -> QFrame:
73
+ frame = QFrame(self.host)
74
+ frame.setObjectName("TutorialHighlightFrame")
75
+ frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
76
+ theme_manager.apply_style(
77
+ frame,
78
+ "QFrame#TutorialHighlightFrame {"
79
+ " border: 2px solid {ACCENT_PRIMARY};"
80
+ " border-radius: 6px;"
81
+ " background: transparent;"
82
+ "}",
83
+ )
84
+ return frame
@@ -1,12 +1,8 @@
1
1
  """SOLID models and interfaces for the Karcytics Academy tutorial engine.
2
2
 
3
- Vendored from the Hub's `karcytics.core.models.tutorial_models` — these are
4
- pure dataclasses/interfaces with zero dependency on the Hub itself (only
5
- `abc`/`dataclasses`/`typing`), so a plugin building its own Academy course
6
- content can depend on this permanent, real copy directly rather than an
7
- unimportable Hub-internal path. Any plugin author writing course content
8
- imports from here; the Hub's own copy is the authoritative implementation
9
- these mirror.
3
+ Originally vendored from the Hub; this is now the only copy. Pure
4
+ dataclasses/interfaces with no dependency on the Hub (only
5
+ `abc`/`dataclasses`/`typing`), so plugin course content imports from here.
10
6
  """
11
7
 
12
8
  from abc import ABC, abstractmethod
@@ -206,6 +202,86 @@ class ConsentStep(BaseStep):
206
202
  on_decline_step_id: str | None = None
207
203
 
208
204
 
205
+ QUESTION_CHECK = "check"
206
+ QUESTION_PREDICT = "predict"
207
+
208
+
209
+ @dataclass
210
+ class AnswerChoice:
211
+ """One answer to a `QuestionStep`.
212
+
213
+ `feedback` is shown when the learner picks this choice: for a wrong
214
+ choice it says *why* it's wrong (required for "check" questions), for a
215
+ correct one it can add a short confirmation.
216
+ """
217
+
218
+ text: str
219
+ correct: bool = False
220
+ feedback: str = ""
221
+
222
+
223
+ @dataclass
224
+ class QuestionStep(BaseStep):
225
+ """A question answered in Cyto's bubble; `text` is the question.
226
+
227
+ Two kinds:
228
+
229
+ - ``"check"`` (default): the learner must select the correct choice(s)
230
+ to continue. A wrong pick shows its feedback; after
231
+ `reveal_after_attempts` wrong tries the correct choice(s) are
232
+ outlined — they still have to select them.
233
+ - ``"predict"``: any answer is accepted and recorded under
234
+ `question_id`, so a later step can reveal it (``{answer:<id>}`` in a
235
+ step's text is replaced with the learner's choice, across courses).
236
+
237
+ `multi_select` makes it "select all that apply": correct only when the
238
+ selection is exactly the set of correct choices. `explanation` is shown
239
+ once answered, before Continue.
240
+ """
241
+
242
+ choices: list[AnswerChoice] = field(default_factory=list)
243
+ multi_select: bool = False
244
+ kind: str = QUESTION_CHECK
245
+ question_id: str = ""
246
+ explanation: str = ""
247
+ reveal_after_attempts: int = 2
248
+
249
+ def __post_init__(self) -> None:
250
+ """Rejects questions that can't be answered (or can't teach)."""
251
+ if self.kind not in (QUESTION_CHECK, QUESTION_PREDICT):
252
+ raise ValueError(f"{self.id}: kind must be 'check' or 'predict', got {self.kind!r}")
253
+ if len(self.choices) < 2: # noqa: PLR2004
254
+ raise ValueError(f"{self.id}: a question needs at least 2 choices")
255
+ if self.kind == QUESTION_PREDICT:
256
+ if not self.question_id:
257
+ raise ValueError(f"{self.id}: a prediction needs a question_id to reveal it later")
258
+ return
259
+ n_correct = sum(c.correct for c in self.choices)
260
+ if self.multi_select and n_correct < 1:
261
+ raise ValueError(f"{self.id}: select-all question needs at least one correct choice")
262
+ if not self.multi_select and n_correct != 1:
263
+ raise ValueError(f"{self.id}: single-choice question needs exactly one correct choice")
264
+ missing = [c.text for c in self.choices if not c.correct and not c.feedback]
265
+ if missing:
266
+ raise ValueError(f"{self.id}: wrong choices need feedback explaining why: {missing}")
267
+
268
+ @property
269
+ def correct_indices(self) -> frozenset[int]:
270
+ """Indices of the correct choices (empty for a prediction)."""
271
+ return frozenset(i for i, c in enumerate(self.choices) if c.correct)
272
+
273
+ def is_correct(self, selected: set[int] | frozenset[int]) -> bool:
274
+ """Whether `selected` (choice indices) answers the question.
275
+
276
+ Any non-empty selection answers a prediction.
277
+ """
278
+ if not selected:
279
+ return False
280
+ if self.kind == QUESTION_PREDICT:
281
+ return True
282
+ return frozenset(selected) == self.correct_indices
283
+
284
+
209
285
  @dataclass
210
286
  class Course:
211
287
  """A collection of polymorphic steps representing a guided tutorial course."""
@@ -284,5 +360,9 @@ __all__ = [
284
360
  "SubplotCheckStep",
285
361
  "WaitForEventStep",
286
362
  "ConsentStep",
363
+ "QUESTION_CHECK",
364
+ "QUESTION_PREDICT",
365
+ "AnswerChoice",
366
+ "QuestionStep",
287
367
  "Course",
288
368
  ]
@@ -41,6 +41,7 @@ from .academy import (
41
41
  from .cyto_character import CytoWidget
42
42
  from .dialogs import ask_yes_no
43
43
  from .draggable import DraggableMixin
44
+ from .question_panel import QuestionPanel
44
45
  from .theme_fallback import Colors, theme_manager
45
46
  from .tutorial_models import (
46
47
  BaseStep,
@@ -49,6 +50,7 @@ from .tutorial_models import (
49
50
  ForcedInteractionStep,
50
51
  InfoStep,
51
52
  InteractionStep,
53
+ QuestionStep,
52
54
  SubplotCheckStep,
53
55
  VerificationStep,
54
56
  WaitForEventStep,
@@ -137,6 +139,8 @@ class TutorialOverlay(QWidget):
137
139
  self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
138
140
 
139
141
  self.target_rects: list[QRect] = []
142
+ # The live QuestionStep's answer panel, if any (see _render_question).
143
+ self.question_panel: QuestionPanel | None = None
140
144
  # The widget a step's allow_scroll=True should forward wheel events
141
145
  # to (see set_scroll_target()/wheelEvent()) — set by AcademyStepDriver
142
146
  # from the ancestor QScrollArea of the step's own target widget(s),
@@ -442,6 +446,10 @@ class TutorialOverlay(QWidget):
442
446
  self.cyto.apply_theme()
443
447
 
444
448
  def _update_text_rendering(self, text: str) -> None:
449
+ # `{answer:<id>}` placeholders → the learner's recorded answers.
450
+ format_text = getattr(self._academy_manager, "format_step_text", None)
451
+ if callable(format_text):
452
+ text = format_text(text)
445
453
  import re
446
454
 
447
455
  # Replace newlines with <br>
@@ -666,6 +674,10 @@ class TutorialOverlay(QWidget):
666
674
  self.btn_next.hide()
667
675
  self._render_checklist(step)
668
676
 
677
+ elif isinstance(step, QuestionStep):
678
+ self.btn_next.hide()
679
+ self._render_question(step)
680
+
669
681
  elif isinstance(step, SubplotCheckStep):
670
682
  self.btn_next.show()
671
683
  self.btn_next.setText("Confirm Subplot")
@@ -1223,6 +1235,59 @@ class TutorialOverlay(QWidget):
1223
1235
  if remaining_count == 0 and not getattr(self.current_step, "auto_advance_when_complete", False):
1224
1236
  self.btn_next.show()
1225
1237
 
1238
+ def _render_question(self, step: QuestionStep) -> None:
1239
+ """Choices in the bubble; Check in the footer, then Continue once answered."""
1240
+ manager = self._academy_manager
1241
+ panel = QuestionPanel(
1242
+ step,
1243
+ check=lambda selection, attempts: manager.record_answer(selection, attempts),
1244
+ width=CONTENT_WIDTH,
1245
+ )
1246
+ self.question_panel = panel
1247
+ self.dynamic_content.addWidget(panel)
1248
+
1249
+ self._clear_buttons()
1250
+ self.btn_layout.addStretch()
1251
+ self.btn_layout.addWidget(self.btn_previous)
1252
+ self._show_previous_button()
1253
+ btn_check = QPushButton("Check answer")
1254
+ btn_check.setObjectName("QuestionCheckButton")
1255
+ theme_manager.apply_style(
1256
+ btn_check,
1257
+ "background-color: {ACCENT_PRIMARY}; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-weight: bold;",
1258
+ )
1259
+ btn_check.setCursor(Qt.CursorShape.PointingHandCursor)
1260
+ btn_check.clicked.connect(panel.submit)
1261
+ self.btn_layout.addWidget(btn_check)
1262
+ btn_check.show() # see _render_consent_options
1263
+
1264
+ def _on_answered() -> None:
1265
+ btn_check.clicked.disconnect()
1266
+ btn_check.setObjectName("QuestionContinueButton")
1267
+ btn_check.setText("Let's find out →" if step.kind == "predict" else "Continue →")
1268
+ btn_check.clicked.connect(lambda _checked=False: manager.next_step())
1269
+
1270
+ panel.answered.connect(_on_answered)
1271
+ panel.content_changed.connect(self._on_question_content_changed)
1272
+
1273
+ def _on_question_content_changed(self) -> None:
1274
+ """Feedback appeared or grew: re-fit the bubble and re-punch the mask.
1275
+
1276
+ Once now and once on the next event-loop turn: the panel's own layout
1277
+ only settles after its pending LayoutRequest, and fitting to the stale
1278
+ size hint squeezes the choices and clips the explanation's last lines.
1279
+ """
1280
+ self._refit_for_question()
1281
+ QTimer.singleShot(0, self._refit_for_question)
1282
+
1283
+ def _refit_for_question(self) -> None:
1284
+ if not self._is_alive():
1285
+ return
1286
+ self._force_resize()
1287
+ if not self._user_positioned:
1288
+ self._reposition_cyto_and_bubble(getattr(self, "target_rects", []))
1289
+ self._update_mask()
1290
+
1226
1291
  def _render_branching_options(self, options: dict) -> None:
1227
1292
  """Render branching-option buttons that advance to their selected tutorial steps.
1228
1293
 
@@ -1379,12 +1444,16 @@ class TutorialOverlay(QWidget):
1379
1444
  self._update_mask()
1380
1445
 
1381
1446
  def _clear_dynamic_content(self) -> None:
1447
+ self.question_panel = None
1382
1448
  # Stop any running WaitForEventStep pulse timer
1383
1449
  if hasattr(self, "_wait_pulse_timer") and self._wait_pulse_timer.isActive():
1384
1450
  self._wait_pulse_timer.stop()
1385
1451
  while self.dynamic_content.count():
1386
1452
  item = self.dynamic_content.takeAt(0)
1387
1453
  if item.widget():
1454
+ # Hidden now; deleteLater() waits for the event loop to come
1455
+ # back round, and until then it would still paint in the bubble.
1456
+ item.widget().hide()
1388
1457
  item.widget().deleteLater()
1389
1458
 
1390
1459
  def _force_resize(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karcytics-sdk
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Software Development Kit and CLI for Karcytics.
5
5
  Author: Kalaimaran Balasothy
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -64,6 +64,7 @@ src/karcytics_sdk/plugin/manifest.py
64
64
  src/karcytics_sdk/plugin/manifest_parser.py
65
65
  src/karcytics_sdk/plugin/menu_builder.py
66
66
  src/karcytics_sdk/plugin/preferences.py
67
+ src/karcytics_sdk/plugin/question_panel.py
67
68
  src/karcytics_sdk/plugin/ribbon.py
68
69
  src/karcytics_sdk/plugin/runtime_services.py
69
70
  src/karcytics_sdk/plugin/security_parser.py
@@ -71,6 +72,7 @@ src/karcytics_sdk/plugin/signals.py
71
72
  src/karcytics_sdk/plugin/state.py
72
73
  src/karcytics_sdk/plugin/theme_fallback.py
73
74
  src/karcytics_sdk/plugin/toast.py
75
+ src/karcytics_sdk/plugin/tutorial_highlight.py
74
76
  src/karcytics_sdk/plugin/tutorial_models.py
75
77
  src/karcytics_sdk/plugin/tutorial_overlay.py
76
78
  src/karcytics_sdk/plugin/ui_daemon_runtime.py
File without changes
File without changes
File without changes