更新
This commit is contained in:
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user