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
« 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.
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.
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.
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"""
22from __future__ import annotations
24import html
25import re
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
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
40#: Tighter than the 1rem default: these rows are dense and the width is scarce.
41LABEL_GAP = "xsmall"
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*")
53def plain(text: str) -> str:
54 """``text`` with markdown emphasis stripped, for a plain-text tooltip.
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()
67def tooltip(*parts: str | None) -> str:
68 """A CSS tooltip's text: ``parts`` joined with " — ", plain, no icon codes.
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)
77def row_label(host, label: str, help: str | None, *, emphasis: bool = False) -> None:
78 """Render one row's title into its own (left) column.
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.
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.
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 )
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"})
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.
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.
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).
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 )
173def accessible_name(label: str) -> str:
174 """``label`` as words: icon shortcodes and markdown bold removed (#374 F19).
176 Streamlit sets a widget's ``aria-label`` to its label string as written."""
177 return " ".join(re.sub(r":material/\w+:|\*\*", " ", label).split())
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)