Source code for spacr.qt.widgets.pivot_builder

"""Tabulate — drag columns onto rows and columns, get a table with its n.

The chrome over :mod:`spacr.qt.widgets.pivot_spec`. Everything about *what the
table contains* is in there; this module is three drop wells, a stack of
aggregation tick boxes and a grid.

Reused rather than rebuilt
--------------------------
The column well is
:class:`spacr.qt.widgets.graph_builder.ColumnWell` and the drag payload is
:data:`~spacr.qt.widgets.graph_builder.COLUMN_MIME` — the same list, the same
classification of what is worth offering, and the same payload type, so a
column dragged in the Graph Builder and a column dragged here are the same
gesture. The drop targets are different, and that is the only reason there is a
widget in here at all: a Graph Builder zone holds exactly one column, and a
pivot axis holds a *nest* of them, outermost first.

What the grid shows, and what it refuses to
-------------------------------------------
**Every cell prints its n**, under the statistics, whether or not ``n`` was
ticked. An aggregate over 4 objects and one over 4 000 are three digits either
way, and the only thing that tells them apart is the number this panel refuses
to hide. Cells at or below :data:`~spacr.qt.widgets.pivot_spec.LOW_N` are drawn
muted, so a table can be scanned for "which of these am I allowed to believe".

**An empty cell is blank, not zero.** The rule and its reasoning are in
:mod:`spacr.qt.widgets.pivot_spec`; the panel's part is not to paper over it
with a ``0`` because a ``QTableWidgetItem`` would rather have a number.

Plotting the result
-------------------
There is no chart in here. :meth:`PivotPanel.long_frame` hands the Graph
Builder a tidy frame — one row per non-empty cell, one column per statistic —
and the Graph Builder does what it already does. A second plotting
implementation would be a second set of scale, facet and colour rules to keep
in step with the first.
"""
from __future__ import annotations

import logging
from typing import Dict, List, Optional, Tuple

import pandas as pd
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QBrush, QColor, QFont
from PySide6.QtWidgets import (
    QAbstractItemView, QDoubleSpinBox, QFileDialog, QGridLayout,
    QHBoxLayout, QHeaderView, QLabel, QListWidget, QListWidgetItem,
    QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout,
    QWidget,
)

from ..theme import (RADIUS, SPACING, active_palette, apply_close_mark,
                     font_px, mark_surface, register_widget_qss)
from .collapsible_splitter import CollapsibleSplitter
from .graph_builder import COLUMN_MIME, ColumnWell
from .pivot_spec import (
    AGGREGATION_LABELS, AGGREGATIONS, COUNT_ONLY, LOW_N, MEAN, N, SD,
    WELL_HIERARCHY, PivotError, PivotResult, PivotSpec, format_value, pivot,
)
from .toggle import Toggle
from .sortable_table import install_sorting, table_item

LOG = logging.getLogger("spacr.qt.pivot")

__all__ = ["AXIS_ROWS", "AXIS_COLS", "AXIS_VALUES", "AXIS_LABELS",
           "DropWell", "PivotTable", "PivotPanel"]

AXIS_ROWS = "rows"
AXIS_COLS = "cols"
AXIS_VALUES = "values"

AXIS_LABELS = {
    AXIS_ROWS: "Rows",
    AXIS_COLS: "Columns",
    AXIS_VALUES: "Values",
}

AXIS_HINTS = {
    AXIS_ROWS: "Keys nesting down the rows, outermost first — plateID, then "
               "rowID, then columnID is the usual one. Drop order is nesting "
               "order.",
    AXIS_COLS: "Keys nesting across the columns. Keep this axis short; a "
               "table is read down the page.",
    AXIS_VALUES: "The measurements to aggregate. Leave it empty for a "
                 "contingency table of counts.",
}

#: Recomputes are coalesced this long, so ticking three aggregations in a row
#: costs one pass over the frame rather than three.
DEBOUNCE_MS = 150

#: Beyond this many cells the grid is built but not populated cell by cell:
#: a QTableWidget with a million items is minutes of construction for a table
#: nobody will scroll. Export is the answer, and the notice says so.
MAX_RENDERED_CELLS = 20_000


[docs] class DropWell(QWidget): """One pivot axis: an ordered list of columns, filled by dropping. A list rather than a single-slot zone, because a pivot axis is a *nest*: ``plateID`` then ``rowID`` then ``columnID`` is three keys on one axis and the order is the hierarchy. Drop order is that order, which is the only rule that needs no explanation at the moment a user drags something. Removing is Delete, Backspace or a double-click. There is no drag-out: a column dragged from here to another well would have to decide whether it was a move or a copy, and getting that wrong silently loses an axis. :param axis: which pivot axis this well holds. Must be a key of :data:`AXIS_LABELS`; anything else RAISES here rather than drawing a well nothing can be dropped into. :param parent: parent widget. """ changed = Signal() def __init__(self, axis: str, parent=None): """Build one axis well of the pivot shelf. :param axis: which axis this well holds -- a key of ``AXIS_LABELS``. :param parent: parent widget, or ``None``. :raises ValueError: if ``axis`` names no pivot axis. """ super().__init__(parent) if axis not in AXIS_LABELS: raise ValueError(f"unknown pivot axis {axis!r}") self.axis = axis self.setObjectName("PivotDropWell") outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(2) head = QHBoxLayout() head.setContentsMargins(0, 0, 0, 0) head.setSpacing(SPACING["xs"]) title = QLabel(AXIS_LABELS[axis], self) title.setObjectName("PivotWellName") head.addWidget(title, 1) clear = QPushButton(self) clear.setObjectName("PivotWellClear") apply_close_mark(clear, tooltip=f"Empty the {AXIS_LABELS[axis]} well") clear.clicked.connect(self.clear) head.addWidget(clear) outer.addLayout(head) self._list = _AxisList(self) self._list.setObjectName("PivotWellList") self._list.setToolTip(AXIS_HINTS[axis]) self._list.dropped.connect(self._on_dropped) self._list.remove_requested.connect(self._on_remove) outer.addWidget(self._list, 1)
[docs] def columns(self) -> Tuple[str, ...]: """The columns dropped into this well. :returns: the column names, in drop order. """ return tuple(self._list.item(i).data(Qt.UserRole) for i in range(self._list.count()))
[docs] def set_columns(self, columns) -> None: """Replace the contents. Silent when nothing changes — the panel recomputes on every emission and a redundant pass over a million rows is a visible pause for no visible difference. :param columns: the column names to hold, in order; empty names are dropped and the rest converted to strings. """ wanted = tuple(str(c) for c in columns if c) if wanted == self.columns(): return self._list.clear() for name in wanted: self._add(name) self.changed.emit()
[docs] def clear(self) -> None: """Empty the well.""" self.set_columns(())
def _add(self, name: str) -> None: """Put one column into the well. :param name: the column name; also stored on the item, so the well can be read back without parsing its labels. """ item = QListWidgetItem(name) item.setData(Qt.UserRole, name) item.setToolTip(f"{name}\nDelete or double-click to remove it.") self._list.addItem(item) def _on_dropped(self, name: str) -> None: """Accept a dropped column, unless the well already holds it. A column twice on one axis would group by itself, so the duplicate is dropped silently rather than reported. :param name: the dropped column name. """ if name and name not in self.columns(): self._add(name) self.changed.emit() def _on_remove(self, row: int) -> None: """Take one column out of the well. :param row: its position; out-of-range rows are ignored, since the list can change between the request and its delivery. """ if 0 <= row < self._list.count(): self._list.takeItem(row) self.changed.emit()
class _AxisList(QListWidget): """The list inside a :class:`DropWell`: takes :data:`COLUMN_MIME`. :param parent: parent widget; ownership only. """ dropped = Signal(str) remove_requested = Signal(int) def __init__(self, parent=None): """Build the list as a drop target that does not drag out.""" super().__init__(parent) self.setAcceptDrops(True) self.setDragEnabled(False) self.setDragDropMode(QAbstractItemView.DropOnly) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setMaximumHeight(84) self.itemDoubleClicked.connect( lambda item: self.remove_requested.emit(self.row(item))) def _accepts(self, event) -> bool: """Whether a drag carries a column this list can take.""" return event.mimeData() is not None and \ event.mimeData().hasFormat(COLUMN_MIME) def dragEnterEvent(self, event): # noqa: N802 - Qt name """Accept a dragged column, and refuse anything else. :param event: the drag event. """ if self._accepts(event): event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event): # noqa: N802 - Qt name """Keep accepting while the drag is over this well. :param event: the drag event. """ if self._accepts(event): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event): # noqa: N802 - Qt name """Announce the dropped column. :param event: the drop event; an empty payload is accepted but announces nothing, so a malformed drag does not add a nameless axis. """ if not self._accepts(event): event.ignore() return raw = bytes(event.mimeData().data(COLUMN_MIME)).decode("utf-8") if raw: self.dropped.emit(raw) event.acceptProposedAction() def keyPressEvent(self, event): # noqa: N802 - Qt name """Remove the selected column on Delete or Backspace. :param event: the key event. """ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): row = self.currentRow() if row >= 0: self.remove_requested.emit(row) return super().keyPressEvent(event)
[docs] class PivotTable(QTableWidget): """The grid. Renders a :class:`~spacr.qt.widgets.pivot_spec.PivotResult`. One column per row key so the table can be copied out whole, then one per column-level combination. Every populated cell ends with its ``n``; every empty one is blank. :param parent: parent widget. """ def __init__(self, parent=None): """Create an empty pivot table view. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setObjectName("PivotTable") self.setAlternatingRowColors(True) self.setEditTriggers(QAbstractItemView.NoEditTriggers) self.setSelectionMode(QAbstractItemView.ContiguousSelection) self.verticalHeader().setVisible(False) self.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeToContents) self.setWordWrap(True) install_sorting(self) self._result: Optional[PivotResult] = None self._truncated = 0 @property
[docs] def result(self) -> Optional[PivotResult]: """The pivot being displayed, if any. :returns: the result, or None before one has been computed. """ return self._result
@property
[docs] def truncated_cells(self) -> int: """Cells the grid refused to build. Non-zero means export, not scroll.""" return self._truncated
[docs] def cell_text(self, row: int, col: int) -> str: """What is actually painted in a body cell — for a test, and for a caller that wants the string rather than the float. :param row: the table row. :param col: the body column, counted after the row-key columns. """ item = self.item(row, col + len(self._header_offset_keys())) return item.text() if item is not None else ""
def _header_offset_keys(self) -> Tuple[str, ...]: """Return the row-key names that the leading header columns stand for. :returns: the result's row keys, ``("rows",)`` when it has none, and an empty tuple when nothing has been computed. """ result = self._result if result is None: return () return result.row_keys or ("rows",)
[docs] def set_result(self, result: Optional[PivotResult]) -> None: """Show a computed pivot. :param result: the pivot result, or None to clear. """ self._result = result self.clear() self._truncated = 0 if result is None: self.setRowCount(0) self.setColumnCount(0) return palette = active_palette() key_columns = self._header_offset_keys() n_rows, n_cols = result.shape self.setRowCount(n_rows) self.setColumnCount(len(key_columns) + n_cols) headers = list(key_columns) + [ (result.col_label(c) or "all") for c in range(n_cols)] self.setHorizontalHeaderLabels(headers) if result.n_cells > MAX_RENDERED_CELLS: self._truncated = result.n_cells note = table_item( f"{result.n_cells:,} cells is past the {MAX_RENDERED_CELLS:,} " f"this grid draws — narrow the axes, or export the CSV.") self.setRowCount(1) self.setColumnCount(1) self.setHorizontalHeaderLabels(["too large to draw"]) self.setItem(0, 0, note) return low_font = QFont() low_font.setItalic(True) for r in range(n_rows): for i, key in enumerate(key_columns): text = (result.row_levels[r][i] if result.row_keys else "all") item = table_item(str(text)) item.setToolTip(f"{key} = {text}") self.setItem(r, i, item) for c in range(n_cols): item = table_item(self._body_text(result, r, c)) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) item.setToolTip(self._tooltip(result, r, c)) smallest = self._smallest_n(result, r, c) if smallest is not None and smallest <= LOW_N: item.setFont(low_font) item.setForeground( QBrush(QColor(str(palette["warning"])))) self.setItem(r, len(key_columns) + c, item)
@staticmethod def _smallest_n(result: PivotResult, row: int, col: int) -> Optional[int]: """Return the smallest object count behind one cell. :param result: the computed pivot. :param row: cell row. :param col: cell column. :returns: the smallest ``n`` across the cell's value layers, or ``None`` when none of them has one. It is the smallest rather than the mean's own: a cell is only as trustworthy as its thinnest layer. """ counts = [result.n_at(value, row, col) for value in (result.spec.values or (COUNT_ONLY,))] found = [c for c in counts if c is not None] return min(found) if found else None @staticmethod def _body_text(result: PivotResult, row: int, col: int) -> str: """The statistics, then the n. Blank when the combination has no rows. ``n`` is printed whether or not it was ticked and always last, because it is the qualifier on everything above it. """ if result.is_empty(row, col): return "" multiple = len(result.spec.values) > 1 lines: List[str] = [] for value in (result.spec.values or (COUNT_ONLY,)): prefix = f"{value} " if multiple else "" for agg in (result.spec.aggs if result.spec.values else (N,)): if agg == N: continue lines.append( f"{prefix}{agg} {format_value(result.value_at(value, agg, row, col))}") count = result.n_at(value, row, col) lines.append(f"{prefix}n {0 if count is None else count:,}") return "\n".join(lines) @staticmethod def _tooltip(result: PivotResult, row: int, col: int) -> str: """Build the hover text for one cell. Every layer is listed with its value, and a cell at or below ``LOW_N`` says to read it as an anecdote. An empty cell says it is blank rather than zero on purpose -- that combination was not measured, or nothing survived the filter. :param result: the computed pivot. :param row: cell row. :param col: cell column. :returns: the tooltip text. """ where = " · ".join(p for p in (result.row_label(row), result.col_label(col)) if p) or "all" if result.is_empty(row, col): return (f"{where}\nNo objects here at all — this combination was " f"not measured, or nothing survived the filter. It is " f"blank rather than zero on purpose.") lines = [where, f"{int(result.sizes[row, col]):,} source row(s)"] for value, agg in result.layer_keys: name = f"{agg}({value})" if value else "n" lines.append( f"{name} = " f"{format_value(result.value_at(value, agg, row, col)) or '—'}") smallest = PivotTable._smallest_n(result, row, col) if smallest is not None and smallest <= LOW_N: lines.append(f"n ≤ {LOW_N}: read this cell as an anecdote.") return "\n".join(lines)
[docs] class PivotPanel(QWidget): """The well, the three axes, the aggregations and the grid. :param parent: parent widget. """ #: Emitted after every successful pivot. computed = Signal(object) #: Emitted with :meth:`long_frame` when the user asks for a chart. plot_requested = Signal(object) def __init__(self, parent=None): """Build the pivot shelf beside the table. The shelf ("Pivot fields") and the table ("Pivot table") are sections of one :class:`~spacr.qt.widgets.collapsible_splitter.CollapsibleSplitter` (``pivot_builder::panel``): each folds by its heading, and the edge between them drags, opening at the old 300 / 900 split. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setObjectName("PivotPanel") self._frame: Optional[pd.DataFrame] = None self._result: Optional[PivotResult] = None self._building = False outer = QHBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["sm"]) splitter = CollapsibleSplitter(Qt.Horizontal, self, persist_key="pivot_builder::panel") self._splitter = splitter outer.addWidget(splitter, 1) shelf = QWidget(self) shelf.setObjectName("PivotShelf") shelf_layout = QVBoxLayout(shelf) shelf_layout.setContentsMargins(SPACING["sm"], SPACING["sm"], SPACING["sm"], SPACING["sm"]) shelf_layout.setSpacing(SPACING["sm"]) self.well = ColumnWell(shelf) shelf_layout.addWidget(self.well, 1) self.wells: Dict[str, DropWell] = {} for axis in (AXIS_ROWS, AXIS_COLS, AXIS_VALUES): well = DropWell(axis, shelf) well.changed.connect(self._on_axis_changed) self.wells[axis] = well shelf_layout.addWidget(well) preset = QPushButton("Plate / row / column", shelf) preset.setToolTip( "Put the well hierarchy on the rows — what a plate summary is") preset.clicked.connect(self.use_well_hierarchy) shelf_layout.addWidget(preset) aggs = QGridLayout() aggs.setContentsMargins(0, 0, 0, 0) aggs.setSpacing(SPACING["xs"]) self._agg_boxes: Dict[str] = {} for i, agg in enumerate(AGGREGATIONS): box = Toggle(agg, shelf) box.setToolTip(AGGREGATION_LABELS[agg]) box.setChecked(agg in (N, MEAN, SD)) if agg == N: box.setEnabled(False) box.toggled.connect(self._on_axis_changed) self._agg_boxes[agg] = box aggs.addWidget(box, i // 2, i % 2) shelf_layout.addLayout(aggs) self._quantile = QDoubleSpinBox(shelf) self._quantile.setRange(0.0, 1.0) self._quantile.setSingleStep(0.05) self._quantile.setDecimals(3) self._quantile.setValue(0.75) self._quantile.setToolTip("Which quantile, when quantile is ticked") row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.addWidget(QLabel("Quantile at", shelf)) row.addWidget(self._quantile, 1) self._quantile.valueChanged.connect(self._on_axis_changed) shelf_layout.addLayout(row) buttons = QHBoxLayout() buttons.setContentsMargins(0, 0, 0, 0) buttons.setSpacing(SPACING["xs"]) self._export = QPushButton("Export CSV…", shelf) self._export.setToolTip("Write the table as it is shown") self._export.clicked.connect(self.export_csv) buttons.addWidget(self._export) self._plot = QPushButton("Plot this table", shelf) self._plot.setToolTip( "Hand the summary to the Graph Builder — one row per cell, one " "column per statistic") self._plot.clicked.connect(self._on_plot) buttons.addWidget(self._plot) shelf_layout.addLayout(buttons) splitter.add_section(shelf, "Pivot fields", persist_key="pivot_builder/Pivot fields", stretch=0, extent=300) right = QWidget(self) right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(SPACING["xs"]) self.table = PivotTable(right) mark_surface(self.table) right_layout.addWidget(self.table, 1) self.notice = QLabel("", right) self.notice.setObjectName("PivotNotice") self.notice.setWordWrap(True) right_layout.addWidget(self.notice) splitter.add_section(right, "Pivot table", persist_key="pivot_builder/Pivot table", stretch=1, extent=900) self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(DEBOUNCE_MS) self._debounce.timeout.connect(self.recompute) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self)
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Point the panel at a table. Axis columns the new table does not have are dropped rather than carried over — a pivot half-resolved against the wrong frame would group by fewer keys than the wells claim. :param frame: the table to pivot, or None; axis columns it lacks are removed from the wells. """ self._frame = frame self.well.set_frame(frame) if frame is not None: for well in self.wells.values(): well.set_columns([c for c in well.columns() if c in frame.columns]) self.recompute()
@property
[docs] def result(self) -> Optional[PivotResult]: """The pivot this panel is showing, if any. :returns: the result, or None before one has been computed. """ return self._result
[docs] def spec(self) -> PivotSpec: """The spec the wells and tick boxes currently describe.""" aggs = [agg for agg in AGGREGATIONS if self._agg_boxes[agg].isChecked()] return PivotSpec( rows=self.wells[AXIS_ROWS].columns(), cols=self.wells[AXIS_COLS].columns(), values=self.wells[AXIS_VALUES].columns(), aggs=tuple(aggs), quantile=self._quantile.value())
[docs] def set_spec(self, spec: PivotSpec) -> None: """Push a whole spec in — restoring a saved table, or a preset. :param spec: the pivot spec whose rows, columns, values, aggregations and quantile are loaded into the controls. """ self._building = True try: self.wells[AXIS_ROWS].set_columns(spec.rows) self.wells[AXIS_COLS].set_columns(spec.cols) self.wells[AXIS_VALUES].set_columns(spec.values) for agg, box in self._agg_boxes.items(): box.setChecked(agg in spec.aggs) self._quantile.setValue(spec.quantile) finally: self._building = False self.recompute()
[docs] def use_well_hierarchy(self) -> None: """The preset: plate / row / column down the rows.""" frame = self._frame if frame is None: return self.wells[AXIS_ROWS].set_columns( [c for c in WELL_HIERARCHY if c in frame.columns])
[docs] def long_frame(self) -> pd.DataFrame: """The tidy summary — what :attr:`plot_requested` carries.""" if self._result is None: return pd.DataFrame() return self._result.to_long()
[docs] def recompute(self) -> Optional[PivotResult]: """Rebuild the table. Refusals become a message, never a traceback.""" self._debounce.stop() if self._frame is None or self._frame.empty: self._result = None self.table.set_result(None) self.notice.setText("Load a table, then drop a column onto Rows.") return None spec = self.spec() if spec.is_empty: self._result = None self.table.set_result(None) self.notice.setText( "Drop a column onto Rows or Columns to group by it, and a " "measurement onto Values to summarise it. With no Values you " "get a table of counts.") return None try: result = pivot(self._frame, spec) except PivotError as exc: self._result = None self.table.set_result(None) self.notice.setText(str(exc)) return None except Exception as exc: LOG.info("the pivot failed", exc_info=True) self._result = None self.table.set_result(None) self.notice.setText(f"could not build that table: {exc}") return None self._result = result self.table.set_result(result) parts = [result.summary()] if self.table.truncated_cells: parts.append("shown as a message rather than a grid — export it") self.notice.setText(" · ".join(parts)) self.computed.emit(result) return result
def _on_axis_changed(self, *_args) -> None: """Queue a recompute after an axis or aggregation edit. Debounced, so dragging three columns into a well costs one recompute rather than three, and suppressed entirely while the panel is being built from a spec. :param _args: whatever the emitting signal passes; ignored, since the whole spec is re-read from the widgets either way. """ if self._building: return self._debounce.start() def _on_plot(self) -> None: """Hand the summary to the Graph Builder, one row per cell. An empty table says so rather than emitting nothing, which would read as a broken button. """ frame = self.long_frame() if frame.empty: self.notice.setText( "Nothing to plot yet — build a table with at least one " "non-empty cell.") return self.plot_requested.emit(frame)
[docs] def export_csv(self, path: Optional[str] = None) -> Optional[str]: """Write the table as shown. Returns the path written, or ``None``.""" if self._result is None: self.notice.setText("Nothing to export — build a table first.") return None if not path: path, _ = QFileDialog.getSaveFileName( self, "Export the table", "tabulate.csv", "CSV (*.csv)") if not path: return None try: self._result.to_csv(path) except OSError as exc: LOG.info("could not export the pivot", exc_info=True) self.notice.setText(f"could not write that file: {exc}") return None self.notice.setText(f"wrote {path}") return path
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Stop background work before going away. :param event: the Qt close event. """ self._debounce.stop() super().closeEvent(event)
def _pivot_qss(palette, opacity) -> str: """Build the pivot shelf's stylesheet. :param palette: the active palette. :param opacity: the page opacity, blended into the shelf's surface so it sits over the backdrop like every other panel. :returns: the QSS. """ from ..theme import block_surface surface_alt = block_surface("surface_alt", palette["theme"], opacity) return f""" QWidget#PivotShelf {{ background: {surface_alt}; border-radius: {RADIUS["md"]}px; }} QLabel#PivotWellName {{ color: {palette["fg_muted"]}; font-weight: 600; }} QListWidget#PivotWellList {{ background: transparent; border: 1px dashed {palette["border"]}; border-radius: {RADIUS["sm"]}px; }} QLabel#PivotNotice {{ color: {palette["fg_muted"]}; font-size: {font_px(11)}px; }} QTableWidget#PivotTable {{ border: 1px solid {palette["border_soft"]}; border-radius: {RADIUS["sm"]}px; gridline-color: {palette["border_soft"]}; }} """ register_widget_qss("Pivot", _pivot_qss, replace=True)