Coverage for scanpath_studio/fields.py: 100%

34 statements  

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

1"""The ``label | field`` row — one primitive, shared by every panel that has one. 

2 

3UX-51 built this for the Scanpath rail's plot controls and UX-53 spread it over 

4the upload wizard, but both kept it private to :mod:`controls`. UX-69 needs the 

5same row in the Scanpath subtabs, and :mod:`controls` cannot supply it there: 

6it already imports :mod:`annotations` and :mod:`export`, so those two — which 

7render three of the subtabs between them — would import it back in a cycle. 

8Hence a module *below* all of them, holding nothing but the row. 

9 

10The row is two columns: the field's name on the left, the control itself on the 

11right, vertically centered against each other. That buys back the line every 

12Streamlit widget spends on its label, which is what makes a panel of a dozen 

13fields fit on one screen — and it lines the controls up on a common left edge, 

14so a column of them reads as a form rather than as a ragged stack. 

15 

16``help`` folds into the *title's* own hover tooltip rather than getting a `?` 

17icon beside it (see :func:`row_label`); the widget keeps its real label and 

18``help``, only ``label_visibility="collapsed"`` hides where Streamlit would 

19have drawn them. 

20""" 

21 

22from __future__ import annotations 

23 

24import html 

25import re 

26 

27#: The label column's share of a ``label | field`` row in a **full-width** panel 

28#: — a Scanpath subtab body, which is 4/5 of the page (UX-69). About 180px at a 

29#: typical window: enough for "Participant fields to include" to survive, while 

30#: leaving the control the room a pills row or a path pattern wants. 

31PANEL_LABEL_W = 0.2 

32 

33#: The same share in a **narrow** column: the rail's ~28rem popover (`styles. 

34#: get_app_css` pins `stPopoverBody`), or a subtab's side column. ~160px of 

35#: label — about 23 characters at the rail's 0.92rem — while leaving the field 

36#: wide enough for a multiselect's chips, or for a slider plus the UX-9 box you 

37#: type an exact value into. 

38NARROW_LABEL_W = 0.36 

39 

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

41LABEL_GAP = "xsmall" 

42 

43#: Markdown emphasis, which a plain-text tooltip would show as literal 

44#: punctuation. 

45_MD_MARKS = re.compile(r"\*\*|`") 

46_WHITESPACE_RUN = re.compile(r"\s+") 

47#: A `constants.ICONS` shortcode (UX-138) — in the label, or in a gate reason 

48#: folded into the help: drawn as the glyph in the visible title, but a 

49#: tooltip is plain text and would spell it out. 

50_ICON_CODE = re.compile(r":material/[a-z0-9_]+:\s*") 

51 

52 

53def plain(text: str) -> str: 

54 """``text`` with markdown emphasis stripped, for a plain-text tooltip. 

55 

56 Newlines collapse to spaces along with every other run of whitespace, and 

57 that is not cosmetic (UX-68): :func:`row_label` interpolates the result into 

58 an HTML attribute inside an ``st.markdown`` string, and a **blank line** ends 

59 a raw HTML block in markdown — so a two-paragraph help text (every 

60 ``controls._gated_help`` result is one) would split the opening ``<span …>`` 

61 and render the rest of the tag as visible page text. A tooltip is one line 

62 anyway; the paragraph break has nothing to do there. 

63 """ 

64 return _WHITESPACE_RUN.sub(" ", _MD_MARKS.sub("", text)).strip() 

65 

66 

67def tooltip(*parts: str | None) -> str: 

68 """A CSS tooltip's text: ``parts`` joined with " — ", plain, no icon codes. 

69 

70 Escaped for an HTML attribute. Every ``data-tip`` goes through this (#374 

71 F36): an icon shortcode left in one printed as text, ":material_warning:". 

72 """ 

73 text = " — ".join(plain(part) for part in parts if part) 

74 return html.escape(_ICON_CODE.sub("", text).strip(), quote=True) 

75 

76 

77def row_label(host, label: str, help: str | None, *, emphasis: bool = False) -> None: 

78 """Render one row's title into its own (left) column. 

79 

80 ``help`` folds into the title's own hover tooltip rather than getting a `?` 

81 icon beside it, and the title text is repeated at the head of that tooltip so 

82 a label the column had to truncate is still readable in full. 

83 

84 The tooltip is CSS (``data-tip`` + ``styles.py``'s ``.sps-fhelp::after``), 

85 not the browser's native ``title=``. Native ``title`` waits about a second 

86 before it appears — fine for an occasional "what is this file", far too slow 

87 for a form whose every row hides its description there. The CSS one opens in 

88 ~120 ms, matching the `?` icons Streamlit draws elsewhere. ``aria-label`` 

89 keeps the text reachable now that no ``title`` carries it. 

90 

91 ``emphasis`` (UX-113) adds ``.sps-flabel-emph`` — bolder and a touch 

92 larger than the ordinary field title, for a row that should stand out 

93 among plainer ones on the same screen (the upload wizard's own table 

94 titles among their neighbouring mapping fields). 

95 """ 

96 text = plain(label) 

97 emph = " sps-flabel-emph" if emphasis else "" 

98 if not help: 

99 host.markdown( 

100 f'<span class="sps-flabel{emph}">{html.escape(text)}</span>', 

101 unsafe_allow_html=True, 

102 ) 

103 return 

104 tip = tooltip(text, help) 

105 host.markdown( 

106 f'<span class="sps-fhelp" data-tip="{tip}" aria-label="{tip}">' 

107 f'<span class="sps-flabel sps-flabel-help{emph}">{html.escape(text)}</span>' 

108 "</span>", 

109 unsafe_allow_html=True, 

110 ) 

111 

112 

113#: Widget kinds that take a ``wrap=`` argument (ENG-49). Streamlit 1.63 resolves 

114#: ``wrap=None`` to **no** wrapping for a control "directly placed in a column" 

115#: — which is exactly what :func:`labeled` does — so on the upgrade every 

116#: multiselect, segmented control and pill row in the app would silently turn 

117#: from a block that grows taller into a strip that scrolls sideways. That is a 

118#: reasonable default for a wide page and a poor one here: the rail is ~28rem 

119#: with a title column in front of the field, and these particular controls are 

120#: read at a glance (*which* participants the pool is filtered to, *which* 

121#: layout is selected), so a selection that scrolls out of sight is worse than a 

122#: row that grows. The pre-1.63 behaviour is therefore kept **explicitly** at 

123#: this one chokepoint rather than inherited — a caller that wants the new look 

124#: passes ``wrap=False`` itself, and dropping this set adopts it everywhere. 

125WRAPPING_KINDS = frozenset({"multiselect", "segmented_control", "pills"}) 

126 

127 

128def labeled( 

129 host, 

130 kind: str, 

131 label: str, 

132 *, 

133 display: str | None = None, 

134 help: str | None = None, 

135 label_width: float = NARROW_LABEL_W, 

136 align: str = "center", 

137 **kwargs, 

138): 

139 """Render one control as a ``label | field`` row; return the widget's value. 

140 

141 ``kind`` names the Streamlit method to call (``"selectbox"``, 

142 ``"multiselect"``, ``"color_picker"``, …) and every other argument is 

143 forwarded untouched, so converting a call site is a matter of *naming* the 

144 widget instead of calling it. The widget still receives the real ``label`` 

145 and ``help`` — only where they are drawn changes. 

146 

147 ``display`` overrides the *visible* text without touching the widget's own 

148 label. Use it where the accessible name has to stay unique but would be far 

149 too long for the column — the per-scanpath comparison styling, whose rows are 

150 already captioned with the scanpath they belong to, or a checkbox whose label 

151 is a whole sentence (the title column is one ellipsized line, so the sentence 

152 belongs in ``help``, on the hover). 

153 

154 ``label_width`` picks the column split: :data:`PANEL_LABEL_W` in a full-width 

155 panel, :data:`NARROW_LABEL_W` (the default) in the rail or a side column. 

156 ``align`` is the columns' ``vertical_alignment``; ``"top"`` suits a control 

157 that is many lines tall (a text area), where a centered title would float 

158 opposite the middle of an empty box. 

159 """ 

160 label_col, field_col = host.columns( 

161 [label_width, 1.0 - label_width], gap=LABEL_GAP, vertical_alignment=align 

162 ) 

163 row_label(label_col, display if display is not None else label, help) 

164 if kind in WRAPPING_KINDS: 

165 kwargs.setdefault("wrap", True) 

166 # #374 F19: the collapsed label is only the accessible name, which a 

167 # screen reader reads verbatim — so no icon shortcode or `**` in it. 

168 return getattr(field_col, kind)( 

169 accessible_name(label), help=help, label_visibility="collapsed", **kwargs 

170 ) 

171 

172 

173def accessible_name(label: str) -> str: 

174 """``label`` as words: icon shortcodes and markdown bold removed (#374 F19). 

175 

176 Streamlit sets a widget's ``aria-label`` to its label string as written.""" 

177 return " ".join(re.sub(r":material/\w+:|\*\*", " ", label).split()) 

178 

179 

180def panel_field(host, kind: str, label: str, **kwargs): 

181 """:func:`labeled` at :data:`PANEL_LABEL_W` — the full-width-panel default.""" 

182 kwargs.setdefault("label_width", PANEL_LABEL_W) 

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