Coverage for scanpath_studio/controls.py: 92%

2594 statements  

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

1from __future__ import annotations 

2 

3import html 

4import json 

5import math 

6import re 

7from collections.abc import Callable 

8from contextlib import contextmanager 

9from contextvars import ContextVar 

10from copy import deepcopy 

11 

12import numpy as np 

13import pandas as pd 

14import streamlit as st 

15from streamlit.errors import StreamlitAPIException 

16from streamlit_sortables import sort_items 

17 

18from . import column_names as cn 

19from .alignment import ALGORITHMS as ALIGN_ALGORITHMS 

20from .annotations import has_screen_annotations, known_tags 

21from .constants import ( 

22 BACKGROUND_PRESETS, 

23 COLORSCALES, 

24 COMPARE_FIXATION_OPACITY, 

25 CUSTOM_PALETTE, 

26 DEFAULT_BACKGROUND_COLOR, 

27 DEFAULT_FIXATION_COLOR, 

28 DEFAULT_FIXATION_COLORSCALE, 

29 DEFAULT_FIXATION_SYMBOL, 

30 DEFAULT_HEATMAP_COLORSCALE, 

31 DEFAULT_HEATMAP_SIGMA_PX, 

32 DEFAULT_MARKER_DURATION_RANGE, 

33 DEFAULT_MARKER_SIZE_RANGE, 

34 DEFAULT_MARKER_SIZE_SCALE, 

35 DEFAULT_PALETTE, 

36 DEFAULT_SACCADE_WIDTH, 

37 DEMO_CHOICE, 

38 FIXATION_SYMBOLS, 

39 HEATMAP_SIGMA_BOUNDS, 

40 HIGHLIGHTED_TEXT_COLOR, 

41 ICONS, 

42 LEGEND_ARRANGEMENT_LABELS, 

43 LEGEND_KIND_LABELS, 

44 LEGEND_KINDS, 

45 LEGEND_POSITION_LABELS, 

46 MARKER_DURATION_BOUNDS, 

47 MARKER_SIZE_SCALES, 

48 OUT_OF_TEXT_COLOR, 

49 PALETTES, 

50 RAW_GAZE_LINK_FOR_KEY, 

51 RAW_GAZE_SEEDED_FOR_KEY, 

52 RAW_GAZE_SNAP_RESTORE_KEY, 

53 SACCADE_CLASS_COLORS, 

54 SACCADE_CLASS_EDITABLE, 

55 SACCADE_CLASS_LABELS, 

56 SACCADE_CLASS_ORDER, 

57 SACCADE_COLOR, 

58 SACCADE_COLOR_MODES, 

59 SACCADE_DASH_OPTIONS, 

60 SACCADE_DIRECTION_CLASSES, 

61 SACCADE_WIDTH_BOUNDS, 

62 SELF_SCALED_HEATMAP_STYLES, 

63 UNIFORM_COLOR_FIELD, 

64 WORD_BOX_COLOR, 

65 WORD_BOX_FILL_COLOR, 

66 WORD_BOX_FILL_OPACITY, 

67 WORD_BOX_LINE_OPACITY, 

68 WORD_LABEL_COLOR, 

69 compare_palette_color, 

70 drift_correction_enabled, 

71 icon_html, 

72 icons_to_html, 

73 palette_label, 

74 palette_settings, 

75 spoken, 

76 upload_limit_mb, 

77) 

78from .crash_report import guarded 

79from .data import ( 

80 INTERNAL_COLUMNS, 

81 READING_MEASURE_FIELDS, 

82 READING_MEASURE_KEYS, 

83 coerce_bool_or_na, 

84 frame_fingerprint, 

85 mapping_value_preview, 

86 user_columns, 

87) 

88from .export import ( 

89 DEFAULT_CAPTION_PATTERN, 

90 DEFAULT_TITLE_PATTERN, 

91 pattern_error, 

92 pattern_fields, 

93 render_pattern, 

94) 

95from .fields import ( 

96 LABEL_GAP, 

97 NARROW_LABEL_W, 

98 labeled, 

99 plain, 

100 row_label, 

101 tooltip, 

102) 

103from .session_keys import ( 

104 COMPARE_B_FILTER_STATE_KEYS, 

105 SHARE_FLOAT_RANGE_PARAMS, 

106 SINGLE_COMPARE_FIX_RANGE, 

107 SINGLE_COMPARE_LAYOUT, 

108 SINGLE_COMPARE_STIMULUS, 

109 SINGLE_COMPARE_TOGGLE, 

110 SINGLE_PLAYBACK_SPEED, 

111 compare_state_keys, 

112 keep_legacy_marker_scale, 

113 rename_legacy_keys, 

114) 

115from .session_keys import DESIGN_PRESETS as _DESIGN_PRESETS_WIRE_KEY 

116 

117NONE_OPTION = "(none)" 

118 

119 

120# --- UX-51: compact `label | field` rows -------------------------------------- 

121# Every control in the Scanpath rail used to stack its title ABOVE its field, so 

122# one ⚙️ Style popover spent well over a screen's height on eight controls. The 

123# title now sits in a column to the LEFT of the field: a row is one line instead 

124# of two, and a section reads as a compact form rather than a long scroll. 

125# 

126# Built from per-row `st.columns`, not CSS on Streamlit's own widget-label DOM. 

127# The split is then ordinary layout — it cannot leak outside the containers we 

128# build it in, and it does not ride on internal test ids a Streamlit re-skin can 

129# move. (Container-scoped CSS is the fallback if this reads badly when the rail 

130# is tight, not the starting point.) 

131# 

132# ONE label width for the whole rail (`_LABEL_W`) rather than a width per row: 

133# labels lining up down a section — and across sections — is most of what makes 

134# the result read as a form. A label too long for the column truncates with an 

135# ellipsis and shows in full on hover, instead of widening the column for every 

136# other row in the section. 

137# 

138# The widget keeps its real `label` and `help`, and merely hides them 

139# (`label_visibility="collapsed"`), so the accessible name, `AppTest` lookups and 

140# the wire format are all untouched. What the user sees is the markdown twin in 

141# the left column — and the `?` tooltip icon folds INTO it: the help text becomes 

142# the label's own hover tooltip, which buys back the icon's width on every row. 

143# 

144# Controls whose label already sits beside the field — `st.checkbox`, 

145# `st.toggle` — keep their native one-line shape; splitting those would only 

146# indent them away from the section's other rows. 

147 

148#: The label column's share of a `label | field` row. Tuned for the ~28rem 

149#: popover body (`styles.get_app_css` pins `stPopoverBody`), which is where these 

150#: rows live: ~160px of label — about 23 characters at the rail's 0.92rem — while 

151#: leaving the field wide enough for a multiselect's chips, or for a slider plus 

152#: the UX-9 box you type an exact value into. 

153#: 

154#: UX-69 moved the row itself down into `fields.py`, so the Scanpath subtabs can 

155#: share it without importing this module (which imports two of them). The 

156#: names below stay as they were — this module's call sites are the row's 

157#: heaviest user by far. 

158_LABEL_W = NARROW_LABEL_W 

159 

160#: UX-158: a popover whose titles are all short can narrow its own label column 

161#: (`_rail_label_width`), so its fields sit closer to their titles. A ContextVar, 

162#: not a module global, because Streamlit runs each session's script on its own 

163#: thread and a temporarily-rebound global would leak into another session. 

164_LABEL_W_OVERRIDE: ContextVar[float | None] = ContextVar( 

165 "rail_label_width", default=None 

166) 

167 

168 

169def _label_w() -> float: 

170 """The rail's label-column share for the row being drawn now.""" 

171 override = _LABEL_W_OVERRIDE.get() 

172 return _LABEL_W if override is None else override 

173 

174 

175#: The rail popovers' label column (UX-158 for 👁️ Fixations, UX-159 for the 

176#: rest): with titles kept short, this much of the ~28rem body holds the 

177#: longest of them ("Snap above words", "Direction arrows") and brings every 

178#: field closer to its title than the rail's default split does. 

179_POPOVER_LABEL_W = 0.3 

180 

181 

182@contextmanager 

183def _rail_label_width(width: float): 

184 """Draw the rows inside with a label column of ``width`` (UX-158).""" 

185 token = _LABEL_W_OVERRIDE.set(width) 

186 try: 

187 yield 

188 finally: 

189 _LABEL_W_OVERRIDE.reset(token) 

190 

191 

192@contextmanager 

193def _popover_rows(slug: str): 

194 """Lay a rail popover's rows out the UX-158 way (UX-159). 

195 

196 The popover's own label column (`_POPOVER_LABEL_W`) and a keyed container, 

197 ``rail_rows_<slug>``, that `styles.py` spaces the rows of apart — the two 

198 things that made 👁️ Fixations read as a form, for every popover alike. 

199 """ 

200 with _rail_label_width(_POPOVER_LABEL_W), st.container(key=f"rail_rows_{slug}"): 

201 yield 

202 

203 

204#: Tighter than the 1rem default: these rows are dense and the width is scarce. 

205_LABEL_GAP = LABEL_GAP 

206 

207#: `label | field | note` for one column-mapping row (UX-52 round 3). Its own 

208#: triple rather than `_LABEL_W`: the mapping renders full-width on the 🗂️ Data 

209#: page and inside the upload wizard, not in the rail's ~28rem popover, so there 

210#: is room for the "✨ auto-detected …" note beside the field instead of under it. 

211_MAPPING_ROW_W = (0.24, 0.40, 0.36) 

212 

213#: `label | ✨` on a grid cell's first line, with the select on its second 

214#: (UX-53 r14). Stacking is what makes a packed theme read as two rows — every 

215#: field name on one line, every control on the next — instead of as a ragged 

216#: run of label-field pairs. The flag is one glyph and takes only what it needs. 

217_GRID_LABEL_W = (0.85, 0.15) 

218 

219 

220def inline_field_label( 

221 host, label: str, help_text: str | None = None, *, emphasis: bool = False 

222) -> None: 

223 """Render a field title above its control (UX-53 r14). 

224 

225 The public door onto `_row_label`, for callers that build their own row: 

226 `wizard._render_identity_field` puts one picker per table across a shared 

227 row and needs their titles to look like every other mapping title, tooltip 

228 included. ``emphasis`` (UX-113) is the bolder/larger `.sps-flabel-emph` 

229 variant, for a title that should stand out among its neighbours (the 

230 wizard's own upload/attach table titles). 

231 """ 

232 _row_label(host, label, help_text, emphasis=emphasis) 

233 

234 

235#: Shown in a select with nothing chosen. UX-53 r10 replaced the `"(none)"` 

236#: *option* with a real empty state, so this is placeholder text, not a value 

237#: anyone can pick. 

238_UNMAPPED_PLACEHOLDER = "Not mapped" 

239 

240 

241#: `fields.plain` under this module's own name — the markdown-stripping the 

242#: mapping UI's headings and flags do before writing a plain-text tooltip. 

243_plain = plain 

244 

245 

246#: `fields.row_label` under this module's own name: one row's title, with its 

247#: description folded into the title's own hover tooltip rather than a `?` icon. 

248_row_label = row_label 

249 

250 

251def _labeled(host, kind: str, label: str, **kwargs): 

252 """`fields.labeled` at the rail's label width — see that module's docstring. 

253 

254 Every call site in this module renders into the rail or its popovers, so the 

255 narrow width is the one they all want; passing `label_width` explicitly 

256 overrides it. 

257 """ 

258 kwargs.setdefault("label_width", _label_w()) 

259 # UX-97: a layer's controls grey out while its layer toggle is off. 

260 kwargs["disabled"], kwargs["help"] = _layer_gate( 

261 bool(kwargs.get("disabled", False)), kwargs.get("help") 

262 ) 

263 return labeled(host, kind, label, **kwargs) 

264 

265 

266def _rail_names() -> cn.ColumnNames: 

267 """DATA-66: the open dataset's names, for the rail's column pickers. 

268 

269 The fixations table's, then the words table's for the word-level fields 

270 (surprisal, frequency …) carried onto fixations. The pickers' values stay 

271 canonical — only what they show changes.""" 

272 return cn.active_all(st.session_state) 

273 

274 

275def _slider_row(host, n_boxes: int, lead: float = 0.0) -> list: 

276 """Columns for a ``label | slider | box…`` row, label column first (UX-51). 

277 

278 The slider keeps its pre-UX-51 5 : 1.5 proportion against each typed box; the 

279 label takes ``_LABEL_W`` off the top so the row lines up with the plain 

280 ``label | field`` rows around it. ``vertical_alignment="center"`` is what puts 

281 the label beside the slider's *track*: a slider prints its current value 

282 above the track, so a top-aligned label would sit against that number instead 

283 of against the control. 

284 

285 ``lead`` (UX-157) inserts a column of that relative weight between the label 

286 and the slider, for a control that belongs on the slider's line — the colour 

287 range's *Auto* checkbox. 

288 """ 

289 label_w = _label_w() 

290 rest = 1.0 - label_w 

291 total = lead + 5.0 + 1.5 * n_boxes 

292 weights = [ 

293 label_w, 

294 *([rest * lead / total] if lead else []), 

295 rest * 5.0 / total, 

296 *([rest * 1.5 / total] * n_boxes), 

297 ] 

298 return host.columns(weights, gap=_LABEL_GAP, vertical_alignment="center") 

299 

300 

301# --- UX-9: sliders you can also type an exact value into ---------------------- 

302# A slider is the right control for "sweep until it looks right", but it can't be 

303# set to a precise value — awkward when a figure has to match a spec (marker size 

304# 12, opacity 0.65, line width 1.5). These wrappers pair each slider with a 

305# number box. 

306# 

307# The SLIDER keeps the canonical session key, so nothing downstream changes: deep 

308# links, Share, Save & restore and `_collect_viz_settings` all still read 

309# `global_*` / `cmp*_*` / `single_*` exactly as before. The box owns a shadow 

310# `{key}__num*` key and writes the canonical one from its `on_change`; the 

311# canonical value is mirrored back into the box *before* either widget renders, 

312# so a slider drag, a deep link, a restored config and a Quick-view preset all 

313# move the box too — one-way sync each direction, no feedback loop. 

314 

315 

316def _shadow_key_missing(*keys: str) -> bool: 

317 """True when a number box's shadow key is not in session state (BUG-18). 

318 

319 An ``on_change`` callback runs *before* the script that would (re)create the 

320 widget, and Streamlit drops a widget's key at the end of any run in which it 

321 did not render. Several of these boxes are conditional — the heatmap 

322 colour-range pair only renders when the current trial/metric has data — so a 

323 change queued while a slow rerun was still in flight can reach a callback 

324 whose own key no longer exists, and reading it raised ``KeyError`` and took 

325 the app down mid-rerun. 

326 

327 A missing shadow key means there is no user edit left to apply: the canonical 

328 key still holds the last committed value, and the box re-seeds from it the 

329 next time it renders. So the callback becomes a no-op rather than a crash. 

330 """ 

331 return any(k not in st.session_state for k in keys) 

332 

333 

334_INT_NUMBER_FORMATS = frozenset({"%d", "%u", "%i"}) 

335 

336 

337def _number_box_format(fmt: str | None, *values) -> str | None: 

338 """Adapt a slider's ``format`` for the number box beside it. 

339 

340 ``st.number_input`` renders a yellow "value below has type float, but format 

341 %d displays as integer" warning above itself when an integer format meets a 

342 float value — and the colour-range sliders pass float bounds on purpose (so a 

343 restored config clamps into another dataset's range) while wanting whole 

344 numbers on screen. ``"%.0f"`` shows the same digits without the warning. 

345 """ 

346 if fmt in _INT_NUMBER_FORMATS and any(isinstance(v, float) for v in values): 

347 return "%.0f" 

348 return fmt 

349 

350 

351def _numeric_slider( 

352 host, 

353 label: str, 

354 *, 

355 key: str, 

356 min_value, 

357 max_value, 

358 step=None, 

359 slider_format: str | None = None, 

360 number_format: str | None = None, 

361 help: str | None = None, 

362 disabled: bool = False, 

363 on_change=None, 

364 persist_state: str | None = None, 

365 label_left: bool = False, 

366 display: str | None = None, 

367 field_host=None, 

368) -> None: 

369 """A single-value slider plus a number box bound to the same setting. 

370 

371 ``field_host`` (UX-158) draws the slider and its box into that column, for a 

372 `_sub_row` whose title and caption the caller has already drawn. 

373 

374 ``number_format`` defaults to ``slider_format``; pass it separately when the 

375 slider's format carries a unit suffix (``"%.1f px"``), which ``number_input`` 

376 does not accept. 

377 

378 ``disabled`` greys BOTH halves (VIZ-21) without touching the canonical key — 

379 a disabled Streamlit widget still owns and keeps its value, so a mode toggle 

380 never rewrites a deep-linked / restored setting. 

381 

382 ``label_left`` opts the row into the UX-51 ``label | slider | box`` shape. 

383 It is opt-in rather than the default because the sliders in the rail's 

384 ⚙️ Playback popover are laid out two-up in half-width columns, where a third 

385 column would leave the slider unusable. 

386 """ 

387 disabled, help = _layer_gate(disabled, help) # UX-97 

388 num_key = f"{key}__num" 

389 if key in st.session_state: 

390 st.session_state[num_key] = st.session_state[key] 

391 

392 def _apply() -> None: 

393 if _shadow_key_missing(num_key): # BUG-18 

394 return 

395 st.session_state[key] = st.session_state[num_key] 

396 if on_change is not None: 

397 on_change() 

398 

399 # A narrow box on the same line as the slider: the box is for typing an 

400 # exact value, so it only needs room for the number itself (the CSS drops its 

401 # +/- steppers and caps its width), and the slider keeps most of the row. 

402 if field_host is not None: 

403 slider_col, num_col = field_host.columns( 

404 [5, 1.5], gap=_LABEL_GAP, vertical_alignment="center" 

405 ) 

406 elif label_left: 

407 label_col, slider_col, num_col = _slider_row(host, 1) 

408 _row_label(label_col, display if display is not None else label, help) 

409 else: 

410 slider_col, num_col = host.columns([5, 1.5], vertical_alignment="bottom") 

411 slider_col.slider( 

412 label, 

413 min_value=min_value, 

414 max_value=max_value, 

415 step=step, 

416 format=slider_format, 

417 key=key, 

418 help=help, 

419 disabled=disabled, 

420 on_change=on_change, 

421 persist_state=persist_state, 

422 label_visibility=( 

423 "collapsed" if label_left or field_host is not None else "visible" 

424 ), 

425 ) 

426 num_col.number_input( 

427 label, 

428 min_value=min_value, 

429 max_value=max_value, 

430 step=step, 

431 format=_number_box_format( 

432 number_format if number_format is not None else slider_format, 

433 min_value, 

434 max_value, 

435 step, 

436 st.session_state.get(num_key), 

437 ), 

438 key=num_key, 

439 on_change=_apply, 

440 label_visibility="collapsed", 

441 disabled=disabled, 

442 ) 

443 

444 

445def _range_slider( 

446 host, 

447 label: str, 

448 *, 

449 key: str, 

450 min_value, 

451 max_value, 

452 step=None, 

453 slider_format: str | None = None, 

454 number_format: str | None = None, 

455 help: str | None = None, 

456 disabled: bool = False, 

457 on_change=None, 

458 persist_state: str | None = None, 

459 label_left: bool = False, 

460 display: str | None = None, 

461 lead=None, 

462 field_host=None, 

463 number_bounds: tuple | None = None, 

464) -> None: 

465 """A two-handle range slider plus min/max number boxes, all on one line. 

466 

467 ``number_bounds`` bounds the two number boxes when it differs from the 

468 slider's (``None`` on either side = unbounded); a typed value outside the 

469 slider's span is then the caller's to make room for on the next run, as 

470 `_render_color_range` does by widening the slider to the stored range. 

471 

472 ``lead`` (UX-157) is a callable given a column ahead of the slider, to draw 

473 a control of its own there. ``field_host`` (UX-158) draws the whole line 

474 into that column, for a `_sub_row` whose title and caption the caller has 

475 already drawn. 

476 

477 The boxes are deliberately small — they hold a number, not a sentence — so 

478 the slider still gets most of the row. A min typed above the max is swapped 

479 rather than rejected. ``disabled`` greys all three without changing the 

480 stored range (VIZ-21). 

481 

482 ``label_left`` / ``display`` behave as in :func:`_numeric_slider` (UX-51). 

483 """ 

484 disabled, help = _layer_gate(disabled, help) # UX-97 

485 lo_key, hi_key = f"{key}__num_lo", f"{key}__num_hi" 

486 current = st.session_state.get(key) 

487 if isinstance(current, (tuple, list)) and len(current) == 2: 

488 st.session_state[lo_key], st.session_state[hi_key] = current 

489 

490 def _apply() -> None: 

491 if _shadow_key_missing(lo_key, hi_key): # BUG-18 

492 return 

493 lo, hi = st.session_state[lo_key], st.session_state[hi_key] 

494 st.session_state[key] = (min(lo, hi), max(lo, hi)) 

495 if on_change is not None: 

496 on_change() 

497 

498 if field_host is not None: 

499 weights = [*([2.2] if lead is not None else []), 5, 1.5, 1.5] 

500 cols = field_host.columns(weights, gap=_LABEL_GAP, vertical_alignment="center") 

501 if lead is not None: 

502 lead(cols[0]) 

503 slider_col, lo_col, hi_col = cols[-3:] 

504 elif label_left: 

505 if lead is not None: 

506 label_col, lead_col, slider_col, lo_col, hi_col = _slider_row( 

507 host, 2, lead=2.2 

508 ) 

509 lead(lead_col) 

510 else: 

511 label_col, slider_col, lo_col, hi_col = _slider_row(host, 2) 

512 _row_label(label_col, display if display is not None else label, help) 

513 else: 

514 slider_col, lo_col, hi_col = host.columns( 

515 [5, 1.5, 1.5], vertical_alignment="bottom" 

516 ) 

517 slider_col.slider( 

518 label, 

519 min_value=min_value, 

520 max_value=max_value, 

521 step=step, 

522 format=slider_format, 

523 key=key, 

524 help=help, 

525 disabled=disabled, 

526 on_change=on_change, 

527 persist_state=persist_state, 

528 label_visibility=( 

529 "collapsed" if label_left or field_host is not None else "visible" 

530 ), 

531 ) 

532 fmt = number_format if number_format is not None else slider_format 

533 num_min, num_max = ( 

534 number_bounds if number_bounds is not None else (min_value, max_value) 

535 ) 

536 for col, num_key, side in ((lo_col, lo_key, "min"), (hi_col, hi_key, "max")): 

537 col.number_input( 

538 f"{label} ({side})", 

539 min_value=num_min, 

540 max_value=num_max, 

541 step=step, 

542 format=_number_box_format( 

543 fmt, min_value, max_value, step, st.session_state.get(num_key) 

544 ), 

545 key=num_key, 

546 on_change=_apply, 

547 label_visibility="collapsed", 

548 disabled=disabled, 

549 ) 

550 

551 

552# VIZ-4: MIME by extension for a user-uploaded stimulus image → a `data:` URI the 

553# figure builders accept as `background_image` (plots._image_to_data_uri passes a 

554# `data:` URI straight through). 

555_UPLOAD_IMAGE_MIME = { 

556 "png": "image/png", 

557 "jpg": "image/jpeg", 

558 "jpeg": "image/jpeg", 

559 "gif": "image/gif", 

560 "webp": "image/webp", 

561} 

562 

563 

564def _uploaded_image_data_uri(uploaded) -> str | None: 

565 """Base64 ``data:`` URI for a Streamlit ``UploadedFile`` image, or ``None``. 

566 

567 Cached in session state keyed by the file's id so a multi-MB screenshot is 

568 encoded once, not on every rerun (VIZ-4).""" 

569 if uploaded is None: 

570 return None 

571 import base64 

572 

573 cache = st.session_state.get("_stimulus_image_upload_cache") 

574 if isinstance(cache, dict) and cache.get("id") == uploaded.file_id: 

575 return cache.get("uri") 

576 ext = (uploaded.name.rsplit(".", 1)[-1] if "." in uploaded.name else "").lower() 

577 mime = _UPLOAD_IMAGE_MIME.get(ext, "image/png") 

578 uri = f"data:{mime};base64," + base64.b64encode(uploaded.getvalue()).decode("ascii") 

579 st.session_state["_stimulus_image_upload_cache"] = { 

580 "id": uploaded.file_id, 

581 "uri": uri, 

582 } 

583 return uri 

584 

585 

586# PRE-3: drift-correction picker options — "Off" + each algorithm title-cased. 

587_ALIGN_OPTIONS = ["Off", *(a.title() for a in ALIGN_ALGORITHMS)] 

588 

589 

590# --- VIZ-21/23: which rail controls actually apply in Animate / Compare ------- 

591# One rail feeds the static, animation, and comparison renderers, but some layers 

592# do not exist in every mode. Each affected control therefore declares which 

593# render paths consume it. Unsupported controls stay visible but disabled so the 

594# reason is discoverable, and their stored values survive mode switches, deep 

595# links, and restored configs. Keep the authoritative setting → render-path 

596# table in `CLAUDE.md` in sync with these gates. 

597 

598 

599def _mode_gate( 

600 animating: bool, 

601 comparing: bool, 

602 *, 

603 in_animation: bool = True, 

604 in_compare: bool = True, 

605) -> tuple[bool, str]: 

606 """``(disabled, reason)`` for a control, given which paths honour it. 

607 

608 ``in_animation`` / ``in_compare`` state whether the corresponding builder 

609 actually consumes the setting. The reason string is prefixed onto the 

610 control's ``help`` so the tooltip explains the greying instead of leaving 

611 the user guessing.""" 

612 modes = [] 

613 if animating and not in_animation: 

614 modes.append("**Animate**") 

615 if comparing and not in_compare: 

616 modes.append("**Compare**") 

617 if not modes: 

618 return False, "" 

619 return True, ( 

620 f"{ICONS['warning']} Not used in " 

621 + " / ".join(modes) 

622 + " mode. Your value is kept." 

623 ) 

624 

625 

626def _gated_help(base: str | None, reason: str) -> str | None: 

627 """Prefix ``reason`` (from :func:`_mode_gate`) onto a control's help text.""" 

628 if not reason: 

629 return base 

630 return f"{reason}\n\n{base}" if base else reason 

631 

632 

633# --- UX-97: a layer's settings stay readable while the layer is off ---------- 

634# Before this, each layer block was gated `if show_<layer>:`, so switching the 

635# layer off left its ▾ clickable but empty — the affordance said "there is 

636# something here" and the popover said otherwise. The controls now always 

637# render; while the layer is off they are greyed, because they change something 

638# nothing is drawing. Same rule as `_mode_gate`: disabling never rewrites the 

639# stored value, since a disabled Streamlit widget keeps its key. 

640_LAYER_OFF_REASON: list[str] = [] 

641 

642 

643@contextmanager 

644def _layer_off( 

645 label: str, *, off: bool, reason: str | None = None, caption: bool = True 

646): 

647 """Grey every rail control rendered inside, while ``off``. 

648 

649 ``label`` names the layer's toggle, so the reason reads as an instruction 

650 ("Turn **👁️ Fixations** on…") rather than a bare refusal. Nested use pushes 

651 onto a stack, so an inner block that greys for its own reason wins. 

652 ``reason`` replaces that instruction when switching the layer on would not 

653 help — VIZ-45's trial with no fixations for the layer to draw. 

654 ``caption=False`` greys without writing the reason again, where the section 

655 already said it once. 

656 """ 

657 if not off: 

658 yield 

659 return 

660 _LAYER_OFF_REASON.append( 

661 reason 

662 or f"{ICONS['warning']} **{label}** is off — turn the layer on to change " 

663 "this. Your settings are kept either way." 

664 ) 

665 try: 

666 if caption: 

667 st.caption(_LAYER_OFF_REASON[-1]) 

668 yield 

669 finally: 

670 _LAYER_OFF_REASON.pop() 

671 

672 

673def _layer_gate(disabled: bool, help: str | None) -> tuple[bool, str | None]: 

674 """Fold the active :func:`_layer_off` reason into one control's args.""" 

675 if not _LAYER_OFF_REASON: 

676 return disabled, help 

677 return True, _gated_help(help, _LAYER_OFF_REASON[-1]) 

678 

679 

680# Static defaults for the keyed visualization widgets that the plot-config 

681# restore (app._restore_plot_config) can set. Seeded into session_state so those 

682# widgets render WITHOUT a `value=`/`index=` argument — that keeps their key 

683# programmatically settable without Streamlit's "default value but also set via 

684# Session State API" warning. Data-dependent defaults (color-by / axis fields / 

685# sizing / canvas) are seeded locally where they're computed. 

686_VIZ_WIDGET_DEFAULTS = { 

687 # First-load layers default to the *core scanpath* only — fixations, saccades 

688 # and the reading text — so a new user lands on a legible picture instead of 

689 # seven stacked encodings. The bounding-box grid and the density heatmap are 

690 # analytical overlays, off by default and one click (or one design preset) away. 

691 "global_show_words": False, 

692 "global_show_labels": True, 

693 # UX-128: the 📄 Stimulus section's own master switch — on by default, so 

694 # a fresh session's figure is unchanged (Text on, Bounding boxes/Image 

695 # off, same as before this toggle existed). 

696 "global_show_stimulus": True, 

697 "global_show_fix": True, 

698 "global_show_order": False, 

699 "global_show_saccades": True, 

700 "global_show_saccade_arrows": False, 

701 "global_saccade_color": SACCADE_COLOR, 

702 "global_saccade_style": "Solid", 

703 "global_saccade_width": DEFAULT_SACCADE_WIDTH, 

704 # VIZ-8: colour saccades uniformly, or by reading type (forward / skip / 

705 # refixation / return sweep / regression). "By type" splits the saccade trace 

706 # into one colour per class with a small legend; the five class colours are 

707 # each restorable, so seed them here. 

708 "global_saccade_color_mode": "Uniform", 

709 # VIZ-8: show the saccade-type colour key on the plot (default on). Optional, 

710 # like the other legends. 

711 "global_saccade_type_legend": True, 

712 # The fixed duration scale: one mapping of duration to marker size for every 

713 # figure (√ by default — area grows with duration). Old configs and links 

714 # that predate it are migrated to "relative" so they still draw as saved. 

715 "global_marker_size_scale": DEFAULT_MARKER_SIZE_SCALE, 

716 "global_marker_duration_range": DEFAULT_MARKER_DURATION_RANGE, 

717 "global_duration_size_legend": True, 

718 # 📐 Figure & canvas → Legends: where each legend sits (all Auto = as drawn 

719 # before the setting existed). Size None = the figure's own text size. 

720 **{ 

721 f"global_legend_{kind}_{part}": default 

722 for kind in LEGEND_KINDS 

723 for part, default in ( 

724 ("position", "auto"), 

725 ("arrangement", "auto"), 

726 ("size", None), 

727 ) 

728 }, 

729 "global_saccade_class_color_forward": SACCADE_CLASS_COLORS["forward"], 

730 "global_saccade_class_color_skip": SACCADE_CLASS_COLORS["skip"], 

731 "global_saccade_class_color_refixation": SACCADE_CLASS_COLORS["refixation"], 

732 "global_saccade_class_color_return_sweep": SACCADE_CLASS_COLORS["return_sweep"], 

733 "global_saccade_class_color_regression": SACCADE_CLASS_COLORS["regression"], 

734 # VIZ-31: the saccade *filter* — which reading classes are drawn at all. The 

735 # same `measures.classify_saccades` split the colour mode above uses, applied 

736 # as visibility instead of hue ("show me only the regressions"). Default is 

737 # every class, which the figure builder treats as "no filter" and short- 

738 # circuits, so the common case pays nothing. 

739 "global_saccade_classes": list(SACCADE_CLASS_ORDER), 

740 # VIZ-9 "linear reading" mode: draw saccades as upward arcs (`Arc`) instead of 

741 # straight connectors, and/or snap each fixation above the word it lands on. 

742 "global_saccade_render_mode": "Straight", 

743 "global_fixation_snap_to_word": False, 

744 "global_illustration_label": "Auto", 

745 "global_illustration_text": "", 

746 # VIZ-10: autoplay the animated replay on load (default on). The toggle lives 

747 # in the Animate ⚙ Playback popover (tabs.render_single_trial_tab); the replay 

748 # player starts it at the configured speed (plots.animation_player_post_script). 

749 "global_anim_autoplay": True, 

750 # VIZ-11 follow-up: the animation frame grid, exposed instead of decided for 

751 # the user. Step = smoothness; max frames = the ceiling that keeps a long 

752 # reading's GIF/MP4 bounded (it coarsens the step, and the popover says so). 

753 # Defaults match the old constants, so nothing changes until someone moves them. 

754 "global_anim_grid_step_ms": 100, 

755 "global_anim_max_frames": 360, 

756 # VIZ-6: fixation marker alpha. Default 0.7 so overlapping fixations show 

757 # through (the classic translucent scanpath look); drag to 1.0 for fully 

758 # opaque markers. This replaced the old binary `Hollow circles` toggle in the 

759 # UI — the `global_hollow_fixations` key is kept (no widget) so saved configs 

760 # / deep links that carry it still render hollow. 

761 "global_fixation_opacity": 0.7, 

762 "global_hollow_fixations": False, 

763 # VIZ-17: the flat colour every fixation wears when "Color fixations by" is 

764 # "(uniform)" — the default, since marker size already encodes duration. 

765 "global_fixation_color": DEFAULT_FIXATION_COLOR, 

766 # VIZ-15: fixation marker shape. A second encoding channel that, unlike hue, 

767 # survives greyscale printing. 

768 "global_fixation_symbol": DEFAULT_FIXATION_SYMBOL, 

769 # VIZ-18: the active colour palette. A preset, not a rendering mode — picking 

770 # one writes the individual colour keys below, so every per-element picker 

771 # still overrides it and every surface carries the resulting colours. 

772 "global_palette": DEFAULT_PALETTE, 

773 # PRE-3: in-place vertical drift-correction. "Off" = raw fixations; otherwise 

774 # one of alignment.ALGORITHMS (title-cased in the UI) snaps each fixation to 

775 # its assigned text line. `align_connectors` draws faint original→corrected 

776 # connector lines. 

777 "global_align_algorithm": "Off", 

778 "global_align_connectors": False, 

779 "global_highlight_text_color": HIGHLIGHTED_TEXT_COLOR, 

780 "global_show_heatmap": False, 

781 "global_heatmap_sigma_auto": True, 

782 "global_heatmap_sigma_px": DEFAULT_HEATMAP_SIGMA_PX, 

783 "global_show_raw_gaze": False, 

784 # UX-86: raw gaze's own style — previously fixed in `plots._add_raw_gaze_layer` 

785 # (#888888, size 4, opacity 0.6) with no control at all. 

786 "global_raw_gaze_color": "#888888", 

787 "global_raw_gaze_marker_size": 4.0, 

788 "global_raw_gaze_opacity": 0.6, 

789 # ⬚ Word boxes' own style — previously fixed in `plots.build_word_boxes`. 

790 "global_word_box_color": WORD_BOX_COLOR, 

791 "global_word_box_line_opacity": WORD_BOX_LINE_OPACITY, 

792 "global_word_box_fill_color": WORD_BOX_FILL_COLOR, 

793 "global_word_box_fill_opacity": WORD_BOX_FILL_OPACITY, 

794 "global_show_stimulus_image": False, 

795 # VIZ-4: image-based stimuli. Opacity dims a busy stimulus image so the AOIs / 

796 # scanpath read over it (round-trips in Share / Save & restore, since it also 

797 # applies to dataset images). A user-uploaded image (session-only — an uploaded 

798 # image can't ride a deep link) is stretched to fill the monitor; precise 

799 # crop placement is available via the CLI / headless API (background_image_*). 

800 "global_stimulus_image_opacity": 1.0, 

801 # VIZ-4: manual image alignment — nudge the image origin (px) and scale its 

802 # size so it lines up with the text boxes / fixations when the data's frame 

803 # doesn't match the image. Applies to dataset + uploaded images alike. 

804 "global_stimulus_image_offset_x": 0.0, 

805 "global_stimulus_image_offset_y": 0.0, 

806 "global_stimulus_image_scale": 1.0, 

807 "global_heatmap_style": "Word boxes", 

808 "global_heatmap_metric": "duration_ms", 

809 # VIZ-3: heatmap colour-scaling. "Linear" maps colour straight to the value; 

810 # "Log" maps to log1p(value), compressing heavy-tailed dwell times so a few 

811 # very-hot words don't wash out the rest. 

812 "global_heatmap_norm": "Linear", 

813 "global_show_fixation_colorbar": True, 

814 "global_show_heatmap_colorbar": True, 

815 # Frame the view to the whole presentation monitor (scanpath sits at its true 

816 # on-screen position) rather than cropping to the data extent. Default on. 

817 "global_fit_to_monitor": True, 

818 # VIZ-34: optional monitor-pixel coordinate grid. Auto chooses a stable 

819 # 1/2/5×10ⁿ interval from the visible range; the stored manual value remains 

820 # available while Auto is on so switching back does not lose it. 

821 "global_show_coordinate_grid": False, 

822 "global_coordinate_grid_auto": True, 

823 "global_coordinate_grid_spacing": 100.0, 

824 "global_order_font_color": "#111111", 

825 "global_order_font_size": 10, 

826 "global_fixation_colorscale": DEFAULT_FIXATION_COLORSCALE, 

827 "global_heatmap_colorscale": DEFAULT_HEATMAP_COLORSCALE, 

828 # Restorable by the Save & restore config too, so seed here (no inline 

829 # value=/index=) to avoid Streamlit's "default value but also set via 

830 # Session State API" warning when a restore pre-sets them. 

831 "global_critical_span_style": "Mark text", 

832 "global_span_border_color": "#000000", 

833 # Fixation classification (viz-only — PRE-2). SHORT / LONG / OUT-OF-BOUNDS 

834 # each get a mode (Off | Highlight | Discard); Highlight overlays a marker in 

835 # the chosen symbol+colour, Discard hides them from the plot only (reading 

836 # measures and export tables are untouched). Short/long thresholds in ms 

837 # follow eyekit's discard_short (~80) / discard_long (~800). 

838 "global_fixclass_short_mode": "Off", 

839 "global_fixclass_short_threshold_ms": 80, 

840 "global_fixclass_short_symbol": "triangle-up-open", 

841 "global_fixclass_short_color": "#ff7f0e", 

842 "global_fixclass_long_mode": "Off", 

843 "global_fixclass_long_threshold_ms": 800, 

844 "global_fixclass_long_symbol": "square-open", 

845 "global_fixclass_long_color": "#9467bd", 

846 "global_fixclass_oob_mode": "Off", 

847 "global_fixclass_oob_symbol": "x", 

848 "global_fixclass_oob_color": OUT_OF_TEXT_COLOR, 

849 "global_fixclass_blink_mode": "Off", 

850 "global_fixclass_blink_symbol": "diamond-open", 

851 "global_fixclass_blink_color": "#17becf", 

852 # VIZ-7: single-trial fixation-index window (start, end over `order_in_trial`) 

853 # for the main scanpath plot. `None` = full trial; the real bounds depend on 

854 # the selected trial's fixation count, so `render_plot_controls` resolves/clamps 

855 # the concrete (1, max_fix) range at render time (mirroring `multi_fix_range`). 

856 "single_fix_range": None, 

857 # Whether that window survives a trial change. Off = the window belongs to 

858 # the trial it was drawn on (switching trials shows the whole new trial); on 

859 # = re-apply it to every trial, clamped to each one's length. Pinned here so 

860 # it re-syncs when its popover first mounts on a later run (BUG-15). 

861 "single_fix_range_all_trials": False, 

862 # Show the A/B legend on the two-trial comparison overlay (CMP-2). Off by 

863 # default — the per-scanpath colours already tell the readings apart. 

864 "global_show_compare_legend": True, # #374 F26: names A and B 

865 # VIZ-13: reading measure shown in the word hover tooltip. "Off" (None) hides 

866 # the measure line; any canonical measure column name shows it. 

867 "global_word_hover_measure": "total_fixation_duration_ms", 

868 # Colour-bar styling (Axes & color bars expander). 

869 **{ 

870 f"global_{bar}_colorbar_{name}": default 

871 for bar in ("fixation", "heatmap") 

872 for name, default in ( 

873 ("orientation", "Vertical"), 

874 ("tickangle", 0), 

875 ("tickfont_size", 12), 

876 ) 

877 }, 

878 # EXP-5: title/caption on the figure (Figure & canvas group). Off by default; 

879 # the two patterns are only meaningful while the toggle is on — see 

880 # `_collect_viz_settings`, which reports them empty otherwise. 

881 "global_show_title": False, 

882 "global_show_caption": False, 

883 "global_title_pattern": "", 

884 "global_caption_pattern": "", 

885} 

886 

887 

888# Out-of-text fixation marker options: Plotly symbol → emoji-prefixed label (the 

889# emoji makes each choice stand out in the dropdown). 

890_OUT_OF_TEXT_MARKERS = { 

891 "x": "✕ Cross", 

892 "circle-open": "○ Circle", 

893 "diamond-open": "◇ Diamond", 

894 "square-open": "□ Square", 

895 "star": "★ Star", 

896 "triangle-up-open": "△ Triangle", 

897 "triangle-down-open": "▽ Triangle (down)", 

898} 

899 

900# Fixation-classification modes (PRE-2): a category can be left alone, marked with 

901# an overlay marker, or hidden from the plot (viz-only — never changes measures). 

902_FIXCLASS_MODES = ("Off", "Highlight", "Discard") 

903 

904 

905#: PRE-2's four fixation classes: ``(key prefix, row title, help, has a ms 

906#: threshold)``. UX-162 shortened the titles to fit one table row each. 

907_FIXCLASS_CATEGORIES = ( 

908 ("short", "Short", "Fixations shorter than the ms threshold.", True), 

909 ("long", "Long", "Fixations longer than the ms threshold.", True), 

910 ( 

911 "oob", 

912 "Out of bounds", 

913 "Fixations in no word box (gaps between lines count).", 

914 False, 

915 ), 

916 ("blink", "Blink", "Fixations at or next to a blink; needs a blink column.", False), 

917) 

918 

919 

920def _render_fixation_cleaning( 

921 *, disabled: bool = False, reason: str = "", prefix: str = "global" 

922) -> None: 

923 """PRE-2 short / long / out-of-bounds / blink visual filtering controls. 

924 

925 VIZ-27 gives this its own popover instead of burying data inclusion under 

926 marker styling. Viz-only: highlight or discard, with customizable short/long 

927 thresholds, all on the spot. 

928 

929 UX-162: one table row per class — its mode, then the ms threshold (short and 

930 long only), then the marker and colour a *Highlight* draws with — under a 

931 captioned header, instead of up to four rows each that came and went with the 

932 mode. What a mode leaves idle is greyed, not hidden. Every value rides a 

933 ``global_fixclass_{prefix}_*`` key (seeded in ``_VIZ_WIDGET_DEFAULTS``). 

934 

935 ``make_scanpath_figure`` and ``make_scanpath_animation`` both consume 

936 ``fixation_flags`` (VIZ-23 — *Discard* drops the rows before the replay's 

937 frames are built, *Highlight* overlays them as the trail reaches them); the 

938 comparison builders take no flags argument, so the whole block renders 

939 disabled (with the reason) in Compare only. 

940 

941 CMP-24: the comparison builders take the flags now, one set per scanpath. 

942 ``prefix`` is the key namespace — ``global`` for A (and every non-compare 

943 figure), ``cmp1`` for scanpath B, whose table has only the *Mode* and *ms* 

944 columns: B chooses which of its fixations are flagged, and a Highlight draws 

945 with A's marker and colour.""" 

946 own_look = prefix == "global" 

947 label_w = _label_w() 

948 rest = 1.0 - label_w 

949 weights = ( 

950 [label_w, rest * 0.3, rest * 0.22, rest * 0.32, rest * 0.16] 

951 if own_look 

952 else [label_w, rest * 0.5, rest * 0.5] 

953 ) 

954 mode_help = _gated_help( 

955 "**Highlight** marks these fixations; **Discard** hides their markers " 

956 "(saccades and the heatmap still use them; measures and exports are " 

957 "unchanged).", 

958 reason, 

959 ) 

960 head = st.columns(weights, gap=_LABEL_GAP, vertical_alignment="center") 

961 _sub_caption(head[1], "Mode", mode_help) 

962 _sub_caption(head[2], "ms", "Short: below this many ms. Long: above it.") 

963 if own_look: 

964 _sub_caption(head[3], "Marker", "The marker a Highlight draws.") 

965 _sub_caption(head[4], "Color") 

966 for category, label, row_help, has_threshold in _FIXCLASS_CATEGORIES: 

967 row_disabled, help_text = _layer_gate(disabled, _gated_help(row_help, reason)) 

968 cols = st.columns(weights, gap=_LABEL_GAP, vertical_alignment="center") 

969 _row_label(cols[0], label, help_text) 

970 mode = cols[1].selectbox( 

971 f"{label} fixations", 

972 options=_FIXCLASS_MODES, 

973 key=f"{prefix}_fixclass_{category}_mode", 

974 persist_state="session", 

975 disabled=row_disabled, 

976 help=mode_help, 

977 label_visibility="collapsed", 

978 ) 

979 if has_threshold: 

980 cols[2].number_input( 

981 f"{label} threshold (ms)", 

982 min_value=1, 

983 step=10, 

984 key=f"{prefix}_fixclass_{category}_threshold_ms", 

985 persist_state="session", 

986 disabled=row_disabled or mode == "Off", 

987 label_visibility="collapsed", 

988 ) 

989 if not own_look: 

990 continue 

991 highlight_idle = row_disabled or mode != "Highlight" 

992 cols[3].selectbox( 

993 f"{label} marker", 

994 options=list(_OUT_OF_TEXT_MARKERS), 

995 format_func=lambda s: _OUT_OF_TEXT_MARKERS[s], 

996 key=f"global_fixclass_{category}_symbol", 

997 persist_state="session", 

998 disabled=highlight_idle, 

999 label_visibility="collapsed", 

1000 ) 

1001 # `persist_state` keeps a picker first drawn in a popover from mounting 

1002 # at its proto default (black) — BUG-15 / ENG-36. 

1003 cols[4].color_picker( 

1004 f"{label} color", 

1005 key=f"global_fixclass_{category}_color", 

1006 persist_state="session", 

1007 disabled=highlight_idle, 

1008 label_visibility="collapsed", 

1009 ) 

1010 

1011 

1012def _collect_fixation_flags(prefix: str = "global") -> dict: 

1013 """Build the ``fixation_flags`` dict the figure builder consumes from the 

1014 ``global_fixclass_*`` session keys (PRE-2). One entry per category; ``oob`` has 

1015 no threshold. 

1016 

1017 CMP-24: ``prefix="cmp1"`` is scanpath B's set — its own modes and thresholds, 

1018 A's markers and colours (see ``_render_fixation_cleaning``).""" 

1019 ss = st.session_state 

1020 return { 

1021 "short": { 

1022 "mode": ss.get(f"{prefix}_fixclass_short_mode", "Off"), 

1023 "threshold_ms": float( 

1024 ss.get(f"{prefix}_fixclass_short_threshold_ms") or 80 

1025 ), 

1026 "symbol": ss.get("global_fixclass_short_symbol") or "triangle-up-open", 

1027 "color": ss.get("global_fixclass_short_color") or "#ff7f0e", 

1028 }, 

1029 "long": { 

1030 "mode": ss.get(f"{prefix}_fixclass_long_mode", "Off"), 

1031 "threshold_ms": float( 

1032 ss.get(f"{prefix}_fixclass_long_threshold_ms") or 800 

1033 ), 

1034 "symbol": ss.get("global_fixclass_long_symbol") or "square-open", 

1035 "color": ss.get("global_fixclass_long_color") or "#9467bd", 

1036 }, 

1037 "oob": { 

1038 "mode": ss.get(f"{prefix}_fixclass_oob_mode", "Off"), 

1039 "symbol": ss.get("global_fixclass_oob_symbol") or "x", 

1040 "color": ss.get("global_fixclass_oob_color") or OUT_OF_TEXT_COLOR, 

1041 }, 

1042 "blink": { 

1043 "mode": ss.get(f"{prefix}_fixclass_blink_mode", "Off"), 

1044 "symbol": ss.get("global_fixclass_blink_symbol") or "diamond-open", 

1045 "color": ss.get("global_fixclass_blink_color") or "#17becf", 

1046 }, 

1047 } 

1048 

1049 

1050def _fixation_filter_badge(prefix: str = "global") -> str: 

1051 """Compact VIZ-27 badge summarising active visual filters.""" 

1052 active = [ 

1053 st.session_state.get(f"{prefix}_fixclass_{name}_mode", "Off") 

1054 for name in ("short", "long", "oob", "blink") 

1055 ] 

1056 n_active = sum(mode != "Off" for mode in active) 

1057 n_discard = sum(mode == "Discard" for mode in active) 

1058 if not n_active: 

1059 return "" 

1060 detail = f"{n_active} on" 

1061 if n_discard: 

1062 detail += f", {n_discard} discarding" 

1063 return f" · {detail}" 

1064 

1065 

1066def _plot_filter_badge() -> str: 

1067 """UX-72: one badge for the whole Filters & highlights section. 

1068 

1069 The section folds the fixation and saccade filters together, so its header 

1070 has to answer "is anything being hidden?" for both — the reason each of them 

1071 badged its own trigger before (VIZ-27): a thinned figure otherwise reads as 

1072 missing data. The detail stays on each half's own badge inside. 

1073 """ 

1074 comparing = bool(st.session_state.get("_resolved_comparing")) 

1075 b_active = comparing and bool( 

1076 _fixation_filter_badge("cmp1") 

1077 or _saccade_filter_badge("cmp1_saccade_classes") 

1078 or st.session_state.get("single_compare_fix_range_user_set") 

1079 ) 

1080 return ( 

1081 " •" if _fixation_filter_badge() or _saccade_filter_badge() or b_active else "" 

1082 ) 

1083 

1084 

1085def _saccade_filter_badge(key: str = "global_saccade_classes") -> str: 

1086 """Compact badge summarising the VIZ-31 saccade reading-class filter. 

1087 

1088 Mirrors :func:`_fixation_filter_badge` — an active filter must be visible 

1089 without opening the popover, or a figure missing half its saccades reads as 

1090 a data problem. Empty (or a full selection) means no filter, so no badge. 

1091 """ 

1092 selected = st.session_state.get(key) 

1093 if not selected: 

1094 return "" 

1095 hidden = [cls for cls in SACCADE_CLASS_ORDER if cls not in set(selected)] 

1096 if not hidden: 

1097 return "" 

1098 if len(hidden) == len(SACCADE_CLASS_ORDER) - 1: 

1099 # One class left standing — name it; "5 hidden" says much less than 

1100 # "regression only" when that is the whole point of the figure. 

1101 kept = next(c for c in SACCADE_CLASS_ORDER if c not in set(hidden)) 

1102 return f" · {SACCADE_CLASS_LABELS[kept].lower()} only" 

1103 return f" · {len(hidden)} types hidden" 

1104 

1105 

1106# Quick-view presets: one click starts from the app's visualization defaults and 

1107# then applies the focused layer/style overrides below. A named view is therefore 

1108# deterministic: returning to Scanpath cannot keep a colour, size, filter or 

1109# geometry edit made in Custom. The persistent Custom tile owns that hand-tuned 

1110# state and restores it exactly when selected again. 

1111_ILLUSTRATION_OVERRIDE_KEYS = ( 

1112 "global_saccade_render_mode", 

1113 "global_fixation_snap_to_word", 

1114 "global_saccade_color_mode", 

1115 "global_fixation_opacity", 

1116) 

1117_PRE_ILLUSTRATION_STATE = "_quick_view_pre_illustration" 

1118_QUICK_VIEW_SELECTION_KEY = "_quick_view_selection" 

1119_QUICK_VIEW_CUSTOM_STATE = "_quick_view_custom_state" 

1120_QUICK_VIEW_APPLIED_STATE = "_quick_view_applied_state" 

1121#: The design a drift to Custom left, and its baseline: ``(selection, state)``. 

1122#: While the highlight reads Custom, settings that come back to that baseline 

1123#: (Compare switched on and off again) put the design's highlight back. 

1124_QUICK_VIEW_DRIFTED_FROM = "_quick_view_drifted_from" 

1125_CUSTOM_VIEW = "custom" 

1126 

1127#: VIZ-39 — the user's own saved designs: ``{name: {global_key: value}}``. 

1128#: The three built-ins are *code*; these are data, and they are what the 

1129#: "Quick views" row was standing in for — it offered exactly one unnamed 

1130#: snapshot ("your most recent custom settings"), which could not be kept, 

1131#: compared, or come back to. 

1132DESIGN_PRESETS_KEY = _DESIGN_PRESETS_WIRE_KEY 

1133#: Which saved design the ✏️ button has open for editing, if any. 

1134_DESIGN_EDIT_KEY = "_design_preset_editing" 

1135#: Whether the 💾 "Save current design" modal is open. 

1136_DESIGN_SAVE_PENDING_KEY = "_design_save_pending" 

1137#: Which saved design the 🗑️ confirmation is asking about, if any. 

1138_DESIGN_DELETE_PENDING_KEY = "_design_delete_pending" 

1139#: The 💾 dialog's two ways of saving, and the radio that picks between them. 

1140_SAVE_MODE_NEW = "new" 

1141_SAVE_MODE_REPLACE = "replace" 

1142_DESIGN_SAVE_MODE_KEY = "design_save_mode" 

1143#: `[name | ✏️ | 🗑️]` — the shared column split, so the rename field lands 

1144#: exactly where the name it replaces was. 

1145_DESIGN_ROW_W = (0.62, 0.19, 0.19) 

1146#: A selection of `design:<name>`, kept in the same slot as the built-ins so 

1147#: `_sync_quick_view_state`'s drift detection applies to saved designs too — 

1148#: touch any control and the highlight drops, exactly as for a built-in. 

1149_DESIGN_SELECTION_PREFIX = "design:" 

1150 

1151 

1152def design_presets() -> dict[str, dict]: 

1153 """The user's saved designs, newest last. Always a dict (VIZ-39).""" 

1154 stored = st.session_state.get(DESIGN_PRESETS_KEY) 

1155 return stored if isinstance(stored, dict) else {} 

1156 

1157 

1158def _design_selection(name: str) -> str: 

1159 return f"{_DESIGN_SELECTION_PREFIX}{name}" 

1160 

1161 

1162def selected_design_name() -> str | None: 

1163 """The saved design currently applied, or ``None``.""" 

1164 selected = st.session_state.get(_QUICK_VIEW_SELECTION_KEY) 

1165 if isinstance(selected, str) and selected.startswith(_DESIGN_SELECTION_PREFIX): 

1166 return selected[len(_DESIGN_SELECTION_PREFIX) :] 

1167 return None 

1168 

1169 

1170def save_design_preset(name: str) -> str | None: 

1171 """Store the live plot settings under ``name``. Returns the name taken. 

1172 

1173 An existing name is **overwritten**, which is what the ✏️ editor's *Update 

1174 to current settings* means; the caller is what distinguishes saving a new 

1175 design from updating one, because only it knows which the user asked for. 

1176 """ 

1177 clean = " ".join(str(name).split())[:60] 

1178 if not clean: 

1179 return None 

1180 # A design named after a built-in would shadow it in `_apply_view_preset`, 

1181 # which checks saved designs first — so the built-in button would silently 

1182 # start applying someone else's settings. 

1183 if clean in _VIEW_PRESETS: 

1184 clean = f"{clean} (mine)" 

1185 presets = dict(design_presets()) 

1186 presets[clean] = _capture_quick_view_state() 

1187 st.session_state[DESIGN_PRESETS_KEY] = presets 

1188 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _design_selection(clean) 

1189 st.session_state.pop(_QUICK_VIEW_DRIFTED_FROM, None) 

1190 # Pop rather than snapshot, exactly as `_apply_view_preset` does. Saving runs 

1191 # inside the dialog's fragment frame, where popovers that are open have their 

1192 # `*__num` slider twins in session_state; those keys are collected the moment 

1193 # the popover closes, so a baseline taken here would read as drift on the 

1194 # next full run and drop the highlight off the design just saved. Letting 

1195 # `_sync_quick_view_state` take the baseline puts it in the same frame as 

1196 # the comparison. 

1197 st.session_state.pop(_QUICK_VIEW_APPLIED_STATE, None) 

1198 return clean 

1199 

1200 

1201def rename_design_preset(old: str, new: str) -> str | None: 

1202 """Rename a saved design in place, keeping its position in the list.""" 

1203 clean = " ".join(str(new).split())[:60] 

1204 presets = design_presets() 

1205 if not clean or old not in presets or clean == old: 

1206 return None 

1207 st.session_state[DESIGN_PRESETS_KEY] = { 

1208 (clean if key == old else key): value for key, value in presets.items() 

1209 } 

1210 if selected_design_name() == old: 

1211 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _design_selection(clean) 

1212 return clean 

1213 

1214 

1215def delete_design_preset(name: str) -> None: 

1216 """Forget a saved design. The live plot settings are left exactly as they are. 

1217 

1218 Deleting the design you are *looking at* must not change the figure — the 

1219 settings are already applied and are the user's own. Only the highlight 

1220 goes, which `_sync_quick_view_state` handles by falling through to Custom. 

1221 """ 

1222 presets = dict(design_presets()) 

1223 presets.pop(name, None) 

1224 st.session_state[DESIGN_PRESETS_KEY] = presets 

1225 if selected_design_name() == name: 

1226 st.session_state[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW 

1227 if st.session_state.get(_DESIGN_EDIT_KEY) == name: 

1228 st.session_state.pop(_DESIGN_EDIT_KEY, None) 

1229 

1230 

1231#: UX-179 — the saved-design file: `{"kind": DESIGNS_FILE_KIND, "designs": {…}}`. 

1232#: The retired 💾 Session backup was the only portable copy of the library; this 

1233#: is its own file now, written by *Export* and read by *Import* in My designs. 

1234DESIGNS_FILE_KIND = "scanpath_studio_designs" 

1235#: 2 — the fixed duration scale. A schema-1 design predates it, so it keeps 

1236#: the relative marker scale it was drawn with (`keep_legacy_marker_scale`). 

1237DESIGNS_FILE_SCHEMA = 2 

1238_DESIGN_IMPORT_KEY = "design_import_upload" 

1239_DESIGN_IMPORT_NOTE_KEY = "_design_import_note" 

1240 

1241 

1242def designs_to_json(designs: dict[str, dict]) -> str: 

1243 """Serialize a design library to the Export file (pure — no Streamlit).""" 

1244 from scanpath_studio import __version__ 

1245 

1246 return json.dumps( 

1247 { 

1248 "kind": DESIGNS_FILE_KIND, 

1249 "schema": DESIGNS_FILE_SCHEMA, 

1250 "app": {"name": "Scanpath Studio", "version": __version__}, 

1251 "designs": {name: dict(values) for name, values in designs.items()}, 

1252 }, 

1253 indent=2, 

1254 ) 

1255 

1256 

1257def sanitize_design(values: dict) -> tuple[dict, list[str]]: 

1258 """A design's settings as they may be applied, and the keys that were not. 

1259 

1260 Keeps the keys :func:`_is_design_key` names, each through the typed, 

1261 bounded rule a link or the recovery cache is read with 

1262 (``url_state.sanitize_session_value``): numbers clamped to their widget's 

1263 bounds, colours ``#rrggbb``, switches booleans, choices from their 

1264 vocabulary. A value that fails is left out and its key returned, so one bad 

1265 setting never reaches a widget or ``_collect_viz_settings``. 

1266 """ 

1267 from .url_state import sanitize_session_value 

1268 

1269 clean: dict = {} 

1270 skipped: list[str] = [] 

1271 for key, value in values.items(): 

1272 key = str(key) 

1273 if not _is_design_key(key): 

1274 continue 

1275 try: 

1276 clean[key] = sanitize_session_value(key, value) 

1277 except (TypeError, ValueError, OverflowError): 

1278 skipped.append(key) 

1279 return clean, skipped 

1280 

1281 

1282def designs_from_json(text: str, *, report: list[str] | None = None) -> dict[str, dict]: 

1283 """Parse an Export file into ``{name: settings}`` (pure — no Streamlit). 

1284 

1285 Keeps only what a design can hold — the keys :func:`_is_design_key` names, 

1286 as `_apply_view_preset` applies them, each validated by 

1287 :func:`sanitize_design` — and gives a name that collides with a built-in 

1288 the same ``" (mine)"`` suffix :func:`save_design_preset` does. What was 

1289 left out is appended to ``report`` as one line per design (and one for a 

1290 file written by a newer version). Raises ``ValueError`` for anything that 

1291 is not a designs file. 

1292 """ 

1293 data = json.loads(text) 

1294 if not isinstance(data, dict) or data.get("kind") != DESIGNS_FILE_KIND: 

1295 raise ValueError("not a Scanpath Studio designs file") 

1296 raw = data.get("designs") 

1297 if not isinstance(raw, dict): 

1298 raise ValueError("the file holds no designs") 

1299 try: 

1300 schema = int(data.get("schema", 1)) 

1301 except (TypeError, ValueError, OverflowError): 

1302 schema = 1 

1303 notes = report if report is not None else [] 

1304 if schema > DESIGNS_FILE_SCHEMA: 

1305 notes.append( 

1306 "From a newer version; settings this one doesn't know were skipped" 

1307 ) 

1308 designs: dict[str, dict] = {} 

1309 for name, values in raw.items(): 

1310 clean = " ".join(str(name).split())[:60] 

1311 if not clean or not isinstance(values, dict): 

1312 if clean: 

1313 notes.append(f"{clean}: not a design, skipped") 

1314 continue 

1315 if clean in _VIEW_PRESETS: 

1316 clean = f"{clean} (mine)" 

1317 design = { 

1318 str(key): value for key, value in values.items() if _is_design_key(key) 

1319 } 

1320 if schema < 2: 

1321 design = keep_legacy_marker_scale(design) 

1322 # A design saved before a key was renamed holds the old name. 

1323 design, skipped = sanitize_design(rename_legacy_keys(design)) 

1324 designs[clean] = design 

1325 if skipped: 

1326 notes.append( 

1327 f"{clean}: skipped {len(skipped)} invalid setting" 

1328 + ("" if len(skipped) == 1 else "s") 

1329 ) 

1330 return designs 

1331 

1332 

1333def _import_designs() -> None: 

1334 """``on_change`` of the Import uploader: merge the file into the library. 

1335 

1336 A design with the same name is **replaced** — the file is the newer copy 

1337 of it — and every other design the user has stays. 

1338 """ 

1339 uploaded = st.session_state.get(_DESIGN_IMPORT_KEY) 

1340 if uploaded is None: 

1341 return 

1342 report: list[str] = [] 

1343 try: 

1344 incoming = designs_from_json(uploaded.getvalue().decode("utf-8"), report=report) 

1345 except (ValueError, UnicodeDecodeError): 

1346 st.session_state[_DESIGN_IMPORT_NOTE_KEY] = ( 

1347 "error:Couldn't import it: it isn't a designs file." 

1348 ) 

1349 return 

1350 st.session_state[DESIGN_PRESETS_KEY] = {**design_presets(), **incoming} 

1351 count = len(incoming) 

1352 note = f"Imported {count} design{'' if count == 1 else 's'}." 

1353 if report: 

1354 note = f"warning:{note} " + "; ".join(report) + "." 

1355 st.session_state[_DESIGN_IMPORT_NOTE_KEY] = note 

1356 

1357 

1358def _render_design_file_row(host, saved: dict[str, dict]) -> None: 

1359 """*Export* / *Import* under the design list (UX-179).""" 

1360 note = st.session_state.pop(_DESIGN_IMPORT_NOTE_KEY, None) 

1361 if note and note.startswith("error:"): 

1362 host.error(note.removeprefix("error:"), icon=ICONS["error"]) 

1363 elif note and note.startswith("warning:"): 

1364 host.warning(note.removeprefix("warning:"), icon=ICONS["warning"]) 

1365 elif note: 

1366 host.success(note, icon=ICONS["confirm"]) 

1367 row = host.container(horizontal=True, gap="small", key="design_file_row") 

1368 row.download_button( 

1369 "Export", 

1370 icon=ICONS["download"], 

1371 data=designs_to_json(saved), 

1372 file_name="scanpath_studio_designs.json", 

1373 mime="application/json", 

1374 key="design_export", 

1375 disabled=not saved, 

1376 help="Download your saved designs as a JSON file, to use on another " 

1377 "computer or share.", 

1378 ) 

1379 with row.popover("Import", icon=ICONS["upload"]): 

1380 st.file_uploader( 

1381 "Designs file (JSON)", 

1382 type=["json"], 

1383 key=_DESIGN_IMPORT_KEY, 

1384 on_change=_import_designs, 

1385 max_upload_size=upload_limit_mb(), 

1386 ) 

1387 st.caption( 

1388 "A file exported here. A design with the same name as one of yours " 

1389 "replaces it; the rest are added." 

1390 ) 

1391 

1392 

1393def _toggle_design_editor(name: str) -> None: 

1394 """✏️ opens the inline editor for one design, and closes any other.""" 

1395 current = st.session_state.get(_DESIGN_EDIT_KEY) 

1396 st.session_state[_DESIGN_EDIT_KEY] = None if current == name else name 

1397 

1398 

1399_VIEW_PRESETS: dict[str, dict[str, object]] = { 

1400 "scanpath": { 

1401 "global_show_fix": True, 

1402 "global_show_saccades": True, 

1403 "global_show_saccade_arrows": False, 

1404 "global_show_labels": True, 

1405 "global_show_stimulus": True, 

1406 "global_show_order": False, 

1407 "global_show_heatmap": False, 

1408 "global_show_words": False, 

1409 "global_show_raw_gaze": False, 

1410 }, 

1411 "heatmap": { 

1412 "global_show_heatmap": True, 

1413 "global_show_labels": True, 

1414 "global_show_stimulus": True, 

1415 "global_show_fix": False, 

1416 "global_show_saccades": False, 

1417 "global_show_order": False, 

1418 "global_show_words": False, 

1419 "global_show_raw_gaze": False, 

1420 # #374 F16: the word colours are the whole figure; a highlighted span 

1421 # would read as part of the map. 

1422 "global_critical_span_style": "None", 

1423 }, 

1424 "illustration": { 

1425 "global_show_fix": True, 

1426 "global_show_saccades": True, 

1427 "global_show_saccade_arrows": False, 

1428 "global_show_labels": True, 

1429 "global_show_stimulus": True, 

1430 "global_show_order": False, 

1431 "global_show_heatmap": False, 

1432 "global_show_words": False, 

1433 "global_show_raw_gaze": False, 

1434 "global_saccade_render_mode": "Arc", 

1435 "global_fixation_snap_to_word": True, 

1436 "global_saccade_color_mode": "Uniform", 

1437 "global_fixation_opacity": 1.0, 

1438 }, 

1439 "reading_order": { 

1440 "global_show_fix": True, 

1441 "global_show_order": True, 

1442 "global_show_saccades": True, 

1443 "global_show_saccade_arrows": True, 

1444 "global_show_labels": True, 

1445 "global_show_stimulus": True, 

1446 "global_show_heatmap": False, 

1447 "global_show_words": False, 

1448 "global_show_raw_gaze": False, 

1449 }, 

1450 "everything": { 

1451 "global_show_words": True, 

1452 "global_show_labels": True, 

1453 "global_show_stimulus": True, 

1454 "global_show_fix": True, 

1455 "global_show_saccades": True, 

1456 "global_show_saccade_arrows": True, 

1457 "global_show_heatmap": True, 

1458 "global_show_order": False, 

1459 "global_show_raw_gaze": False, 

1460 }, 

1461} 

1462 

1463 

1464def _forget_raw_gaze_default(ss) -> None: 

1465 """Let the next run decide the raw-gaze layer for the open dataset again. 

1466 

1467 A named design or *Reset* puts the view back to the defaults, and on a 

1468 dataset whose only gaze is samples the default is **on** — a preset whose 

1469 name says nothing about raw gaze must not leave that dataset a blank plot. 

1470 Clearing the record (and the stashed pre-snap value, which the reset has 

1471 just made stale) is what lets `app.seed_raw_gaze_default` apply it.""" 

1472 ss.pop(RAW_GAZE_SEEDED_FOR_KEY, None) 

1473 ss.pop(RAW_GAZE_SNAP_RESTORE_KEY, None) 

1474 # …and the link's claim: both callers take the link's view params off the 

1475 # URL, so the dataset is decided afresh rather than left on the link's off. 

1476 ss.pop(RAW_GAZE_LINK_FOR_KEY, None) 

1477 

1478 

1479def _drop_linked_view_params() -> None: 

1480 """Take a deep link's view params off the URL once a design is chosen. 

1481 

1482 ``url_state._apply_url_preset`` re-applies them at the top of every rerun, 

1483 as ``setdefault`` — so any key the chosen design leaves unset is refilled 

1484 from the link. Every design leaves some unset; since VIZ-46 an *auto* 

1485 colour range is one of them (absent means auto), so a design saved on auto 

1486 came back showing the link's range. Selection/source params are not in 

1487 ``URL_PRESET_PARAMS`` and stay. 

1488 """ 

1489 from . import session_keys as _sk 

1490 

1491 for param in (*_sk.URL_PRESET_PARAMS, *_sk.LEGEND_PARAMS): 

1492 st.query_params.pop(param, None) 

1493 

1494 

1495def _apply_view_preset(name: str) -> None: 

1496 """Apply one deterministic named view, or restore the Custom snapshot. 

1497 

1498 Runs as a button ``on_click`` callback, i.e. *before* the next rerun 

1499 instantiates the layer checkboxes — so writing their ``global_show_*`` keys 

1500 here is picked up cleanly (no "set after widget instantiated" warning). 

1501 

1502 Named views always begin from ``_VIZ_WIDGET_DEFAULTS``. Existing dynamic 

1503 ``global_*`` values are cleared so the next app rerun can re-seed dataset- 

1504 dependent canvas/field defaults. Custom is the only view that preserves 

1505 manual edits; leaving it snapshots the complete live design state (``_is_design_key``). 

1506 """ 

1507 saved = design_presets() 

1508 if name not in {*_VIEW_PRESETS, _CUSTOM_VIEW, *saved}: 

1509 raise ValueError(f"Unknown design preset: {name}") 

1510 

1511 ss = st.session_state 

1512 before = { 

1513 key: deepcopy(ss[key]) 

1514 for key in list(ss) 

1515 if _is_design_key(key) or _is_restorable_global(key) 

1516 } 

1517 try: 

1518 _apply_view_preset_state(ss, name, saved) 

1519 finally: 

1520 _hold_view_writes(ss, before) 

1521 

1522 

1523def _hold_view_writes(ss, before: dict) -> None: 

1524 """#374 F9 for the design presets: hold every value a preset changed. 

1525 

1526 A popover widget the user has opened keeps echoing the value it last 

1527 showed, so a preset's write to it (Saccades ▾ → Arc, Fixations ▾ → Snap, 

1528 the defaults a preset resets to) would hold for one run and snap back. So 

1529 each changed value goes through `write_through`. The dataset-seeded keys 

1530 are left alone: the rerun seeds them from the data, and holding the 

1531 static default would overwrite that. 

1532 """ 

1533 for key, old in before.items(): 

1534 if key in _SEEDED_VIEW_KEYS or key not in ss: 

1535 continue 

1536 if _write_match_key(ss[key]) != _write_match_key(old): 

1537 write_through(key, ss[key]) 

1538 

1539 

1540#: Keys `app.seed_canvas_state` / the font and raw-gaze seeding refill after a 

1541#: preset clears their guards — see `_hold_view_writes`. 

1542_SEEDED_VIEW_KEYS = frozenset( 

1543 { 

1544 "global_canvas_width", 

1545 "global_canvas_height", 

1546 "global_base_font_size", 

1547 "global_font_family", 

1548 "global_scale_text_to_boxes", 

1549 "global_show_raw_gaze", 

1550 } 

1551) 

1552 

1553 

1554def _apply_view_preset_state(ss, name: str, saved: dict) -> None: 

1555 """The body of `_apply_view_preset`: write the chosen design's state.""" 

1556 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None) 

1557 current = ss.get(_QUICK_VIEW_SELECTION_KEY) 

1558 if current == _CUSTOM_VIEW: 

1559 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state() 

1560 

1561 # VIZ-39 — one of the user's own saved designs. Same shape as the built-in 

1562 # branch below (clear, then write what the design owns), and deliberately 

1563 # NOT the Custom branch's shape: a saved design is a *record* of settings, 

1564 # so it starts from the widget defaults like a built-in does rather than 

1565 # layering onto whatever happened to be on screen. 

1566 if name in saved: 

1567 for key in list(ss): 

1568 if _is_design_key(key): 

1569 ss.pop(key, None) 

1570 for key, value in _VIZ_WIDGET_DEFAULTS.items(): 

1571 if _is_design_key(key): 

1572 ss[key] = deepcopy(value) 

1573 # Validated again here: a design can also come from the recovery 

1574 # cache or an older session, and a bad value must cost that setting, 

1575 # not the rerun (round 11). 

1576 for key, value in sanitize_design(saved[name])[0].items(): 

1577 ss[key] = deepcopy(value) 

1578 ss.pop("_canvas_seeded_for", None) 

1579 ss.pop("_font_seeded_for", None) 

1580 ss.pop("_palette_picked", None) 

1581 ss.pop(_PRE_ILLUSTRATION_STATE, None) 

1582 _drop_linked_view_params() 

1583 ss[_QUICK_VIEW_SELECTION_KEY] = _design_selection(name) 

1584 ss.pop(_QUICK_VIEW_APPLIED_STATE, None) 

1585 return 

1586 

1587 if name == _CUSTOM_VIEW: 

1588 custom = ss.get(_QUICK_VIEW_CUSTOM_STATE) 

1589 if isinstance(custom, dict): 

1590 for key in list(ss): 

1591 if _is_design_key(key): 

1592 ss.pop(key, None) 

1593 for key, value in custom.items(): 

1594 if _is_design_key(key): 

1595 ss[key] = deepcopy(value) 

1596 _drop_linked_view_params() 

1597 ss[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW 

1598 ss.pop(_QUICK_VIEW_APPLIED_STATE, None) 

1599 return 

1600 

1601 for key in list(ss): 

1602 if _is_restorable_global(key): 

1603 ss.pop(key, None) 

1604 for key, value in _VIZ_WIDGET_DEFAULTS.items(): 

1605 if _is_restorable_global(key): 

1606 ss[key] = deepcopy(value) 

1607 # Canvas/font values are source-dependent rather than static defaults. 

1608 # Removing their guards lets app.seed_canvas_state restore them on the rerun 

1609 # that follows this button callback. 

1610 ss.pop("_canvas_seeded_for", None) 

1611 ss.pop("_font_seeded_for", None) 

1612 ss.pop("_palette_picked", None) 

1613 ss.pop(_PRE_ILLUSTRATION_STATE, None) 

1614 # VIZ-45 — the raw-gaze layer is dataset-dependent too. Not for a saved 

1615 # design above: that is the user's own record, raw-gaze switch included. 

1616 _forget_raw_gaze_default(ss) 

1617 

1618 # A deep-link preset is applied at the top of every rerun. Once the user has 

1619 # explicitly chosen a design preset it must not immediately put the old visual 

1620 # settings back; selection/source parameters are not part of this list. 

1621 _drop_linked_view_params() 

1622 

1623 for key, value in _VIEW_PRESETS[name].items(): 

1624 ss[key] = deepcopy(value) 

1625 ss[_QUICK_VIEW_SELECTION_KEY] = name 

1626 # The complete baseline is captured by `_sync_quick_view_state` after the 

1627 # next rerun has restored data-dependent defaults. 

1628 ss.pop(_QUICK_VIEW_APPLIED_STATE, None) 

1629 

1630 

1631#: Widget keys a design preset must not carry, even though they are `global_*`. 

1632#: `st.file_uploader` refuses to have its key assigned from session state at 

1633#: all, so snapshotting one and writing it back raises 

1634#: `StreamlitValueAssignmentNotAllowedError` and takes the whole page down — 

1635#: which is what happened the moment a Custom view was captured with a stimulus 

1636#: image attached. The `_upload` suffix is the same convention 

1637#: `url_state._restore_column_mapping` already excludes by. 

1638def _is_restorable_global(key: object) -> bool: 

1639 name = str(key) 

1640 return name.startswith("global_") and not name.endswith("_upload") 

1641 

1642 

1643#: The fixation-index windows (VIZ-7, CMP-24) — one per scanpath, each a value 

1644#: plus the flag that says it was *chosen* rather than the slider's own 

1645#: auto-default. They are filters, so a design owns them; see 

1646#: `_capture_quick_view_state` for how they are recorded. 

1647_FIX_WINDOWS = ( 

1648 ("single_fix_range", "single_fix_range_user_set"), 

1649 (SINGLE_COMPARE_FIX_RANGE, f"{SINGLE_COMPARE_FIX_RANGE}_user_set"), 

1650) 

1651_FIX_WINDOW_KEYS = frozenset(key for pair in _FIX_WINDOWS for key in pair) 

1652_FIX_WINDOW_ALL_TRIALS_KEY = "single_fix_range_all_trials" 

1653 

1654#: Plot controls that are not `global_*` — the rest of what the rail, the Compare 

1655#: view and the replay let you set. A design used to snapshot only `global_*`, 

1656#: so it kept scanpath A's filters and dropped everything beside them: each 

1657#: scanpath's own styling (`cmp{0,1}_*`), scanpath B's filters 

1658#: (`cmp1_fixclass_*`, `cmp1_saccade_classes`), whether Compare is on and how it 

1659#: lays out, the fixation windows and the replay speed. Only the stimulus-image 

1660#: upload stays out, and that is `_is_restorable_global`'s. 

1661_DESIGN_EXTRA_KEYS = ( 

1662 compare_state_keys(0) 

1663 | compare_state_keys(1) 

1664 | COMPARE_B_FILTER_STATE_KEYS 

1665 | { 

1666 SINGLE_COMPARE_TOGGLE, 

1667 SINGLE_COMPARE_LAYOUT, 

1668 SINGLE_COMPARE_STIMULUS, 

1669 SINGLE_PLAYBACK_SPEED, 

1670 _FIX_WINDOW_ALL_TRIALS_KEY, 

1671 } 

1672 | _FIX_WINDOW_KEYS 

1673) 

1674 

1675 

1676def _is_design_key(key: object) -> bool: 

1677 """Whether a session key is a plot setting a design records and restores.""" 

1678 return _is_restorable_global(key) or str(key) in _DESIGN_EXTRA_KEYS 

1679 

1680 

1681def _render_saved_designs(host) -> None: 

1682 """The user's own designs: save, apply, rename, delete (VIZ-39). 

1683 

1684 Its foot is *Export* / *Import* (UX-179): the library's portable file, 

1685 which used to ride in the 💾 Session backup. 

1686 

1687 The list is an expander, because it grows and the rail is narrow — and 

1688 because it is the *built-ins* above that should stay one click away. The 💾 

1689 button is drawn **into the expander's own title bar** (`.st-key-design_shell` 

1690 is the positioning context; the CSS lives in `styles.py`) rather than in a 

1691 column beside it: a column would take a fifth of the rail's width away from 

1692 the list underneath for a single icon, and the header row's right-hand side 

1693 is empty space Streamlit is not using. Saving stays on screen whether the 

1694 list is open or shut, which is the point. 

1695 """ 

1696 saved = design_presets() 

1697 active = selected_design_name() 

1698 label = ( 

1699 f"{ICONS['designs']} My designs ({len(saved)})" 

1700 if saved 

1701 else f"{ICONS['designs']} My designs" 

1702 ) 

1703 shell = host.container(key="design_shell") 

1704 with shell.expander(label, expanded=bool(st.session_state.get(_DESIGN_EDIT_KEY))): 

1705 if not saved: 

1706 st.caption( 

1707 "No saved designs yet. Set the layers, colors and figure up " 

1708 f"the way you like them, then hit {ICONS['save']} — it lands here, one click " 

1709 "from every trial you look at afterwards." 

1710 ) 

1711 for name in saved: 

1712 # One bordered container per design, so a design reads as one object 

1713 # rather than as three buttons that happen to be adjacent. 

1714 row = st.container(border=True, key=f"design_row_{name}") 

1715 if st.session_state.get(_DESIGN_EDIT_KEY) == name: 

1716 _render_design_rename(row, name) 

1717 continue 

1718 cells = row.columns(_DESIGN_ROW_W, vertical_alignment="center") 

1719 cells[0].button( 

1720 name, 

1721 key=f"design_apply_{name}", 

1722 type="primary" if active == name else "tertiary", 

1723 width="stretch", 

1724 help=f"Apply “{name}”.", 

1725 on_click=_apply_view_preset, 

1726 args=(name,), 

1727 ) 

1728 # Material icons rather than emoji: emoji render at whatever size and 

1729 # baseline the platform font decides, which is what made these two 

1730 # sit high and unaligned in their buttons. 

1731 # UX-200: `spoken` names each icon for screen readers. 

1732 cells[1].button( 

1733 spoken(f"Rename design {name}"), 

1734 icon=ICONS["edit"], 

1735 wrap=True, 

1736 key=f"design_edit_{name}", 

1737 type="tertiary", 

1738 width="stretch", 

1739 help="Rename this design.", 

1740 on_click=_toggle_design_editor, 

1741 args=(name,), 

1742 ) 

1743 cells[2].button( 

1744 spoken(f"Delete design {name}"), 

1745 icon=ICONS["delete"], 

1746 wrap=True, 

1747 key=f"design_delete_{name}", 

1748 type="tertiary", 

1749 width="stretch", 

1750 help="Delete this design.", 

1751 on_click=_ask_delete_design, 

1752 args=(name,), 

1753 ) 

1754 _render_design_file_row(st, saved) 

1755 if shell.button( 

1756 spoken("Save the plot settings as a design"), 

1757 icon=ICONS["save"], 

1758 wrap=True, 

1759 key="design_save", 

1760 help="Save the plot settings on screen now as a named design.", 

1761 ): 

1762 st.session_state[_DESIGN_SAVE_PENDING_KEY] = True 

1763 st.session_state[_DESIGN_SAVE_MODE_KEY] = _SAVE_MODE_NEW 

1764 if st.session_state.get(_DESIGN_SAVE_PENDING_KEY): 

1765 _design_save_dialog() 

1766 pending_delete = st.session_state.get(_DESIGN_DELETE_PENDING_KEY) 

1767 if pending_delete in saved: 

1768 _design_delete_dialog(str(pending_delete)) 

1769 

1770 

1771def _ask_delete_design(name: str) -> None: 

1772 """🗑️ arms the confirmation instead of deleting (VIZ-39). 

1773 

1774 A design is a handful of choices the user made and cannot get back by 

1775 undoing anything, so it gets the same confirm step ♻️ Reset visualization 

1776 has — and for the same reason, an `on_click` that only sets a flag, because 

1777 a callback may not open a dialog. 

1778 """ 

1779 st.session_state[_DESIGN_DELETE_PENDING_KEY] = name 

1780 st.session_state.pop(_DESIGN_EDIT_KEY, None) 

1781 

1782 

1783def _close_design_delete_dialog() -> None: 

1784 """Disarm the 🗑️ confirmation. Also its ``on_dismiss`` hook.""" 

1785 st.session_state.pop(_DESIGN_DELETE_PENDING_KEY, None) 

1786 

1787 

1788@st.dialog("Delete this design?", on_dismiss=_close_design_delete_dialog) 

1789@guarded() 

1790def _design_delete_dialog(name: str) -> None: 

1791 """Confirm forgetting one saved design — VIZ-39.""" 

1792 st.caption( 

1793 f"**{name}** will be deleted. The plot on screen does not change: these " 

1794 "settings stay applied until you pick another design." 

1795 ) 

1796 yes, no = st.columns(2, gap="small") 

1797 if yes.button( 

1798 f"{ICONS['delete']} Delete it", 

1799 key="design_delete_confirm", 

1800 type="primary", 

1801 width="stretch", 

1802 ): 

1803 delete_design_preset(name) 

1804 _close_design_delete_dialog() 

1805 st.rerun(scope="app") 

1806 if no.button("Cancel", key="design_delete_cancel", width="stretch"): 

1807 _close_design_delete_dialog() 

1808 st.rerun(scope="app") 

1809 

1810 

1811def _close_design_save_dialog() -> None: 

1812 """Disarm the modal. Also the ``on_dismiss`` hook — see below.""" 

1813 st.session_state.pop(_DESIGN_SAVE_PENDING_KEY, None) 

1814 

1815 

1816# `on_dismiss` is what keeps a *flag*-driven dialog honest: ✕ and Esc close the 

1817# modal in the browser without running a line of the body, so without this the 

1818# flag stayed armed and the dialog reopened on the very next rerun — clicking a 

1819# preset, toggling a layer, anything. 

1820@st.dialog("Save current design", on_dismiss=_close_design_save_dialog) 

1821@guarded() 

1822def _design_save_dialog() -> None: 

1823 """Name the settings on screen and keep them (VIZ-39). 

1824 

1825 A **form**, so ⏎ is the same as clicking 💾 Save: Streamlit routes Enter to 

1826 the form's *first* submit button, which is why Save is written before Cancel 

1827 and why it is never `disabled` (a disabled first button turns Enter off for 

1828 the whole form — an empty name is caught below instead). 

1829 

1830 Opened from a pending flag rather than the button's return value, and closed 

1831 with an explicit ``scope="app"`` rerun, for the same reason as 

1832 `_reset_viz_confirmation_dialog`: a dialog body is a fragment. 

1833 """ 

1834 saved = design_presets() 

1835 mode = _SAVE_MODE_NEW 

1836 if saved: 

1837 # Outside the form on purpose. A form batches its widgets and does not 

1838 # rerun until it is submitted, so a radio *inside* one cannot change 

1839 # what the form shows — the choice has to be made where it can. 

1840 mode = st.radio( 

1841 "Save the settings on screen as", 

1842 options=(_SAVE_MODE_NEW, _SAVE_MODE_REPLACE), 

1843 format_func=lambda choice: ( 

1844 "A new design" 

1845 if choice == _SAVE_MODE_NEW 

1846 else "A replacement for one I saved" 

1847 ), 

1848 key=_DESIGN_SAVE_MODE_KEY, 

1849 ) 

1850 with st.form("design_save_form", border=False): 

1851 if mode == _SAVE_MODE_REPLACE: 

1852 name = st.selectbox( 

1853 "Design to replace", 

1854 options=list(saved), 

1855 key="design_replace_target", 

1856 ) 

1857 st.warning( 

1858 "The chosen design's stored settings are **overwritten** by " 

1859 "the ones on screen now. What it held is not recoverable.", 

1860 icon=ICONS["warning"], 

1861 ) 

1862 else: 

1863 name = st.text_input( 

1864 "Design name", 

1865 key="design_new_name", 

1866 placeholder="e.g. Paper figure", 

1867 # Streamlit 1.65 blocks the form's Save until there is a name; 

1868 # `save_design_preset` still refuses a blank one server-side. 

1869 required=True, 

1870 help="Stores every plot setting on screen now — layers, " 

1871 "colors, filter, figure and canvas.", 

1872 ) 

1873 row = st.columns(2, gap="small") 

1874 save = row[0].form_submit_button( 

1875 f"{ICONS['save']} Save", type="primary", width="stretch" 

1876 ) 

1877 cancel = row[1].form_submit_button("Cancel", width="stretch") 

1878 if cancel: 

1879 _close_design_save_dialog() 

1880 st.rerun(scope="app") 

1881 if save: 

1882 if not save_design_preset(name or ""): 

1883 st.error("Give the design a name first.") 

1884 else: 

1885 st.session_state.pop("design_new_name", None) 

1886 _close_design_save_dialog() 

1887 st.rerun(scope="app") 

1888 

1889 

1890def _render_design_rename(row, name: str) -> None: 

1891 """✏️ turns the card *itself* into the rename field — VIZ-39. 

1892 

1893 The box sits exactly where the name was and the two icons keep their slots, 

1894 so nothing moves under the cursor and the list does not grow a panel: the 

1895 card is either a design you can apply or a name you are typing. 

1896 

1897 A **form**, because ⏎ has to commit — Streamlit routes Enter to the first 

1898 submit button, which is ✓. Both buttons are `on_click` callbacks rather than 

1899 return values, so the row is already drawn in its new state on the rerun the 

1900 submit itself causes; handling the result after the fact would draw the card 

1901 once in the state the user just left. 

1902 """ 

1903 with row.form(f"design_rename_form_{name}", border=False): 

1904 cells = st.columns(_DESIGN_ROW_W, vertical_alignment="center") 

1905 cells[0].text_input( 

1906 "Name", 

1907 value=name, 

1908 key=f"design_rename_{name}", 

1909 label_visibility="collapsed", 

1910 required=True, 

1911 ) 

1912 # Both need an explicit `key`: a submit button's identity is its label, 

1913 # and these two shared the empty one — the icon is not part of it, so 

1914 # the second silently collapsed to a 0-height cell without them. 

1915 # UX-200 named them for screen readers (`spoken`). 

1916 cells[1].form_submit_button( 

1917 spoken("Save the new name"), 

1918 icon=ICONS["confirm"], 

1919 wrap=True, 

1920 key=f"design_rename_go_{name}", 

1921 type="tertiary", 

1922 width="stretch", 

1923 help=f"Rename “{name}”.", 

1924 on_click=_rename_named_design, 

1925 args=(name,), 

1926 ) 

1927 cells[2].form_submit_button( 

1928 spoken("Cancel renaming"), 

1929 icon=ICONS["close"], 

1930 wrap=True, 

1931 key=f"design_rename_cancel_{name}", 

1932 type="tertiary", 

1933 width="stretch", 

1934 help="Keep the name it has.", 

1935 on_click=_cancel_design_rename, 

1936 ) 

1937 

1938 

1939def _cancel_design_rename() -> None: 

1940 st.session_state.pop(_DESIGN_EDIT_KEY, None) 

1941 

1942 

1943def _rename_named_design(old: str) -> None: 

1944 """``on_click`` for ✓: read the card's box, then leave edit mode. 

1945 

1946 Always leaves it, including when the name is blank or unchanged — ✓ means 

1947 *done*, and ✕ is there for backing out. `rename_design_preset` no-ops on 

1948 both, so neither can lose a design. 

1949 """ 

1950 rename_design_preset(old, st.session_state.get(f"design_rename_{old}") or "") 

1951 st.session_state.pop(_DESIGN_EDIT_KEY, None) 

1952 

1953 

1954def _capture_quick_view_state() -> dict[str, object]: 

1955 """Snapshot every live plot setting for the persistent Custom view and for 

1956 saved designs — the `global_*` keys plus `_DESIGN_EXTRA_KEYS`. 

1957 

1958 Uploader keys are left out (see `_is_restorable_global`): an `UploadedFile` 

1959 is neither deep-copyable in any useful sense nor assignable back, and the 

1960 image itself is not a *setting* — it survives on its own widget key across 

1961 the view switch regardless. 

1962 

1963 A fixation window is recorded only once it has been *chosen*: the slider 

1964 rewrites its auto-default on every render, so snapshotting that would make 

1965 the drift check below fire on a plain trial change. It is applied to 

1966 whichever trial is open, clamped to its length. 

1967 """ 

1968 ss = st.session_state 

1969 state = { 

1970 str(key): deepcopy(value) 

1971 for key, value in ss.items() 

1972 if _is_design_key(key) and str(key) not in _FIX_WINDOW_KEYS 

1973 } 

1974 for window_key, user_set_key in _FIX_WINDOWS: 

1975 window = ss.get(window_key) 

1976 if ( 

1977 ss.get(user_set_key) 

1978 and isinstance(window, (list, tuple)) 

1979 and len(window) == 2 

1980 ): 

1981 state[window_key] = tuple(window) 

1982 return state 

1983 

1984 

1985#: VIZ-44 — `global_*` keys that *mirror* a setting rather than hold one, so 

1986#: they are left out of the drift check below. The highlight-span pair is 

1987#: re-derived from `global_critical_span_style` on every run the Stimulus 

1988#: popover draws, and each `__num*` key is the typed box beside a slider, 

1989#: registered the first time its popover renders — neither is anything the user 

1990#: set apart from the canonical key, which the check does compare. 

1991_DRIFT_MIRROR_KEYS = frozenset( 

1992 {"global_highlight_span_on", "global_highlight_span_mode"} 

1993) 

1994_NUMERIC_TWIN_SUFFIXES = ("__num", "__num_lo", "__num_hi") 

1995_ABSENT = object() 

1996 

1997 

1998#: #374 F25 — Compare and Animate are ways of *viewing* a design, not part of 

1999#: it: switching one on leaves the design's highlight where it is. (A design 

2000#: still records whether Compare was on, so applying it restores that.) 

2001_VIEW_MODE_KEYS = frozenset({SINGLE_COMPARE_TOGGLE, "single_animate"}) 

2002#: The dataset the applied design's baseline was taken on; see 

2003#: `_sync_quick_view_state`. 

2004_QUICK_VIEW_DATASET = "_quick_view_dataset" 

2005 

2006 

2007def _is_drift_mirror(key: str) -> bool: 

2008 return key in _DRIFT_MIRROR_KEYS or key.endswith(_NUMERIC_TWIN_SUFFIXES) 

2009 

2010 

2011def _design_drifted(applied: dict, current: dict) -> bool: 

2012 """Whether the plot settings moved off the design that was applied (VIZ-44). 

2013 

2014 Every design key counts — a named view resets *all* of them to the widget 

2015 defaults, so changing any plot control is a departure from it — except the 

2016 mirrors above. A key present on only one side is compared against its 

2017 widget default: a control registered late (its popover opened after the 

2018 baseline was taken) at its default value has not been changed, while a key 

2019 with no default (an explicit colour range, VIZ-46) appearing *has*. 

2020 """ 

2021 for key in applied.keys() | current.keys(): 

2022 if _is_drift_mirror(key) or key in _VIEW_MODE_KEYS: 

2023 continue 

2024 default = _VIZ_WIDGET_DEFAULTS.get(key, _ABSENT) 

2025 if applied.get(key, default) != current.get(key, default): 

2026 return True 

2027 return False 

2028 

2029 

2030def _drift_to_custom(selected: str, applied: dict) -> str: 

2031 """Drop the highlight to Custom, remembering the design it left.""" 

2032 ss = st.session_state 

2033 ss[_QUICK_VIEW_SELECTION_KEY] = _CUSTOM_VIEW 

2034 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state() 

2035 ss[_QUICK_VIEW_DRIFTED_FROM] = (selected, applied) 

2036 ss.pop(_QUICK_VIEW_APPLIED_STATE, None) 

2037 return _CUSTOM_VIEW 

2038 

2039 

2040def _returned_to_design() -> str | None: 

2041 """The design a drift left, once the settings are back on its baseline. 

2042 

2043 Changing a setting reads Custom; changing it back restores every setting 

2044 the design had, so the highlight goes back to it rather than staying 

2045 Custom. Only the design that was left is checked — 

2046 an explicit pick (`_apply_view_preset`, a save, Reset) forgets it. 

2047 """ 

2048 ss = st.session_state 

2049 drifted = ss.get(_QUICK_VIEW_DRIFTED_FROM) 

2050 if not (isinstance(drifted, tuple) and len(drifted) == 2): 

2051 return None 

2052 name, applied = drifted 

2053 saved = name.removeprefix(_DESIGN_SELECTION_PREFIX) 

2054 still_exists = name in _VIEW_PRESETS or ( 

2055 name != saved and saved in design_presets() 

2056 ) 

2057 if not still_exists or not isinstance(applied, dict): 

2058 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None) 

2059 return None 

2060 if _design_drifted(applied, _capture_quick_view_state()): 

2061 return None 

2062 ss[_QUICK_VIEW_SELECTION_KEY] = name 

2063 ss[_QUICK_VIEW_APPLIED_STATE] = applied 

2064 ss.pop(_QUICK_VIEW_DRIFTED_FROM, None) 

2065 return name 

2066 

2067 

2068def _link_departs_from(name: str) -> bool: 

2069 """Whether the open deep link sets a design value design ``name`` would not. 

2070 

2071 #374 F25: on a link's first run the highlight is inferred from the settings 

2072 the link restored. Matching a design's own few keys is not enough — a link 

2073 from a view with a hand-changed colour matched Scanpath — so every design 

2074 value the link carries is held against what the design would set (its own 

2075 value, else the widget default). Values with no default (the canvas size, 

2076 seeded per dataset) say nothing either way. 

2077 """ 

2078 from .url_state import linked_state_keys 

2079 

2080 ss = st.session_state 

2081 preset = _VIEW_PRESETS[name] 

2082 for key in linked_state_keys(): 

2083 if not _is_design_key(key) or key in _VIEW_MODE_KEYS or key not in ss: 

2084 continue 

2085 expected = preset.get(key, _VIZ_WIDGET_DEFAULTS.get(key, _ABSENT)) 

2086 if expected is _ABSENT: 

2087 continue 

2088 if _write_match_key(ss.get(key)) != _write_match_key(expected): 

2089 return True 

2090 return False 

2091 

2092 

2093def _sync_quick_view_state() -> str: 

2094 """Keep the design-preset highlight in step with manual plot-control edits. 

2095 

2096 Only a change to a plot setting drops the highlight to 🛠️ Custom; see 

2097 `_design_drifted` for what is not one (VIZ-44 — narrowing the trial pool 

2098 used to flip it). 

2099 """ 

2100 ss = st.session_state 

2101 selected = ss.get(_QUICK_VIEW_SELECTION_KEY) 

2102 # #374 F25: another dataset re-seeds its own canvas size, highlight column 

2103 # and hover fields. That is the design meeting new data, not a departure 

2104 # from it, so a design that was in force stays highlighted: its baseline is 

2105 # retaken on the new dataset (the seeds have run by now). 

2106 dataset = (ss.get("data_source_choice"), ss.get("public_dataset_choice")) 

2107 if ss.get(_QUICK_VIEW_DATASET, dataset) != dataset and isinstance( 

2108 ss.get(_QUICK_VIEW_APPLIED_STATE), dict 

2109 ): 

2110 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state() 

2111 ss[_QUICK_VIEW_DATASET] = dataset 

2112 # VIZ-39: a `design:<name>` selection is valid while that design still 

2113 # exists, and from here on is treated exactly like a built-in — including 

2114 # the drift check below, so editing any control drops the highlight. 

2115 if selected_design_name() in design_presets(): 

2116 applied = ss.get(_QUICK_VIEW_APPLIED_STATE) 

2117 if not isinstance(applied, dict): 

2118 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state() 

2119 return str(selected) 

2120 if _design_drifted(applied, _capture_quick_view_state()): 

2121 return _drift_to_custom(str(selected), applied) 

2122 return str(selected) 

2123 if selected not in {*_VIEW_PRESETS, _CUSTOM_VIEW}: 

2124 selected = next( 

2125 ( 

2126 name 

2127 for name in ("illustration", "scanpath", "heatmap") 

2128 if all( 

2129 ss.get(key) == value for key, value in _VIEW_PRESETS[name].items() 

2130 ) 

2131 and not _link_departs_from(name) 

2132 ), 

2133 _CUSTOM_VIEW, 

2134 ) 

2135 ss[_QUICK_VIEW_SELECTION_KEY] = selected 

2136 if selected == _CUSTOM_VIEW: 

2137 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state() 

2138 else: 

2139 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state() 

2140 

2141 if selected == _CUSTOM_VIEW: 

2142 returned = _returned_to_design() 

2143 if returned is not None: 

2144 return returned 

2145 ss[_QUICK_VIEW_CUSTOM_STATE] = _capture_quick_view_state() 

2146 return _CUSTOM_VIEW 

2147 

2148 applied = ss.get(_QUICK_VIEW_APPLIED_STATE) 

2149 if not isinstance(applied, dict): 

2150 ss[_QUICK_VIEW_APPLIED_STATE] = _capture_quick_view_state() 

2151 return str(selected) 

2152 if _design_drifted(applied, _capture_quick_view_state()): 

2153 return _drift_to_custom(str(selected), applied) 

2154 return str(selected) 

2155 

2156 

2157def _active_quick_view() -> str | None: 

2158 """Return the quick-view preset whose owned values match current state. 

2159 

2160 Illustration is deliberately checked before Scanpath: its layer set is a 

2161 superset of the Scanpath contract, so the old order mislabeled an active 

2162 Illustration as Scanpath even while arc-and-snap geometry remained live. 

2163 """ 

2164 return _sync_quick_view_state() 

2165 

2166 

2167# VIZ-18: palette setting name → the session key it writes. A palette is applied 

2168# by writing the *ordinary* colour keys, so nothing downstream has to know 

2169# palettes exist — the per-element pickers, the deep link, Save & restore, the 

2170# CLI and the API all keep carrying plain colours. 

2171_PALETTE_STATE_KEYS = { 

2172 "fixation_color": "global_fixation_color", 

2173 "fixation_colorscale": "global_fixation_colorscale", 

2174 "heatmap_colorscale": "global_heatmap_colorscale", 

2175 "saccade_color": "global_saccade_color", 

2176 "word_label_color": "global_text_color", 

2177 "highlight_text_color": "global_highlight_text_color", 

2178} 

2179 

2180 

2181def palette_state(name: str) -> dict: 

2182 """The ``session_state`` writes that applying palette ``name`` performs.""" 

2183 settings = palette_settings(name) 

2184 state = { 

2185 state_key: settings[setting] 

2186 for setting, state_key in _PALETTE_STATE_KEYS.items() 

2187 if setting in settings 

2188 } 

2189 for cls_name, color in settings.get("saccade_class_colors", {}).items(): 

2190 if cls_name in SACCADE_CLASS_EDITABLE: 

2191 state[f"global_saccade_class_color_{cls_name}"] = color 

2192 return state 

2193 

2194 

2195def apply_palette(name: str) -> None: 

2196 """Apply a VIZ-18 palette by writing its colours into ``session_state``. 

2197 

2198 Runs as a widget ``on_change`` callback — i.e. before the next rerun 

2199 instantiates the colour pickers — so writing their keys here is picked up 

2200 cleanly, exactly like ``_apply_view_preset``. Deliberately does *not* touch 

2201 the background colour: that's a canvas/Experimental-Setup choice the user 

2202 makes for their output medium, not part of the mark palette. 

2203 

2204 ``CUSTOM_PALETTE`` is a no-op: it names the *absence* of a palette, so 

2205 re-selecting it must not overwrite the colours the user just set by hand. 

2206 """ 

2207 if name == CUSTOM_PALETTE: 

2208 return 

2209 for key, value in palette_state(name).items(): 

2210 write_through(key, value) 

2211 

2212 

2213#: #374 F9 — programmatic widget writes the browser may not have taken yet: 

2214#: ``{key: [value, stale_echo]}``. See `write_through`. 

2215_PENDING_WRITES_KEY = "_pending_widget_writes" 

2216_WRITE_FRESH = "\x00fresh" # written this run: the browser has not answered yet 

2217_WRITE_UNSEEN = "\x00unseen" # one run on: the next echo is the browser's 

2218 

2219 

2220def _write_match_key(value): 

2221 """Normalize a widget value for comparison: pickers hand back lowercase hex, 

2222 sliders tuples where the stored value is a list.""" 

2223 if isinstance(value, tuple): 

2224 return [_write_match_key(v) for v in value] 

2225 if isinstance(value, list): 

2226 return [_write_match_key(v) for v in value] 

2227 return _palette_match_key(value) 

2228 

2229 

2230def write_through(key: str, value) -> None: 

2231 """Write ``value`` to a widget's key so that a closed popover cannot undo it. 

2232 

2233 #374 F9. A widget inside an ``st.popover`` is mounted in the browser only 

2234 while the popover is open. Once it has been open, the browser remembers the 

2235 value it showed and sends that value back on every rerun; a programmatic 

2236 write made while the popover is closed reaches no mounted widget, so it 

2237 holds for one run and the next rerun puts the remembered value back (the 

2238 palette that "stopped sticking" after Fixations ▾ had been opened). 

2239 

2240 So the write is also recorded here, and `reassert_pending_writes` repeats 

2241 it at the top of each run while the browser keeps echoing the old value. 

2242 It lets go as soon as the browser sends anything else: the written value 

2243 (the widget remounted and took it) or a new pick of the user's own. 

2244 Call it from a callback, like any write to a widget key. 

2245 """ 

2246 ss = st.session_state 

2247 ss[key] = value 

2248 pending = dict(ss.get(_PENDING_WRITES_KEY) or {}) 

2249 pending[key] = [deepcopy(value), _WRITE_FRESH] 

2250 ss[_PENDING_WRITES_KEY] = pending 

2251 

2252 

2253def reassert_pending_writes() -> None: 

2254 """Re-apply `write_through` writes the browser has not taken yet. 

2255 

2256 Runs at the top of every script run, before any widget is built. The first 

2257 run after a write learns what the browser echoes for the key; while it 

2258 keeps echoing that, the write is repeated; any other value ends it. 

2259 """ 

2260 ss = st.session_state 

2261 pending = ss.get(_PENDING_WRITES_KEY) 

2262 if not pending: 

2263 return 

2264 kept = {} 

2265 for key, (value, stale) in pending.items(): 

2266 if stale == _WRITE_FRESH: 

2267 # The run the callback wrote in: the write itself is what reads 

2268 # back, so there is nothing to learn yet. 

2269 kept[key] = [value, _WRITE_UNSEEN] 

2270 continue 

2271 current = _write_match_key(ss.get(key)) 

2272 if current == _write_match_key(value): 

2273 continue # the browser has it 

2274 if stale == _WRITE_UNSEEN: 

2275 stale = current 

2276 elif current != stale: 

2277 continue # the user picked something else 

2278 ss[key] = deepcopy(value) 

2279 kept[key] = [value, stale] 

2280 if kept: 

2281 ss[_PENDING_WRITES_KEY] = kept 

2282 else: 

2283 ss.pop(_PENDING_WRITES_KEY, None) 

2284 

2285 

2286def _palette_match_key(value): 

2287 """Normalize a colour for comparison — the pickers hand back lowercase hex.""" 

2288 return value.lower() if isinstance(value, str) and value.startswith("#") else value 

2289 

2290 

2291def _active_palette() -> str | None: 

2292 """Which palette the live colour keys match, or ``None`` once customized. 

2293 

2294 The VIZ-12 rule applied to VIZ-18: a palette is one-way (it writes the 

2295 ordinary colour keys and never reads them back), so without this the selector 

2296 keeps reading "Colourblind-safe" after the user has hand-edited one of its 

2297 colours — naming a property the figure no longer has. 

2298 """ 

2299 ss = st.session_state 

2300 for name in PALETTES: 

2301 wanted = palette_state(name) 

2302 if all( 

2303 _palette_match_key(ss.get(key)) == _palette_match_key(value) 

2304 for key, value in wanted.items() 

2305 ): 

2306 return name 

2307 return None 

2308 

2309 

2310def _on_palette_change() -> None: 

2311 name = st.session_state.get("global_palette") or DEFAULT_PALETTE 

2312 if name != CUSTOM_PALETTE: 

2313 # What "Custom" is a departure *from*, for the caption below. 

2314 st.session_state["_palette_picked"] = name 

2315 apply_palette(name) 

2316 

2317 

2318def _popover_selectbox(label: str, options: list, state_key: str, host=None, **kwargs): 

2319 """A selectbox inside a popover whose seeded session value actually shows. 

2320 

2321 A *keyed* selectbox first painted inside a (closed-until-clicked) popover 

2322 renders its first option rather than the value seeded into session state, and 

2323 commits that wrong value on the next interaction — the same first-open quirk 

2324 the VIZ-8 class colour pickers hit. Passing an explicit ``index=`` and writing 

2325 the pick back by hand sidesteps it, which is what lets a VIZ-18 palette (or a 

2326 deep link, or a restored config) set a non-first colorscale and have the 

2327 picker agree with the figure. 

2328 

2329 Renders as a UX-51 ``label | field`` row like every other rail control. 

2330 """ 

2331 current = st.session_state.get(state_key) 

2332 index = options.index(current) if current in options else 0 

2333 picked = _labeled( 

2334 host if host is not None else st, 

2335 "selectbox", 

2336 label, 

2337 options=options, 

2338 index=index, 

2339 **kwargs, 

2340 ) 

2341 st.session_state[state_key] = picked 

2342 return picked 

2343 

2344 

2345# Help text for the (multi-capable) Trial ID mapping, shared by all tables. 

2346_TRIAL_MAPPING_HELP = ( 

2347 "The column that identifies each trial. If none does alone, pick several " 

2348 "(e.g. participant + text + repeated reading); their values are joined " 

2349 "into one ID. Pick the same columns in every table so trials line up." 

2350) 

2351 

2352# Word-box geometry is one rectangle in two interchangeable encodings. The 

2353# mapping UI shows a format picker plus four fields instead of all eight at once; 

2354# both encodings normalize to canonical x/y/width/height in 

2355# ``data.normalize_words``, so the returned schema still carries all eight keys. 

2356BOX_FORMAT_EDGES = "Edges" 

2357BOX_FORMAT_ORIGIN = "Origin + size" 

2358_BOX_SUBFIELDS: dict[str, list[tuple]] = { 

2359 BOX_FORMAT_EDGES: [ 

2360 ("left", "Box left"), 

2361 ("right", "Box right"), 

2362 ("top", "Box top"), 

2363 ("bottom", "Box bottom"), 

2364 ], 

2365 BOX_FORMAT_ORIGIN: [ 

2366 ("x", "Box x (top-left)"), 

2367 ("y", "Box y (top-left)"), 

2368 ("width", "Box width"), 

2369 ("height", "Box height"), 

2370 ], 

2371} 

2372_ALL_BOX_KEYS = [key for fields in _BOX_SUBFIELDS.values() for key, _ in fields] 

2373 

2374#: UX-91 — the word-box row: `Word box * + format radio | four coordinate 

2375#: selects`. The head is wide enough for the title and both radio options 

2376#: without wrapping (a wrapped radio would push the selects beside it out of 

2377#: line with every other row on the page); the four selects split the rest 

2378#: evenly, matching the width they had when they were a row of their own. 

2379_BOX_ROW_W = (0.26, 0.185, 0.185, 0.185, 0.185) 

2380 

2381 

2382def _default_box_format(proposed: dict[str, str | None]) -> str: 

2383 """Which box encoding to show first, from what auto-detect found. 

2384 

2385 Edges if all four edge columns were detected, else origin+size if those four 

2386 were, else edges.""" 

2387 if all(proposed.get(k) for k in ("left", "right", "top", "bottom")): 

2388 return BOX_FORMAT_EDGES 

2389 if all(proposed.get(k) for k in ("x", "y", "width", "height")): 

2390 return BOX_FORMAT_ORIGIN 

2391 return BOX_FORMAT_EDGES 

2392 

2393 

2394# UX-52: mapping fields that only a **multipart** dataset needs (one logical 

2395# trial spread over several screens, DATA-21/DATA-24) plus the per-screen canvas 

2396# size. All optional, all inert for an ordinary single-screen corpus, and 

2397# together they were half the rows in the editor — so they fold into an 

2398# "Advanced" group instead of padding the list everyone reads. 

2399# UX-113: "block" joins it for the same reason — only a table with sub-screen 

2400# blocks that each restart their own numbering (e.g. a comprehension 

2401# question's answer blocks) ever needs it. 

2402_ADVANCED_MAPPING_KEYS = frozenset({"screen_id", "block"}) 

2403#: AN-32 — the reading measures fold into a group of their own wherever every 

2404#: field is listed at once (the ⚙️ Configure panel), so thirteen optional 

2405#: fields never stretch the required ones apart. 

2406_MEASURE_MAPPING_KEYS = frozenset(READING_MEASURE_KEYS) 

2407 

2408#: Mapping keys that are **resolved but never rendered** (UX-53 round 3). 

2409#: 

2410#: `screen_fixation_id` and the two `canvas_*` fields are per-screen bookkeeping 

2411#: that only a multipart export carries, and when it does carry them the column 

2412#: names are the canonical ones auto-detection already finds. They were three 

2413#: more rows on a page whose complaint was length, offering a choice nobody 

2414#: makes. `_assemble_mapping` still puts them in the schema straight from the 

2415#: proposal, so multipart datasets keep their per-screen canvas and nothing 

2416#: downstream sees a narrower schema — what is gone is the widget, not the 

2417#: field. Anything genuinely unmappable this way is a column-name problem, and 

2418#: `data.py`'s candidate lists are where that gets fixed. 

2419#: UX-55 r4 hid `screen_index` ("Screen name") behind this set; UX-88 removed 

2420#: the field outright, from the specs themselves — it is not offered, not 

2421#: resolved from a proposal, and not written into any schema. The *column* is 

2422#: untouched and still load-bearing: `multipart.normalize_screen_identity` 

2423#: derives it from first appearance, and the public corpora in `datasets.py` 

2424#: stamp it straight onto the frames — neither goes through a mapping. What is 

2425#: gone is the idea that anyone should have to think about it. 

2426_HIDDEN_MAPPING_KEYS = frozenset( 

2427 {"screen_fixation_id", "canvas_width", "canvas_height"} 

2428) 

2429 

2430WORD_FIELD_SPECS: list[dict] = [ 

2431 { 

2432 "key": "participant", 

2433 "label": "Participant ID", 

2434 "required": False, 

2435 "help": "Which participant produced this row. Omit for word boxes " 

2436 "shared by all participants.", 

2437 }, 

2438 { 

2439 "key": "trial", 

2440 "label": "Trial ID", 

2441 "required": True, 

2442 "multi": True, 

2443 "help": _TRIAL_MAPPING_HELP, 

2444 }, 

2445 { 

2446 "key": "screen_id", 

2447 "label": "Screen ID", 

2448 "required": False, 

2449 "help": "Which screen of a multi-screen trial this word is on. Leave " 

2450 "empty for one-screen trials.", 

2451 }, 

2452 { 

2453 "key": "word_id", 

2454 "label": "Word/IA ID", 

2455 "required": True, 

2456 "help": "Identifier of each word (interest area) — the key fixations " 

2457 "attach to, and the order of words within a trial.", 

2458 }, 

2459 { 

2460 "key": "text", 

2461 "label": "Word text/label", 

2462 "required": False, 

2463 "help": "The word's text; drawn on the stimulus and shown in tooltips.", 

2464 }, 

2465 { 

2466 "key": "text_id", 

2467 "label": "Text ID", 

2468 "required": False, 

2469 "help": "Groups words by the text they belong to, for filtering and " 

2470 "selection. Empty: the trial ID is used (a repeat shares the first's).", 

2471 }, 

2472 { 

2473 "key": "line", 

2474 "label": "Line index", 

2475 "required": False, 

2476 "help": "Line number of the word on screen, kept as source metadata. " 

2477 "The plot's line coloring and hover infer lines from the word boxes' Y " 

2478 "instead, since many exports carry one constant here.", 

2479 }, 

2480 # UX-113: only meaningful alongside "Aggregate character AOIs into word 

2481 # boxes" — a table whose rows are grouped into sub-blocks that each 

2482 # restart their own numbering (e.g. a comprehension question's stem / 

2483 # target / distractor answer blocks). Without it, two blocks' word 0 

2484 # would silently aggregate into one merged box. 

2485 { 

2486 "key": "block", 

2487 "label": "AOI block", 

2488 "required": False, 

2489 "help": "Groups word boxes that each restart their own numbering " 

2490 "within one screen (e.g. a question's stem/target/distractor " 

2491 "blocks) — leave empty for ordinary one-block screens. Only used " 

2492 "when aggregating character AOIs into word boxes.", 

2493 }, 

2494 { 

2495 "key": "canvas_width", 

2496 "label": "Screen canvas width", 

2497 "required": False, 

2498 "help": "Recorded canvas width in pixels; must be constant within a screen.", 

2499 }, 

2500 { 

2501 "key": "canvas_height", 

2502 "label": "Screen canvas height", 

2503 "required": False, 

2504 "help": "Recorded canvas height in pixels; must be constant within a screen.", 

2505 }, 

2506 { 

2507 "key": "box", 

2508 "kind": "box", 

2509 "label": "Word box", 

2510 "required": True, 

2511 "help": "Bounding box per word/AOI. Edges = left/right/top/bottom (EyeLink IA_*); Origin + size = x/y/width/height.", 

2512 }, 

2513 # AN-32: the reading measures a dataset brings, one optional field each — 

2514 # the Corpus Analysis page shows these and computes none. Short labels, as 

2515 # they share two lines; the full name and the EyeLink column are the hover. 

2516 *( 

2517 { 

2518 "key": key, 

2519 "label": label, 

2520 "help": f"{name}. Auto-detected from EyeLink's `{candidates[0]}`" 

2521 " (or a column named like it). Leave empty if your report has none.", 

2522 } 

2523 for key, _column, label, name, _kind, candidates in READING_MEASURE_FIELDS 

2524 ), 

2525] 

2526 

2527FIX_FIELD_SPECS: list[dict] = [ 

2528 { 

2529 "key": "participant", 

2530 "label": "Participant ID", 

2531 "required": True, 

2532 "help": "Which participant produced this fixation.", 

2533 }, 

2534 { 

2535 "key": "trial", 

2536 "label": "Trial ID", 

2537 "required": True, 

2538 "multi": True, 

2539 "help": _TRIAL_MAPPING_HELP, 

2540 }, 

2541 { 

2542 "key": "screen_id", 

2543 "label": "Screen ID", 

2544 "required": False, 

2545 "help": "Which screen of a multi-screen trial this fixation is on. Map " 

2546 "it in the Words table too.", 

2547 }, 

2548 { 

2549 "key": "x", 

2550 "label": "X coordinate", 

2551 "required": False, 

2552 "help": "Fixation pixel X. Leave empty for AOI-only data and map " 

2553 "Word/IA ID instead — those fixations are placed at word-box centers.", 

2554 }, 

2555 { 

2556 "key": "y", 

2557 "label": "Y coordinate", 

2558 "required": False, 

2559 "help": "Fixation pixel Y. Leave empty for AOI-only data (map Word/IA ID instead).", 

2560 }, 

2561 { 

2562 "key": "duration", 

2563 "label": "Duration (ms)", 

2564 "required": True, 

2565 "help": "Fixation length in milliseconds; sets marker size and the " 

2566 "dwell-time heatmap.", 

2567 }, 

2568 { 

2569 "key": "timestamp", 

2570 "label": "Timestamp (ms)", 

2571 "required": False, 

2572 "help": "When each fixation starts (ms), on one clock for the whole " 

2573 "trial; orders fixations and times the replay. Defaults to row order.", 

2574 }, 

2575 # UX-53 removed *Screen-local timestamp (ms)* from the mapping: it was a 

2576 # second clock for the same fixations, and the parent-trial timestamp above 

2577 # already orders every screen. `screen_timestamp_ms` survives as a 

2578 # passthrough column for the corpora that ship one (datasets.py stamps it), 

2579 # so nothing downstream loses it — it just stops being a question every 

2580 # uploader has to answer. 

2581 { 

2582 "key": "fixation_id", 

2583 "label": "Fixation ID", 

2584 "required": False, 

2585 "help": "Sequential fixation number within a trial. Defaults to row order.", 

2586 }, 

2587 { 

2588 "key": "screen_fixation_id", 

2589 "label": "Screen-local fixation ID", 

2590 "required": False, 

2591 "help": "Optional fixation number that resets within each screen; the " 

2592 "parent-global fixation ID is retained separately.", 

2593 }, 

2594 { 

2595 "key": "text_id", 

2596 "label": "Text ID", 

2597 "required": False, 

2598 "help": "Groups fixations by the text/passage they belong to, for " 

2599 "filtering and selection.", 

2600 }, 

2601 { 

2602 "key": "word_id", 

2603 "label": "Word/IA ID", 

2604 "required": False, 

2605 "help": "Which word/AOI each fixation landed on. Authoritative when " 

2606 "present (overrides geometric assignment), and supplies the location " 

2607 "when X/Y are absent — for AOI-only data, leave X/Y empty and map this.", 

2608 }, 

2609 { 

2610 "key": "canvas_width", 

2611 "label": "Screen canvas width", 

2612 "required": False, 

2613 "help": "Recorded canvas width in pixels; must be constant within a screen.", 

2614 }, 

2615 { 

2616 "key": "canvas_height", 

2617 "label": "Screen canvas height", 

2618 "required": False, 

2619 "help": "Recorded canvas height in pixels; must be constant within a screen.", 

2620 }, 

2621 # pass_index / saccade_type / saccade_amplitude / eye are no longer explicit 

2622 # mapping fields — they're auto-detected and offered under "fields to keep" 

2623 # (see data.FIX_OPTIONAL_FIELDS), so they don't clutter the wizard and aren't 

2624 # hardcoded as schema. noise_flag was removed (it silently dropped fixations 

2625 # with no UI to undo); saccade_amplitude is recomputed from X/Y by measures. 

2626] 

2627 

2628RAW_GAZE_FIELD_SPECS: list[dict] = [ 

2629 { 

2630 "key": "participant", 

2631 "label": "Participant ID", 

2632 "required": True, 

2633 "help": "Which participant produced this gaze sample.", 

2634 }, 

2635 { 

2636 "key": "trial", 

2637 "label": "Trial ID", 

2638 "required": True, 

2639 "multi": True, 

2640 "help": _TRIAL_MAPPING_HELP, 

2641 }, 

2642 { 

2643 "key": "screen_id", 

2644 "label": "Screen ID", 

2645 "required": False, 

2646 "help": "Which screen of a multi-screen trial this sample is on.", 

2647 }, 

2648 # UX-113: text_id/word_id round out the row to the same six identity 

2649 # fields Fixations/AOI map (Trial · Screen · Participant · Text · Word/IA 

2650 # · text-or-id), instead of raw gaze being the one table with no way to 

2651 # say either. Both optional — a raw-gaze-only dataset with no such column 

2652 # still works exactly as before (see `data.normalize_raw_gaze`). 

2653 { 

2654 "key": "text_id", 

2655 "label": "Text ID", 

2656 "required": False, 

2657 "help": "Groups gaze samples by the text/passage they belong to. Left " 

2658 "unmapped, the text id mirrors the trial id — raw gaze normally has " 

2659 "no separate text/passage concept.", 

2660 }, 

2661 { 

2662 "key": "word_id", 

2663 "label": "Word/IA ID", 

2664 "required": False, 

2665 "help": "Which word/AOI each gaze sample landed on, if the export " 

2666 "already carries one — raw gaze is not assigned to words the way " 

2667 "fixations are, so this is carried through as-is, not computed.", 

2668 }, 

2669 { 

2670 "key": "text", 

2671 "label": "Word text/label", 

2672 "required": False, 

2673 "help": "Optional word/label associated with the sample.", 

2674 }, 

2675 { 

2676 "key": "x", 

2677 "label": "X coordinate", 

2678 "required": True, 

2679 "help": "Gaze pixel X at this timepoint.", 

2680 }, 

2681 { 

2682 "key": "y", 

2683 "label": "Y coordinate", 

2684 "required": True, 

2685 "help": "Gaze pixel Y at this timepoint.", 

2686 }, 

2687 { 

2688 "key": "timestamp", 

2689 "label": "Timestamp (ms)", 

2690 "required": False, 

2691 "help": "Sample time (ms); orders the continuous gaze path. Left " 

2692 "unmapped, the samples keep their row order and are numbered " 

2693 "1, 2, … per trial — with no time, since no sampling rate is known.", 

2694 }, 

2695] 

2696 

2697 

2698def _assemble_mapping( 

2699 df: pd.DataFrame, 

2700 field_specs: list[dict], 

2701 proposed: dict[str, str | None], 

2702 only_keys: list[str] | None, 

2703 *, 

2704 pick: Callable[..., str | None], 

2705 pick_box_format: Callable[[dict], str], 

2706 pick_multi: Callable[..., list[str]], 

2707) -> dict[str, str | None]: 

2708 """Build the schema dict, deferring every *choice* to the caller. 

2709 

2710 The **shape** of a mapping — which keys exist, that a ``kind: "box"`` field 

2711 expands into all eight box keys with the inactive four set to ``None``, that 

2712 a ``multi`` field collapses to a plain string when exactly one column is 

2713 picked — is defined once, here; :func:`column_mapping_ui` supplies the 

2714 choices by rendering widgets. 

2715 """ 

2716 mapping: dict[str, str | None] = {} 

2717 for spec in field_specs: 

2718 key = spec["key"] 

2719 # When ``only_keys`` is given, handle just that subset (the wizard 

2720 # renders fields in grouped, ordered steps). 

2721 if only_keys is not None and key not in only_keys: 

2722 continue 

2723 default = proposed.get(key) 

2724 # Resolved from auto-detection, never offered as a row (UX-53). 

2725 if key in _HIDDEN_MAPPING_KEYS: 

2726 mapping[key] = default 

2727 continue 

2728 label = spec["label"] + (" *" if spec.get("required") else "") 

2729 if spec.get("kind") == "box": 

2730 fmt = pick_box_format(spec) 

2731 # Always emit all eight box keys; only the active format's four 

2732 # get a column, the rest stay None. 

2733 mapping.update({box_key: None for box_key in _ALL_BOX_KEYS}) 

2734 for sub_key, sub_label in _BOX_SUBFIELDS[fmt]: 

2735 # UX-91: the four coordinates *are* the required Word box, so 

2736 # each carries the `*` its parent does. The star was on the 

2737 # group heading alone, which read as one optional-looking row 

2738 # of four beneath a required title. 

2739 sub_star = " *" if spec.get("required") else "" 

2740 mapping[sub_key] = pick(sub_key, sub_label + sub_star, None) 

2741 continue 

2742 if spec.get("multi"): 

2743 chosen_cols = pick_multi(spec, default, label) 

2744 if not chosen_cols: 

2745 mapping[key] = None 

2746 elif len(chosen_cols) == 1: 

2747 mapping[key] = chosen_cols[0] 

2748 else: 

2749 mapping[key] = list(chosen_cols) 

2750 continue 

2751 mapping[key] = pick(key, label, spec.get("help")) 

2752 return mapping 

2753 

2754 

2755#: Set once the user has pressed **✅ Add dataset** on a wizard that still has a 

2756#: required field unmapped. Until then a blank required field is simply *not 

2757#: filled in yet* — colouring it red on arrival would paint a fresh upload with 

2758#: errors before the user has done anything wrong (UX-53). 

2759ADD_ATTEMPTED_KEY = "_wizard_add_attempted" 

2760 

2761#: UX-53 round 4 — the field's state is a **tint on the select itself**, not a 

2762#: dot and a sentence beside it. `● ✨ auto-detected \`CURRENT_FIX_INDEX\`` was 

2763#: longer than the control it described, on every row. Low-alpha rgba so it 

2764#: tints whatever the active theme paints underneath rather than assuming a 

2765#: light background. 

2766#: UX-67 dropped the green: a mapping the user chose is simply *filled*, and 

2767#: tinting it made "reviewed" compete for attention with the two states that 

2768#: actually need acting on. `user` keeps its own name in `_field_state` — it is 

2769#: still what moves a field **out** of amber once someone picks — it just has no 

2770#: colour of its own now. 

2771_FIELD_TINT = { 

2772 "auto": "rgba(234, 179, 8, 0.16)", 

2773 "missing": "rgba(239, 68, 68, 0.20)", 

2774} 

2775 

2776 

2777#: Fields the user has actually interacted with this session. Deliberately ONE 

2778#: key, and deliberately *outside* the `col_map_` namespace: 

2779#: `tabs._collect_column_mapping` sweeps every `col_map_*` key into the saved 

2780#: config, so a per-field marker named that way would travel in users' configs 

2781#: as if it were part of the mapping. 

2782TOUCHED_FIELDS_KEY = "_mapping_touched_fields" 

2783 

2784 

2785def _mark_field_touched(state_key: str) -> None: 

2786 """Record that the user moved this field (the select's ``on_change``). 

2787 

2788 What separates *auto-detected and left alone* from *the user chose this* — 

2789 which are the same value, and different claims. Picking the detected column 

2790 by hand is an approval, and UX-53 r11 wants it to read as one. 

2791 """ 

2792 st.session_state.setdefault(TOUCHED_FIELDS_KEY, set()).add(state_key) 

2793 

2794 

2795def _field_state( 

2796 *, 

2797 chosen, 

2798 default, 

2799 is_required: bool, 

2800 attempted: bool, 

2801 touched: bool, 

2802 detected_label: str, 

2803) -> tuple[str, str]: 

2804 """``(state, hover)`` for one mapping row. 

2805 

2806 ``state`` keys `_FIELD_TINT`: **user** they chose it, **auto** detection 

2807 found it and nobody has touched it, **missing** it is required and still 

2808 empty *after* an add was attempted (before that, empty is simply not filled 

2809 in yet). ``""`` is the neutral, untinted row. 

2810 

2811 Two rules from UX-53 r11, both about who decided: 

2812 

2813 * **Choosing a value is an approval**, even when it is the value detection 

2814 already proposed — so a touched field goes green rather than staying 

2815 amber. Amber means "nobody has looked at this yet", which stops being true 

2816 the moment they pick. 

2817 * **Clearing a detected field goes neutral**, not amber: the ✕ is a decision 

2818 that this column is *not* the one, and leaving it amber would keep 

2819 flagging a suggestion the user has just rejected. It stays red only when 

2820 the field is required and an add has been attempted, because then it is 

2821 genuinely blocking. 

2822 

2823 ``hover`` is the ✨ icon's tooltip, and the only place the detected column 

2824 name is written: the name is what made the old inline note run past the 

2825 width of the control it annotated, and it is looked at once. 

2826 """ 

2827 unmapped = chosen in (None, NONE_OPTION) 

2828 if unmapped: 

2829 if is_required and attempted: 

2830 return "missing", "required — pick a column" 

2831 if default: 

2832 return "", f"{detected_label} `{default}` · not used" 

2833 return "", "" 

2834 if touched: 

2835 if default and chosen != default: 

2836 return "user", f"{detected_label} `{default}` · overridden" 

2837 return "user", f"{detected_label} `{default}` · confirmed" if default else "" 

2838 if default and chosen == default: 

2839 return "auto", f"{detected_label} `{default}`" 

2840 if default: 

2841 return "user", f"{detected_label} `{default}` · overridden" 

2842 return "user", "" 

2843 

2844 

2845#: Marker key recording which table a stored mapping was made for. 

2846#: 

2847#: The prefix, not a suffix, and deliberately outside ``col_map_*``: 

2848#: `tabs._collect_column_mapping` sweeps **every** `col_map_*` key that does not 

2849#: end in `_upload` into the saved config, so a marker named 

2850#: ``col_map_fix__mapped_columns`` would travel in one — come back from JSON as a 

2851#: *list* rather than the tuple it was written as, never compare equal to the 

2852#: signature again, and so clear the mapping on the first run after every 

2853#: restore. It describes this session's widget state, not the mapping, and has 

2854#: no business in a file that opens on another machine. 

2855def _mapped_columns_key(state_key_prefix: str) -> str: 

2856 """Session key holding the ``(dataset, columns)`` ``state_key_prefix`` maps.""" 

2857 return f"_mapped_columns_{state_key_prefix}" 

2858 

2859 

2860def claim_mapping(state_key_prefix: str, dataset: object) -> None: 

2861 """Record that ``state_key_prefix``'s keys now describe ``dataset`` (BUG-32). 

2862 

2863 For a writer that seeds mapping keys for a table nobody has read yet — the 

2864 wizard starting a fresh dataset, a setup restored into it. The columns are 

2865 left unknown, so that dataset's first sighting counts as the *same* dataset 

2866 (DATA-24's stale-only rule keeps every pick its table can honour), while any 

2867 other dataset that meets the keys first — the demo, after ✕ Cancel — drops 

2868 them. Without it the marker would still name whatever those keys used to 

2869 describe, and the new table would clear exactly what was just restored. 

2870 """ 

2871 st.session_state[_mapped_columns_key(state_key_prefix)] = (dataset, None) 

2872 

2873 

2874def _mapping_state_keys(state_key_prefix: str, field_specs: list[dict]) -> list[str]: 

2875 """Every session key the mapping widgets for ``field_specs`` write.""" 

2876 keys: list[str] = [] 

2877 for spec in field_specs: 

2878 if spec.get("kind") == "box": 

2879 keys.append(f"{state_key_prefix}_box_format") 

2880 keys.extend(f"{state_key_prefix}_{box_key}" for box_key in _ALL_BOX_KEYS) 

2881 continue 

2882 keys.append(f"{state_key_prefix}_{spec['key']}") 

2883 return keys 

2884 

2885 

2886def forget_mapping_for_other_table( 

2887 df: pd.DataFrame, 

2888 state_key_prefix: str, 

2889 field_specs: list[dict], 

2890 *, 

2891 dataset: object = None, 

2892) -> None: 

2893 """Drop a stored mapping that was made for a *different* table (DATA-24). 

2894 

2895 A mapping widget owns its key once it has rendered, and a key that exists 

2896 beats the ``index=`` computed from auto-detection — that is what makes a 

2897 user's override stick. But the app switches data sources in place, so the 

2898 same keys outlive the table they describe: opening the bundled demo (no 

2899 screen columns → *Screen order* = ``(none)``) and then switching to 

2900 MultiplEYE left *Screen order* on ``(none)`` while the caption beneath it 

2901 still read "✨ auto-detected `screen_index`", because the proposal had indeed 

2902 found the column and only the widget was stale. The multipart trial then 

2903 ordered its screens by name instead of by reading order, and the user had to 

2904 set a field the app had already detected. 

2905 

2906 The discriminator is the **column universe**, not the data: a new file with 

2907 the same headers is the case where keeping the mapping is the whole point, 

2908 while different headers mean this mapping was never about this table. The 

2909 first sighting of a prefix only *records* the signature — it must not clear, 

2910 or it would wipe the ``col_map_*`` keys a deep link or a restored config 

2911 seeds before any widget renders (``url_state._seed_column_mapping``). 

2912 

2913 Even then it clears only what has gone stale — a pick naming a column the new 

2914 table still has survives. That is not tidiness: the wizard *grows* its own 

2915 frame mid-flow (``_wizard_filename_derive`` appends ``file_part_N``), so a 

2916 signature change is routine there and dropping the whole mapping would reset 

2917 steps the user had already filled in. What gets cleared is a field left at 

2918 ``(none)`` or pointing at a column that is gone — in both cases there is no 

2919 user choice to lose, and auto-detection deserves another go. 

2920 

2921 **BUG-32: the column universe alone is not the table.** Two datasets that 

2922 share an AOI file have identical headers by construction, so under a 

2923 columns-only signature the second silently inherited every pick made for 

2924 the first — and nothing was cleared or said, because every pick still named 

2925 a real column. ``dataset`` is the caller's identity for the data the 

2926 mapping describes (the source key on the 🗂️ Data page, the add-dataset 

2927 wizard's own), and a change of dataset drops **every** pick, however valid 

2928 it still looks: a choice made for one dataset is not a choice for another. 

2929 The same-dataset rules above are unchanged, so the wizard growing its own 

2930 frame keeps what was filled in. A caller seeding keys *for* a dataset whose 

2931 table has not been read yet stamps it first with :func:`claim_mapping`. 

2932 """ 

2933 columns_seen = tuple(str(column) for column in df.columns) 

2934 signature = (dataset, columns_seen) 

2935 marker = _mapped_columns_key(state_key_prefix) 

2936 previous = st.session_state.get(marker) 

2937 st.session_state[marker] = signature 

2938 if not (isinstance(previous, tuple) and len(previous) == 2): 

2939 return # first sighting: record only 

2940 if previous == signature: 

2941 return 

2942 keys = _mapping_state_keys(state_key_prefix, field_specs) 

2943 if previous[0] != dataset: 

2944 for key in keys: 

2945 st.session_state.pop(key, None) 

2946 st.session_state.get(TOUCHED_FIELDS_KEY, set()).discard(key) 

2947 return 

2948 columns = set(columns_seen) 

2949 for key in keys: 

2950 stored = st.session_state.get(key) 

2951 if isinstance(stored, str) and stored != NONE_OPTION and stored in columns: 

2952 continue 

2953 # The multi-capable Trial ID. Keep a composite whose every component 

2954 # survived; a partial one is not a mapping the user can have meant. 

2955 if ( 

2956 isinstance(stored, (list, tuple)) 

2957 and stored 

2958 and all(column in columns for column in stored) 

2959 ): 

2960 continue 

2961 # The box *format* is a property of the table (which four columns it 

2962 # has), not a preference, so it is re-derived from the new proposal. 

2963 st.session_state.pop(key, None) 

2964 # The approval goes with the answer it approved (UX-53 r11): a field 

2965 # re-proposed for a different table has not been confirmed by anyone. 

2966 st.session_state.get(TOUCHED_FIELDS_KEY, set()).discard(key) 

2967 

2968 

2969def column_mapping_ui( 

2970 df: pd.DataFrame, 

2971 table_label: str, 

2972 state_key_prefix: str, 

2973 field_specs: list[dict], 

2974 proposed: dict[str, str | None], 

2975 expand_on_problem: bool = True, 

2976 problems: list[str] | None = None, 

2977 container=None, 

2978 use_expander: bool = True, 

2979 only_keys: list[str] | None = None, 

2980 header: bool = True, 

2981 detected_label: str = "auto-detected", 

2982 columns_per_row: int = 1, 

2983 stack_labels: bool | None = None, 

2984 dataset: object = None, 

2985 option_labels: dict | None = None, 

2986) -> dict[str, str | None]: 

2987 """Render a column-mapping expander letting users override the inferred mapping. 

2988 

2989 Renders into ``container`` — the 🗂️ Data page's mapping slot, or the setup 

2990 wizard's own step. With no container it renders inline, wherever the caller 

2991 already is. 

2992 

2993 Returns a mapping {field_key: column_name_or_None}. Fields marked 

2994 ``multi: True`` (Trial ID) render as a multiselect: picking several columns 

2995 yields a list, meaning "build this ID on the fly by joining the columns' 

2996 values" (see ``data.trial_id_series``); a single pick stays a plain string. 

2997 A field marked ``kind: "box"`` (the word box) renders a coordinate-format 

2998 radio plus the four sub-fields for that format, and expands into all eight 

2999 box keys (the four inactive ones set to None) so the returned schema keeps 

3000 its fixed shape. 

3001 

3002 ``columns_per_row`` (UX-53 r7) packs several fields onto one line — four 

3003 fixation fields fit where one used to sit, and a mapping that fits on a 

3004 screen is one you can check against itself. The default of 1 keeps the 

3005 🗂️ Data page's editor exactly as it was. 

3006 

3007 ``stack_labels`` picks the row shape: label *above* the control (the wizard) 

3008 or ``label | field`` beside it (the Data page). It defaults to 

3009 ``columns_per_row > 1``, which is right for both of those — but a caller 

3010 that renders **one** field into a cell of a row it built itself still wants 

3011 the stacked shape, and inferring it from the field count got that wrong 

3012 (UX-53 r17: the screen fields landed on the identity rows with their titles 

3013 beside them while every neighbour had its title above). 

3014 

3015 ``dataset`` names the data this mapping is for, so picks made for one 

3016 dataset never carry into another with the same headers (BUG-32) — see 

3017 :func:`forget_mapping_for_other_table`. 

3018 """ 

3019 forget_mapping_for_other_table(df, state_key_prefix, field_specs, dataset=dataset) 

3020 # UX-108 — PERF-6 narrows `df` to only the columns a plan decided to 

3021 # actually *parse* (auto-detect + the optional-field registry + whatever a 

3022 # `col_map_*` key already names, session-wide); a column nobody has named 

3023 # yet is never in it, so offering `df.columns` here hid the rest of the 

3024 # file. Worse, "whatever a `col_map_*` key already names" is swept from 

3025 # session state with no dataset scoping — re-uploading the same file for a 

3026 # *second* dataset inherits the first one's picks as the plan's floor, so 

3027 # the read narrows to exactly what was kept last time and the picker looks 

3028 # like it is *remembering* which fields to hide. `app._read_uploaded_frame` 

3029 # stashes the file's real header the moment it reads it, independent of 

3030 # what the plan actually parsed; every field in the file belongs in this 

3031 # list regardless. Empty outside an upload context (the 🗂️ Data page's 

3032 # remap editor has no raw file to ask, and offers only what survived the 

3033 # original import by design) — fall back to the parsed frame there. 

3034 full_header = st.session_state.get(f"{state_key_prefix}_header") 

3035 options = list(full_header) if full_header else user_columns(df) 

3036 

3037 # DATA-66: ✏️ Edit dataset offers the stored *canonical* columns; the caller 

3038 # passes the dataset's own names for them. The values stay canonical. 

3039 def _option_label(column) -> str: 

3040 return (option_labels or {}).get(column, column) 

3041 

3042 expanded = bool(expand_on_problem and problems) 

3043 # UX-53 field colour: which rows *must* be filled, and whether the user has 

3044 # already tried to add the dataset (before that, empty is not an error). 

3045 required_keys = {spec["key"] for spec in field_specs if spec.get("required")} 

3046 # UX-90: a `kind="box"` spec is required under its own key ("box"), but what 

3047 # the user actually fills are its four coordinate sub-fields — which are not 

3048 # in `field_specs` at all, so `_field_state` saw them as optional and they 

3049 # stayed neutral after a failed add. Only the active format's four ever 

3050 # render, so naming all eight here is safe and needs no format resolution. 

3051 if any(spec.get("kind") == "box" and spec.get("required") for spec in field_specs): 

3052 required_keys.update(_ALL_BOX_KEYS) 

3053 add_attempted = bool(st.session_state.get(ADD_ATTEMPTED_KEY)) 

3054 stacked = columns_per_row > 1 if stack_labels is None else stack_labels 

3055 #: state -> the keyed cells in that state, filled as rows render and emitted 

3056 #: as ONE <style> block at the end. Per-row style tags would be one extra 

3057 #: element per field on a page whose whole problem is length. 

3058 tint_cells: dict[str, list[str]] = {} 

3059 # UX-52 round 2 — "the column mapping can be overwhelming". Two changes: 

3060 # every row is `label | field` (UX-51's shape, which the user asked for here 

3061 # too), and the multipart/canvas fields fold into an **Advanced** group. 

3062 # They are all optional, all meaningless for an ordinary single-screen 

3063 # dataset, and they were half the rows. The group is skipped when the caller 

3064 # asks for a subset (`only_keys`) — that is the wizard, which already groups 

3065 # these fields into its own ordered steps (DATA-22). 

3066 group_advanced = only_keys is None 

3067 hosts: dict[str, object] = {} 

3068 

3069 def _host_for(field_key: str): 

3070 """The container a field's row renders into (main, or Advanced).""" 

3071 if group_advanced and field_key in _ADVANCED_MAPPING_KEYS: 

3072 return hosts.get("advanced") or hosts["main"] 

3073 if group_advanced and field_key in _MEASURE_MAPPING_KEYS: 

3074 return hosts.get("measures") or hosts["main"] 

3075 return hosts["main"] 

3076 

3077 #: Grid cursor for `columns_per_row > 1`: the current row's columns and how 

3078 #: many of them are used. Reset whenever the host changes, so the Advanced 

3079 #: group never continues a row started by the main one. 

3080 grid: dict = {"cols": [], "used": 0, "host": None} 

3081 

3082 #: The word box's own row (UX-57). Reserved by `_render_box_format` and 

3083 #: drained by the four sub-field picks that follow it — a separate cursor 

3084 #: from `grid`, because the box is *one* top-level spec that expands into 

3085 #: four, so it cannot borrow the group's `columns_per_row` without pulling 

3086 #: the format radio into a cell meant for a select. 

3087 box_grid: dict = {"cells": [], "used": 0} 

3088 

3089 def _grid_cell(host): 

3090 """The next free cell in a `columns_per_row`-wide grid.""" 

3091 if grid["host"] is not host or grid["used"] >= columns_per_row: 

3092 grid["cols"] = host.columns(columns_per_row, gap=_LABEL_GAP) 

3093 grid["used"] = 0 

3094 grid["host"] = host 

3095 cell = grid["cols"][grid["used"]] 

3096 grid["used"] += 1 

3097 return cell 

3098 

3099 def _row(field_key: str, field_label: str, help_text): 

3100 """Where one field's control and its ✨ flag render. 

3101 

3102 Two shapes. **One per row** (the default, and what the 🗂️ Data page's 

3103 editor uses) is UX-52's `label | field | note` triple: the flag rides 

3104 beside the control instead of under it, so a row is one line tall. 

3105 

3106 **`columns_per_row > 1`** (UX-53 r7) stacks label-over-field inside a 

3107 grid cell instead, because four `label | field` pairs side by side would 

3108 leave nothing but a sliver for each select. The label row reserves its 

3109 own flag column *before* the select renders — the flag depends on the 

3110 value the select returns, and a Streamlit container can be filled after 

3111 later elements are written, which is what makes the order work. 

3112 """ 

3113 host = _host_for(field_key) 

3114 if field_key in _ALL_BOX_KEYS and box_grid["used"] < len(box_grid["cells"]): 

3115 # A box sub-field takes the next cell of the row the box reserved, 

3116 # so the four sit side by side under one heading (UX-57). 

3117 cell = box_grid["cells"][box_grid["used"]] 

3118 box_grid["used"] += 1 

3119 head = cell.container() 

3120 label_col, flag_col = head.columns( 

3121 _GRID_LABEL_W, gap=None, vertical_alignment="center" 

3122 ) 

3123 _row_label(label_col, field_label, help_text) 

3124 return cell, flag_col 

3125 if stacked: 

3126 # UX-53 r14: label over field. Each cell writes its title first and 

3127 # its select second, so across the row the titles line up on one 

3128 # line and the controls on the next — and the select gets the cell's 

3129 # full width instead of splitting it with the label, which is what 

3130 # keeps a long column name legible. 

3131 # 

3132 # `host` itself is the cell when the caller supplied one (a single 

3133 # field dropped into a row it laid out); only a multi-field group 

3134 # cuts its own grid. 

3135 cell = _grid_cell(host) if columns_per_row > 1 else host.container() 

3136 head = cell.container() 

3137 label_col, flag_col = head.columns( 

3138 _GRID_LABEL_W, gap=None, vertical_alignment="center" 

3139 ) 

3140 _row_label(label_col, field_label, help_text) 

3141 return cell, flag_col 

3142 label_col, field_col, note_col = host.columns( 

3143 _MAPPING_ROW_W, gap=_LABEL_GAP, vertical_alignment="center" 

3144 ) 

3145 _row_label(label_col, field_label, help_text) 

3146 return field_col, note_col 

3147 

3148 def _selectbox(field_key: str, field_label: str, help_text=None) -> str | None: 

3149 default = proposed.get(field_key) 

3150 field_col, note_col = _row(field_key, field_label, help_text) 

3151 # The select goes in its own keyed container so the state tint has 

3152 # something to attach to: Streamlit stamps `.st-key-<key>` on it, and the 

3153 # one <style> block emitted at the end of this mapping lists the cells 

3154 # per state (see `tint_cells`). 

3155 cell_key = f"{state_key_prefix}_{field_key}_cell" 

3156 field_col = field_col.container(key=cell_key) 

3157 state_key = f"{state_key_prefix}_{field_key}" 

3158 # UX-53 r10: the value lives in the KEY and `index` is always None, which 

3159 # is precisely what turns Streamlit's own clear (✕) on — its selectbox 

3160 # sets `clearable=(index is None)`. So the empty state is a real `None` 

3161 # rather than the old `"(none)"` sentinel option, and the ✕ sits *inside* 

3162 # the control instead of beside it. 

3163 # 

3164 # Absent key -> seed from auto-detection. Present but holding something 

3165 # this table cannot offer -> blank it: that is a legacy `"(none)"` from a 

3166 # saved config, or a column a new upload does not have, and Streamlit 

3167 # raises on a stored value outside `options`. Present and already None -> 

3168 # left alone, because that is the user having cleared it on purpose. 

3169 if state_key in st.session_state: 

3170 stored = st.session_state[state_key] 

3171 if stored is not None and stored not in options: 

3172 st.session_state[state_key] = None 

3173 else: 

3174 st.session_state[state_key] = default if default in options else None 

3175 chosen = field_col.selectbox( 

3176 field_label, 

3177 options=options, 

3178 format_func=_option_label, 

3179 index=None, 

3180 placeholder=_UNMAPPED_PLACEHOLDER, 

3181 key=state_key, 

3182 help=help_text, 

3183 label_visibility="collapsed", 

3184 # DATA-26: the editor lives on the Data page, which executes only 

3185 # while it is the active view — but the mapping drives `prepare_data` 

3186 # on every view. Without this, Streamlit drops the key at the end of 

3187 # any run in which the widget did not render and the mapping reverts 

3188 # to auto-detection the moment the user clicks over to Scanpath. 

3189 persist_state="session", 

3190 # Marks the field as *decided by a person*, which is what separates 

3191 # green from amber even when the value is identical (UX-53 r11). 

3192 on_change=_mark_field_touched, 

3193 args=(state_key,), 

3194 ) 

3195 # Surface what auto-detection found for this field (ENG-9), flag when the 

3196 # user has overridden it, and carry UX-53's colour so the row's state is 

3197 # readable without parsing the sentence. DATA-24: `(none)` gets its own 

3198 # wording — it used to fall into the plain branch, so a field detection 

3199 # had found but the widget was not using read exactly like one it was. 

3200 state, hover = _field_state( 

3201 chosen=chosen, 

3202 default=default if default in df.columns else None, 

3203 is_required=field_key in required_keys, 

3204 attempted=add_attempted, 

3205 touched=state_key in st.session_state.get(TOUCHED_FIELDS_KEY, ()), 

3206 detected_label=detected_label, 

3207 ) 

3208 if option_labels and default and hover: 

3209 # DATA-66: "currently mapped `duration_ms`" names the user's column. 

3210 hover = hover.replace(f"`{default}`", f"`{_option_label(default)}`") 

3211 if state: 

3212 tint_cells.setdefault(state, []).append(cell_key) 

3213 # UX-92 — the ✨ is a **button** while the row is amber, and pressing it 

3214 # is the approval the select cannot report. 

3215 # 

3216 # Re-picking the value a select already holds fires no `on_change`: 

3217 # Streamlit dedupes it in the frontend and does not even rerun (verified 

3218 # in a browser, not inferred). Since UX-53 r10 the value lives in the 

3219 # widget key with `index=None` — which is what makes the ✕ clear work — 

3220 # so the detected column *is* the widget's value, and confirming it by 

3221 # hand is invisible to Python by construction. A one-click confirm in 

3222 # the space the flag already occupies is the only honest way to say "I 

3223 # chose this" for that case. 

3224 _render_field_flag( 

3225 note_col, 

3226 state=state, 

3227 hover=hover, 

3228 preview=value_preview_tip(df, field_key, chosen), 

3229 confirm_label=f"Confirm the detected {field_label} column", 

3230 confirm_help=f"{hover} — click to confirm this column and clear the mark.", 

3231 cell_key=cell_key, 

3232 state_key=state_key, 

3233 ) 

3234 # `NONE_OPTION` is still tolerated on the way out: a config restored 

3235 # before this run could have seeded it. 

3236 return None if chosen in (None, NONE_OPTION) else chosen 

3237 

3238 host = container if container is not None else st.container() 

3239 # Render inside an expander by default; ``use_expander=False`` renders inline 

3240 # (the collapsed wizard panel already lives in an expander, and the ⚙️ Configure 

3241 # menu popover nests no expander either — Streamlit forbids both). 

3242 section = ( 

3243 host.expander(f"Column mapping — {table_label}", expanded=expanded) 

3244 if use_expander 

3245 else host.container() 

3246 ) 

3247 with section: 

3248 if not use_expander and header: 

3249 st.markdown(f"**Column mapping — {table_label}**") 

3250 if header: 

3251 st.caption( 

3252 "Detected from your file's column names. Change any row that is wrong." 

3253 ) 

3254 if problems: 

3255 st.warning( 

3256 "Fix these before the app can use this table: " + "; ".join(problems) 

3257 ) 

3258 # Both hosts are reserved up front, so the Advanced group sits *after* 

3259 # every ordinary row no matter where its fields fall in the spec order 

3260 # (Streamlit lays containers out in creation order, and 

3261 # `_assemble_mapping` interleaves them). 

3262 hosts["main"] = st.container() 

3263 measures_slot = st.container() 

3264 advanced_slot = st.container() 

3265 if group_advanced and any( 

3266 spec["key"] in _MEASURE_MAPPING_KEYS for spec in field_specs 

3267 ): 

3268 hosts["measures"] = measures_slot.expander( 

3269 f"{ICONS['settings']} Reading measures", 

3270 expanded=any(proposed.get(key) for key in _MEASURE_MAPPING_KEYS), 

3271 ) 

3272 hosts["measures"].caption( 

3273 "The per-AOI measures your report already has (FFD, TFD, …). " 

3274 "The Corpus Analysis page shows these; it computes none." 

3275 ) 

3276 if group_advanced and any( 

3277 spec["key"] in _ADVANCED_MAPPING_KEYS for spec in field_specs 

3278 ): 

3279 # Open when the dataset actually uses one of them, so a multipart 

3280 # corpus does not hide its screen mapping behind a fold. 

3281 def _mapped(key: str) -> bool: 

3282 # `NONE_OPTION` is the literal string the selectbox holds for 

3283 # "not mapped" — truthy, so a bare `or` kept the group open 

3284 # forever once the widgets had rendered once. 

3285 stored = st.session_state.get(f"{state_key_prefix}_{key}") 

3286 if stored in (None, NONE_OPTION, ""): 

3287 stored = None 

3288 return bool(proposed.get(key) or stored) 

3289 

3290 in_use = any(_mapped(key) for key in _ADVANCED_MAPPING_KEYS) 

3291 hosts["advanced"] = advanced_slot.expander( 

3292 f"{ICONS['settings']} Screens & AOI blocks — advanced", 

3293 expanded=bool(in_use), 

3294 ) 

3295 hosts["advanced"].caption( 

3296 "Only for trials that span several screens, or word boxes " 

3297 "numbered in blocks. Leave empty otherwise." 

3298 ) 

3299 

3300 def _render_box_format(spec: dict) -> str: 

3301 """The box's heading, its format radio, and the row its four 

3302 sub-fields will render into (UX-57). 

3303 

3304 The description moves onto the heading's hover, like every other 

3305 explanation on this page (UX-53), and the row is reserved *here* 

3306 because `_assemble_mapping` calls this immediately before picking 

3307 the four sub-fields — so by the time they ask `_row` for a cell, 

3308 there is one waiting. 

3309 """ 

3310 fmt_key = f"{state_key_prefix}_box_format" 

3311 if fmt_key not in st.session_state: 

3312 # Seed via session state (no `index=`) so it survives reruns 

3313 # and never fights a default arg — same pattern as the 

3314 # multiselect below. 

3315 st.session_state[fmt_key] = _default_box_format(proposed) 

3316 star = " *" if spec.get("required") else "" 

3317 box_host = hosts["main"] 

3318 title = html.escape(_plain(spec["label"]) + star) 

3319 # UX-91: title, format radio and the four coordinate selects on 

3320 # **one** line. They used to stack — heading, radio, then a row of 

3321 # four — which cost three lines for one field on a page whose whole 

3322 # complaint is length, and left the radio looking like a heading for 

3323 # the row beneath rather than the switch that chooses what it holds. 

3324 # 

3325 # The row can be sized before the radio returns because every format 

3326 # in `_BOX_SUBFIELDS` has exactly four sub-fields; only their 

3327 # *names* differ. `_BOX_ROW_W` gives the head enough room for 

3328 # "Word box *" plus both radio options on one line. 

3329 head = box_host 

3330 if stacked: 

3331 cells = box_host.columns(_BOX_ROW_W, gap=_LABEL_GAP) 

3332 head = cells[0] 

3333 box_grid["cells"] = list(cells[1:]) 

3334 box_grid["used"] = 0 

3335 if spec.get("help"): 

3336 tip = tooltip(spec["label"], spec["help"]) 

3337 head.markdown( 

3338 f'<div class="sps-box-title"><span class="sps-fhelp" ' 

3339 f'data-tip="{tip}" aria-label="{tip}">{title}</span></div>', 

3340 unsafe_allow_html=True, 

3341 ) 

3342 else: 

3343 head.markdown( 

3344 f'<div class="sps-box-title">{title}</div>', 

3345 unsafe_allow_html=True, 

3346 ) 

3347 chosen = head.radio( 

3348 "Coordinate format", 

3349 options=list(_BOX_SUBFIELDS), 

3350 key=fmt_key, 

3351 horizontal=True, 

3352 label_visibility="collapsed", 

3353 persist_state="session", 

3354 ) 

3355 return chosen 

3356 

3357 def _render_multi(spec: dict, default, label: str) -> list[str]: 

3358 state_key = f"{state_key_prefix}_{spec['key']}" 

3359 # A source may *declare* a composite id (PoTeC's reader + text). 

3360 parts = default if isinstance(default, (list, tuple)) else [default] 

3361 proposed_default = [c for c in parts if c is not None and c in df.columns] 

3362 stored = st.session_state.get(state_key) 

3363 if stored is None: 

3364 # Seed via session state instead of `default=` so the 

3365 # stale-column reset below never fights a default arg. 

3366 st.session_state[state_key] = proposed_default 

3367 else: 

3368 # A new upload changes the column universe — silently 

3369 # keeping stale picks would leave the field empty (the 

3370 # selectboxes self-heal via their index fallback; a 

3371 # multiselect doesn't). Drop unknown columns and fall 

3372 # back to the auto-proposal when nothing survives. 

3373 valid = [c for c in stored if c in df.columns] 

3374 if len(valid) != len(stored): 

3375 st.session_state[state_key] = valid or proposed_default 

3376 field_col, note_col = _row(spec["key"], label, spec.get("help")) 

3377 # UX-90: a keyed cell, like `_selectbox`'s, so `_emit_field_tints` 

3378 # has something to colour when this required field is left empty. 

3379 cell_key = f"{state_key_prefix}_{spec['key']}_cell" 

3380 field_col = field_col.container(key=cell_key) 

3381 chosen_cols = field_col.multiselect( 

3382 label, 

3383 # UX-108 — the same widened list `_selectbox` uses (`options`, 

3384 # closed over from the outer scope), not `df.columns` again: 

3385 # Trial ID is the one field every dataset composes, and it is a 

3386 # `multi: True` field precisely so several raw columns can be 

3387 # joined into one id — the composable columns are exactly the 

3388 # ones a narrowed parse is most likely to have left out. 

3389 options=options, 

3390 format_func=_option_label, 

3391 key=state_key, 

3392 help=spec.get("help"), 

3393 label_visibility="collapsed", 

3394 select_all=False, # an id is a few columns, never all (#374) 

3395 persist_state="session", 

3396 on_change=_mark_field_touched, 

3397 args=(state_key,), 

3398 ) 

3399 # UX-176: the same amber / ✨-confirm / green / red rule the 

3400 # selects get (UX-90's red-when-required-and-empty included). 

3401 state = multi_field_flag( 

3402 note_col, 

3403 state_key=state_key, 

3404 cell_key=cell_key, 

3405 chosen=list(chosen_cols), 

3406 default=proposed_default, 

3407 required=spec["key"] in required_keys, 

3408 detected_label=detected_label, 

3409 preview=value_preview_tip(df, spec["key"], list(chosen_cols)), 

3410 ) 

3411 if state: 

3412 tint_cells.setdefault(state, []).append(cell_key) 

3413 return list(chosen_cols) 

3414 

3415 mapping = _assemble_mapping( 

3416 df, 

3417 field_specs, 

3418 proposed, 

3419 only_keys, 

3420 pick=_selectbox, 

3421 pick_box_format=_render_box_format, 

3422 pick_multi=_render_multi, 

3423 ) 

3424 _emit_field_tints(tint_cells) 

3425 return mapping 

3426 

3427 

3428def multi_field_flag( 

3429 flag_host, 

3430 *, 

3431 state_key: str, 

3432 cell_key: str, 

3433 chosen: list, 

3434 default: list, 

3435 required: bool, 

3436 detected_label: str = "auto-detected", 

3437 preview: str = "", 

3438) -> str: 

3439 """The ✨ flag of a *multi-column* picker, and its tint state (UX-176). 

3440 

3441 The identity pickers (Trial / Participant / Text ID) are multiselects, so 

3442 they never reached `_selectbox`'s amber tint and ✨ confirm button — the 

3443 auto-detected id read exactly like one somebody had checked. This is the 

3444 same rule for them: the columns detection proposed, untouched, are amber 

3445 with a ✨ **button** that approves them; picking goes green; clearing goes 

3446 neutral (or red once an add is attempted, for a required one). Returns the 

3447 `_FIELD_TINT` state for the caller to paint (`mark_cells`). ``preview`` 

3448 is the picked columns' value preview (`value_preview_tip`).""" 

3449 joined = " + ".join(chosen) if chosen else None 

3450 proposed = " + ".join(default) if default else None 

3451 state, hover = _field_state( 

3452 chosen=joined, 

3453 default=proposed, 

3454 is_required=required, 

3455 attempted=bool(st.session_state.get(ADD_ATTEMPTED_KEY)), 

3456 touched=state_key in st.session_state.get(TOUCHED_FIELDS_KEY, ()), 

3457 detected_label=detected_label, 

3458 ) 

3459 _render_field_flag( 

3460 flag_host, 

3461 state=state, 

3462 hover=hover, 

3463 preview=preview, 

3464 confirm_label="Confirm the detected columns", 

3465 confirm_help=f"{hover} — click to confirm and clear the mark.", 

3466 cell_key=cell_key, 

3467 state_key=state_key, 

3468 ) 

3469 return state 

3470 

3471 

3472def value_preview_tip(df, field_key: str, column) -> str: 

3473 """`data.mapping_value_preview` for a picked column, as a tooltip line.""" 

3474 if column in (None, NONE_OPTION, "", []): 

3475 return "" 

3476 preview = mapping_value_preview(df, field_key, column) 

3477 return f"Values: {preview}" if preview else "" 

3478 

3479 

3480def _render_field_flag( 

3481 host, 

3482 *, 

3483 state: str, 

3484 hover: str, 

3485 preview: str, 

3486 confirm_label: str, 

3487 confirm_help: str, 

3488 cell_key: str, 

3489 state_key: str, 

3490) -> None: 

3491 """A mapping row's ✨ flag and its value preview, in the slot beside it. 

3492 

3493 The ✨ is a **button** while the row is amber (UX-92) and an icon whose 

3494 tooltip says what detection found otherwise. The preview — a few of the 

3495 mapped column's values and what the app reads them as — is an icon of its 

3496 own with a hover tooltip, like every other note on this form; on an amber 

3497 row it rides on the confirm button's tooltip instead, which is where the 

3498 eye already is, and keeps the slot one line tall. 

3499 """ 

3500 if state == "auto": 

3501 host.button( 

3502 # UX-200: named for screen readers; only the ✨ shows. 

3503 f"{ICONS['auto_detected']} {spoken(confirm_label)}", 

3504 wrap=True, 

3505 key=f"{cell_key}_confirm", 

3506 help=confirm_help + (f"\n\n{preview}" if preview else ""), 

3507 on_click=_mark_field_touched, 

3508 args=(state_key,), 

3509 ) 

3510 return 

3511 # Icons only, on the rail's CSS hover (`.sps-fhelp`, 120 ms) rather than 

3512 # the browser's ~1 s native one. 

3513 spans = [] 

3514 if hover: 

3515 spans.append( 

3516 f'<span class="sps-map-flag sps-fhelp" ' 

3517 f'data-tip="{tooltip(hover)}">' 

3518 f"{icon_html('auto_detected')}</span>" 

3519 ) 

3520 if preview: 

3521 tip = tooltip(preview) 

3522 spans.append( 

3523 f'<span class="sps-map-flag sps-map-preview sps-fhelp" tabindex="0" ' 

3524 f'data-tip="{tip}" aria-label="{tip}">{icon_html("preview")}</span>' 

3525 ) 

3526 if spans: 

3527 host.markdown("".join(spans), unsafe_allow_html=True) 

3528 

3529 

3530def mark_cells(cells_by_state: dict) -> None: 

3531 """Paint mapping cells built outside `column_mapping_ui` (UX-176) — 

3532 ``{state: [cell_key, …]}``, the same states and `<style>` block.""" 

3533 _emit_field_tints( 

3534 { 

3535 state: [str(k) for k in keys] 

3536 for state, keys in cells_by_state.items() 

3537 if keys 

3538 } 

3539 ) 

3540 

3541 

3542def mark_missing_cells(cell_keys) -> None: 

3543 """Paint the *missing* tint onto keyed cells the mapping UI did not render. 

3544 

3545 UX-91. `wizard._render_identity_field` builds its own multiselects — one per 

3546 table, laid out by the caller — so they never pass through 

3547 `column_mapping_ui` and never reached `tint_cells`. Trial ID is required and 

3548 was the last field that could survive a failed add without going red. This 

3549 is the same `<style>` block by the same rules; only the collection point 

3550 differs. 

3551 """ 

3552 _emit_field_tints({"missing": [str(key) for key in cell_keys]}) 

3553 

3554 

3555def _emit_field_tints(tint_cells: dict[str, list[str]]) -> None: 

3556 """One <style> block tinting each mapping cell by its state (UX-53 r4). 

3557 

3558 Written after the rows because a cell's state is only known once its widget 

3559 has returned a value. Targets the BaseWeb select *control* rather than the 

3560 Streamlit wrapper, so the colour lands on the box the user is looking at and 

3561 not on the whole row. 

3562 """ 

3563 rules = [] 

3564 for state, keys in tint_cells.items(): 

3565 tint = _FIELD_TINT.get(state) 

3566 if not tint or not keys: 

3567 continue 

3568 # The painted node, checked against the live DOM (UX-91): a selectbox 

3569 # and a multiselect have the *same* shape — 

3570 # `[data-testid="stSelectbox"|"stMultiSelect"] > div > div` is the box 

3571 # carrying the background, and everything above it is transparent. 

3572 # `!important` because the theme paints that node through an emotion 

3573 # class that outranks a plain class selector. 

3574 # 

3575 # The earlier `[data-baseweb="select"]` selectors are gone: they match 

3576 # **nothing** in this Streamlit version. Selectboxes tinted anyway, 

3577 # through the `stSelectbox` fallback beside them, which is why the rule 

3578 # looked fine — while multiselects (Trial ID, the one required one) had 

3579 # no matching selector at all and silently never coloured. 

3580 selector = ", ".join( 

3581 f".st-key-{key} [data-testid='stSelectbox'] > div > div, " 

3582 f".st-key-{key} [data-testid='stMultiSelect'] > div > div" 

3583 for key in keys 

3584 ) 

3585 rules.append(f"{selector} {{ background-color: {tint} !important; }}") 

3586 if rules: 

3587 st.markdown(f"<style>{''.join(rules)}</style>", unsafe_allow_html=True) 

3588 

3589 

3590# Field-option helpers — shared by the rail's selectors and the plot-config 

3591# restore path (`app._restore_plot_config`) so both agree on what's valid for 

3592# the current data. 

3593#: Numeric fixation columns 'Color fixations by' never offers on its own: 

3594#: identifiers and on-screen geometry, which say *which* fixation or *where* — 

3595#: the figure already shows both — not something about it. 

3596_COLOR_BY_EXCLUDED = frozenset( 

3597 { 

3598 "participant_id", 

3599 "trial_id", 

3600 "text_id", 

3601 "screen_id", 

3602 "screen_index", 

3603 "fixation_id", 

3604 "screen_fixation_id", 

3605 "x", 

3606 "y", 

3607 "canvas_width", 

3608 "canvas_height", 

3609 } 

3610) 

3611 

3612 

3613def color_field_options(trial_fixations: pd.DataFrame) -> list[str]: 

3614 """Columns offered in the 'Color fixations by' selector — the familiar fields 

3615 in a preferred order, then the dataset's other numeric columns, falling back 

3616 to ``['duration_ms']``.""" 

3617 preferred_color_fields = [ 

3618 "duration_ms", 

3619 "pass_index", 

3620 "eye", 

3621 "saccade_type", 

3622 "saccade_amplitude", 

3623 # BUG-25: EyeLink's own amplitudes, in degrees, kept distinct from the 

3624 # pixel one above (and from each other — outgoing vs incoming saccade). 

3625 "next_saccade_amplitude_deg", 

3626 "prev_saccade_amplitude_deg", 

3627 "word_id", 

3628 "timestamp_ms", 

3629 "is_regression", 

3630 "progression", 

3631 "gpt2_surprisal", 

3632 "wordfreq_frequency", 

3633 "subtlex_frequency", 

3634 "universal_pos", 

3635 "ptb_pos", 

3636 ] 

3637 fields = [f for f in preferred_color_fields if f in trial_fixations.columns] 

3638 # Then every other numeric column the dataset kept (pupil size, a detection 

3639 # confidence, a measure of its own), as the axis and hover pickers offer 

3640 # them — but no identifier, no position (the plot already *is* x/y) and no 

3641 # bookkeeping column (`user_columns`), and no boolean: a 0–1 colorscale over 

3642 # a flag reads worse than the flags' own pickers. 

3643 fields += [ 

3644 col 

3645 for col in user_columns(trial_fixations) 

3646 if col not in fields 

3647 and col not in _COLOR_BY_EXCLUDED 

3648 and pd.api.types.is_numeric_dtype(trial_fixations[col]) 

3649 and not pd.api.types.is_bool_dtype(trial_fixations[col]) 

3650 ] 

3651 fields = fields or ["duration_ms"] 

3652 # `(uniform)` leads and is the default (VIZ-17): marker *size* already encodes 

3653 # duration, so mapping duration to hue as well spends the colour channel on a 

3654 # variable that's already shown. Colour-by is then an opt-in for a *second* 

3655 # variable. "line" is likewise synthetic (not a real column): colour each 

3656 # fixation by the text line it lands on, inferred from word geometry. 

3657 return [UNIFORM_COLOR_FIELD] + fields + ["line"] 

3658 

3659 

3660def hover_field_options( 

3661 frame: pd.DataFrame | None, *, words: bool = False 

3662) -> list[str]: 

3663 """Scalar columns that can be added to a VIZ-26 hover tooltip. 

3664 

3665 Read off the columns, never the rows: a trial with no fixations still has 

3666 the dataset's columns, and answering ``[]`` for it made `_seed_viz_state` 

3667 drop the user's hover picks as stale the moment a filter landed on one 

3668 (VIZ-44), for good. 

3669 """ 

3670 if frame is None: 

3671 return [] 

3672 preferred = ( 

3673 [ 

3674 "text", 

3675 "word_id", 

3676 "line_idx", 

3677 "total_fixation_duration_ms", 

3678 "first_fixation_ms", 

3679 "first_pass_gaze_duration_ms", 

3680 "regression_path_duration_ms", 

3681 "n_fixations", 

3682 ] 

3683 if words 

3684 else [ 

3685 "order_in_trial", 

3686 "duration_ms", 

3687 "word_id", 

3688 "timestamp_ms", 

3689 "pass_index", 

3690 "eye", 

3691 "saccade_type", 

3692 "saccade_amplitude", 

3693 ] 

3694 ) 

3695 available = user_columns(frame) 

3696 if words and {"x", "y", "height"} <= set(frame.columns): 

3697 available.append("line_idx") # geometry-derived in plots._add_word_label_trace 

3698 result: list[str] = [] 

3699 for column in [*preferred, *available]: 

3700 if column in available and column != "image_path" and column not in result: 

3701 result.append(column) 

3702 return result 

3703 

3704 

3705def numeric_field_options(trial_fixations: pd.DataFrame) -> list[str]: 

3706 """Numeric columns offered as X/Y axis fields.""" 

3707 return [ 

3708 col 

3709 for col in user_columns(trial_fixations) 

3710 if pd.api.types.is_numeric_dtype(trial_fixations[col]) 

3711 ] 

3712 

3713 

3714# Word columns that look like a per-word boolean flag the user might want to 

3715# highlight on the text (the OneStop answer/distractor spans first, then any 

3716# other boolean column). 

3717_PREFERRED_HIGHLIGHT_FIELDS = ["is_in_aspan", "is_in_dspan"] 

3718 

3719#: The highlight column the app seeded itself, as opposed to one the user 

3720#: picked: a seeded pick is re-derived when the dataset changes. 

3721_HIGHLIGHT_SEEDED_KEY = "_global_highlight_column_seeded" 

3722 

3723 

3724def highlight_column_options(words: pd.DataFrame | None) -> list[str]: 

3725 """Boolean word columns offered in the 'Highlight words by' selector. 

3726 

3727 The OneStop answer/distractor spans lead, followed by any other boolean 

3728 column in the words frame. Empty when there's nothing to highlight.""" 

3729 if words is None or words.empty: 

3730 return [] 

3731 cols = [c for c in _PREFERRED_HIGHLIGHT_FIELDS if c in words.columns] 

3732 for col in user_columns(words): 

3733 if col not in cols and pd.api.types.is_bool_dtype(words[col]): 

3734 cols.append(col) 

3735 return cols 

3736 

3737 

3738def _drop_stale(state_key: str, options: list) -> None: 

3739 """Clear a persisted selectbox value that isn't valid for the current 

3740 ``options`` (e.g. after switching datasets, or restoring a config built on 

3741 different data) so ``st.selectbox`` falls back to its ``index=`` default 

3742 instead of raising.""" 

3743 if state_key in st.session_state and st.session_state[state_key] not in options: 

3744 del st.session_state[state_key] 

3745 

3746 

3747def _drop_stale_multi(state_key: str, options: list) -> None: 

3748 """Keep only still-valid values in a persisted multiselect list.""" 

3749 value = st.session_state.get(state_key) 

3750 if isinstance(value, (list, tuple)): 

3751 filtered = [item for item in value if item in options] 

3752 if list(value) != filtered: 

3753 st.session_state[state_key] = filtered 

3754 elif value is not None: 

3755 st.session_state.pop(state_key, None) 

3756 

3757 

3758def _explicit_pair(val) -> tuple | None: 

3759 """A stored ``(min, max)`` as an ordered pair of finite floats, or ``None`` 

3760 for a malformed/missing value — WITHOUT touching session_state. 

3761 

3762 Shared by the rail's colour-range slider (``_render_color_range``) and 

3763 ``_collect_viz_settings``, so the figure and the slider read one value. It 

3764 is deliberately **not** clamped to the loaded data: an explicit range is 

3765 the user's endpoints, and narrowing the trial pool must not change the 

3766 mapping it pins (round-7 review, finding 10). VIZ-46 clamped it to the 

3767 pool's span, which re-scaled a pinned figure whenever a filter removed the 

3768 trial holding its extreme value.""" 

3769 if not (isinstance(val, (list, tuple)) and len(val) == 2): 

3770 return None 

3771 try: 

3772 a, b = float(val[0]), float(val[1]) 

3773 except (TypeError, ValueError): 

3774 return None 

3775 if not (math.isfinite(a) and math.isfinite(b)): 

3776 return None 

3777 return (min(a, b), max(a, b)) 

3778 

3779 

3780# --- VIZ-46: a colour range is *auto* until the user sets one ----------------- 

3781# `api.plot_scanpath` leaves `fixation_color_range` / `heatmap_range` at `None`, 

3782# and every builder then scales the figure to its own trial (a comparison, to A 

3783# and B together). The rail used to `setdefault` its slider key to the whole 

3784# dataset's span the moment the slider rendered, so the app never drew that 

3785# default: its heatmap sat on the dataset's longest single fixation while the 

3786# headless one used the trial's own per-word values. 

3787# 

3788# So the canonical `global_*` key now means **explicit**: it is present only 

3789# when a range was chosen — dragged or typed here, un-ticking *Auto*, or 

3790# arriving on a Share link / saved config / saved design — and `None` reaches 

3791# the builders otherwise, i.e. the API's rule, not a copy of it. The slider 

3792# draws a private *view* key instead, seeded from the canonical value or (auto) 

3793# the dataset span it is bounded by, so rendering it can no longer pin a number 

3794# into every link, config and bulk export. Nothing else changes shape: the 

3795# link's generic range sweep already emits only a key that is present, and the 

3796# config writer already writes the figure's `None`. 

3797_COLOR_RANGE_URL_PARAMS = { 

3798 state_key: param for param, state_key in SHARE_FLOAT_RANGE_PARAMS.items() 

3799} 

3800 

3801 

3802def _color_range_view_key(state_key: str) -> str: 

3803 """The private key the rail's slider draws for ``state_key`` (VIZ-46).""" 

3804 return f"_{state_key.removeprefix('global_')}_view" 

3805 

3806 

3807def _color_range_auto_key(state_key: str) -> str: 

3808 """The private key of ``state_key``'s *Auto* checkbox (VIZ-46).""" 

3809 return f"_{state_key.removeprefix('global_')}_auto" 

3810 

3811 

3812def forget_color_range(state_key: str) -> None: 

3813 """Put one colour range back to auto — per trial, like the API (VIZ-46). 

3814 

3815 Also drops the link param that may have set it: `url_state._apply_url_preset` 

3816 re-seeds from `st.query_params` at the top of every rerun, so on a page 

3817 opened from a Share link the range would otherwise come straight back. 

3818 """ 

3819 st.session_state.pop(state_key, None) 

3820 param = _COLOR_RANGE_URL_PARAMS.get(state_key) 

3821 if param is not None: 

3822 st.query_params.pop(param, None) 

3823 

3824 

3825#: The reading's grain, in the order a per-word dwell groups by: one word of one 

3826#: screen of one reading (the screen only on multipart data). 

3827_WORD_DWELL_KEYS = ("participant_id", "trial_id", "screen_id", "word_id") 

3828 

3829 

3830def heatmap_value_bounds( 

3831 fixations: pd.DataFrame | None, 

3832 words: pd.DataFrame | None, 

3833 *, 

3834 counts: bool = False, 

3835) -> tuple[float, float] | None: 

3836 """The span of the values a duration-weighted word-box heatmap maps, in ms. 

3837 

3838 A word box is tinted by the *summed* duration of the fixations in it, so 

3839 its range is per-word dwell — which refixations and rereading push well 

3840 past the longest single fixation the rail used to bound it by (round-7 

3841 review, finding 11). Summed over the pool's ``word_id`` assignment, the 

3842 grouping Compare's shared word heatmap uses too; the static heatmap bins by 

3843 box containment, which can differ by a stray fixation, so these are the 

3844 slider's *suggested* bounds and any endpoint can still be typed. 

3845 

3846 Without a ``word_id`` the upper bound is a reading's whole dwell (no word 

3847 can hold more); words-only data (no fixations) maps its own 

3848 ``total_fixation_duration_ms``, as the figure's fallback does. ``None`` 

3849 when there is nothing to map. One groupby over the pool, cached on its 

3850 fingerprint by :func:`_heatmap_value_bounds_cached`. 

3851 

3852 ``counts`` gives the span of fixations per word instead (per reading 

3853 without a ``word_id``); the words-only fallback has no counts to give. 

3854 """ 

3855 if counts: 

3856 if fixations is None or fixations.empty: 

3857 return None 

3858 keys = [k for k in _WORD_DWELL_KEYS if k in fixations.columns] 

3859 if "word_id" in keys and fixations["word_id"].notna().any(): 

3860 per_word = ( 

3861 fixations[fixations["word_id"].notna()] 

3862 .groupby(keys, dropna=False, sort=False) 

3863 .size() 

3864 ) 

3865 else: 

3866 per_word = fixations.groupby( 

3867 [k for k in keys if k != "word_id"] or [np.zeros(len(fixations))], 

3868 dropna=False, 

3869 sort=False, 

3870 ).size() 

3871 return (1.0, float(per_word.max())) if len(per_word) else None 

3872 if ( 

3873 fixations is not None 

3874 and not fixations.empty 

3875 and "duration_ms" in fixations.columns 

3876 ): 

3877 duration = pd.to_numeric(fixations["duration_ms"], errors="coerce") 

3878 keys = [k for k in _WORD_DWELL_KEYS if k in fixations.columns] 

3879 if "word_id" in keys and fixations["word_id"].notna().any(): 

3880 frame = fixations[keys].assign(_d=duration) 

3881 frame = frame[frame["word_id"].notna()] 

3882 dwell = frame.groupby(keys, dropna=False, sort=False)["_d"].sum() 

3883 values = dwell[dwell > 0] 

3884 if not values.empty: 

3885 return float(values.min()), float(values.max()) 

3886 positive = duration[duration > 0] 

3887 if positive.empty: 

3888 return None 

3889 reading = [k for k in _WORD_DWELL_KEYS[:3] if k in fixations.columns] 

3890 if reading: 

3891 per_reading = positive.groupby( 

3892 [fixations.loc[positive.index, k] for k in reading], dropna=False 

3893 ).sum() 

3894 upper = float(per_reading.max()) 

3895 else: 

3896 upper = float(positive.sum()) 

3897 return float(positive.min()), upper 

3898 if words is not None and "total_fixation_duration_ms" in words.columns: 

3899 values = pd.to_numeric(words["total_fixation_duration_ms"], errors="coerce") 

3900 values = values[values > 0] 

3901 if not values.empty: 

3902 return float(values.min()), float(values.max()) 

3903 return None 

3904 

3905 

3906def _heatmap_bounds_for_rail( 

3907 fixations: pd.DataFrame | None, 

3908 words: pd.DataFrame | None, 

3909 *, 

3910 counts: bool = False, 

3911) -> tuple[float, float] | None: 

3912 """:func:`heatmap_value_bounds`, cached on the frames it actually reads. 

3913 

3914 The words table is read only when the fixations carry no durations (the 

3915 words-only fallback), so only then does it — and its fingerprint — enter 

3916 the cache key; otherwise a change to the words cannot change the answer. 

3917 """ 

3918 from scanpath_studio.data import frame_fingerprint 

3919 

3920 words_used = ( 

3921 fixations is None or fixations.empty or "duration_ms" not in fixations.columns 

3922 ) 

3923 if not words_used: 

3924 words = None 

3925 return _heatmap_value_bounds_cached( 

3926 fixations, 

3927 words, 

3928 counts, 

3929 ( 

3930 frame_fingerprint(fixations), 

3931 None if words is None else frame_fingerprint(words), 

3932 ), 

3933 ) 

3934 

3935 

3936def _cheap_heatmap_bounds( 

3937 fixations: pd.DataFrame | None, *, counts: bool = False 

3938) -> tuple[float, float] | None: 

3939 """Placeholder bounds for the greyed range while the heatmap is off. 

3940 

3941 The shortest and longest single fixation — one vectorised pass and no 

3942 groupby — or ``None`` when there is none (the range is then not drawn, 

3943 as before). The heatmap's own bounds (:func:`_heatmap_bounds_for_rail`) 

3944 replace them once it is shown. For ``counts``, 1 to the fixation count. 

3945 """ 

3946 if counts: 

3947 n = 0 if fixations is None else len(fixations) 

3948 return (1.0, float(n)) if n else None 

3949 if ( 

3950 fixations is not None 

3951 and not fixations.empty 

3952 and "duration_ms" in fixations.columns 

3953 ): 

3954 duration = pd.to_numeric(fixations["duration_ms"], errors="coerce") 

3955 duration = duration[duration > 0] 

3956 if not duration.empty: 

3957 return float(duration.min()), float(duration.max()) 

3958 return None 

3959 

3960 

3961@st.cache_data(show_spinner=False, max_entries=8) 

3962def _heatmap_value_bounds_cached( 

3963 _fixations: pd.DataFrame | None, _words: pd.DataFrame | None, counts, cache_key 

3964) -> tuple[float, float] | None: 

3965 return heatmap_value_bounds(_fixations, _words, counts=counts) 

3966 

3967 

3968def _render_color_range( 

3969 label: str, 

3970 state_key: str, 

3971 lo: float, 

3972 hi: float, 

3973 *, 

3974 disabled: bool, 

3975 reason: str, 

3976 help: str | None = None, 

3977 field_host=None, 

3978 slider_format: str = "%d", 

3979) -> None: 

3980 """*Auto* checkbox + the ``[lo, hi]``-bounded range slider (VIZ-46). 

3981 

3982 ``slider_format`` labels the handles, e.g. ``"%d ms"`` for a range in ms; 

3983 the number boxes beside it keep a bare number. 

3984 

3985 ``field_host`` (UX-158) draws *Auto*, the slider and its boxes into that 

3986 column, for a `_sub_row` whose title and caption the caller has already 

3987 drawn. 

3988 

3989 While the range is auto the slider sits at its full bounds and *Auto* is 

3990 ticked; the figure is scaled to the trial, not to those bounds. Dragging the 

3991 slider or typing a bound makes the range explicit (and un-ticks *Auto*), as 

3992 does un-ticking *Auto* itself, which pins the bounds on screen — the 

3993 dataset-wide scale the app used to default to, now one click away. An 

3994 explicit range is sticky across trials until *Auto* is ticked again. 

3995 

3996 ``[lo, hi]`` is the span the data suggests. An explicit range is drawn — 

3997 and reaches the figure — exactly as stored, never clamped to it: the 

3998 slider's bounds widen to hold its endpoints when no remaining observation 

3999 reaches them (a filter removed the trial with the extreme value, or the 

4000 range came on a link built on other data), and the number boxes take any 

4001 endpoint, beyond the observed span too (round-7 review, findings 10–11). 

4002 """ 

4003 ss = st.session_state 

4004 view_key = _color_range_view_key(state_key) 

4005 auto_key = _color_range_auto_key(state_key) 

4006 explicit = _explicit_pair(ss.get(state_key)) 

4007 if explicit is None: 

4008 ss.pop(state_key, None) # a malformed value is not a range 

4009 else: 

4010 lo = min(lo, float(math.floor(explicit[0]))) 

4011 hi = max(hi, float(math.ceil(explicit[1]))) 

4012 hi = hi if hi > lo else lo + 1.0 

4013 shown = explicit if explicit is not None else (lo, hi) 

4014 if ss.get(view_key) != shown: 

4015 ss[view_key] = shown 

4016 ss[auto_key] = explicit is None 

4017 

4018 def _commit_view() -> None: 

4019 view = ss.get(view_key) 

4020 if isinstance(view, (tuple, list)) and len(view) == 2: 

4021 ss[state_key] = (float(min(view)), float(max(view))) 

4022 

4023 def _toggle_auto() -> None: 

4024 if ss.get(auto_key): 

4025 forget_color_range(state_key) 

4026 else: 

4027 _commit_view() 

4028 

4029 auto_text = ( 

4030 "Auto (default): each trial is scaled to its own values; in Compare, A and " 

4031 "B share one range. Off: the range applies to every trial. Dragging the " 

4032 "range turns Auto off." 

4033 ) 

4034 auto_disabled, _ = _layer_gate(disabled, None) 

4035 

4036 # UX-157: *Auto* sits on the range's own line, between its title and the 

4037 # slider, instead of a row of its own above it. Its explanation joins the 

4038 # row title's tooltip: a `?` icon beside it would squeeze "Auto" to "A…". 

4039 def _auto(col) -> None: 

4040 col.checkbox( 

4041 "Auto", 

4042 key=auto_key, 

4043 on_change=_toggle_auto, 

4044 disabled=auto_disabled, 

4045 ) 

4046 

4047 range_help = _gated_help(f"{help} {auto_text}" if help else auto_text, reason) 

4048 _range_slider( 

4049 st, 

4050 label, 

4051 label_left=True, 

4052 key=view_key, 

4053 min_value=lo, 

4054 max_value=hi, 

4055 step=1.0, 

4056 slider_format=slider_format, 

4057 number_format="%d", 

4058 disabled=disabled, 

4059 on_change=_commit_view, 

4060 help=range_help, 

4061 lead=_auto, 

4062 field_host=field_host, 

4063 # Any endpoint can be typed: the slider spans the data, but a common 

4064 # scale often reaches past this pool's largest value. 

4065 number_bounds=(None, None), 

4066 ) 

4067 

4068 

4069def _sub_row( 

4070 caption: str | None, 

4071 *, 

4072 section: str | None = None, 

4073 section_help: str | None = None, 

4074 caption_help: str | None = None, 

4075 section_share: float = 0.45, 

4076): 

4077 """One row of a titled group of rows; return the column for its field (UX-158). 

4078 

4079 The label column is split in two: the group's title (``section``, drawn on 

4080 the group's first row only) and this row's short caption, so a run of 

4081 related controls reads as one setting without a full title per row. The 

4082 two together are exactly the label column's width, so the fields line up 

4083 with the ordinary ``label | field`` rows around them. ``section_share`` is 

4084 the title's part of that column — wider for a longer title ("Scanpath A"). 

4085 A ``None`` caption leaves its cell empty, for a group's continuation row. 

4086 """ 

4087 label_w = _label_w() 

4088 section_w = label_w * section_share 

4089 section_col, caption_col, field_col = st.columns( 

4090 [section_w, label_w - section_w, 1.0 - label_w], 

4091 gap=_LABEL_GAP, 

4092 vertical_alignment="center", 

4093 ) 

4094 if section is not None: 

4095 _, section_help = _layer_gate(False, section_help) 

4096 _row_label(section_col, section, section_help) 

4097 if caption: 

4098 _sub_caption(caption_col, caption, caption_help) 

4099 return field_col 

4100 

4101 

4102def _check_row( 

4103 label: str, 

4104 *, 

4105 key: str, 

4106 help: str | None = None, 

4107 check_label: str = "Show", 

4108 check_share: float = 0.26, 

4109 disabled: bool = False, 

4110 on_change=None, 

4111 args: tuple = (), 

4112 persist_state: str | None = None, 

4113): 

4114 """A ``label | ☑ Show | …`` row; return ``(value, rest)`` (UX-159). 

4115 

4116 The shape UX-155 gave *Fixation index*: the row's title on the left, a 

4117 checkbox that says what it does, and the rest of the row (``rest``) for the 

4118 controls it governs — which the caller greys while it is off rather than 

4119 hiding them. The help goes on the title's tooltip, not the checkbox, so the 

4120 row carries no ``?`` icon. ``persist_state`` is forwarded to the checkbox, 

4121 and every caller on a wire-format key spells out ``"session"`` — 

4122 `test_widget_value_sync` checks for it at the call site. 

4123 """ 

4124 disabled, help = _layer_gate(disabled, help) 

4125 label_w = _label_w() 

4126 rest_w = 1.0 - label_w 

4127 label_col, check_col, rest_col = st.columns( 

4128 [label_w, rest_w * check_share, rest_w * (1.0 - check_share)], 

4129 gap=_LABEL_GAP, 

4130 vertical_alignment="center", 

4131 ) 

4132 _row_label(label_col, label, help) 

4133 return ( 

4134 check_col.checkbox( 

4135 check_label, 

4136 key=key, 

4137 disabled=disabled, 

4138 on_change=on_change, 

4139 args=args, 

4140 persist_state=persist_state, 

4141 ), 

4142 rest_col, 

4143 ) 

4144 

4145 

4146#: The help on the duration-scale rows — shared with nothing else, but long 

4147#: enough that the row code reads better without it inline. 

4148_SCALE_HELP = ( 

4149 "How duration sets marker size. √, linear and log use the duration bounds " 

4150 "below, so a duration is the same size in every figure. Relative stretches " 

4151 "each figure from its own shortest to longest fixation." 

4152) 

4153_DURATION_BOUNDS_HELP = ( 

4154 "The durations (ms) that get the smallest and the largest marker. Unused on " 

4155 "the relative scale." 

4156) 

4157_SIZE_KEY_HELP = ( 

4158 "Reference circles labeled in ms. Drawn on a fixed scale only, and in Compare " 

4159 "only when both scanpaths use the same size range." 

4160) 

4161 

4162 

4163def _render_duration_scale_rows() -> None: 

4164 """The fixed duration scale: curve, duration bounds and the size key. 

4165 

4166 One scale for both scanpaths of a comparison too (only the size *range* is 

4167 per scanpath there), so none of the three carries Compare's gate. The bounds 

4168 and the key are greyed, never hidden, on the relative scale. 

4169 """ 

4170 scale_dis, scale_help = _layer_gate(False, _SCALE_HELP) 

4171 # Keyless, like the colorscale picker: a keyed selectbox first painted in a 

4172 # closed popover shows its first option rather than the seeded value — and a 

4173 # link or settings file that predates the fixed scale seeds "relative". 

4174 options = list(MARKER_SIZE_SCALES) 

4175 current = st.session_state.get("global_marker_size_scale") 

4176 st.session_state["global_marker_size_scale"] = _sub_row( 

4177 "Scale", caption_help=scale_help 

4178 ).selectbox( 

4179 "Duration scale", 

4180 options=options, 

4181 index=options.index(current) if current in options else 0, 

4182 format_func=lambda s: MARKER_SIZE_SCALES[s], 

4183 disabled=scale_dis, 

4184 help=scale_help, 

4185 label_visibility="collapsed", 

4186 ) 

4187 relative = st.session_state["global_marker_size_scale"] == "relative" 

4188 _, bounds_help = _layer_gate(relative, _DURATION_BOUNDS_HELP) 

4189 _range_slider( 

4190 st, 

4191 "Durations (ms)", 

4192 key="global_marker_duration_range", 

4193 persist_state="session", 

4194 min_value=MARKER_DURATION_BOUNDS[0], 

4195 max_value=MARKER_DURATION_BOUNDS[1], 

4196 step=10, 

4197 disabled=relative, 

4198 slider_format="%d ms", 

4199 number_format="%d", 

4200 help=_DURATION_BOUNDS_HELP, 

4201 field_host=_sub_row("Durations", caption_help=bounds_help), 

4202 ) 

4203 key_dis, key_help = _layer_gate(relative, _SIZE_KEY_HELP) 

4204 _sub_row("Size key", caption_help=key_help).checkbox( 

4205 "Show", 

4206 key="global_duration_size_legend", 

4207 persist_state="session", 

4208 disabled=key_dis, 

4209 ) 

4210 

4211 

4212def _sub_caption(host, text: str, help: str | None = None) -> None: 

4213 """A muted field caption — `fields.row_label`'s markup plus ``.sps-fsub``.""" 

4214 text = _plain(text) 

4215 if not help: 

4216 host.markdown( 

4217 f'<span class="sps-flabel sps-fsub">{html.escape(text)}</span>', 

4218 unsafe_allow_html=True, 

4219 ) 

4220 return 

4221 tip = tooltip(text, help) 

4222 host.markdown( 

4223 f'<span class="sps-fhelp" data-tip="{tip}" aria-label="{tip}">' 

4224 f'<span class="sps-flabel sps-flabel-help sps-fsub">{html.escape(text)}' 

4225 "</span></span>", 

4226 unsafe_allow_html=True, 

4227 ) 

4228 

4229 

4230#: UX-159: "A" and "B", as the ⚖️ Compare popover's *Label A* / *Label B* and 

4231#: the figure's A/B legend name them — they were "Scanpath 1" / "Scanpath 2". 

4232_COMPARE_SCANPATHS = ((0, "Scanpath A"), (1, "Scanpath B")) 

4233 

4234 

4235def render_pattern_help(host, fields: dict) -> None: 

4236 """The one place the ``{field}`` vocabulary is spelled out (UX-31). 

4237 

4238 Three surfaces speak this little language — the figure title/caption, the 

4239 Compare A/B legend labels, and the bulk-export file-naming pattern — and two 

4240 of them used to describe it only as "same fields as the other one", so a user 

4241 who had seen neither had no way to learn that ``{participant_id}`` was even a 

4242 thing. Rendered as a collapsed expander: it is reference material, not a 

4243 control, and the rail is narrow. 

4244 """ 

4245 if not fields: 

4246 return 

4247 from .export import TABLE_PATTERN_LABELS 

4248 

4249 # EXP-22: `{table.field}` names are listed under their table's heading, 

4250 # after the plain list — which they are kept out of, so it reads as it did. 

4251 grouped: dict[str, list[str]] = {} 

4252 plain = [] 

4253 for name in fields: 

4254 table, dot, _rest = str(name).partition(".") 

4255 if dot and table in TABLE_PATTERN_LABELS: 

4256 grouped.setdefault(table, []).append(name) 

4257 else: 

4258 plain.append(name) 

4259 sections = [ 

4260 f"**{TABLE_PATTERN_LABELS[table]}**\n\n" 

4261 + "\n".join(f"- `{{{name}}}`" for name in sorted(grouped[table])) 

4262 for table in TABLE_PATTERN_LABELS 

4263 if table in grouped 

4264 ] 

4265 with host.expander("Available fields", expanded=False): 

4266 st.markdown( 

4267 "Type any of these in a pattern and the trial's own value is " 

4268 "substituted:\n\n" 

4269 + "\n".join(f"- `{{{name}}}`" for name in sorted(plain)) 

4270 + "".join(f"\n\n{section}" for section in sections) 

4271 + "\n\nAnything else is left as literal text." 

4272 ) 

4273 

4274 

4275def _trial_rows( 

4276 frame: pd.DataFrame | None, trial_fixations: pd.DataFrame 

4277) -> pd.DataFrame: 

4278 """``frame``'s rows for the trial ``trial_fixations`` holds (EXP-22). 

4279 

4280 Cached on the frame's fingerprint and the trial: ``frame`` is the filtered 

4281 corpus, and slicing it by string ids is a full scan the rail would otherwise 

4282 repeat on every rerun while *Title* or *Caption* is on.""" 

4283 if frame is None or frame.empty or trial_fixations.empty: 

4284 return pd.DataFrame() 

4285 ids = tuple( 

4286 (column, str(trial_fixations[column].iloc[0])) 

4287 for column in ("participant_id", "trial_id") 

4288 if column in frame.columns and column in trial_fixations.columns 

4289 ) 

4290 return _c_trial_rows(frame, frame_fingerprint(frame), ids) 

4291 

4292 

4293@st.cache_data(show_spinner=False, max_entries=8) 

4294def _c_trial_rows(_frame: pd.DataFrame, fingerprint, ids: tuple) -> pd.DataFrame: 

4295 mask = pd.Series(True, index=_frame.index) 

4296 for column, value in ids: 

4297 mask &= _frame[column].astype(str) == value 

4298 return _frame[mask] 

4299 

4300 

4301def _selected_metadata_rows(trial_fixations: pd.DataFrame | None) -> dict: 

4302 """The attached metadata tables' rows for the trial ``trial_fixations`` is 

4303 (EXP-22) — enough to name every field, whichever trial it is.""" 

4304 from . import metadata as md 

4305 

4306 ids = {} 

4307 if trial_fixations is not None and not trial_fixations.empty: 

4308 for column in ("participant_id", "trial_id", "text_id"): 

4309 if column in trial_fixations.columns: 

4310 ids[column] = str(trial_fixations[column].iloc[0]) 

4311 return md.pattern_rows( 

4312 ids.get("participant_id"), ids.get("trial_id"), ids.get("text_id") 

4313 ) 

4314 

4315 

4316def current_dataset_name() -> str: 

4317 """The name the dataset picker shows for the source now loaded (VIZ-36). 

4318 

4319 ``data_source_choice`` is canonical: since DATA-9's flat picker the stored 

4320 value *is* the entry's label — the user's own name for an upload, the 

4321 registry label for a public corpus — and DATA-23's rename re-keys it, so a 

4322 renamed dataset carries the new name without anything else to update. 

4323 

4324 Returns ``""`` rather than a placeholder when there is no session (headless 

4325 render, import time): ``{dataset_name}`` then renders empty, which is the 

4326 same thing an unnamed CLI render produces, instead of inventing a label 

4327 that would disagree with the app. 

4328 """ 

4329 try: 

4330 return str(st.session_state.get("data_source_choice") or "") 

4331 except Exception: # no ScriptRunContext — headless import, tests, docs 

4332 return "" 

4333 

4334 

4335def render_pattern_input( 

4336 host, 

4337 label: str, 

4338 key: str, 

4339 fields: dict, 

4340 *, 

4341 help: str | None = None, 

4342 placeholder: str | None = None, 

4343 label_left: bool = False, 

4344 disabled: bool = False, 

4345 label_visibility: str = "visible", 

4346 preview: bool = True, 

4347) -> str: 

4348 """A pattern text box with live validation and a rendered preview. 

4349 

4350 Returns the pattern, or ``""`` when it names a field that does not exist — 

4351 an invalid pattern must not reach a figure or a filename, and the error says 

4352 which placeholder is wrong (see ``export.pattern_error``). 

4353 

4354 ``placeholder`` is what an *empty* box falls back to, printed greyed inside 

4355 the box itself (UX-31). These boxes default to empty — the fallback used to 

4356 be stated only in the `help` tooltip, so the one thing you need to know 

4357 before typing (what you get if you don't) needed a hover to find. 

4358 

4359 Pass it **only** where an empty box really does produce that string. The 

4360 Compare A/B labels qualify (empty → the auto ``participant · trial``); the 

4361 figure title/caption do not (empty → no title at all), and a placeholder 

4362 there would promise the opposite of what happens. 

4363 

4364 ``label_left`` opts into the UX-51 ``label | field`` row (the rail's 

4365 Title/Caption boxes). The error and the preview still span the full width 

4366 below the row — they are the box's *output*, not a second field. 

4367 

4368 ``disabled`` greys the box without touching its key (UX-68, the Compare 

4369 settings menu while Compare is off) — the same "your value is kept" contract 

4370 as :func:`_numeric_slider`. The error and preview still render, because they 

4371 describe the pattern that *is* stored, not one being typed. 

4372 """ 

4373 if label_left: 

4374 _labeled( 

4375 host, 

4376 "text_input", 

4377 label, 

4378 key=key, 

4379 persist_state="session", 

4380 help=help, 

4381 placeholder=placeholder, 

4382 disabled=disabled, 

4383 ) 

4384 else: 

4385 host.text_input( 

4386 label, 

4387 key=key, 

4388 persist_state="session", 

4389 help=help, 

4390 placeholder=placeholder, 

4391 disabled=disabled, 

4392 label_visibility=label_visibility, 

4393 ) 

4394 value = st.session_state.get(key, "") 

4395 if not value or not preview: 

4396 # `preview=False` (a switched-off title) asks nothing of ``fields``, 

4397 # whose values are computed on demand. 

4398 return "" 

4399 error = pattern_error(value, fields) 

4400 if error: 

4401 host.error(error) 

4402 return "" 

4403 host.caption(f"{label} preview — **{render_pattern(value, fields)}**") 

4404 return value 

4405 

4406 

4407def _pin(key: str, default) -> None: 

4408 """Seed ``key``'s default if it has none yet. Never overwrites. 

4409 

4410 This is the *first value* half of the widget-state contract. The other half 

4411 — keeping that value alive through runs where the widget doesn't render, and 

4412 pushing it to the browser when the widget finally mounts — used to be a 

4413 matching ``rewrite=True`` re-assertion here (BUG-15: Streamlit only sent a 

4414 value down on the run it was written programmatically, so a control whose 

4415 popover first opened on a *later* run mounted at its proto default — nothing 

4416 pressed on a segmented control, black on a colour picker). Streamlit 1.61 

4417 owns that natively: every widget on these keys passes 

4418 ``persist_state="session"``, which preserves the value while unmounted and 

4419 marks it as changed on remount so the frontend adopts it (ENG-36). Keep the 

4420 kwarg when adding a widget on a ``global_*`` / ``single_*`` / ``cmp{idx}_*`` 

4421 key — it is what makes this a plain ``setdefault`` again. 

4422 

4423 Safe only because no viz widget passes ``value=``/``index=`` (see 

4424 ``_seed_viz_state``); adding one would fight the stored value *and* log 

4425 Streamlit's "default value but also set via Session State API" warning. 

4426 

4427 A **list** default is copied on the way in: ``_VIZ_WIDGET_DEFAULTS`` is a 

4428 module-level table, so seeding a multiselect (``global_saccade_classes``) 

4429 with the list object itself would hand session state a live alias of the 

4430 default, and one in-place edit anywhere would change it for the rest of the 

4431 process. 

4432 """ 

4433 if key in st.session_state: 

4434 return 

4435 try: 

4436 st.session_state[key] = list(default) if isinstance(default, list) else default 

4437 except StreamlitAPIException: 

4438 # The key belongs to a widget already created this run (⚙ Compare 

4439 # options / ⚙ Playback render in tabs.py before the rail). It is already 

4440 # carrying its value, so there is nothing to seed. 

4441 pass 

4442 

4443 

4444def compare_style_defaults() -> dict: 

4445 """Every per-scanpath comparison styling key → the value a session seeds. 

4446 

4447 One table for the seeding below and for EXP-19's share link, which leaves a 

4448 style off the link while it still equals this (`url_state._link_defaults`). 

4449 ``cmp{idx}_label_pattern`` is not seeded — its absence *is* the auto label — 

4450 so it is listed here as the empty string it reads as; ``cmp{idx}_box_color`` 

4451 likewise, its absence being "the scanpath's own colour", and 

4452 ``cmp{idx}_box_fill_color``, its absence being "the figure's fill", and 

4453 ``cmp{idx}_raw_gaze_color``, its absence being "the scanpath's own colour", 

4454 and ``cmp{idx}_heatmap_colorscale``, "the figure's colour scale". 

4455 """ 

4456 defaults: dict = {} 

4457 for idx, _ in _COMPARE_SCANPATHS: 

4458 defaults.update( 

4459 { 

4460 f"cmp{idx}_fix_color": compare_palette_color(idx), 

4461 f"cmp{idx}_saccade_color": compare_palette_color(idx), 

4462 f"cmp{idx}_saccade_style": "Solid", 

4463 f"cmp{idx}_saccade_width": DEFAULT_SACCADE_WIDTH, 

4464 f"cmp{idx}_marker_size_range": DEFAULT_MARKER_SIZE_RANGE, 

4465 # VIZ-6: per-scanpath marker alpha (replaces the per-scanpath 

4466 # hollow checkbox). Default 0.7 matches the single-trial default 

4467 # so overlapping fixations show through. `cmp{idx}_hollow` kept 

4468 # seeded for saved-config / deep-link backward compatibility (no 

4469 # widget renders it anymore). 

4470 f"cmp{idx}_opacity": COMPARE_FIXATION_OPACITY, 

4471 f"cmp{idx}_hollow": False, 

4472 f"cmp{idx}_label_pattern": "", 

4473 # The word-box outline; empty follows `cmp{idx}_fix_color`. 

4474 f"cmp{idx}_box_color": "", 

4475 # The word-box fill; empty follows `global_word_box_fill_color`. 

4476 f"cmp{idx}_box_fill_color": "", 

4477 # The raw-gaze samples; empty follows `cmp{idx}_fix_color`. 

4478 f"cmp{idx}_raw_gaze_color": "", 

4479 # The heatmap's colour scale; empty follows the figure's. 

4480 f"cmp{idx}_heatmap_colorscale": "", 

4481 } 

4482 ) 

4483 # CMP-24 — scanpath B's own filters (A's are the rail's ordinary ones). 

4484 defaults.update( 

4485 { 

4486 "cmp1_saccade_classes": list(SACCADE_CLASS_ORDER), 

4487 **{f"cmp1_fixclass_{cat}_mode": "Off" for cat, *_ in _FIXCLASS_CATEGORIES}, 

4488 "cmp1_fixclass_short_threshold_ms": 80, 

4489 "cmp1_fixclass_long_threshold_ms": 800, 

4490 } 

4491 ) 

4492 return defaults 

4493 

4494 

4495def _seed_compare_styles() -> None: 

4496 """Seed the per-scanpath comparison styling keys (so the collected dicts have 

4497 values even when the relevant layer popover isn't open this run). 

4498 

4499 Seeding is all that is needed: the widgets themselves carry 

4500 ``persist_state="session"``, which keeps the value alive through the runs 

4501 where the popover isn't open (ENG-36).""" 

4502 for key, default in compare_style_defaults().items(): 

4503 if not key.endswith( 

4504 ( 

4505 "_label_pattern", 

4506 "_box_color", 

4507 "_box_fill_color", 

4508 "_raw_gaze_color", 

4509 "_heatmap_colorscale", 

4510 ) 

4511 ): 

4512 _pin(key, default) 

4513 

4514 

4515def _render_compare_fix_styles(*, uniform: bool = True) -> None: 

4516 """Scanpath B's fixation styling for the two-trial comparison, rendered in 

4517 the Fixations popover straight under the *Marker* group — which, in 

4518 Compare, is scanpath A's (its colour, size and opacity rows write A's 

4519 ``cmp0_*`` keys). 

4520 

4521 UX-159: the scanpath's name as the group title and a caption per row 

4522 (`_sub_row`). The widgets keep the prefixed label as their accessible name; 

4523 only the visible text is shortened. With a colour-by column the markers are 

4524 filled by it, so the scanpath's colour is captioned as their *Outline*.""" 

4525 idx, name = _COMPARE_SCANPATHS[1] 

4526 caption = "Color" if uniform else "Outline" 

4527 _compare_fix_color_picker( 

4528 _sub_row( 

4529 caption, 

4530 section=name, 

4531 section_help=_COMPARE_SCANPATH_HELP[idx], 

4532 caption_help=None if uniform else _COMPARE_OUTLINE_HELP, 

4533 section_share=_COMPARE_SECTION_SHARE, 

4534 ), 

4535 idx, 

4536 ) 

4537 _compare_size_slider( 

4538 idx, 

4539 "This scanpath's smallest and largest marker diameter, in px.", 

4540 ) 

4541 _compare_opacity_slider(idx) 

4542 

4543 

4544_COMPARE_OUTLINE_HELP = ( 

4545 "The color-by column fills the markers, so this color outlines them." 

4546) 

4547 

4548 

4549def _compare_fix_color_picker(host, idx: int) -> None: 

4550 """One scanpath's flat fixation colour, ``cmp{idx}_fix_color``.""" 

4551 disabled, _ = _layer_gate(False, None) 

4552 host.color_picker( 

4553 f"{_COMPARE_SCANPATHS[idx][1]} — fixation color", 

4554 key=f"cmp{idx}_fix_color", 

4555 persist_state="session", 

4556 disabled=disabled, 

4557 label_visibility="collapsed", 

4558 ) 

4559 

4560 

4561def _compare_size_slider(idx: int, help: str) -> None: 

4562 """One scanpath's marker size range, ``cmp{idx}_marker_size_range``.""" 

4563 _, size_help = _layer_gate(False, help) 

4564 _range_slider( 

4565 st, 

4566 f"{_COMPARE_SCANPATHS[idx][1]} — marker size range", 

4567 key=f"cmp{idx}_marker_size_range", 

4568 persist_state="session", 

4569 min_value=4, 

4570 max_value=40, 

4571 help=help, 

4572 field_host=_sub_row("Size", caption_help=size_help), 

4573 ) 

4574 

4575 

4576def _compare_opacity_slider(idx: int) -> None: 

4577 """One scanpath's marker opacity, ``cmp{idx}_opacity``.""" 

4578 _numeric_slider( 

4579 st, 

4580 f"{_COMPARE_SCANPATHS[idx][1]} — opacity", 

4581 key=f"cmp{idx}_opacity", 

4582 persist_state="session", 

4583 min_value=0.1, 

4584 max_value=1.0, 

4585 step=0.05, 

4586 slider_format="%.2f", 

4587 help="Marker opacity for this scanpath (1.0 = fully opaque).", 

4588 field_host=_sub_row("Opacity"), 

4589 ) 

4590 

4591 

4592#: UX-159: the per-scanpath groups' titles ("Scanpath A") are longer than a 

4593#: popover group title usually is, so their title row takes more of the label 

4594#: column. Only that row: the group's other captions keep the usual share, so 

4595#: "Opacity" is not cut to "Opa…". 

4596_COMPARE_SECTION_SHARE = 0.62 

4597 

4598#: The tooltip already leads with the group's title ("Scanpath A — …"), so 

4599#: these start at what follows it. 

4600_COMPARE_SCANPATH_HELP = { 

4601 0: "The selected trial.", 

4602 1: "The trial it is compared with.", 

4603} 

4604 

4605 

4606def _compare_saccade_color_picker(host, idx: int) -> None: 

4607 """One scanpath's saccade colour, ``cmp{idx}_saccade_color``.""" 

4608 disabled, _ = _layer_gate(False, None) 

4609 host.color_picker( 

4610 f"{_COMPARE_SCANPATHS[idx][1]} — saccade color", 

4611 key=f"cmp{idx}_saccade_color", 

4612 persist_state="session", 

4613 disabled=disabled, 

4614 label_visibility="collapsed", 

4615 ) 

4616 

4617 

4618def _compare_saccade_line_rows(idx: int) -> None: 

4619 """One scanpath's saccade *Style* and *Width* rows (``cmp{idx}_saccade_*``).""" 

4620 name = _COMPARE_SCANPATHS[idx][1] 

4621 disabled, _ = _layer_gate(False, None) 

4622 _sub_row("Style").selectbox( 

4623 f"{name} — line style", 

4624 options=list(SACCADE_DASH_OPTIONS.keys()), 

4625 key=f"cmp{idx}_saccade_style", 

4626 persist_state="session", 

4627 disabled=disabled, 

4628 label_visibility="collapsed", 

4629 ) 

4630 _numeric_slider( 

4631 st, 

4632 f"{name} — line width", 

4633 key=f"cmp{idx}_saccade_width", 

4634 persist_state="session", 

4635 min_value=SACCADE_WIDTH_BOUNDS[0], 

4636 max_value=SACCADE_WIDTH_BOUNDS[1], 

4637 step=0.5, 

4638 slider_format="%.1f px", 

4639 number_format="%.1f", 

4640 field_host=_sub_row("Width"), 

4641 ) 

4642 

4643 

4644def _render_compare_saccade_styles() -> None: 

4645 """Scanpath B's saccade styling for the two-trial comparison, rendered in 

4646 the Saccades popover under scanpath A's group — the popover's *Line* group, 

4647 retitled in Compare, whose colour, style and width rows write A's 

4648 ``cmp0_*`` keys. Laid out like :func:`_render_compare_fix_styles`.""" 

4649 idx, name = _COMPARE_SCANPATHS[1] 

4650 _compare_saccade_color_picker( 

4651 _sub_row( 

4652 "Color", 

4653 section=name, 

4654 section_help=_COMPARE_SCANPATH_HELP[idx], 

4655 section_share=_COMPARE_SECTION_SHARE, 

4656 ), 

4657 idx, 

4658 ) 

4659 _compare_saccade_line_rows(idx) 

4660 

4661 

4662def _render_heatmap_blur_row( 

4663 fixations: pd.DataFrame | None, 

4664 words: pd.DataFrame | None, 

4665 *, 

4666 disabled: bool, 

4667 reason: str | None, 

4668) -> None: 

4669 """``Blur | ☑ Auto | σ px``: the Interpolated heatmap's Gaussian σ. 

4670 

4671 The box always shows the σ in use: on Auto, the one the figure computes 

4672 from this trial (`plots.interpolated_sigma_px`, greyed); off, the fixed 

4673 one, ``global_heatmap_sigma_px``. It is a shadow of that key, so Auto's 

4674 value is never written over the user's own.""" 

4675 from scanpath_studio.plots import _compute_axis_ranges, interpolated_sigma_px 

4676 

4677 help_text = _gated_help( 

4678 "Interpolated only: the Gaussian blur's σ, in px. Auto: 2% of the " 

4679 "larger span of fixations and word boxes, ≥ 8 px.", 

4680 reason, 

4681 ) 

4682 auto, rest = _check_row( 

4683 "Blur", 

4684 key="global_heatmap_sigma_auto", 

4685 persist_state="session", 

4686 check_label="Auto", 

4687 disabled=disabled, 

4688 help=help_text, 

4689 ) 

4690 view_key = "_heatmap_sigma_view" 

4691 if auto: 

4692 trial_words = ( 

4693 _trial_rows(words, fixations) 

4694 if words is not None and fixations is not None 

4695 else None 

4696 ) 

4697 *_, x_min, x_max, y_min, y_max = _compute_axis_ranges( 

4698 1, 

4699 1, 

4700 (fixations, "x", "y"), 

4701 word_frames=[] if trial_words is None else [trial_words], 

4702 ) 

4703 shown = ( 

4704 interpolated_sigma_px(x_max - x_min, y_max - y_min) 

4705 if x_min is not None 

4706 else DEFAULT_HEATMAP_SIGMA_PX 

4707 ) 

4708 else: 

4709 shown = st.session_state.get( 

4710 "global_heatmap_sigma_px", DEFAULT_HEATMAP_SIGMA_PX 

4711 ) 

4712 st.session_state[view_key] = round(float(shown), 1) 

4713 

4714 def _apply() -> None: 

4715 if _shadow_key_missing(view_key): # BUG-18 

4716 return 

4717 st.session_state["global_heatmap_sigma_px"] = float(st.session_state[view_key]) 

4718 

4719 box_col, unit_col = rest.columns( 

4720 [0.75, 0.25], gap=_LABEL_GAP, vertical_alignment="center" 

4721 ) 

4722 box_col.number_input( 

4723 "Blur σ (px)", 

4724 min_value=HEATMAP_SIGMA_BOUNDS[0], 

4725 max_value=HEATMAP_SIGMA_BOUNDS[1], 

4726 step=1.0, 

4727 format="%.1f", 

4728 key=view_key, 

4729 on_change=_apply, 

4730 disabled=_layer_gate(disabled or auto, None)[0], 

4731 label_visibility="collapsed", 

4732 ) 

4733 _sub_caption(unit_col, "px") 

4734 

4735 

4736def _render_colorbar_rows(bar: str, *, disabled: bool, reason: str | None) -> None: 

4737 """One colour scale's bar: ``Color bar | ☑ Show | orientation``, then the 

4738 tick labels' angle and size — for ``bar`` ``"fixation"`` or ``"heatmap"``. 

4739 

4740 Each bar has its own keys, so the fixations' and the heatmap's can be styled 

4741 apart. ``disabled`` greys all four (the scale itself is idle) without 

4742 touching a stored value.""" 

4743 shown, rest = _check_row( 

4744 "Color bar", 

4745 key=f"global_show_{bar}_colorbar", 

4746 persist_state="session", 

4747 disabled=disabled, 

4748 help=_gated_help( 

4749 "The scale's legend: right of the plot (Vertical) or below it " 

4750 "(Horizontal).", 

4751 reason, 

4752 ), 

4753 ) 

4754 idle = disabled or not shown 

4755 rest.radio( 

4756 "Color bar orientation", 

4757 options=["Vertical", "Horizontal"], 

4758 horizontal=True, 

4759 key=f"global_{bar}_colorbar_orientation", 

4760 persist_state="session", 

4761 disabled=_layer_gate(idle, None)[0], 

4762 label_visibility="collapsed", 

4763 ) 

4764 angle_help = _gated_help("Tick-label angle, in degrees.", reason) 

4765 _numeric_slider( 

4766 st, 

4767 "Tick label angle", 

4768 key=f"global_{bar}_colorbar_tickangle", 

4769 persist_state="session", 

4770 min_value=-90, 

4771 max_value=90, 

4772 step=15, 

4773 disabled=idle, 

4774 help=angle_help, 

4775 field_host=_sub_row("Angle", caption_help=_layer_gate(False, angle_help)[1]), 

4776 ) 

4777 size_help = _gated_help("Tick-label size, in px.", reason) 

4778 _numeric_slider( 

4779 st, 

4780 "Tick label size", 

4781 key=f"global_{bar}_colorbar_tickfont_size", 

4782 persist_state="session", 

4783 min_value=6, 

4784 max_value=20, 

4785 disabled=idle, 

4786 help=size_help, 

4787 field_host=_sub_row("Size", caption_help=_layer_gate(False, size_help)[1]), 

4788 ) 

4789 

4790 

4791#: ``colour | opacity slider + box`` inside one ⬚ Word boxes row: the swatch 

4792#: takes only what it needs and the opacity fills the rest of the line. 

4793_COLOR_OPACITY_W = (1.0, 4.5) 

4794 

4795 

4796def _box_opacity( 

4797 host, *, key: str, label: str, help: str, persist_state: str | None = None 

4798) -> None: 

4799 """One ⬚ Word boxes opacity (outline or fill), drawn beside its colour.""" 

4800 _numeric_slider( 

4801 st, 

4802 label, 

4803 key=key, 

4804 persist_state=persist_state, 

4805 min_value=0.0, 

4806 max_value=1.0, 

4807 step=0.01, 

4808 number_format="%.2f", 

4809 help=help, 

4810 field_host=host, 

4811 ) 

4812 

4813 

4814_LINE_OPACITY_HELP = "Outline opacity; 0 hides it." 

4815_FILL_OPACITY_HELP = "How strongly the fill shows; 0 draws outlines only." 

4816 

4817 

4818def _compare_follow_color_picker( 

4819 host, 

4820 idx: int, 

4821 part: str, 

4822 *, 

4823 follow: str, 

4824 help: str, 

4825 what: str, 

4826 disabled: bool = False, 

4827) -> None: 

4828 """One scanpath's ``part`` colour, ``cmp{idx}_{part}_color`` — the word-box 

4829 outline (``"box"``), its fill (``"box_fill"``) or the raw-gaze samples 

4830 (``"raw_gaze"``); ``what`` names it in the widget's label. 

4831 

4832 The picker is a shadow of that key: it shows the colour actually drawn — 

4833 ``follow`` until one is picked — and only a pick writes the override, so an 

4834 untouched colour keeps following ``follow`` when that changes. Picking 

4835 ``follow`` itself again goes back to following it.""" 

4836 key = f"cmp{idx}_{part}_color" 

4837 pick_key = f"{key}__pick" 

4838 st.session_state[pick_key] = st.session_state.get(key) or follow 

4839 

4840 def _apply() -> None: 

4841 if _shadow_key_missing(pick_key): # BUG-18 

4842 return 

4843 picked = st.session_state[pick_key] 

4844 st.session_state[key] = "" if picked.lower() == follow.lower() else picked 

4845 

4846 disabled, tip = _layer_gate(disabled, help) 

4847 host.color_picker( 

4848 f"{_COMPARE_SCANPATHS[idx][1]} — {what} color", 

4849 key=pick_key, 

4850 on_change=_apply, 

4851 disabled=disabled, 

4852 help=tip, 

4853 label_visibility="collapsed", 

4854 ) 

4855 

4856 

4857def _compare_fix_color(idx: int) -> str: 

4858 """The fixation colour one scanpath wears in Compare.""" 

4859 return st.session_state.get(f"cmp{idx}_fix_color") or compare_palette_color(idx) 

4860 

4861 

4862def _render_compare_box_groups(fill_help: str) -> None: 

4863 """The word boxes per scanpath, for the static comparison — laid out like 

4864 the Fixations popover's Compare groups: scanpath A's group (its *Line* and 

4865 *Fill* rows) where the *Box* group is, then scanpath B's. 

4866 

4867 Each reading's boxes are outlined in its own colour — its fixation colour 

4868 until one is picked here, so A and B stay apart by default — and filled in 

4869 its own, the figure's fill until one is picked. The two opacities are 

4870 shared by both readings and sit on A's rows.""" 

4871 figure_fill = ( 

4872 st.session_state.get("global_word_box_fill_color") or WORD_BOX_FILL_COLOR 

4873 ) 

4874 for idx, name in _COMPARE_SCANPATHS: 

4875 line_help = f"{name}'s word-box outline color. Defaults to its fixation color." 

4876 line_col, line_opacity_col = _sub_row( 

4877 "Line", 

4878 section=name, 

4879 section_help=_COMPARE_SCANPATH_HELP[idx] 

4880 + ( 

4881 " Line and fill colors are its own; the opacities are shared." 

4882 if idx == 0 

4883 else "" 

4884 ), 

4885 caption_help=_layer_gate(False, line_help)[1], 

4886 section_share=_COMPARE_SECTION_SHARE, 

4887 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center") 

4888 _compare_follow_color_picker( 

4889 line_col, 

4890 idx, 

4891 "box", 

4892 follow=_compare_fix_color(idx), 

4893 help=line_help, 

4894 what="word box line", 

4895 ) 

4896 this_fill_help = f"{name}'s word-box fill color. {fill_help}" 

4897 fill_col, fill_opacity_col = _sub_row( 

4898 "Fill", caption_help=_layer_gate(False, this_fill_help)[1] 

4899 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center") 

4900 _compare_follow_color_picker( 

4901 fill_col, 

4902 idx, 

4903 "box_fill", 

4904 follow=figure_fill, 

4905 help=this_fill_help, 

4906 what="word box fill", 

4907 ) 

4908 # One outline and one fill opacity for both readings, on A's rows. 

4909 if idx == 0: 

4910 _box_opacity( 

4911 line_opacity_col, 

4912 key="global_word_box_line_opacity", 

4913 persist_state="session", 

4914 label="Line opacity", 

4915 help=f"{_LINE_OPACITY_HELP} Applies to both scanpaths' outlines.", 

4916 ) 

4917 _box_opacity( 

4918 fill_opacity_col, 

4919 key="global_word_box_fill_opacity", 

4920 persist_state="session", 

4921 label="Fill opacity", 

4922 help=f"{_FILL_OPACITY_HELP} Applies to both scanpaths' fills.", 

4923 ) 

4924 

4925 

4926def _compare_heatmap_colorscale_row( 

4927 idx: int, *, disabled: bool, reason: str | None 

4928) -> None: 

4929 """One scanpath's group title and heatmap *Colors* row, for the comparison: 

4930 ``cmp{idx}_heatmap_colorscale``, the figure's colour scale until one is 

4931 picked. The metric, scaling and range above are shared, so A and B stay on 

4932 one scale; two different colour scales get a colour bar each. 

4933 

4934 Keyless, like the figure's own colour-scale picker (`_popover_selectbox`), 

4935 and a shadow like `_compare_follow_color_picker`: it shows the scale drawn, 

4936 and only a pick writes the override.""" 

4937 name = _COMPARE_SCANPATHS[idx][1] 

4938 key = f"cmp{idx}_heatmap_colorscale" 

4939 follow = ( 

4940 st.session_state.get("global_heatmap_colorscale") or DEFAULT_HEATMAP_COLORSCALE 

4941 ) 

4942 shown = st.session_state.get(key) or follow 

4943 disabled, tip = _layer_gate( 

4944 disabled, 

4945 _gated_help( 

4946 f"{name}'s heatmap color scale, on the range both share. Defaults to " 

4947 "the figure's.", 

4948 reason, 

4949 ), 

4950 ) 

4951 picked = _sub_row( 

4952 "Colors", 

4953 section=name, 

4954 section_help=_COMPARE_SCANPATH_HELP[idx] 

4955 + (" Its color scale is its own; the rest is shared." if idx == 0 else ""), 

4956 caption_help=tip, 

4957 section_share=_COMPARE_SECTION_SHARE, 

4958 ).selectbox( 

4959 f"{name} — heatmap colors", 

4960 COLORSCALES, 

4961 index=COLORSCALES.index(shown) if shown in COLORSCALES else 0, 

4962 disabled=disabled, 

4963 help=tip, 

4964 label_visibility="collapsed", 

4965 ) 

4966 # A scale the picker cannot show (an API-only name) is left alone. 

4967 if not disabled and shown in COLORSCALES and picked != shown: 

4968 st.session_state[key] = "" if picked == follow else picked 

4969 

4970 

4971def _compare_raw_gaze_color_row(idx: int, *, disabled: bool) -> None: 

4972 """One scanpath's group title and raw-gaze *Color* row, for the comparison: 

4973 its samples' colour, ``cmp{idx}_raw_gaze_color`` — its fixation colour 

4974 until one is picked. Scanpath A's group goes on with the shared *Size* and 

4975 *Opacity* rows.""" 

4976 name = _COMPARE_SCANPATHS[idx][1] 

4977 help_text = f"{name}'s raw-gaze sample color. Defaults to its fixation color." 

4978 _compare_follow_color_picker( 

4979 _sub_row( 

4980 "Color", 

4981 section=name, 

4982 section_help=_COMPARE_SCANPATH_HELP[idx] 

4983 + (" Color is its own; size and opacity are shared." if idx == 0 else ""), 

4984 caption_help=_layer_gate(disabled, help_text)[1], 

4985 section_share=_COMPARE_SECTION_SHARE, 

4986 ), 

4987 idx, 

4988 "raw_gaze", 

4989 follow=_compare_fix_color(idx), 

4990 help=help_text, 

4991 what="raw gaze", 

4992 disabled=disabled, 

4993 ) 

4994 

4995 

4996def _collect_compare_styles() -> tuple[dict, dict]: 

4997 """Build the ``(style_a, style_b)`` dicts the comparison figure consumes from 

4998 the ``cmp{idx}_*`` session keys (rendered under each layer's popover).""" 

4999 styles: list[dict] = [] 

5000 for idx, _ in _COMPARE_SCANPATHS: 

5001 default_color = compare_palette_color(idx) 

5002 styles.append( 

5003 dict( 

5004 # `or default_color` so a falsy ("" / None) value can never escape 

5005 # as a colour (defensive against the color-picker black desync). 

5006 fix_color=st.session_state.get(f"cmp{idx}_fix_color") or default_color, 

5007 saccade_color=( 

5008 st.session_state.get(f"cmp{idx}_saccade_color") or default_color 

5009 ), 

5010 saccade_style=SACCADE_DASH_OPTIONS.get( 

5011 st.session_state.get(f"cmp{idx}_saccade_style", "Solid"), "solid" 

5012 ), 

5013 saccade_width=float( 

5014 st.session_state.get( 

5015 f"cmp{idx}_saccade_width", DEFAULT_SACCADE_WIDTH 

5016 ) 

5017 ), 

5018 marker_size_range=tuple( 

5019 st.session_state.get( 

5020 f"cmp{idx}_marker_size_range", DEFAULT_MARKER_SIZE_RANGE 

5021 ) 

5022 ), 

5023 hollow=bool(st.session_state.get(f"cmp{idx}_hollow", False)), 

5024 opacity=float(st.session_state.get(f"cmp{idx}_opacity", 1.0)), 

5025 # None (no override) is dropped by the builder, which then 

5026 # outlines the boxes in `fix_color`. 

5027 box_color=st.session_state.get(f"cmp{idx}_box_color") or None, 

5028 # Likewise: None fills with the figure's `word_box_fill_color`. 

5029 box_fill_color=( 

5030 st.session_state.get(f"cmp{idx}_box_fill_color") or None 

5031 ), 

5032 # None colours the samples in `fix_color`. 

5033 raw_gaze_color=( 

5034 st.session_state.get(f"cmp{idx}_raw_gaze_color") or None 

5035 ), 

5036 # None draws the heatmap in the figure's colour scale. 

5037 heatmap_colorscale=( 

5038 st.session_state.get(f"cmp{idx}_heatmap_colorscale") or None 

5039 ), 

5040 ) 

5041 ) 

5042 # CMP-24: B draws under its own filters. A's style names none, so the 

5043 # builders give A the figure's — the rail's ordinary filters. 

5044 styles[1].update(compare_b_filters()) 

5045 return styles[0], styles[1] 

5046 

5047 

5048def _ordered_saccade_classes(key: str) -> list[str]: 

5049 """A saccade-class multiselect's value in ``SACCADE_CLASS_ORDER`` (VIZ-31) — 

5050 a cleared one reads as every class, i.e. no filter.""" 

5051 chosen = set(st.session_state.get(key) or SACCADE_CLASS_ORDER) 

5052 return [cls_name for cls_name in SACCADE_CLASS_ORDER if cls_name in chosen] 

5053 

5054 

5055def compare_b_filters() -> dict: 

5056 """Scanpath B's filters, as the comparison builders read them off its style 

5057 (CMP-24) — ``plots.COMPARE_FILTER_STYLE_KEYS``.""" 

5058 return dict( 

5059 fixation_flags=_collect_fixation_flags("cmp1"), 

5060 saccade_classes=_ordered_saccade_classes("cmp1_saccade_classes"), 

5061 ) 

5062 

5063 

5064def render_compare_filters(host, compare_fixations: pd.DataFrame | None) -> None: 

5065 """Scanpath B's half of the Filters & highlights section (CMP-24). 

5066 

5067 Rendered into the slot ``render_plot_controls`` reserved under A's filters — 

5068 after the rail, because B is picked (and its fixations loaded) below it. The 

5069 same three filters A has: the fixation-index window, the short / long / 

5070 out-of-bounds / blink flags, and which saccade classes are drawn. Every value 

5071 rides B's own keys (``session_keys.COMPARE_B_FILTER_STATE_KEYS`` and 

5072 ``single_compare_fix_range``), so the two readings are filtered apart. 

5073 """ 

5074 if host is None: 

5075 return 

5076 animating = bool(st.session_state.get("_resolved_animating")) 

5077 with host: 

5078 with ( 

5079 _rail_subsection( 

5080 st, 

5081 f"{ICONS['fixations']} Fixations · B{_fixation_filter_badge('cmp1')}", 

5082 ), 

5083 _popover_rows("filter_fix_b"), 

5084 ): 

5085 _render_fix_range_slider( 

5086 compare_fixations, 

5087 key="single_compare_fix_range", 

5088 trial_state_key="_compare_fix_range_trial", 

5089 all_trials_key=None, 

5090 ) 

5091 _render_fixation_cleaning(prefix="cmp1") 

5092 _cls_dis, _cls_reason = _mode_gate(animating, True, in_animation=False) 

5093 with ( 

5094 _rail_subsection( 

5095 st, 

5096 f"{ICONS['saccades']} Saccades · B" 

5097 f"{_saccade_filter_badge('cmp1_saccade_classes')}", 

5098 note=_cls_reason, 

5099 ), 

5100 _popover_rows("filter_sac_b"), 

5101 ): 

5102 _labeled( 

5103 st, 

5104 "multiselect", 

5105 "Show saccade types · B", 

5106 display="Types", 

5107 options=SACCADE_CLASS_ORDER, 

5108 format_func=lambda cls: SACCADE_CLASS_LABELS[cls], 

5109 key="cmp1_saccade_classes", 

5110 persist_state="session", 

5111 disabled=_cls_dis, 

5112 help=_gated_help( 

5113 "The saccade classes scanpath B draws. An empty list draws all.", 

5114 _cls_reason, 

5115 ), 

5116 ) 

5117 

5118 

5119def _fix_range_bounds(fixations: pd.DataFrame | None) -> tuple[int, int]: 

5120 """Lowest and highest fixation index in ``fixations`` (``(0, 0)`` when none). 

5121 

5122 Both bounds come from the *displayed* frame, which is what makes the slider 

5123 correct on a multipart trial: ``order_in_trial`` there is the PARENT-GLOBAL 

5124 index, so a later screen runs e.g. 509-578, not 1-70. Assuming a floor of 1 

5125 (BUG-47) both put indices the screen does not contain inside the slider's 

5126 reach and left the untouched default ``(1, max)`` unequal to the frame's own 

5127 range, which stamped every screen after the first with the Illustration 

5128 "fixation subset" disclosure. 

5129 """ 

5130 if ( 

5131 fixations is None 

5132 or fixations.empty 

5133 or "order_in_trial" not in fixations.columns 

5134 ): 

5135 return (0, 0) 

5136 order = pd.to_numeric(fixations["order_in_trial"], errors="coerce").dropna() 

5137 if order.empty: 

5138 return (0, 0) 

5139 return (int(order.min()), int(order.max())) 

5140 

5141 

5142def _fix_range_trial_key(fixations: pd.DataFrame | None) -> tuple | None: 

5143 """Identity of the trial the window slider is sizing, or ``None`` if unclear. 

5144 

5145 Used only to notice a *trial change*; a frame that isn't a single trial (or 

5146 carries no identity columns) returns ``None``, which is treated as "don't 

5147 reset" so an ambiguous frame never silently drops the user's window. 

5148 """ 

5149 if fixations is None or fixations.empty: 

5150 return None 

5151 parts = [] 

5152 # BUG-47: a multipart trial's screens are separate readings sharing one 

5153 # parent-global index axis, so "fixations 5-20" means as little across two 

5154 # screens as it does across two trials -- the screen is part of the identity. 

5155 for col in ("participant_id", "trial_id", "screen_id"): 

5156 if col not in fixations.columns: 

5157 continue 

5158 values = fixations[col].dropna().unique() 

5159 if len(values) != 1: 

5160 return None 

5161 parts.append(str(values[0])) 

5162 return tuple(parts) or None 

5163 

5164 

5165def _render_fix_range_slider( 

5166 fixations: pd.DataFrame | None, 

5167 *, 

5168 key: str = "single_fix_range", 

5169 trial_state_key: str = "_fix_range_trial", 

5170 all_trials_key: str | None = "single_fix_range_all_trials", 

5171) -> None: 

5172 """Render the VIZ-7 fixation-index window slider (``single_fix_range``). 

5173 

5174 The slider value persists across trial changes (which shift the bounds), so 

5175 it is seeded/clamped via session_state *only* (no ``value=`` arg) to stay 

5176 inside ``[min_fix, max_fix]`` — a stored out-of-range value would otherwise 

5177 raise. **Both** bounds come from the displayed frame (BUG-47): on a multipart 

5178 trial ``order_in_trial`` is parent-global, so a later screen runs 509-578 and 

5179 a hard-coded floor of 1 would offer indices that screen does not hold and 

5180 leave the untouched default unequal to the frame's full range — which the 

5181 Illustration policy reads as a deliberate "fixation subset". 

5182 This is the single fixation-index control for the app; the Comparisons subtab 

5183 deliberately has none of its own (ENG-8). A frame with fewer than two 

5184 fixations can't host a range slider (a one-value slider throws in the 

5185 browser), so the window is cleared to ``None`` (the full, unsliced trial). 

5186 

5187 **Scope.** A window is per-trial by default: picking another trial shows all 

5188 of that trial's fixations again, because a range like "fixations 5–20" rarely 

5189 means the same thing on a different reading. A multipart trial's *screens* 

5190 count as different readings here too (BUG-47). The *Apply to all trials* 

5191 checkbox opts into the sticky behaviour (the window is re-applied to every 

5192 trial, clamped to each one's length). 

5193 

5194 The checkbox is deliberately **UI-only** state (cf. ``share_identity_mode``), 

5195 because what it governs — what happens to the window when you select a 

5196 *different* trial — has no referent on the other three surfaces: a share link, 

5197 a ``render`` invocation and an ``api.plot_scanpath`` call each address one 

5198 explicit trial, and ``api``'s ``fix_index_range`` is a per-call argument 

5199 rather than sticky state. If it is ever persisted into a saved config it must 

5200 be added to ``session_keys.PLOT_CONFIG_STATE_KEYS``. 

5201 

5202 ``single_fix_range`` itself now reaches all four surfaces (VIZ-40 closed the 

5203 last gap with the ``?fix_range=lo,hi`` param; ``render --fix-index-range`` 

5204 and ``api``'s ``fix_index_range`` were already there). The link carries it 

5205 only when the ``single_fix_range_user_set`` flag above says the window was 

5206 *chosen* — see the ``FIX_RANGE_PARAM`` block in 

5207 ``url_state._build_share_query`` — because the untouched default is the 

5208 trial's own full range and would otherwise re-window every recipient. 

5209 ``tabs._build_studio_config`` still does not write one: a saved config is 

5210 restored onto whatever trial is open, where a fixation window means even 

5211 less than it does on a link. 

5212 

5213 ``single_fix_range_user_set`` itself is deliberately **not** wire format, 

5214 for the same reason as ``share_identity_mode`` and the checkbox above: it 

5215 records that *this* user moved *this* slider, which has no referent on the 

5216 other three surfaces, and a recipient re-derives it here anyway (the 

5217 ``setdefault`` in the explicit-value branch below). It is nonetheless 

5218 load-bearing for the ``fix_range`` emission, so if it is ever persisted into 

5219 a saved config it must be added to ``session_keys.PLOT_CONFIG_STATE_KEYS``. 

5220 """ 

5221 if fixations is None: 

5222 return 

5223 # CMP-24: the same slider serves scanpath B under its own keys; B's window is 

5224 # always per-trial (no *All trials* box — B's trial moves with its picker). 

5225 user_key = f"{key}_user_set" 

5226 min_fix, max_fix = _fix_range_bounds(fixations) 

5227 if max_fix < 1 or min_fix >= max_fix: 

5228 # Nothing meaningful to window — clear any stale stored range so the 

5229 # plot isn't filtered by a window the slider can no longer show. The 

5230 # `min_fix >= max_fix` half is the single-fixation frame: a one-value 

5231 # range slider throws in the browser, and on a multipart screen that 

5232 # frame's lone index is 509, not 1 (BUG-47). 

5233 if st.session_state.get(key) is not None: 

5234 st.session_state[key] = None 

5235 st.session_state[user_key] = False 

5236 return 

5237 # Notice a trial change *before* resolving the stored window: in per-trial 

5238 # mode, un-freezing the window is what makes the `user_set is False` branch 

5239 # below expand it to the new trial's full range. 

5240 all_trials = bool(all_trials_key and st.session_state.get(all_trials_key, False)) 

5241 trial_key = _fix_range_trial_key(fixations) 

5242 if trial_key is not None: 

5243 previous = st.session_state.get(trial_state_key) 

5244 st.session_state[trial_state_key] = trial_key 

5245 if previous is not None and previous != trial_key and not all_trials: 

5246 st.session_state[user_key] = False 

5247 stored = st.session_state.get(key) 

5248 user_set = st.session_state.get(user_key) 

5249 

5250 def _reset_to_full() -> None: 

5251 st.session_state[key] = (min_fix, max_fix) 

5252 

5253 if stored is None: 

5254 _reset_to_full() 

5255 st.session_state[user_key] = False 

5256 elif user_set is False: 

5257 # BUG-16: an untouched auto-default follows the selected trial and always 

5258 # expands to its full range — which is the frame's OWN range, floor 

5259 # included, so that an untouched window equals the full range on a later 

5260 # multipart screen too and no Illustration disclosure fires (BUG-47). 

5261 _reset_to_full() 

5262 elif isinstance(stored, (tuple, list)) and len(stored) == 2: 

5263 # A value supplied before this widget first renders (test seam, restored 

5264 # session, or future deep link) is explicit and should be preserved. 

5265 st.session_state.setdefault(user_key, True) 

5266 lo = max(min_fix, min(int(stored[0]), max_fix)) 

5267 hi = max(lo, min(int(stored[1]), max_fix)) 

5268 st.session_state[key] = (lo, hi) 

5269 else: 

5270 _reset_to_full() 

5271 st.session_state[user_key] = False 

5272 

5273 # The slider sits in the 🧹 Filter popover. Once that has been open, the 

5274 # browser sends the window it last showed back on every rerun (#374 F9), so 

5275 # a window this run changed by itself — a new trial's full range, a clamp, a 

5276 # reset, a link — was undone on the next rerun, clamped to the new trial and 

5277 # silently hid most of its fixations. So `key` holds the window and the 

5278 # widgets draw it under a key of their own, which moves to a new generation 

5279 # whenever the window changed without them: a fresh widget has nothing old 

5280 # to send back. 

5281 gen_key = f"_{key}_widget_gen" 

5282 generation = int(st.session_state.get(gen_key) or 0) 

5283 widget_key = f"_{key}__w{generation}" 

5284 window = tuple(st.session_state[key]) 

5285 shown = st.session_state.get(widget_key) 

5286 if shown is None or tuple(shown) != window: 

5287 for stale in (widget_key, f"{widget_key}__num_lo", f"{widget_key}__num_hi"): 

5288 st.session_state.pop(stale, None) 

5289 generation += 1 

5290 st.session_state[gen_key] = generation 

5291 widget_key = f"_{key}__w{generation}" 

5292 st.session_state[widget_key] = window 

5293 

5294 def _mark_fix_range_user_set() -> None: 

5295 st.session_state[key] = tuple(st.session_state[widget_key]) 

5296 st.session_state[user_key] = True 

5297 

5298 all_trials_disabled, _ = _layer_gate(False, None) 

5299 

5300 # UX-162: *All trials* sits on the range's own line, ahead of its slider, 

5301 # the way a colour range's *Auto* does (UX-157); its explanation joins the 

5302 # row title's tooltip. Seeded via `_VIZ_WIDGET_DEFAULTS`, so no `value=`. 

5303 def _all_trials(col) -> None: 

5304 col.checkbox( 

5305 "All trials", 

5306 key=all_trials_key, 

5307 persist_state="session", 

5308 disabled=all_trials_disabled, 

5309 ) 

5310 

5311 _range_slider( 

5312 st, 

5313 "Fixation index range" 

5314 if key == "single_fix_range" 

5315 else "B fixation index range", 

5316 display="Index range", 

5317 label_left=True, 

5318 key=widget_key, 

5319 persist_state="session", 

5320 min_value=min_fix, 

5321 max_value=max_fix, 

5322 on_change=_mark_fix_range_user_set, 

5323 help="Draw only the fixations whose index is in this range. All trials: " 

5324 "keep the window when you move to another trial. In Compare, this is " 

5325 "scanpath A's window.", 

5326 lead=_all_trials if all_trials_key else None, 

5327 ) 

5328 

5329 

5330def _seed_viz_state( 

5331 trial_fixations: pd.DataFrame, 

5332 base_font_size: int, 

5333 words: pd.DataFrame | None, 

5334) -> tuple[list[str], list[str], list[str]]: 

5335 """Seed every viz widget's session_state default (pure — renders nothing). 

5336 

5337 Both ``render_plot_controls`` (which renders the widgets) and 

5338 ``viz_settings_from_state`` (the non-rendering reader used by the Corpus view 

5339 and the Save & restore panel) call this first, so the controls and their 

5340 consumers can't drift. The widgets render WITHOUT a ``value=``/``index=`` 

5341 argument and rely on these defaults, which keeps their keys programmatically 

5342 settable (deep links / plot-config restore) without Streamlit's "default 

5343 value but also set via Session State API" warning. 

5344 

5345 Seeding only — keeping a stored value alive through a run where its widget 

5346 doesn't render is the widgets' own ``persist_state="session"`` (ENG-36), so 

5347 this is safe to call both before rendering (``render_plot_controls``) and after 

5348 (``app.main`` re-reads the settings once the rail has rendered, where writing 

5349 a widget key would raise). Returns ``(color_fields, numeric_fields, 

5350 highlight_options)`` for the caller to reuse. 

5351 """ 

5352 for _key, _default in _VIZ_WIDGET_DEFAULTS.items(): 

5353 _pin(_key, _default) 

5354 _pin("global_marker_size_range", (8, 24)) 

5355 _seed_compare_styles() 

5356 

5357 color_fields = color_field_options(trial_fixations) 

5358 _drop_stale("global_color_by", color_fields) 

5359 # VIZ-17: one flat colour by default — see `color_field_options`. 

5360 _pin("global_color_by", UNIFORM_COLOR_FIELD) 

5361 

5362 numeric_fields = numeric_field_options(trial_fixations) 

5363 if numeric_fields: 

5364 x_default = "x" if "x" in numeric_fields else numeric_fields[0] 

5365 y_default = ( 

5366 "y" 

5367 if "y" in numeric_fields 

5368 else numeric_fields[min(1, len(numeric_fields) - 1)] 

5369 ) 

5370 _drop_stale("global_x_field", numeric_fields) 

5371 _pin("global_x_field", x_default) 

5372 _drop_stale("global_y_field", numeric_fields) 

5373 _pin("global_y_field", y_default) 

5374 

5375 # Highlight-column default + stale-clear run every time (even when the Text 

5376 # styling popover isn't rendered this run) so a restored config on data with 

5377 # no boolean columns can't carry a dangling pick. 

5378 highlight_options = highlight_column_options(words) 

5379 _drop_stale("global_highlight_column", highlight_options) 

5380 # A column the app seeded is re-derived for each dataset; only the user's own 

5381 # pick survives a switch. #374 F6: only the bundled demo is seeded (its 

5382 # answer span); any other dataset opens with nothing highlighted, rather 

5383 # than with whichever yes/no column came first (IA_SKIP on EyeLink data). 

5384 ss = st.session_state 

5385 seeded = ( 

5386 "is_in_aspan" 

5387 if "is_in_aspan" in highlight_options and current_dataset_name() == DEMO_CHOICE 

5388 else None 

5389 ) 

5390 current = ss.get("global_highlight_column") 

5391 if current not in (None, seeded) and current == ss.get(_HIGHLIGHT_SEEDED_KEY): 

5392 ss.pop("global_highlight_column", None) 

5393 if seeded is not None: 

5394 if "global_highlight_column" not in ss: 

5395 ss[_HIGHLIGHT_SEEDED_KEY] = seeded 

5396 _pin("global_highlight_column", seeded) 

5397 

5398 # VIZ-26: arbitrary multi-field word/fixation hover. The legacy one-measure 

5399 # key remains as a fallback for old links/configs, but new surfaces write the 

5400 # explicit lists. 

5401 word_hover_options = hover_field_options(words, words=True) 

5402 fix_hover_options = hover_field_options(trial_fixations) 

5403 _drop_stale_multi("global_word_hover_fields", word_hover_options) 

5404 _drop_stale_multi("global_fixation_hover_fields", fix_hover_options) 

5405 if "global_word_hover_fields" not in st.session_state: 

5406 legacy = st.session_state.get( 

5407 "global_word_hover_measure", "total_fixation_duration_ms" 

5408 ) 

5409 default_word_hover = ["text", "word_id", "line_idx"] 

5410 if legacy: 

5411 default_word_hover.append(legacy) 

5412 _pin( 

5413 "global_word_hover_fields", 

5414 [field for field in default_word_hover if field in word_hover_options], 

5415 ) 

5416 if "global_fixation_hover_fields" not in st.session_state: 

5417 _pin( 

5418 "global_fixation_hover_fields", 

5419 [ 

5420 field 

5421 for field in ("order_in_trial", "duration_ms", "word_id") 

5422 if field in fix_hover_options 

5423 ], 

5424 ) 

5425 return color_fields, numeric_fields, highlight_options 

5426 

5427 

5428#: What each Legends row places (📐 Figure & canvas → Legends). 

5429_LEGEND_ROW_HELP = { 

5430 "compare": "The A/B legend naming the two scanpaths (Compare's *Legend*).", 

5431 "saccades": "The saccade-type legend (↗️ Saccades → Color by type → Legend).", 

5432 "colors": "The legend of a categorical Color by, and the Highlight entries.", 

5433 "size_key": "The duration size key (👁️ Fixations → Size key). Its circles " 

5434 "keep the true marker sizes; Size sets its labels.", 

5435} 

5436 

5437 

5438def _collect_legend_layout(ss) -> dict | None: 

5439 """The legend placements set under 📐 Figure & canvas → Legends. 

5440 

5441 Only the legends moved off *Auto* are listed; ``None`` when none is, which 

5442 every builder reads as "as it always drew". A stale value a link or an old 

5443 config left behind falls back to *Auto* rather than failing the figure. 

5444 """ 

5445 layout = {} 

5446 for kind in LEGEND_KINDS: 

5447 position = ss.get(f"global_legend_{kind}_position") or "auto" 

5448 arrangement = ss.get(f"global_legend_{kind}_arrangement") or "auto" 

5449 size = ss.get(f"global_legend_{kind}_size") 

5450 if position not in LEGEND_POSITION_LABELS: 

5451 position = "auto" 

5452 if arrangement not in LEGEND_ARRANGEMENT_LABELS: 

5453 arrangement = "auto" 

5454 try: 

5455 size = int(size) if size else None 

5456 except (TypeError, ValueError): 

5457 size = None 

5458 if position != "auto" or arrangement != "auto" or size: 

5459 layout[kind] = { 

5460 "position": position, 

5461 "arrangement": arrangement, 

5462 "size": size, 

5463 } 

5464 return layout or None 

5465 

5466 

5467def _collect_viz_settings( 

5468 trial_fixations: pd.DataFrame, 

5469 words: pd.DataFrame | None, 

5470 *, 

5471 numeric_fields: list[str] | None = None, 

5472 highlight_options: list[str] | None = None, 

5473) -> dict: 

5474 """Build the viz-settings dict from session_state (pure — renders nothing). 

5475 

5476 The single source of truth for the dict the figure builders consume, so the 

5477 rendered controls (``render_plot_controls``) and the non-rendering reader 

5478 (``viz_settings_from_state``) return identical shapes. Conditionally-applied 

5479 fields (colour ranges, the highlight column, saccade arrows) are gated here 

5480 exactly as the widgets gate them, so a stored value for an off layer doesn't 

5481 leak into the figure. ``compare_style_a``/``_b`` are ``None``; the rendering 

5482 path fills them in when the comparison toggle is on. 

5483 """ 

5484 ss = st.session_state 

5485 if highlight_options is None: 

5486 highlight_options = highlight_column_options(words) 

5487 

5488 show_fix = bool(ss.get("global_show_fix")) 

5489 show_saccades = bool(ss.get("global_show_saccades")) 

5490 show_heatmap = bool(ss.get("global_show_heatmap")) 

5491 # UX-128: the 📄 Stimulus section's master switch — ANDed into its two 

5492 # layers' *effective* values below, rather than read by the figure 

5493 # builders directly, so it stays a pure display gate: `global_show_labels`/ 

5494 # `global_show_stimulus_image` still hold whatever the user configured, for 

5495 # the moment this is turned back on. Word boxes left the section for one of 

5496 # their own, so this switch no longer gates them. 

5497 show_stimulus = bool(ss.get("global_show_stimulus", True)) 

5498 show_labels = show_stimulus and bool(ss.get("global_show_labels")) 

5499 color_by = ss.get("global_color_by") 

5500 

5501 # Fixation colour range only applies when fixations are shown AND coloured by 

5502 # a numeric column with a valid spread — mirror the widget's gate. A range 

5503 # the user never set is ABSENT, not seeded (VIZ-46), so `None` reaches the 

5504 # builders and each trial is scaled to its own values — the API's own rule. 

5505 fixation_color_range = None 

5506 if ( 

5507 show_fix 

5508 and color_by in trial_fixations.columns 

5509 and pd.api.types.is_numeric_dtype(trial_fixations[color_by]) 

5510 ): 

5511 cmin, cmax = trial_fixations[color_by].min(), trial_fixations[color_by].max() 

5512 if pd.notna(cmin) and pd.notna(cmax): 

5513 # Passed through as stored, not clamped to this pool's span: a 

5514 # pinned scale must not move when a filter narrows the pool. 

5515 fixation_color_range = _explicit_pair(ss.get("global_fixation_color_range")) 

5516 

5517 # Heatmap colour range only applies for the duration-weighted heatmap — 

5518 # over fixations, or a words-only dataset's own dwell column. 

5519 heatmap_range = None 

5520 if show_heatmap and ss.get("global_heatmap_metric") == "duration_ms": 

5521 heatmap_range = _explicit_pair(ss.get("global_heatmap_color_range")) 

5522 

5523 # Fixation-index window (VIZ-7): a (start, end) tuple over `order_in_trial`, 

5524 # or None for the full trial. Read straight from the slider's session key; 

5525 # the rendering path clamps it to the trial's fixation count, and the 

5526 # non-rendering Corpus reader simply leaves it None (it never windows). 

5527 fix_index_range = None 

5528 _fr = ss.get("single_fix_range") 

5529 if isinstance(_fr, (tuple, list)) and len(_fr) == 2: 

5530 fix_index_range = (int(_fr[0]), int(_fr[1])) 

5531 

5532 # The highlight column applies only while its style has something to draw on: 

5533 # **Mark text** recolours the word labels, so it needs Text; **Mark border** 

5534 # is its own outline layer (independent of Text and Word boxes, as in the 

5535 # builder), so it needs only the 📄 Stimulus master switch. 

5536 critical_span_style = ss.get("global_critical_span_style", "Mark text") 

5537 span_drawable = ( 

5538 show_labels if critical_span_style == "Mark text" else show_stimulus 

5539 ) and critical_span_style in ("Mark text", "Mark border") 

5540 highlight_column = None 

5541 if span_drawable and highlight_options: 

5542 candidate = ss.get("global_highlight_column") 

5543 highlight_column = candidate if candidate in highlight_options else None 

5544 

5545 # Background colour comes from the Experimental Setup picker (read here so it 

5546 # flows into the figure via viz_settings). 

5547 bg_options = list(BACKGROUND_PRESETS.keys()) + ["Custom…"] 

5548 bg_choice = ss.get("global_bg_choice", bg_options[0]) 

5549 if bg_choice == "Custom…": 

5550 background_color = ss.get("global_bg_custom", DEFAULT_BACKGROUND_COLOR) 

5551 else: 

5552 background_color = BACKGROUND_PRESETS.get( 

5553 bg_choice, BACKGROUND_PRESETS[bg_options[0]] 

5554 ) 

5555 

5556 return dict( 

5557 show_words=bool(ss.get("global_show_words")), 

5558 word_box_color=ss.get("global_word_box_color") or WORD_BOX_COLOR, 

5559 word_box_line_opacity=float( 

5560 ss.get("global_word_box_line_opacity", WORD_BOX_LINE_OPACITY) 

5561 ), 

5562 word_box_fill_color=ss.get("global_word_box_fill_color") or WORD_BOX_FILL_COLOR, 

5563 word_box_fill_opacity=float( 

5564 ss.get("global_word_box_fill_opacity", WORD_BOX_FILL_OPACITY) 

5565 ), 

5566 show_labels=show_labels, 

5567 show_fix=show_fix, 

5568 show_order=bool(ss.get("global_show_order")), 

5569 show_saccades=show_saccades, 

5570 # Arrows are a saccade sub-layer: never report them on when saccades off. 

5571 show_saccade_arrows=bool(ss.get("global_show_saccade_arrows")) 

5572 and show_saccades, 

5573 show_heatmap=show_heatmap, 

5574 # `or default` (not get-default) so a segmented_control deselect → None 

5575 # falls back instead of propagating None into the figure builders. 

5576 heatmap_style=ss.get("global_heatmap_style") or "Word boxes", 

5577 heatmap_norm=ss.get("global_heatmap_norm") or "Linear", 

5578 heatmap_sigma_px=None 

5579 if ss.get("global_heatmap_sigma_auto", True) 

5580 else float(ss.get("global_heatmap_sigma_px", DEFAULT_HEATMAP_SIGMA_PX)), 

5581 show_raw_gaze=bool(ss.get("global_show_raw_gaze")), 

5582 # UX-86: raw gaze's own style. 

5583 raw_gaze_color=ss.get("global_raw_gaze_color") or "#888888", 

5584 raw_gaze_marker_size=float(ss.get("global_raw_gaze_marker_size", 4.0)), 

5585 raw_gaze_opacity=float(ss.get("global_raw_gaze_opacity", 0.6)), 

5586 show_stimulus_image=show_stimulus 

5587 and bool(ss.get("global_show_stimulus_image")), 

5588 # VIZ-4: image-stimulus opacity (applies to dataset + uploaded images) and 

5589 # the uploaded image's data URI (session-only; set by render_plot_controls). 

5590 stimulus_image_opacity=float(ss.get("global_stimulus_image_opacity", 1.0)), 

5591 stimulus_image_upload_uri=ss.get("_stimulus_image_upload_uri"), 

5592 # VIZ-4: manual image alignment (origin nudge + size scale). 

5593 stimulus_image_offset_x=float(ss.get("global_stimulus_image_offset_x", 0.0)), 

5594 stimulus_image_offset_y=float(ss.get("global_stimulus_image_offset_y", 0.0)), 

5595 stimulus_image_scale=float(ss.get("global_stimulus_image_scale", 1.0)), 

5596 color_by=color_by, 

5597 heatmap_metric=ss.get("global_heatmap_metric") or "duration_ms", 

5598 x_field=ss.get("global_x_field"), 

5599 y_field=ss.get("global_y_field"), 

5600 marker_size_range=tuple(ss.get("global_marker_size_range", (8, 24))), 

5601 marker_size_scale=( 

5602 ss.get("global_marker_size_scale") or DEFAULT_MARKER_SIZE_SCALE 

5603 ), 

5604 marker_duration_range=tuple( 

5605 ss.get("global_marker_duration_range") or DEFAULT_MARKER_DURATION_RANGE 

5606 ), 

5607 duration_size_legend=bool(ss.get("global_duration_size_legend", True)), 

5608 legend_layout=_collect_legend_layout(ss), 

5609 order_font_size=ss.get("global_order_font_size"), 

5610 order_font_color=ss.get("global_order_font_color"), 

5611 **{ 

5612 f"show_{bar}_colorbar": bool(ss.get(f"global_show_{bar}_colorbar")) 

5613 for bar in ("fixation", "heatmap") 

5614 }, 

5615 fit_to_monitor=bool(ss.get("global_fit_to_monitor")), 

5616 show_coordinate_grid=bool(ss.get("global_show_coordinate_grid")), 

5617 coordinate_grid_auto=bool(ss.get("global_coordinate_grid_auto", True)), 

5618 coordinate_grid_spacing=( 

5619 None 

5620 if bool(ss.get("global_coordinate_grid_auto", True)) 

5621 else float(ss.get("global_coordinate_grid_spacing", 100.0)) 

5622 ), 

5623 fixation_color_range=fixation_color_range, 

5624 heatmap_range=heatmap_range, 

5625 fixation_colorscale=ss.get("global_fixation_colorscale") 

5626 or DEFAULT_FIXATION_COLORSCALE, 

5627 heatmap_colorscale=ss.get("global_heatmap_colorscale") 

5628 or DEFAULT_HEATMAP_COLORSCALE, 

5629 critical_span_style=critical_span_style, 

5630 highlight_column=highlight_column, 

5631 saccade_color=ss.get("global_saccade_color", SACCADE_COLOR), 

5632 saccade_style=ss.get("global_saccade_style") or "Solid", 

5633 saccade_width=float(ss.get("global_saccade_width") or DEFAULT_SACCADE_WIDTH), 

5634 # VIZ-8: colour-by-reading-type mode + the per-class palette + optional 

5635 # colour-key legend. 

5636 saccade_color_mode=ss.get("global_saccade_color_mode") or "Uniform", 

5637 saccade_type_legend=bool(ss.get("global_saccade_type_legend", True)), 

5638 saccade_class_colors={ 

5639 cls_name: ss.get( 

5640 f"global_saccade_class_color_{cls_name}", SACCADE_CLASS_COLORS[cls_name] 

5641 ) 

5642 for cls_name in SACCADE_CLASS_EDITABLE 

5643 }, 

5644 # VIZ-31: the reading-class filter. Ordered by SACCADE_CLASS_ORDER (not by 

5645 # click order) so the same selection always produces the same figure key, 

5646 # and unknown names are dropped — a stale link must not smuggle a class 

5647 # the build no longer classifies into the builder. An *empty* selection 

5648 # reads as "no filter", not "draw nothing": hiding the layer entirely is 

5649 # what the Saccades toggle above the filter is for, and a cleared 

5650 # multiselect that blanks the figure reads as a bug. 

5651 saccade_classes=[ 

5652 cls_name 

5653 for cls_name in SACCADE_CLASS_ORDER 

5654 if cls_name in set(ss.get("global_saccade_classes") or SACCADE_CLASS_ORDER) 

5655 ], 

5656 # VIZ-9: linear-reading mode (arced saccades + snap fixations above words). 

5657 saccade_render_mode=ss.get("global_saccade_render_mode") or "Straight", 

5658 fixation_snap_to_word=bool(ss.get("global_fixation_snap_to_word")), 

5659 illustration_label=ss.get("global_illustration_label") or "Auto", 

5660 illustration_text=str(ss.get("global_illustration_text") or ""), 

5661 # VIZ-10: autoplay the animated replay on load (default on). 

5662 anim_autoplay=bool(ss.get("global_anim_autoplay", True)), 

5663 # VIZ-11 follow-up: the animation frame grid (smoothness vs. frame count). 

5664 anim_grid_step_ms=float(ss.get("global_anim_grid_step_ms", 100) or 100), 

5665 anim_max_frames=int(ss.get("global_anim_max_frames", 360) or 360), 

5666 hollow_fixations=bool(ss.get("global_hollow_fixations")), 

5667 fixation_opacity=float(ss.get("global_fixation_opacity", 1.0)), 

5668 # VIZ-17 uniform fixation colour + VIZ-15 marker shape. 

5669 fixation_color=ss.get("global_fixation_color") or DEFAULT_FIXATION_COLOR, 

5670 fixation_symbol=ss.get("global_fixation_symbol") or DEFAULT_FIXATION_SYMBOL, 

5671 # VIZ-18: the active palette name — *derived* from the colour keys above 

5672 # rather than read back from the selector, so a hand-edited figure is 

5673 # reported as `Custom` on every surface instead of carrying a palette 

5674 # name it no longer matches. The colours themselves ride in the 

5675 # individual keys, so `Custom` restores exactly; the name is only there 

5676 # for the picker to come back on the right entry and for export captions. 

5677 palette=_active_palette() or CUSTOM_PALETTE, 

5678 fix_index_range=fix_index_range, 

5679 highlight_text_color=ss.get("global_highlight_text_color"), 

5680 text_color=ss.get("global_text_color", WORD_LABEL_COLOR), 

5681 color_by_line=color_by == "line", 

5682 # Fixation classification (PRE-2) + compare-overlay legend (CMP-2). 

5683 fixation_flags=_collect_fixation_flags(), 

5684 show_compare_legend=bool(ss.get("global_show_compare_legend")), 

5685 span_border_color=ss.get("global_span_border_color", "#000000"), 

5686 **{ 

5687 key: value 

5688 for bar in ("fixation", "heatmap") 

5689 for key, value in ( 

5690 ( 

5691 f"{bar}_colorbar_orientation", 

5692 ss.get(f"global_{bar}_colorbar_orientation") or "Vertical", 

5693 ), 

5694 ( 

5695 f"{bar}_colorbar_tickangle", 

5696 int(ss.get(f"global_{bar}_colorbar_tickangle") or 0), 

5697 ), 

5698 ( 

5699 f"{bar}_colorbar_tickfont_size", 

5700 int(ss.get(f"global_{bar}_colorbar_tickfont_size") or 12), 

5701 ), 

5702 ) 

5703 }, 

5704 background_color=background_color, 

5705 compare_style_a=None, 

5706 compare_style_b=None, 

5707 word_hover_measure=ss.get( 

5708 "global_word_hover_measure", "total_fixation_duration_ms" 

5709 ), 

5710 word_hover_fields=list(ss.get("global_word_hover_fields") or []), 

5711 fixation_hover_fields=list(ss.get("global_fixation_hover_fields") or []), 

5712 # PRE-3: in-place drift correction. `tabs._drift_corrected` applies it once 

5713 # above the render-mode split, so it reaches all three builders (VIZ-23); 

5714 # only the connector layer is still static-figure-only. 

5715 # 

5716 # PRE-21: resolved to "Off" while the feature is gated, rather than each 

5717 # consumer gating separately. That is what makes an old share link or 

5718 # saved config carrying `align_algorithm=warp` degrade *silently* — the 

5719 # setting is read, then ignored, and nothing downstream can disagree. 

5720 align_algorithm=( 

5721 (ss.get("global_align_algorithm") or "Off") 

5722 if drift_correction_enabled() 

5723 else "Off" 

5724 ), 

5725 align_connectors=drift_correction_enabled() 

5726 and bool(ss.get("global_align_connectors")) 

5727 and (ss.get("global_align_algorithm") or "Off") != "Off", 

5728 # EXP-5: empty when the toggle is off, regardless of stored pattern text, 

5729 # so turning it off can never leave a stale pattern silently applied. 

5730 title_pattern=( 

5731 ss.get("global_title_pattern") or "" if ss.get("global_show_title") else "" 

5732 ), 

5733 caption_pattern=( 

5734 ss.get("global_caption_pattern") or "" 

5735 if ss.get("global_show_caption") 

5736 else "" 

5737 ), 

5738 ) 

5739 

5740 

5741def viz_settings_from_state( 

5742 trial_fixations: pd.DataFrame, 

5743 base_font_size: int, 

5744 words: pd.DataFrame | None = None, 

5745) -> dict: 

5746 """Resolve the viz-settings dict from session_state WITHOUT rendering widgets. 

5747 

5748 Used by the views that consume the settings but don't host the controls — the 

5749 Corpus Analysis figures and the Save & restore panel — so they stay in sync 

5750 with the scanpath rail (which renders the actual widgets via 

5751 ``render_plot_controls``) on whatever the user last set. 

5752 """ 

5753 _, numeric_fields, highlight_options = _seed_viz_state( 

5754 trial_fixations, base_font_size, words 

5755 ) 

5756 return _collect_viz_settings( 

5757 trial_fixations, 

5758 words, 

5759 numeric_fields=numeric_fields, 

5760 highlight_options=highlight_options, 

5761 ) 

5762 

5763 

5764def corpus_style_controls( 

5765 trial_fixations: pd.DataFrame, 

5766 base_font_size: int, 

5767 *, 

5768 words: pd.DataFrame | None = None, 

5769 host=None, 

5770 canvas_renderer=None, 

5771) -> dict: 

5772 """Focused, shared-key styling controls for Corpus Analysis (AN-29). 

5773 

5774 Corpus figures intentionally expose only the palette channels they consume; 

5775 the single-scanpath layer controls remain in the Scanpath rail. Because these 

5776 widgets write the same ``global_*`` keys, Share/config restore, the CLI 

5777 palette, and headless builder parameters stay one contract. 

5778 

5779 ``canvas_renderer`` renders the canvas / text panel here too (VIZ-31). The 

5780 corpus figures are drawn true-to-scale from exactly those values — monitor 

5781 size, fonts, line spacing, background — so the view that consumes them needs 

5782 a way to change them; before VIZ-31 the panel lived in the always-present 

5783 sidebar and was reachable from here for free (there is no sidebar now — 

5784 UX-38). 

5785 """ 

5786 _seed_viz_state(trial_fixations, base_font_size, words) 

5787 target = host or st 

5788 with target.expander( 

5789 f"{ICONS['designs']} Corpus figure style", expanded=False 

5790 ) as style_panel: 

5791 active = _active_palette() 

5792 options = list(PALETTES) if active else [CUSTOM_PALETTE, *PALETTES] 

5793 st.session_state["global_palette"] = active or CUSTOM_PALETTE 

5794 st.selectbox( 

5795 "Palette", 

5796 options=options, 

5797 key="global_palette", 

5798 persist_state="session", 

5799 on_change=_on_palette_change, 

5800 format_func=palette_label, 

5801 help="Same palette as the Scanpath view; designs and Share links keep it.", 

5802 ) 

5803 columns = st.columns(2) 

5804 columns[0].color_picker( 

5805 "Primary series", 

5806 key="global_fixation_color", 

5807 persist_state="session", 

5808 help="First group or profile; also the Scanpath fixation color.", 

5809 ) 

5810 columns[1].color_picker( 

5811 "Secondary series", 

5812 key="global_saccade_color", 

5813 persist_state="session", 

5814 help="Second group; also the Scanpath saccade color.", 

5815 ) 

5816 st.selectbox( 

5817 "Heatmap color scale", 

5818 options=COLORSCALES, 

5819 key="global_heatmap_colorscale", 

5820 persist_state="session", 

5821 help="Used by word matrices and stimulus heatmaps.", 

5822 ) 

5823 # VIZ-31's canvas controls belong to this disclosure too. Rendering 

5824 # them after the `with` block left the expander collapsed but every 

5825 # monitor/font/background field open across the Corpus page. 

5826 if canvas_renderer is not None: 

5827 canvas_renderer(style_panel) 

5828 return viz_settings_from_state(trial_fixations, base_font_size, words=words) 

5829 

5830 

5831def _rail_section(host, label: str, *, slug: str, name: str | None = None, **toggle): 

5832 """One rail section: `[toggle | ▾]` on a single line (UX-80). 

5833 

5834 The shape #UX-68 gave 🎬 Animate and ⚖️ Compare, applied to every section of 

5835 the rail: the layer's switch and the disclosure for its options share a row, 

5836 and the options open in a **popover** rather than inside an expander. 

5837 

5838 That last part is the point, not the tidiness. The rail is ~150–200 px wide 

5839 on purpose (the plot is the hero, #UX-51), so an expander lays its controls 

5840 out inside that width and crops them; a popover is positioned over the page 

5841 and sizes to its content. #UX-74 tried the opposite — inlining the popovers — 

5842 and had to be reverted for exactly this. 

5843 

5844 Passing ``toggle`` kwargs (``key=``, ``disabled=``) draws the switch and 

5845 returns its value; the name is the switch's label, so clicking it flips the 

5846 switch (UX-153). Omitting them leaves the section's **name** on its own, 

5847 for the sections that have no layer to switch: 📐 Figure & canvas holds 

5848 none, and Filters & highlights is not a layer at all — there, clicking the name opens 

5849 the popover. (📄 Stimulus has a master switch over its three layers since 

5850 UX-128.) ``note=`` is a line written into the top of the popover — used for 

5851 the ⚠️ that says why a switch is greyed. 

5852 

5853 Returns ``(value, body)`` — ``value`` is ``None`` for a name-only section. 

5854 The ``split_mode_`` key prefix is what `styles.py` styles the row with; it is 

5855 shared with the two mode rows above the plot deliberately, because they are 

5856 the same control. 

5857 

5858 **Round 2 (UX-80).** Three things the first cut got wrong, all visible at 

5859 once in one screenshot: the row wrapped, the trigger showed two arrows, and 

5860 the toggle carried a `?`. 

5861 

5862 - ``width="content"``, not ``"stretch"`` — a stretched trigger claims the 

5863 row's whole width, so the switch and the ▾ could not share a line however 

5864 much room the rail had. Animate and Compare had it right. 

5865 - **No visible label and no icon on the trigger.** A `▾` label (or a 

5866 `:material/arrow_drop_down:` icon) sits *beside* the chevron Streamlit 

5867 draws on every popover, which is one arrow too many. Since BUG-108 the 

5868 trigger does carry a label, ``name="Fixation"`` → "Fixation settings" 

5869 (``label`` without its icon and bold when ``name`` is omitted), for 

5870 screen readers; `styles.py` clips it off screen. 

5871 - ``help_text`` went to the **popover**, not the toggle, so that a row which 

5872 has to fit did not also carry Streamlit's `?` icon. **UX-103 took the 

5873 hover text off this row entirely** — see the comments in the body. 

5874 

5875 **BUG-37 — the popover carries an explicit ``key=``.** ``st.popover`` is a 

5876 stateful widget in this Streamlit version (it takes ``key``/``on_change`` 

5877 like ``st.expander``), and every section here called it with the same empty 

5878 label (``""``, until BUG-108 named each) — the only thing distinguishing one from another is 

5879 surrounding call order. Without a `key`, Streamlit falls back to a 

5880 positional auto-key, and this row sits downstream of several booleans that 

5881 change which widgets render (`_mode_gate`'s `disabled=`/`help=`, the 

5882 layer toggles themselves) — so the auto-key for a *later* section can shift 

5883 between reruns whenever an *earlier* one's rendered shape changes. A 

5884 shifted key reads to Streamlit as a brand-new widget, which drops the one 

5885 thing this widget tracks: whether it is open. That is what a click "not 

5886 working" looked like — the popover opened, a rerun landed before the user 

5887 saw it, and the fresh auto-key came back closed. Every OTHER popover in the 

5888 app has a distinct, stable label (the filter funnel, ⇅, Summary stats, …), which 

5889 is why only 

5890 this shared-blank-label family of eight was affected. 

5891 """ 

5892 row = host.container( 

5893 horizontal=True, 

5894 wrap=False, 

5895 vertical_alignment="center", 

5896 gap=None, 

5897 key=f"split_mode_rail_{slug}", 

5898 ) 

5899 note = toggle.pop("note", None) 

5900 # UX-153: the name is the switch's own label again, so clicking the word 

5901 # flips the switch -- as it always did on Animate and Compare. UX-103 had 

5902 # split them (a collapsed switch + the name as markdown) to be rid of the 

5903 # native tooltip: in a one-line row Streamlit puts a checkbox label in 

5904 # "truncate" mode, which stamps a `title=` repeating the words already on 

5905 # screen, and a `title` cannot be styled or suppressed from CSS. `wrap=True` 

5906 # is what turns truncate mode off, and with it the `title`; the one-line 

5907 # ellipsis it would have drawn comes from `styles.py` instead (the 

5908 # `split_mode_` label rule), which draws it without a tooltip. 

5909 if toggle: 

5910 # #374 F19: no `**` in the label, which is the switch's accessible 

5911 # name verbatim; `styles.py` draws the row names bold instead. 

5912 value = row.toggle(label.replace("**", ""), wrap=True, **toggle) 

5913 else: 

5914 # A name-only section: `styles.py` stretches the ▾ trigger's click 

5915 # target over the whole row, so the name opens the popover (UX-153). 

5916 value = None 

5917 row.markdown(label) 

5918 # BUG-108: the trigger is named for screen readers ("Fixation settings"); 

5919 # `styles.py` clips that label off screen, so the chevron stays the only 

5920 # thing drawn. `wrap=True` for the reason the switch has it: a truncated 

5921 # one-line label would stamp a native `title=` tooltip. 

5922 name = name or re.sub(r":material/\w+:|\*", "", label).strip() 

5923 body = row.popover( 

5924 f"{name} settings", 

5925 width="content", 

5926 wrap=True, 

5927 key=f"split_mode_rail_{slug}_popover", 

5928 ) 

5929 # ...and the popover trigger carries no `help=` either. What hovered there 

5930 # was a restatement of the section's own name; what is worth saying -- why 

5931 # a switch is greyed -- is written inside the popover instead, where it is 

5932 # read without waiting for a tooltip and without covering the row below. 

5933 if note: 

5934 body.caption(note) 

5935 return value, body 

5936 

5937 

5938def _rail_subsection(host, label: str, *, note: str = ""): 

5939 """A named block inside the rail's Filters & highlights section (UX-72). 

5940 

5941 **Scope, after UX-74 was reverted.** That item flattened *every* section's 

5942 `⚙️ …` popovers into blocks like this one; the rail read worse for it — a 

5943 section became a long unbroken run — so the popovers are back everywhere 

5944 they were. What is left using this is the one section that never had them: 

5945 #UX-72's Filters & highlights, whose two halves (👁️ Fixations · ↗️ Saccades) are 

5946 genuinely one thing each and would spend a click for nothing. 

5947 

5948 ``note`` renders under the label — a block has no trigger, so the sentence a 

5949 popover carried as a tooltip (what the filter does, and why it is inert in 

5950 Animate or Compare) goes here instead. 

5951 """ 

5952 # A line opening with `<div` is a raw HTML block, where the label's 

5953 # `ICONS` shortcode would print as text (UX-138). 

5954 host.markdown( 

5955 f'<div class="sps-rail-subhead">{icons_to_html(label)}</div>', 

5956 unsafe_allow_html=True, 

5957 ) 

5958 box = host.container() 

5959 if note: 

5960 box.caption(note) 

5961 return box 

5962 

5963 

5964#: BUG-36 — the ♻️ Reset visualization button's confirmation flag. 

5965_RESET_VIZ_PENDING_KEY = "_reset_viz_pending" 

5966 

5967 

5968@st.dialog("Reset visualization?") 

5969@guarded() 

5970def _reset_viz_confirmation_dialog() -> None: 

5971 """The modal body — BUG-36. Opened by ``render_viz_reset``. 

5972 

5973 Handled by the button's *return value*, not ``on_click`` (see the module 

5974 note on ``reset_viz_settings`` for why that function itself still runs as 

5975 a bare call rather than a callback here: the confirm click executes inside 

5976 the dialog's own fragment rerun, a different script frame from the one 

5977 that instantiates the rail's ``global_*`` widgets, so deleting their keys 

5978 here hits none of the "set after instantiation" ordering that forces 

5979 ``on_click`` on the *un-confirmed* button next door). 

5980 """ 

5981 st.caption( 

5982 "Reset every plot setting, Filters & highlights included. Annotations, trial " 

5983 "filters, data and the selected trial are kept." 

5984 ) 

5985 yes, no = st.columns(2) 

5986 if yes.button( 

5987 f"{ICONS['reset']} Reset it", 

5988 key="reset_viz_confirm", 

5989 type="primary", 

5990 width="stretch", 

5991 ): 

5992 reset_viz_settings() 

5993 st.session_state.pop(_RESET_VIZ_PENDING_KEY, None) 

5994 st.rerun(scope="app") 

5995 if no.button("Cancel", key="reset_viz_cancel", width="stretch"): 

5996 st.session_state.pop(_RESET_VIZ_PENDING_KEY, None) 

5997 st.rerun(scope="app") 

5998 

5999 

6000def render_viz_reset(host) -> None: 

6001 """Render the scoped visualization reset into ``host`` — a plain button. 

6002 

6003 Full width, like every other control in the rail, and at its foot (BUG-24) 

6004 below everything it resets. 

6005 

6006 **UX-73**: it used to be a popover holding a caption and this button, so the 

6007 rail's one escape hatch was itself a click deep — and the thing behind the 

6008 click was a single button, which is what a popover is *for* avoiding. The 

6009 caption it held is the button's tooltip now; nothing else was in there. 

6010 

6011 **BUG-36**: the click now arms a confirmation dialog rather than firing 

6012 ``reset_viz_settings`` straight away — a Share link's settings ride on this 

6013 too, so an accidental click used to be able to lose more than it looked 

6014 like. 

6015 """ 

6016 if host.button( 

6017 f"{ICONS['reset']} Reset visualization", 

6018 key="reset_viz_settings_btn", 

6019 width="stretch", 

6020 help="Reset every plot setting, Filters & highlights included. Annotations, " 

6021 "trial filters, data and the selected trial are kept.", 

6022 ): 

6023 st.session_state[_RESET_VIZ_PENDING_KEY] = True 

6024 if st.session_state.get(_RESET_VIZ_PENDING_KEY): 

6025 _reset_viz_confirmation_dialog() 

6026 

6027 

6028def render_plot_controls( 

6029 trial_fixations: pd.DataFrame, 

6030 base_font_size: int, 

6031 *, 

6032 host=None, 

6033 has_raw_gaze: bool = False, 

6034 has_stimulus_image: bool = False, 

6035 words: pd.DataFrame | None = None, 

6036 fix_range_fixations: pd.DataFrame | None = None, 

6037 canvas_renderer=None, 

6038 slots: dict | None = None, 

6039 has_fixations: bool = True, 

6040 has_words: bool = True, 

6041) -> dict: 

6042 """Render the visualization controls and return the resolved settings dict. 

6043 

6044 Layout (VIZ-31 / UX-44 — grouped so the rail reads by category): 

6045 1. Quick-view presets + Palette at the top: the two controls that get to a 

6046 good figure without opening anything. 

6047 2. Five collapsible sections — **👁️ Fixations** (expanded), then collapsed 

6048 **↗️ Saccades**, **📄 Stimulus**, **🔥 Overlays**, and 

6049 **📐 Figure & canvas** (canvas/text plus axes/labels). 

6050 3. Inside a section: **layer toggle → ⚙️ style**, the detail popovers 

6051 shown only while the layer is on. Streamlit nests neither 

6052 expander-in-expander nor popover-in-popover, so an expander holding 

6053 popovers is the only two-level shape available — which is also why 

6054 Fixations and Saccades are peer sections rather than sub-sections of a 

6055 single "Scanpath" group. UX-74 tried replacing those popovers with 

6056 inline blocks and was reverted: a section then read as one long 

6057 undifferentiated run. 

6058 3b. Filtering left the sections entirely (UX-72): one **Filters & highlights** 

6059 section after them holds both the fixation and the saccade filters. 

6060 4. **📐 Figure & canvas** follows the same shape with no layer to toggle 

6061 (UX-48): the framing toggle inline, then four popovers — 🖥️ Screen & 

6062 geometry · 🔤 Text & fonts (both from ``canvas_renderer``) · 📊 Axes & 

6063 grid · 🏷️ Title & labels. 

6064 

6065 The sections are created up front (Streamlit lays containers out in creation 

6066 order), so each block below renders into its section without moving in this 

6067 file — see the "Layer groups" comment. 

6068 

6069 ``canvas_renderer`` is an optional ``callable(slot)`` rendering the canvas / 

6070 text panel (``app.render_canvas_controls``) into a slot reserved 

6071 between the Overlays and Figure groups. VIZ-31 moved that panel into the rail 

6072 so the figure's fonts, text colour and background sit beside the 

6073 other visual controls; when it is ``None`` (the wizard, the non-rendering 

6074 readers) nothing is drawn there and the panel keeps its own home. 

6075 

6076 ``host`` is the container to render into — the app passes the scanpath rail 

6077 (``tabs.render_single_trial_tab``); ``None`` renders in place. The returned 

6078 dict is built by 

6079 ``_collect_viz_settings`` (shared with ``viz_settings_from_state``) so the 

6080 rendered controls and the non-rendering readers can't drift. 

6081 

6082 ``fix_range_fixations`` is the *selected trial's* fixations, used only to size 

6083 the VIZ-7 fixation-index window slider (its max is that trial's fixation 

6084 count). When omitted, the slider isn't rendered (e.g. the non-rendering 

6085 Corpus reader, which never windows). 

6086 """ 

6087 # can re-push the stored values to the browser (BUG-15 — see `_pin`). 

6088 color_fields, numeric_fields, highlight_options = _seed_viz_state( 

6089 trial_fixations, base_font_size, words 

6090 ) 

6091 if not numeric_fields: 

6092 st.error( 

6093 "The fixations have no numeric columns to plot. Check the fixation " 

6094 "mapping on the Data Management page." 

6095 ) 

6096 st.stop() 

6097 

6098 # The keyed container is the spotlight-tour target 

6099 # (`.st-key-tour_grp_viz_controls`). Values for controls not rendered this run 

6100 # are read back from session_state by `_collect_viz_settings`, so the returned 

6101 # dict always carries every key the figure builders depend on. 

6102 viz = (host if host is not None else st).container(key="tour_grp_viz_controls") 

6103 

6104 # --- Design presets --------------------------------------------------- 

6105 # VIZ-39 renamed this from "Quick views" and gave it a second half: the four 

6106 # built-in designs in the 2x2 grid they have always been in, and the user's 

6107 # own saved designs in an expander under them. 🛠️ Custom is not a design — 

6108 # it is the one unnamed slot holding *your most recent hand-tuning*, so 

6109 # switching to a built-in and back does not lose it. Naming settings you 

6110 # want to keep is what "My designs" is for. The remaining preset keys 

6111 # (`reading_order`, `everything`) stay in `_VIEW_PRESETS` for any deep link. 

6112 viz.markdown( 

6113 '<div class="sps-control-label">Design presets</div>', 

6114 unsafe_allow_html=True, 

6115 ) 

6116 # A 2×2 grid keeps the labels readable in the narrow rail. 

6117 _active = _active_quick_view() 

6118 _qv_grid = viz.container(key="quick_views_grid") 

6119 _qv_top = _qv_grid.columns(2, gap="small") 

6120 _qv_top[0].button( 

6121 f"{ICONS['preset_scanpath']} Scanpath", 

6122 key="viz_view_scanpath", 

6123 type="primary" if _active == "scanpath" else "secondary", 

6124 width="stretch", 

6125 help="Fixations + saccades over the text — the core scanpath.", 

6126 on_click=_apply_view_preset, 

6127 args=("scanpath",), 

6128 ) 

6129 _qv_top[1].button( 

6130 f"{ICONS['heatmap']} Heatmap", 

6131 key="viz_view_heatmap", 

6132 type="primary" if _active == "heatmap" else "secondary", 

6133 width="stretch", 

6134 help="Each word box colored by the total time spent on it (ms), with " 

6135 "nothing else drawn.", 

6136 on_click=_apply_view_preset, 

6137 args=("heatmap",), 

6138 ) 

6139 _qv_bottom = _qv_grid.columns(2, gap="small") 

6140 _qv_bottom[0].button( 

6141 f"{ICONS['illustration']} Illustration", 

6142 key="viz_view_illustration", 

6143 type="primary" if _active == "illustration" else "secondary", 

6144 width="stretch", 

6145 help="A clean schematic: fixations snapped above words, arced " 

6146 "saccades, one saccade color, opaque markers.", 

6147 on_click=_apply_view_preset, 

6148 args=("illustration",), 

6149 ) 

6150 _qv_bottom[1].button( 

6151 f"{ICONS['preset_custom']} Custom", 

6152 key="viz_view_custom", 

6153 type="primary" if _active == _CUSTOM_VIEW else "secondary", 

6154 width="stretch", 

6155 help="Your most recent custom plot settings. Save them under a name in " 

6156 f"{ICONS['designs']} My designs to keep them.", 

6157 on_click=_apply_view_preset, 

6158 args=(_CUSTOM_VIEW,), 

6159 ) 

6160 _render_saved_designs(viz) 

6161 

6162 # VIZ-31: the Illustration *label* (the publication-disclosure override) now 

6163 # lives in the "📐 Figure & canvas" group below, with the other figure-level 

6164 # presentation settings, rather than as a third top-level row up here. 

6165 

6166 # VIZ-18: these figures end up in papers — printed, sometimes in black & 

6167 # white — and are read by colourblind viewers, so the colour defaults are a 

6168 # choice rather than a constant. Picking one writes the individual colour 

6169 # keys, so every per-element picker below still overrides it — and once one 

6170 # is overridden the selector says **Custom** rather than keeping a name the 

6171 # figure no longer earns (the same rule the Quick-view buttons follow above). 

6172 # `Custom` is offered only while it's true, so the list stays the three real 

6173 # palettes the moment the colours match one again. 

6174 _active = _active_palette() 

6175 _palette_options = list(PALETTES) if _active else [CUSTOM_PALETTE, *PALETTES] 

6176 st.session_state["global_palette"] = _active or CUSTOM_PALETTE 

6177 # UX-80 r2: no `help=`, because Streamlit draws it as a `?` icon and the ask 

6178 # was to clear those off the rail's head. This is the one that had nowhere 

6179 # else to go — the toggles' text moved to their ▾ tooltips, but a selectbox 

6180 # has no second hover target — so what each palette *is* now reads off the 

6181 # option names themselves ("Default (colourblind-safe)", "Print / greyscale", 

6182 # "High contrast"), and "any change reads Custom" is visible the moment it 

6183 # happens. The full explanation lives in the docs. 

6184 viz.selectbox( 

6185 "Palette", 

6186 options=_palette_options, 

6187 key="global_palette", 

6188 persist_state="session", 

6189 on_change=_on_palette_change, 

6190 format_func=palette_label, 

6191 ) 

6192 

6193 # Keep the palette controls visually separate from the bordered layer cards. 

6194 # A keyed wrapper gives the spacing a stable, narrowly scoped CSS hook. 

6195 viz.container(key="palette_layers_divider").divider() 

6196 

6197 # Each main layer is an `st.toggle`; the layer's detailed styling lives in a 

6198 # per-layer popover shown only while the layer is on — so the rail shows just 

6199 # the toggles (plus, for fixations, the primary "Color by" control), and the 

6200 # fiddly knobs open in an overlay instead of growing the rail past the plot. 

6201 # Values for off layers are read back from session_state by 

6202 # `_collect_viz_settings`, so the returned dict always carries every key. 

6203 

6204 # VIZ-21: the two view modes (rail → 🎛️ Plot controls, rendered *before* this 

6205 # function) route the figure through different builders, and each ignores a 

6206 # different slice of these controls. Read both flags and gate every affected 

6207 # widget through `_mode_gate` — greyed with a reason, never silently ignored, 

6208 # and never with its stored value rewritten. VIZ-23 then made a batch of them 

6209 # live in Animate / Compare, so what remains gated below is the genuinely 

6210 # builder-less set (see CLAUDE.md's setting → render-path table). 

6211 comparing = bool( 

6212 st.session_state.get( 

6213 "_resolved_comparing", st.session_state.get("single_compare_toggle") 

6214 ) 

6215 ) 

6216 animating = bool( 

6217 st.session_state.get( 

6218 "_resolved_animating", st.session_state.get("single_animate") 

6219 ) 

6220 ) 

6221 # Handy shorthands for the two recurring gates. 

6222 _static_only = dict(in_animation=False, in_compare=False) 

6223 _no_compare = dict(in_compare=False) 

6224 

6225 # --- Layer groups (VIZ-31) -------------------------------------------- 

6226 # The seven layers used to sit as one flat top-to-bottom run of toggles, so 

6227 # the rail opened as ~13 peer rows with no hint that Text / Bounding boxes / 

6228 # Stimulus image describe the *stimulus* while Fixations / Saccades / Raw 

6229 # gaze describe the *recording*. They are now named groups, plus two for how 

6230 # the figure is framed. 

6231 # 

6232 # The groups are created HERE, up front, because Streamlit lays containers 

6233 # out in **creation** order — which lets each block below keep its current 

6234 # position in this file while rendering into whichever group it belongs to. 

6235 # So the visual order is exactly the order of these six lines; the code 

6236 # order further down is unchanged (and irrelevant to the layout). 

6237 # 

6238 # **Section shape.** Each group is `layer toggle → ⚙️ style → 🧹 filter`, the 

6239 # nesting the original wireframe asked for. Streamlit nests neither 

6240 # expander-in-expander nor popover-in-popover, but popover-in-**expander** is 

6241 # allowed — so a section is an expander and its sub-sections are popovers, 

6242 # which is the only two-level shape the framework actually renders. That is 

6243 # also why Fixations and Saccades are *peer* sections rather than one 

6244 # "Scanpath" group holding two sub-sections: the sub-section level is spent 

6245 # on style/filter, where it earns more than on the layer split. 

6246 # 

6247 # Fixations opens because it is the primary layer; Saccades and the less-used 

6248 # groups stay collapsed so the independent rail starts compact. The preset row 

6249 # above still covers the common combinations without opening anything at all. 

6250 # UX-80: each section is a row — its switch (when it has exactly one thing 

6251 # to switch) and a ▾ holding everything else. The layer toggles are rendered 

6252 # HERE rather than in the blocks below, because a row lays its children out 

6253 # in creation order and the switch has to precede the ▾; the blocks below 

6254 # still own everything inside the popovers. 

6255 fix_off_disabled, _fix_off_reason = _mode_gate( 

6256 animating, comparing, in_animation=False, in_compare=True 

6257 ) 

6258 # VIZ-45: a trial with no fixations (raw gaze only, or words only) has 

6259 # nothing for the fixation-built controls to act on — Fixations, Saccades, 

6260 # the Filter and its index window — and the samples are never turned into 

6261 # fixations, so they grey with that reason, keeping their values for the 

6262 # next trial that has fixations. 

6263 no_fixations_note = ( 

6264 "" 

6265 if has_fixations 

6266 else f"{ICONS['warning']} This trial has no fixations." 

6267 + ( 

6268 f" Its gaze samples are under {ICONS['raw_gaze']} **Raw gaze**." 

6269 if has_raw_gaze 

6270 else "" 

6271 ) 

6272 ) 

6273 show_fix, fix_grp = _rail_section( 

6274 viz, 

6275 f"{ICONS['fixations']} **Fixations**", 

6276 slug="fix", 

6277 name="Fixation", 

6278 key="global_show_fix", 

6279 persist_state="session", 

6280 disabled=fix_off_disabled or not has_fixations, 

6281 # No fixations: the popover body's own `_layer_off` caption says it. 

6282 note="" 

6283 if no_fixations_note 

6284 else ( 

6285 f"{ICONS['warning']} **Animate** always draws fixations; this switch " 

6286 "applies to the other figures." 

6287 if fix_off_disabled 

6288 else "" 

6289 ), 

6290 ) 

6291 show_saccades, sac_grp = _rail_section( 

6292 viz, 

6293 f"{ICONS['saccades']} **Saccades**", 

6294 slug="sac", 

6295 name="Saccade", 

6296 key="global_show_saccades", 

6297 persist_state="session", 

6298 disabled=not has_fixations, 

6299 ) 

6300 # UX-128: a master switch for the section's layers (text, image), 

6301 # matching Fixations/Saccades. Earlier this was name-only — each 

6302 # layer carried its own toggle and nothing gated all of them at once — on 

6303 # the reasoning that a master switch would have to remember which of the 

6304 # layers were on to restore them. It doesn't: this toggle never touches 

6305 # `global_show_labels`/`global_show_stimulus_image` themselves, so each 

6306 # keeps whatever the user set. It only ANDs into the *effective* values 

6307 # `_collect_viz_settings` returns — turning it back on reveals exactly what 

6308 # was configured before, with nothing to restore. 

6309 show_stimulus, stim_grp = _rail_section( 

6310 viz, 

6311 f"{ICONS['stimulus']} **Stimulus**", 

6312 slug="stim", 

6313 name="Stimulus", 

6314 key="global_show_stimulus", 

6315 persist_state="session", 

6316 ) 

6317 # Word boxes were a third layer inside 📄 Stimulus; they are a section of 

6318 # their own now, with a style (outline + fill) that the Stimulus popover had 

6319 # no room for. The switch keeps its `global_show_words` key, so links and 

6320 # saved configs are unchanged — only the Stimulus master no longer gates it. 

6321 show_word_boxes, boxes_grp = _rail_section( 

6322 viz, 

6323 f"{ICONS['word_boxes']} **Word boxes**", 

6324 slug="boxes", 

6325 name="Word boxes", 

6326 key="global_show_words", 

6327 persist_state="session", 

6328 # No word boxes: the popover body's own `_layer_off` caption says it. 

6329 disabled=not has_words, 

6330 ) 

6331 # UX-86: Overlays dissolved — Heatmap and Raw gaze are now peer sections, 

6332 # each with exactly one thing to switch, so each carries its own toggle 

6333 # on the row the way Fixations/Saccades do. `_mode_gate` is called again 

6334 # (cheaply) where each layer's style popover needs its own `reason` text. 

6335 heat_disabled, heat_reason = _mode_gate(animating, comparing, in_animation=False) 

6336 # VIZ-45: the heatmap draws from fixations or from the word boxes' own 

6337 # measures, so only a trial with neither has nothing for it. 

6338 heat_nothing = not has_fixations and not has_words 

6339 heat_nothing_note = ( 

6340 f"{ICONS['warning']} This trial has no fixations and no word boxes." 

6341 + ( 

6342 f" Its gaze samples are under {ICONS['raw_gaze']} **Raw gaze**." 

6343 if has_raw_gaze 

6344 else "" 

6345 ) 

6346 ) 

6347 show_heatmap, heatmap_grp = _rail_section( 

6348 viz, 

6349 f"{ICONS['heatmap']} **Heatmap**", 

6350 slug="heatmap", 

6351 name="Heatmap", 

6352 key="global_show_heatmap", 

6353 persist_state="session", 

6354 disabled=heat_disabled or heat_nothing, 

6355 # Nothing to draw: the popover body's own `_layer_off` caption says it. 

6356 note="" if heat_nothing else heat_reason, 

6357 ) 

6358 # VIZ-48: the comparison builder draws each reading's samples; the replay 

6359 # still has no raw-gaze layer (VIZ-49). 

6360 raw_disabled, raw_reason = _mode_gate(animating, comparing, in_animation=False) 

6361 show_raw_gaze, raw_gaze_grp = _rail_section( 

6362 viz, 

6363 f"{ICONS['raw_gaze']} **Raw gaze**", 

6364 slug="rawgaze", 

6365 name="Raw gaze", 

6366 key="global_show_raw_gaze", 

6367 persist_state="session", 

6368 disabled=not has_raw_gaze or raw_disabled, 

6369 note=_gated_help( 

6370 "" if has_raw_gaze else f"{ICONS['warning']} No raw gaze samples to show.", 

6371 raw_reason, 

6372 ), 

6373 ) 

6374 # UX-72 — ONE filter section for the whole figure, a peer of the layer 

6375 # sections rather than a 🧹 popover inside each of them. "Filter the plot" 

6376 # was two controls, in two places, each two clicks deep; it is one place 

6377 # now, and it sits after the layers because it thins what they draw. 

6378 # 

6379 # Not to be confused with the filter funnel on the control line (#UX-64), 

6380 # which narrows 

6381 # the *trial pool* — which readings you can pick. This one thins one 

6382 # reading. The badge still says an active filter is on, or a thinned figure 

6383 # reads as missing data; with two filters folded together it now reports 

6384 # both, so `•` on the section means at least one of them is narrowing. 

6385 # UX-80: no toggle — filtering is not a layer — but the same row shape, so 

6386 # its controls open over the page instead of being cropped by the rail. 

6387 _filter_none, filter_grp = _rail_section( 

6388 viz, 

6389 f"{ICONS['plot_filter']} **Filters & highlights**{_plot_filter_badge()}", 

6390 slug="filter", 

6391 name="Filters & highlights", 

6392 note=no_fixations_note, 

6393 ) 

6394 # Sub-slots up front so each block below renders into the right half of the 

6395 # section from wherever it sits in this file (the same trick the sections 

6396 # themselves use). 

6397 filter_fix_slot = filter_grp.container() 

6398 filter_sac_slot = filter_grp.container() 

6399 # CMP-24: scanpath B's own filters, filled by `render_compare_filters` once B 

6400 # is loaded (below the rail). Handed back through `slots`, not the settings 

6401 # dict, which is hashed into figure keys and must stay plain data. 

6402 if comparing and slots is not None: 

6403 slots["compare_filter"] = filter_grp.container() 

6404 ab = " · A" if comparing else "" 

6405 # Canvas/text and the former Figure/axes controls share one disclosure: both 

6406 # describe the figure's framing rather than a data layer. The injected canvas 

6407 # renderer writes directly into this expander (not a nested expander), as its 

6408 # own popover sub-groups — see the "Figure & canvas" block below. 

6409 _figure_none, figure_grp = _rail_section( 

6410 viz, 

6411 f"{ICONS['figure']} **Figure & canvas**", 

6412 slug="figure", 

6413 name="Figure & canvas", 

6414 ) 

6415 

6416 # --- Fixations -------------------------------------------------------- 

6417 # The Fixations toggle reaches the static figure AND Compare (CMP-7 — the 

6418 # comparison heatmap is unreadable under two full sets of markers). Only the 

6419 # animated replay ignores it: the replay *is* the fixation trail, so there is 

6420 # nothing left to draw with it off, and `make_scanpath_animation` takes no 

6421 # `show_fixations` argument. 

6422 # The toggle is on the section's row (UX-80); the styling below is still 

6423 # (partly) live in Animate / Compare, so the popover stays reachable even 

6424 # when the (inert) layer toggle reads off. 

6425 # UX-158: every title in this popover is short, so its label column is 

6426 # narrower than the rail's, bringing the fields closer to their titles; the 

6427 # keyed container is what `styles.py` spaces the rows apart by. 

6428 with ( 

6429 fix_grp, 

6430 _layer_off( 

6431 f"{ICONS['fixations']} Fixations", 

6432 off=not (show_fix or fix_off_disabled) or not has_fixations, 

6433 reason=no_fixations_note or None, 

6434 ), 

6435 _popover_rows("fix"), 

6436 ): 

6437 # The metric that maps to fixation HUE — applies on every render path. 

6438 # In Compare and the co-animation (Animate + Compare) the chosen values 

6439 # — numeric, a category, or the text line — fill both scanpaths' 

6440 # markers on one shared scale / one shared category→colour mapping, 

6441 # and each scanpath's flat colour becomes its marker outline. 

6442 metric_disabled, metric_reason = _mode_gate(animating, comparing) 

6443 # UX-158: colour, shape, size and opacity are one "Marker" group — a 

6444 # title on the first row and a short caption per row, instead of a full 

6445 # title each (VIZ-17 → UX-154 put the flat colour / colorscale beside 

6446 # the colour-by pick; UX-156 briefly squeezed shape, size and opacity 

6447 # into one row of unlabelled boxes). The swatch shows while 

6448 # **(uniform)** is picked, the colorscale once a column is; the flat 

6449 # colour is inert in Compare, where each scanpath wears its own colour 

6450 # (see "Per-scanpath (comparison)" below). 

6451 by_help = _gated_help( 

6452 "The column that colors the markers. **One color**: the " 

6453 "color in the box beside it. A numeric column: the color scale " 

6454 "beside it. **Line** or a categorical column: a discrete palette. In " 

6455 "Compare, both scanpaths share the mapping.", 

6456 metric_reason, 

6457 ) 

6458 by_disabled, by_help = _layer_gate(metric_disabled, by_help) 

6459 # In Compare this group *is* scanpath A's: its colour, size and opacity 

6460 # rows write A's `cmp0_*` keys (the figure-wide ones are inert there), 

6461 # and scanpath B's group follows it. Shape and the duration scale stay 

6462 # shared by both. 

6463 field = _sub_row( 

6464 "Color", 

6465 section=_COMPARE_SCANPATHS[0][1] if comparing else "Marker", 

6466 section_help=( 

6467 _COMPARE_SCANPATH_HELP[0] + " Color, size and opacity are its " 

6468 "own; shape and duration scale are " 

6469 "shared." 

6470 if comparing 

6471 else "How fixation markers are drawn." 

6472 ), 

6473 caption_help=by_help, 

6474 section_share=_COMPARE_SECTION_SHARE if comparing else 0.45, 

6475 ) 

6476 by_col, style_col = field.columns( 

6477 [0.6, 0.4], gap=_LABEL_GAP, vertical_alignment="center" 

6478 ) 

6479 # DATA-66: the dataset's own names, its columns before the app's. 

6480 rail_names = _rail_names() 

6481 color_labels = rail_names.option_labels( 

6482 color_fields, 

6483 { 

6484 UNIFORM_COLOR_FIELD: "One color", 

6485 "line": "Line" + cn.COMPUTED_SUFFIX, 

6486 }, 

6487 roles=True, 

6488 ) 

6489 color_by = by_col.selectbox( 

6490 "Color fixations by", 

6491 # "line" is no column of either table, so it is placed by hand, 

6492 # after the app's own fields like the computed field it is. 

6493 options=rail_names.sort_options( 

6494 [f for f in color_fields if f != "line"], first=(UNIFORM_COLOR_FIELD,) 

6495 ) 

6496 + (["line"] if "line" in color_fields else []), 

6497 format_func=color_labels.__getitem__, 

6498 key="global_color_by", 

6499 persist_state="session", 

6500 # VIZ-46: a chosen colour range is in the units of the column it was 

6501 # chosen for, so picking another column puts it back to auto rather 

6502 # than clamping ms into, say, surprisal's (often one-value) span. 

6503 on_change=forget_color_range, 

6504 args=("global_fixation_color_range",), 

6505 disabled=by_disabled, 

6506 help=by_help, 

6507 label_visibility="collapsed", 

6508 ) 

6509 if color_by == UNIFORM_COLOR_FIELD and comparing: 

6510 _compare_fix_color_picker(style_col, 0) 

6511 elif color_by == UNIFORM_COLOR_FIELD: 

6512 _dis, _reason = _mode_gate(animating, comparing, **_no_compare) 

6513 _dis, _tip = _layer_gate( 

6514 _dis, 

6515 _gated_help("The single color every fixation marker wears.", _reason), 

6516 ) 

6517 style_col.color_picker( 

6518 "Fixation color", 

6519 key="global_fixation_color", 

6520 persist_state="session", 

6521 disabled=_dis, 

6522 help=_tip, 

6523 label_visibility="collapsed", 

6524 ) 

6525 else: 

6526 # 'line' and a categorical column are drawn from a discrete 

6527 # palette, so the colorscale is idle for them — greyed, not hidden. 

6528 discrete = color_by == "line" or ( 

6529 color_by in trial_fixations.columns 

6530 and not pd.api.types.is_numeric_dtype(trial_fixations[color_by]) 

6531 ) 

6532 _dis, _tip = _layer_gate( 

6533 metric_disabled or discrete, 

6534 _gated_help( 

6535 "The color scale for a numeric column. Not used for **Line** or " 

6536 "a categorical column.", 

6537 metric_reason, 

6538 ), 

6539 ) 

6540 # Keyless on purpose, like `_popover_selectbox`: a keyed selectbox 

6541 # first painted in a closed popover shows its first option instead 

6542 # of the seeded value, so the index is passed and the pick written 

6543 # back by hand. 

6544 current_scale = st.session_state.get("global_fixation_colorscale") 

6545 st.session_state["global_fixation_colorscale"] = style_col.selectbox( 

6546 "Colorscale", 

6547 COLORSCALES, 

6548 index=( 

6549 COLORSCALES.index(current_scale) 

6550 if current_scale in COLORSCALES 

6551 else 0 

6552 ), 

6553 disabled=_dis, 

6554 help=_tip, 

6555 label_visibility="collapsed", 

6556 ) 

6557 if comparing and color_by != UNIFORM_COLOR_FIELD: 

6558 # A column fills the markers, so A's colour is their outline. 

6559 _compare_fix_color_picker( 

6560 _sub_row("Outline", caption_help=_COMPARE_OUTLINE_HELP), 0 

6561 ) 

6562 raw_cmin = ( 

6563 trial_fixations[color_by].min() 

6564 if color_by in trial_fixations.columns 

6565 and pd.api.types.is_numeric_dtype(trial_fixations[color_by]) 

6566 else None 

6567 ) 

6568 raw_cmax = trial_fixations[color_by].max() if raw_cmin is not None else None 

6569 if pd.notna(raw_cmin) and pd.notna(raw_cmax): 

6570 # Integer bounds + step so the range reads as whole numbers 

6571 # (durations, surprisal, … all read cleaner as ints); values 

6572 # stay floats so a restored config on different data clamps in. 

6573 # The bounds span the loaded pool; the *default* is auto — each 

6574 # trial on its own scale, like the API (VIZ-46). 

6575 cmin = float(math.floor(raw_cmin)) 

6576 cmax = float(math.ceil(raw_cmax)) 

6577 cmax_eff = cmax if cmax > cmin else cmin + 1.0 

6578 _render_color_range( 

6579 "Fixation color range", 

6580 "global_fixation_color_range", 

6581 cmin, 

6582 cmax_eff, 

6583 field_host=_sub_row( 

6584 "Range", 

6585 caption_help="The values at the two ends of the color scale.", 

6586 ), 

6587 disabled=metric_disabled, 

6588 reason=metric_reason, 

6589 help="The values at the two ends of the color scale.", 

6590 ) 

6591 # VIZ-15: shape survives greyscale printing where hue doesn't, and 

6592 # VIZ-23 made it a true global — the one marker property Compare does 

6593 # NOT override per scanpath. 

6594 shape_help = ( 

6595 "Marker shape. Applies to every figure, and to both scanpaths in Compare." 

6596 ) 

6597 shape_dis, shape_help = _layer_gate(False, shape_help) 

6598 _sub_row("Shape", caption_help=shape_help).selectbox( 

6599 "Marker shape", 

6600 options=list(FIXATION_SYMBOLS), 

6601 format_func=lambda s: FIXATION_SYMBOLS[s], 

6602 key="global_fixation_symbol", 

6603 persist_state="session", 

6604 disabled=shape_dis, 

6605 help=shape_help, 

6606 label_visibility="collapsed", 

6607 ) 

6608 # Size / opacity are per-scanpath in Compare (`cmp*_marker_size_range` 

6609 # / `cmp*_opacity` override these there), so they carry its gate. 

6610 _dis, _reason = _mode_gate(animating, comparing, **_no_compare) 

6611 size_text = "Smallest and largest marker diameter, in px." 

6612 if comparing: 

6613 _compare_size_slider(0, size_text) 

6614 else: 

6615 _, size_help = _layer_gate(_dis, _gated_help(size_text, _reason)) 

6616 _range_slider( 

6617 st, 

6618 "Size", 

6619 key="global_marker_size_range", 

6620 persist_state="session", 

6621 min_value=4, 

6622 max_value=40, 

6623 disabled=_dis, 

6624 help=_gated_help(size_text, _reason), 

6625 field_host=_sub_row("Size", caption_help=size_help), 

6626 ) 

6627 _render_duration_scale_rows() 

6628 if comparing: 

6629 _compare_opacity_slider(0) 

6630 # Scanpath B's group, straight under A's. 

6631 _render_compare_fix_styles(uniform=color_by == UNIFORM_COLOR_FIELD) 

6632 else: 

6633 _, opac_help = _layer_gate( 

6634 _dis, 

6635 _gated_help( 

6636 "Marker opacity; lower it to see overlapping fixations.", 

6637 _reason, 

6638 ), 

6639 ) 

6640 _numeric_slider( 

6641 st, 

6642 "Opacity", 

6643 key="global_fixation_opacity", 

6644 persist_state="session", 

6645 min_value=0.1, 

6646 max_value=1.0, 

6647 step=0.05, 

6648 slider_format="%.2f", 

6649 disabled=_dis, 

6650 help=_gated_help( 

6651 "Marker opacity; lower it to see overlapping fixations.", 

6652 _reason, 

6653 ), 

6654 field_host=_sub_row("Opacity", caption_help=opac_help), 

6655 ) 

6656 # The fixations' own colour bar, after the marker groups — idle unless the colour-by column is 

6657 # numeric, since a discrete palette has no scale to show. 

6658 _render_colorbar_rows( 

6659 "fixation", 

6660 disabled=metric_disabled or raw_cmin is None, 

6661 reason=metric_reason, 

6662 ) 

6663 # PRE-3: vertical drift correction. Snap each fixation to its assigned 

6664 # text line using one of the Carr et al. (2021) algorithms; "Off" 

6665 # leaves the raw coordinates. VIZ-23 hoisted the correction above the 

6666 # render-mode split in `tabs.py`, so the *algorithm* now applies on all 

6667 # three paths. The CONNECTORS don't: only `make_scanpath_figure` has a 

6668 # connector layer, and drawing a full-length "original position" layer 

6669 # from frame zero would misread as part of the replay's trail. 

6670 static_disabled, static_reason = _mode_gate( 

6671 animating, comparing, **_static_only 

6672 ) 

6673 # PRE-21: not fully integrated, so hidden unless SCANPATH_EXPERIMENTAL 

6674 # is set. The keys keep their defaults ("Off"), so nothing downstream 

6675 # needs a second gate to render correctly. 

6676 if drift_correction_enabled(): 

6677 align_algo = _labeled( 

6678 st, 

6679 "selectbox", 

6680 "Drift correction", 

6681 options=_ALIGN_OPTIONS, 

6682 key="global_align_algorithm", 

6683 persist_state="session", 

6684 help="Move each fixation vertically onto the text line an " 

6685 "algorithm assigns it to (Carr et al., 2021). Off: as recorded. " 

6686 f"{ICONS['line_assignment']} Line assignment compares the " 

6687 "algorithms.", 

6688 ) 

6689 if align_algo != "Off": 

6690 _labeled( 

6691 st, 

6692 "checkbox", 

6693 "Show drift connectors", 

6694 key="global_align_connectors", 

6695 persist_state="session", 

6696 disabled=static_disabled, 

6697 help=_gated_help( 

6698 "A faint line from each fixation's recorded position to " 

6699 "its corrected one.", 

6700 static_reason, 

6701 ), 

6702 ) 

6703 # UX-155: the switch, the label colour and the label size share one 

6704 # row, the last two greyed while the switch is off (never hidden, and 

6705 # never rewritten — a disabled widget keeps its key). The size is a 

6706 # number box rather than UX-9's slider + box: three controls do not 

6707 # leave a slider enough width to be draggable. 

6708 order_disabled, order_help = _layer_gate( 

6709 False, "Number each fixation by its order in the trial." 

6710 ) 

6711 label_w = _label_w() 

6712 rest = 1.0 - label_w 

6713 # UX-158: the checkbox says what it does ("Show") and the number box is 

6714 # captioned, so the row reads without hovering. 

6715 label_col, check_col, color_col, size_cap_col, size_col = st.columns( 

6716 [label_w, rest * 0.26, rest * 0.2, rest * 0.18, rest * 0.36], 

6717 gap=_LABEL_GAP, 

6718 vertical_alignment="center", 

6719 ) 

6720 _row_label(label_col, "Fixation index", order_help) 

6721 show_order = check_col.checkbox( 

6722 "Show", 

6723 key="global_show_order", 

6724 persist_state="session", 

6725 disabled=order_disabled, 

6726 ) 

6727 # In Compare (and in a dual animation) the index labels are tinted to 

6728 # each scanpath's own colour, so the global colour is inert there. 

6729 _dis, _reason = _mode_gate(animating, comparing, **_no_compare) 

6730 _dis, _tip = _layer_gate( 

6731 _dis or not show_order, 

6732 _gated_help("Fixation-index label color.", _reason), 

6733 ) 

6734 color_col.color_picker( 

6735 "Index label color", 

6736 key="global_order_font_color", 

6737 persist_state="session", 

6738 disabled=_dis, 

6739 help=_tip, 

6740 label_visibility="collapsed", 

6741 ) 

6742 # A shadow box (`__num`) writing the canonical key, as UX-9's boxes do: 

6743 # the rail's CSS drops a `__num` box's steppers, and the canonical key is 

6744 # what deep links, Share and restore read. Rounded, since a link or a 

6745 # restored config can hand back a float and the box has int bounds. 

6746 size_key = "global_order_font_size" 

6747 size_num_key = f"{size_key}__num" 

6748 if size_key in st.session_state: 

6749 st.session_state[size_num_key] = round(st.session_state[size_key]) 

6750 

6751 def _apply_index_size() -> None: 

6752 if _shadow_key_missing(size_num_key): # BUG-18 

6753 return 

6754 st.session_state[size_key] = st.session_state[size_num_key] 

6755 

6756 _dis, _tip = _layer_gate( 

6757 not show_order, 

6758 "Index label size, in px.", 

6759 ) 

6760 _sub_caption(size_cap_col, "Size") 

6761 size_col.number_input( 

6762 "Index label size", 

6763 key=size_num_key, 

6764 min_value=6, 

6765 max_value=72, 

6766 step=1, 

6767 on_change=_apply_index_size, 

6768 disabled=_dis, 

6769 help=_tip, 

6770 label_visibility="collapsed", 

6771 ) 

6772 # "Snap above words" is the fixation half of VIZ-9 (its 

6773 # partner is Saccades → Style → Line shape → Arc). Keep the control 

6774 # for saved-view compatibility, but do not give it a separate 

6775 # "Linear-reading schematic" heading in this already compact panel. 

6776 # Still `make_scanpath_figure`-only (VIZ-9's `fixation_snap_to_word`), 

6777 # unlike the drift correction — hence its own gate. UX-156 moved it to the 

6778 # bottom of the panel: it is a schematic mode, not marker styling. 

6779 _labeled( 

6780 st, 

6781 "checkbox", 

6782 "Snap above words", 

6783 key="global_fixation_snap_to_word", 

6784 persist_state="session", 

6785 disabled=static_disabled, 

6786 help=_gated_help( 

6787 "Draw each fixation above its word, not at its recorded position.", 

6788 static_reason, 

6789 ), 

6790 ) 

6791 

6792 # VIZ-27: filtering decides which fixations are visible; it is not marker 

6793 # appearance. Keep it beside the fixation layer as a first-class popover and 

6794 # show a local badge so an active Discard cannot be forgotten. A chip in the 

6795 # trial-fact strip was rejected because this is a view setting, not trial data. 

6796 # CMP-24: every builder honours the flags now — Compare draws each scanpath 

6797 # under its own set — so nothing greys this block any more. 

6798 _flag_dis, _flag_reason = False, "" 

6799 # UX-162: the subsection says only why it is greyed, when it is; what it does 

6800 # is in the rows' tooltips now, beside the controls it describes. 

6801 with ( 

6802 _rail_subsection( 

6803 filter_fix_slot, 

6804 f"{ICONS['fixations']} Fixations{ab}{_fixation_filter_badge()}", 

6805 note=_flag_reason, 

6806 ), 

6807 _layer_off( 

6808 f"{ICONS['fixations']} Fixations", 

6809 off=not (show_fix or fix_off_disabled) or not has_fixations, 

6810 reason=no_fixations_note or None, 

6811 # The Filters & highlights section's own note already said it. 

6812 caption=has_fixations, 

6813 ), 

6814 _popover_rows("filter_fix"), 

6815 ): 

6816 # VIZ-27 follow-up: the index window removes fixations just like the 

6817 # short/long/OOB rules, so it belongs here rather than under marker style. 

6818 # The max follows the selected trial (BUG-16). 

6819 _render_fix_range_slider(fix_range_fixations) 

6820 _render_fixation_cleaning(disabled=_flag_dis, reason=_flag_reason) 

6821 

6822 # --- Saccades --------------------------------------------------------- 

6823 # UX-159: laid out like 👁️ Fixations (UX-158) — one *Line* group (colour, 

6824 # style, width, shape) with a caption per row, then *Direction arrows* as a 

6825 # `label | ☑ Show` row. 

6826 with ( 

6827 sac_grp, 

6828 _layer_off( 

6829 f"{ICONS['saccades']} Saccades", 

6830 off=not show_saccades or not has_fixations, 

6831 reason=no_fixations_note or None, 

6832 ), 

6833 _popover_rows("sac"), 

6834 ): 

6835 # VIZ-8 / VIZ-19: uniform colour, the two-way forward-vs-regression 

6836 # split, or the full reading-class breakdown. Reading-class colouring 

6837 # is a `make_scanpath_figure` feature — the animation draws one 

6838 # uniform saccade colour and the comparison overlay one colour per 

6839 # scanpath — so the mode picker greys out in both. 

6840 class_disabled, class_reason = _mode_gate(animating, comparing, **_static_only) 

6841 # The single uniform colour, style and width: honoured by the static 

6842 # figure and the animation; Compare paints each scanpath in its own 

6843 # colour and style instead (see "Per-scanpath (comparison)" below). 

6844 _dis, _reason = _mode_gate(animating, comparing, **_no_compare) 

6845 mode_disabled, mode_help = _layer_gate( 

6846 class_disabled, 

6847 _gated_help( 

6848 "Uniform: one color. Forward / regression: two colors. By type: " 

6849 "forward, skip, refixation, return sweep and regression.", 

6850 class_reason, 

6851 ), 

6852 ) 

6853 # In Compare this group *is* scanpath A's, as the Fixations popover's 

6854 # *Marker* group is: its colour, style and width rows write A's 

6855 # `cmp0_*` keys, and scanpath B's group follows the shared Shape row. 

6856 field = _sub_row( 

6857 "Color", 

6858 section=_COMPARE_SCANPATHS[0][1] if comparing else "Line", 

6859 section_help=( 

6860 _COMPARE_SCANPATH_HELP[0] + " Color, style and width are its " 

6861 "own; shape and direction arrows are shared." 

6862 if comparing 

6863 else "How saccades are drawn." 

6864 ), 

6865 caption_help=mode_help, 

6866 section_share=_COMPARE_SECTION_SHARE if comparing else 0.45, 

6867 ) 

6868 mode_col, swatch_col = field.columns( 

6869 [0.6, 0.4], gap=_LABEL_GAP, vertical_alignment="center" 

6870 ) 

6871 color_mode = mode_col.selectbox( 

6872 "Saccade color", 

6873 options=SACCADE_COLOR_MODES, 

6874 key="global_saccade_color_mode", 

6875 persist_state="session", 

6876 disabled=mode_disabled, 

6877 help=mode_help, 

6878 label_visibility="collapsed", 

6879 ) 

6880 # In Animate / Compare the class breakdown never draws, so the slot 

6881 # keeps the uniform swatch rather than showing five dead class ones. 

6882 if comparing: 

6883 _compare_saccade_color_picker(swatch_col, 0) 

6884 elif color_mode == "Uniform" or class_disabled: 

6885 swatch_disabled, swatch_help = _layer_gate( 

6886 _dis, 

6887 _gated_help( 

6888 "Color of the saccade lines and direction arrows.", _reason 

6889 ), 

6890 ) 

6891 swatch_col.color_picker( 

6892 "Line color", 

6893 key="global_saccade_color", 

6894 persist_state="session", 

6895 disabled=swatch_disabled, 

6896 help=swatch_help, 

6897 label_visibility="collapsed", 

6898 ) 

6899 else: 

6900 # VIZ-19: the two-way mode reuses the same class colours, so only 

6901 # the pickers it actually draws with, three to a row. 

6902 classes = ( 

6903 list(SACCADE_DIRECTION_CLASSES) 

6904 if color_mode == "Forward / regression" 

6905 else list(SACCADE_CLASS_EDITABLE) 

6906 ) 

6907 classes_help = ( 

6908 "Saccades classed by where they land relative to the fixation they " 

6909 "leave." 

6910 if color_mode == "By type" 

6911 else "Skips, refixations and return sweeps count as forward; " 

6912 "saccades out of bounds are Other." 

6913 ) 

6914 swatch_disabled, _ = _layer_gate(False, None) 

6915 for start in range(0, len(classes), 3): 

6916 row = _sub_row( 

6917 "Types" if start == 0 else None, caption_help=classes_help 

6918 ) 

6919 for col, cls_name in zip( 

6920 row.columns(3, gap=_LABEL_GAP), classes[start : start + 3] 

6921 ): 

6922 # `persist_state` is what keeps a picker first drawn in a 

6923 # popover from mounting at its proto default (black) while 

6924 # the figure draws the stored colour (BUG-15 / ENG-36). 

6925 col.color_picker( 

6926 SACCADE_CLASS_LABELS[cls_name], 

6927 key=f"global_saccade_class_color_{cls_name}", 

6928 persist_state="session", 

6929 disabled=swatch_disabled, 

6930 ) 

6931 _, legend_help = _layer_gate( 

6932 False, 

6933 "The saccade-type color key on the plot.", 

6934 ) 

6935 _sub_row("Legend", caption_help=legend_help).checkbox( 

6936 "Show", 

6937 key="global_saccade_type_legend", 

6938 persist_state="session", 

6939 disabled=swatch_disabled, 

6940 ) 

6941 if comparing: 

6942 _compare_saccade_line_rows(0) 

6943 else: 

6944 # A selectbox, not UX-80's segmented control: four segments do not fit 

6945 # beside a caption, and a wrapped control reads as two settings. 

6946 if st.session_state.get("global_saccade_style") not in SACCADE_DASH_OPTIONS: 

6947 st.session_state["global_saccade_style"] = "Solid" 

6948 style_disabled, style_help = _layer_gate( 

6949 _dis, _gated_help("Line style for the saccade traces.", _reason) 

6950 ) 

6951 _sub_row("Style", caption_help=style_help).selectbox( 

6952 "Saccade line style", 

6953 options=list(SACCADE_DASH_OPTIONS.keys()), 

6954 key="global_saccade_style", 

6955 persist_state="session", 

6956 disabled=style_disabled, 

6957 help=style_help, 

6958 label_visibility="collapsed", 

6959 ) 

6960 _, width_help = _layer_gate( 

6961 _dis, _gated_help("Thickness of the saccade lines. Default 2.", _reason) 

6962 ) 

6963 _numeric_slider( 

6964 st, 

6965 "Saccade line width", 

6966 key="global_saccade_width", 

6967 persist_state="session", 

6968 min_value=SACCADE_WIDTH_BOUNDS[0], 

6969 max_value=SACCADE_WIDTH_BOUNDS[1], 

6970 step=0.5, 

6971 slider_format="%.1f px", 

6972 number_format="%.1f", 

6973 disabled=_dis, 

6974 help=_gated_help("Thickness of the saccade lines. Default 2.", _reason), 

6975 field_host=_sub_row("Width", caption_help=width_help), 

6976 ) 

6977 # VIZ-9: "linear reading" schematic — arched saccades. Its paired 

6978 # control, "Snap above words", remains under Fixations because it moves 

6979 # fixations. Arcs are a `make_scanpath_figure` feature. 

6980 shape_disabled, shape_help = _layer_gate( 

6981 class_disabled, 

6982 _gated_help( 

6983 "Straight connectors, or upward **arcs** over the text (the " 

6984 f"classic linear-reading diagram). Pairs with {ICONS['fixations']} Fixations ▾ → " 

6985 "**Snap above words**.", 

6986 class_reason, 

6987 ), 

6988 ) 

6989 _sub_row("Shape", caption_help=shape_help).segmented_control( 

6990 "Line shape", 

6991 options=["Straight", "Arc"], 

6992 key="global_saccade_render_mode", 

6993 persist_state="session", 

6994 disabled=shape_disabled, 

6995 help=shape_help, 

6996 label_visibility="collapsed", 

6997 ) 

6998 if comparing: 

6999 # Scanpath B's group, under A's (whose last row is the shared Shape). 

7000 _render_compare_saccade_styles() 

7001 # VIZ-23 gave `make_scanpath_animation` an arrow layer of its own (each 

7002 # arrowhead un-masks with the saccade it belongs to), so direction 

7003 # arrows reach all three builders. 

7004 _check_row( 

7005 "Direction arrows", 

7006 key="global_show_saccade_arrows", 

7007 persist_state="session", 

7008 help="An arrowhead on each saccade, pointing in the gaze direction.", 

7009 ) 

7010 

7011 # VIZ-31: the Saccades section's *filter* sub-section, the counterpart to the 

7012 # fixation one above — which reading classes are drawn at all, as opposed to 

7013 # what colour they are drawn in. "Show only the regressions" is the figure a 

7014 # reading paper asks for, and until now the only way to approximate it was to 

7015 # colour the other four classes to match the background. Static-only for the 

7016 # same reason the class *colouring* is: the classification never reaches the 

7017 # animation or comparison builders (see CLAUDE.md's render-path table), so the 

7018 # picker greys out there rather than silently dropping the filter. 

7019 # CMP-24: the comparison builders honour the class filter now; only the 

7020 # animation still has no classification to filter on. 

7021 _cls_dis, _cls_reason = _mode_gate(animating, comparing, in_animation=False) 

7022 with ( 

7023 _rail_subsection( 

7024 filter_sac_slot, 

7025 f"{ICONS['saccades']} Saccades{ab}{_saccade_filter_badge()}", 

7026 note=_cls_reason, 

7027 ), 

7028 _layer_off( 

7029 f"{ICONS['saccades']} Saccades", 

7030 off=not show_saccades or not has_fixations, 

7031 reason=no_fixations_note or None, 

7032 caption=has_fixations, 

7033 ), 

7034 _popover_rows("filter_sac"), 

7035 ): 

7036 _labeled( 

7037 st, 

7038 "multiselect", 

7039 "Show saccade types", 

7040 display="Types", 

7041 options=SACCADE_CLASS_ORDER, 

7042 format_func=lambda cls: SACCADE_CLASS_LABELS[cls], 

7043 key="global_saccade_classes", 

7044 persist_state="session", 

7045 disabled=_cls_dis, 

7046 help=_gated_help( 

7047 "Draw only saccades of these types (Other: starts or lands off " 

7048 "the text). Empty draws all.", 

7049 _cls_reason, 

7050 ), 

7051 ) 

7052 

7053 # --- Stimulus --------------------------------------------------------- 

7054 # UX-163: the Fixations layout (UX-158). Each of the section's layers is a 

7055 # `label | ☑ Show` row — *Text*, *Image* — with what it governs as 

7056 # captioned rows under it, greyed while it is off (UX-97) rather than 

7057 # hidden; the span highlight is a *Highlight* row of its own. Word boxes 

7058 # have their own section now, and the hover fields moved to 

7059 # 📐 Figure & canvas → Hover. 

7060 # 

7061 # UX-128: the layer switches and their settings stay live while the 

7062 # section's master switch is off, so a user can set up what they want shown 

7063 # before turning it back on — nothing here mutates them; `_collect_viz_ 

7064 # settings` only ANDs the master into what reaches the figure. 

7065 with stim_grp, _popover_rows("stim"): 

7066 if not show_stimulus: 

7067 st.caption( 

7068 f"{ICONS['warning']} **{ICONS['stimulus']} Stimulus** is off — " 

7069 "nothing below shows in the plot. Your settings are kept either way." 

7070 ) 

7071 show_labels, _ = _check_row( 

7072 "Text", 

7073 key="global_show_labels", 

7074 persist_state="session", 

7075 help="Draw the reading text.", 

7076 ) 

7077 # UX-81: the typography that draws this text lives beside the layer 

7078 # that draws it. Reserved here and filled by the single 

7079 # `canvas_renderer` call in the 📐 Figure & canvas block below — one 

7080 # call draws both halves, since a widget drawn twice is a duplicate-key 

7081 # error. Keyed so `styles.py` spaces its rows like the popover's own. 

7082 stim_text_slot = st.container(key="rail_rows_stim_text") 

7083 

7084 # "Highlight a span": the canonical value stays in 

7085 # `global_critical_span_style` ("Mark text" | "Mark border" | "None"), 

7086 # so deep links / Share / restore are unchanged — the on/off and the 

7087 # mode widgets are derived from it each run, and their callbacks write 

7088 # it back on interaction. 

7089 canonical = st.session_state.get("global_critical_span_style", "Mark text") 

7090 

7091 def _on_span_toggle(): 

7092 st.session_state["global_critical_span_style"] = ( 

7093 st.session_state.get("global_highlight_span_mode", "Mark text") 

7094 if st.session_state["global_highlight_span_on"] 

7095 else "None" 

7096 ) 

7097 

7098 def _on_span_mode(): 

7099 st.session_state["global_critical_span_style"] = st.session_state[ 

7100 "global_highlight_span_mode" 

7101 ] 

7102 

7103 st.session_state["global_highlight_span_on"] = canonical != "None" 

7104 if canonical in ("Mark text", "Mark border"): 

7105 st.session_state["global_highlight_span_mode"] = canonical 

7106 else: 

7107 st.session_state.setdefault("global_highlight_span_mode", "Mark text") 

7108 

7109 # VIZ-23: the span's *text*-marking channel reaches all three builders — 

7110 # the animation and the comparison figure take `highlight_column` + 

7111 # `highlight_text_color` and recolour the word labels. Neither has a 

7112 # border-overlay layer, so **Mark border** stays a `make_scanpath_figure` 

7113 # feature and only its colour picker is gated (`tabs._marked_text_column` 

7114 # hands the other two builders `None` under that style, so nothing is 

7115 # marked there rather than silently falling back to text marking). 

7116 border_disabled, border_reason = _mode_gate( 

7117 animating, comparing, **_static_only 

7118 ) 

7119 span_on, span_rest = _check_row( 

7120 "Highlight", 

7121 key="global_highlight_span_on", 

7122 persist_state="session", 

7123 on_change=_on_span_toggle, 

7124 help="Mark the words where the chosen true/false column is true " 

7125 "(OneStop: its answer span).", 

7126 ) 

7127 span_off_disabled, _ = _layer_gate(not span_on, None) 

7128 if highlight_options: 

7129 highlight_labels = cn.active(st.session_state, "words").option_labels( 

7130 highlight_options 

7131 ) 

7132 span_rest.selectbox( 

7133 "Highlight words by", 

7134 options=highlight_options, 

7135 format_func=highlight_labels.__getitem__, 

7136 key="global_highlight_column", 

7137 persist_state="session", 

7138 disabled=span_off_disabled, 

7139 label_visibility="collapsed", 

7140 placeholder="Choose a column", 

7141 # #374 F6: nothing is seeded outside the demo, and an unseeded 

7142 # selectbox would otherwise pick its first option itself. 

7143 **( 

7144 {} 

7145 if "global_highlight_column" in st.session_state 

7146 else {"index": None} 

7147 ), 

7148 ) 

7149 style_help = ( 

7150 "**Mark text**: color the span's words (needs **Text** on). " 

7151 "**Mark border**: outline the span. The box beside it is the color." 

7152 + ( 

7153 f"\n\n{ICONS['warning']} **Mark border** is drawn on the static " 

7154 "figure only." 

7155 if border_disabled 

7156 else "" 

7157 ) 

7158 ) 

7159 style_field = _sub_row("Style", caption_help=style_help) 

7160 mode_col, span_color_col = style_field.columns( 

7161 [0.75, 0.25], gap=_LABEL_GAP, vertical_alignment="center" 

7162 ) 

7163 span_mode = mode_col.radio( 

7164 "Style", 

7165 options=["Mark text", "Mark border"], 

7166 horizontal=True, 

7167 key="global_highlight_span_mode", 

7168 persist_state="session", 

7169 on_change=_on_span_mode, 

7170 disabled=span_off_disabled, 

7171 label_visibility="collapsed", 

7172 ) 

7173 critical_span_style = span_mode if span_on else "None" 

7174 st.session_state["global_critical_span_style"] = critical_span_style 

7175 if span_mode == "Mark border": 

7176 span_color_col.color_picker( 

7177 "Border color", 

7178 key="global_span_border_color", 

7179 persist_state="session", 

7180 disabled=span_off_disabled or border_disabled, 

7181 help=_gated_help( 

7182 "Color of the span outline (used with 'Mark border').", 

7183 border_reason, 

7184 ), 

7185 label_visibility="collapsed", 

7186 ) 

7187 else: 

7188 span_color_col.color_picker( 

7189 "Highlighted text color", 

7190 key="global_highlight_text_color", 

7191 persist_state="session", 

7192 disabled=span_off_disabled, 

7193 label_visibility="collapsed", 

7194 ) 

7195 

7196 # VIZ-4: a stimulus image can come from the dataset (MultiplEYE stamps a 

7197 # per-trial `image_path`) OR be uploaded here for any dataset (a 

7198 # full-monitor screenshot of the reading screen). The upload's `data:` 

7199 # URI is stashed in session for the tab to place + the (pure) collector 

7200 # to read; the switch is enabled whenever either source exists. 

7201 uploaded_img = st.session_state.get("global_stimulus_image_upload") 

7202 upload_uri = _uploaded_image_data_uri(uploaded_img) 

7203 st.session_state["_stimulus_image_upload_uri"] = upload_uri 

7204 can_show_image = has_stimulus_image or upload_uri is not None 

7205 # VIZ-23: `background_image*` are parameters of all three builders — the 

7206 # comparison figure places one `layout.image` per panel in the split 

7207 # layouts — so the whole image group is live in every mode. 

7208 show_stim_image, _ = _check_row( 

7209 "Image", 

7210 key="global_show_stimulus_image", 

7211 persist_state="session", 

7212 disabled=not can_show_image, 

7213 help="The stimulus page behind the scanpath: the dataset's own image, " 

7214 "or one you upload." 

7215 + ("" if can_show_image else " Upload one below to switch it on."), 

7216 ) 

7217 # The uploader is never greyed: it is the only way to get an image in 

7218 # and enable the switch in the first place. 

7219 _sub_row( 

7220 "File", 

7221 caption_help="Upload a screenshot of the reading screen. It replaces " 

7222 "the dataset's image and is stretched to the monitor; " 

7223 "offset and scale below align it. Not included in Share " 

7224 "links.", 

7225 ).file_uploader( 

7226 "Upload a stimulus image", 

7227 type=["png", "jpg", "jpeg", "gif", "webp"], 

7228 # No persist_state: st.file_uploader does not take it, and an 

7229 # UploadedFile is already stashed by _uploaded_image_data_uri. 

7230 key="global_stimulus_image_upload", 

7231 max_upload_size=upload_limit_mb(), 

7232 label_visibility="collapsed", 

7233 ) 

7234 # The placement rows need an image to place: with none loaded they 

7235 # could never come alive, so they wait for one rather than sit greyed. 

7236 # Loaded but switched off, they grey (UX-97). 

7237 if can_show_image: 

7238 image_idle = not show_stim_image 

7239 opacity_help = "Image opacity." 

7240 _numeric_slider( 

7241 st, 

7242 "Image opacity", 

7243 key="global_stimulus_image_opacity", 

7244 persist_state="session", 

7245 min_value=0.1, 

7246 max_value=1.0, 

7247 step=0.05, 

7248 number_format="%.2f", 

7249 disabled=image_idle, 

7250 help=opacity_help, 

7251 field_host=_sub_row("Opacity", caption_help=opacity_help), 

7252 ) 

7253 # VIZ-4: manual alignment. When the data's coordinates don't match the 

7254 # image exactly, nudge it (X/Y px) and scale it to line it up with the 

7255 # word boxes and fixations. Dataset and uploaded images alike. 

7256 offset = _sub_row( 

7257 "Offset", 

7258 caption_help="Shift the image right (X) and down (Y), in px, to " 

7259 "align it with the text.", 

7260 ) 

7261 x_cap, x_col, y_cap, y_col = offset.columns( 

7262 [0.1, 0.4, 0.1, 0.4], gap=_LABEL_GAP, vertical_alignment="center" 

7263 ) 

7264 _sub_caption(x_cap, "X") 

7265 x_col.number_input( 

7266 "Image X offset (px)", 

7267 step=5.0, 

7268 key="global_stimulus_image_offset_x", 

7269 persist_state="session", 

7270 disabled=image_idle, 

7271 label_visibility="collapsed", 

7272 ) 

7273 _sub_caption(y_cap, "Y") 

7274 y_col.number_input( 

7275 "Image Y offset (px)", 

7276 step=5.0, 

7277 key="global_stimulus_image_offset_y", 

7278 persist_state="session", 

7279 disabled=image_idle, 

7280 label_visibility="collapsed", 

7281 ) 

7282 scale_help = ( 

7283 "Scale the image so its text matches the word boxes (1 = as placed)." 

7284 ) 

7285 _numeric_slider( 

7286 st, 

7287 "Image scale", 

7288 key="global_stimulus_image_scale", 

7289 persist_state="session", 

7290 min_value=0.25, 

7291 max_value=3.0, 

7292 step=0.05, 

7293 number_format="%.2f", 

7294 disabled=image_idle, 

7295 help=scale_help, 

7296 field_host=_sub_row("Scale", caption_help=scale_help), 

7297 ) 

7298 

7299 # --- Heatmap ---------------------------------------------------------- 

7300 # Compare supports a shared word-box scale: overlay splits each box into 

7301 # A/B halves; side-by-side and stacked tint their respective full boxes. 

7302 # Animation remains disabled because a time-varying density layer would 

7303 # need a distinct frame contract. The toggle itself is on the section's 

7304 # row now (UX-86); this block only owns the style popover's contents. 

7305 # UX-160: the Fixations layout (UX-158) — a *Style* row (the style picker, 

7306 # plus Duration mass's spread, greyed for the other two styles), then one 

7307 # *Color* group: what is mapped and its colorscale, the scaling, the range. 

7308 with ( 

7309 heatmap_grp, 

7310 _layer_off( 

7311 f"{ICONS['heatmap']} Heatmap", 

7312 off=not show_heatmap or heat_nothing, 

7313 reason=heat_nothing_note if heat_nothing else None, 

7314 ), 

7315 _popover_rows("heatmap"), 

7316 ): 

7317 # A selectbox now rather than a radio: three long options do not fit 

7318 # one line beside a title, and a wrapped radio reads as two settings. 

7319 # `persist_state` shows the seeded style on first open — the reason 

7320 # this was a radio, not a segmented control, before ENG-36. 

7321 style_disabled, style_help = _layer_gate( 

7322 heat_disabled or comparing, 

7323 _gated_help( 

7324 "Word boxes: color each word box by its fixations. Interpolated: " 

7325 "the fixations blurred with a Gaussian (Blur, below), scaled to " 

7326 "the figure's own peak. Compare always uses word boxes.", 

7327 "Comparison heatmaps use split word boxes." 

7328 if comparing 

7329 else heat_reason, 

7330 ), 

7331 ) 

7332 heat_style = _sub_row( 

7333 None, 

7334 section="Style", 

7335 section_help=style_help, 

7336 ).selectbox( 

7337 "Style", 

7338 options=["Word boxes", "Interpolated"], 

7339 key="global_heatmap_style", 

7340 persist_state="session", 

7341 disabled=style_disabled, 

7342 help=style_help, 

7343 label_visibility="collapsed", 

7344 ) 

7345 _render_heatmap_blur_row( 

7346 trial_fixations, 

7347 words, 

7348 disabled=heat_disabled or comparing or heat_style != "Interpolated", 

7349 reason="Comparison heatmaps use split word boxes." 

7350 if comparing 

7351 else heat_reason, 

7352 ) 

7353 metric_disabled_h, metric_help = _layer_gate( 

7354 heat_disabled, 

7355 _gated_help( 

7356 "What the heatmap shows, and its color scale.", 

7357 heat_reason, 

7358 ), 

7359 ) 

7360 field = _sub_row( 

7361 "By", 

7362 section="Color", 

7363 section_help="What the heatmap colors by and how.", 

7364 caption_help=metric_help, 

7365 ) 

7366 # In Compare each scanpath picks its own colour scale (its group, 

7367 # below), so the metric takes the whole row. 

7368 metric_col, scale_col = ( 

7369 (field, None) 

7370 if comparing 

7371 else field.columns([0.5, 0.5], gap=_LABEL_GAP, vertical_alignment="center") 

7372 ) 

7373 metric_labels = _rail_names().option_labels( 

7374 ["duration_ms", "counts"], {"counts": "Fixation count"}, roles=True 

7375 ) 

7376 heatmap_metric = metric_col.selectbox( 

7377 "Metric", 

7378 options=["duration_ms", "counts"], 

7379 format_func=metric_labels.__getitem__, 

7380 key="global_heatmap_metric", 

7381 persist_state="session", 

7382 # A pinned range is in the old metric's units: back to auto. 

7383 on_change=forget_color_range, 

7384 args=("global_heatmap_color_range",), 

7385 disabled=metric_disabled_h, 

7386 help=metric_help, 

7387 label_visibility="collapsed", 

7388 ) 

7389 if scale_col is not None: 

7390 # Keyless on purpose — see `_popover_selectbox`. 

7391 current_scale = st.session_state.get("global_heatmap_colorscale") 

7392 st.session_state["global_heatmap_colorscale"] = scale_col.selectbox( 

7393 "Colors", 

7394 COLORSCALES, 

7395 index=COLORSCALES.index(current_scale) 

7396 if current_scale in COLORSCALES 

7397 else 0, 

7398 disabled=metric_disabled_h, 

7399 help=metric_help, 

7400 label_visibility="collapsed", 

7401 ) 

7402 norm_disabled, norm_help = _layer_gate( 

7403 heat_disabled, 

7404 _gated_help( 

7405 "Linear: color follows the value. Log: color follows log(1 + " 

7406 "value), so a few high values don't wash out the rest.", 

7407 heat_reason, 

7408 ), 

7409 ) 

7410 # `required` — a radio before UX-160, so it could never be deselected; 

7411 # an empty segmented control would read "nothing" while the figure 

7412 # draws Linear (the collector's fallback). 

7413 _sub_row("Scale", caption_help=norm_help).segmented_control( 

7414 "Scaling", 

7415 options=["Linear", "Log"], 

7416 required=True, 

7417 key="global_heatmap_norm", 

7418 persist_state="session", 

7419 disabled=norm_disabled, 

7420 help=norm_help, 

7421 label_visibility="collapsed", 

7422 ) 

7423 # Finding 11: bounded by what a word box maps — its summed dwell — 

7424 # not by the longest single fixation, which refixations exceed. 

7425 # The dwell groupby runs only while the heatmap is shown; switched off, 

7426 # the greyed range is drawn from the single-fixation span instead. 

7427 counts = heatmap_metric == "counts" 

7428 heat_bounds = ( 

7429 _heatmap_bounds_for_rail(trial_fixations, words, counts=counts) 

7430 if show_heatmap 

7431 else _cheap_heatmap_bounds(trial_fixations, counts=counts) 

7432 ) 

7433 if heat_bounds is not None: 

7434 # The scale starts at 0 (an empty word), as the figure's auto does. 

7435 hmin = 0.0 

7436 hmax = float(math.ceil(heat_bounds[1])) 

7437 hmax_eff = hmax if hmax > hmin else hmin + 1.0 

7438 range_text = ( 

7439 "Fixations per word" if counts else "Word dwell time (ms)" 

7440 ) + ( 

7441 " at the two ends of the color scale; auto runs from 0 to the " 

7442 "trial's highest. You can type values beyond the slider." 

7443 ) 

7444 # Finding 12: the smoothed styles scale their density to their own 

7445 # peak, so a range does nothing there — greyed, and kept for Word 

7446 # boxes. Compare always draws word boxes, so it applies again. 

7447 self_scaled = not comparing and heat_style in SELF_SCALED_HEATMAP_STYLES 

7448 # VIZ-46: auto (per trial, like the API) until a range is chosen. 

7449 _render_color_range( 

7450 "Color range", 

7451 "global_heatmap_color_range", 

7452 hmin, 

7453 hmax_eff, 

7454 disabled=heat_disabled or self_scaled, 

7455 reason=heat_reason 

7456 or ( 

7457 f"{ICONS['warning']} **{heat_style}** is scaled to each " 

7458 "figure's own peak, so the range does not apply to it." 

7459 if self_scaled 

7460 else "" 

7461 ), 

7462 help=range_text, 

7463 slider_format="%d" if counts else "%d ms", 

7464 field_host=_sub_row("Range", caption_help=range_text), 

7465 ) 

7466 

7467 if comparing: 

7468 # The per-scanpath groups, after the rows both share. 

7469 for idx, _ in _COMPARE_SCANPATHS: 

7470 _compare_heatmap_colorscale_row( 

7471 idx, disabled=heat_disabled, reason=heat_reason 

7472 ) 

7473 _render_colorbar_rows("heatmap", disabled=heat_disabled, reason=heat_reason) 

7474 

7475 # Raw gaze is drawn by the static and comparison builders. The toggle is on 

7476 # the section's row (UX-86); this owns the style popover — previously 

7477 # nothing, since raw gaze had no styling of its own before it got a section. 

7478 # UX-161: one *Marker* group, as in 👁️ Fixations (UX-158). 

7479 with ( 

7480 raw_gaze_grp, 

7481 _layer_off(f"{ICONS['raw_gaze']} Raw gaze", off=not show_raw_gaze), 

7482 _popover_rows("rawgaze"), 

7483 ): 

7484 if comparing: 

7485 # VIZ-48: a comparison colours each reading's samples by its own 

7486 # scanpath (the A/B cue), so the *Marker* group becomes scanpath 

7487 # A's — as in 👁️ Fixations — and B's group follows it. 

7488 _compare_raw_gaze_color_row(0, disabled=raw_disabled) 

7489 else: 

7490 color_disabled, color_help = _layer_gate( 

7491 raw_disabled, 

7492 _gated_help( 

7493 "Not used outside Compare: samples are colored by time (or order).", 

7494 raw_reason, 

7495 ), 

7496 ) 

7497 _sub_row( 

7498 "Color", 

7499 section="Marker", 

7500 section_help="How each raw-gaze sample is drawn: color, size and " 

7501 "opacity.", 

7502 caption_help=color_help, 

7503 ).color_picker( 

7504 "Color", 

7505 key="global_raw_gaze_color", 

7506 persist_state="session", 

7507 disabled=color_disabled, 

7508 help=color_help, 

7509 label_visibility="collapsed", 

7510 ) 

7511 size_help = "Diameter of each raw-gaze sample dot, in px." 

7512 _numeric_slider( 

7513 st, 

7514 "Marker size", 

7515 key="global_raw_gaze_marker_size", 

7516 persist_state="session", 

7517 min_value=1.0, 

7518 max_value=12.0, 

7519 step=0.5, 

7520 disabled=raw_disabled, 

7521 help=size_help, 

7522 field_host=_sub_row("Size", caption_help=_layer_gate(False, size_help)[1]), 

7523 ) 

7524 opacity_help = "Sample opacity; lower it to see dense clusters." 

7525 _numeric_slider( 

7526 st, 

7527 "Opacity", 

7528 key="global_raw_gaze_opacity", 

7529 persist_state="session", 

7530 min_value=0.1, 

7531 max_value=1.0, 

7532 step=0.05, 

7533 number_format="%.2f", 

7534 disabled=raw_disabled, 

7535 help=opacity_help, 

7536 field_host=_sub_row( 

7537 "Opacity", caption_help=_layer_gate(False, opacity_help)[1] 

7538 ), 

7539 ) 

7540 if comparing: 

7541 # Scanpath B's group, under A's (whose size and opacity are shared). 

7542 _compare_raw_gaze_color_row(1, disabled=raw_disabled) 

7543 # --- Word boxes ------------------------------------------------------- 

7544 # The interest areas' outline and fill. One *Box* group, as raw gaze's 

7545 # *Marker* (UX-161). All three render paths draw the boxes; a static 

7546 # comparison outlines and fills each reading's in its own colours. 

7547 with ( 

7548 boxes_grp, 

7549 _layer_off( 

7550 f"{ICONS['word_boxes']} Word boxes", 

7551 off=not show_word_boxes or not has_words, 

7552 reason=None 

7553 if has_words 

7554 else f"{ICONS['warning']} This trial has no word boxes to draw.", 

7555 ), 

7556 _popover_rows("boxes"), 

7557 ): 

7558 # The co-animation (Compare + Animate) draws one set of boxes in these 

7559 # colours; the static comparison outlines and fills each reading's 

7560 # boxes on its own, so there the *Box* group becomes scanpath A's, and 

7561 # scanpath B's follows it — as the Fixations popover does. 

7562 box_section_help = "How each word's box (as given in the data) is drawn." 

7563 fill_text = ( 

7564 "Keep its opacity low so the text, fixations and image under the " 

7565 "boxes still read; 0 draws outlines only." 

7566 ) 

7567 if comparing and not animating: 

7568 _render_compare_box_groups( 

7569 "Defaults to the figure's fill color. " + fill_text 

7570 ) 

7571 else: 

7572 line_disabled, line_help = _layer_gate( 

7573 False, "Color of each word box's outline." 

7574 ) 

7575 color_col, opacity_col = _sub_row( 

7576 "Line", 

7577 section="Box", 

7578 section_help=box_section_help, 

7579 caption_help=line_help, 

7580 ).columns(_COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center") 

7581 color_col.color_picker( 

7582 "Line color", 

7583 key="global_word_box_color", 

7584 persist_state="session", 

7585 disabled=line_disabled, 

7586 help=line_help, 

7587 label_visibility="collapsed", 

7588 ) 

7589 _box_opacity( 

7590 opacity_col, 

7591 key="global_word_box_line_opacity", 

7592 persist_state="session", 

7593 label="Line opacity", 

7594 help=_LINE_OPACITY_HELP, 

7595 ) 

7596 fill_disabled, fill_help = _layer_gate( 

7597 False, 

7598 "Color the inside of each box is filled with, at the opacity " 

7599 "beside it. " + fill_text, 

7600 ) 

7601 color_col, opacity_col = _sub_row("Fill", caption_help=fill_help).columns( 

7602 _COLOR_OPACITY_W, gap=_LABEL_GAP, vertical_alignment="center" 

7603 ) 

7604 color_col.color_picker( 

7605 "Fill color", 

7606 key="global_word_box_fill_color", 

7607 persist_state="session", 

7608 disabled=fill_disabled, 

7609 help=fill_help, 

7610 label_visibility="collapsed", 

7611 ) 

7612 _box_opacity( 

7613 opacity_col, 

7614 key="global_word_box_fill_opacity", 

7615 persist_state="session", 

7616 label="Fill opacity", 

7617 help=_FILL_OPACITY_HELP, 

7618 ) 

7619 

7620 # --- Figure & canvas -------------------------------------------------- 

7621 # UX-80/81: one popover, four named groups inside it and nothing nested — 

7622 # 

7623 # 🖥️ Screen & framing Crop to data (the monitor's size is the Data 

7624 # page's Recording setup) 

7625 # 📊 Axes & grid the coordinate grid and the axis fields (each 

7626 # colour bar is under its own layer) 

7627 # 🏷️ Title & labels the Illustration disclosure + the EXP-5 title 

7628 # 💬 Hover the word and fixation tooltip fields (moved here 

7629 # from 📄 Stimulus and 👁️ Fixations) 

7630 # 

7631 # 🔤 Text & fonts is **not** here any more: it describes the stimulus text, 

7632 # so it moved to 📄 Stimulus → Text (UX-81), beside the layer it draws. The 

7633 # physical-geometry fields (monitor width in mm, viewing distance, DPI) went 

7634 # with the same pass — they are experiment facts the 🗂️ Data page's Recording 

7635 # setup already owns, and a second set here could disagree with it. 

7636 # 

7637 # The three containers are created up front so each block keeps its place in 

7638 # this file while landing in the right group. 

7639 screen_group = _rail_subsection(figure_grp, f"{ICONS['screen']} Screen & framing") 

7640 axes = _rail_subsection(figure_grp, f"{ICONS['axes']} Axes & grid") 

7641 labels = _rail_subsection(figure_grp, f"{ICONS['labels']} Title & labels") 

7642 hover = _rail_subsection(figure_grp, f"{ICONS['hover']} Hover") 

7643 legends = _rail_subsection(figure_grp, f"{ICONS['legend']} Legends") 

7644 # UX-163: each block's rows take the popover layout (`_popover_rows`) — the 

7645 # framing switch, the grid and the colour bar become `label | ☑ Show | …` 

7646 # rows carrying what they govern (greyed while off), the monitor size and 

7647 # the two axis fields one row each. 

7648 with screen_group, _popover_rows("fig_screen"): 

7649 # The box reads "crop", the wire key "fit to monitor" — its inverse. The 

7650 # box is a shadow re-seeded from the key every run, so links, configs and 

7651 # presets that set the key move it, and only a click writes the key. 

7652 crop_key = "_rail_crop_to_data" 

7653 st.session_state[crop_key] = not st.session_state.get( 

7654 "global_fit_to_monitor", True 

7655 ) 

7656 

7657 def _apply_crop() -> None: 

7658 if _shadow_key_missing(crop_key): # BUG-18 

7659 return 

7660 st.session_state["global_fit_to_monitor"] = not st.session_state[crop_key] 

7661 

7662 _check_row( 

7663 "Frame", 

7664 key=crop_key, 

7665 on_change=_apply_crop, 

7666 check_label="Crop to data", 

7667 check_share=0.6, 

7668 help="Off: show the whole monitor. On: zoom to the fixations and word " 

7669 "boxes, plus a 5% margin.", 

7670 ) 

7671 screen_rows = st.container(key="rail_rows_fig_screen_canvas") 

7672 if canvas_renderer is not None: 

7673 # UX-163: the typography rows always draw, greyed while *Text* is off 

7674 # (`text_disabled`), at the popovers' label width. BUG-38 still holds: 

7675 # they belong to 📄 Stimulus → Text and never fall back into this one. 

7676 with _rail_label_width(_POPOVER_LABEL_W): 

7677 canvas_renderer( 

7678 screen_rows, 

7679 text_host=stim_text_slot, 

7680 text_disabled=not show_labels, 

7681 ) 

7682 

7683 with axes, _popover_rows("fig_axes"): 

7684 show_coordinate_grid, grid_rest = _check_row( 

7685 "Grid", 

7686 key="global_show_coordinate_grid", 

7687 persist_state="session", 

7688 help="A grid of screen coordinates, in monitor pixels. Auto picks the " 

7689 "interval; untick it to set the major interval (px).", 

7690 ) 

7691 grid_off_disabled, _ = _layer_gate(not show_coordinate_grid, None) 

7692 auto_col, spacing_col, px_col = grid_rest.columns( 

7693 [0.4, 0.42, 0.18], gap=_LABEL_GAP, vertical_alignment="center" 

7694 ) 

7695 automatic_grid = auto_col.checkbox( 

7696 "Auto", 

7697 key="global_coordinate_grid_auto", 

7698 persist_state="session", 

7699 disabled=grid_off_disabled, 

7700 ) 

7701 spacing_col.number_input( 

7702 "Major grid interval (px)", 

7703 min_value=10.0, 

7704 max_value=5000.0, 

7705 step=10.0, 

7706 key="global_coordinate_grid_spacing", 

7707 persist_state="session", 

7708 disabled=grid_off_disabled or automatic_grid, 

7709 label_visibility="collapsed", 

7710 ) 

7711 _sub_caption(px_col, "px") 

7712 

7713 # The animation and the comparison figures always plot spatial x/y — 

7714 # only `make_scanpath_figure` takes `x_field`/`y_field`. 

7715 axis_disabled, axis_reason = _mode_gate(animating, comparing, **_static_only) 

7716 axis_disabled, axis_help = _layer_gate( 

7717 axis_disabled, 

7718 _gated_help( 

7719 "The fixation columns on the X and Y axes. Only x / y (screen " 

7720 "position) is fully supported; with any other field the plot " 

7721 "shows fixation markers only — no word boxes, text, saccades, " 

7722 "heatmap or coordinate grid.", 

7723 axis_reason, 

7724 ), 

7725 ) 

7726 label_w = _label_w() 

7727 rest = 1.0 - label_w 

7728 axes_cols = st.columns( 

7729 [label_w, rest * 0.1, rest * 0.4, rest * 0.1, rest * 0.4], 

7730 gap=_LABEL_GAP, 

7731 vertical_alignment="center", 

7732 ) 

7733 _row_label(axes_cols[0], "Axes", axis_help) 

7734 _sub_caption(axes_cols[1], "X") 

7735 axis_labels = _rail_names().option_labels(numeric_fields, roles=True) 

7736 axes_cols[2].selectbox( 

7737 "X axis field", 

7738 options=numeric_fields, 

7739 format_func=axis_labels.__getitem__, 

7740 key="global_x_field", 

7741 persist_state="session", 

7742 disabled=axis_disabled, 

7743 label_visibility="collapsed", 

7744 ) 

7745 _sub_caption(axes_cols[3], "Y") 

7746 axes_cols[4].selectbox( 

7747 "Y axis field", 

7748 options=numeric_fields, 

7749 format_func=axis_labels.__getitem__, 

7750 key="global_y_field", 

7751 persist_state="session", 

7752 disabled=axis_disabled, 

7753 label_visibility="collapsed", 

7754 ) 

7755 if ( 

7756 st.session_state.get("global_x_field", "x"), 

7757 st.session_state.get("global_y_field", "y"), 

7758 ) != ("x", "y"): 

7759 st.caption( 

7760 f"{ICONS['warning']} Limited support: the plot shows fixation " 

7761 "markers only — no word boxes, text, saccades, heatmap or " 

7762 "coordinate grid." 

7763 ) 

7764 

7765 # EXP-5: title/caption on the figure — moved here from being Export-only 

7766 # (EXP-2), so it's visible live rather than a setting a user has to remember 

7767 # to go find under Export. This is now the single source of truth: the 

7768 # Export panel's bulk section reads these two patterns back instead of 

7769 # keeping its own copy, and the live figure on screen (all three render 

7770 # paths) carries the same title/caption a bulk export would produce. 

7771 def _prefill(show_key: str, pattern_key: str, default: str) -> None: 

7772 # Switching one on fills an empty box with a starting pattern, rather 

7773 # than leaving one the user has to know the field syntax to fill. It 

7774 # has to be the switch's callback: `_seed_viz_state` already seeded the 

7775 # pattern to "" this run, so a `setdefault` here would be a no-op. 

7776 if st.session_state.get(show_key) and not st.session_state.get(pattern_key): 

7777 st.session_state[pattern_key] = default 

7778 

7779 with labels, _popover_rows("fig_labels"): 

7780 label_help = ( 

7781 "Auto: label the figure when a setting changes its geometry or data. " 

7782 "Show: always label it. Hide: never." 

7783 ) 

7784 label_mode = _sub_row( 

7785 "Label", 

7786 section="Illustration", 

7787 section_help="A note in the figure's corner saying it is not drawn " 

7788 "exactly as recorded.", 

7789 caption_help=label_help, 

7790 ).selectbox( 

7791 "Illustration label", 

7792 options=["Auto", "Show", "Hide"], 

7793 key="global_illustration_label", 

7794 persist_state="session", 

7795 help=label_help, 

7796 label_visibility="collapsed", 

7797 ) 

7798 text_help = "The label's text. Empty: “Illustration ·” and the reasons." 

7799 _sub_row("Text", caption_help=text_help).text_input( 

7800 "Illustration text", 

7801 key="global_illustration_text", 

7802 persist_state="session", 

7803 placeholder="Illustration · <reasons>", 

7804 disabled=label_mode == "Hide", 

7805 help=text_help, 

7806 label_visibility="collapsed", 

7807 ) 

7808 # EXP-22: the *selected* trial's frames, not the corpus the rail was 

7809 # handed — its tables name the `{table.field}` fields the boxes validate 

7810 # against and the list shows, and "one value per trial" has to be read 

7811 # off one trial. 

7812 _sel_fix = ( 

7813 fix_range_fixations if fix_range_fixations is not None else pd.DataFrame() 

7814 ) 

7815 # Read only while one is shown: with both off, the greyed boxes ask 

7816 # nothing of the fields (`preview=False`), so the default rerun does no 

7817 # title/caption work at all (PERF-7's rule). 

7818 _title_caption_fields = ( 

7819 pattern_fields( 

7820 "p01", 

7821 "t01", 

7822 _trial_rows(words, _sel_fix), 

7823 _sel_fix, 

7824 {}, 

7825 dataset_name=current_dataset_name(), 

7826 metadata_rows=_selected_metadata_rows(_sel_fix), 

7827 # DATA-66: the field list offers the dataset's own names too. 

7828 column_names=_rail_names(), 

7829 ) 

7830 if st.session_state.get("global_show_title") 

7831 or st.session_state.get("global_show_caption") 

7832 else {} 

7833 ) 

7834 any_shown = False 

7835 for name, show_key, pattern_key, default, help_text in ( 

7836 ( 

7837 "Title", 

7838 "global_show_title", 

7839 "global_title_pattern", 

7840 DEFAULT_TITLE_PATTERN, 

7841 "A line of text above the plot. {field} inserts a value of this " 

7842 "trial; the figure grows to make room.", 

7843 ), 

7844 ( 

7845 "Caption", 

7846 "global_show_caption", 

7847 "global_caption_pattern", 

7848 DEFAULT_CAPTION_PATTERN, 

7849 "A line of text below the plot. {field} inserts a value of this " 

7850 "trial; the figure grows to make room.", 

7851 ), 

7852 ): 

7853 shown, rest = _check_row( 

7854 name, 

7855 key=show_key, 

7856 persist_state="session", 

7857 on_change=_prefill, 

7858 args=(show_key, pattern_key, default), 

7859 help=help_text, 

7860 ) 

7861 any_shown = any_shown or shown 

7862 render_pattern_input( 

7863 rest, 

7864 name, 

7865 pattern_key, 

7866 _title_caption_fields, 

7867 help=help_text, 

7868 disabled=not shown, 

7869 label_visibility="collapsed", 

7870 preview=shown, 

7871 ) 

7872 if any_shown: 

7873 render_pattern_help(st.container(), _title_caption_fields) 

7874 

7875 # The tooltips' fields, for words and for fixations — figure-wide rather 

7876 # than one layer's, so they sit together here instead of closing the 

7877 # 📄 Stimulus and 👁️ Fixations popovers. Both are honoured by all three 

7878 # render paths (the comparison builders take them too), so neither carries 

7879 # a `_mode_gate`; and neither greys with its layer, since a hidden layer's 

7880 # tooltip choice is still the one it shows when switched back on. 

7881 with hover, _popover_rows("fig_hover"): 

7882 word_names = cn.active(st.session_state, "words") 

7883 word_hover = word_names.sort_options(hover_field_options(words, words=True)) 

7884 word_hover_labels = word_names.option_labels(word_hover, roles=True) 

7885 _labeled( 

7886 st, 

7887 "multiselect", 

7888 "Word hover fields", 

7889 display="Words", 

7890 options=word_hover, 

7891 format_func=word_hover_labels.__getitem__, 

7892 key="global_word_hover_fields", 

7893 persist_state="session", 

7894 help="Fields shown when hovering a word.", 

7895 ) 

7896 fix_names = _rail_names() 

7897 fix_hover = fix_names.sort_options(hover_field_options(trial_fixations)) 

7898 fix_hover_labels = fix_names.option_labels(fix_hover, roles=True) 

7899 _labeled( 

7900 st, 

7901 "multiselect", 

7902 "Fixation hover fields", 

7903 display="Fixations", 

7904 options=fix_hover, 

7905 format_func=fix_hover_labels.__getitem__, 

7906 key="global_fixation_hover_fields", 

7907 persist_state="session", 

7908 help="Fields shown when hovering a fixation, in this order.", 

7909 ) 

7910 

7911 # Where each legend sits. In addition to each layer's own *Show legend* 

7912 # switch, never instead of it: a legend that is off stays off wherever it 

7913 # is placed. Auto everywhere draws the figure as it always was. 

7914 with legends, _popover_rows("fig_legends"): 

7915 for kind in LEGEND_KINDS: 

7916 # A row whose legend the current figure cannot draw greys out, its 

7917 # values kept (no `index=`/`value=`), like every gated rail control. 

7918 gated_off = { 

7919 "compare": None 

7920 if comparing 

7921 else "Only in Compare: the A/B legend names the two scanpaths.", 

7922 "saccades": "Only on the static figure: the replay and Compare " 

7923 "draw no saccade-type legend." 

7924 if animating or comparing 

7925 else None, 

7926 }.get(kind) 

7927 field = _sub_row( 

7928 LEGEND_KIND_LABELS[kind], 

7929 caption_help=gated_off or _LEGEND_ROW_HELP[kind], 

7930 ) 

7931 pos_col, arr_col, size_col = field.columns( 

7932 [0.44, 0.34, 0.22], gap=_LABEL_GAP, vertical_alignment="center" 

7933 ) 

7934 pos_col.selectbox( 

7935 f"{LEGEND_KIND_LABELS[kind]} legend position", 

7936 disabled=bool(gated_off), 

7937 options=list(LEGEND_POSITION_LABELS), 

7938 format_func=LEGEND_POSITION_LABELS.__getitem__, 

7939 key=f"global_legend_{kind}_position", 

7940 persist_state="session", 

7941 label_visibility="collapsed", 

7942 help="Where this legend sits. Above, Below, Left and Right are " 

7943 "outside the plot (the figure grows to make room); the Inside " 

7944 "spots sit over it. Auto: where it is drawn by default.", 

7945 ) 

7946 arr_col.selectbox( 

7947 f"{LEGEND_KIND_LABELS[kind]} legend arrangement", 

7948 disabled=bool(gated_off), 

7949 options=list(LEGEND_ARRANGEMENT_LABELS), 

7950 format_func=LEGEND_ARRANGEMENT_LABELS.__getitem__, 

7951 key=f"global_legend_{kind}_arrangement", 

7952 persist_state="session", 

7953 label_visibility="collapsed", 

7954 help="Stacked: one item under the other. Side by side: in a " 

7955 "row. Auto: a row above or below the plot, a stack elsewhere.", 

7956 ) 

7957 size_col.number_input( 

7958 f"{LEGEND_KIND_LABELS[kind]} legend text size", 

7959 disabled=bool(gated_off), 

7960 min_value=6, 

7961 max_value=72, 

7962 step=1, 

7963 key=f"global_legend_{kind}_size", 

7964 persist_state="session", 

7965 placeholder="Auto", 

7966 label_visibility="collapsed", 

7967 help="Text size in px. Empty: the figure's own.", 

7968 ) 

7969 

7970 # Build the dict from session_state so it matches viz_settings_from_state 

7971 # exactly; then fill in the per-scanpath comparison styling, shown only when 

7972 # the Compare toggle (rail plot-controls section) is on, so all styling sits here. 

7973 settings = _collect_viz_settings( 

7974 trial_fixations, 

7975 words, 

7976 numeric_fields=numeric_fields, 

7977 highlight_options=highlight_options, 

7978 ) 

7979 # The per-scanpath comparison styling is rendered inline under each layer's 

7980 # popover (Fixation / Saccade) above; here we just collect it from the keys. 

7981 if st.session_state.get("single_compare_toggle"): 

7982 settings["compare_style_a"], settings["compare_style_b"] = ( 

7983 _collect_compare_styles() 

7984 ) 

7985 return settings 

7986 

7987 

7988# Cached option-list scans for the trial-filter panel. These run on every 

7989# rerun to populate the multiselects; caching them on a cheap frame fingerprint 

7990# keeps them off the hot path on large corpora (full-column unique() scans). 

7991@st.cache_data(show_spinner=False) 

7992def _participant_options( 

7993 _words: pd.DataFrame, _fixations: pd.DataFrame, cache_key 

7994) -> list[str]: 

7995 return sorted( 

7996 set(_words["participant_id"].dropna().astype(str)) 

7997 | set(_fixations["participant_id"].dropna().astype(str)) 

7998 ) 

7999 

8000 

8001@st.cache_data(show_spinner=False) 

8002def _column_unique_strs(_df: pd.DataFrame, column: str, cache_key) -> list[str]: 

8003 if column not in _df.columns: 

8004 return [] 

8005 # Drop missing values, including the literal "nan" a string-coerced optional 

8006 # field leaves for NaN (e.g. ET2 readers with no recorded gender) — a "nan" 

8007 # filter option would be meaningless. 

8008 values = _df[column].dropna().astype(str).unique() 

8009 return sorted(v for v in values if v.strip().lower() not in ("nan", "none", "<na>")) 

8010 

8011 

8012@st.cache_data(show_spinner=False) 

8013def _numeric_column_bounds(_df: pd.DataFrame, column: str, cache_key): 

8014 """``(lo, hi, distinct)`` over a column's **finite** values, or ``None``. 

8015 

8016 UX-49's slider needs finite bounds and at least two distinct values — a 

8017 one-option range control is the same family as the single-option 

8018 ``st.select_slider`` that throws ``RangeError`` in the browser. ``None`` here 

8019 means "render no slider", not "render a degenerate one". Infinities are 

8020 dropped along with NaN: one ``inf`` would otherwise pin the whole slider. 

8021 

8022 A full-column scan, hence the cache — keyed on the frame fingerprint by the 

8023 caller, exactly like ``_column_unique_strs``. 

8024 """ 

8025 if column not in _df.columns: 

8026 return None 

8027 values = pd.to_numeric(_df[column], errors="coerce") 

8028 finite = values[np.isfinite(values)] if len(values) else values 

8029 if finite.empty: 

8030 return None 

8031 distinct = int(finite.nunique()) 

8032 if distinct < 2: 

8033 return None 

8034 lo, hi = finite.min(), finite.max() 

8035 # A whole-numbered column gets whole-numbered bounds, so the slider steps in 

8036 # 1s and reads "3 – 17" rather than "3.00 – 17.00". Streamlit picks int vs 

8037 # float behaviour from the *type* of min_value/max_value, so the decision has 

8038 # to be made here, on the data. `%` on a float that happens to be whole (an 

8039 # int column with NaNs is float64) counts as whole — the point is the values, 

8040 # not the dtype pandas landed on. 

8041 if bool((finite % 1 == 0).all()): 

8042 return int(lo), int(hi), distinct 

8043 return float(lo), float(hi), distinct 

8044 

8045 

8046@st.cache_data(show_spinner=False) 

8047def _trials_missing_column(_df: pd.DataFrame, column: str, cache_key) -> int: 

8048 """How many trials carry no numeric value for ``column``. 

8049 

8050 Counted in *trials*, not rows, because that is the unit the filter keeps or 

8051 drops — and it is what makes the "kept anyway" caption honest. 

8052 """ 

8053 if column not in _df.columns or "trial_id" not in _df.columns: 

8054 return 0 

8055 keys = ( 

8056 ["participant_id", "trial_id"] 

8057 if "participant_id" in _df.columns 

8058 else ["trial_id"] 

8059 ) 

8060 values = pd.to_numeric(_df[column], errors="coerce") 

8061 usable = pd.DataFrame({"_v": np.isfinite(values)}) 

8062 for k in keys: 

8063 usable[k] = _df[k].astype(str).to_numpy() 

8064 per_trial = usable.groupby(keys, dropna=False)["_v"].any() 

8065 return int((~per_trial).sum()) 

8066 

8067 

8068@st.cache_data(show_spinner=False) 

8069def _column_present_bools(_df: pd.DataFrame, column: str, cache_key) -> frozenset: 

8070 if column not in _df.columns: 

8071 return frozenset() 

8072 # Callers pass only bool-dtype columns today (a string "True"/"False" 

8073 # column takes the categorical path); read by meaning anyway, never by 

8074 # truthiness, so a future caller can't hide a class (round 11). 

8075 flags = coerce_bool_or_na(pd.Series(_df[column])).dropna() 

8076 return frozenset(bool(v) for v in flags.unique()) 

8077 

8078 

8079def _bool_metadata_filter( 

8080 label: str, 

8081 col: str, 

8082 df: pd.DataFrame, 

8083 true_label: str, 

8084 false_label: str, 

8085 key: str, 

8086 host, 

8087 on_change=None, 

8088 help: str | None = None, 

8089) -> None: 

8090 """Render a friendly multiselect for a boolean metadata column. 

8091 

8092 Rendering only — the narrowing value is derived from the widget key by 

8093 ``_compute_trial_filters``. Renders nothing when the column is absent or has 

8094 fewer than two classes.""" 

8095 if col not in df.columns: 

8096 return 

8097 present = _column_present_bools(df, col, cache_key=(frame_fingerprint(df), col)) 

8098 label_to_val = {true_label: True, false_label: False} 

8099 options = [lbl for lbl, val in label_to_val.items() if val in present] 

8100 if len(options) < 2: 

8101 return 

8102 _seed_filter_widget(key, options, options) 

8103 _labeled( 

8104 host, 

8105 "multiselect", 

8106 label, 

8107 options=options, 

8108 key=key, 

8109 on_change=on_change, 

8110 help=help or None, 

8111 ) 

8112 

8113 

8114def _bool_filter_narrowing( 

8115 col: str, df: pd.DataFrame, true_label: str, false_label: str, key: str 

8116) -> set | None: 

8117 """The set of raw bool values to keep for a boolean metadata column, read 

8118 from its widget key — or None when absent / fewer than two classes / the user 

8119 kept everything (no narrowing). The read-side twin of ``_bool_metadata_filter``.""" 

8120 if col not in df.columns: 

8121 return None 

8122 present = _column_present_bools(df, col, cache_key=(frame_fingerprint(df), col)) 

8123 label_to_val = {true_label: True, false_label: False} 

8124 options = [lbl for lbl, val in label_to_val.items() if val in present] 

8125 if len(options) < 2: 

8126 return None 

8127 chosen = st.session_state.get(key) 

8128 if not chosen or set(chosen) == set(options): 

8129 return None 

8130 vals = {label_to_val[c] for c in chosen if c in label_to_val} 

8131 return vals or None 

8132 

8133 

8134# What the two values of a well-known boolean condition column mean. A filter's 

8135# *title* is the dataset's own name for its column (DATA-66, `trial_filter_labels`); 

8136# these name its values, and any other boolean column reads Yes / No. 

8137_FILTER_FIELD_LABELS = { 

8138 "question_preview": {"true": "Hunting", "false": "Gathering"}, 

8139 "repeated_reading_trial": {"true": "Repeated", "false": "First"}, 

8140 "is_correct": {"true": "Correct", "false": "Incorrect"}, 

8141} 

8142 

8143# Built-in sources (no wizard) auto-offer these known trial-level conditions when 

8144# present; the Upload source uses the fields the user chose in the wizard. 

8145_DEFAULT_FILTER_FIELDS = [ 

8146 "question_preview", 

8147 "difficulty_level", 

8148 "repeated_reading_trial", 

8149 "is_correct", 

8150 # UX-49: the offered set is otherwise all-categorical, which left the range 

8151 # slider invisible on every bundled and public corpus. Presentation order is 

8152 # the one numeric trial-level field that is both universal (EyeLink writes it 

8153 # on every export) and worth filtering on — it is how you exclude the start 

8154 # or the tail of a session when you suspect practice or fatigue effects. 

8155 "TRIAL_INDEX", 

8156 # MultiplEYE facets (present only when that corpus is loaded). 

8157 "genre", 

8158 "session", 

8159 "is_practice", 

8160] 

8161 

8162 

8163_EMPTY_TRIAL_FILTERS: dict = { 

8164 "participants": None, 

8165 "metadata": {}, 

8166 # UX-49: column → (lo, hi) for the numeric trial-level range filters. Kept 

8167 # apart from `metadata` because that one is membership (`.isin`) and 

8168 # enumerating a float column's values is exactly what doesn't work. 

8169 "ranges": {}, 

8170 # The ranged columns whose *Keep unknown values* is off: a trial with no 

8171 # value there is left out instead of kept (`data.filter_trials`). 

8172 "ranges_drop_unknown": (), 

8173 # DATA-20: widget keys behind a participant-grain metadata narrowing (which 

8174 # lands in `participants`, not `metadata`), so UX-7's per-filter clear can 

8175 # reset the control that actually caused it. 

8176 "participant_filter_keys": (), 

8177 "favorites_only": False, 

8178 "required_tags": [], 

8179 "excluded_tags": [], 

8180} 

8181 

8182 

8183#: Every filter-layer namespace in the app. ``""`` is the main trial pool; 

8184#: ``"cmp"`` is compare mode's scanpath B (CMP-8 §5.2). The empty prefix's 

8185#: clear-sweep uses this to know which keys are *not* its own — without it, 

8186#: "Clear all filters" on A would wipe B too, since ``"filter_"`` is a prefix of 

8187#: ``"cmpfilter_"``. Register a new instance here, not just at its call site. 

8188FILTER_PREFIXES: tuple = ("", "cmp") 

8189 

8190 

8191def read_trial_filters(prefix: str = "") -> dict: 

8192 """The trial-filter selections to apply this run. 

8193 

8194 ``prefix`` scopes the whole filter layer to one instance (CMP-8 §5.2). The 

8195 default ``""`` is the main pool and leaves every existing call site 

8196 byte-identical; compare mode's scanpath **B** renders with ``prefix="cmp"`` 

8197 so it can be narrowed on *its own* dataset's columns, which A's filters may 

8198 not even have. 

8199 

8200 Computed last run by ``render_trial_filters`` and stashed in a *plain* 

8201 session_state value (not a widget key), so it survives runs where the filter 

8202 panel itself isn't rendered — e.g. when a non-Scanpath view is active on the 

8203 top nav. ``main()`` reads this *before* the tab renders, so filtering 

8204 stays global even though the controls now live in the Trial Selection panel. 

8205 """ 

8206 return dict(st.session_state.get(f"{prefix}_trial_filters", _EMPTY_TRIAL_FILTERS)) 

8207 

8208 

8209def clear_trial_filters(prefix: str = "") -> None: 

8210 """Reset every trial filter to "no constraint" (UX-7's one-click escape). 

8211 

8212 All of them — the Narrow-by multiselects, the More-popover condition filters, 

8213 and the annotation filters — live under the ``filter_`` key prefix, so 

8214 dropping those keys is the whole reset: each widget re-seeds to its own empty 

8215 default (an empty multiselect means *no* narrowing) on the next render. The 

8216 derived results and the cross-view mirror are cleared with them so the same 

8217 run already sees an unfiltered pool. 

8218 

8219 Safe to call as a button ``on_click``: callbacks run before the rerun 

8220 instantiates the widgets, so removing their keys doesn't trip Streamlit's 

8221 "set after instantiation" guard. 

8222 """ 

8223 for key in _own_filter_keys(prefix): 

8224 del st.session_state[key] 

8225 st.session_state.pop(f"{prefix}_trial_filters", None) 

8226 st.session_state.pop(f"{prefix}_trial_filters_raw", None) 

8227 

8228 

8229def _own_filter_keys(prefix: str) -> list: 

8230 """Filter widget keys belonging to ``prefix`` and to no *longer* prefix. 

8231 

8232 The sweep used to be prefix-blind, which is fine while there is one filter 

8233 set and wrong the moment there are two: ``"filter_"`` is a prefix of 

8234 ``"cmpfilter_"``, so clearing A's filters would silently wipe B's as well. 

8235 Matching forwards is not enough — the empty prefix has to explicitly skip 

8236 keys carrying a known namespace. 

8237 """ 

8238 own = f"{prefix}filter_" 

8239 foreign = tuple(f"{p}filter_" for p in FILTER_PREFIXES if p and p != prefix) 

8240 return [ 

8241 k 

8242 for k in list(st.session_state) 

8243 if str(k).startswith(own) and not (prefix == "" and str(k).startswith(foreign)) 

8244 ] 

8245 

8246 

8247def reset_viz_settings() -> None: 

8248 """Put every visualization setting back to the app's defaults (UX-26). 

8249 

8250 The mechanism is ``clear_trial_filters``' — delete the widget keys and let 

8251 each control re-seed from ``_VIZ_WIDGET_DEFAULTS`` (plus the data-dependent 

8252 defaults `_seed_viz_state` computes) on the next render — so it must run as a 

8253 button ``on_click``: callbacks run before the rerun instantiates the widgets, 

8254 which is what keeps deleting their keys clear of Streamlit's "set after 

8255 instantiation" guard. 

8256 

8257 The key set is the honest inventory of what *visualization settings* means: 

8258 every ``global_*`` key, the per-scanpath compare styles 

8259 (``session_keys.compare_state_keys``), and the fixation-window pair the rail 

8260 owns. ``session_keys.PLOT_CONFIG_STATE_KEYS`` is folded in so a setting that 

8261 is restorable-but-not-currently-rendered is reset too. Deliberately NOT 

8262 touched: the trial selection, the annotations (user-authored content, not a 

8263 setting), the column mapping, and the data source. 

8264 

8265 Deep links re-apply: ``url_state._apply_url_preset`` seeds from 

8266 ``st.query_params`` at the top of every rerun, so on a page opened from a 

8267 Share link, deleting the keys alone would let the link reinstate itself on 

8268 the very next run. The viz params are stripped from the query string here; 

8269 the selection params (source / participant / trial) are left, so a reset 

8270 keeps you on the trial you were looking at. 

8271 """ 

8272 from . import session_keys as _sk 

8273 

8274 keys = set(_sk.PLOT_CONFIG_STATE_KEYS) 

8275 keys |= set(_sk.compare_state_keys(0)) | set(_sk.compare_state_keys(1)) 

8276 # CMP-24 — B's own filters and window. 

8277 keys |= set(_sk.COMPARE_B_FILTER_STATE_KEYS) | { 

8278 _sk.SINGLE_COMPARE_FIX_RANGE, 

8279 f"{_sk.SINGLE_COMPARE_FIX_RANGE}_user_set", 

8280 } 

8281 keys |= {k for k in st.session_state if str(k).startswith("global_")} 

8282 keys |= { 

8283 "single_fix_range", 

8284 "single_fix_range_all_trials", 

8285 _PRE_ILLUSTRATION_STATE, 

8286 _QUICK_VIEW_SELECTION_KEY, 

8287 _QUICK_VIEW_CUSTOM_STATE, 

8288 _QUICK_VIEW_APPLIED_STATE, 

8289 _QUICK_VIEW_DRIFTED_FROM, 

8290 } 

8291 # VIZ-39: `DESIGN_PRESETS_KEY` is deliberately NOT in that set. Reset puts 

8292 # the *view* back to defaults; the user's saved designs are a library, not 

8293 # a view, and losing them to a reset button would be the kind of undoless 

8294 # deletion nothing here offers. 

8295 for key in keys: 

8296 st.session_state.pop(key, None) 

8297 # Re-seeding is source-driven for these two (see app.seed_canvas_state); 

8298 # dropping the guard makes the canvas / font snap back to the source's 

8299 # authoritative monitor on the next run rather than sticking at the old size. 

8300 st.session_state.pop("_canvas_seeded_for", None) 

8301 st.session_state.pop("_font_seeded_for", None) 

8302 st.session_state.pop("_palette_picked", None) 

8303 st.session_state.pop(_PRE_ILLUSTRATION_STATE, None) 

8304 # VIZ-45 — and the raw-gaze layer's dataset default, the same way. 

8305 _forget_raw_gaze_default(st.session_state) 

8306 for param in (*_sk.URL_PRESET_PARAMS, *_sk.LEGEND_PARAMS): 

8307 st.query_params.pop(param, None) 

8308 

8309 

8310def clear_trial_filter( 

8311 *keys: str, prefix: str = "", frames: tuple | None = None 

8312) -> None: 

8313 """Reset *one* trial filter (UX-7) — the same mechanism as the reset-all. 

8314 

8315 Deleting the widget's key is the correct reset for every filter shape here, 

8316 because each re-seeds to its own "no constraint" default on the next render: 

8317 an empty multiselect for Narrow-by, *all* values selected for a condition, 

8318 unchecked for Favorites. Safe as a button ``on_click`` for the same reason 

8319 :func:`clear_trial_filters` is. 

8320 

8321 BUG-115: only ``keys`` leave the ``_trial_filters_raw`` mirror. The 

8322 empty-pool panel, the one caller, replaces the view and its filter funnel, 

8323 so on that run Streamlit has dropped every filter widget's key and the 

8324 mirror is all that remembers the *other* filters; dropping it whole cleared 

8325 them all. With ``frames`` (the ``(words, fixations)`` the funnel filters) 

8326 the survivors are seeded back into their widget keys and the result is 

8327 re-derived, so the next run applies them instead of one run later. 

8328 """ 

8329 raw_key = f"{prefix}_trial_filters_raw" 

8330 mirror = dict(st.session_state.get(raw_key) or {}) 

8331 # A range's *Keep unknown values* choice is part of that filter, so it 

8332 # goes with it. 

8333 for key in (*keys, *(keep_unknown_key(k) for k in keys)): 

8334 st.session_state.pop(key, None) 

8335 mirror.pop(key, None) 

8336 st.session_state[raw_key] = mirror 

8337 if frames is None: 

8338 st.session_state.pop(f"{prefix}_trial_filters", None) 

8339 return 

8340 for key, value in mirror.items(): 

8341 st.session_state.setdefault(key, value) 

8342 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters( 

8343 *frames, prefix=prefix 

8344 ) 

8345 

8346 

8347def has_active_trial_filters(prefix: str = "") -> bool: 

8348 """Whether any trial filter is currently narrowing the pool.""" 

8349 f = read_trial_filters(prefix) 

8350 return bool( 

8351 # `[]` is a narrowing that matched nobody — the *most* active a filter 

8352 # can be. `None` is the no-constraint default. 

8353 f.get("participants") is not None 

8354 or f.get("metadata") 

8355 or f.get("ranges") 

8356 or f.get("favorites_only") 

8357 or f.get("required_tags") 

8358 or f.get("excluded_tags") 

8359 ) 

8360 

8361 

8362#: UX-198 — the titles of the filters whose session key is not 

8363#: ``filter_<column>``; every other one is titled by its column. 

8364_FILTER_KEY_LABELS = { 

8365 "filter_participants": "Participant", 

8366 "filter_text_id": "Text", 

8367 "filter_favorites": "Favorites only", 

8368 "filter_req_tags": "With any of these tags", 

8369 "filter_exc_tags": "Excluding tags", 

8370} 

8371 

8372#: The metadata filters' key stems (DATA-20 / DATA-29 / text grain), longest 

8373#: first so ``filter_meta_`` cannot claim a ``filter_metadata_…`` column. 

8374_METADATA_FILTER_STEMS = ("filter_trialmeta_", "filter_textmeta_", "filter_meta_") 

8375 

8376 

8377def active_filter_keys(trial_filters: dict, prefix: str = "") -> list[str]: 

8378 """The widget keys behind every narrowing in ``trial_filters`` (UX-198). 

8379 

8380 The filter result already carries them for UX-7's per-filter clear; this 

8381 lists them once, in the panel's order, so a summary of the pool names the 

8382 same controls the panel shows. 

8383 """ 

8384 keys: list[str] = [] 

8385 if trial_filters.get("participants") is not None: 

8386 keys.append(f"{prefix}filter_participants") 

8387 keys.extend(trial_filters.get("participant_filter_keys") or ()) 

8388 keys.extend((trial_filters.get("metadata_keys") or {}).values()) 

8389 keys.extend(trial_filters.get("text_filter_keys") or ()) 

8390 if trial_filters.get("trial_keys") is not None: 

8391 keys.extend(trial_filters.get("trial_filter_keys") or ()) 

8392 if trial_filters.get("favorites_only"): 

8393 keys.append(f"{prefix}filter_favorites") 

8394 if trial_filters.get("required_tags"): 

8395 keys.append(f"{prefix}filter_req_tags") 

8396 if trial_filters.get("excluded_tags"): 

8397 keys.append(f"{prefix}filter_exc_tags") 

8398 return list(dict.fromkeys(keys)) 

8399 

8400 

8401def _is_number(value) -> bool: 

8402 return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance( 

8403 value, (bool, np.bool_) 

8404 ) 

8405 

8406 

8407def describe_filter_keys( 

8408 keys, values, label_for: Callable[[str], str], prefix: str = "" 

8409) -> list[dict]: 

8410 """One ``{"field", "values" | "range"}`` entry per filter that narrows. 

8411 

8412 Pure: ``values`` maps a key to its widget value and ``label_for`` titles it. 

8413 A key whose value no longer narrows (an emptied multiselect) is skipped, so 

8414 the list says exactly what is constraining the pool. A range is a 

8415 two-number tuple, or a two-number list under a range or metadata key — 

8416 a categorical multiselect also holds a list. ``Favorites only`` has 

8417 neither values nor range: it is on or absent. 

8418 

8419 A range also says what happens to the records with no value — 

8420 ``"unknown": "kept"`` or ``"excluded"``, from its *Keep unknown values* 

8421 choice (``values`` holds it under :func:`keep_unknown_key`). A constant 

8422 field has no range to slide, so its entry is only ``"unknown": 

8423 "excluded"``: that choice is the whole filter. 

8424 """ 

8425 items: list[dict] = [] 

8426 for key in keys: 

8427 value = values.get(key) 

8428 bare = key[len(prefix) :] if prefix and key.startswith(prefix) else key 

8429 if bare == "filter_favorites": 

8430 if value: 

8431 items.append({"field": label_for(key)}) 

8432 continue 

8433 unknown = "excluded" if values.get(keep_unknown_key(key)) is False else "kept" 

8434 ranged = isinstance(value, tuple) or ( 

8435 isinstance(value, list) 

8436 and (bare.endswith("_range") or bare.startswith(_METADATA_FILTER_STEMS)) 

8437 ) 

8438 if ranged and len(value) == 2 and all(_is_number(v) for v in value): 

8439 items.append( 

8440 { 

8441 "field": label_for(key), 

8442 "range": [float(value[0]), float(value[1])], 

8443 "unknown": unknown, 

8444 } 

8445 ) 

8446 continue 

8447 if value is None and unknown == "excluded": 

8448 items.append({"field": label_for(key), "unknown": unknown}) 

8449 continue 

8450 if isinstance(value, (list, tuple, set)) and value: 

8451 items.append({"field": label_for(key), "values": [str(v) for v in value]}) 

8452 return items 

8453 

8454 

8455def active_filter_items( 

8456 words: pd.DataFrame, fixations: pd.DataFrame, *, prefix: str = "" 

8457) -> list[dict]: 

8458 """What is narrowing the pool this run, one entry per filter (UX-198). 

8459 

8460 Read from the result ``app.main`` filtered with (``read_trial_filters``), 

8461 so the list always matches the counts beside it. The values are the 

8462 widgets', falling back to the ``_trial_filters_raw`` mirror on a run where 

8463 the panel has not drawn them yet — the labels the user picked, not the 

8464 raw booleans a condition filter resolves to. 

8465 """ 

8466 keys = active_filter_keys(read_trial_filters(prefix), prefix) 

8467 if not keys: 

8468 return [] 

8469 values = dict(st.session_state.get(f"{prefix}_trial_filters_raw") or {}) 

8470 live = [*keys, *(keep_unknown_key(k) for k in keys)] 

8471 values.update({k: st.session_state[k] for k in live if k in st.session_state}) 

8472 names = _rail_names() 

8473 labels = trial_filter_labels(words, fixations, names=names) 

8474 

8475 def label_for(key: str) -> str: 

8476 from scanpath_studio import metadata as md 

8477 

8478 bare = key[len(prefix) :] if prefix and key.startswith(prefix) else key 

8479 if bare in _FILTER_KEY_LABELS: 

8480 return _FILTER_KEY_LABELS[bare] 

8481 for stem in _METADATA_FILTER_STEMS: 

8482 if bare.startswith(stem): 

8483 return md.field_label(bare[len(stem) :]) 

8484 col = bare.removeprefix("filter_") 

8485 if col.endswith("_range") and col.removesuffix("_range") in labels: 

8486 col = col.removesuffix("_range") 

8487 return labels.get(col) or names.field_label(col) 

8488 

8489 return describe_filter_keys(keys, values, label_for, prefix) 

8490 

8491 

8492def format_filter_item(item: dict, *, max_values: int = 3) -> str: 

8493 """``Participant: p1, p2`` / ``Trial index: 3–10`` / ``Favorites only``. 

8494 

8495 A filter that leaves out the records with no value says so: 

8496 ``Score: 80–100 (unknown values excluded)``. 

8497 """ 

8498 excluded = item.get("unknown") == "excluded" 

8499 if "range" in item: 

8500 lo, hi = item["range"] 

8501 text = f"{item['field']}: {lo:,.10g}–{hi:,.10g}" 

8502 return f"{text} (unknown values excluded)" if excluded else text 

8503 if excluded and not item.get("values"): 

8504 return f"{item['field']}: unknown values excluded" 

8505 values = list(item.get("values") or ()) 

8506 if not values: 

8507 return str(item["field"]) 

8508 shown = ", ".join(values[:max_values]) 

8509 if len(values) > max_values: 

8510 shown += f" +{len(values) - max_values} more" 

8511 return f"{item['field']}: {shown}" 

8512 

8513 

8514# --- Trial summary chips (the "Field = Value" strip above the plot) ---------- 

8515_CHIP_TEXT_ID_COLS = ( 

8516 "unique_text_id", 

8517 "text_id", 

8518 "unique_paragraph_id", 

8519 "paragraph_id", 

8520) 

8521# Sensible default chips: trial identity + the common OneStop conditions + the 

8522# computed trial-level summary stats (which the chips replaced the Trial Info tab 

8523# with). The "@"-prefixed keys are virtual fields computed per trial in 

8524# `tabs._render_trial_condition_chips` (see SUMMARY_CHIP_FIELDS). 

8525_CHIP_DEFAULT_CONDITIONS = [ 

8526 "difficulty_level", 

8527 "question_preview", 

8528 "repeated_reading_trial", 

8529 "is_correct", 

8530 # MultiplEYE facets + reader metadata (present only for that corpus). 

8531 "genre", 

8532 "session", 

8533 "pp_age", 

8534 "pp_gender", 

8535] 

8536# Virtual chip fields → label. These are computed per trial (not data columns), 

8537# always trial-level, and folded in from the former Trial Info tab's summary. 

8538SUMMARY_CHIP_FIELDS = { 

8539 # #374 F8: the sum of the fixation durations (or the recorded dwell time), 

8540 # not the time spent on the trial — that is the next chip. The key keeps 

8541 # its old name: saved chip lists carry it. 

8542 "@reading_time_s": "Total fixation time (s)", 

8543 "@trial_duration_s": "Trial duration (s)", 

8544 "@word_count": "Number of words", 

8545 "@fixation_count": "Number of fixations", 

8546 "@in_text_fixations": "Fixations in word boxes", 

8547 # VIZ-45: a raw-gaze trial's own headline number. Written only for a trial 

8548 # that has samples (`tabs._summary_rows`), so a dataset without raw gaze 

8549 # never shows it. 

8550 "@gaze_sample_count": "Number of gaze samples", 

8551} 

8552#: What each summary chip counts, as its tooltip (#374 F8). 

8553SUMMARY_CHIP_HELP = { 

8554 "@reading_time_s": "The sum of the trial's fixation durations " 

8555 "(the recorded trial dwell time when the data has one).", 

8556 "@trial_duration_s": "From the first fixation's onset to the last fixation's " 

8557 "end, saccades included.", 

8558} 

8559#: …and the ones shown by default. The other two are offered in *Available* 

8560#: like any other field. All four used to be default chips behind a **Summary 

8561#: stats** popover; now that they are chips on the strip itself, four of them 

8562#: crowd out the conditions beside them — and reading time and the fixation 

8563#: count are the ones worth a permanent chip ("how long was this reading, and 

8564#: how many fixations"). Word count is a property of the text rather than of 

8565#: the reading, and the in-text count only means something when you are 

8566#: already chasing a geometry problem. 

8567#: 

8568#: VIZ-45 added the gaze-sample count. A summary chip is drawn only for a number 

8569#: the trial has, so a trial with fixations and no samples still shows the same 

8570#: two chips, and a raw-gaze-only trial — whose reading time and fixation count 

8571#: were never measured and are left out — shows the one count it has. 

8572_CHIP_DEFAULT_SUMMARY = ( 

8573 "@reading_time_s", 

8574 "@trial_duration_s", 

8575 "@fixation_count", 

8576 "@gaze_sample_count", 

8577) 

8578 

8579 

8580def _trial_level_columns(words: pd.DataFrame, fixations: pd.DataFrame) -> set: 

8581 """Columns that are constant within a trial (so a single chip value is 

8582 meaningful), sampled from the first trial only — cheap, and trial-level-ness 

8583 is essentially a property of the dataset, not the specific trial. A column 

8584 counts as trial-level when it has ≤1 distinct value within that sample trial. 

8585 """ 

8586 level: set = set() 

8587 src = fixations if (fixations is not None and not fixations.empty) else words 

8588 if ( 

8589 src is None 

8590 or src.empty 

8591 or "participant_id" not in src.columns 

8592 or "trial_id" not in src.columns 

8593 ): 

8594 return level 

8595 pid, tid = str(src["participant_id"].iloc[0]), str(src["trial_id"].iloc[0]) 

8596 for frame in (words, fixations): 

8597 if ( 

8598 frame is None 

8599 or frame.empty 

8600 or "participant_id" not in frame.columns 

8601 or "trial_id" not in frame.columns 

8602 ): 

8603 continue 

8604 sub = frame[ 

8605 (frame["participant_id"].astype(str) == pid) 

8606 & (frame["trial_id"].astype(str) == tid) 

8607 ] 

8608 if sub.empty: 

8609 continue 

8610 for col in sub.columns: 

8611 if sub[col].nunique(dropna=True) <= 1: 

8612 level.add(col) 

8613 return level 

8614 

8615 

8616def _chip_field_options(words, fixations, trial_level: set) -> list[str]: 

8617 """Pickable chip fields: participant + a text id + the data's *trial-level* 

8618 columns + the computed summary fields. Non-trial-level columns (per-word / 

8619 per-fixation) are intentionally excluded — a single chip value for them would 

8620 be misleading.""" 

8621 cols: list[str] = [] 

8622 

8623 def add(c: str) -> None: 

8624 # #374 F5: one chip per role — `unique_trial_id` beside `trial_id` would 

8625 # be a second "Trial", told apart only by an internal name. 

8626 role = cn.ROLE_LABELS.get(c) 

8627 if role is not None and any(cn.ROLE_LABELS.get(x) == role for x in cols): 

8628 return 

8629 if c and c not in cols: 

8630 cols.append(c) 

8631 

8632 if "participant_id" in words.columns or "participant_id" in fixations.columns: 

8633 add("participant_id") 

8634 add(next((c for c in _CHIP_TEXT_ID_COLS if c in words.columns), "")) 

8635 for c in list(words.columns) + list(fixations.columns): 

8636 if c in trial_level and c not in INTERNAL_COLUMNS: 

8637 add(c) 

8638 # DATA-20: participant-grain metadata is constant within a trial by 

8639 # construction, so it belongs in this list on exactly the same terms as a 

8640 # trial-level recorded column — no allowlist of its own. 

8641 for field in participant_metadata_fields(): 

8642 add(field.name) 

8643 # DATA-29 — and a trial-grain field is constant within a trial by 

8644 # definition, which is the property this list is for. 

8645 for field in trial_metadata_fields(): 

8646 add(field.name) 

8647 # Text-grain metadata is constant within a trial by construction too — a 

8648 # text attribute (genre, difficulty) doesn't vary across the readers of it. 

8649 for field in text_metadata_fields(): 

8650 add(field.name) 

8651 cols.extend(SUMMARY_CHIP_FIELDS) 

8652 return cols 

8653 

8654 

8655def unique_field_labels(columns, label_of) -> dict[str, str]: 

8656 """``{column: label}`` with every label distinct, in ``columns``' order. 

8657 

8658 Two columns can share a label — `text_id` and `unique_text_id` read from 

8659 one column of the user's file — so the first keeps its label and each later 

8660 one adds its column name: "PARAGRAPH (unique_text_id)". The ✏️ chip editor needs this to stay 

8661 invertible; the trial filters use it (UX-149) so two sliders over different 

8662 columns never carry the same title. 

8663 """ 

8664 labels: dict[str, str] = {} 

8665 taken: set[str] = set() 

8666 for column in columns: 

8667 if column in labels: 

8668 continue 

8669 base = label_of(column) 

8670 label = base if base not in taken else f"{base} ({column})" 

8671 taken.add(label) 

8672 labels[column] = label 

8673 return labels 

8674 

8675 

8676def chip_field_label(col: str, names: cn.ColumnNames | None = None) -> str: 

8677 """What a chip field is called, on the chip and in ✏️ Edit chips alike. 

8678 

8679 A summary statistic keeps the app's name (`SUMMARY_CHIP_FIELDS`); a data 

8680 column is shown under the dataset's own name (DATA-66). ``names`` is the 

8681 map to read, the open dataset's by default (Compare's B passes its own). 

8682 """ 

8683 if col in SUMMARY_CHIP_FIELDS: 

8684 return SUMMARY_CHIP_FIELDS[col] 

8685 return (names if names is not None else _rail_names()).field_label(col) 

8686 

8687 

8688def field_help(col: str, names: cn.ColumnNames | None = None) -> str: 

8689 """The tooltip beside a field's name (#374 F5): the bundled demo's 

8690 description of its own column, and a role's source column 

8691 ("from RECORDING_SESSION_LABEL"). ``""`` when there is neither.""" 

8692 if col in SUMMARY_CHIP_FIELDS: 

8693 return SUMMARY_CHIP_HELP.get(col, "") 

8694 names = names if names is not None else _rail_names() 

8695 note = ( 

8696 cn.DEMO_COLUMN_NOTES.get(col, "") 

8697 if current_dataset_name() == DEMO_CHOICE 

8698 else "" 

8699 ) 

8700 source = names.source_tooltip(col) 

8701 if source: 

8702 source = source[:1].upper() + source[1:] + "." 

8703 return " ".join(part for part in (note, source) if part) 

8704 

8705 

8706def _default_chip_fields(available: list[str]) -> list[str]: 

8707 text_col = next((c for c in _CHIP_TEXT_ID_COLS if c in available), None) 

8708 wanted = ( 

8709 ["participant_id"] 

8710 + ([text_col] if text_col else []) 

8711 + _CHIP_DEFAULT_CONDITIONS 

8712 + list(_CHIP_DEFAULT_SUMMARY) 

8713 ) 

8714 return [f for f in wanted if f in available] 

8715 

8716 

8717def render_trial_chip_picker( 

8718 words: pd.DataFrame, fixations: pd.DataFrame, host 

8719) -> None: 

8720 """Render the inline **Edit chips** control: choose which ``Field = Value`` 

8721 chips appear above the scanpath and **drag to reorder** them (UX-1 / UX-1a). 

8722 

8723 Two drag buckets — *Shown* (in display order) and *Available* — via 

8724 ``streamlit_sortables.sort_items``: drag a field between buckets to show / hide 

8725 it, and within *Shown* to reorder. The resulting order is written to the plain 

8726 session key ``trial_chip_fields`` (read by ``tabs._render_trial_condition_chips``). 

8727 

8728 Only *trial-level* fields are offered (constant within a trial) plus the 

8729 computed summary stats, so a chip never shows a per-word column whose single 

8730 value would mislead. The trial-level set is computed once (sampling the first 

8731 trial) and cached per column-signature; a **Refresh** button recomputes it. 

8732 Default seeded once (participant + text + common conditions + summary).""" 

8733 # Cached per column-signature (stable across trials / filters within a 

8734 # dataset), recomputed on a dataset/column change or Refresh. Shared with 

8735 # UX-49's range filters, which gate on the same answer. 

8736 # DATA-20: the field universe now also depends on the attached participant 

8737 # registry, so it has to be part of the signature — attaching or detaching a 

8738 # table changes what the picker offers, and without this the component kept 

8739 # a drag order built from the old set. 

8740 signature = ( 

8741 tuple(words.columns), 

8742 tuple(fixations.columns), 

8743 tuple(field.name for field in participant_metadata_fields()), 

8744 # DATA-29 — attaching or detaching the trial table changes the offered 

8745 # set the same way, so it belongs in the signature for the same reason. 

8746 tuple(field.name for field in trial_metadata_fields()), 

8747 # And the text table, the third grain — same reasoning again. 

8748 tuple(field.name for field in text_metadata_fields()), 

8749 ) 

8750 available = _chip_field_options( 

8751 words, fixations, cached_trial_level_columns(words, fixations) 

8752 ) 

8753 if not available: 

8754 return 

8755 

8756 # Display labels must be unique to stay invertible: two fields can share a 

8757 # name (`text_id` and `unique_text_id` read from one column), so disambiguate. 

8758 names = _rail_names() 

8759 key_to_label = unique_field_labels( 

8760 available, lambda col: chip_field_label(col, names) 

8761 ) 

8762 label_to_key = {label: key for key, label in key_to_label.items()} 

8763 

8764 # Current selection/order, pruned to what's available + seeded once. 

8765 if "trial_chip_fields" in st.session_state: 

8766 st.session_state["trial_chip_fields"] = [ 

8767 f for f in st.session_state["trial_chip_fields"] if f in available 

8768 ] 

8769 st.session_state.setdefault("trial_chip_fields", _default_chip_fields(available)) 

8770 selected = list(st.session_state["trial_chip_fields"]) 

8771 hidden = [k for k in available if k not in selected] 

8772 

8773 host.caption( 

8774 "Drag fields between **Shown** and **Available**, and reorder within " 

8775 "**Shown** — these chips appear above the scanpath. Their colors are " 

8776 "set below the list." 

8777 ) 

8778 buckets = [ 

8779 { 

8780 "header": "Shown · drag to reorder", 

8781 "items": [key_to_label[k] for k in selected], 

8782 }, 

8783 {"header": "Available", "items": [key_to_label[k] for k in hidden]}, 

8784 ] 

8785 with host: 

8786 # Key varies with the field universe so the component re-mounts (rather than 

8787 # keeping a stale drag order) when the dataset / columns change. DATA-66: 

8788 # and with the labels — the component hands back the labels it holds, so 

8789 # one renamed by ✏️ Edit dataset → Save would otherwise drop its chip. 

8790 result = sort_items( 

8791 buckets, 

8792 multi_containers=True, 

8793 direction="vertical", 

8794 key="trial_chip_sort_" 

8795 f"{abs(hash((signature, tuple(key_to_label.items()))))}", 

8796 ) 

8797 shown_labels = result[0]["items"] if result else [] 

8798 st.session_state["trial_chip_fields"] = [ 

8799 label_to_key[lbl] for lbl in shown_labels if lbl in label_to_key 

8800 ] 

8801 host.button( 

8802 f"{ICONS['refresh']} Refresh fields", 

8803 key="trial_chip_refresh", 

8804 help="Re-scan which fields are trial-level (if the offered list looks off " 

8805 "for the current data).", 

8806 on_click=lambda: st.session_state.pop("_trial_level_cache", None), 

8807 ) 

8808 

8809 # UX-28: per-chip colour, generalized from what used to be a handful of 

8810 # hardcoded OneStop column/value special-cases (tabs._chip_color) — any 

8811 # dataset's condition chips can now be highlighted, not just OneStop's. 

8812 # `#EEF2F7` must match `tabs._CHIP_NEUTRAL_BG`: picking it back is how a 

8813 # chip returns to "no override" rather than staying pinned to a colour. 

8814 _neutral = "#EEF2F7" 

8815 shown_now = st.session_state["trial_chip_fields"] 

8816 if shown_now: 

8817 host.caption("Chip colors — optional highlight per shown field.") 

8818 colors = dict(st.session_state.get("trial_chip_colors") or {}) 

8819 for key in shown_now: 

8820 label_col, swatch_col = host.columns([3, 1], vertical_alignment="center") 

8821 label_col.caption(key_to_label[key]) 

8822 picked = swatch_col.color_picker( 

8823 key_to_label[key], 

8824 value=colors.get(key) or _neutral, 

8825 key=f"trial_chip_color_{key}", 

8826 label_visibility="collapsed", 

8827 help="Pick back to the default gray to remove the highlight.", 

8828 ) 

8829 if picked.lower() == _neutral.lower(): 

8830 colors.pop(key, None) 

8831 else: 

8832 colors[key] = picked 

8833 st.session_state["trial_chip_colors"] = colors 

8834 

8835 

8836def _seed_filter_widget( 

8837 key: str, options: list, default: list, *, prefix: str = "" 

8838) -> None: 

8839 """Pre-seed a filter widget's state from the persistent mirror. 

8840 

8841 Filter controls live in the Scanpath tab body, which doesn't render on every 

8842 run (other views on the top nav). Streamlit clears a not-rendered 

8843 widget's key, so on return we re-seed it from ``_trial_filters_raw`` (the last 

8844 selections), dropping any value no longer in ``options`` (e.g. after a dataset 

8845 switch). Setting the key *before* the widget renders avoids the 

8846 default-plus-session-state warning.""" 

8847 if key in st.session_state: 

8848 return 

8849 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {}) 

8850 if key in mirror: 

8851 kept = [v for v in mirror[key] if v in options] 

8852 st.session_state[key] = kept if kept else list(default) 

8853 else: 

8854 st.session_state[key] = list(default) 

8855 

8856 

8857def _seed_range_widget(col: str, lo, hi, *, prefix: str = "") -> None: 

8858 """Pre-seed a range slider from the persistent mirror, clamped to the column. 

8859 

8860 The range twin of :func:`_seed_filter_widget`, and it needs its own because 

8861 the stored value is a *pair*, not a list of options to intersect. A stored 

8862 range outside the current column's extent (a dataset switch, or a filter that 

8863 shrank the pool) is clamped rather than dropped, so the slider never renders 

8864 a value Streamlit would reject. 

8865 

8866 The seeded pair keeps ``lo``/``hi``'s own type: Streamlit reads int-vs-float 

8867 slider behaviour off the values, so seeding ``(0.0, 10.0)`` for an integer 

8868 column would put it back on decimal steps. 

8869 """ 

8870 cast = int if isinstance(lo, int) and isinstance(hi, int) else float 

8871 key = _range_filter_key(col, prefix) 

8872 

8873 def _clamped(pair) -> tuple: 

8874 return ( 

8875 cast(min(max(pair[0], lo), hi)), 

8876 cast(min(max(pair[1], lo), hi)), 

8877 ) 

8878 

8879 if key in st.session_state: 

8880 stored = st.session_state[key] 

8881 if isinstance(stored, (tuple, list)) and len(stored) == 2: 

8882 st.session_state[key] = _clamped(stored) 

8883 return 

8884 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {}) 

8885 stored = mirror.get(key) 

8886 if isinstance(stored, (tuple, list)) and len(stored) == 2: 

8887 st.session_state[key] = _clamped(stored) 

8888 else: 

8889 st.session_state[key] = (lo, hi) 

8890 

8891 

8892def _filter_fields_for(words: pd.DataFrame, fixations: pd.DataFrame) -> list: 

8893 """Trial-level condition columns to offer as filters (wizard-chosen for an 

8894 upload, else the built-in defaults present in the data).""" 

8895 filter_fields = st.session_state.get("wizard_filter_fields") 

8896 if filter_fields is None: 

8897 filter_fields = [ 

8898 c 

8899 for c in _DEFAULT_FILTER_FIELDS 

8900 if c in words.columns or c in fixations.columns 

8901 ] 

8902 return filter_fields 

8903 

8904 

8905def cached_trial_level_columns(words: pd.DataFrame, fixations: pd.DataFrame) -> set: 

8906 """``_trial_level_columns`` memoized per column-signature for this session. 

8907 

8908 Trial-level-ness is a property of the dataset's shape, so the signature is 

8909 the two frames' column tuples — stable across trials and filters. Shared by 

8910 the ✏️ Edit chips picker and UX-49's range filters, which need the *same* 

8911 answer: a column that varies inside a trial would filter rows rather than 

8912 trials, silently cutting a scanpath in half. 

8913 """ 

8914 signature = (tuple(words.columns), tuple(fixations.columns)) 

8915 cache = st.session_state.get("_trial_level_cache") 

8916 if not cache or cache.get("signature") != signature: 

8917 cache = { 

8918 "signature": signature, 

8919 "fields": _trial_level_columns(words, fixations), 

8920 } 

8921 st.session_state["_trial_level_cache"] = cache 

8922 return cache["fields"] 

8923 

8924 

8925def _range_filter_key(col: str, prefix: str = "") -> str: 

8926 """Session key of ``col``'s range slider (prefix-scoped, CMP-8 §5.2). 

8927 

8928 Under the ``filter_`` prefix on purpose: that is what gets it swept by 

8929 *✕ Clear all filters* and kept out of compare-mode B's namespace for free. 

8930 """ 

8931 return f"{prefix}filter_{col}_range" 

8932 

8933 

8934#: The stem a range filter's *Keep unknown values* key carries after 

8935#: ``filter_`` — see :func:`keep_unknown_key`. 

8936_KEEP_UNKNOWN_STEM = "keepunknown_" 

8937 

8938 

8939def keep_unknown_key(range_key: str) -> str: 

8940 """Session key of the *Keep unknown values* choice beside a range filter. 

8941 

8942 ``filter_score_range`` → ``filter_keepunknown_score_range``, 

8943 ``cmpfilter_meta_age`` → ``cmpfilter_keepunknown_meta_age``. Under the 

8944 filter layer's own ``…filter_`` prefix, so *✕ Clear all filters*, the 

8945 ``_trial_filters_raw`` mirror and compare-mode B's namespace treat it as 

8946 one more filter key without being taught about it. A plain session key: 

8947 trial filters travel in no share link or settings file, so it is not a 

8948 wire key either. ``True`` (or absent) keeps the records with no value — 

8949 the filter's long-standing rule; ``False`` leaves them out. 

8950 """ 

8951 head, sep, rest = range_key.partition("filter_") 

8952 if not sep or rest.startswith(_KEEP_UNKNOWN_STEM): 

8953 return range_key 

8954 return f"{head}filter_{_KEEP_UNKNOWN_STEM}{rest}" 

8955 

8956 

8957def _keeps_unknown(range_key: str, prefix: str = "") -> bool: 

8958 """The *Keep unknown values* choice for ``range_key``, mirror as fallback.""" 

8959 key = keep_unknown_key(range_key) 

8960 if key in st.session_state: 

8961 return st.session_state[key] is not False 

8962 mirror = st.session_state.get(f"{prefix}_trial_filters_raw") or {} 

8963 return mirror.get(key, True) is not False 

8964 

8965 

8966def _render_keep_unknown( 

8967 host, range_key: str, *, unknown: int, noun: str, prefix: str, on_change 

8968) -> None: 

8969 """The *Keep unknown values* checkbox under a range, and what it does. 

8970 

8971 Drawn only when some record has no value — with none, there is nothing to 

8972 keep or leave out, and the stale choice is dropped so it cannot narrow a 

8973 pool where it means nothing. The caption names how many records it 

8974 concerns, in the unit the filter keeps or drops. 

8975 """ 

8976 key = keep_unknown_key(range_key) 

8977 if unknown <= 0: 

8978 st.session_state.pop(key, None) 

8979 return 

8980 if key not in st.session_state: 

8981 mirror = st.session_state.get(f"{prefix}_trial_filters_raw") or {} 

8982 st.session_state[key] = mirror.get(key, True) is not False 

8983 _labeled( 

8984 host, 

8985 "checkbox", 

8986 "Keep unknown values", 

8987 key=key, 

8988 on_change=on_change, 

8989 help=f"On: {noun}s with no value are kept whatever the range. " 

8990 f"Off: only {noun}s with a value in the range are kept.", 

8991 ) 

8992 plural = f"{unknown:,} {noun}{'s' if unknown != 1 else ''}" 

8993 one = unknown == 1 

8994 verb = "has" if one else "have" 

8995 if st.session_state[key]: 

8996 fate = "and is kept" if one else "and are kept" 

8997 else: 

8998 fate = "and is left out" if one else "and are left out" 

8999 host.caption(f"{plural} {verb} no value {fate}.") 

9000 

9001 

9002def _render_fixed_value(host, label: str, value: float, noun: str) -> None: 

9003 """A numeric field with one value: shown as that value, not a slider. 

9004 

9005 Streamlit refuses a slider whose ends are equal, and there is no range to 

9006 pick anyway. 

9007 """ 

9008 host.caption(f"**{label}**: {value:,.10g} for every {noun} that has a value.") 

9009 

9010 

9011def _numeric_filter_fields( 

9012 words: pd.DataFrame, fixations: pd.DataFrame 

9013) -> dict[str, tuple]: 

9014 """UX-49: which of the offered filter fields render as a *range*, and over what. 

9015 

9016 Maps column → ``(frame, lo, hi)``. The set of columns the panel offers does 

9017 **not** grow — this only auto-detects the *dtype* of what 

9018 ``_filter_fields_for`` already returns, so a numeric one renders as a 

9019 two-ended slider instead of a multiselect with one option per distinct float. 

9020 That also bounds the panel's size: it can show no more rows than it does now. 

9021 

9022 Two gates, both load-bearing. **Trial-level**: a column that varies inside a 

9023 trial (fixation duration, word surprisal) would filter *rows*, not trials — 

9024 that is PRE-2's render-layer territory, not this one. **Two distinct finite 

9025 values**: fewer, and there is no range to pick. 

9026 """ 

9027 trial_level = cached_trial_level_columns(words, fixations) 

9028 fields: dict[str, tuple] = {} 

9029 for col in _filter_fields_for(words, fixations): 

9030 if col not in trial_level: 

9031 continue 

9032 frame = words if col in words.columns else fixations 

9033 if col not in frame.columns or pd.api.types.is_bool_dtype(frame[col]): 

9034 continue 

9035 if not pd.api.types.is_numeric_dtype(frame[col]): 

9036 continue 

9037 bounds = _numeric_column_bounds( 

9038 frame, col, cache_key=(frame_fingerprint(frame), col) 

9039 ) 

9040 if bounds is None: 

9041 continue 

9042 lo, hi, _distinct = bounds 

9043 fields[col] = (frame, lo, hi) 

9044 return fields 

9045 

9046 

9047def metadata_filter_key(name: str, prefix: str = "") -> str: 

9048 """Session key of a participant-metadata filter (DATA-20). 

9049 

9050 Under the ``filter_`` prefix like every other trial filter, which is what 

9051 gets it swept by *✕ Clear all filters*, mirrored into 

9052 ``_trial_filters_raw``, and kept out of compare-mode B's namespace — all 

9053 for free, rather than by teaching each of those about a new field kind. 

9054 """ 

9055 return f"{prefix}filter_meta_{name}" 

9056 

9057 

9058def trial_metadata_filter_key(name: str, prefix: str = "") -> str: 

9059 """Session key of a trial-metadata filter (DATA-29). 

9060 

9061 Its own ``filter_trialmeta_`` prefix rather than DATA-20's ``filter_meta_``: 

9062 the two tables may legitimately register the *same* column name at different 

9063 grains (a ``session`` column describing readers and one describing trials), 

9064 and a shared prefix would collide them onto one widget. Still under 

9065 ``filter_``, so *✕ Clear all filters*, the ``_trial_filters_raw`` mirror and 

9066 compare-mode B's namespace keep working without being taught about it. 

9067 """ 

9068 return f"{prefix}filter_trialmeta_{name}" 

9069 

9070 

9071def text_metadata_filter_key(name: str, prefix: str = "") -> str: 

9072 """Session key of a text-metadata filter — the third grain. 

9073 

9074 Its own ``filter_textmeta_`` prefix, on the same reasoning as 

9075 ``trial_metadata_filter_key``: a table's column name may legitimately 

9076 collide with one at another grain, and a shared prefix would collide the 

9077 widgets. Still under ``filter_``, so the clear-all sweep, the 

9078 ``_trial_filters_raw`` mirror and compare-mode B's namespace keep working. 

9079 """ 

9080 return f"{prefix}filter_textmeta_{name}" 

9081 

9082 

9083def participant_metadata_fields(): 

9084 """The attached participant table's fields, or ``()`` when none (DATA-20).""" 

9085 from scanpath_studio.tabs import active_participant_metadata 

9086 

9087 attached = active_participant_metadata() 

9088 return attached.fields if attached is not None else () 

9089 

9090 

9091def trial_metadata_fields(): 

9092 """The attached trial table's fields, or ``()`` when none (DATA-29).""" 

9093 from scanpath_studio import metadata as md 

9094 

9095 attached = md.active_trials() 

9096 return attached.fields if attached is not None else () 

9097 

9098 

9099def text_metadata_fields(): 

9100 """The attached text table's fields, or ``()`` when none — third grain.""" 

9101 from scanpath_studio import metadata as md 

9102 

9103 attached = md.active_texts() 

9104 return attached.fields if attached is not None else () 

9105 

9106 

9107def _render_metadata_range( 

9108 host, 

9109 attached, 

9110 field, 

9111 key: str, 

9112 *, 

9113 noun: str, 

9114 table: str, 

9115 unknown: int, 

9116 prefix: str, 

9117 on_change, 

9118) -> None: 

9119 """One numeric metadata field's filter, at any of the three grains. 

9120 

9121 A slider over the loaded records' values, then *Keep unknown values* when 

9122 some record has none. A field with one value has no range to slide — 

9123 Streamlit refuses a slider whose ends are equal, which stopped the whole 

9124 Scanpath view for a one-row trial table — so it is shown as that value, 

9125 and its unknowns choice is the only narrowing it offers. 

9126 """ 

9127 extent = _metadata_numeric_summary(attached)[field.name][0] 

9128 if extent is None: 

9129 st.session_state.pop(keep_unknown_key(key), None) 

9130 return 

9131 low, high = extent 

9132 if low < high: 

9133 _seed_range_bounds(key, low, high, prefix=prefix) 

9134 host.slider( 

9135 field.label, 

9136 min_value=low, 

9137 max_value=high, 

9138 key=key, 

9139 on_change=on_change, 

9140 help=f"From your {table} table ({field.source}). Keep only {noun}s " 

9141 "whose value falls in this range.", 

9142 ) 

9143 else: 

9144 _render_fixed_value(host, field.label, low, noun) 

9145 _render_keep_unknown( 

9146 host, key, unknown=unknown, noun=noun, prefix=prefix, on_change=on_change 

9147 ) 

9148 

9149 

9150def _metadata_range_narrowing(attached, field, key: str, prefix: str): 

9151 """``(range, keep_unknown)`` one numeric metadata field narrows by, or 

9152 ``None`` when it does not narrow. 

9153 

9154 It narrows when its slider is off full extent, or when *Keep unknown 

9155 values* is off — then even the full extent (or a constant field's one 

9156 value) leaves out the records with no value. 

9157 """ 

9158 extent = _metadata_numeric_summary(attached)[field.name][0] 

9159 if extent is None: 

9160 return None 

9161 keep = _keeps_unknown(key, prefix) 

9162 chosen = st.session_state.get(key) 

9163 if ( 

9164 extent[0] < extent[1] 

9165 and isinstance(chosen, (tuple, list)) 

9166 and len(chosen) == 2 

9167 and tuple(chosen) != extent 

9168 ): 

9169 return (float(chosen[0]), float(chosen[1])), keep 

9170 if not keep: 

9171 return extent, keep 

9172 return None 

9173 

9174 

9175def _render_participant_metadata_filters(host, *, prefix: str, on_change) -> None: 

9176 """One control per registered participant-grain field. 

9177 

9178 A reader attribute narrows by **reader**, so these do not need the field to 

9179 exist on the words/fixations frames — `_compute_trial_filters` resolves the 

9180 selection to a set of participant ids and intersects it with the participant 

9181 filter. That is the whole reason the table never has to be broadcast. 

9182 """ 

9183 from scanpath_studio import metadata as md 

9184 

9185 attached = md.attached_for("participant", prefix) 

9186 if attached is None or not attached.fields: 

9187 return 

9188 host.markdown("**By participant**") 

9189 for field in attached.fields: 

9190 key = metadata_filter_key(field.name, prefix) 

9191 if field.is_numeric: 

9192 _render_metadata_range( 

9193 host, 

9194 attached, 

9195 field, 

9196 key, 

9197 noun="participant", 

9198 table="participant", 

9199 unknown=_metadata_numeric_summary(attached)[field.name][1], 

9200 prefix=prefix, 

9201 on_change=on_change, 

9202 ) 

9203 continue 

9204 options = md.options_for(attached, field.name) 

9205 if len(options) <= 1: 

9206 continue 

9207 _seed_filter_widget(key, options, options, prefix=prefix) 

9208 _labeled( 

9209 host, 

9210 "multiselect", 

9211 field.label, 

9212 options=options, 

9213 key=key, 

9214 on_change=on_change, 

9215 help=f"From your participant table ({field.source}).", 

9216 ) 

9217 

9218 

9219def _render_trial_metadata_filters( 

9220 host, 

9221 *, 

9222 prefix: str, 

9223 on_change, 

9224 keys: Callable[[], set] | None = None, 

9225 pool_key: tuple | None = None, 

9226) -> None: 

9227 """One control per registered trial-grain field (DATA-29). 

9228 

9229 The mirror image of :func:`_render_participant_metadata_filters`, and simpler 

9230 for the reason DATA-29 opened with: a trial attribute already *is* the grain 

9231 the pool is keyed on, so the selection resolves straight to 

9232 ``(participant_id, trial_id)`` keys with no reader indirection in between. 

9233 

9234 ``keys`` returns the loaded pool's ``(participant_id, trial_id)`` pairs, so 

9235 the *Keep unknown values* caption counts readings; without it the count is 

9236 of the table's own keys. ``pool_key`` identifies that pool (the frames' 

9237 fingerprints) for the cached count, so ``keys`` is called only on a miss. 

9238 """ 

9239 from scanpath_studio import metadata as md 

9240 

9241 attached = md.attached_for("trial", prefix) 

9242 if attached is None or not attached.fields: 

9243 return 

9244 host.markdown("**By trial**") 

9245 for field in attached.fields: 

9246 key = trial_metadata_filter_key(field.name, prefix) 

9247 if field.is_numeric: 

9248 _render_metadata_range( 

9249 host, 

9250 attached, 

9251 field, 

9252 key, 

9253 noun="trial", 

9254 table="trial", 

9255 unknown=_metadata_numeric_summary(attached, keys, pool_key)[field.name][ 

9256 1 

9257 ], 

9258 prefix=prefix, 

9259 on_change=on_change, 

9260 ) 

9261 continue 

9262 options = md.trial_options_for(attached, field.name) 

9263 if len(options) <= 1: 

9264 continue 

9265 _seed_filter_widget(key, options, options, prefix=prefix) 

9266 _labeled( 

9267 host, 

9268 "multiselect", 

9269 field.label, 

9270 options=options, 

9271 key=key, 

9272 on_change=on_change, 

9273 help=f"From your trial table ({field.source}).", 

9274 ) 

9275 

9276 

9277def _render_text_metadata_filters(host, *, prefix: str, on_change) -> None: 

9278 """One control per registered text-grain field — the third grain. 

9279 

9280 Flat, like :func:`_render_participant_metadata_filters` — a text attribute 

9281 narrows by **text**, so `_compute_trial_filters` resolves the selection to 

9282 a set of text ids and folds it into the existing "Narrow by → Text" slot 

9283 rather than a new one. 

9284 """ 

9285 from scanpath_studio import metadata as md 

9286 

9287 attached = md.attached_for("text", prefix) 

9288 if attached is None or not attached.fields: 

9289 return 

9290 host.markdown("**By text**") 

9291 for field in attached.fields: 

9292 key = text_metadata_filter_key(field.name, prefix) 

9293 if field.is_numeric: 

9294 _render_metadata_range( 

9295 host, 

9296 attached, 

9297 field, 

9298 key, 

9299 noun="text", 

9300 table="text", 

9301 unknown=_metadata_numeric_summary(attached)[field.name][1], 

9302 prefix=prefix, 

9303 on_change=on_change, 

9304 ) 

9305 continue 

9306 options = md.text_options_for(attached, field.name) 

9307 if len(options) <= 1: 

9308 continue 

9309 _seed_filter_widget(key, options, options, prefix=prefix) 

9310 _labeled( 

9311 host, 

9312 "multiselect", 

9313 field.label, 

9314 options=options, 

9315 key=key, 

9316 on_change=on_change, 

9317 help=f"From your text table ({field.source}).", 

9318 ) 

9319 

9320 

9321def _text_metadata_narrowing(prefix: str) -> tuple: 

9322 """``(text ids | None, widget keys)`` for the active text-metadata filters. 

9323 

9324 Flat-grain sibling of :func:`_participant_metadata_narrowing` — ``None`` 

9325 means no constraint; an **empty set** means a constraint nothing satisfies. 

9326 """ 

9327 from scanpath_studio import metadata as md 

9328 

9329 attached = md.attached_for("text", prefix) 

9330 if attached is None or not attached.fields: 

9331 return None, () 

9332 selections: dict[str, list] = {} 

9333 ranges: dict[str, tuple] = {} 

9334 keep_unknown: dict[str, bool] = {} 

9335 keys: list = [] 

9336 for field in attached.fields: 

9337 key = text_metadata_filter_key(field.name, prefix) 

9338 chosen = st.session_state.get(key) 

9339 if field.is_numeric: 

9340 narrowing = _metadata_range_narrowing(attached, field, key, prefix) 

9341 if narrowing is not None: 

9342 ranges[field.name], keep_unknown[field.name] = narrowing 

9343 keys.append(key) 

9344 continue 

9345 options = md.text_options_for(attached, field.name) 

9346 if chosen and len(chosen) < len(options): 

9347 selections[field.name] = list(chosen) 

9348 keys.append(key) 

9349 return ( 

9350 md.texts_matching(attached, selections, ranges, keep_unknown=keep_unknown), 

9351 tuple(keys), 

9352 ) 

9353 

9354 

9355def _trial_metadata_narrowing(prefix: str, keys) -> tuple: 

9356 """``((participant_id, trial_id) keys | None, widget keys)`` (DATA-29). 

9357 

9358 ``None`` means no constraint; an empty set means a constraint nothing 

9359 satisfies — the same three-way contract as 

9360 :func:`_participant_metadata_narrowing`, one grain down. ``keys`` is a 

9361 callable returning the loaded pool's keys, called only when a trial table 

9362 is attached: it scans every frame, and this runs on every rerun. 

9363 """ 

9364 from scanpath_studio import metadata as md 

9365 

9366 attached = md.attached_for("trial", prefix) 

9367 if attached is None or not attached.fields: 

9368 return None, () 

9369 selections: dict[str, list] = {} 

9370 ranges: dict[str, tuple] = {} 

9371 keep_unknown: dict[str, bool] = {} 

9372 widget_keys: list = [] 

9373 for field in attached.fields: 

9374 key = trial_metadata_filter_key(field.name, prefix) 

9375 chosen = st.session_state.get(key) 

9376 if field.is_numeric: 

9377 narrowing = _metadata_range_narrowing(attached, field, key, prefix) 

9378 if narrowing is not None: 

9379 ranges[field.name], keep_unknown[field.name] = narrowing 

9380 widget_keys.append(key) 

9381 continue 

9382 options = md.trial_options_for(attached, field.name) 

9383 if chosen and len(chosen) < len(options): 

9384 selections[field.name] = list(chosen) 

9385 widget_keys.append(key) 

9386 return ( 

9387 md.trials_matching( 

9388 attached, selections, ranges, keys=keys(), keep_unknown=keep_unknown 

9389 ), 

9390 tuple(widget_keys), 

9391 ) 

9392 

9393 

9394def _seed_range_bounds(key: str, low: float, high: float, *, prefix: str) -> None: 

9395 """``_seed_range_widget`` for a key that is not derived from a column name.""" 

9396 

9397 def _clamped(pair) -> tuple: 

9398 return (min(max(pair[0], low), high), min(max(pair[1], low), high)) 

9399 

9400 if key in st.session_state: 

9401 stored = st.session_state[key] 

9402 if isinstance(stored, (tuple, list)) and len(stored) == 2: 

9403 st.session_state[key] = _clamped(stored) 

9404 return 

9405 mirror = st.session_state.get(f"{prefix}_trial_filters_raw", {}) 

9406 stored = mirror.get(key) 

9407 if isinstance(stored, (tuple, list)) and len(stored) == 2: 

9408 st.session_state[key] = _clamped(stored) 

9409 else: 

9410 st.session_state[key] = (low, high) 

9411 

9412 

9413def _participant_metadata_narrowing(prefix: str) -> tuple: 

9414 """``(reader ids | None, widget keys)`` for the active metadata filters. 

9415 

9416 ``None`` means no constraint; an **empty set** means a constraint nothing 

9417 satisfies. The keys are the widgets that produced it, so UX-7's per-filter 

9418 clear can reset the right controls. 

9419 """ 

9420 from scanpath_studio import metadata as md 

9421 

9422 attached = md.attached_for("participant", prefix) 

9423 if attached is None or not attached.fields: 

9424 return None, () 

9425 selections: dict[str, list] = {} 

9426 ranges: dict[str, tuple] = {} 

9427 keep_unknown: dict[str, bool] = {} 

9428 keys: list = [] 

9429 for field in attached.fields: 

9430 key = metadata_filter_key(field.name, prefix) 

9431 chosen = st.session_state.get(key) 

9432 if field.is_numeric: 

9433 narrowing = _metadata_range_narrowing(attached, field, key, prefix) 

9434 if narrowing is not None: 

9435 ranges[field.name], keep_unknown[field.name] = narrowing 

9436 keys.append(key) 

9437 continue 

9438 options = md.options_for(attached, field.name) 

9439 if chosen and len(chosen) < len(options): 

9440 selections[field.name] = list(chosen) 

9441 keys.append(key) 

9442 return ( 

9443 md.participants_matching( 

9444 attached, selections, ranges, keep_unknown=keep_unknown 

9445 ), 

9446 tuple(keys), 

9447 ) 

9448 

9449 

9450def _loaded_trial_keys(words: pd.DataFrame, fixations: pd.DataFrame) -> set: 

9451 """``(participant_id, trial_id)`` pairs present in either frame (DATA-29).""" 

9452 from scanpath_studio.data import frame_fingerprint 

9453 

9454 return set( 

9455 _c_loaded_trial_keys( 

9456 words, fixations, frame_fingerprint(words), frame_fingerprint(fixations) 

9457 ) 

9458 ) 

9459 

9460 

9461@st.cache_data(show_spinner=False, max_entries=8) 

9462def _c_loaded_trial_keys(_words, _fixations, wkey, fkey) -> frozenset: 

9463 from scanpath_studio.data import trial_keys as _keys 

9464 

9465 return frozenset(_keys(_words)) | frozenset(_keys(_fixations)) 

9466 

9467 

9468def _pool_fingerprint(words: pd.DataFrame, fixations: pd.DataFrame) -> tuple: 

9469 """The loaded pool's identity, for a cache keyed on it.""" 

9470 from scanpath_studio.data import frame_fingerprint 

9471 

9472 return (frame_fingerprint(words), frame_fingerprint(fixations)) 

9473 

9474 

9475def _metadata_numeric_summary( 

9476 attached, keys: Callable[[], set] | None = None, pool_key: tuple | None = None 

9477) -> dict: 

9478 """``{field: (extent, unknown count)}`` for an attached metadata table's 

9479 numeric fields — what each range filter is drawn and narrowed from. 

9480 

9481 Cached on the table's content (its frame's fingerprint and its join report) 

9482 and, when ``keys`` counts unknowns over the loaded pool, on ``pool_key``: 

9483 the filter panel draws on every rerun, and these were several Python passes 

9484 over the table and the pool per numeric field each time. ``keys`` without a 

9485 ``pool_key`` is called every time, and the pool it returns is the key. 

9486 """ 

9487 from scanpath_studio.data import frame_fingerprint 

9488 

9489 names = tuple(f.name for f in attached.fields if f.is_numeric) 

9490 table_key = ( 

9491 type(attached).__name__, 

9492 frame_fingerprint(attached.frame), 

9493 hash(attached.report), 

9494 names, 

9495 ) 

9496 if keys is not None and pool_key is None: 

9497 loaded = frozenset(keys()) 

9498 keys, pool_key = (lambda: loaded), loaded 

9499 return _c_metadata_numeric_summary(attached, table_key, keys, pool_key) 

9500 

9501 

9502@st.cache_data(show_spinner=False, max_entries=16) 

9503def _c_metadata_numeric_summary(_attached, table_key, _keys, pool_key) -> dict: 

9504 from scanpath_studio import metadata as md 

9505 

9506 loaded = _keys() if _keys is not None else None 

9507 return { 

9508 name: ( 

9509 md.numeric_extent(_attached, name), 

9510 md.unknown_count(_attached, name, loaded), 

9511 ) 

9512 for name in table_key[-1] 

9513 } 

9514 

9515 

9516def _compute_trial_filters( 

9517 words: pd.DataFrame, fixations: pd.DataFrame, *, prefix: str = "" 

9518) -> dict: 

9519 """Derive the narrowing filter result from the live filter-widget values. 

9520 

9521 Reads the widget keys (filter_participants / filter_<col> / filter_favorites / 

9522 filter_req_tags / filter_exc_tags) — which Streamlit has already updated on the 

9523 rerun the user changed a filter — so the filter applies on the SAME run. The 

9524 on_change callbacks in ``render_trial_filters`` call this *before* the rerun; 

9525 it also runs at the end of that function for no-change runs. Only narrowing 

9526 selections feed the result. 

9527 """ 

9528 result: dict = { 

9529 "participants": None, 

9530 "metadata": {}, 

9531 "ranges": {}, 

9532 # Ranged columns whose *Keep unknown values* is off. 

9533 "ranges_drop_unknown": (), 

9534 # column -> the session key holding it, so "clear just this filter" 

9535 # (UX-7) can reset one widget. Not derivable from the column name: the 

9536 # Narrow-by Text multiselect lands in `metadata` under the *text column* 

9537 # but lives under `filter_text_id`. 

9538 "metadata_keys": {}, 

9539 # DATA-20: widget keys behind a participant-grain metadata narrowing, 

9540 # which lands in `participants` above rather than in `metadata`. 

9541 "participant_filter_keys": (), 

9542 # DATA-29: a trial-grain narrowing is already `(participant_id, 

9543 # trial_id)` keys, so it gets its own slot rather than being squeezed 

9544 # into `participants` (a reader may be kept for one trial and dropped 

9545 # for another) or `metadata` (which is column → values on the frames). 

9546 # `None` = no constraint; an empty set = nothing satisfies it. 

9547 "trial_keys": None, 

9548 "trial_filter_keys": (), 

9549 # Text-grain metadata's own widget keys, folded into the existing 

9550 # "Narrow by → Text" slot above (`metadata`/`metadata_keys`) rather 

9551 # than a fourth top-level slot — this is only for UX-7's per-filter 

9552 # clear, since the *value* already lives in `metadata[text_field]`. 

9553 "text_filter_keys": (), 

9554 "favorites_only": False, 

9555 "required_tags": [], 

9556 "excluded_tags": [], 

9557 } 

9558 parts = _participant_options( 

9559 words, 

9560 fixations, 

9561 cache_key=(frame_fingerprint(words), frame_fingerprint(fixations)), 

9562 ) 

9563 if len(parts) > 1: 

9564 sel = st.session_state.get(f"{prefix}filter_participants") 

9565 if sel and len(sel) < len(parts): 

9566 result["participants"] = list(sel) 

9567 # DATA-20: a participant-grain metadata constraint *is* a participant 

9568 # constraint, so it folds into the same slot rather than becoming a fourth 

9569 # kind of filter every consumer would have to learn. Intersection, not 

9570 # replacement: an explicit reader pick still wins over the table. 

9571 by_metadata, meta_keys = _participant_metadata_narrowing(prefix) 

9572 if by_metadata is not None: 

9573 chosen = result["participants"] 

9574 keep = by_metadata if chosen is None else by_metadata & {str(p) for p in chosen} 

9575 # Ordered by the dataset's own participant order, so the resulting 

9576 # selector list doesn't reshuffle when a metadata filter changes. May 

9577 # legitimately be **empty** — an impossible combination narrows to no 

9578 # reader, which `data.filter_trials` distinguishes from "no constraint". 

9579 result["participants"] = [p for p in parts if str(p) in keep] 

9580 # UX-7's per-filter report clears by widget key, and this narrowing did 

9581 # not come from `filter_participants`. Its own top-level slot, not an 

9582 # entry in `metadata_keys`: that dict is column-name → *one* key string, 

9583 # and a dataset is free to have a column called "participants". 

9584 result["participant_filter_keys"] = meta_keys 

9585 # DATA-29: the trial table narrows to keys directly. Computed against the 

9586 # loaded pool so a trial-id-keyed table expands to every reading of that 

9587 # trial, and so a numeric range keeps the trials the table never mentions 

9588 # (UX-49's rule, one grain down). 

9589 by_trial, trial_keys_used = _trial_metadata_narrowing( 

9590 prefix, lambda: _loaded_trial_keys(words, fixations) 

9591 ) 

9592 if by_trial is not None: 

9593 result["trial_keys"] = by_trial 

9594 result["trial_filter_keys"] = trial_keys_used 

9595 # Text narrowing (the "Narrow by → Text" multiselect). Like a categorical 

9596 # condition, but the text id isn't in the condition list, so handle it here. 

9597 text_field, text_frame = _text_field_and_frame(words, fixations) 

9598 if text_field is not None: 

9599 text_vals = _column_unique_strs( 

9600 text_frame, 

9601 text_field, 

9602 cache_key=(frame_fingerprint(text_frame), text_field), 

9603 ) 

9604 sel = st.session_state.get(f"{prefix}filter_text_id") 

9605 by_picker = ( 

9606 set(sel) 

9607 if sel and len(text_vals) > 1 and len(sel) < len(text_vals) 

9608 else None 

9609 ) 

9610 # Text-grain metadata folds into the same slot, the same way 

9611 # participant-grain metadata folds into `result["participants"]` — an 

9612 # explicit pick still wins over (intersects with) the table. 

9613 by_text_meta, text_meta_keys = _text_metadata_narrowing(prefix) 

9614 combined = None 

9615 if by_picker is not None and by_text_meta is not None: 

9616 combined = by_picker & by_text_meta 

9617 elif by_picker is not None: 

9618 combined = by_picker 

9619 elif by_text_meta is not None: 

9620 combined = by_text_meta & {str(v) for v in text_vals} 

9621 if combined is not None: 

9622 result["metadata"][text_field] = combined 

9623 # UX-7's per-filter clear pops exactly this key, so it must be 

9624 # emitted already-prefixed or clearing one of B's filters no-ops. 

9625 result["metadata_keys"][text_field] = f"{prefix}filter_text_id" 

9626 result["text_filter_keys"] = text_meta_keys 

9627 # UX-49: numeric trial-level columns narrow by range, not by membership. A 

9628 # slider still at full extent is "no filter" and contributes nothing. 

9629 # With *Keep unknown values* off, even the full extent narrows: it leaves 

9630 # out the trials with no value. 

9631 numeric_fields = _numeric_filter_fields(words, fixations) 

9632 drop_unknown: list = [] 

9633 for col, (_frame, lo, hi) in numeric_fields.items(): 

9634 key = _range_filter_key(col, prefix) 

9635 chosen = st.session_state.get(key) 

9636 keep = _keeps_unknown(key, prefix) 

9637 if isinstance(chosen, (tuple, list)) and len(chosen) == 2: 

9638 sel_lo, sel_hi = float(chosen[0]), float(chosen[1]) 

9639 else: 

9640 sel_lo, sel_hi = float(lo), float(hi) 

9641 if sel_lo <= lo and sel_hi >= hi and keep: 

9642 continue 

9643 result["ranges"][col] = (sel_lo, sel_hi) 

9644 result["metadata_keys"][col] = key 

9645 if not keep: 

9646 drop_unknown.append(col) 

9647 result["ranges_drop_unknown"] = tuple(drop_unknown) 

9648 for col in _filter_fields_for(words, fixations): 

9649 if col in numeric_fields: 

9650 continue 

9651 frame = words if col in words.columns else fixations 

9652 if col not in frame.columns: 

9653 continue 

9654 spec = _FILTER_FIELD_LABELS.get(col, {}) 

9655 if pd.api.types.is_bool_dtype(frame[col]): 

9656 vals = _bool_filter_narrowing( 

9657 col, 

9658 frame, 

9659 spec.get("true", "Yes"), 

9660 spec.get("false", "No"), 

9661 f"{prefix}filter_{col}", 

9662 ) 

9663 if vals is not None: 

9664 result["metadata"][col] = vals 

9665 result["metadata_keys"][col] = f"{prefix}filter_{col}" 

9666 else: 

9667 values = _column_unique_strs( 

9668 frame, col, cache_key=(frame_fingerprint(frame), col) 

9669 ) 

9670 sel = st.session_state.get(f"{prefix}filter_{col}") 

9671 if sel and len(values) > 1 and len(sel) < len(values): 

9672 result["metadata"][col] = set(sel) 

9673 result["metadata_keys"][col] = f"{prefix}filter_{col}" 

9674 result["favorites_only"] = bool( 

9675 st.session_state.get(f"{prefix}filter_favorites", False) 

9676 ) 

9677 result["required_tags"] = list( 

9678 st.session_state.get(f"{prefix}filter_req_tags") or [] 

9679 ) 

9680 result["excluded_tags"] = list( 

9681 st.session_state.get(f"{prefix}filter_exc_tags") or [] 

9682 ) 

9683 return result 

9684 

9685 

9686def _text_field_and_frame(words: pd.DataFrame, fixations: pd.DataFrame): 

9687 """The text/passage id column to narrow by + the frame it lives on (prefer 

9688 fixations, where trials live). ``(None, fixations)`` when no text column.""" 

9689 for field in ("unique_text_id", "text_id"): 

9690 if field in fixations.columns: 

9691 return field, fixations 

9692 if field in words.columns: 

9693 return field, words 

9694 return None, fixations 

9695 

9696 

9697def render_narrow_by( 

9698 words: pd.DataFrame, 

9699 fixations: pd.DataFrame, 

9700 *, 

9701 prefix: str = "", 

9702 text_host=None, 

9703 part_host=None, 

9704) -> None: 

9705 """Inline **Narrow by** multiselects — Text + Participant — that narrow the 

9706 trial pool feeding the picker (the former Browse-by Text/Participant modes, now 

9707 filters). They write the same ``filter_*`` keys the "More" popover uses and 

9708 recompute via ``_compute_trial_filters``, so narrowing applies the same run. 

9709 Start empty = no narrowing; pick values to narrow.""" 

9710 

9711 def _apply() -> None: 

9712 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters( 

9713 words, fixations, prefix=prefix 

9714 ) 

9715 

9716 th = text_host if text_host is not None else st 

9717 ph = part_host if part_host is not None else st 

9718 

9719 text_field, text_frame = _text_field_and_frame(words, fixations) 

9720 if text_field is not None: 

9721 text_vals = _column_unique_strs( 

9722 text_frame, 

9723 text_field, 

9724 cache_key=(frame_fingerprint(text_frame), text_field), 

9725 ) 

9726 if len(text_vals) > 1: 

9727 _seed_filter_widget(f"{prefix}filter_text_id", text_vals, [], prefix=prefix) 

9728 th.multiselect( 

9729 "Text", 

9730 options=text_vals, 

9731 key=f"{prefix}filter_text_id", 

9732 on_change=_apply, 

9733 placeholder="All texts", 

9734 label_visibility="collapsed", 

9735 ) 

9736 

9737 parts = _participant_options( 

9738 words, 

9739 fixations, 

9740 cache_key=(frame_fingerprint(words), frame_fingerprint(fixations)), 

9741 ) 

9742 if len(parts) > 1: 

9743 _seed_filter_widget(f"{prefix}filter_participants", parts, [], prefix=prefix) 

9744 ph.multiselect( 

9745 "Participant", 

9746 options=parts, 

9747 key=f"{prefix}filter_participants", 

9748 on_change=_apply, 

9749 placeholder="All participants", 

9750 label_visibility="collapsed", 

9751 ) 

9752 

9753 

9754def trial_filter_labels( 

9755 words: pd.DataFrame, 

9756 fixations: pd.DataFrame, 

9757 numeric_fields: dict | None = None, 

9758 *, 

9759 names: cn.ColumnNames | None = None, 

9760) -> dict[str, str]: 

9761 """``{column: title}`` for the trial-filter panel's data-column filters. 

9762 

9763 DATA-66: a filter is titled by the dataset's own name for its column 

9764 (``names``, the open dataset's map by default; Compare's B passes its own). 

9765 UX-149: one label namespace across the range sliders and the multiselects, 

9766 so two filters over different columns never share a title — the ✏️ chip 

9767 editor's rule (`unique_field_labels`). 

9768 """ 

9769 if numeric_fields is None: 

9770 numeric_fields = _numeric_filter_fields(words, fixations) 

9771 if names is None: 

9772 names = _rail_names() 

9773 columns = [ 

9774 *numeric_fields, 

9775 *(c for c in _filter_fields_for(words, fixations) if c not in numeric_fields), 

9776 ] 

9777 return unique_field_labels(columns, names.field_label) 

9778 

9779 

9780def render_trial_filters( 

9781 words: pd.DataFrame, 

9782 fixations: pd.DataFrame, 

9783 *, 

9784 host, 

9785 prefix: str = "", 

9786 names: cn.ColumnNames | None = None, 

9787) -> dict: 

9788 """Render the trial-filter controls into ``host`` and persist the selections. 

9789 

9790 Lets the user narrow the trial pool by participant and by categorical 

9791 condition (Hunting/Gathering, difficulty, first/repeated reading, 

9792 correctness) plus annotation state (favorites / tags). Renders into ``host`` 

9793 — since UX-64 the trial picker's 🔎 funnel popover. The narrowing result is 

9794 derived by ``_compute_trial_filters`` and stashed in session_state 

9795 (`_trial_filters`); each widget's ``on_change`` recomputes it *before* the 

9796 rerun so ``main()``'s ``read_trial_filters`` applies the change on the same 

9797 run. The persisted value also survives runs where this panel isn't rendered 

9798 (a non-Scanpath view on the top nav). 

9799 ``host`` is required: it used to default to ``st.sidebar``, a container the 

9800 app has not drawn since UX-38 — a caller that forgot it rendered the whole 

9801 panel into chrome nobody sees. Now it cannot compile. 

9802 """ 

9803 

9804 def _apply() -> None: 

9805 st.session_state[f"{prefix}_trial_filters"] = _compute_trial_filters( 

9806 words, fixations, prefix=prefix 

9807 ) 

9808 

9809 # Text + Participant narrowing now lives in the inline "Narrow by" row 

9810 # (``render_narrow_by``); this popover keeps the condition + annotation filters. 

9811 # 

9812 # UX-49: a numeric trial-level column gets a two-ended range slider instead 

9813 # of a multiselect over its distinct floats. Rendered first, as extra rows 

9814 # among the categorical ones rather than in a section of their own. 

9815 numeric_fields = _numeric_filter_fields(words, fixations) 

9816 labels = trial_filter_labels(words, fixations, numeric_fields, names=names) 

9817 for col, (frame, lo, hi) in numeric_fields.items(): 

9818 label = labels[col] 

9819 _seed_range_widget(col, lo, hi, prefix=prefix) 

9820 host.slider( 

9821 label, 

9822 min_value=lo, 

9823 max_value=hi, 

9824 key=_range_filter_key(col, prefix), 

9825 on_change=_apply, 

9826 help=" ".join( 

9827 filter( 

9828 None, 

9829 ( 

9830 field_help(col, names), 

9831 "Keep only trials whose value falls in this range.", 

9832 ), 

9833 ) 

9834 ), 

9835 ) 

9836 # Say how many trials have no value and what happens to them, or the 

9837 # kept-anyway trials look like the range isn't working. 

9838 _render_keep_unknown( 

9839 host, 

9840 _range_filter_key(col, prefix), 

9841 unknown=_trials_missing_column( 

9842 frame, col, cache_key=(frame_fingerprint(frame), col) 

9843 ), 

9844 noun="trial", 

9845 prefix=prefix, 

9846 on_change=_apply, 

9847 ) 

9848 for col in _filter_fields_for(words, fixations): 

9849 if col in numeric_fields: 

9850 continue 

9851 frame = words if col in words.columns else fixations 

9852 if col not in frame.columns: 

9853 continue 

9854 spec = _FILTER_FIELD_LABELS.get(col, {}) 

9855 label = labels[col] 

9856 if pd.api.types.is_bool_dtype(frame[col]): 

9857 _bool_metadata_filter( 

9858 label, 

9859 col, 

9860 frame, 

9861 spec.get("true", "Yes"), 

9862 spec.get("false", "No"), 

9863 f"{prefix}filter_{col}", 

9864 host, 

9865 on_change=_apply, 

9866 help=field_help(col, names), 

9867 ) 

9868 else: 

9869 values = _column_unique_strs( 

9870 frame, col, cache_key=(frame_fingerprint(frame), col) 

9871 ) 

9872 if len(values) > 1: 

9873 _seed_filter_widget( 

9874 f"{prefix}filter_{col}", values, values, prefix=prefix 

9875 ) 

9876 _labeled( 

9877 host, 

9878 "multiselect", 

9879 label, 

9880 options=values, 

9881 key=f"{prefix}filter_{col}", 

9882 on_change=_apply, 

9883 help=field_help(col, names) or None, 

9884 ) 

9885 

9886 _render_participant_metadata_filters(host, prefix=prefix, on_change=_apply) 

9887 _render_trial_metadata_filters( 

9888 host, 

9889 prefix=prefix, 

9890 on_change=_apply, 

9891 keys=lambda: _loaded_trial_keys(words, fixations), 

9892 pool_key=_pool_fingerprint(words, fixations), 

9893 ) 

9894 _render_text_metadata_filters(host, prefix=prefix, on_change=_apply) 

9895 

9896 # The annotation filters are trial level: they read the trial's own star 

9897 # and tags, never a screen's (`annotations.select_keys`), so the picker 

9898 # offers trial-level tags only and the panel says where screen ones are. 

9899 host.markdown("**By trial annotation**") 

9900 if f"{prefix}filter_favorites" not in st.session_state: 

9901 st.session_state[f"{prefix}filter_favorites"] = bool( 

9902 st.session_state.get(f"{prefix}_trial_filters_raw", {}).get( 

9903 f"{prefix}filter_favorites", False 

9904 ) 

9905 ) 

9906 _labeled( 

9907 host, 

9908 "checkbox", 

9909 f"{ICONS['favorite']} Favorites only", 

9910 key=f"{prefix}filter_favorites", 

9911 on_change=_apply, 

9912 help="Keep trials starred as a whole. A star on one screen does not count.", 

9913 ) 

9914 # DATA-48: the tags of the dataset this pool comes from — compare mode's B 

9915 # (the `cmp` prefix) may be another dataset, with tags of its own. 

9916 tags = known_tags(prefix, trial_level=True) 

9917 if tags: 

9918 _seed_filter_widget(f"{prefix}filter_req_tags", tags, [], prefix=prefix) 

9919 _labeled( 

9920 host, 

9921 "multiselect", 

9922 "With any of these tags", 

9923 options=tags, 

9924 key=f"{prefix}filter_req_tags", 

9925 on_change=_apply, 

9926 help="Keep trials tagged as a whole with any of these.", 

9927 ) 

9928 _seed_filter_widget(f"{prefix}filter_exc_tags", tags, [], prefix=prefix) 

9929 _labeled( 

9930 host, 

9931 "multiselect", 

9932 "Excluding tags", 

9933 options=tags, 

9934 key=f"{prefix}filter_exc_tags", 

9935 on_change=_apply, 

9936 help="Hide trials tagged as a whole with any of these, e.g. 'To exclude'.", 

9937 ) 

9938 if has_screen_annotations(prefix): 

9939 host.caption( 

9940 "Screen annotations are not used by these filters. They are listed " 

9941 f"on {ICONS['view_data']} **Data Management → Annotations**." 

9942 ) 

9943 

9944 # UX-26: the filter reset used to appear only in the empty-result diagnostic 

9945 # panel — you had to filter yourself into nothing before the escape hatch 

9946 # showed up. It now has a permanent home at the foot of the panel that set 

9947 # the filters (and a second one in the rail's Reset settings popover). 

9948 host.divider() 

9949 host.button( 

9950 "✕ Clear all filters", 

9951 key=f"{prefix}clear_all_filters_panel", 

9952 on_click=clear_trial_filters, 

9953 args=(prefix,), 

9954 width="stretch", 

9955 help="Reset every filter in this panel.", 

9956 ) 

9957 

9958 # Mirror the rendered widget values so _seed_filter_widget can restore them on 

9959 # a run where this panel isn't shown (the keys get cleared); then publish the 

9960 # derived result for read_trial_filters (covers no-change runs). 

9961 # UX-49: the range keys have to be listed explicitly. Without them a range 

9962 # silently resets on any run where this popover isn't rendered — a trip 

9963 # through Corpus Analysis is enough, since Streamlit drops the key and 

9964 # `_seed_range_widget` would then find nothing to restore. 

9965 keys = ( 

9966 [ 

9967 f"{prefix}filter_participants", 

9968 f"{prefix}filter_text_id", 

9969 f"{prefix}filter_req_tags", 

9970 f"{prefix}filter_exc_tags", 

9971 ] 

9972 + [f"{prefix}filter_{c}" for c in _filter_fields_for(words, fixations)] 

9973 + [_range_filter_key(c, prefix) for c in numeric_fields] 

9974 # DATA-20 — mirrored like any other filter, so a metadata narrowing 

9975 # survives a run where the popover didn't render and round-trips 

9976 # through Share / save & restore with the rest of the filter layer. 

9977 + [metadata_filter_key(f.name, prefix) for f in participant_metadata_fields()] 

9978 # DATA-29 — same reason, one grain down. 

9979 + [trial_metadata_filter_key(f.name, prefix) for f in trial_metadata_fields()] 

9980 # And the text table, the third grain — same reasoning again. 

9981 + [text_metadata_filter_key(f.name, prefix) for f in text_metadata_fields()] 

9982 ) 

9983 # Each range's *Keep unknown values* choice, for the same reason as the 

9984 # range itself. 

9985 keys += [keep_unknown_key(k) for k in keys] 

9986 st.session_state[f"{prefix}_trial_filters_raw"] = { 

9987 k: st.session_state[k] for k in keys if k in st.session_state 

9988 } 

9989 result = _compute_trial_filters(words, fixations, prefix=prefix) 

9990 st.session_state[f"{prefix}_trial_filters"] = result 

9991 return result