feat(chrome): declarative StatusFooter readout (dots + fields on QStatusBar)
This commit is contained in:
@@ -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"]
|
||||
|
||||
108
cim_suite/core/ui/chrome/status_footer.py
Normal file
108
cim_suite/core/ui/chrome/status_footer.py
Normal 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)
|
||||
@@ -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;
|
||||
|
||||
51
tests/core/test_status_footer.py
Normal file
51
tests/core/test_status_footer.py
Normal 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"
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user