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

1"""The app's header: the native top nav, the title row and the notices strip. 

2 

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. 

6 

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. 

12 

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. 

19 

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""" 

27 

28from __future__ import annotations 

29 

30from dataclasses import dataclass 

31from typing import TYPE_CHECKING 

32 

33import streamlit as st 

34 

35from scanpath_studio.constants import ( 

36 _VIEW_CORPUS, 

37 _VIEW_DATA, 

38 _VIEW_SCANPATH, 

39 ICONS, 

40) 

41 

42if TYPE_CHECKING: # pragma: no cover - typing only 

43 from streamlit.delta_generator import DeltaGenerator 

44 

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"]' 

49 

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} 

64 

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} 

79 

80#: The nav section heading the four entries above collapse under. 

81HELP_SECTION = f"{ICONS['help']} Help" 

82 

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] = {} 

87 

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" 

93 

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" 

99 

100 

101@dataclass(frozen=True) 

102class TopMenu: 

103 """What one rendered header row leaves behind for the rest of the run.""" 

104 

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 

110 

111 

112def close_open_popovers() -> None: 

113 """Dismiss whichever menu popover is open, client-side. 

114 

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. 

120 

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 

125 

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 ) 

141 

142 

143def view_label(view: str) -> str: 

144 """The nav's short label for ``view`` (e.g. "Scanpath"), for use in prose. 

145 

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 

151 

152 

153def _unused_page_body() -> None: # pragma: no cover - never executed 

154 """Placeholder body for the ``st.Page`` objects. 

155 

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") 

163 

164 

165def switch_to_view(view: str) -> None: 

166 """Navigate to ``view`` programmatically. Reruns; does not return. 

167 

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) 

177 

178 

179def _arm_help_action(entry: str) -> None: 

180 """Arm the dialog a nav *action* entry stands for (UX-65, UX-100). 

181 

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 

188 

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 

197 

198 app._arm_about() 

199 

200 

201def render_nav() -> str: 

202 """Draw Streamlit's native top nav and return the active view. 

203 

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. 

207 

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. 

216 

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 

275 

276 

277def render_top_menu(*, active_view: str | None = None) -> TopMenu: 

278 """Draw the native top nav, then the title row and the notices strip. 

279 

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. 

282 

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. 

287 

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 )