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
« 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).
4Knows nothing about columns or dataframes — ``wizard.py`` keeps the part bodies
5and finalize. What lives here is the *chrome*:
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.
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).
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``.
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"""
38from __future__ import annotations
40import html
41from collections.abc import Iterable
42from dataclasses import dataclass
43from enum import Enum
45import streamlit as st
47from .constants import ICONS, icon_html
48from .fields import tooltip
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_"
56class StepStatus(Enum):
57 """What the badge beside a step says."""
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."""
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}
81@dataclass(frozen=True)
82class WizardStep:
83 """One accordion step. ``number`` is the 1-based label the user reads."""
85 id: str
86 number: int
87 title: str
88 caption: str
89 required: bool
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)
115STEPS_BY_ID: dict[str, WizardStep] = {s.id: s for s in STEPS}
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)
192EDITOR_STEPS_BY_ID: dict[str, WizardStep] = {s.id: s for s in EDITOR_STEPS}
195def numbered(
196 steps: Iterable[WizardStep], shown: Iterable[str]
197) -> dict[str, WizardStep]:
198 """``steps`` renumbered 1..n over the subset named in ``shown``.
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
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}"
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}"
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.
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.
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.
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()
274def _badge_concept(status: StepStatus) -> str:
275 return _BADGES.get(status, _BADGES[StepStatus.TODO])
278def badge(status: StepStatus) -> str:
279 """The icon shortcode shown against a step with this status."""
280 return ICONS[_badge_concept(status)]
283def go_to_step(step_id: str) -> None:
284 """Open exactly one step and close the others.
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
295def reset_accordion() -> None:
296 """Forget every step's open flag.
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)
306def step_panel(host, step: WizardStep, status: StepStatus, *, active: bool):
307 """The container a step's body renders into.
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.
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``.
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 )