"""Consistent three-state sorting for Qt tables and trees.
The sorting cycle is:
* the first click on a column sorts it **descending**;
* the second click sorts it **ascending**;
* the third click clears the sort and restores the original row order;
* the fourth click starts the cycle again.
Formatted numeric values, including scientific notation, are sorted
numerically. Missing values such as blank cells, ``NaN``, ``pandas.NA``, and
``NaT`` sort after populated values in both directions.
Build cells with :func:`table_item` or :func:`tree_item` and pass the view to
:func:`install_sorting`::
self.table = QTableWidget(0, 3)
install_sorting(self.table)
...
self.table.setItem(row, 0, table_item(coefficient))
:func:`install_sorting` temporarily suspends sorting while a view is
repopulated, preventing Qt from moving partially populated rows, and restores
the selected ordering after the update completes.
"""
from __future__ import annotations
import re
from typing import Optional
import shiboken6
from PySide6.QtCore import (
QModelIndex,
QObject,
QSortFilterProxyModel,
Qt,
QTimer,
Slot,
)
from PySide6.QtWidgets import (
QTableView,
QTableWidget,
QTableWidgetItem,
QTreeView,
QTreeWidget,
QTreeWidgetItem,
)
__all__ = [
"SORT_KEY_ROLE",
"SortableTableItem",
"SortableTreeItem",
"install_sorting",
"is_missing",
"numeric_value",
"sort_key_of",
"sorts_as_missing",
"SortableProxyModel",
"table_item",
"tree_item",
]
#: A cell may carry its own sort key here when the text is not the thing to
#: sort on -- a duration shown as "2 h 5 m", a date shown as "yesterday".
SORT_KEY_ROLE = Qt.UserRole + 90
_SERIAL = 0
_RESTORING = False
_STATE_ATTR = "_spacr_sort_state"
#: A comma between digits is a thousands separator and nothing else.
_THOUSANDS = re.compile(r"(?<=\d),(?=\d\d\d(\D|$))")
[docs]
def is_missing(value) -> bool:
"""Return whether ``value`` is empty or a recognized missing sentinel.
This includes ``None``, blank strings, ``NaN``, pandas ``NA``, and
``NaT``. Array-like values are not treated as individual missing cells.
:param value: a cell value of any type.
"""
if value is None:
return True
if isinstance(value, str):
return not value.strip()
try:
import pandas as _pd
return bool(_pd.isna(value))
except (ImportError, TypeError, ValueError):
return False
[docs]
def numeric_value(text) -> Optional[float]:
"""Return the numeric value represented by a cell, if one is present.
Accepted formats include ordinary numbers, scientific notation, comma
thousands separators, percentages, and a unit separated from the value by
a space, such as ``"3.2 s"``. Alphanumeric identifiers such as ``"TP53"``
return ``None``. Use :data:`SORT_KEY_ROLE` to provide an explicit numeric
key for other display formats.
:param text: cell text or number; ``None``, booleans, blanks and NaN give
``None``.
"""
if text is None:
return None
if isinstance(text, bool):
return None
if isinstance(text, (int, float)):
number = float(text)
return None if number != number else number
stripped = str(text).strip()
if not stripped:
return None
for candidate in _numeric_candidates(stripped):
try:
number = float(candidate)
except (TypeError, ValueError):
continue
return None if number != number else number
return None
def _numeric_candidates(text: str):
"""The readings of ``text`` that could be a number, best first."""
yield text
without_separators = _THOUSANDS.sub("", text)
if without_separators != text:
yield without_separators
text = without_separators
if text.endswith("%"):
yield text[:-1].strip()
return
head, sep, tail = text.rpartition(" ")
if sep and head and tail and not tail[0].isdigit():
yield head.strip().rstrip("%")
#: How a screen writes "there is nothing here". A cell reading any of these
#: sorts with the holes rather than between the numbers, and keeps the text
#: it was given: an em dash is what the column picker draws for a column with
#: no declaration, and it should still say so while sorting to the bottom.
_PLACEHOLDERS = frozenset(
{"nan", "na", "n/a", "none", "null", "<na>", "nat", "-", "\u2014",
"\u2013", "?"})
[docs]
def sorts_as_missing(value) -> bool:
"""Return whether ``value`` should sort as missing table data.
:param value: a cell value; missing per :func:`is_missing`, or a
placeholder string such as ``"n/a"``, ``"-"`` or ``"?"``
(case-insensitive), sorts as missing.
"""
if is_missing(value):
return True
return isinstance(value, str) and value.strip().casefold() in _PLACEHOLDERS
[docs]
def sort_key_of(value):
"""Return the ``(is_missing, numeric_value)`` comparison key.
:param value: a cell value; see :func:`sorts_as_missing` and
:func:`numeric_value`.
"""
if sorts_as_missing(value):
return True, None
return False, numeric_value(value)
class _SortableMixin:
"""The comparison. See the module docstring for the contract."""
def _init_sort_key(self, value, key=None) -> None:
"""Remember the value to sort by, and the row's arrival order.
The serial is what "natural order" restores to, so it has to be taken
when the row is BUILT rather than derived later from anything on screen.
"""
global _SERIAL
_SERIAL += 1
self._sort_serial = _SERIAL
if key is not None:
self._sort_missing = False
self._sort_number = float(key)
else:
self._sort_missing, self._sort_number = sort_key_of(value)
def _resolve_key(self):
"""The key this cell sorts on. Settled once, refreshed on an edit."""
return self._sort_missing, self._sort_number
def _sort_text(self) -> str:
"""The text this cell sorts as. Subclasses that show text override it."""
return ""
def _descending(self) -> bool:
"""Whether the view is currently ordering downward.
Read because MISSING VALUES GO LAST IN BOTH DIRECTIONS, which means a
missing cell has to compare as the largest when Qt orders by "<" and the
smallest when it orders by ">".
"""
return False
def __lt__(self, other):
"""Order two cells: numbers before words, missing values last.
Returns ``NotImplemented`` for a peer with no text rather than guessing.
``sorted`` believes every answer it is given, so a cell that guessed
would produce an order nobody could account for; declining lets Python
raise instead.
While the natural order is being restored this compares by ARRIVAL
SERIAL, which is the only record of the order the rows came in.
"""
if _RESTORING:
return getattr(self, "_sort_serial", 0) < getattr(
other, "_sort_serial", 0)
mine_missing, mine = self._resolve_key()
if isinstance(other, _SortableMixin):
theirs_missing, theirs = other._resolve_key()
else:
peer = getattr(other, "text", None)
if not callable(peer):
return NotImplemented
theirs_missing, theirs = sort_key_of(peer())
if mine_missing or theirs_missing:
if mine_missing and theirs_missing:
return False
return mine_missing if self._descending() else theirs_missing
if mine is not None and theirs is not None:
return mine < theirs
if mine is None and theirs is None:
return self._sort_text().casefold() < str(other.text()).casefold()
return mine is not None
[docs]
class SortableTableItem(_SortableMixin, QTableWidgetItem):
"""Table item with semantic numeric and missing-value sorting.
:param value: Cell value. Missing values are rendered as empty cells.
:param key: Optional explicit numeric sort key for display text that
cannot be parsed directly.
"""
def __init__(self, value="", key=None):
"""Create a table cell that sorts on a value rather than on its text.
:param value: what the cell shows; a missing value renders blank.
:param key: an explicit sort key, for a cell whose text does not order
the way the value does -- a formatted duration, say. ``None``
derives one from ``value``.
"""
QTableWidgetItem.__init__(
self, "" if is_missing(value) else str(value))
self._init_sort_key(value, key=key)
if key is not None:
self.setData(SORT_KEY_ROLE, float(key))
[docs]
def setData(self, role, value): # noqa: N802 - Qt spelling
"""Update the sort key when displayed or edited text changes.
Non-text roles do not modify the key, and editing does not change the
item's position in the table's unsorted order.
:param role: the Qt item data role; only ``DisplayRole`` and
``EditRole`` refresh the sort key, which then comes from
:data:`SORT_KEY_ROLE` when set and from the text otherwise.
:param value: the data stored for ``role``.
"""
QTableWidgetItem.setData(self, role, value)
if role in (Qt.DisplayRole, Qt.EditRole) and hasattr(
self, "_sort_serial"):
override = self.data(SORT_KEY_ROLE)
serial = self._sort_serial
if override is None:
self._init_sort_key(self.text())
else:
self._init_sort_key(None, key=override)
self._sort_serial = serial
def _sort_text(self) -> str:
"""Return the text this cell falls back to when it has no numeric key.
:returns: the cell's text, never ``None``.
"""
return self.text() or ""
def _descending(self) -> bool:
"""Report whether the table is currently sorted descending.
Used to keep missing values at the bottom in both directions rather
than letting them flip to the top on a reverse.
:returns: ``True`` when the header's indicator says descending;
``False`` for a cell not yet in a table.
"""
view = self.tableWidget()
if view is None:
return False
return view.horizontalHeader().sortIndicatorOrder() == Qt.DescendingOrder
[docs]
class SortableTreeItem(_SortableMixin, QTreeWidgetItem):
"""Tree item with semantic sorting for the selected column.
See :class:`SortableTableItem`. A tree item contains multiple columns, so
its comparison key is resolved from the column currently being sorted.
"""
def __init__(self, *args, **kwargs):
"""Create a tree row that sorts on its values and remembers its insertion order.
The serial is what restores the original order when sorting is turned
off -- a tree has no unsorted model to fall back to.
:param args: passed through to ``QTreeWidgetItem``.
:param kwargs: passed through to ``QTreeWidgetItem``.
"""
QTreeWidgetItem.__init__(self, *args, **kwargs)
global _SERIAL
_SERIAL += 1
self._sort_serial = _SERIAL
def _sort_column(self) -> int:
"""Return the column the tree is sorting on.
:returns: the sort column, or ``0`` for a row not yet in a tree or a
tree with no indicator set.
"""
view = self.treeWidget()
if view is None:
return 0
column = view.sortColumn()
return 0 if column < 0 else column
def _resolve_key(self):
"""The key for the column being sorted on, read once per text.
A tree row carries every column, so the key cannot be settled when
the row is built. Cached against the text it was read from, because
a sort asks for it O(n log n) times and the text does not move.
"""
column = self._sort_column()
text = self.text(column)
cached = getattr(self, "_sort_cache", None)
if cached is not None and cached[0] == column and cached[1] == text:
return cached[2], cached[3]
override = self.data(column, SORT_KEY_ROLE)
if override is not None:
missing, number = False, float(override)
else:
missing, number = sort_key_of(text)
self._sort_cache = (column, text, missing, number)
return missing, number
def _sort_text(self) -> str:
"""Return the text this row falls back to when it has no numeric key.
:returns: the sort column's text, never ``None``.
"""
return self.text(self._sort_column()) or ""
def _descending(self) -> bool:
"""Report whether the tree is currently sorted descending.
:returns: ``True`` when the header's indicator says descending;
``False`` for a row not yet in a tree.
"""
view = self.treeWidget()
if view is None:
return False
return view.header().sortIndicatorOrder() == Qt.DescendingOrder
[docs]
def __lt__(self, other):
"""Order two rows by value, keeping missing values last in both directions.
While the original order is being restored this compares insertion
serials instead, which is what lets "no sort" mean the order the rows
arrived in.
:param other: the row to compare against; a plain ``QTreeWidgetItem`` is
compared on its text, and anything without one is declined so Python
can try the reflected comparison.
:returns: ``True`` when this row sorts first.
"""
if _RESTORING:
return getattr(self, "_sort_serial", 0) < getattr(
other, "_sort_serial", 0)
column = self._sort_column()
mine_missing, mine = self._resolve_key()
if isinstance(other, SortableTreeItem):
theirs_missing, theirs = other._resolve_key()
else:
peer = getattr(other, "text", None)
if not callable(peer):
return NotImplemented
theirs_missing, theirs = sort_key_of(peer(column))
if mine_missing or theirs_missing:
if mine_missing and theirs_missing:
return False
return mine_missing if self._descending() else theirs_missing
if mine is not None and theirs is not None:
return mine < theirs
if mine is None and theirs is None:
return (self.text(column) or "").casefold() < (
other.text(column) or "").casefold()
return mine is not None
[docs]
def table_item(value="", key=None) -> SortableTableItem:
"""Return a :class:`SortableTableItem` for ``value`` and optional key."""
return SortableTableItem(value, key=key)
[docs]
def tree_item(*args, **kwargs) -> SortableTreeItem:
"""Return a :class:`SortableTreeItem` using ``QTreeWidgetItem`` arguments."""
return SortableTreeItem(*args, **kwargs)
class _SortState(QObject):
"""Per-view bookkeeping: the fill guard and the natural-order restore.
:param view: the tree this state belongs to, and its QObject parent --
so the state cannot outlive the view whose sort order it restores.
"""
def __init__(self, view):
"""Take the view as parent and start with nothing suspended."""
super().__init__(view)
self._view = view
self._suspended = None
self._resuming = False
self._stamping = False
self._resume_timer = QTimer(self)
self._resume_timer.setSingleShot(True)
self._resume_timer.timeout.connect(self.resume_after_fill)
@Slot(QModelIndex, int, int)
def suspend_for_fill(self, *args) -> None:
"""Drop the sort while rows are being written, put it back after.
Qt re-sorts a sorted table on every ``setItem`` into the sorted
column: the row moves, and the loop that is filling it writes the
rest of the row into whatever landed at that index. Clearing the sort
indicator stops that -- the model only re-sorts on a write to the
section the indicator names.
"""
view = getattr(self, "_view", None)
header = _header(view)
if header is None or getattr(self, "_resuming", True):
return
if getattr(self, "_suspended", None) is None:
self._suspended = (header.sortIndicatorSection(),
header.sortIndicatorOrder())
if self._suspended[0] >= 0:
header.setSortIndicator(-1, Qt.AscendingOrder)
self._resume_timer.start(0)
@Slot()
def resume_after_fill(self) -> None:
"""Re-apply the sort the user had chosen, once the fill has settled."""
suspended = getattr(self, "_suspended", None)
view = getattr(self, "_view", None)
if suspended is None or not _alive(view):
return
section, order = suspended
self._suspended = None
header = _header(view)
if header is None or section < 0:
return
if section >= _column_count(view):
return
self._resuming = True
try:
header.setSortIndicator(section, order)
finally:
self._resuming = False
@Slot()
@Slot(QModelIndex, int, int)
@Slot(Qt.Orientation, int, int)
def stamp_initial_order(self, *args) -> None:
"""Tell the header that a fresh column starts descending.
``QHeaderView`` asks the model for ``Qt.InitialSortOrderRole`` when
the click lands on a column that is not the sorted one, and uses
ascending when nothing answers. Answering is the whole of
descending-first: Qt does the rest, including the flip on the second
click.
"""
view = getattr(self, "_view", None)
if getattr(self, "_stamping", True) or not _alive(view):
return
self._stamping = True
try:
_stamp_initial_order(view)
finally:
self._stamping = False
@Slot(int, Qt.SortOrder)
def on_indicator_changed(self, section: int, order) -> None:
"""A cleared indicator means "put it back the way it was"."""
view = getattr(self, "_view", None)
if (section >= 0 or getattr(self, "_resuming", True)
or not _alive(view)):
return
restore_natural_order(view)
def _alive(obj) -> bool:
"""True while ``obj``'s C++ half still exists.
A table takes its model down with it, and the model signals its own
teardown -- so the handlers wired here are called once more with the
view already half destroyed. Touching it then raises out of the event
loop, which pytest-qt reports as an error in whatever test happened to
be starting.
"""
return obj is not None and shiboken6.isValid(obj)
def _header(view):
"""Find a view's header, whichever kind of view it is.
:param view: the table or tree.
:returns: the header, or ``None`` for a view that has none or whose C++
half is gone.
"""
if not _alive(view):
return None
if isinstance(view, (QTreeView, QTreeWidget)):
return view.header()
if hasattr(view, "horizontalHeader"):
return view.horizontalHeader()
return None
def _column_count(view) -> int:
"""How many columns a view's model has.
:param view: the table or tree.
:returns: the count, and ``0`` for a view with no model or one whose
C++ half is gone.
"""
if not _alive(view):
return 0
model = view.model()
return 0 if model is None else model.columnCount()
def _stamp_initial_order(view) -> None:
"""Put ``Qt.InitialSortOrderRole`` on every column of ``view``."""
descending = Qt.DescendingOrder.value
if isinstance(view, QTableWidget):
for column in range(view.columnCount()):
item = view.horizontalHeaderItem(column)
if item is None:
item = QTableWidgetItem(str(column + 1))
view.setHorizontalHeaderItem(column, item)
item.setData(Qt.InitialSortOrderRole, descending)
elif isinstance(view, QTreeWidget):
item = view.headerItem()
if item is not None:
for column in range(view.columnCount()):
item.setData(column, Qt.InitialSortOrderRole, descending)
def _settle_sorting(view) -> None:
"""Re-apply a sort suspended for a fill now, instead of on the next turn.
For code that fills a table and then reads or selects rows in the same
call: the deferred resume would otherwise run after it.
:param view: a view :func:`install_sorting` was called on.
"""
state = getattr(view, _STATE_ATTR, None)
if state is None or not _alive(state):
return
state._resume_timer.stop()
state.resume_after_fill()
[docs]
def restore_natural_order(view) -> None:
"""Restore rows to the order in which the view was populated.
This completes the third state of the sorting cycle after Qt clears the
header's sort indicator.
"""
global _RESTORING
if not _alive(view):
return
model = view.model()
if model is None:
return
if not isinstance(view, (QTableWidget, QTreeWidget)):
model.sort(-1, Qt.AscendingOrder)
return
_RESTORING = True
try:
model.sort(0, Qt.AscendingOrder)
finally:
_RESTORING = False
[docs]
def install_sorting(view):
"""Install three-state semantic sorting on ``view`` and return it.
Accepts a ``QTableWidget``, ``QTreeWidget``, or ``QTableView``. A
``QTableView`` is wrapped in :class:`SortableProxyModel`; call this
function immediately after ``setModel`` and obtain the view's selection
model afterwards.
:param view: the view to make sortable; a view with no header is
returned untouched, and one already installed only has its initial
order re-stamped.
"""
header = _header(view)
if header is None:
return view
if getattr(view, _STATE_ATTR, None) is not None:
getattr(view, _STATE_ATTR).stamp_initial_order()
return view
if isinstance(view, QTableView) and not isinstance(view, QTableWidget):
_wrap_in_proxy(view)
view.setSortingEnabled(True)
header.setSectionsClickable(True)
header.setSortIndicatorShown(True)
if hasattr(header, "setSortIndicatorClearable"):
header.setSortIndicatorClearable(True)
header.setSortIndicator(-1, Qt.AscendingOrder)
state = _SortState(view)
setattr(view, _STATE_ATTR, state)
state.stamp_initial_order()
model = view.model()
if model is not None:
model.columnsInserted.connect(state.stamp_initial_order)
model.modelReset.connect(state.stamp_initial_order)
model.headerDataChanged.connect(state.stamp_initial_order)
if isinstance(view, (QTableWidget, QTreeWidget)):
model.rowsInserted.connect(state.suspend_for_fill)
model.rowsRemoved.connect(state.suspend_for_fill)
header.sortIndicatorChanged.connect(state.on_indicator_changed)
return view
def _wrap_in_proxy(view) -> None:
"""Put a sorting proxy over a view's model, unless one is already there.
Wrapping twice would sort a sorted view, so an existing proxy is left
alone -- which is what makes installing sorting idempotent.
:param view: the view to wrap.
"""
model = view.model()
if model is None or isinstance(model, QSortFilterProxyModel):
return
proxy = SortableProxyModel(view)
proxy.setSourceModel(model)
view.setModel(proxy)
[docs]
class SortableProxyModel(QSortFilterProxyModel):
"""Proxy model providing semantic sorting for model-backed views.
Numeric values sort numerically, missing values follow populated values
in either direction, and the initial header order is descending. This is
the model-backed equivalent of :class:`SortableTableItem`.
"""
[docs]
def lessThan(self, left, right):
"""Order two cells, comparing what they MEAN rather than how they read.
A column of numbers rendered as text sorts 10 before 9 under a string
comparison, which is the bug this exists to prevent.
:param left: the left cell's index.
:param right: the right cell's index.
:returns: True when left sorts first.
"""
source = self.sourceModel()
left_value = source.data(left, Qt.DisplayRole)
right_value = source.data(right, Qt.DisplayRole)
mine_missing, mine = sort_key_of(left_value)
theirs_missing, theirs = sort_key_of(right_value)
if mine_missing or theirs_missing:
if mine_missing and theirs_missing:
return False
descending = self.sortOrder() == Qt.DescendingOrder
return mine_missing if descending else theirs_missing
if mine is not None and theirs is not None:
return mine < theirs
if mine is None and theirs is None:
return str(left_value or "").casefold() < str(
right_value or "").casefold()
return mine is not None