This commit is contained in:
Your Name
2026-09-07 12:30:42 +08:00
parent d5164b7369
commit 9ef6eb8d67
369 changed files with 11733 additions and 0 deletions
@@ -0,0 +1,144 @@
"""Page-scoped palette and compact typography for the approved appointment list."""
from string import Template
from .reception_style import body_family, heading_family
def appointments_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
_QSS = """
#AppointmentsPage { background: #F3F7FD; color: #273244; }
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
color: #5D6B80; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QFrame#AppointmentFilterPanel,
#AppointmentsPage QFrame#AppointmentMainCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#AppointmentsPage QPushButton {
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
}
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
#AppointmentsPage QPushButton[variant="primary"] {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
#AppointmentsPage QPushButton[variant="danger"] {
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
}
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
#AppointmentsPage QPushButton:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
}
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
#AppointmentsPage QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
#AppointmentsPage QPushButton#AppointmentSearchButton {
min-height: 39px; max-height: 39px; min-width: 52px;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
border-color: transparent; color: #5D6B80; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
}
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
}
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
background: transparent; border-color: transparent; font-size: 13px;
}
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
}
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
color: #1769E8; border-color: #ADC8F0;
}
#AppointmentsPage QTableWidget#AppointmentTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
}
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
background: transparent; border-left: 3px solid transparent;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QWidget[appointmentInfoHost="true"],
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
background: #EAF2FF; border-radius: 4px; font-size: 13px;
}
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QPushButton[tableCancelAction="true"] {
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
#AppointmentsPage QWidget#Pager QPushButton {
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
}
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
"""
@@ -0,0 +1,131 @@
"""Scoped colors and compact typography for the approved consultation list."""
from string import Template
from .reception_style import body_family, heading_family
def consultations_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
_QSS = """
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
background: #F3F7FD; color: #273244; border: 0;
}
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
}
#DiagnosisIndex QPushButton {
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
}
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
font-size: 13px;
}
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
#DiagnosisIndex QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
width: 22px; border: 0; background: transparent;
}
#DiagnosisIndex QComboBox QAbstractItemView {
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
}
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
#DiagnosisIndex QToolButton[statusTab="true"] {
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
#DiagnosisIndex QFrame#DiagnosisDateRange {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
}
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
#DiagnosisIndex QTableView {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
}
#DiagnosisIndex QTableView QHeaderView::section {
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
}
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,81 @@
"""Compact, presentation-only disclosure for page search and overview regions."""
from __future__ import annotations
from collections.abc import Sequence
from PySide6.QtCore import QObject, QSize, Qt, Signal
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from . import icons
class FilterDisclosure(QObject):
"""Keep query values and loading state intact while reclaiming list space.
Targets should be region containers, not individual permission-controlled
controls. Showing a container preserves its children's explicit visibility.
"""
expanded_changed = Signal(bool)
def __init__(
self,
parent: QWidget,
targets: Sequence[QWidget],
*,
expanded: bool = False,
) -> None:
super().__init__(parent)
self._targets = tuple(targets)
self._expanded = bool(expanded)
self.button = QPushButton(parent)
self.button.setObjectName("FilterDisclosureButton")
self.button.setCheckable(True)
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
self.button.setFixedHeight(32)
self.button.setIconSize(QSize(14, 14))
self.button.setStyleSheet("""
QPushButton#FilterDisclosureButton {
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
min-width: 92px; font-size: 13px; font-weight: 400;
}
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
""")
self.button.toggled.connect(self.set_expanded)
self._apply()
@property
def expanded(self) -> bool:
return self._expanded
def set_expanded(self, expanded: bool) -> None:
expanded = bool(expanded)
changed = expanded != self._expanded
self._expanded = expanded
self._apply()
if changed:
self.expanded_changed.emit(expanded)
def _apply(self) -> None:
# Keep keyboard focus on a visible control when folding a focused form.
focused = QApplication.focusWidget()
if not self._expanded and focused is not None and any(
target is focused or target.isAncestorOf(focused) for target in self._targets
):
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
for target in self._targets:
target.setVisible(self._expanded)
blocked = self.button.blockSignals(True)
self.button.setChecked(self._expanded)
self.button.blockSignals(blocked)
label = "收起筛选" if self._expanded else "展开筛选"
self.button.setText(label)
self.button.setAccessibleName(label)
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
+794
View File
@@ -0,0 +1,794 @@
"""Single source of truth for every line icon in the workstation.
Before this module the application drew its icons from nine independent
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
makes an icon set read as one family:
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
the same glyph asked for at another size came out off-centre or clipped;
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
solid ``down`` triangle);
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
across a pixel boundary and rendered visibly softer than its neighbours;
* six near-identical indigos and two near-identical reds picked per call site
instead of from the palette.
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
area, stroked with one weight formula, and scaled to the requested size by the
painter transform. Glyphs are pure stroke unless a filled counter is part of
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
single apparent weight.
Icons are cached as well. List pages build one icon per action button per row,
so the previous code re-ran a ``QPainter`` for every visible row on every
refresh; the cache turns that into one paint per (kind, colour, size).
"""
from __future__ import annotations
import math
from collections.abc import Callable
from functools import lru_cache
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import QApplication
from .theme import COLORS, crisp_pixmap
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
#: optically identical instead of merely proportional.
GRID = 24.0
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
#: 36 px, which is the range the shell actually asks for.
_STROKE_RATIO = 2.0 / GRID
_STROKE_MIN_PX = 1.25
_STROKE_MAX_PX = 2.25
def stroke_px(size: float) -> float:
"""Return the on-screen stroke width used for an icon of ``size`` px."""
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
# --- Semantic colour roles ------------------------------------------------
# Call sites name a role instead of a hex value. The seven painters replaced
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
# #4965F5 and #6675F5 for what was always meant to be one accent.
ROLES = {
"default": COLORS["muted"],
"muted": COLORS["muted"],
"soft": COLORS["text_soft"],
"strong": COLORS["text"],
"accent": COLORS["indigo"],
"on_accent": "#FFFFFF",
"success": COLORS["success"],
"warning": COLORS["warning"],
"danger": COLORS["danger"],
"info": COLORS["info"],
"disabled": COLORS["disabled_text"],
"inverse": "#FFFFFF",
}
def resolve_color(color: str) -> str:
"""Accept either a semantic role name or a literal colour string."""
return ROLES.get(color, color)
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
_Glyph = Callable[[QPainter, float], None]
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
def register(fn: _Glyph) -> _Glyph:
for name in names:
_GLYPHS[name] = fn
return fn
return register
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
path = QPainterPath(QPointF(*points[0]))
for point in points[1:]:
path.lineTo(QPointF(*point))
p.drawPath(path)
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
p.drawEllipse(QPointF(cx, cy), r, r)
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
"""Filled counter - used only where the mark itself is solid."""
pen = p.pen()
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(pen.color())
p.drawEllipse(QPointF(cx, cy), r, r)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(pen)
def _page(p: QPainter, *, fold: bool = True) -> None:
"""Shared document silhouette so every file-like glyph has one outline."""
path = QPainterPath(QPointF(14.0, 2.5))
path.lineTo(QPointF(6.5, 2.5))
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
path.lineTo(QPointF(5.0, 20.0))
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
path.lineTo(QPointF(17.5, 21.5))
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
path.lineTo(QPointF(19.0, 7.5))
path.closeSubpath()
p.drawPath(path)
if fold:
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
"""Four-point concave star - the one AI mark used across the product."""
path = QPainterPath(QPointF(cx, cy - r))
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
path.closeSubpath()
p.drawPath(path)
def _panel(p: QPainter) -> None:
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
_line(p, 9.5, 4.0, 9.5, 20.0)
# --- Navigation -----------------------------------------------------------
@_glyph("reception", "workbench", "monitor")
def _reception(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
_line(p, 12.0, 17.0, 12.0, 20.5)
_line(p, 8.0, 20.5, 16.0, 20.5)
@_glyph("appointments", "calendar")
def _calendar(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
_line(p, 3.0, 10.0, 21.0, 10.0)
_line(p, 8.0, 2.75, 8.0, 7.0)
_line(p, 16.0, 2.75, 16.0, 7.0)
@_glyph("prescription_library", "library", "layers")
def _layers(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(12.0, 2.5))
path.lineTo(QPointF(21.0, 7.0))
path.lineTo(QPointF(12.0, 11.5))
path.lineTo(QPointF(3.0, 7.0))
path.closeSubpath()
p.drawPath(path)
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
@_glyph("prescriptions", "file_check")
def _file_check(p: QPainter, w: float) -> None:
_page(p)
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
@_glyph("patients", "users")
def _users(p: QPainter, w: float) -> None:
_circle(p, 9.0, 8.0, 3.5)
path = QPainterPath(QPointF(2.5, 20.5))
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
p.drawPath(path)
_circle(p, 17.6, 8.0, 2.8)
tail = QPainterPath(QPointF(17.0, 13.9))
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
p.drawPath(tail)
@_glyph("consultations", "consult", "message", "other")
def _message(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(6.5, 3.5))
path.lineTo(QPointF(17.5, 3.5))
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
path.lineTo(QPointF(20.5, 13.5))
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
path.lineTo(QPointF(11.5, 16.5))
path.lineTo(QPointF(7.0, 20.5))
path.lineTo(QPointF(7.0, 16.5))
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
path.lineTo(QPointF(3.5, 6.5))
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
p.drawPath(path)
_line(p, 7.75, 8.25, 16.25, 8.25)
_line(p, 7.75, 11.75, 13.0, 11.75)
# --- Shell chrome ---------------------------------------------------------
@_glyph("fold", "panel_close")
def _fold(p: QPainter, w: float) -> None:
_panel(p)
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
@_glyph("expand", "panel_open")
def _expand(p: QPainter, w: float) -> None:
_panel(p)
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
@_glyph("search")
def _search(p: QPainter, w: float) -> None:
_circle(p, 10.5, 10.5, 6.25)
_line(p, 15.15, 15.15, 19.75, 19.75)
@_glyph("refresh", "rotate")
def _refresh(p: QPainter, w: float) -> None:
# The arc terminates exactly on the arrow corner so the mark reads as one
# continuous stroke. The painters replaced here left a detached triangle
# (shell) or two stray lines that never formed a head at all (prescriptions).
radius = math.hypot(8.5, 3.5)
start = math.degrees(math.atan2(3.5, 8.5))
p.drawArc(
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
round(start * 16),
round((360.0 - start) * 16),
)
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
@_glyph("ai", "spark", "sparkle", "assistant")
def _ai(p: QPainter, w: float) -> None:
_sparkle(p, 10.2, 11.8, 7.2)
_sparkle(p, 18.0, 6.0, 3.2)
@_glyph("fullscreen", "expand-corners", "maximize")
def _fullscreen(p: QPainter, w: float) -> None:
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
@_glyph("minimize")
def _minimize(p: QPainter, w: float) -> None:
_line(p, 5.0, 12.0, 19.0, 12.0)
@_glyph("close", "cross")
def _close(p: QPainter, w: float) -> None:
_line(p, 5.75, 5.75, 18.25, 18.25)
_line(p, 18.25, 5.75, 5.75, 18.25)
@_glyph("down", "chevron_down")
def _down(p: QPainter, w: float) -> None:
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
@_glyph("up", "chevron_up")
def _up(p: QPainter, w: float) -> None:
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
@_glyph("left", "chevron_left")
def _left(p: QPainter, w: float) -> None:
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
@_glyph("right", "chevron_right")
def _right(p: QPainter, w: float) -> None:
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
@_glyph("notification", "bell")
def _bell(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(6.75, 17.5))
path.lineTo(QPointF(6.75, 10.75))
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
path.lineTo(QPointF(17.25, 17.5))
p.drawPath(path)
_line(p, 4.5, 17.5, 19.5, 17.5)
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
@_glyph("settings", "sliders")
def _settings(p: QPainter, w: float) -> None:
_line(p, 3.5, 8.5, 20.5, 8.5)
_line(p, 3.5, 15.5, 20.5, 15.5)
_circle(p, 9.0, 8.5, 2.4)
_circle(p, 15.0, 15.5, 2.4)
# --- Row and toolbar actions ---------------------------------------------
@_glyph("eye", "view")
def _eye(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(2.5, 12.0))
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
p.drawPath(path)
_circle(p, 12.0, 12.0, 3.0)
@_glyph("pencil", "edit")
def _pencil(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(16.25, 3.0))
path.lineTo(QPointF(20.75, 7.5))
path.lineTo(QPointF(8.5, 19.75))
path.lineTo(QPointF(3.0, 21.0))
path.lineTo(QPointF(4.25, 15.5))
path.closeSubpath()
p.drawPath(path)
_line(p, 13.0, 6.25, 17.5, 10.75)
@_glyph("trash", "delete")
def _trash(p: QPainter, w: float) -> None:
_line(p, 3.5, 6.25, 20.5, 6.25)
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
path = QPainterPath(QPointF(5.75, 6.25))
path.lineTo(QPointF(6.6, 19.4))
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
path.lineTo(QPointF(16.2, 20.5))
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
path.lineTo(QPointF(18.25, 6.25))
p.drawPath(path)
_line(p, 10.0, 10.0, 10.0, 16.75)
_line(p, 14.0, 10.0, 14.0, 16.75)
@_glyph("plus", "add")
def _plus(p: QPainter, w: float) -> None:
_line(p, 12.0, 4.75, 12.0, 19.25)
_line(p, 4.75, 12.0, 19.25, 12.0)
@_glyph("check")
def _check(p: QPainter, w: float) -> None:
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
@_glyph("check_circle", "health")
def _check_circle(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
@_glyph("checkbox")
def _checkbox(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
@_glyph("lock")
def _lock(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
_dot(p, 12.0, 15.5, 1.35)
@_glyph("document", "file")
def _document(p: QPainter, w: float) -> None:
_page(p)
_line(p, 8.5, 12.75, 15.5, 12.75)
_line(p, 8.5, 16.75, 13.25, 16.75)
@_glyph("report")
def _report(p: QPainter, w: float) -> None:
_page(p)
_line(p, 8.75, 17.75, 8.75, 14.25)
_line(p, 12.0, 17.75, 12.0, 10.5)
_line(p, 15.25, 17.75, 15.25, 12.75)
@_glyph("list")
def _list(p: QPainter, w: float) -> None:
for y in (6.5, 12.0, 17.5):
_dot(p, 4.5, y, 1.2)
_line(p, 8.75, y, 19.5, y)
@_glyph("user", "person")
def _user(p: QPainter, w: float) -> None:
_circle(p, 12.0, 8.0, 4.0)
path = QPainterPath(QPointF(4.25, 20.5))
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
p.drawPath(path)
@_glyph("remove", "minus_circle")
def _remove(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 8.0, 12.0, 16.0, 12.0)
@_glyph("stop", "close_circle")
def _stop(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 9.0, 9.0, 15.0, 15.0)
_line(p, 15.0, 9.0, 9.0, 15.0)
@_glyph("info")
def _info(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 12.0, 11.25, 12.0, 16.5)
_dot(p, 12.0, 7.75, 1.0)
@_glyph("picture", "image")
def _picture(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
_circle(p, 7.75, 8.75, 1.6)
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
@_glyph("meds", "pill")
def _meds(p: QPainter, w: float) -> None:
p.save()
p.translate(12.0, 12.0)
p.rotate(-45.0)
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
_line(p, 0.0, -4.5, 0.0, 4.5)
p.restore()
@_glyph("daily", "clipboard")
def _clipboard(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(8.5, 4.5))
path.lineTo(QPointF(6.75, 4.5))
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
path.lineTo(QPointF(4.25, 19.0))
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
path.lineTo(QPointF(17.25, 21.5))
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
path.lineTo(QPointF(19.75, 7.0))
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
path.lineTo(QPointF(15.5, 4.5))
p.drawPath(path)
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
_line(p, 8.0, 12.0, 16.0, 12.0)
_line(p, 8.0, 16.25, 13.5, 16.25)
@_glyph("followup", "calendar_clock")
def _followup(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(13.0, 20.0))
path.lineTo(QPointF(5.5, 20.0))
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
path.lineTo(QPointF(3.0, 7.5))
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
path.lineTo(QPointF(14.5, 5.0))
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
path.lineTo(QPointF(17.0, 9.0))
p.drawPath(path)
_line(p, 3.0, 9.5, 17.0, 9.5)
_line(p, 7.0, 2.75, 7.0, 6.75)
_line(p, 13.0, 2.75, 13.0, 6.75)
_circle(p, 16.75, 16.75, 4.5)
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
@_glyph("brand", "logo")
def _brand(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
# --- AI consultation ------------------------------------------------------
@_glyph("chart", "analytics")
def _analytics(p: QPainter, w: float) -> None:
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
@_glyph("trend")
def _trend(p: QPainter, w: float) -> None:
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
@_glyph("alert", "warning")
def _alert(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(12.0, 3.0))
path.lineTo(QPointF(21.5, 20.0))
path.lineTo(QPointF(2.5, 20.0))
path.closeSubpath()
p.drawPath(path)
_line(p, 12.0, 9.5, 12.0, 14.5)
_dot(p, 12.0, 17.4, 1.05)
@_glyph("mic", "microphone")
def _mic(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
_line(p, 12.0, 17.5, 12.0, 21.0)
_line(p, 8.25, 21.0, 15.75, 21.0)
@_glyph("send")
def _send(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(21.0, 3.0))
path.lineTo(QPointF(2.5, 10.5))
path.lineTo(QPointF(10.25, 13.75))
path.lineTo(QPointF(13.5, 21.5))
path.closeSubpath()
p.drawPath(path)
_line(p, 10.25, 13.75, 21.0, 3.0)
@_glyph("qr", "qrcode")
def _qr(p: QPainter, w: float) -> None:
"""Three finder squares plus a few modules - the shape people scan for."""
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
_dot(p, x + 3.5, y + 3.5, 1.15)
_line(p, 14.5, 14.5, 14.5, 17.0)
_line(p, 18.0, 14.5, 21.0, 14.5)
_line(p, 17.5, 18.0, 17.5, 21.0)
_dot(p, 20.75, 20.75, 1.15)
@_glyph("video", "call")
def _video(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
path = QPainterPath(QPointF(16.0, 10.5))
path.lineTo(QPointF(21.5, 7.25))
path.lineTo(QPointF(21.5, 16.75))
path.lineTo(QPointF(16.0, 13.5))
path.closeSubpath()
p.drawPath(path)
# --- Clinical measures ----------------------------------------------------
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
# "BMI" as the Venus symbol.
@_glyph("height")
def _height(p: QPainter, w: float) -> None:
_line(p, 6.0, 3.5, 18.0, 3.5)
_line(p, 6.0, 20.5, 18.0, 20.5)
_line(p, 12.0, 5.75, 12.0, 18.25)
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
@_glyph("weight", "scale")
def _weight(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
_line(p, 12.0, 15.0, 9.9, 10.9)
@_glyph("bmi", "body")
def _bmi(p: QPainter, w: float) -> None:
_circle(p, 12.0, 5.0, 2.75)
_line(p, 12.0, 7.75, 12.0, 15.0)
_line(p, 7.25, 11.0, 16.75, 11.0)
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
@_glyph("blood_pressure", "gauge", "bp")
def _blood_pressure(p: QPainter, w: float) -> None:
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
_line(p, 3.0, 14.5, 21.0, 14.5)
_line(p, 12.0, 14.5, 16.4, 9.4)
_dot(p, 12.0, 14.5, 1.15)
@_glyph("pulse", "heart")
def _pulse(p: QPainter, w: float) -> None:
heart = QPainterPath(QPointF(12.0, 20.25))
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
heart.closeSubpath()
p.drawPath(heart)
_polyline(
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
)
@_glyph("smoke", "cigarette")
def _smoke(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
_line(p, 13.0, 14.0, 13.0, 19.0)
curl = QPainterPath(QPointF(19.0, 12.0))
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
p.drawPath(curl)
@_glyph("drink", "glass")
def _drink(p: QPainter, w: float) -> None:
bowl = QPainterPath(QPointF(6.5, 3.5))
bowl.lineTo(QPointF(17.5, 3.5))
bowl.lineTo(QPointF(13.75, 12.5))
bowl.lineTo(QPointF(10.25, 12.5))
bowl.closeSubpath()
p.drawPath(bowl)
_line(p, 7.75, 7.5, 16.25, 7.5)
_line(p, 12.0, 12.5, 12.0, 20.0)
_line(p, 8.0, 20.0, 16.0, 20.0)
@_glyph("exercise", "run")
def _exercise(p: QPainter, w: float) -> None:
_circle(p, 15.75, 4.75, 2.5)
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
# --- Painting -------------------------------------------------------------
def _device_ratio() -> float:
app = QApplication.instance()
if app is None:
return 1.0
screen = app.primaryScreen()
if screen is None:
return 1.0
return max(1.0, float(screen.devicePixelRatio()))
def _render(kind: str, color: str, size: int) -> QPixmap:
canvas = crisp_pixmap(size)
draw = _GLYPHS.get(kind)
if draw is None:
return canvas
painter = QPainter(canvas)
try:
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
# Inset the grid by half a stroke on every edge. Without it a glyph that
# legitimately reaches grid unit 24 loses the outer half of its line to
# the pixmap boundary at the smaller sizes - which is exactly how the old
# painters lost the shell star's companion dot and flattened the top of
# the calendar. Insetting here means every glyph can use the full grid.
weight = stroke_px(size)
scale = (size - weight) / GRID
painter.translate(weight / 2.0, weight / 2.0)
painter.scale(scale, scale)
# The pen width is expressed on the design grid, so the on-screen weight
# stays the same fraction of the box at every size the shell asks for.
width = weight / scale
pen = QPen(QColor(color), width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
draw(painter, width)
finally:
painter.end()
return canvas
@lru_cache(maxsize=1024)
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
del ratio # part of the cache key only; crisp_pixmap reads it back itself
return _render(kind, color, size)
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
@lru_cache(maxsize=1024)
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
result = QIcon(_cached_pixmap(kind, color, size, ratio))
result.addPixmap(
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
QIcon.Mode.Disabled,
QIcon.State.Off,
)
return result
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
"""Return a cached icon with a matching disabled variant already attached.
``color`` accepts a role name from :data:`ROLES` or a literal colour.
"""
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
@lru_cache(maxsize=256)
def _cached_state_icon(
kind: str,
size: int,
normal: str,
active: str,
checked: str,
disabled: str,
ratio: float,
) -> QIcon:
result = QIcon()
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
result.addPixmap(
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
)
return result
def state_icon(
kind: str,
*,
size: int = 18,
normal: str = "muted",
active: str = "strong",
checked: str = "inverse",
disabled: str = "disabled",
) -> QIcon:
"""Return an icon carrying its own hover / selected / disabled colours.
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
selected navigation row keeps its resting grey and reads as switched off.
"""
return _cached_state_icon(
kind,
int(size),
resolve_color(normal),
resolve_color(active),
resolve_color(checked),
resolve_color(disabled),
_device_ratio(),
)
def available_kinds() -> tuple[str, ...]:
"""Every glyph name this module answers to, aliases included."""
return tuple(sorted(_GLYPHS))
def clear_cache() -> None:
"""Drop cached pixmaps - used when the display scale factor changes."""
_cached_pixmap.cache_clear()
_cached_icon.cache_clear()
_cached_state_icon.cache_clear()
@@ -0,0 +1,320 @@
"""Incremental server lists with a compact status footer and stable view state."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
from PySide6.QtWidgets import (
QAbstractScrollArea,
QCheckBox,
QHBoxLayout,
QLabel,
QPushButton,
QTableWidget,
QWidget,
)
from .widgets import first_value, get_value, page_items, page_total, run_async
def record_key(row: Any) -> str:
value = first_value(
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
)
return str(value) if value is not None else repr(row)
class ListSnapshot:
"""Keep repository metadata available while replacing only the list payload."""
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
self.items = rows
self.total = total
self.source = source
def __getattr__(self, name: str) -> Any:
return get_value(self.source, name, None)
class InfiniteList(QWidget):
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
Reloads use captured query arguments. Refreshing the same query rebuilds the
loaded prefix atomically, so polling neither drops appended rows nor mixes
an updated first page with an old tail. Failed appends retain the prior page
and can be retried explicitly without an automatic request loop.
"""
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("InfiniteList")
self.setFixedHeight(24)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 0, 12, 0)
self.summary_label = QLabel("", self)
self.summary_label.setStyleSheet(
"color: #5D6B80; font-size: 12px; background: transparent;"
)
layout.addWidget(self.summary_label)
layout.addStretch(1)
self.retry_button = QPushButton("加载失败,点击重试", self)
self.retry_button.setFlat(True)
self.retry_button.setStyleSheet(
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
)
self.retry_button.hide()
self.retry_button.clicked.connect(self.retry)
layout.addWidget(self.retry_button)
self.page_size = page_size
self.page = 0
self.total = 0
self.rows: list[Any] = []
self.loading = False
self.has_more = False
self._generation = 0
self._query_key: Any = object()
self._view: QAbstractScrollArea | None = None
self._views: list[QAbstractScrollArea] = []
self._error = False
self._configured = False
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._maybe_load_more)
def bind(self, view: QAbstractScrollArea) -> None:
if view in self._views:
return
self._views.append(view)
self._view = view
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
view.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: Any) -> bool:
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
self._schedule_check()
return super().eventFilter(watched, event)
def _schedule_check(self, *_: Any) -> None:
self._timer.start(30)
def _maybe_load_more(self) -> None:
view = next((v for v in self._views if v.isVisible()), None)
if view is None or self.loading or self._error or not self.has_more:
return
bar = view.verticalScrollBar()
# pageStep respects both per-item and per-pixel Qt scrolling modes.
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
self.load_more()
def invalidate(self) -> None:
"""Disarm callbacks when a reusable dialog switches to another record."""
self._generation += 1
self._configured = False
self.loading = self.has_more = self._error = False
self.rows, self.page, self.total = [], 0, 0
self._timer.stop()
self.retry_button.hide()
self._status()
reset = invalidate
def reload(
self,
fetch: Callable[[int], Any],
apply: Callable[[Any], None],
on_error: Callable[[Exception], None],
*,
runner: Callable[..., Any] = run_async,
query_key: Any = None,
on_finished: Callable[[], None] | None = None,
) -> None:
same_query = self._configured and query_key == self._query_key
if same_query and self.loading:
# Polling must not restart a slow prefix refresh indefinitely. The
# caller may have advanced its own generation, so use its latest
# render/error closures while the captured request finishes.
self._apply, self._on_error = apply, on_error
self._on_finished = on_finished
return
self._generation += 1
self._query_key = query_key
self._configured = True
self._fetch, self._apply, self._on_error = fetch, apply, on_error
self._runner, self._on_finished = runner, on_finished
self._target = max(1, self.page) if same_query else 1
self._reset_view = not same_query
if not same_query:
self.rows, self.page, self.total = [], 0, 0
self.has_more = False
self._render(ListSnapshot([], 0, None), preserve=False)
self._begin(1, [], refresh=True)
def load_more(self) -> None:
if not self._configured or self.loading or self._error or not self.has_more:
return
self._reset_view = False
self._target = self.page + 1
self._begin(self.page + 1, list(self.rows), refresh=False)
def retry(self) -> None:
if self.loading or not self._error:
return
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
self.loading = True
self._error = False
self.retry_button.hide()
self.summary_label.setText(
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
)
generation = self._generation
fetch = self._fetch
self._runner(
lambda: fetch(page),
on_success=lambda result: self._received(result, generation, page, rows, refresh),
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
on_finished=lambda: None,
)
def _received(
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
) -> None:
if generation != self._generation:
return
incoming = page_items(result)
merged = {record_key(row): row for row in prior}
before = len(merged)
for row in incoming:
merged[record_key(row)] = row
rows = list(merged.values())
total = page_total(result, -1)
more = (
bool(incoming)
and len(rows) > before
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
)
# Retain first-page metadata (scope, counts, filter choices) on refresh.
if page == 1:
self._refresh_source = result
source = self._refresh_source
if refresh and page < self._target and more:
self._begin(page + 1, rows, refresh=True)
return
self.rows, self.page = rows, page
self.total = max(len(rows), total)
self.has_more = more
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
self.loading = False
self._status()
if self._on_finished is not None:
self._on_finished()
self._schedule_check()
def _failed(
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
) -> None:
if generation != self._generation:
return
self.loading = False
self._error = True
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
self.summary_label.setText(f"已加载 {len(self.rows)} 条" if self.rows else "暂未加载数据")
self.retry_button.show()
self._on_error(error)
if self._on_finished is not None:
self._on_finished()
def _status(self) -> None:
if self.has_more:
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
else:
if self.total > len(self.rows):
self.summary_label.setText(
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
)
else:
self.summary_label.setText(
f"共 {len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
)
def update_state(self, page: int, total: int) -> None:
"""Compatibility for existing render callbacks; requests own the state."""
del page, total
self._status()
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
view = next((v for v in self._views if v.isVisible()), self._view)
if view is None:
self._apply(snapshot)
return
bar = view.verticalScrollBar()
scroll = bar.value()
horizontal_scroll = view.horizontalScrollBar().value()
selected: set[str] = set()
checks: dict[tuple[str, int], Qt.CheckState] = {}
widget_checks: dict[tuple[str, int, int], bool] = {}
if preserve and isinstance(view, QTableWidget):
for row in range(view.rowCount()):
first = view.item(row, 0)
if first is None:
continue
key = record_key(first.data(Qt.ItemDataRole.UserRole))
if first.isSelected():
selected.add(key)
for column in range(view.columnCount()):
item = view.item(row, column)
if (
item is not None
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
):
checks[key, column] = item.checkState()
widget = view.cellWidget(row, column)
if widget is not None:
boxes = (
[widget]
if isinstance(widget, QCheckBox)
else widget.findChildren(QCheckBox)
)
for index, box in enumerate(boxes):
widget_checks[key, column, index] = box.isChecked()
blocker = QSignalBlocker(view)
try:
self._apply(snapshot)
if preserve and isinstance(view, QTableWidget):
if selected:
view.clearSelection()
for row in range(view.rowCount()):
first = view.item(row, 0)
if first is None:
continue
key = record_key(first.data(Qt.ItemDataRole.UserRole))
if key in selected:
view.selectRow(row)
for column in range(view.columnCount()):
item = view.item(row, column)
if item is not None and (key, column) in checks:
item.setCheckState(checks[key, column])
widget = view.cellWidget(row, column)
if widget is not None:
boxes = (
[widget]
if isinstance(widget, QCheckBox)
else widget.findChildren(QCheckBox)
)
for index, box in enumerate(boxes):
if (key, column, index) in widget_checks:
box.setChecked(widget_checks[key, column, index])
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
view.horizontalScrollBar().setValue(horizontal_scroll)
finally:
blocker.unblock()
if isinstance(view, QTableWidget):
view.itemSelectionChanged.emit()
__all__ = ["InfiniteList", "ListSnapshot"]
+364
View File
@@ -0,0 +1,364 @@
"""Motion tokens and helpers.
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
at all, so every state change was an instant cut: pages replaced each other
between one frame and the next, drawers appeared fully formed, toasts blinked
in and out. Nothing was slow - it just gave the eye no continuity to follow,
which is what reads as "not smooth" however fast the code underneath is.
Everything here is short. A workstation is used all day, so transitions are
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
measured in single-digit pixels. Anything longer starts costing the user time.
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
throughout. Two rules keep that safe:
* an animation must be owned, or PySide garbage-collects it mid-flight and the
widget freezes half-faded - :func:`_own` parks it on the target;
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
offscreen render path, which would make a table scroll badly for the rest of
the session - every fade here removes its effect when it finishes.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import (
QAbstractAnimation,
QEasingCurve,
QEvent,
QObject,
QPoint,
QPropertyAnimation,
Qt,
QTimer,
)
from PySide6.QtWidgets import (
QAbstractScrollArea,
QGraphicsOpacityEffect,
QStackedWidget,
QWidget,
)
#: Durations in milliseconds.
FAST = 110 # hover-scale feedback, small fades
BASE = 170 # the default: page and panel transitions
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
#: Entering elements decelerate; elements that move between two known places
#: ease in and out; large travel gets a longer tail so it never looks linear.
EASE_ENTER = QEasingCurve.Type.OutCubic
EASE_MOVE = QEasingCurve.Type.InOutCubic
EASE_TRAVEL = QEasingCurve.Type.OutQuint
#: How far an entering surface rises, in device-independent pixels. Kept small
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
RISE = 8
def reduced_motion() -> bool:
"""Whether animation should be skipped entirely.
Off by default under the offscreen platform so widget grabs in tests and in
the packaging smoke checks capture a settled frame rather than a frame from
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
"""
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
if override in {"off", "0", "false", "none", "reduce"}:
return True
if override in {"on", "1", "true", "full"}:
return False
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
previous = running.get(key)
if previous is not None:
previous.stop()
running[key] = animation
target._doctor_motion = running
return animation
def animate(
target: Any,
prop: bytes,
start: Any,
end: Any,
*,
duration: int = BASE,
easing: QEasingCurve.Type = EASE_ENTER,
key: str | None = None,
owner: QWidget | None = None,
on_finished: Callable[[], None] | None = None,
) -> QPropertyAnimation | None:
"""Animate one Qt property, or apply the end value outright if motion is off.
``owner`` keeps the animation alive independently of ``target``. Fades
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
ends, so parenting the animation to the effect would destroy the animation
from inside its own ``finished`` emission.
"""
if reduced_motion():
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
if on_finished is not None:
on_finished()
return None
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
animation.setDuration(duration)
animation.setEasingCurve(easing)
animation.setStartValue(start)
animation.setEndValue(end)
if on_finished is not None:
animation.finished.connect(on_finished)
_own(owner if owner is not None else target, key or prop.decode(), animation)
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
return animation
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
effect.setEnabled(True)
return effect
def _drop_effect(widget: QWidget) -> None:
"""Detach the opacity effect once a fade is done.
Leaving it attached keeps the widget on Qt's offscreen composite path, which
is exactly the sort of quiet, permanent frame-rate tax this module exists to
avoid introducing.
The detach is deferred by one event-loop turn on purpose. ``finished`` is
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
the old effect immediately - tearing down the object graph underneath a
signal that is still being delivered.
"""
def detach() -> None:
try:
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
widget.setGraphicsEffect(None)
except RuntimeError: # the widget went away while the fade was running
pass
QTimer.singleShot(0, detach)
def fade_in(
widget: QWidget,
*,
duration: int = BASE,
start: float = 0.0,
easing: QEasingCurve.Type = EASE_ENTER,
) -> None:
"""Fade a widget up to full opacity, showing it first if needed."""
if reduced_motion():
widget.show()
return
effect = _opacity_effect(widget)
effect.setOpacity(start)
widget.show()
animate(
effect,
b"opacity",
start,
1.0,
duration=duration,
easing=easing,
key="fade",
owner=widget,
on_finished=lambda: _drop_effect(widget),
)
def fade_out(
widget: QWidget,
*,
duration: int = FAST,
hide: bool = True,
on_finished: Callable[[], None] | None = None,
) -> None:
"""Fade a widget down, optionally hiding it when the fade completes."""
if reduced_motion():
if hide:
widget.hide()
if on_finished is not None:
on_finished()
return
effect = _opacity_effect(widget)
def done() -> None:
if hide:
widget.hide()
_drop_effect(widget)
if on_finished is not None:
on_finished()
animate(
effect,
b"opacity",
float(effect.opacity()),
0.0,
duration=duration,
easing=EASE_MOVE,
key="fade",
owner=widget,
on_finished=done,
)
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
"""Fade a surface in while it settles upward by a few pixels.
The rise is what makes a swap read as one surface replacing another rather
than as a repaint; keeping it under ten pixels stops it becoming a gesture
the user has to wait out.
"""
if reduced_motion():
widget.show()
return
fade_in(widget, duration=duration)
if rise:
origin = widget.pos()
widget.move(origin + QPoint(0, rise))
animate(
widget,
b"pos",
widget.pos(),
origin,
duration=duration,
easing=EASE_ENTER,
key="enter",
)
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
"""Change the current page of a stack with a short cross-fade.
``QStackedWidget`` swaps pages between two frames with nothing in between,
which is the single most-seen transition in this product - it happens on
every sidebar click and on every list that toggles to its empty state.
"""
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
stack.setCurrentIndex(index)
return
stack.setCurrentIndex(index)
page = stack.currentWidget()
if page is None or reduced_motion():
return
enter(page, rise=rise)
# --- Smooth scrolling -----------------------------------------------------
#: One wheel notch travels this far, and takes this long to get there. Qt's
#: default is an instant jump of three lines per notch, which on a long clinical
#: record is the single jerkiest thing in the interface.
SCROLL_STEP = 120
SCROLL_MS = 190
class _SmoothScroller(QObject):
"""Animate a scroll area's wheel movement instead of jumping to it."""
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
super().__init__(area)
self._bar = (
area.verticalScrollBar()
if orientation is Qt.Orientation.Vertical
else area.horizontalScrollBar()
)
self._target = self._bar.value()
self._animation = QPropertyAnimation(self._bar, b"value", self)
self._animation.setEasingCurve(EASE_ENTER)
self._animation.setDuration(SCROLL_MS)
# Keyboard, programmatic and drag movements must not be fought over: when
# nothing is animating, the wheel target follows wherever the bar went.
self._bar.valueChanged.connect(self._sync_target)
area.viewport().installEventFilter(self)
def _sync_target(self, value: int) -> None:
if self._animation.state() != QAbstractAnimation.State.Running:
self._target = value
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
del watched
if event.type() is not QEvent.Type.Wheel or reduced_motion():
return False
delta = event.angleDelta().y() or event.angleDelta().x()
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
return False
lower, upper = self._bar.minimum(), self._bar.maximum()
# Re-clamp first: the range can shrink underneath a running animation
# when the content behind it reloads, which would otherwise leave the
# pending target past the end of the new content.
self._target = max(lower, min(upper, self._target))
target = self._target - round(delta / 120.0 * SCROLL_STEP)
target = max(lower, min(upper, target))
# At either end, hand the wheel back so an enclosing scroll area still
# gets it - swallowing it there is what makes nested panes feel stuck.
if target == self._target:
return False
self._target = target
self._animation.stop()
self._animation.setStartValue(self._bar.value())
self._animation.setEndValue(target)
self._animation.start()
return True
def install_smooth_scroll(
area: QAbstractScrollArea,
*,
orientation: Qt.Orientation = Qt.Orientation.Vertical,
) -> None:
"""Give a scroll area eased wheel scrolling."""
if getattr(area, "_doctor_smooth_scroll", None) is not None:
return
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
def press_feedback(widget: QWidget) -> None:
"""Mark a widget so the shared stylesheet can give it a pressed transform.
Qt has no CSS transitions, so the visual step itself lives in the palette's
pressed state; this only tags the widget as one that should get it.
"""
widget.setProperty("motionPress", True)
__all__ = [
"BASE",
"EASE_ENTER",
"EASE_MOVE",
"EASE_TRAVEL",
"FAST",
"RISE",
"SLOW",
"animate",
"enter",
"fade_in",
"fade_out",
"install_smooth_scroll",
"press_feedback",
"reduced_motion",
"switch_stack",
]
@@ -0,0 +1,119 @@
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return Template(_ORDERS).substitute(body=body_family())
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: #273244; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,54 @@
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return Template(_PROGRESS).substitute(body=body_family())
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: #273244; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,141 @@
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
from string import Template
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
def patient_list_stylesheet() -> str:
return Template(_LIST).substitute(body=body_family())
_CHROME = """
#PatientsPage { background: #F3F7FD; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: #273244; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: #5D6B80; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientSummaryStrip,
#PatientListWorkspace QFrame#PatientListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
}
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
}
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF;
}
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientListWorkspace QDateEdit:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
#PatientListWorkspace QTableWidget#PatientTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,359 @@
"""Local technology-blue rendering for the approved prescription library."""
from __future__ import annotations
from collections.abc import Iterable
from string import Template
from typing import Any
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, first_value
def library_stylesheet() -> str:
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, "#A4ADBA"),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Repaint existing action widgets and restore selection by template ID."""
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = "#BE4657" if button.property("danger") else "#1769E8"
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("、")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "、" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor("#5D6B80"))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor("#E6EDF6"))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor("#1769E8"))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor("#273244"))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.drawRoundedRect(pill, 4, 4)
painter.setPen(QColor("#1769E8"))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
#PrescriptionLibraryPage QFrame#MetricCard,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: #273244; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: #1555B6; background: #F2F7FF;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
@@ -0,0 +1,107 @@
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
_PRESCRIPTIONS = """
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: #A4ADBA;
}
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,106 @@
"""Palette and real font families for the approved reception page only."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.resources import resource_path
TECH_BLUE = {
"accent": "#1769E8",
"accent_hover": "#155BCC",
"accent_pressed": "#124EA9",
"selection": "#EAF2FF",
"selected_text": "#1555B6",
"canvas": "#F3F7FD",
"sidebar": "#EDF4FF",
"surface": "#FFFFFF",
"raised": "#F7FAFE",
"line": "#DBE5F2",
"text": "#273244",
"heading": "#202C3F",
"muted": "#5D6B80",
"focus": "#75A5F0",
}
def _families() -> dict[str, str]:
"""Resolve after Qt starts; font registration does not change its theme."""
app = QApplication.instance()
if app is None:
raise RuntimeError("Reception font families require a QApplication")
cached = getattr(app, "_reception_font_families", None)
if cached is not None:
return cached
heading = getattr(app, "_doctor_bundled_font_family", None)
if not heading:
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if registered:
heading = registered[0]
app._doctor_bundled_font_family = heading
available = set(QFontDatabase.families())
# Qt's offscreen platform does not enumerate Windows fonts automatically.
# Register only files already installed on this machine, never substitutes
# downloaded or installed into the user's Windows font registry.
if sys.platform == "win32":
font_dir = (
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
)
for family, filenames in (
("Microsoft YaHei UI", ("msyh.ttc",)),
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
):
if family in available:
continue
for filename in filenames:
font_path = font_dir / filename
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0:
available.update(QFontDatabase.applicationFontFamilies(font_id))
fallback = heading or next(
(
family
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
if family in available
),
app.font().family(),
)
resolved = {
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
"heading": heading or fallback,
"number": "Segoe UI" if "Segoe UI" in available else fallback,
}
app._reception_font_families = resolved
return resolved
def body_family() -> str:
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
return _families()["body"]
def heading_family() -> str:
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
return _families()["heading"]
def number_family() -> str:
"""Segoe UI supplies Regular and Semibold for numeric labels."""
return _families()["number"]