feat(chrome): declarative StatusFooter readout (dots + fields on QStatusBar)

This commit is contained in:
2026-06-10 13:46:33 -04:00
parent 05ce9df6ea
commit f769f83b84
5 changed files with 184 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ geometry from ``theme.tokens.Metrics``. Nothing here talks to controllers or mod
"""
from .accent_strip import AccentStrip
from .status_footer import StatusFooter
from .title_bar import ChromeTitleBar
__all__ = ["AccentStrip", "ChromeTitleBar"]
__all__ = ["AccentStrip", "ChromeTitleBar", "StatusFooter"]

View File

@@ -0,0 +1,108 @@
"""The 30px instrument status footer (spec §4) — dark chrome in both themes.
A ``QStatusBar`` subclass so ``QMainWindow.setStatusBar`` / ``statusBar()`` and
transient ``showMessage`` (copy feedback, error toasts) keep working, but modules
feed it through a declarative API instead of composing labels:
footer.add_dot("link", "Link")
footer.add_field("sensors", "Sensors")
footer.add_field("station", "Station", side="right")
footer.set_dot("link", "ok")
footer.set_field("sensors", "4")
Dot states: ``ok`` / ``warn`` / ``bad`` / ``off`` — rendered glyph + microcaps label
(● ● ■ ○: the alarm square / hollow-off shapes are spec §1.2's color-blind
redundancy). Field labels render dim (``ft_text``), values bright (``ft_bright``);
``state="warn"`` recolors a value. Colors come from the QSS ``FooterDot`` /
``FooterField*`` rules so a theme switch restyles everything.
"""
from __future__ import annotations
from PySide6.QtWidgets import QLabel, QStatusBar, QWidget
from ..theme import type_styles
from ..theme.tokens import Metrics
_DOT_GLYPH = {"ok": "", "warn": "", "bad": "", "off": ""}
def _set_prop(widget: QWidget, name: str, value: str) -> None:
"""Set a QSS-driving property and repolish only when it actually changes."""
if widget.property(name) != value:
widget.setProperty(name, value)
widget.style().unpolish(widget)
widget.style().polish(widget)
class StatusFooter(QStatusBar):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("StatusFooter")
self.setSizeGripEnabled(False)
self.setFixedHeight(Metrics.FOOTER_H)
self._dots: dict[str, QLabel] = {}
self._values: dict[str, QLabel] = {}
# --- status dots ---------------------------------------------------------
def add_dot(self, name: str, label: str, *, tooltip: str = "") -> None:
"""A left-side status dot readout, e.g. ``● LINK``. Starts ``off``."""
widget = QLabel(self)
widget.setObjectName("FooterDot")
widget.setFont(type_styles.footer_readout())
widget.setToolTip(tooltip)
widget.setProperty("dotLabel", label.upper())
self._dots[name] = widget
self.addWidget(widget)
self.set_dot(name, "off")
def set_dot(self, name: str, state: str, *, tooltip: str | None = None) -> None:
widget = self._dots[name]
widget.setText(f"{_DOT_GLYPH[state]} {widget.property('dotLabel')}")
if tooltip is not None:
widget.setToolTip(tooltip)
_set_prop(widget, "state", state)
def dot_state(self, name: str) -> str:
return self._dots[name].property("state")
def dot_label(self, name: str) -> QLabel:
return self._dots[name]
# --- label/value fields ----------------------------------------------------
def add_field(self, name: str, label: str, *, side: str = "left", tooltip: str = "") -> None:
"""A ``LABEL value`` readout; value renders bright. Starts as an em dash."""
lab = QLabel(label.upper(), self)
lab.setObjectName("FooterFieldLabel")
val = QLabel("", self)
val.setObjectName("FooterFieldValue")
val.setProperty("state", "steady")
for widget in (lab, val):
widget.setFont(type_styles.footer_readout())
widget.setToolTip(tooltip)
self._values[name] = val
if side == "right":
self.addPermanentWidget(lab)
self.addPermanentWidget(val)
else:
self.addWidget(lab)
self.addWidget(val)
def set_field(self, name: str, value: str, *, state: str = "steady",
tooltip: str | None = None) -> None:
val = self._values[name]
val.setText(str(value))
if tooltip is not None:
val.setToolTip(tooltip)
_set_prop(val, "state", state)
def field_text(self, name: str) -> str:
return self._values[name].text()
def value_label(self, name: str) -> QLabel:
return self._values[name]
# --- transient messages -----------------------------------------------------
def show_message(self, text: str, msecs: int = 5000) -> None:
"""Transient toast (errors, copy feedback); readouts return when it expires."""
self.showMessage(text, msecs)

View File

@@ -285,6 +285,28 @@ QStatusBar QLabel {{
padding: 0px {S.XS}px;
}}
QLabel#FooterDot {{
background: transparent;
padding: 2px {S.SM}px;
font-weight: 600;
}}
QLabel#FooterDot[state="ok"] {{ color: {t.footer_ok_dot}; }}
QLabel#FooterDot[state="warn"] {{ color: {t.warn}; }}
QLabel#FooterDot[state="bad"] {{ color: {t.alarm}; }}
QLabel#FooterDot[state="off"] {{ color: {t.ft_text}; }}
QLabel#FooterFieldLabel {{
background: transparent;
color: {t.ft_text};
padding: 0px 2px 0px {S.SM}px;
}}
QLabel#FooterFieldValue {{
background: transparent;
color: {t.ft_bright};
padding: 0px {S.SM}px 0px 0px;
}}
QLabel#FooterFieldValue[state="warn"] {{ color: {t.warn}; font-weight: 600; }}
QLabel#ConnPill {{
border-radius: {R.SM}px;
padding: 2px {S.SM}px;

View File

@@ -0,0 +1,51 @@
"""StatusFooter declarative API: dots, fields, transient messages, geometry."""
from cim_suite.core.ui.chrome import StatusFooter
def _footer(qtbot):
footer = StatusFooter()
qtbot.addWidget(footer)
return footer
def test_footer_is_30px_with_no_size_grip(qtbot):
footer = _footer(qtbot)
assert footer.height() == 30
assert footer.isSizeGripEnabled() is False
def test_dot_renders_glyph_plus_microcaps_label(qtbot):
footer = _footer(qtbot)
footer.add_dot("link", "Link", tooltip="Service-cable link")
assert footer.dot_state("link") == "off"
assert footer.dot_label("link").text() == "○ LINK"
footer.set_dot("link", "ok")
assert footer.dot_label("link").text() == "● LINK"
footer.set_dot("link", "bad", tooltip="lost")
assert footer.dot_label("link").text() == "■ LINK" # alarm square (spec §1.2)
assert footer.dot_label("link").toolTip() == "lost"
def test_field_uppercases_label_and_tracks_value_state(qtbot):
footer = _footer(qtbot)
footer.add_field("buffered", "Buffered")
assert footer.field_text("buffered") == ""
footer.set_field("buffered", "1,438")
assert footer.field_text("buffered") == "1,438"
assert footer.value_label("buffered").property("state") == "steady"
footer.set_field("buffered", "1,500", state="warn")
assert footer.value_label("buffered").property("state") == "warn"
def test_right_side_fields_and_messages(qtbot):
footer = _footer(qtbot)
footer.add_field("station", "Station", side="right")
footer.set_field("station", "06/10/26 09:05:53")
assert footer.field_text("station") == "06/10/26 09:05:53"
footer.show_message("Copied to clipboard", 2000)
assert footer.currentMessage() == "Copied to clipboard"

View File

@@ -22,6 +22,7 @@ def test_qss_keeps_existing_widget_hooks(tokens):
qss = build_qss(tokens, SANS, MONO)
for hook in (
"PrimaryAction", "BrandTitle", "ConnPill", "ActivityDot", "BufferedStat",
"FooterDot", "FooterFieldValue",
"RebootBanner", "LauncherPage", "WhatsNewButton", "ModuleCard",
"CablePanel", "SubnetPreview", "LoadingOverlay", "HelpText",
"ChromeTitleBar", "TitleBarBrand", "ThemeToggle", "CloseButton",