"""Drag an edge to resize a pane; collapsing is where the drag stops.
Two requests that turn out to be one: every container
collapses ("whenever anything is collapsed it should auto lock to the bottom
of the container it is in"), and every container can be "expanded or
shrinkable by draging the edges". A :class:`~PySide6.QtWidgets.QSplitter`
already does both -- a handle to drag, and a child that can be taken down to
nothing -- so both halves live here as ONE mechanism, and anything that
collapses in a splitter also resizes, and the other way round.
THE THREE PIECES, AND WHAT SLICE C REUSES
-----------------------------------------
:class:`CollapsibleSplitter`
A ``QSplitter`` whose children are registered as named panes::
split = CollapsibleSplitter(Qt.Vertical, persist_key="mask::shell")
split.add_pane(card, "Figures", folder=card.folder, extent=420)
split.add_pane(console_wrap, "Console", folder=console_folder)
split.add_pane(settings, "Settings", mode=EDGE, fold_key="mask/Settings")
A pane is one of three kinds:
* ``HEADER`` -- it has a :class:`~spacr.qt.widgets.foldable.Folder` (a
clickable heading over a body). Collapsed, its body is hidden, the pane
shrinks to its heading, and the heading sits at the BOTTOM of the room
the pane is given (:func:`lock_folded_to_bottom`). This is the kind for
anything with a name to click: the console, System, a figure panel.
* ``EDGE`` -- it collapses to nothing and the handle beside it becomes the
strip that brings it back: click the handle to hide or show the pane,
drag it to resize. The kind for a column with no heading of its own (the
settings column, which collapses to the left).
* plain (``mode=None``, no folder) -- resizable, never collapsed.
Dragging an expanded pane below its minimum collapses it (Qt's own
collapse, turned into the pane's folded state). Collapsed HEADER panes are
held at their heading's height, so a window resize never hands them room
they would show as a gap. When nothing in the splitter is left expanded,
the FIRST collapsed pane takes the spare room and, locked to the bottom,
stacks every heading at the bottom of the container.
Sizes are remembered per pane NAME (not per index, so adding a pane to a
screen does not scramble the old layout) under ``persist_key``, in the
preferences store. Only what the user dragged is stored; a collapse made
on the user's behalf is never stored as a size.
API: ``add_pane(widget, name, *, folder=None, mode=None, stretch=1,
extent=0, minimum=None, focus=False, fold_key="", index=None) -> Pane``;
``pane(name)``, ``panes()``, ``is_collapsed(name)``,
``set_collapsed(name, collapsed, *, by_user=False)``,
``toggle_pane(name, *, by_user=True)``, ``rebalance(grow=None)``, and the
``pane_toggled(name, collapsed, by_user)`` signal. A widget that is later
re-wrapped in a container which is put in its place (the settings search
strip does this to the settings column) keeps its registration: the
container inherits it.
:class:`FocusCollapse`
The automatic half. Some widgets are FOCUS widgets (a live preview, the
figures panel): while any of them is on screen, the TARGET panes collapse
(console, System, the buttons, the settings column), and when the last one
goes they come back. It does not override the user: a target the user
expands by hand is pinned open and left alone until the view ends. See the
class for what a view is.
:func:`lock_folded_to_bottom`
For a folded panel that is NOT in a splitter (a section in a scroll area,
a figure tile in a column): keeps its heading at the bottom of whatever
room its layout gives it, which is the "auto lock to the bottom" rule on
its own.
:class:`FoldSection`, :meth:`CollapsibleSplitter.add_section`, :func:`fold_card`
Slice C's use of the three above inside the screens: a heading that folds
a table, a figure, a side panel; the same heading in a splitter, so the
section also trades room with its neighbours by dragging; and the same
fold on a titled card. Folding never shows or builds a panel its owner
keeps hidden.
"""
from __future__ import annotations
import json
import logging
from functools import partial
from typing import Callable, List, Optional
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (QSizePolicy, QSpacerItem, QSplitter,
QSplitterHandle, QWidget)
from ..gil_priority import _stop_watching_object_events, _watch_object_events
#: What a pane's own watch reads: its hide, a pane shown or hidden, and a
#: pane whose contents changed size (a new font or GUI scale re-measures a
#: collapsed heading).
_PANE_KINDS = frozenset((QEvent.Hide, QEvent.ShowToParent, QEvent.HideToParent,
QEvent.LayoutRequest))
#: What a body or focus-widget watch reads.
_VISIBILITY_KINDS = frozenset((QEvent.ShowToParent, QEvent.HideToParent))
from shiboken6 import isValid
from ..i18n import tr
LOG = logging.getLogger("spacr.qt.collapsible_splitter")
#: A pane with a clickable heading: collapsed, it is that heading, at the
#: bottom of its room.
HEADER = "header"
#: A pane that collapses to nothing; the handle beside it is its strip.
EDGE = "edge"
#: Qt's "no maximum". Named so the reset reads as a reset.
UNLIMITED = 16777215
#: Width of a handle between panes, in pixels. Wide enough to grab; only a
#: one-pixel line in its middle is drawn, so the gap looks as it did.
GRIP_PX = 6
#: Width of a handle that is also a collapsed pane's strip. Wider, because it
#: carries the arrow that says which way a click will send the pane.
EDGE_GRIP_PX = 12
#: A press that moves less than this is a click on the handle, not a drag.
CLICK_SLOP_PX = 3
#: Preferences key prefix for the remembered pane sizes.
_EXTENTS_PREFIX = "layout/panes"
#: A remembered size at or below this, in 100 % pixels, is not a size the
#: user dragged to but a pane squeezed shut by an older layout or a damaged
#: store; it is dropped on load, and the pane opens at its default.
_SMALLEST_STORED = 24
#: A remembered size above this is not a screen's worth of anything.
_LARGEST_STORED = 20000
#: How long a held handle rests before the sizes are written, in ms.
#: Writing rewrites the whole preferences file, 5-7 ms, which every move of
#: a drag paid; letting go writes at once.
_EXTENTS_SAVE_MS = 300
def _alive(widget) -> bool:
"""Whether ``widget`` still has its C++ half."""
return widget is not None and bool(isValid(widget))
def _settings():
"""The preferences store; see :func:`spacr.qt.preferences._settings`."""
from ..preferences import _settings as _prefs_settings
return _prefs_settings()
[docs]
def get_pane_extents(persist_key: str) -> dict:
"""The sizes the user dragged the panes of ``persist_key`` to.
:param persist_key: the splitter's key, e.g. ``"mask::runtime"``.
:returns: ``{pane name: pixels}``; empty when nothing was ever dragged or
the stored value is unreadable.
"""
key = str(persist_key or "").strip()
if not key:
return {}
try:
raw = _settings().value(f"{_EXTENTS_PREFIX}/{key}", "")
value = json.loads(raw) if raw else {}
except Exception: # noqa: BLE001
return {}
if not isinstance(value, dict):
return {}
out = {}
for name, size in value.items():
try:
size = int(size)
except (TypeError, ValueError, OverflowError):
continue
if _SMALLEST_STORED < size <= _LARGEST_STORED:
out[str(name)] = size
return out
def _repair_pane_extents(persist_key: str) -> dict:
"""Read the sizes of ``persist_key``, writing back a repaired copy.
A size of zero, a negative or absurd size, a value that is not a number
or a store that is not a mapping cannot be laid out; it is dropped from
the store as well as from what is returned, so a damaged value is
repaired once rather than skipped on every launch.
:param persist_key: the splitter's key.
:returns: the usable sizes, ``{pane name: pixels}``.
"""
key = str(persist_key or "").strip()
if not key:
return {}
clean = get_pane_extents(key)
try:
raw = _settings().value(f"{_EXTENTS_PREFIX}/{key}", "")
stored = json.loads(raw) if raw else None
except Exception: # noqa: BLE001
stored = False
if stored is None:
return clean
if stored != clean:
if clean:
set_pane_extents(key, clean)
else:
_forget_pane_extents(key)
LOG.info("repaired the remembered pane sizes of %s", key)
return clean
def _forget_pane_extents(persist_key: str) -> None:
"""Remove the sizes remembered for ``persist_key``.
:param persist_key: the splitter's key.
"""
key = str(persist_key or "").strip()
if not key:
return
try:
_settings().remove(f"{_EXTENTS_PREFIX}/{key}")
except Exception: # noqa: BLE001
LOG.debug("could not forget the pane sizes", exc_info=True)
[docs]
def set_pane_extents(persist_key: str, extents: dict) -> None:
"""Remember ``{pane name: pixels}`` for ``persist_key``.
:param persist_key: the splitter's key.
:param extents: ``{pane name: pixels}``; zero and negative sizes are
dropped.
"""
key = str(persist_key or "").strip()
if not key:
return
try:
clean = {str(k): int(v) for k, v in dict(extents or {}).items()
if int(v) > 0}
_settings().setValue(f"{_EXTENTS_PREFIX}/{key}", json.dumps(clean))
except Exception: # noqa: BLE001
LOG.debug("could not store the pane sizes", exc_info=True)
[docs]
def lock_folded_to_bottom(container: QWidget, folder,
orientation=Qt.Vertical) -> None:
"""Keep ``container``'s heading at the bottom of its room while folded.
A folded panel given more room than its heading needs -- the last pane in a
splitter, a section stretched by its column -- would otherwise lay its
heading out in the middle of that room. A stretch put above the heading
while it is folded takes the spare room instead.
:param container: the widget whose layout holds the heading (first) and
the body.
:param folder: its :class:`~spacr.qt.widgets.foldable.Folder`.
:param orientation: ``Qt.Horizontal`` locks it to the left instead, for a
panel that folds sideways.
Called twice for one container and folder -- a :class:`FoldSection`
locks itself and :meth:`CollapsibleSplitter.add_pane` locks every HEADER
pane -- the second call does nothing, so there is only ever one stretch
and the first orientation asked for wins.
"""
locked = getattr(container, "_spacr_bottom_locks", None)
if locked is None:
locked = set()
try:
container._spacr_bottom_locks = locked
except Exception: # noqa: BLE001
locked = set()
if id(folder) in locked:
return
locked.add(id(folder))
state = {"spacer": None}
def apply(shut: bool, _by_user: bool = True) -> None:
"""Add or take away the stretch in front of the heading."""
layout = container.layout() if _alive(container) else None
if layout is None:
return
spacer = state["spacer"]
if shut and spacer is None:
if orientation == Qt.Vertical:
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum,
QSizePolicy.Expanding)
layout.insertItem(0, spacer)
else:
spacer = QSpacerItem(0, 0, QSizePolicy.Expanding,
QSizePolicy.Minimum)
layout.addItem(spacer)
state["spacer"] = spacer
elif not shut and spacer is not None:
layout.removeItem(spacer)
state["spacer"] = None
layout.invalidate()
folder.add_listener(apply)
if folder.shut:
apply(True)
def _hidden_by_owner(widget) -> bool:
"""Whether someone called ``hide()`` on ``widget`` (not merely unshown).
A widget with no parent that was never shown also answers ``isHidden()``
-- it is a window nobody opened -- and taking that for "its owner keeps
it hidden" would hide every section built before it is laid out.
"""
return bool(_alive(widget)
and widget.testAttribute(Qt.WA_WState_ExplicitShowHide)
and widget.isHidden())
[docs]
class FoldSection(QWidget):
"""A body under a clickable heading; folded, the heading sits at the bottom.
Requested: "any sections inside the figures or live preview containers
should also be colapseable ... evey GUI element be it a figure or a table".
This is the one way a section inside a screen gets a fold control, so every
one of them looks and behaves alike: an arrow and a name above the body
(the same :class:`~spacr.qt.widgets.foldable.Folder` the console and System
use), a click folds it, and a folded section is its heading locked to the
bottom of whatever room it is given (:func:`lock_folded_to_bottom`).
The body's own visibility stays under its owner's control. The fold hides
an inner holder, never the body, so unfolding cannot show a panel its owner
hid or build a panel that is built on first show. The other way round, the
section follows its body: while the owner keeps the body hidden the whole
section -- heading included -- is hidden too, and it comes back when the
owner shows the body. Only the body's hidden flag is read for that, so
nothing is shown, measured or built to find out.
:param body: the section's content. Re-parented into the section.
:param name: stable English name; the heading shows it translated.
:param parent: parent widget.
:param persist_key: ``"<module>/<section>"``; given, a fold the USER
makes survives a restart (a fold made on their behalf never does).
:param stretch: the body's stretch inside the section.
:param actions: widgets put at the right-hand end of the heading row
(a Refresh button, a count); they stay visible while folded.
:param follow_body: False keeps the section shown whatever the body's
own flag says.
:param folded: start folded until the user opens it; with a
``persist_key`` the opening is remembered.
:ivar heading: the heading label (object name ``FoldHeading``).
:ivar folder: the section's Folder; HELD, it owns the click filter.
:ivar body: the content widget.
"""
def __init__(self, body: QWidget, name: str, parent=None, *,
persist_key: str = "", stretch: int = 1, actions=(),
follow_body: bool = True, folded: bool = False):
"""Wrap ``body`` under a heading called ``name``."""
super().__init__(parent)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout
from .foldable import make_foldable
self.setObjectName("FoldSection")
self.body = body
self._follow_body = bool(follow_body)
body_hidden = _hidden_by_owner(body)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
self.heading = QLabel(str(name), self)
self.heading.setObjectName("FoldHeading")
self.heading.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
actions = [w for w in (actions or ()) if w is not None]
if actions:
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
row.addWidget(self.heading)
row.addStretch(1)
for widget in actions:
row.addWidget(widget)
layout.addLayout(row)
else:
layout.addWidget(self.heading)
self._holder = QWidget(self)
self._holder.setObjectName("FoldSectionBody")
inner = QVBoxLayout(self._holder)
inner.setContentsMargins(0, 0, 0, 0)
inner.setSpacing(0)
inner.addWidget(body)
layout.addWidget(self._holder, max(0, int(stretch)))
if body_hidden:
body.setVisible(False)
self.folder = make_foldable(self.heading, self._holder, name=str(name),
persist_key=persist_key,
shut_by_default=bool(folded))
lock_folded_to_bottom(self, self.folder, Qt.Vertical)
if self._follow_body:
_watch_object_events(body, self, _VISIBILITY_KINDS)
if body_hidden:
self.setVisible(False)
@property
[docs]
def shut(self) -> bool:
"""Whether the section is folded."""
return bool(self.folder.shut)
[docs]
def set_folded(self, folded: bool, *, by_user: bool = True) -> bool:
"""Fold or open the section; returns the new folded state.
:param folded: True to fold it.
:param by_user: False for a fold made on the user's behalf, which is
never remembered.
"""
return self.folder.set_shut(folded, by_user=by_user)
[docs]
def eventFilter(self, watched, event) -> bool: # noqa: N802
"""Hide the section with its body, and show it again with it.
:param watched: the body.
:param event: only ShowToParent and HideToParent are read.
:returns: False, so the event goes on as usual.
"""
if (watched is getattr(self, "body", None)
and getattr(self, "_follow_body", False)):
kind = event.type()
if kind == QEvent.HideToParent and not self.isHidden():
self.setVisible(False)
elif kind == QEvent.ShowToParent and self.isHidden():
self.setVisible(True)
return False
[docs]
def fold_card(card, name: str = "", *, persist_key: str = ""):
"""Make a titled :class:`~spacr.qt.widgets.card.Card` fold by its title.
The card keeps its frame and its title; clicking the title folds the
body away and the folded card's title locks to the bottom of its room.
A card whose body is built on first show stays unbuilt: the fold moves
the card's body widget only, never the card.
:param card: the card; one without a title is left alone.
:param name: English name; defaults to the card's title text.
:param persist_key: ``"<module>/<card>"`` to remember a user's fold.
:returns: the card's Folder, or None when it has no title.
"""
folder = getattr(card, "folder", None)
title = getattr(card, "title_label", None)
body = getattr(card, "body", None)
if folder is None:
if title is None or body is None:
return None
from .foldable import make_foldable
folder = make_foldable(title, body,
name=name or title.text().strip(),
persist_key=persist_key)
card.folder = folder
lock_folded_to_bottom(card, folder, Qt.Vertical)
return folder
[docs]
class Pane:
"""One registered child of a :class:`CollapsibleSplitter`.
Made by :meth:`CollapsibleSplitter.add_pane`; each argument is kept as
the attribute of the same name.
:param widget: the splitter's direct child.
:param name: stable English name; the key its size is stored under.
:param mode: :data:`HEADER`, :data:`EDGE` or ``None``.
:param folder: the heading's Folder, for a HEADER pane.
:param stretch: 0 keeps the pane at its own size when the splitter grows.
:param extent: the size it opens at; updated by every drag.
:param minimum: its minimum while open, dropped while it is collapsed so
it can shrink to its heading.
:param focus: showing it also opens it (a live preview the user folded
opens again when it is switched back on).
:param fold_key: stores an EDGE pane's collapse across restarts.
:param hint: what dragging this EDGE pane's handle does, in English; it
replaces the generic "or drag to resize it" in the handle's tooltip.
"""
def __init__(self, widget, name, mode, folder, stretch, extent, minimum,
focus, fold_key, hint=""):
"""Hold the pane's description; see the class for each field."""
self.widget = widget
self.name = str(name)
self.mode = mode
self.folder = folder
self.stretch = int(stretch)
self.extent = int(extent or 0)
self.minimum = minimum
self.focus = bool(focus)
self.fold_key = str(fold_key or "")
self.hint = str(hint or "")
self.edge_collapsed = False
#: The size it opens at by default, which a layout reset returns to.
self.default_extent = self.extent
[docs]
def is_collapsed(self) -> bool:
"""Whether the pane is collapsed right now."""
if self.mode == HEADER and self.folder is not None:
return bool(self.folder.shut)
if self.mode == EDGE:
return self.edge_collapsed
return False
class _PaneHandle(QSplitterHandle):
"""A splitter handle that draws a thin line, and is a strip when needed.
Painted here rather than by the stylesheet, because the theme's hover rule
colours the WHOLE handle, and a grab area wide enough to find would turn
into a blue bar. Beside an EDGE pane it also carries an arrow, and a
click (a press that does not move) collapses or restores that pane.
"""
def __init__(self, orientation, splitter):
"""Build the handle; ``splitter`` is its CollapsibleSplitter."""
super().__init__(orientation, splitter)
self.setAttribute(Qt.WA_Hover, True)
self._pressed_at = None
self._dragged = False
def edge_pane(self) -> Optional[Pane]:
"""The EDGE pane this handle is the strip of, if any."""
splitter = self.splitter()
if getattr(splitter, "_panes", None) is None:
return None
finder = getattr(splitter, "_edge_pane_beside", None)
return finder(self) if callable(finder) else None
def retranslate_dynamic_content(self, language=None) -> None:
"""Rewrite the tooltip in ``language``."""
pane = self.edge_pane()
if pane is None:
self.setToolTip("")
return
name = tr(pane.name, language)
if pane.hint:
action = (tr("Click to show {name} again.", language, name=name)
if pane.is_collapsed() else
tr("Click to hide {name}.", language, name=name))
self.setToolTip(f"{action} {tr(pane.hint, language)}")
elif pane.is_collapsed():
self.setToolTip(tr("Click to show {name} again, or drag to "
"resize it.", language, name=name))
else:
self.setToolTip(tr("Click to hide {name}, or drag to resize it.",
language, name=name))
def enterEvent(self, event) -> None: # noqa: N802
"""Light the line up under the pointer."""
self.update()
super().enterEvent(event)
def leaveEvent(self, event) -> None: # noqa: N802
"""Put the line back."""
self.update()
super().leaveEvent(event)
def mousePressEvent(self, event) -> None: # noqa: N802
"""Remember where a press began, to tell a click from a drag."""
self._pressed_at = event.position().toPoint()
self._dragged = False
self._opened = False
self.splitter()._held = True
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: N802
"""A press that travels is a drag; one that grows a heading opens it.
A collapsed HEADER pane is held at its heading's height, so dragging
its edge would otherwise do nothing at all. Dragging towards it opens
it, once per press, and the drag goes on to size it; until the press
ends, a drag that is short of its minimum holds it there rather than
letting Qt collapse it straight back.
"""
if self._pressed_at is not None:
moved = event.position().toPoint() - self._pressed_at
if moved.manhattanLength() > CLICK_SLOP_PX:
self._dragged = True
if not getattr(self, "_opened", False):
along = (moved.y() if self.orientation() == Qt.Vertical
else moved.x())
pane = self.folded_heading_towards(along)
if pane is not None:
self._opened = True
splitter = self.splitter()
splitter.set_collapsed(pane.name, False, by_user=True)
splitter.setCollapsible(
splitter.indexOf(pane.widget), False)
super().mouseMoveEvent(event)
def folded_heading_towards(self, along: int) -> Optional[Pane]:
"""The collapsed HEADER pane a drag of ``along`` pixels would grow.
:param along: the drag along the splitter; negative grows the pane
after this handle, positive the pane before it.
:returns: the pane, or None when that pane is open or has no heading.
"""
splitter = self.splitter()
finder = getattr(splitter, "_pane_of", None)
at = self._index()
if not callable(finder) or at < 0 or along == 0:
return None
if along < 0:
indices = range(at, splitter.count())
else:
indices = range(at - 1, -1, -1)
for i in indices:
widget = splitter.widget(i)
if widget is None or widget.isHidden():
continue
pane = finder(widget)
if (pane is not None and pane.mode == HEADER
and pane.is_collapsed()):
return pane
return None
return None
def mouseReleaseEvent(self, event) -> None: # noqa: N802
"""A click beside an EDGE pane collapses or restores it."""
super().mouseReleaseEvent(event)
clicked = (self._pressed_at is not None and not self._dragged
and event.button() == Qt.LeftButton)
dragged = (self._pressed_at is not None and self._dragged
and event.button() == Qt.LeftButton)
self._pressed_at = None
self.splitter()._held = False
if getattr(self, "_opened", False):
self._opened = False
self.splitter()._sync_flags()
self.splitter()._flush_extents()
pane = self.edge_pane() if clicked else None
if pane is not None:
self.splitter().toggle_pane(pane.name, by_user=True)
elif dragged:
from .ambient import field_ripple_for_widget
field_ripple_for_widget(self.splitter())
def paintEvent(self, _event) -> None: # noqa: N802
"""Draw the one-pixel line and, beside an EDGE pane, its arrow."""
try:
from ..theme import _painting_palette
palette = _painting_palette()
except Exception: # noqa: BLE001
palette = {}
hovered = self.underMouse() or getattr(self, "_pressed_at", None) is not None
line = QColor(palette.get("accent" if hovered else "border_soft",
"#4c8dff" if hovered else "#3a3f4b"))
painter = QPainter(self)
rect = self.rect()
if self.orientation() == Qt.Horizontal:
x = rect.center().x()
painter.fillRect(x, rect.top(), 1, rect.height(), line)
else:
y = rect.center().y()
painter.fillRect(rect.left(), y, rect.width(), 1, line)
pane = self.edge_pane()
if pane is not None:
self._paint_arrow(painter, pane, palette, hovered)
painter.end()
def _paint_arrow(self, painter, pane, palette, hovered) -> None:
"""A dark tab with a chevron pointing where a click sends the pane.
The dark-grey tab carries a stroked chevron. When hovered or held
down, the chevron and the tab's edge turn
the accent colour, so the thing that is about to move says so.
"""
splitter = self.splitter()
before = splitter.indexOf(pane.widget) < splitter.indexOf(
splitter.widget(self._index()))
collapsed = pane.is_collapsed()
towards_start = before != collapsed
from .collapse_arrow import paint_collapse_arrow
paint_collapse_arrow(painter, self.rect(), self.orientation(),
towards_start, palette, hovered)
def _index(self) -> int:
"""This handle's index in its splitter."""
splitter = self.splitter()
for i in range(splitter.count()):
if splitter.handle(i) is self:
return i
return -1
[docs]
class CollapsibleSplitter(QSplitter):
"""A splitter whose panes resize by dragging and collapse at the limit.
See the module docstring for the pane kinds and the API.
:param orientation: ``Qt.Vertical`` stacks the panes.
:param parent: parent widget.
:param persist_key: where the dragged sizes are remembered, e.g.
``"mask::shell"``; empty remembers nothing (what a test wants).
"""
#: ``(pane name, collapsed, by_user)`` after a pane collapses or opens.
pane_toggled = Signal(str, bool, bool)
def __init__(self, orientation, parent=None, *, persist_key: str = ""):
"""Build an empty splitter; add panes with :meth:`add_pane`."""
super().__init__(orientation, parent)
self._panes: List[Pane] = []
self._persist_key = str(persist_key or "")
self._stored = _repair_pane_extents(self._persist_key)
self._laid_out = False
self._rebalance_queued = False
self._held = False
self._extents_timer = QTimer(self)
self._extents_timer.setSingleShot(True)
self._extents_timer.setInterval(_EXTENTS_SAVE_MS)
self._extents_timer.timeout.connect(self._save_extents)
self._limits_queued = False
self._applied_extents = {}
self.setHandleWidth(GRIP_PX)
self.splitterMoved.connect(self._on_moved)
_watch_object_events(self, self, _PANE_KINDS)
from .. import gui_scale
gui_scale.add_listener(self._on_gui_scale)
[docs]
def createHandle(self): # noqa: N802
"""Every handle is a :class:`_PaneHandle`."""
return _PaneHandle(self.orientation(), self)
def _along(self, size) -> int:
"""The part of ``size`` that runs along the splitter."""
return (size.height() if self.orientation() == Qt.Vertical
else size.width())
[docs]
def add_pane(self, widget: QWidget, name: str, *, folder=None,
mode: Optional[str] = None, stretch: int = 1,
extent: int = 0, minimum: Optional[int] = None,
focus: bool = False, fold_key: str = "",
index: Optional[int] = None, hint: str = "") -> Pane:
"""Register ``widget`` as the pane ``name``, adding it if need be.
:param widget: the pane. Added at ``index`` (or the end) unless it is
already a child of this splitter.
:param name: stable English name, used for the stored size and the
handle's tooltip; translated where it is shown.
:param folder: the heading's Folder; makes it a HEADER pane.
:param mode: :data:`EDGE` for a pane that collapses to nothing.
:param stretch: 0 keeps its own size as the splitter grows.
:param extent: the size to open at when the user never dragged it.
:param minimum: its minimum while open; ``None`` keeps the widget's.
:param focus: showing the widget also opens the pane.
:param fold_key: remembers an EDGE pane's collapse across restarts.
:param hint: what dragging the pane's handle does, for its tooltip.
:returns: the :class:`Pane`.
"""
if self.indexOf(widget) < 0:
if index is None:
super().addWidget(widget)
else:
super().insertWidget(index, widget)
existing = self.pane(name)
if existing is not None:
self._panes.remove(existing)
if mode is None and folder is not None:
mode = HEADER
if minimum is None:
minimum = self._along(widget.minimumSize())
pane = Pane(widget, name, mode, folder, stretch,
self._stored.get(str(name), extent), minimum, focus,
fold_key, hint)
pane.default_extent = int(extent or 0)
self._panes.append(pane)
self.setStretchFactor(self.indexOf(widget), max(0, int(stretch)))
_watch_object_events(widget, self, _PANE_KINDS)
if folder is not None:
folder.add_listener(partial(self._folder_moved, pane))
lock_folded_to_bottom(widget, folder, self.orientation())
if folder.shut:
self._shape(pane, True)
if mode == EDGE and pane.fold_key:
try:
from ..preferences import get_folded_panels
pane.edge_collapsed = bool(
get_folded_panels().get(pane.fold_key))
except Exception: # noqa: BLE001
LOG.debug("could not read the folded panes", exc_info=True)
self._sync_flags()
self._queue_rebalance()
return pane
[docs]
def add_section(self, widget: QWidget, name: str, *,
persist_key: str = "", stretch: int = 1, extent: int = 0,
minimum: Optional[int] = None, focus: bool = False,
index: Optional[int] = None, actions=()) -> "FoldSection":
"""Wrap ``widget`` in a :class:`FoldSection` and add it as a pane.
The short way to give a splitter child a heading that folds it: the
section's Folder makes it a HEADER pane, so it resizes by its edge,
collapses at the limit of a drag, and folded, is its heading at the
bottom of its room.
:param widget: the content. Must not already be in this splitter.
:param name: stable English name for the heading and the stored size.
:param persist_key: ``"<module>/<section>"`` to remember a user fold.
:param stretch: 0 keeps its own size as the splitter grows.
:param extent: the size to open at when the user never dragged it.
:param minimum: its minimum while open.
:param focus: showing it also opens it.
:param index: where to insert it; the end by default.
:param actions: widgets for the right-hand end of the heading.
:returns: the section; its pane is ``self.pane(name)``.
"""
section = FoldSection(widget, name, persist_key=persist_key,
actions=actions)
self.add_pane(section, name, folder=section.folder, stretch=stretch,
extent=extent, minimum=minimum, focus=focus,
index=index)
return section
[docs]
def pane(self, name: str) -> Optional[Pane]:
"""The pane called ``name``, or None.
:param name: the name it was added under.
"""
for pane in self._panes:
if pane.name == str(name):
return pane
return None
[docs]
def panes(self) -> list:
"""Every registered pane, in the order registered."""
return list(self._panes)
def _pane_of(self, widget) -> Optional[Pane]:
"""The pane whose widget is ``widget``."""
for pane in self._panes:
if pane.widget is widget:
return pane
return None
[docs]
def is_collapsed(self, name: str) -> bool:
"""Whether the pane ``name`` is collapsed; False for no such pane.
:param name: the pane's name.
"""
pane = self.pane(name)
return bool(pane is not None and pane.is_collapsed())
[docs]
def set_collapsed(self, name: str, collapsed: bool, *,
by_user: bool = False) -> bool:
"""Collapse or open the pane ``name``.
:param name: the pane's name.
:param collapsed: True to collapse it.
:param by_user: True when a person asked for it. Only such a change
is remembered, and only such a change pins a pane against
:class:`FocusCollapse`.
:returns: whether the pane is now in the state asked for.
"""
pane = self.pane(name)
if pane is None or pane.mode is None:
return False
collapsed = bool(collapsed)
if pane.is_collapsed() == collapsed:
return True
if pane.mode == HEADER:
pane.folder.set_shut(collapsed, by_user=by_user)
else:
self._set_edge(pane, collapsed, by_user=by_user)
return pane.is_collapsed() == collapsed
[docs]
def toggle_pane(self, name: str, *, by_user: bool = True) -> bool:
"""Flip the pane ``name``; returns whether it is now collapsed.
:param name: the pane's name.
:param by_user: whether a person asked for it; see
:meth:`set_collapsed`.
"""
pane = self.pane(name)
if pane is None:
return False
self.set_collapsed(name, not pane.is_collapsed(), by_user=by_user)
return pane.is_collapsed()
def _folder_moved(self, pane: Pane, shut: bool, by_user: bool) -> None:
"""A HEADER pane's heading was clicked or folded for the user."""
if pane not in self._panes or not _alive(pane.widget):
return
self._shape(pane, shut)
self._sync_flags()
self.rebalance(grow=None if shut else pane)
self.pane_toggled.emit(pane.name, bool(shut), bool(by_user))
from .ambient import field_ripple_for_widget
field_ripple_for_widget(
self,
edge="bottom" if self.orientation() == Qt.Vertical else "right",
rect=pane.widget.geometry())
def _shape(self, pane: Pane, collapsed: bool) -> None:
"""Let a HEADER pane shrink to its heading, or hold its minimum."""
widget = pane.widget
index = self.indexOf(widget)
if collapsed:
if 0 <= index < self.count():
from ..gui_scale import _actual_sizes
size = _actual_sizes(self)[index]
if size > self._collapsed_extent(pane) + GRIP_PX:
pane.extent = self._base(size)
if self.orientation() == Qt.Vertical:
widget.setMinimumHeight(0)
else:
widget.setMinimumWidth(0)
else:
if self.orientation() == Qt.Vertical:
widget.setMaximumHeight(UNLIMITED)
widget.setMinimumHeight(int(pane.minimum or 0))
else:
widget.setMaximumWidth(UNLIMITED)
widget.setMinimumWidth(int(pane.minimum or 0))
def _set_edge(self, pane: Pane, collapsed: bool, *, by_user: bool,
from_drag: bool = False) -> None:
"""Collapse or open an EDGE pane."""
if pane.edge_collapsed == bool(collapsed):
return
index = self.indexOf(pane.widget)
if collapsed and 0 <= index < self.count():
size = self.sizes()[index]
if size > 0:
pane.extent = size
pane.edge_collapsed = bool(collapsed)
if by_user and pane.fold_key:
try:
from ..preferences import set_folded_panel
set_folded_panel(pane.fold_key, bool(collapsed))
except Exception: # noqa: BLE001
LOG.debug("could not store the folded pane", exc_info=True)
self._sync_flags()
if not from_drag:
self.rebalance(grow=None if collapsed else pane)
self.pane_toggled.emit(pane.name, bool(collapsed), bool(by_user))
if not from_drag:
from .ambient import field_ripple_for_widget
landing = None
if collapsed and index >= 0:
handle_index = index + 1 if index < self.count() - 1 else index
landing = self.handle(handle_index)
if collapsed and landing is None:
return
try:
landing_rect = landing.geometry() if collapsed else pane.widget.geometry()
except RuntimeError:
return
field_ripple_for_widget(
self,
edge=("right" if index < self.count() - 1 else "left")
if self.orientation() == Qt.Horizontal else
("bottom" if index < self.count() - 1 else "top"),
rect=landing_rect)
def _factor(self) -> float:
"""The GUI scale this splitter is drawn at."""
from ..gui_scale import _factor_for
return float(_factor_for(self) or 1.0)
def _base(self, drawn: int) -> int:
"""``drawn`` pixels in the 100 % units the sizes are remembered in."""
factor = self._factor()
drawn = int(drawn)
return drawn if factor == 1.0 or drawn <= 0 else int(
round(drawn / factor))
def _drawn(self, base: int) -> int:
"""A remembered 100 % size in the pixels it is drawn at now."""
from ..gui_scale import scale_int
return scale_int(int(base or 0), self._factor())
def _collapsed_extent(self, pane: Pane) -> int:
"""How much room a collapsed HEADER pane needs: its heading.
In the pixels it is drawn at, measured from the heading's font at
the GUI and font scale in force, never a fixed number: the larger of
the pane's own layout hints and :meth:`_heading_need`.
"""
widget = pane.widget
layout = widget.layout()
if layout is not None:
layout.activate()
return max(self._along(widget.minimumSizeHint()),
self._along(widget.sizeHint()) if pane.is_collapsed()
else 0,
self._heading_need(pane))
def _heading_need(self, pane: Pane) -> int:
"""The room a HEADER pane's heading needs to be read and clicked.
The heading's own size hint, which follows its font metrics, and at
least one line of its font, plus every layout margin between it and
the pane, so a heading inside a card's frame is not cut by the frame.
:returns: pixels along the splitter, or 0 for a pane with no heading.
"""
heading = getattr(pane.folder, "heading", None)
if not _alive(heading):
return 0
vertical = self.orientation() == Qt.Vertical
metrics = heading.fontMetrics()
if vertical:
need = max(heading.sizeHint().height(),
heading.minimumSizeHint().height(),
metrics.height())
else:
need = max(heading.minimumSizeHint().width(),
metrics.height())
widget = pane.widget
holder = heading.parentWidget()
while _alive(holder):
layout = holder.layout()
if layout is not None:
margins = layout.contentsMargins()
need += (margins.top() + margins.bottom() if vertical
else margins.left() + margins.right())
if holder is widget:
break
holder = holder.parentWidget()
return int(need)
def _sync_flags(self) -> None:
"""Which children Qt may take to zero, and the handles' dress.
An open pane with a way back is collapsible (a drag past its minimum
collapses it); a collapsed HEADER pane is not (its heading must stay);
an EDGE pane always is (zero is its collapsed size); a plain child is
not.
"""
for i in range(self.count()):
pane = self._pane_of(self.widget(i))
flag = bool(pane is not None and (
pane.mode == EDGE
or (pane.mode == HEADER and not pane.is_collapsed())))
self.setCollapsible(i, flag)
self._dress_handles()
def _edge_pane_beside(self, handle) -> Optional[Pane]:
"""The EDGE pane that ``handle`` belongs to, if any."""
at = -1
for i in range(self.count()):
if self.handle(i) is handle:
at = i
break
if at < 0:
return None
for pane in self._panes:
if pane.mode != EDGE:
continue
k = self.indexOf(pane.widget)
if k < 0:
continue
if at == k + 1 or (k == self.count() - 1 and at == k and k > 0):
return pane
return None
def _dress_handles(self) -> None:
"""Widen and label the handles that are an EDGE pane's strip."""
wide = any(p.mode == EDGE for p in self._panes)
self.setHandleWidth(EDGE_GRIP_PX if wide else GRIP_PX)
for i in range(self.count()):
handle = self.handle(i)
handle.retranslate_dynamic_content()
handle.update()
def _rebind(self, widget: QWidget) -> None:
"""Hand a pane to the container that now holds its old widget.
The settings search strip replaces the settings column with a
container holding the strip and the column. Without this the column
would silently stop collapsing, and the container would be a plain,
unnamed child.
"""
for pane in self._panes:
old = pane.widget
if old is widget or not _alive(old):
continue
if self.indexOf(old) < 0 and widget.isAncestorOf(old):
_stop_watching_object_events(old, self)
pane.widget = widget
_watch_object_events(widget, self, _PANE_KINDS)
self.setStretchFactor(self.indexOf(widget),
max(0, pane.stretch))
self._sync_flags()
self._queue_rebalance()
[docs]
def eventFilter(self, watched, event) -> bool: # noqa: N802
"""Follow a pane being shown or hidden by its owner.
:param watched: a pane's widget.
:param event: the event; only show and hide are read.
:returns: False, so the event goes on as usual.
"""
kind = event.type()
if kind == QEvent.Hide and watched is self:
self._flush_extents()
elif kind == QEvent.LayoutRequest:
pane = self._pane_of(watched)
if (pane is not None and pane.mode == HEADER
and pane.is_collapsed()):
self._queue_limits()
elif kind in (QEvent.ShowToParent, QEvent.HideToParent):
pane = self._pane_of(watched)
if pane is not None:
if (kind == QEvent.ShowToParent and pane.focus
and pane.is_collapsed()):
self.set_collapsed(pane.name, False, by_user=False)
self._queue_rebalance()
return False
def _on_gui_scale(self, _scale: float) -> None:
"""The GUI scale changed: measure the collapsed headings again."""
if _alive(self):
self._queue_limits()
def _queue_limits(self) -> None:
"""Re-measure the collapsed headings on the next turn, once."""
if self._limits_queued:
return
self._limits_queued = True
QTimer.singleShot(0, self._refresh_limits)
def _refresh_limits(self) -> None:
"""Follow a collapsed heading whose font or scale changed.
A new font scale, GUI scale or language changes how tall a heading
is. Its pane is held to the old height while that is not measured
again, which is done here; the panes are laid out again only when a
heading's need moved or a collapsed pane is below it, and never
while a handle is held.
"""
self._limits_queued = False
if not _alive(self):
return
before = dict(self._applied_extents)
self._apply_limits()
if self._held or not self._laid_out:
return
from ..gui_scale import _actual_sizes
sizes = _actual_sizes(self)
short = False
for i in self._visible_indices():
pane = self._pane_of(self.widget(i))
if (pane is not None and pane.mode == HEADER
and pane.is_collapsed()
and sizes[i] < self._applied_extents.get(pane.name, 0)):
short = True
if short or before != self._applied_extents:
self.rebalance()
def _reset_layout(self) -> int:
"""Open every pane and put it back at the size it opens at.
What was remembered for the panes -- their folds and the sizes the
user dragged them to -- is forgotten too, so the next launch starts
from the defaults. A panel that starts folded goes back to folded.
:returns: how many panes were reset.
"""
try:
from ..preferences import set_folded_panel
except Exception: # noqa: BLE001
set_folded_panel = None
count = 0
for pane in list(self._panes):
if not _alive(pane.widget):
continue
count += 1
pane.extent = int(pane.default_extent or 0)
if pane.mode == HEADER and pane.folder is not None:
key = str(getattr(pane.folder, "persist_key", "") or "")
shut = bool(getattr(pane.folder, "shut_by_default", False))
if key and set_folded_panel is not None:
set_folded_panel(key, shut, default_shut=shut)
if pane.folder.shut != shut:
pane.folder.set_shut(shut, by_user=False)
elif pane.mode == EDGE:
if pane.fold_key and set_folded_panel is not None:
set_folded_panel(pane.fold_key, False)
if pane.edge_collapsed:
self._set_edge(pane, False, by_user=False)
self._stored = {}
self._extents_timer.stop()
_forget_pane_extents(self._persist_key)
self._sync_flags()
if self._laid_out:
self.rebalance(fresh=True)
return count
[docs]
def showEvent(self, event) -> None: # noqa: N802
"""Lay the panes out from their remembered sizes, once.
:param event: the show event.
"""
super().showEvent(event)
if not self._laid_out:
self._laid_out = True
self._queue_rebalance(first=True)
def _queue_rebalance(self, first: bool = False) -> None:
"""Rebalance on the next turn of the event loop, once."""
self._apply_limits()
if first:
self._first_layout = True
if self._rebalance_queued:
return
self._rebalance_queued = True
QTimer.singleShot(0, self._run_queued_rebalance)
def _run_queued_rebalance(self) -> None:
"""The queued rebalance.
Skipped for a splitter that has never been shown: it has no real
size yet, so sharing it out would squeeze every fixed pane down to
its minimum and a later resize would hand the growth to the others.
The first show lays everything out from the remembered sizes anyway.
"""
self._rebalance_queued = False
if not _alive(self) or not self._laid_out:
return
first = bool(getattr(self, "_first_layout", False))
self._first_layout = False
self.rebalance(fresh=first)
def _visible_indices(self) -> list:
"""Indices of the children that are not hidden."""
return [i for i in range(self.count())
if not self.widget(i).isHidden()]
def _absorber(self, visible) -> Optional[int]:
"""The collapsed pane that takes the spare room, when nothing can.
Spare room belongs to an open pane that stretches. When there is
none -- the console folded with only System and the buttons open
beneath it -- the FIRST collapsed heading takes it, locked to the
bottom of that room, so every heading and every fixed-size pane
below it ends up stacked at the bottom of the container. None while
a stretching pane is open.
"""
first = None
for i in visible:
pane = self._pane_of(self.widget(i))
if pane is None:
return None
if not pane.is_collapsed():
if pane.stretch > 0:
return None
continue
if pane.mode == HEADER and first is None:
first = i
return first
def _apply_limits(self) -> None:
"""Hold every collapsed HEADER pane at its heading's size.
A maximum rather than a size: Qt keeps it through every window
resize and every drag, where a size would be handed the growth. And
the same size as its minimum, so no drag, restored size or squeezed
window can take a collapsed pane below its heading. Both are set in
the pixels the heading was measured in, which the GUI scale must
not scale a second time.
"""
from ..gui_scale import _set_actual_dim
visible = self._visible_indices()
absorber = self._absorber(visible)
vertical = self.orientation() == Qt.Vertical
low, high = ("minh", "maxh") if vertical else ("minw", "maxw")
applied = {}
for i in range(self.count()):
pane = self._pane_of(self.widget(i))
if pane is None:
continue
widget = pane.widget
if pane.mode != HEADER:
continue
if pane.is_collapsed():
need = self._collapsed_extent(pane)
applied[pane.name] = need
_set_actual_dim(widget, low, need)
_set_actual_dim(widget, high,
UNLIMITED if i == absorber else need)
else:
_set_actual_dim(widget, high, UNLIMITED)
self._applied_extents = applied
def _height_for_width(self, widget) -> int:
"""The height ``widget`` needs at this splitter's width, or 0.
A splitter ignores height-for-width, which a row of buttons that
wraps onto a second line depends on: given its one-line size hint, the
second line was simply cut off. The need is read here and used as the
pane's size when it is laid out, and again when the width changes.
NOT as a minimum: the splitter's minimum is the window's, and a row
that wraps at a narrow first width would hold a laptop window taller
than its screen.
"""
if self.orientation() != Qt.Vertical or not widget.hasHeightForWidth():
return 0
width = self.width()
if width <= 0:
return 0
try:
return max(0, int(widget.heightForWidth(width)))
except Exception: # noqa: BLE001
return 0
[docs]
def resizeEvent(self, event) -> None: # noqa: N802
"""A new width can wrap a row onto another line; make room for it.
A resize of the window is not a drag -- no handle is held -- so
re-fitting here does not interfere with a drag.
:param event: the resize event.
"""
super().resizeEvent(event)
if (self._laid_out and self.orientation() == Qt.Vertical
and event.oldSize().width() != event.size().width()
and not getattr(self, "_refit_queued", False)):
self._refit_queued = True
QTimer.singleShot(0, self._refit)
def _refit(self) -> None:
"""The queued re-fit of the fixed-size panes that wrap."""
self._refit_queued = False
if _alive(self):
self.rebalance(refit=True)
[docs]
def rebalance(self, grow: Optional[Pane] = None,
fresh: bool = False, refit: bool = False) -> list:
"""Give every visible pane its share and return the new sizes.
Collapsed HEADER panes get their heading, collapsed EDGE panes get
nothing, panes with ``stretch`` 0 get their own size, and the rest of
the room is shared by the others in proportion to their current size.
A pane being opened (``grow``), one too small to have a real size, and
every pane on the first layout (``fresh``) are sized from their
remembered size instead. ``refit`` sizes a fixed-size pane that wraps
to the height its width now needs.
Worked out in the pixels the panes are drawn at, because a heading
and a wrapped row are measured in them; only a remembered size is
kept in 100 % units, and is scaled here.
:returns: the sizes set, in 100 % units, or ``[]`` when there was
nothing to lay out.
"""
from ..gui_scale import _actual_sizes, _set_actual_sizes
self._apply_limits()
count = self.count()
visible = self._visible_indices()
if not visible:
return []
sizes = _actual_sizes(self)
handles = self.handleWidth() * max(0, len(visible) - 1)
room = self._along(self.size()) - handles
total = sum(sizes[i] for i in visible)
if room > 0 and self._laid_out:
total = room
absorber = self._absorber(visible)
fixed = {}
rigid = {}
elastic = {}
for i in visible:
widget = self.widget(i)
pane = self._pane_of(widget)
if pane is not None and pane.is_collapsed():
fixed[i] = (0 if pane.mode == EDGE
else self._collapsed_extent(pane))
continue
natural = self._along(widget.sizeHint())
current = sizes[i]
remembered = self._drawn(pane.extent) if pane is not None else 0
small = current <= GRIP_PX * 4
if pane is not None and (pane is grow or small or fresh):
want = remembered or natural or current
else:
want = current or remembered or natural
stretch = pane.stretch if pane is not None else 1
wraps = self._height_for_width(widget)
if pane is not None and pane is grow and remembered:
rigid[i] = remembered
elif stretch <= 0 and wraps and (refit or fresh or small):
rigid[i] = max(wraps, self._along(widget.minimumSizeHint()))
elif stretch <= 0:
rigid[i] = want
else:
elastic[i] = max(1, want)
if total <= 0:
total = (sum(fixed.values()) + sum(rigid.values())
+ sum(elastic.values()))
if absorber is not None:
others = (sum(v for j, v in fixed.items() if j != absorber)
+ sum(rigid.values()))
fixed[absorber] = max(fixed[absorber], total - others)
spare = total - sum(fixed.values()) - sum(rigid.values())
if spare < 0 and rigid:
scale = max(0.0, (total - sum(fixed.values()))
/ float(sum(rigid.values()) + sum(elastic.values())))
rigid = {i: int(v * scale) for i, v in rigid.items()}
elastic = {i: max(1, int(v * scale)) for i, v in elastic.items()}
spare = sum(elastic.values())
new = list(sizes)
for i, v in fixed.items():
new[i] = int(v)
for i, v in rigid.items():
new[i] = int(v)
weight = float(sum(elastic.values())) or 1.0
for i, v in elastic.items():
new[i] = max(0, int(max(0, spare) * v / weight))
for i in range(count):
if i not in visible:
new[i] = sizes[i]
_set_actual_sizes(self, new)
return [self._base(v) for v in new]
def _on_moved(self, _pos: int, _index: int) -> None:
"""The user dragged a handle: collapse at the limit, remember sizes.
Never rebalances. A drag is the user placing the handle, and moving
anything they did not move while they hold it is the fight item 471
rules out. While a handle is held the sizes are written once it is
let go or rests, not on every move.
"""
sizes = self.sizes()
for i, size in enumerate(sizes):
widget = self.widget(i)
pane = self._pane_of(widget)
if pane is None or widget.isHidden():
continue
if pane.mode == EDGE:
if size == 0 and not pane.edge_collapsed:
self._set_edge(pane, True, by_user=True, from_drag=True)
elif size > 0 and pane.edge_collapsed:
self._set_edge(pane, False, by_user=True, from_drag=True)
elif (pane.mode == HEADER and size == 0
and not pane.is_collapsed()):
pane.folder.set_shut(True, by_user=True)
continue
if not pane.is_collapsed() and size > 0:
pane.extent = size
if self._held:
self._extents_timer.start()
else:
self._save_extents()
[docs]
def extents(self) -> dict:
"""``{pane name: size to open at}`` for every registered pane."""
return {p.name: p.extent for p in self._panes if p.extent > 0}
def _flush_extents(self) -> None:
"""Write the dragged sizes now when a write is waiting.
Called when a handle is let go and when the splitter is hidden, so a
window closed in the middle of a drag keeps the sizes.
"""
timer = getattr(self, "_extents_timer", None)
if _alive(timer) and timer.isActive():
self._save_extents()
def _save_extents(self) -> None:
"""Store the dragged sizes under this splitter's key."""
self._extents_timer.stop()
if self._persist_key:
set_pane_extents(self._persist_key, self.extents())
[docs]
class FocusCollapse(QObject):
"""Collapse the surroundings while a focus widget is on screen.
Requested: "when a live preview opens, it takes the whole screen height:
the console, the System container and the button section below System
auto-collapse, and the settings collapse to the left", and the same
whenever figures appear.
Focus widgets are watched (:meth:`watch`); while any of them is shown the
target panes (:meth:`target`) are collapsed, and when the last is hidden
the targets this collapsed are opened again. A target that was already
collapsed -- by the user, or remembered from last session -- is left
exactly as it is, both ways.
Changes made by hand are kept. A target the user opens by hand (clicking
its heading or its handle, or dragging it open) is pinned: nothing here
collapses it again until the view ends. A target the user collapses by hand
is theirs too, and is not re-opened when the focus goes.
A view is one visit to one screen: it begins when the screen is shown
(:meth:`begin_view`, from its ``showEvent``) and ends when it is hidden
because the user went to another screen (:meth:`end_view`, from its
``hideEvent``). Minimising the window does not end it -- the owner passes
only non-spontaneous events. Pins last for the view: returning to the
screen with its live preview still open collapses the surroundings again,
which is what a fresh arrival at that layout would get.
:param parent: owner, normally the screen.
"""
def __init__(self, parent=None):
"""Watch nothing and collapse nothing until told what to."""
super().__init__(parent)
self._triggers: list = []
self._targets: list = []
self._splitters: list = []
self._pinned: set = set()
self._auto: set = set()
self._active = False
[docs]
def watch(self, widget: QWidget) -> None:
"""Treat ``widget`` being shown as a reason to collapse the targets.
Only its hidden flag is read -- it is never shown, measured or
built, so a lazily-built panel stays unbuilt until its owner shows
it.
:param widget: the focus widget.
"""
if widget is None or widget in self._triggers:
return
self._triggers.append(widget)
_watch_object_events(widget, self, _VISIBILITY_KINDS)
self.refresh()
[docs]
def target(self, splitter: CollapsibleSplitter, name: str) -> None:
"""Collapse the pane ``name`` of ``splitter`` while a focus is shown.
:param splitter: the splitter holding the pane.
:param name: the pane's name.
"""
if splitter is None or splitter.pane(name) is None:
return
if (splitter, name) in self._targets:
return
self._targets.append((splitter, str(name)))
if splitter not in self._splitters:
self._splitters.append(splitter)
splitter.pane_toggled.connect(
partial(self._on_toggled, splitter))
if self._active:
self._collapse_one(splitter, str(name))
@staticmethod
def _key(splitter, name) -> tuple:
"""How a target is recorded."""
return (id(splitter), str(name))
[docs]
def is_pinned(self, splitter, name) -> bool:
"""Whether the user opened ``name`` by hand during this view.
:param splitter: the splitter holding the pane.
:param name: the pane's name.
"""
return self._key(splitter, name) in self._pinned
def _on_toggled(self, splitter, name, collapsed, by_user) -> None:
"""Hear the user's own folds, which outrank this class's.
Opening a target is a pin only while a focus widget is shown: that is
the user overruling the layout this class made. Opening the console
before any preview exists overrules nothing, and must not stop the
preview, opened later, from taking the height.
"""
if not by_user:
return
key = self._key(splitter, name)
self._auto.discard(key)
if collapsed:
self._pinned.discard(key)
elif self.is_active():
self._pinned.add(key)
[docs]
def is_active(self) -> bool:
"""Whether any focus widget is shown."""
live = []
shown = False
for widget in self._triggers:
if not _alive(widget):
continue
live.append(widget)
if not widget.isHidden():
shown = True
self._triggers = live
return shown
[docs]
def eventFilter(self, watched, event) -> bool: # noqa: N802
"""A focus widget was shown or hidden.
:param watched: the focus widget.
:param event: the event; only show and hide are read.
:returns: False, so the event goes on as usual.
"""
if event.type() in (QEvent.ShowToParent, QEvent.HideToParent):
self.refresh()
return False
[docs]
def refresh(self, *, force: bool = False) -> bool:
"""Collapse or restore the targets for what is shown now.
Acts on a CHANGE (or when ``force``), so a figure arriving while a
preview is already open does not re-collapse what the user opened.
:returns: whether a focus widget is shown.
"""
now = self.is_active()
if now and (force or not self._active):
for splitter, name in list(self._targets):
self._collapse_one(splitter, name)
elif not now and (force or self._active):
self._release()
self._active = now
return now
def _collapse_one(self, splitter, name) -> None:
"""Collapse one target unless the user pinned it or already did."""
key = self._key(splitter, name)
if key in self._pinned or not _alive(splitter):
return
if splitter.is_collapsed(name):
return
if splitter.set_collapsed(name, True, by_user=False):
self._auto.add(key)
def _release(self) -> None:
"""Open what this class collapsed and the user has not touched."""
for splitter, name in list(self._targets):
key = self._key(splitter, name)
if key not in self._auto:
continue
if _alive(splitter) and splitter.is_collapsed(name):
splitter.set_collapsed(name, False, by_user=False)
self._auto.clear()
[docs]
def begin_view(self) -> None:
"""The screen came on: forget last view's pins and apply the rule."""
self._pinned.clear()
self.refresh(force=True)
[docs]
def end_view(self) -> None:
"""The user left the screen: the pins of this view are cleared."""
self._pinned.clear()
[docs]
def splitter_of(widget) -> Optional[CollapsibleSplitter]:
"""The CollapsibleSplitter ``widget`` sits directly in, if any.
:param widget: any widget.
"""
parent = widget.parentWidget() if _alive(widget) else None
return parent if isinstance(parent, CollapsibleSplitter) else None
def _splitters_under(root) -> list:
"""Every CollapsibleSplitter in ``root``, ``root`` included.
:param root: a widget, usually a module screen or the main window.
"""
if not _alive(root):
return []
found = list(root.findChildren(CollapsibleSplitter))
if isinstance(root, CollapsibleSplitter):
found.insert(0, root)
return [split for split in found if _alive(split)]
def _reset_layout_under(root) -> int:
"""Put every collapsible container in ``root`` back to its defaults.
Behind Help > Window > "Reset this module's layout": a container that
was collapsed and left somewhere it could not be reached comes back, and
nothing remembered for it outlives the reset.
:param root: the module screen.
:returns: how many panes were reset.
"""
total = 0
for split in _splitters_under(root):
try:
total += split._reset_layout()
except RuntimeError:
continue
return total
def _rect_in(item, window):
"""``item``'s rectangle in ``window`` coordinates."""
from PySide6.QtCore import QPoint, QRect
return QRect(item.mapTo(window, QPoint()), item.size())
def _clip_in(widget, window):
"""The part of ``widget`` that every ancestor up to ``window`` shows.
:returns: a ``QRect`` in ``window`` coordinates.
"""
clip = _rect_in(widget, window)
ancestor = widget.parentWidget()
while ancestor is not None and ancestor is not window:
clip = clip.intersected(_rect_in(ancestor, window))
ancestor = ancestor.parentWidget()
return clip.intersected(window.rect())
def _handles_of(split, index: int) -> list:
"""The shown handles on either side of the child at ``index``.
The one before it, and the one before the next child that is shown.
"""
out = []
candidates = [index]
for at in range(index + 1, split.count()):
if not split.widget(at).isHidden():
candidates.append(at)
break
for at in candidates:
if 0 < at < split.count():
handle = split.handle(at)
if _alive(handle) and handle.isVisible():
out.append(handle)
return out
def _inside_a_shut_pane(widget) -> bool:
"""Whether ``widget`` sits inside a collapsed EDGE pane.
A splitter in a column folded away to its strip is not on screen; it is
judged when its column is open.
"""
child = widget
parent = widget.parentWidget()
while _alive(parent):
if isinstance(parent, CollapsibleSplitter):
pane = parent._pane_of(child)
if pane is not None and pane.mode == EDGE and pane.is_collapsed():
return True
child, parent = parent, parent.parentWidget()
return False
def _collapsed_header_faults(root) -> list:
"""Every collapsed container in ``root`` that a user could not get back.
The check shared by the tests and the distribution smoke's layout pass.
A collapsed HEADER pane is a fault when its heading is hidden, shorter
than one line of its own font, cut by its pane or any ancestor, or when
no handle beside it can be grabbed. A collapsed EDGE pane is a fault
when the handle that is its strip is hidden or has no area. Panes their
owner hides, and splitters not on screen, are not judged. Only the pane
and its splitter clip the heading here: a heading a scroll area has
scrolled away can be scrolled to, which the smoke's reachability pass
judges on its own.
:param root: the widget to search, usually the main window.
:returns: one ``dict`` per fault, with ``splitter`` (its key), ``pane``,
``problem`` and the measured numbers.
"""
faults = []
if not _alive(root):
return faults
for split in _splitters_under(root):
if (not split.isVisible() or split.width() <= 0
or split.height() <= 0 or _inside_a_shut_pane(split)):
continue
key = split._persist_key or split.objectName() or type(split).__name__
shown = split._visible_indices()
for pane in split.panes():
widget = pane.widget
if (not _alive(widget) or not pane.is_collapsed()
or not widget.isVisible()):
continue
index = split.indexOf(widget)
row = {"splitter": key, "pane": pane.name, "mode": pane.mode}
handles = _handles_of(split, index)
grabbable = [h for h in handles
if h.width() > 0 and h.height() > 0
and not _clip_in(h, split).isEmpty()]
if pane.mode == EDGE:
if not grabbable:
faults.append(dict(row, problem="no strip to click"))
continue
heading = getattr(pane.folder, "heading", None)
if not _alive(heading) or not heading.isVisible():
faults.append(dict(row, problem="heading hidden"))
continue
line = heading.fontMetrics().height()
clip = _clip_in(heading, split)
along = split._along(widget.size())
need = split._collapsed_extent(pane)
numbers = {"heading": heading.height(), "line": line,
"visible": clip.height(), "size": along,
"need": need}
if heading.height() < line:
faults.append(dict(row, **numbers,
problem="heading shorter than its text"))
elif clip.height() < heading.height() or clip.width() <= 0:
faults.append(dict(row, **numbers, problem="heading cut"))
elif along < need:
faults.append(dict(row, **numbers,
problem="pane below its heading"))
if len(shown) > 1 and not grabbable:
faults.append(dict(row, **numbers,
problem="no handle to drag"))
return faults
def _probe_collapsed_headers(root, settle: Callable[[], None]) -> list:
"""Collapse every open container in ``root``, judge them, open them again.
The active half of :func:`_collapsed_header_faults`, for a pass that
must not depend on what the user happened to leave collapsed. The
panes with a heading are collapsed and judged first, then the columns
that fold to a strip, so a heading inside such a column is judged while
it is on screen. Every
fold is made on the user's behalf, so nothing is remembered, and every
pane is put back as it was.
:param root: the module screen.
:param settle: runs the event loop until the layout has caught up.
:returns: the faults found while everything was collapsed.
"""
faults = []
for mode in (HEADER, EDGE):
folded = []
for split in _splitters_under(root):
if not split.isVisible():
continue
for pane in split.panes():
if (pane.mode != mode or pane.is_collapsed()
or not _alive(pane.widget)
or pane.widget.isHidden()):
continue
if split.set_collapsed(pane.name, True, by_user=False):
folded.append((split, pane.name))
settle()
try:
faults += _collapsed_header_faults(root)
finally:
for split, name in reversed(folded):
if _alive(split):
split.set_collapsed(name, False, by_user=False)
settle()
return faults
__all__ = [
"CollapsibleSplitter", "EDGE", "FocusCollapse", "FoldSection", "HEADER",
"Pane", "fold_card", "get_pane_extents", "lock_folded_to_bottom",
"set_pane_extents", "splitter_of",
]