Source code for spacr.qt.widgets.column_aligned_row

"""A row of buttons laid out over the columns of the table underneath it.

Regression's Input Tables category starts with a Download row -- Score,
Count, Measurements (.db), Image crops -- and the first three of those fill
one column each of the paired-data table directly below. Each button is
centred on its column, and the download table's column widths are locked to
the widths of their counterparts in the paired-data table below.

ONE COLUMN MODEL, READ IN ONE DIRECTION. The build the request describes --
a second table above the first, both told to keep the same widths -- is two
column models that have to be kept in step, and this repository has already
paid for that once (see the `_ClusterSettingsDialog` docstring on what two
editors of one setting cost). This layout owns no widths at all. It asks the
paired table's ``QHeaderView`` where each section starts and how wide it is,
every time it lays out, and it writes nothing back: the header never learns
that a strip is following it, so there is nothing to drift.

WHY A QLayout RATHER THAN SPACERS IN A QHBoxLayout. Stretches and fixed
spacers can be tuned to line up at one column width and are wrong at every
other one, so each drag of a column edge would mean rebuilding them. A
layout's ``setGeometry`` runs on every relayout anyway, so reading the header
there is both shorter and correct at any width; all that is left to arrange
is that a column resize causes a relayout, which is what the ``sectionResized``
connection below is for.

WHY A BUTTON IS CLAMPED TO ITS COLUMN. Measured on a 1400x900 screen with the
table's default 100 px columns: "Measurements (.db)" wants 127 px, so left at
its natural width and centred on its column it would reach 3.5 px into the
Count button beside it. Two download buttons drawn overlapping is exactly the
confusion the alignment exists to remove, so the button is given its column
and no more -- which is also what "lock the width ... to the width of the
paired data column counterparts below" asks for. The cost is that a label
longer than its column is clipped by Qt at the default width; the full
sentence is in the button's tooltip, and widening the column shows it.
"""

from __future__ import annotations

import logging
from typing import Iterable, Optional, Tuple

import shiboken6
from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QWidget, QWidgetItem

LOG = logging.getLogger(__name__)

__all__ = ["ColumnAlignedRow", "align_row_to_columns", "TRAILING_SPACING"]

#: Gap left between two buttons that have no column to sit over, in px.
#:
#: Small and fixed rather than taken from ``SPACING``: these are the leftovers
#: of the row -- a button whose download fills a SETTING rather than a table
#: column -- and they are placed as a plain run after the last column, not as
#: part of the aligned grid.
TRAILING_SPACING = 6


def _alive(obj) -> bool:
    """True when ``obj``'s C++ half is still there.

    The strip outlives nothing, but the header can go first: a closed screen
    deletes the table while the layout is still connected to its signals, and
    ``sectionSize`` on a deleted header is a hard crash rather than an
    exception.
    """
    try:
        return obj is not None and shiboken6.isValid(obj)
    except Exception:                                        # noqa: BLE001
        return obj is not None


[docs] class ColumnAlignedRow(QLayout): """Lay a row of widgets out over the sections of a ``QHeaderView``. Each managed widget carries either a column index -- it is centred in that column's span, clamped to its width -- or ``None``, in which case it is placed in a plain left-to-right run after the last aligned column. The header is read, never written. Nothing here calls ``resizeSection`` or a resize mode, so the user's own column widths remain the only source of truth for what a column is. :param header: the ``QHeaderView`` whose sections the row aligns to. READ, never written -- nothing here calls `resizeSection` or a resize mode, so the user's own column widths stay the only source of the geometry. :param parent: parent widget. """ def __init__(self, header, parent: Optional[QWidget] = None, on_invalidate=None) -> None: """Lay a row out against a header's column widths. Passing ``parent`` installs this as that widget's layout, which is why the caller has to have removed the previous one first. :param header: the header view whose sections set the column widths; the row follows its resizes, reorders and re-layouts. :param parent: the widget to become the layout of, or ``None``. :param on_invalidate: called with no arguments whenever a managed widget's size hint changes. THE ROW CANNOT ACT ON THAT ITSELF: a button that grew needs its COLUMN widened, the header is read and never written here, and a layout that wrote back to the geometry it reads would re-enter itself on the resize it caused. So it tells whoever owns the header instead. """ super().__init__(parent) self._items: list[Tuple[QWidgetItem, Optional[int]]] = [] self._header = header self._on_invalidate = on_invalidate self.setContentsMargins(0, 0, 0, 0) if _alive(header): header.sectionResized.connect(self._restate) header.sectionMoved.connect(self._restate) header.geometriesChanged.connect(self._restate) header.viewport().installEventFilter(self)
[docs] def addItem(self, item) -> None: # noqa: N802 """Qt's own door, used by ``addWidget``: no column, so it trails. :param item: the ``QLayoutItem`` to manage; it is placed in the trailing run after the column-aligned widgets. """ self._items.append((item, None))
[docs] def add_over_column(self, widget: QWidget, column: Optional[int]) -> None: """Manage ``widget``, centred over ``column`` (``None`` to trail). Separate from ``addWidget`` because Qt's signature has no room for the column, and a column set afterwards through a second call would be a second place the pairing is written down. :param widget: the widget to place; it is reparented to the layout's parent widget, and ``None`` or a deleted widget is ignored. :param column: the table's logical column index to centre the widget over, or ``None`` to put it in the trailing run. An index past the header's end or a hidden column also trails. """ if widget is None or not _alive(widget): return widget.setParent(self.parentWidget()) self._items.append((QWidgetItem(widget), column)) self.invalidate()
[docs] def count(self) -> int: """How many items the row holds. Part of the QLayout contract: Qt walks a layout through count, itemAt and takeAt, so all three must agree about the same list. :returns: the item count. """ return len(self._items)
[docs] def itemAt(self, index): # noqa: N802 """The item at ``index``, or None when out of range. NONE RATHER THAN RAISING: Qt probes past the end to discover where a layout stops, so an exception here is a crash during ordinary layout. :param index: the position. :returns: the item, or None. """ if 0 <= index < len(self._items): return self._items[index][0] return None
[docs] def takeAt(self, index): # noqa: N802 """Remove and return the item at ``index``, or None. :param index: the position. :returns: the item, or None when out of range. """ if 0 <= index < len(self._items): return self._items.pop(index)[0] return None
[docs] def sizeHint(self) -> QSize: # noqa: N802 """Wide enough for the buttons side by side, tall enough for one. Not the header's length: the row must remain usable in a form that is narrower than the table's columns add up to, and the columns are the table's business rather than the strip's. """ width = 0 height = 0 for item, _column in self._items: hint = item.sizeHint() width += hint.width() + TRAILING_SPACING height = max(height, hint.height()) return QSize(width, height)
[docs] def minimumSize(self) -> QSize: # noqa: N802 """Zero wide. A strip narrower than its buttons still shows them in the right place, because the places come from the table, not from the strip's own width.""" height = 0 for item, _column in self._items: height = max(height, item.minimumSize().height()) return QSize(0, height)
[docs] def expandingDirections(self): # noqa: N802 """Horizontally only. QLayout's default claims both, which makes the form give this one-button-high row every spare pixel of height.""" return Qt.Orientations(Qt.Horizontal)
[docs] def invalidate(self) -> None: """Tell the header's owner that a managed widget changed size. WHY THIS EXISTS AND WHAT IT COST. A button's caption is set in English when the row is built and REPLACED by the language pass afterwards, and "Count" is 100 px where "Contagem" is 151. The column was sized once, before the translation, and the row then clamped the wider caption into the narrower column for the life of the screen -- which is this class behaving exactly as documented and still showing a cut-off word. Qt already reports the event: `setText` calls `updateGeometry`, which invalidates the parent layout. The row passes it on rather than acting, because widening a column is the header owner's job and doing it from here would re-enter this layout. Never raises: a failed refit is a column that stays where it was, and a layout that raised here would take the whole strip with it. """ super().invalidate() callback = getattr(self, "_on_invalidate", None) if callback is None or getattr(self, "_in_callback", False): return self._in_callback = True try: callback() except Exception: # noqa: BLE001 LOG.debug("the column refit callback failed", exc_info=True) finally: self._in_callback = False
[docs] def setGeometry(self, rect: QRect) -> None: # noqa: N802 """Put each widget over its column, and the rest after them. :param rect: the rectangle the layout is given, in the parent widget's coordinates; widgets are vertically centred in it. """ super().setGeometry(rect) owner = self.parentWidget() if owner is None: return trailing = [] right = rect.x() for item, column in self._items: hint = item.sizeHint() height = min(hint.height(), rect.height()) top = rect.y() + (rect.height() - height) // 2 span = self._column_span(owner, column) if span is None: trailing.append((item, hint, height, top)) continue left, width = span drawn = min(hint.width(), width) item.setGeometry(QRect(left + (width - drawn) // 2, top, drawn, height)) right = max(right, left + width) for item, hint, height, top in trailing: right += TRAILING_SPACING item.setGeometry(QRect(right, top, hint.width(), height)) right += hint.width()
def _column_span(self, owner: QWidget, column: Optional[int]): """``(left, width)`` of ``column`` in ``owner``'s coordinates. ``None`` when there is no column to follow -- the widget was given none, the header has gone, the index is past its end, or the column is hidden (width 0). A hidden column must not orphan the button above it, so the button joins the trailing run instead of being drawn on top of its neighbour. Mapped THROUGH GLOBAL COORDINATES rather than through a common ancestor: the strip and the table are two field widgets of the same QFormLayout today, but nothing here should depend on that, and ``mapFrom`` requires the ancestor relationship that global mapping does not. """ header = self._header if column is None or not _alive(header): return None if column < 0 or column >= header.count(): return None width = header.sectionSize(column) if width <= 0: return None viewport = header.viewport() if not _alive(viewport): return None edge = viewport.mapToGlobal( QPoint(header.sectionViewportPosition(column), 0)).x() return owner.mapFromGlobal(QPoint(edge, 0)).x(), width def _restate(self, *_args) -> None: """Lay out again because the columns moved.""" if self.parentWidget() is None: return self.invalidate() self.activate()
[docs] def eventFilter(self, watched, event): # noqa: N802 """Follow the table when it moves or changes size. :param watched: the object the event is for (the table header's viewport); not read. :param event: the event; a resize, move or show re-lays the row out. It is never consumed. """ if event.type() in (QEvent.Resize, QEvent.Move, QEvent.Show): self._restate() return False
[docs] def align_row_to_columns( strip: QWidget, header, columns: Iterable[Tuple[QWidget, Optional[int]]], on_invalidate=None, ) -> Optional[ColumnAlignedRow]: """Re-lay ``strip``'s widgets out over ``header``'s sections. Idempotent: a strip that is already following this header is returned unchanged, so a repeated show costs nothing and cannot stack two layouts. :param strip: the widget holding the buttons. Its existing layout is emptied and destroyed -- the widgets survive, reparented on ``strip``. :param header: the ``QHeaderView`` whose columns are followed. :param columns: ``(widget, column index or None)`` in the order the un-aligned ones should trail in. :param on_invalidate: passed to the layout; called when a managed widget's size hint changes, so the header's owner can re-fit the column to a caption that has since been translated. :returns: the installed layout, or ``None`` when there was nothing to do. """ if strip is None or not _alive(strip) or not _alive(header): return None existing = strip.layout() if isinstance(existing, ColumnAlignedRow): return existing if existing is not None: while existing.count(): item = existing.takeAt(0) widget = item.widget() if item is not None else None if widget is not None: widget.setParent(strip) shiboken6.delete(existing) row = ColumnAlignedRow(header, strip, on_invalidate=on_invalidate) for widget, column in columns: row.add_over_column(widget, column) row.activate() return row