Coverage for scanpath_studio/wizard_shell.py: 99%

70 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""The Add-dataset wizard's shell: part registries, status badges, and the few 

2navigation helpers that survive (DATA-22 → UX-135). 

3 

4Knows nothing about columns or dataframes — ``wizard.py`` keeps the part bodies 

5and finalize. What lives here is the *chrome*: 

6 

7- `STEPS` — the add screen's three linear parts (name → data → setup), drawn by 

8 `part()` as numbered one-line headlines. They are labels, not navigation: there 

9 is nothing to map until a file is read, so no chips, no accordion, no open state. 

10- `EDITOR_STEPS` + `numbered()` — the ✏️ Edit dataset screen's parts, renumbered 

11 over the ones that actually render. 

12- `step_panel` — the collapsed *Data & mapping* review panel's per-step block, and 

13 the keyed-expander form it had while the wizard was an accordion. 

14- `go_to_step` / `reset_accordion` — the accordion-era open-flag helpers, 

15 still called by the guide (`tour.py`) and the wizard's reset. 

16 

17**The rule those helpers keep.** A step's open flag (``wiz_open_<id>``) is 

18written *only* by them and the guide. Nothing inside a step body may touch it: 

19the old wizard recomputed ``expanded=`` from whether the step was "done", so the 

20first pick in a step collapsed the expander under the user's cursor mid-edit 

21(DATA-19). 

22 

23**A keyed expander's label and icon must be CONSTANT.** Changing either remounts 

24the widget at its default — i.e. collapsed — on the very next run, no matter what 

25its key holds, which is why `step_panel` never renders a status badge into the 

26expander header. Reproduced and pinned by 

27``tests/test_wizard_helpers.py::TestWizardAccordion:: 

28test_a_changing_header_would_collapse_a_keyed_expander``. 

29 

30**Step bodies must never be gated on the expander being open.** Streamlit drops 

31a widget's key from session state at the end of any run in which the widget did 

32not render, and ``controls.column_mapping_ui`` builds its ``col_map_*`` widgets 

33without ``persist_state`` — so a collapsed-and-therefore-unrendered step would 

34silently discard its mapping. Collapsed-but-rendered is correct; see the comment 

35at the `step_panel` call sites in ``wizard.py``. 

36""" 

37 

38from __future__ import annotations 

39 

40import html 

41from collections.abc import Iterable 

42from dataclasses import dataclass 

43from enum import Enum 

44 

45import streamlit as st 

46 

47from .constants import ICONS, icon_html 

48from .fields import tooltip 

49 

50#: Prefix for the accordion's per-step open flags. Deliberately *not* the 

51#: ``col_map_`` prefix — ``tabs._collect_column_mapping`` sweeps that whole 

52#: namespace into the saved config, and a UI open/closed flag is not mapping. 

53OPEN_KEY_PREFIX = "wiz_open_" 

54 

55 

56class StepStatus(Enum): 

57 """What the badge beside a step says.""" 

58 

59 DONE = "done" 

60 """Satisfied — nothing more is required here.""" 

61 ACTION = "action" 

62 """Required, started, and currently blocked on something specific.""" 

63 TODO = "todo" 

64 """Required and not started.""" 

65 OPTIONAL = "optional" 

66 """Optional and untouched.""" 

67 

68 

69#: Status → its `constants.ICONS` concept (UX-138). A concept rather than the 

70#: shortcode, because the badge is drawn two ways: as markdown on the review 

71#: panel (:func:`badge`) and inside the part title's raw HTML (:func:`part`), 

72#: where a shortcode is inert. 

73_BADGES: dict[StepStatus, str] = { 

74 StepStatus.DONE: "step_done", 

75 StepStatus.ACTION: "step_action", 

76 StepStatus.TODO: "step_todo", 

77 StepStatus.OPTIONAL: "step_optional", 

78} 

79 

80 

81@dataclass(frozen=True) 

82class WizardStep: 

83 """One accordion step. ``number`` is the 1-based label the user reads.""" 

84 

85 id: str 

86 number: int 

87 title: str 

88 caption: str 

89 required: bool 

90 

91 

92#: UX-53 folded the original seven steps into two, then UX-113 unfolded them 

93#: to five — flat and same-size, not the old per-step expanders. UX-114 folded 

94#: "Keep extra fields" back into "Map data fields" (each table's own keep 

95#: picker now sits directly under that table's own mapping — a cross-table 

96#: pick was a second, confusing decision), leaving four. UX-129 folded "Map 

97#: data fields" itself into "Upload data tables" — UX-122/127 had already 

98#: moved every table's uploader into its own mapping row, so by this point the 

99#: two stages held the same content split across two headings for no reason; 

100#: a second numbered heading with nothing under it that the one above didn't 

101#: already cover just read as a bare divider. `_part("data")` alone now 

102#: covers both, leaving three. Upload always precedes mapping (there is 

103#: nothing to map until a file is read), so the three are labelled but not 

104#: navigable: no chips, no accordion, no open state. `part()` renders each as 

105#: a one-line numbered headline. The dataset name is its own numbered stage 

106#: rather than an unlabeled header above everything, and Recording setup is 

107#: its own numbered stage rather than a sub-heading nested inside "Upload data 

108#: tables" — all three read as one flat sequence. 

109STEPS: tuple[WizardStep, ...] = ( 

110 WizardStep("name", 1, "Name & description", "What to call it", True), 

111 WizardStep("data", 2, "Upload data tables", "The tables you exported", True), 

112 WizardStep("setup", 3, "Recording setup", "The screen it was recorded on", True), 

113) 

114 

115STEPS_BY_ID: dict[str, WizardStep] = {s.id: s for s in STEPS} 

116 

117 

118#: UX-135 — the ✏️ Edit dataset screen's parts, in page order. 

119#: 

120#: The ask was that the two screens read the same ("make it be as similar as 

121#: possible to add dataset page"), so the editor uses this module's `part()` 

122#: headline rather than the `st.divider()` + `st.subheader()` + `st.caption()` 

123#: stack it grew section by section. The first three ids line up one-for-one with 

124#: `STEPS` — naming it (UX-178), its tables and mapping (the same question as 

125#: uploading them), and its recording setup (the *same renderer*) — and the rest 

126#: are the questions that only have an answer once the dataset exists. 

127#: 

128#: The ids are prefixed ``edit_`` because `part_key` makes them container keys 

129#: and a key may be used once per run. The editor's *slots* are reserved on 

130#: every run (the screen is hidden by CSS, not skipped — see the slot comments 

131#: in `app.main`), and `edit_stimulus` is drawn on every run outside the view 

132#: guard, so an unprefixed id could meet the wizard's own while the add screen 

133#: is open. 

134#: 

135#: ``number`` here is a *placeholder*: two of the five are conditional (stimulus 

136#: images need a local filesystem, preprocessing is behind PRE-22's flag), and a 

137#: screen numbered 1 · 2 · 3 · 5 reads as a missing section rather than as a 

138#: hidden one. `numbered()` renumbers whatever is actually on screen. 

139EDITOR_STEPS: tuple[WizardStep, ...] = ( 

140 # UX-178 — the add screen's part 1, for a dataset that already has a name: 

141 # renaming it and its description are here, not in a dialog or on a row. 

142 WizardStep( 

143 "edit_name", 

144 1, 

145 "Name & description", 

146 "What the dataset is called in the list of datasets and the picker, and " 

147 "the sentence shown under its name.", 

148 False, 

149 ), 

150 WizardStep( 

151 "edit_data", 

152 1, 

153 "Data tables & column mapping", 

154 "Which of your columns the app reads as participant, trial, word, " 

155 "position and duration — everything drawn follows from it. Any " 

156 "metadata tables attached to the dataset are here too.", 

157 True, 

158 ), 

159 WizardStep( 

160 "edit_setup", 

161 2, 

162 "Recording setup", 

163 "Screen, physical geometry and reading-text setup for this dataset.", 

164 True, 

165 ), 

166 WizardStep( 

167 "edit_identity", 

168 3, 

169 "Trial identity", 

170 "Whether the Trial ID above actually identifies one trial — checked " 

171 "on the whole dataset, before any filtering.", 

172 False, 

173 ), 

174 WizardStep( 

175 "edit_stimulus", 

176 4, 

177 "Stimulus images", 

178 "Attach screenshots of the stimulus from a local folder, without adding " 

179 "an image_path column to your data.", 

180 False, 

181 ), 

182 WizardStep( 

183 "edit_preproc", 

184 5, 

185 "Preprocessing", 

186 "Optional soft exclusion and merging of short fixations, applied to " 

187 "every view. Off by default; original rows remain available.", 

188 False, 

189 ), 

190) 

191 

192EDITOR_STEPS_BY_ID: dict[str, WizardStep] = {s.id: s for s in EDITOR_STEPS} 

193 

194 

195def numbered( 

196 steps: Iterable[WizardStep], shown: Iterable[str] 

197) -> dict[str, WizardStep]: 

198 """``steps`` renumbered 1..n over the subset named in ``shown``. 

199 

200 Returns a mapping keyed by step id, so a caller can draw a part without 

201 knowing where in the sequence it landed. Ids in ``shown`` that no step 

202 declares are ignored; steps not in ``shown`` are absent from the result, 

203 which is what a caller should check rather than tracking the condition 

204 twice. 

205 """ 

206 keep = set(shown) 

207 out: dict[str, WizardStep] = {} 

208 for step in steps: 

209 if step.id not in keep: 

210 continue 

211 out[step.id] = WizardStep( 

212 step.id, len(out) + 1, step.title, step.caption, step.required 

213 ) 

214 return out 

215 

216 

217def open_key(step_id: str) -> str: 

218 """Session key holding whether ``step_id``'s expander is open.""" 

219 return f"{OPEN_KEY_PREFIX}{step_id}" 

220 

221 

222def part_key(step_id: str) -> str: 

223 """Container key for a part, and so its `.st-key-…` CSS/tour selector.""" 

224 return f"wiz_part_{step_id}" 

225 

226 

227def part( 

228 host, 

229 step: WizardStep, 

230 *, 

231 status: StepStatus | None = None, 

232 trailing=None, 

233 note: str = "", 

234): 

235 """One linear part of the wizard: a minimal headline, then its body. 

236 

237 UX-53 r8. The parts run in a fixed order — there is nothing to map before a 

238 file is read — so this is a *label*, not navigation: no expander, no chips, 

239 no open state, and therefore none of the DATA-19 / DATA-22 collapse 

240 machinery. It costs one line, which is the point; the previous shapes spent 

241 a header and a click each on a sequence with no choices in it. 

242 

243 ``trailing`` (UX-113), when given, is called with a column beside the 

244 title — e.g. stage 2's "↩️ Restore a saved setup" popover trigger — so it 

245 reads as part of the title line instead of as the first thing in the body. 

246 Only the title line splits; the returned body container stays full width. 

247 

248 ``note`` (UX-135) hangs the part's explanation off the title as the same 

249 hover tooltip the rest of the wizard's prose uses, instead of a `st.caption` 

250 line under it. The add screen's own three titles say everything they need to 

251 ("Dataset name"), so only the ✏️ Edit dataset screen passes one — its parts 

252 carry judgements a title cannot ("does the Trial ID identify one reading?"). 

253 """ 

254 box = host.container(key=part_key(step.id)) 

255 mark = f"{icon_html(_badge_concept(status))} " if status else "" 

256 title = html.escape(step.title) 

257 if note: 

258 title = f'<span class="sps-fhelp" data-tip="{tooltip(note)}">{title}</span>' 

259 title_html = ( 

260 f'<div class="sps-wiz-part"><span class="sps-wiz-part-n">{step.number}</span>' 

261 f"{mark}{title}</div>" 

262 ) 

263 if trailing is not None: 

264 title_col, trailing_col = box.columns( 

265 [0.6, 0.4], gap="small", vertical_alignment="center" 

266 ) 

267 title_col.markdown(title_html, unsafe_allow_html=True) 

268 trailing(trailing_col) 

269 else: 

270 box.markdown(title_html, unsafe_allow_html=True) 

271 return box.container() 

272 

273 

274def _badge_concept(status: StepStatus) -> str: 

275 return _BADGES.get(status, _BADGES[StepStatus.TODO]) 

276 

277 

278def badge(status: StepStatus) -> str: 

279 """The icon shortcode shown against a step with this status.""" 

280 return ICONS[_badge_concept(status)] 

281 

282 

283def go_to_step(step_id: str) -> None: 

284 """Open exactly one step and close the others. 

285 

286 Safe as an ``on_click``/``on_change`` callback: callbacks run as part of the 

287 click event, *before* the script re-executes, so these writes land before the 

288 expander widgets instantiate and Streamlit never sees a key being set for an 

289 already-created widget. 

290 """ 

291 for step in STEPS: 

292 st.session_state[open_key(step.id)] = step.id == step_id 

293 

294 

295def reset_accordion() -> None: 

296 """Forget every step's open flag. 

297 

298 Called by ``wizard._reset_wizard_widgets`` when *Add data* starts a fresh 

299 dataset; without it the second dataset would open on whichever step the 

300 first one was left on. 

301 """ 

302 for step in STEPS: 

303 st.session_state.pop(open_key(step.id), None) 

304 

305 

306def step_panel(host, step: WizardStep, status: StepStatus, *, active: bool): 

307 """The container a step's body renders into. 

308 

309 ``active`` (the guided wizard) gives a keyed expander; ``on_change="rerun"`` 

310 is what makes Streamlit track its open state at all — with ``key=`` alone the 

311 frontend keeps one open state while session state keeps another, so the 

312 user's manual collapse is never recorded and the next rerun re-opens it. 

313 

314 ``active=False`` is the collapsed *Data & mapping* review panel, which is 

315 itself an expander: Streamlit forbids expander-in-expander, so the step 

316 degrades to a bold heading and renders inline into ``host``. That preserves 

317 today's review-panel behaviour and the ``wizard_reconfigure`` assertion in 

318 ``tests/test_apptest.py``. 

319 

320 ``status`` is deliberately **not** rendered into the active header. A keyed 

321 expander whose label or icon changes remounts collapsed on the next run, so a 

322 status badge there would slam the step shut the moment an upload or a mapping 

323 pick completed it — see the module docstring. 

324 """ 

325 if not active: 

326 host.markdown(f"**{badge(status)} {step.number}. {step.title}**") 

327 return host 

328 return host.expander( 

329 f"{step.number}. {step.title}", 

330 key=open_key(step.id), 

331 on_change="rerun", 

332 )