Coverage for scanpath_studio/menu.py: 89%
62 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""The app's header: the native top nav, the title row and the notices strip.
3What replaced the sidebar (UX-38 → UX-100). Nothing in the app writes to
4``st.sidebar``, so Streamlit draws no sidebar chrome and the main content gets
5the full page width.
7**The nav** (:func:`render_nav`) is Streamlit's own
8``st.navigation(position="top")``: three **views** — 🗺️ Scanpath · 📊 Corpus
9Analysis · 🗂️ Data — and a ❓ Help section of **action** entries (Tutorials,
10FAQ, About; Debug opens from the foot of About). Selecting an action arms a dialog and bounces the router
11straight back to the view you were on, so the modal opens over your work.
13**UX-179 retired 💾 Session**, the last top-level action entry. Its four blocks
14went where each is used: Debug to ❓ Help, the recovery cache and *Reset
15everything* to the foot of the Data page (``app._render_saved_here_section``),
16and the settings file to 🗺️ Scanpath → 🔗 Share → File
17(``tabs.render_settings_file``) — its annotations having already got their own
18export on 🗂️ Data → Annotations.
20**DATA-26 took ⚙️ Configure and 🧹 Preprocessing off the bar** and made them
21sections of the Data page. Their widgets keep every-run execution: the page
22container is built every run and hidden off-screen when another view is active
23(``constants.DATA_PAGE_OFFSCREEN_KEY`` + the ``display: none`` rule in
24``styles.py``), because Streamlit drops the state of a widget that does not
25render and several of them drive ``app.prepare_data``.
26"""
28from __future__ import annotations
30from dataclasses import dataclass
31from typing import TYPE_CHECKING
33import streamlit as st
35from scanpath_studio.constants import (
36 _VIEW_CORPUS,
37 _VIEW_DATA,
38 _VIEW_SCANPATH,
39 ICONS,
40)
42if TYPE_CHECKING: # pragma: no cover - typing only
43 from streamlit.delta_generator import DeltaGenerator
45#: The spotlight tour's selector for the navigation. Streamlit's own top nav has
46#: no key of ours to hang a ``.st-key-*`` class on, so the step points at the
47#: frontend's stable test id instead.
48NAV_SELECTOR = '[data-testid="stTopNavLinkContainer"]'
50#: Nav entries: view constant → (label, icon, url path). The labels are short —
51#: "Scanpath", not "Scanpath Visualization" — because the nav sits in Streamlit's
52#: header strip beside the toolbar, where a long label crowds it. UX-201 is the
53#: one exception: beta testers looked for the page by its header, *Data
54#: Management*, so the nav says that too.
55#: **Data comes last** (DATA-26) even though setting a dataset up comes first in
56#: time: it is visited occasionally, while the two analysis views are where the
57#: work happens, and moving them rightwards to make room for a setup page would
58#: cost every existing user their aim.
59_NAV_PAGES = {
60 _VIEW_SCANPATH: ("Scanpath", ICONS["view_scanpath"], "scanpath"),
61 _VIEW_CORPUS: ("Corpus Analysis", ICONS["view_corpus"], "corpus-analysis"),
62 _VIEW_DATA: ("Data Management", ICONS["view_data"], "data"),
63}
65#: UX-65 — the ❓ Help *section* of the nav: entry id → (label, icon, url path).
66#: These are not views. Each one opens the dialog that used to sit behind a
67#: button on the Help page, over whatever you were already looking at — see
68#: :func:`render_nav` for the bounce that makes that work. Documentation is
69#: absent on purpose: ``st.Page`` cannot be a URL, and the UX-62 wordmark beside
70#: this nav already links to the docs site.
71_HELP_PAGES = {
72 "help_tutorials": ("Tutorials", ICONS["tutorials"], "help-tutorials"),
73 "help_faq": ("FAQ", ICONS["faq"], "help-faq"),
74 "help_about": ("About", ICONS["about"], "help-about"),
75 # #374 F31: Debug is no longer an entry here — a developer tool listed
76 # beside Tutorials and FAQ read as something every user should open. It
77 # is a small button at the foot of About (`app._about_dialog`).
78}
80#: The nav section heading the four entries above collapse under.
81HELP_SECTION = f"{ICONS['help']} Help"
83#: This run's ``st.Page`` objects, view constant → Page. Rebuilt every run (an
84#: ``st.Page`` belongs to the run that made it) and read by
85#: :func:`switch_to_view`, which is the only way to navigate programmatically.
86_PAGES: dict[str, object] = {}
88#: What :func:`render_nav` last mirrored into ``main_nav``. Session state, not a
89#: module global — it is per-user. See ``render_nav`` for why it is load-bearing:
90#: without it, a nav click is indistinguishable from a request to go back, and
91#: the nav bounces.
92_MIRROR_KEY = "_nav_mirrored"
94#: Keyed wrapper around the main-area strip just under the bar, where load-time
95#: warnings land. They used to be ``st.sidebar.warning`` calls in an
96#: always-visible column; a warning inside a *closed* popover would be invisible,
97#: so these stay in the page where the user can't miss them.
98NOTICES_KEY = "top_menu_notices"
101@dataclass(frozen=True)
102class TopMenu:
103 """What one rendered header row leaves behind for the rest of the run."""
105 #: The main-area strip just under the header, where load-time warnings land.
106 notices: DeltaGenerator
107 #: The page heading's slot — the left half of the row the menu shares.
108 #: ``app._render_about_panel`` fills it; see :func:`render_top_menu`.
109 title: DeltaGenerator | None = None
112def close_open_popovers() -> None:
113 """Dismiss whichever menu popover is open, client-side.
115 A popover's open/closed state lives in the browser, so a server callback can
116 arm a dialog but cannot close the ❓ Help popover the button was inside —
117 leaving the popover panel floating on top of the modal it just opened. Call
118 this from a dialog body: it clicks the expanded trigger, which toggles it
119 shut. Retried, because a click during React hydration is silently lost.
121 Same trick as ``tour``'s tutorial-chooser closer, generalized: match any
122 expanded popover trigger rather than one by label.
123 """
124 from scanpath_studio.html_embed import embed_html_iframe
126 embed_html_iframe(
127 """<script>
128 (function () {
129 const doc = window.parent.document;
130 let tries = 0;
131 (function closePopover() {
132 const trigger = doc.querySelector(
133 '[data-testid="stPopover"] button[aria-expanded="true"]');
134 if (trigger) { trigger.click(); return; }
135 if (++tries < 20) setTimeout(closePopover, 50);
136 })();
137 })();
138 </script>""",
139 height=0,
140 )
143def view_label(view: str) -> str:
144 """The nav's short label for ``view`` (e.g. "Scanpath"), for use in prose.
146 The ``_VIEW_*`` constants are the wire values ("Scanpath Visualization");
147 quoting those at the user would name something the nav doesn't say.
148 """
149 entry = _NAV_PAGES.get(view)
150 return entry[0] if entry else view
153def _unused_page_body() -> None: # pragma: no cover - never executed
154 """Placeholder body for the ``st.Page`` objects.
156 ``st.navigation`` is used here only to *render* the nav and report which
157 entry is selected — we never call ``.run()``, because ``app.main`` still owns
158 the dispatch (it has to: the view bodies close over frames the long prelude
159 computes, and the epilogue — ``save_local_state``, then the Data page's
160 *Saved on this computer* — has to run *after* the body). So these never execute.
161 """
162 raise AssertionError("page body should never run — app.main owns dispatch")
165def switch_to_view(view: str) -> None:
166 """Navigate to ``view`` programmatically. Reruns; does not return.
168 The router owns the selection now, so writing ``main_nav`` is not enough on
169 its own — this is what actually moves the nav. Not callable from a widget
170 callback (Streamlit forbids ``st.switch_page`` there); callbacks should write
171 ``main_nav`` and let :func:`render_nav`'s reconciliation do the switch on the
172 next run.
173 """
174 page = _PAGES.get(view)
175 if page is not None:
176 st.switch_page(page)
179def _arm_help_action(entry: str) -> None:
180 """Arm the dialog a nav *action* entry stands for (UX-65, UX-100).
182 The dialogs themselves are untouched — this only sets the request flag each
183 ``maybe_show_*`` already serves in ``app.main``, which is exactly what the
184 Help *buttons* did through their ``on_click`` callbacks. Imports are local
185 because ``app`` imports this module.
186 """
187 from scanpath_studio import tour
189 if entry == "help_tour":
190 tour._arm_tour()
191 elif entry == "help_tutorials":
192 tour._arm_tutorial_library()
193 elif entry == "help_faq":
194 tour._arm_faq()
195 elif entry == "help_about":
196 from scanpath_studio import app
198 app._arm_about()
201def render_nav() -> str:
202 """Draw Streamlit's native top nav and return the active view.
204 Uses ``st.navigation(position="top")`` — the platform's own navigation,
205 rendered into the header strip beside the toolbar, so it costs no page
206 height and looks like Streamlit rather than like an app control.
208 **``main_nav`` is kept as a mirror, not the source of truth.** The router
209 owns the selection, but several places still *write* ``main_nav`` to request
210 a view (``url_state._go_scanpath`` / ``_go_data``, ``tour`` when a step drives the
211 app to another view, and ``persistence`` restoring the view you were last
212 on) — including from ``on_click`` callbacks, where ``st.switch_page`` is not
213 allowed. So each run reconciles, then writes the resolved view back, and
214 every existing reader of ``main_nav`` — the tour, ``persistence`` — keeps
215 working unchanged.
217 The reconciliation turns on ``_nav_mirrored``: the value this function wrote
218 last run. Without it the two directions are indistinguishable and the nav
219 is unusable — clicking "Corpus Analysis" makes ``main_nav`` (still holding
220 last run's "Scanpath") disagree with the router, which reads as a request to
221 go *back*, and the click bounces. Comparing against what we last mirrored
222 separates them: ``main_nav`` still equal to it means nobody asked for
223 anything and the router simply moved (the user clicked); ``main_nav``
224 changed out from under it is a genuine request, honoured by switching —
225 which reruns, and next run the two agree and it stops.
226 """
227 _PAGES.clear()
228 for view, (label, icon, url_path) in _NAV_PAGES.items():
229 _PAGES[view] = st.Page(
230 _unused_page_body,
231 title=label,
232 icon=icon,
233 url_path=url_path,
234 default=view == _VIEW_SCANPATH,
235 )
236 # UX-65: a *dict* of sections. The entries under the empty-string key are
237 # drawn first, at the top level; every other key becomes a collapsible item
238 # — Streamlit's own documented behaviour for `position="top"`, so ❓ Help
239 # opens a menu with no CSS of ours.
240 action_pages = {
241 entry: st.Page(_unused_page_body, title=label, icon=icon, url_path=url_path)
242 for entry, (label, icon, url_path) in _HELP_PAGES.items()
243 }
244 selected = st.navigation(
245 {
246 "": [*_PAGES.values()],
247 HELP_SECTION: [action_pages[entry] for entry in _HELP_PAGES],
248 },
249 position="top",
250 )
251 # An action entry is not a destination: arm its dialog and go straight back
252 # to the view the user was on, so the modal opens over their work instead of
253 # over an empty page. The bounce reruns, and next run the router has
254 # re-selected that view, so nothing re-arms.
255 chosen_action = next(
256 (entry for entry, page in action_pages.items() if page.title == selected.title),
257 None,
258 )
259 if chosen_action is not None:
260 _arm_help_action(chosen_action)
261 back = st.session_state.get(_MIRROR_KEY)
262 switch_to_view(back if back in _PAGES else _VIEW_SCANPATH) # reruns
263 active = next(
264 (view for view, page in _PAGES.items() if page.title == selected.title),
265 _VIEW_SCANPATH,
266 )
267 requested = st.session_state.get("main_nav")
268 mirrored = st.session_state.get(_MIRROR_KEY)
269 if requested in _PAGES and requested != active and requested != mirrored:
270 st.session_state[_MIRROR_KEY] = requested
271 switch_to_view(requested) # reruns
272 st.session_state["main_nav"] = active
273 st.session_state[_MIRROR_KEY] = active
274 return active
277def render_top_menu(*, active_view: str | None = None) -> TopMenu:
278 """Draw the native top nav, then the title row and the notices strip.
280 Call this once, early in ``app.main`` — before any data loading, so load
281 warnings land in the notices strip rather than after the page content.
283 Args:
284 active_view: The entry the nav currently has selected, from
285 :func:`render_nav`. Accepted so callers that already resolved the
286 view do not resolve it twice.
288 Returns:
289 ``title`` — ``app._render_about_panel`` fills it — plus the main-area
290 ``notices`` strip.
291 """
292 if active_view is None:
293 render_nav()
294 title_col, _ = st.columns([4, 1], vertical_alignment="bottom")
295 return TopMenu(
296 notices=st.container(key=NOTICES_KEY),
297 title=title_col,
298 )