"""The left navigation dock: an icon, a name, and a category heading.
A row is a button with an icon and its name, always both. The only thing the
pointer changes is the colour, and the explanation goes to the strip along
the bottom of the window rather than into a popup.
WHAT WAS REMOVED, AND WHY EACH ONE WAS THE BUG. The dock this replaces was
1,116 lines across ``Sidebar`` and ``_DockRow`` in ``spacr.qt.app``, and
nearly all of it was machinery that existed to defeat itself:
* a translucent slab painted in ``paintEvent`` — the "black box" of four
separate commits, which turned out to be the dock painting itself rather
than any stylesheet;
* a per-row icon-size model (``resting_icon_px``, ``_place_icon``,
``_set_icon_px``, ``_forget_icon_sizes``, ``_rest_every_icon``) that grew
and shrank icons under the pointer, which is what made hovering relayout
the column and blink;
* the name painted only while hovered, so a resting dock was a column of
unlabelled glyphs;
* a second, indented level of folded modules with its own expand state
(``_fold_children``, ``_open_hosts``), which is the "sub categories".
None of that is here. A row is a button with an icon and its name, always
both. The only thing the pointer changes is the colour.
WHERE THE EXPLANATION WENT. Not into a popup tooltip — those are explicitly
unwanted — but into the strip along the bottom of the window, which already
exists as :mod:`spacr.qt.widgets.module_hint_bar` and already holds the last
hovered module for thirty seconds with its API and tutorial links. This dock
only says which module is under the pointer, via :attr:`Dock.module_hovered`;
the bar decides how to explain it.
WHAT IS KEPT, BECAUSE SOMETHING ELSE READS IT. Categories still collapse —
the list is longer than a short screen. Rows still carry ``navKey`` and
headers are still ``SidebarSection``, because the theme, the tutorial script
and the maturity tests all reach the dock through those names. And
:meth:`refresh_visibility` still applies the Alpha/Beta maturity filter and
hides a heading whose modules are all filtered out, which is a separate
reason for a row to be absent from its section being shut.
NOTHING HERE IMPORTS :mod:`spacr.qt.app`. The registry lives there and would
be a circular import, so the rows, the icon lookup and the maturity
predicate are all injected.
"""
from __future__ import annotations
from typing import Callable, Dict, Iterable, List, Optional, Tuple
from functools import partial
from PySide6.QtCore import QEvent, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import QFrame, QLabel, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
from ..i18n import tr
from ..theme import active_palette
from .eliding import ElidingPushButton
#: A dock row as the registry hands it over: key, name, description, section.
Row = Tuple[str, str, str, str]
#: The key of the row that goes Home. Never filtered out.
HOME_KEY = "__home__"
#: Icon edge, in unscaled pixels. Big enough to read as a picture rather
#: than the bullet the dock used to draw, and the same in every state.
ICON_PX = 20
#: Space between the dock's edge and its rounded panel, in pixels.
#:
#: WITHOUT IT THE CORNERS ARE NOT VISIBLE. A rounded rectangle flush against
#: the window edge has its curve cut off by the edge it is flush with, which
#: is the same shape as no rounding at all.
PANEL_INSET = 6
#: The panel's corner radius. HomePanelBox's number, because the request was
#: for the dock to look like that box and not merely to be rounded.
PANEL_RADIUS = 8
[docs]
class DockRow(ElidingPushButton):
"""One module: its icon, then its name, both always drawn.
The row paints nothing of its own — the colour comes from the
stylesheet :class:`Dock` installs, so there is one place that decides
what hover looks like and no ``paintEvent`` to disagree with it.
"""
hovered = Signal(str, bool) #: key, and whether the pointer entered
def __init__(self, key: str, name: str, desc: str = "", parent=None):
"""Build one module row.
:param key: the module's registry key. Stamped onto the row three
times over -- as ``navKey``, as ``moduleAppKey`` and as the
attribute -- because three different readers ask for it: the
icon refresh, the bottom hint strip's filter, and this module.
:param name: the module's name, drawn beside the icon and set as
the accessible name so a screen reader still gets the whole of
it when the column elides it.
:param desc: the one-line summary. Not drawn here at all: it is
stamped as ``moduleSummarySource`` for the strip along the
bottom of the window, which is where descriptions go.
:param parent: parent widget.
"""
super().__init__(name.replace("&", "&&"), parent)
self.key = key
self.desc = desc
self.setObjectName("SidebarItem")
self.setProperty("moduleNameSource", name)
self.setProperty("moduleSummarySource", desc)
self.setProperty("navKey", key)
self.setProperty("moduleAppKey", key)
self.setProperty("moduleTooltipStyle", "sidebar")
self.setAccessibleName(name)
self.setAccessibleDescription(desc)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setSizePolicy(QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Fixed)
self.setToolTip("")
self._hovered = False
[docs]
def is_hovered(self) -> bool:
"""Whether the pointer is currently on this row."""
return self._hovered
[docs]
def enterEvent(self, event): # noqa: N802 - Qt naming
"""Light the row as the pointer arrives.
:param event: the Qt enter event.
"""
self._hovered = True
self.hovered.emit(self.key, True)
super().enterEvent(event)
[docs]
def leaveEvent(self, event): # noqa: N802 - Qt naming
"""Drop the highlight as the pointer leaves.
:param event: the Qt leave event.
"""
self._hovered = False
self.hovered.emit(self.key, False)
super().leaveEvent(event)
[docs]
class Dock(QWidget):
"""The navigation column: categories, each holding icon+name rows.
:param rows: the modules to draw, in order, as ``(key, name, desc,
section)``. Grouping IS ordering: a new heading starts whenever the
section changes, so a row out of place draws its heading twice.
:param icon_for: optional ``key -> QIcon | None`` for the row icons.
:param is_visible: optional ``key -> bool`` maturity predicate. Injected
rather than imported so this module does not depend on
:mod:`spacr.qt.app`, which is what defines the registry.
:param parent: parent widget; ownership only.
"""
nav_selected = Signal(str) #: a row was clicked
module_hovered = Signal(str) #: a row is under the pointer
#: The column starts at ``WIDTH_MIN`` and widens, up to ``WIDTH_MAX``,
#: if the longest name needs it. Both scale with the font.
WIDTH_MIN = 220
WIDTH_MAX = 320
#: How far the user may drag the column's edge (item 529), before the
#: font scale. Narrower than ``WIDTH_MIN`` is allowed: a long name elides
#: rather than pushing the page.
DRAG_MIN = 150
DRAG_MAX = 520
def __init__(self, rows: Iterable[Row],
icon_for: Optional[Callable[[str], object]] = None,
is_visible: Optional[Callable[[str], bool]] = None,
parent=None):
"""Build the dock column: section headings with their module rows under them.
The container is made transparent rather than coloured. The application
sheet carries a blanket ``QWidget { background-color: bg }``, so any
untagged container paints an opaque rectangle -- and a plain widget
holding a rounded panel is exactly that, a square of window colour
behind rounded corners. Colouring it only changes which colour the
rectangle is.
The rounded panel is a child frame rather than this widget's own
background, which satisfies both constraints at once: the container
stays transparent where the theme pins it transparent, and the panel is
still drawn.
:param rows: the module rows to list, in order.
:param icon_for: called with a key for that module's icon.
:param is_visible: called with a key to decide whether to list it.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setObjectName("Dock")
from ..theme import make_transparent
make_transparent(self)
self._icon_for = icon_for
self._is_visible = is_visible
self._rows: List[DockRow] = []
self._headers: Dict[str, SectionHeader] = {}
self._section_rows: Dict[str, List[DockRow]] = {}
self._section_of: Dict[str, str] = {}
self._open: set = set()
self._items = self._rows
self._section_headers = self._headers
outer = QVBoxLayout(self)
outer.setContentsMargins(PANEL_INSET, PANEL_INSET,
PANEL_INSET, PANEL_INSET)
outer.setSpacing(0)
self._panel = QFrame(self)
self._panel.setObjectName("DockPanel")
outer.addWidget(self._panel)
panel_column = QVBoxLayout(self._panel)
panel_column.setContentsMargins(0, 0, 0, 0)
panel_column.setSpacing(0)
title = QLabel("spaCR")
title.setObjectName("SidebarTitle")
panel_column.addWidget(title)
self._scroll = QScrollArea(self)
self._scroll.setObjectName("SidebarScroll")
make_transparent(self._scroll)
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QFrame.Shape.NoFrame)
self._scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._scroll.viewport().setAutoFillBackground(False)
inner = QWidget()
inner.setObjectName("SidebarInner")
column = QVBoxLayout(inner)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(0)
self._scroll.setWidget(inner)
panel_column.addWidget(self._scroll, 1)
current = None
for key, name, desc, section in rows:
if section and section != current:
header = SectionHeader(section)
header.installEventFilter(self)
column.addWidget(header)
self._headers[section] = header
self._section_rows.setdefault(section, [])
if not self._headers or len(self._headers) == 1:
self._open.add(section)
current = section
row = DockRow(key, name, desc)
row.clicked.connect(lambda _checked=False, k=key:
self.nav_selected.emit(k))
row.hovered.connect(self._on_row_hovered)
column.addWidget(row)
self._rows.append(row)
if section:
self._section_rows.setdefault(section, []).append(row)
self._section_of[key] = section or ""
column.addStretch(1)
self.refresh_icons()
self.apply_theme()
self.refresh_visibility()
def _on_row_hovered(self, key: str, entered: bool) -> None:
"""Name the hovered module, and light exactly that one row.
Only ENTERING is reported to the strip. A leave that cleared the bar
would empty it the moment the pointer set off toward the links it
holds, which is the whole reason that bar keeps its last module.
THE INK IS NOT `:hover`, and that is a fix rather than a preference.
The rows were coloured by a `QPushButton#SidebarItem:hover` rule,
which Qt drives from `WA_UnderMouse` -- and that attribute sticks
when the widget under the pointer is replaced without the pointer
moving, which is exactly what clicking a dock row does: the stack
swaps a whole screen in underneath it and no Leave is ever
delivered. Reported: "run compare and run history are
always blue in the dock", and both are rows a user had
opened. Read off the screen recording: Run History accent-coloured
while Database Browser and Report above and below it are white and
the pointer is elsewhere entirely.
So the dock lights the row itself, from one pass over all of them.
At most one can be lit, whatever Qt believes about who is under the
pointer.
"""
self._light_only(key if entered else None)
from ..tooltip_policy import HoverDelay
if not hasattr(self, "_hover_help_delay"):
self._hover_help_delay = HoverDelay(self)
row = next((row for row in self._rows if row.key == key), None)
if entered and row is not None:
stack = getattr(self.window(), "_stack", None)
page = stack.currentWidget() if stack is not None else None
if page is not None and getattr(page, "_hint_bar", None) is not None:
self._hover_help_delay.cancel()
self.module_hovered.emit(key)
else:
self._hover_help_delay.schedule(
row, lambda: self.module_hovered.emit(key))
elif row is not None:
self._hover_help_delay.cancel_for(row)
def _light_only(self, key) -> None:
"""Ink the row named by ``key`` and no other. ``None`` clears all.
Re-polished per row rather than by re-applying the sheet: a
stylesheet reset re-polishes every widget in the dock, and this runs
on every pointer move across the column.
"""
for row in self._rows:
want = (key is not None and getattr(row, "key", None) == key)
if bool(row.property("hovered")) == want:
continue
row.setProperty("hovered", want)
style = row.style()
if style is not None:
style.unpolish(row)
style.polish(row)
[docs]
def leaveEvent(self, event): # noqa: N802 - Qt naming
"""The pointer left the column: no row is lit.
The rows' own Leave covers a pointer stepping between them; this
covers one that leaves the dock altogether, including straight off
the bottom row onto the empty stretch below it, where no other row
will ever be entered.
:param event: the leave event, passed to the base class after every row
is unlit.
"""
self._light_only(None)
super().leaveEvent(event)
[docs]
def eventFilter(self, watched, event): # noqa: N802 - Qt naming
"""Light a heading under the pointer, and toggle it on release.
ON RELEASE, NOT PRESS: a press that toggled would fire while the
pointer was still down, so a drag that began on a heading and ended
elsewhere would still have shut the section.
The hover state is a PROPERTY rather than a colour set from here,
because the stylesheet is the one place that decides what the dock
looks like.
:param watched: the object the event is for; only a
:class:`SectionHeader` is handled here.
:param event: the event; Enter and Leave set the header's ``hovered``
property, and a left-button release toggles its section.
"""
if isinstance(watched, SectionHeader):
kind = event.type()
if kind in (QEvent.Type.Enter, QEvent.Type.Leave):
watched.setProperty("hovered", kind == QEvent.Type.Enter)
watched.style().unpolish(watched)
watched.style().polish(watched)
elif (kind == QEvent.Type.MouseButtonRelease
and event.button() == Qt.MouseButton.LeftButton):
self.toggle_section(watched.section)
return True
return super().eventFilter(watched, event)
[docs]
def hovered_row(self) -> Optional[DockRow]:
"""The row under the pointer, or ``None``."""
for row in self._rows:
if row.is_hovered():
return row
return None
[docs]
def sync_hover(self, entered=None) -> Optional[str]:
"""Report which row the pointer is on.
The old dock needed this to repair hover state it had broken by
relaying out under the pointer. Nothing relayouts now, so this only
answers the question. Kept because ``tools/diagnose_dock.py`` asks.
"""
row = self.hovered_row()
return row.key if row is not None else None
[docs]
def sections(self) -> List[str]:
"""Every category heading, in the order they are drawn."""
return list(self._headers)
[docs]
def rows(self) -> List[DockRow]:
"""Every module row, in the order they are drawn."""
return list(self._rows)
[docs]
def section_is_open(self, section: str) -> bool:
"""Whether ``section``'s rows are currently shown.
:param section: the category name.
"""
return section in self._open
[docs]
def toggle_section(self, section: str) -> bool:
"""Open a closed category or close an open one. Returns the new state.
:param section: the category name to open or close.
"""
if section in self._open:
self._open.discard(section)
else:
self._open.add(section)
self.refresh_visibility()
return section in self._open
[docs]
def expand_host(self, host_key: str) -> None:
"""Accepted and does nothing: there are no folded child rows.
The second level was removed on request. This remains so the callers
that opened a host on navigation do not have to know that, and
because a method that quietly disappeared would fail at the call
site rather than here, where the reason is written down.
:param host_key: the app key of the host; ignored.
"""
return None
[docs]
def host_is_expanded(self, host_key: str) -> bool:
"""Always ``False``: there are no folded child rows to expand.
:param host_key: the app key of the host; ignored.
"""
return False
[docs]
def refresh_visibility(self) -> None:
"""Show a row if its category is open AND maturity allows it.
Two separate reasons for a row to be absent, and they are kept
separate: a shut section hides rows that are perfectly mature, and
the Alpha/Beta filter hides rows inside an open one. A heading
stays put whether its section is open or shut — it is what you
click to open it — and hides only when every module beneath it is
filtered out.
"""
allowed = self._is_visible or (lambda _key: True)
populated = set()
for row in self._rows:
mature = row.key == HOME_KEY or bool(allowed(row.key))
section = self._section_of.get(row.key, "")
row.setVisible(mature and (not section or section in self._open))
if mature and section:
populated.add(section)
for section, header in self._headers.items():
header.setVisible(section in populated)
header.setProperty("open", section in self._open)
header.style().unpolish(header)
header.style().polish(header)
self.setFixedWidth(self.column_width())
[docs]
def column_width(self) -> int:
"""The width the column wears: the one the user dragged, else fitting.
A width the user chose is kept through every refresh, every
hide and show, and every session; with none stored the column fits
its longest name, as :meth:`fitting_width` has always done.
"""
try:
from ..preferences import get_dock_width
wanted = get_dock_width()
except Exception: # noqa: BLE001
wanted = 0
if wanted <= 0:
return self.fitting_width()
return self.clamp_width(wanted)
[docs]
def clamp_width(self, width: int) -> int:
"""``width`` held between :attr:`DRAG_MIN` and :attr:`DRAG_MAX`.
:param width: logical pixels.
"""
from ..preferences import scaled_px
return max(scaled_px(self.DRAG_MIN),
min(int(width), scaled_px(self.DRAG_MAX)))
[docs]
def set_column_width(self, width: int, *, remember: bool = True) -> int:
"""Give the column ``width`` within its bounds and maybe remember it.
:param width: logical pixels; 0 or less goes back to the fitting
width and forgets the dragged one.
:param remember: store it for the next session.
:returns: the width applied.
"""
if width is None or int(width) <= 0:
applied = self.fitting_width()
stored = 0
else:
applied = stored = self.clamp_width(width)
self.setFixedWidth(applied)
if remember:
try:
from ..preferences import set_dock_width
set_dock_width(stored)
except Exception: # noqa: BLE001
pass
return applied
[docs]
def refresh_icons(self) -> None:
"""Re-ask the provider for every row's icon.
A QIcon bakes its pixmap when it is built, so re-applying the
stylesheet does not recolour icons that already exist.
THE SIZE IS FIXED PER SCALE, NOT PER ROW STATE. The old dock grew
and shrank an icon on hover, which relayed out the whole column
under the pointer; that is what :data:`ICON_PX` being one number
stops. It still has to follow the interface scale, so the base is
recorded on each row and re-derived from there -- see
:func:`spacr.qt.preferences._set_scaled_icon_size` for why it is
never recomputed from the size the row is already wearing.
"""
from ..preferences import _set_scaled_icon_size
for row in self._rows:
_set_scaled_icon_size(row, ICON_PX)
if self._icon_for is None:
continue
key = getattr(row, "key", None)
if key is None:
continue
icon = self._icon_for(key)
if icon is not None:
row.setIcon(icon)
[docs]
def apply_theme(self) -> None:
"""Paint the rounded panel, and the one rule hover uses.
THE PANEL IS THE ONLY THING THAT PAINTS. Everything inside it is
transparent on purpose: a title or a heading carrying a fill of its
own would draw a square corner over the rounded one directly beneath
it, which is the exact shape this was asked to stop being.
The three values come from HomePanelBox rather than being chosen
again here -- ``pane_surface('surface_alt')``, ``border_soft`` and an
8 px radius -- so the dock and that box stay the same material when
either is restyled.
"""
from ..theme import pane_surface
palette = active_palette()
accent = palette["accent"]
self.setStyleSheet(
"QFrame#DockPanel {"
f" background: {pane_surface('surface_alt')};"
f" border: 1px solid {palette['border_soft']};"
f" border-radius: {PANEL_RADIUS}px;"
"}"
"QScrollArea#SidebarScroll, QWidget#SidebarInner {"
" background: transparent; border: none;"
"}"
"QLabel#SidebarTitle { background: transparent; }"
"QPushButton#SidebarItem {"
" background: transparent; border: none; text-align: left;"
" padding: 6px 10px;"
"}"
f'QPushButton#SidebarItem[hovered="true"] {{ color: {accent}; }}'
"QLabel#SidebarSection {"
" padding: 10px 10px 4px 10px; font-weight: 600;"
" background: transparent;"
"}"
f"QLabel#SidebarSection:hover {{ color: {accent}; }}"
)
[docs]
def row_height(self) -> int:
"""The height of a row, or 0 if the dock is empty."""
return self._rows[0].sizeHint().height() if self._rows else 0
[docs]
def fitting_width(self) -> int:
"""Width that shows the longest visible name in full, within bounds.
Font scale moves both bounds; the widest visible row moves the
result between them. Public because the locked dock re-applies it
after being re-parented out of the drawer, which had resized it.
"""
from PySide6.QtGui import QFontMetrics
from ..preferences import scaled_px
widest = 0
for row in self._rows:
if row.isHidden():
continue
metrics = QFontMetrics(row.font())
text = getattr(row, "full_text", lambda: row.text())()
widest = max(widest, metrics.horizontalAdvance(str(text)))
room = widest + scaled_px(ICON_PX) + scaled_px(40)
return max(scaled_px(self.WIDTH_MIN),
min(room, scaled_px(self.WIDTH_MAX)))
[docs]
def clipped_items(self) -> list:
"""Rows whose name had to be shortened to fit.
Empty in a healthy layout, and a test asserts that.
"""
return [r for r in self._rows if r.is_elided()]
[docs]
class DockEdge(QWidget):
"""The strip along the dock's right edge that drags its width.
Dragging it sets the width of the dock while the dock is shown. A
sibling of the dock's slot rather than a
splitter handle, because the dock is a fixed-width layout member and
everything that measures it (the drawer, the backdrop, the fitting width)
reads that fixed width. Dragging sets it; releasing stores it; a
double-click forgets it and the column fits its names again. It is
shown and hidden with the dock, so a hidden dock has no edge to catch.
:param dock: the :class:`Dock` it resizes.
:param parent: parent widget; ownership only.
"""
#: The grab area, in pixels: matches the arrow-bearing splitter handle.
GRIP_PX = 12
collapsedChanged = Signal(bool)
def __init__(self, dock: "Dock", parent=None):
"""Build the edge for ``dock``; hidden until the dock is shown."""
super().__init__(parent)
self._dock = dock
self._collapsed = False
self._dragged = False
self._pressed_x = None
self._start_width = 0
self.setObjectName("DockEdge")
self.setStyleSheet(
"QWidget#DockEdge { background: transparent; border: none; }")
self.setAttribute(Qt.WA_Hover, True)
self.setFixedWidth(self.GRIP_PX)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setCursor(Qt.SizeHorCursor)
self.setFocusPolicy(Qt.StrongFocus)
self.setAccessibleName(tr("Dock width"))
self.retranslate_dynamic_content()
self.hide()
[docs]
def retranslate_dynamic_content(self, language=None) -> None:
"""Explain the current collapse action and the resize gesture."""
action = (tr("Click to show {name} again.", language, name=tr("Dock"))
if self._collapsed else
tr("Click to hide {name}.", language, name=tr("Dock")))
self.setToolTip(action + " " + tr(
"Drag to make the dock wider or narrower. "
"Double-click to fit it to the names again.", language))
self.setAccessibleDescription(self.toolTip())
[docs]
def is_collapsed(self) -> bool:
"""Whether the dock slot is collapsed; the handle stays visible."""
return self._collapsed
[docs]
def set_collapsed(self, collapsed: bool) -> None:
"""Hide or restore the slot without changing its dock's saved width.
:param collapsed: true hides the slot; false restores its contents.
"""
collapsed = bool(collapsed)
if collapsed == self._collapsed:
return
self._collapsed = collapsed
self.retranslate_dynamic_content()
self.update()
self.collapsedChanged.emit(collapsed)
if self._pressed_x is None:
from .ambient import field_ripple_for_widget
QTimer.singleShot(0, partial(
field_ripple_for_widget, self if collapsed else self._dock,
edge="right"))
[docs]
def keyPressEvent(self, event) -> None: # noqa: N802
"""Let a keyboard user activate the same collapse control.
:param event: key press; Space, Return and Enter toggle the dock.
"""
if event.key() in (Qt.Key_Space, Qt.Key_Return, Qt.Key_Enter):
self.set_collapsed(not self._collapsed)
event.accept()
return
super().keyPressEvent(event)
[docs]
def enterEvent(self, event) -> None: # noqa: N802
"""Light the line up under the pointer.
:param event: the event.
"""
self.update()
super().enterEvent(event)
[docs]
def leaveEvent(self, event) -> None: # noqa: N802
"""Put the line back.
:param event: the event.
"""
self.update()
super().leaveEvent(event)
[docs]
def mousePressEvent(self, event) -> None: # noqa: N802
"""Begin a drag from the dock's present width.
:param event: the event.
"""
if event.button() != Qt.LeftButton:
super().mousePressEvent(event)
return
self._dragged = False
self._pressed_x = event.globalPosition().x()
self._start_width = self._dock.width()
self.update()
event.accept()
[docs]
def mouseMoveEvent(self, event) -> None: # noqa: N802
"""Follow the pointer, within the dock's bounds; stored on release.
:param event: the event.
"""
if self._pressed_x is None:
super().mouseMoveEvent(event)
return
moved = event.globalPosition().x() - self._pressed_x
if abs(moved) > 4:
self._dragged = True
self.set_collapsed(False)
if not self._dragged:
return
self._dock.set_column_width(self._dragged_to(moved),
remember=False)
event.accept()
[docs]
def mouseReleaseEvent(self, event) -> None: # noqa: N802
"""End the drag and remember the width it left.
:param event: the event.
"""
if self._pressed_x is None:
super().mouseReleaseEvent(event)
return
moved = event.globalPosition().x() - self._pressed_x
self._pressed_x = None
if self._dragged:
self._dock.set_column_width(self._dragged_to(moved))
from .ambient import field_ripple_for_widget
field_ripple_for_widget(self._dock)
else:
self.set_collapsed(not self._collapsed)
self.update()
event.accept()
def _dragged_to(self, moved: float) -> int:
"""The width a drag of ``moved`` pixels asks for, never below 1.
Never 0 or less: to :meth:`Dock.set_column_width` that means
"forget the dragged width", and a drag past the left bound is the
user asking for the narrowest dock, not the fitting one.
"""
return max(1, int(round(self._start_width + moved)))
[docs]
def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
"""Forget the dragged width; the column fits its names again.
:param event: the event.
"""
self._pressed_x = None
self.set_collapsed(False)
self._dock.set_column_width(0)
self.update()
event.accept()
[docs]
def paintEvent(self, _event) -> None: # noqa: N802
"""Draw the one-pixel line a splitter handle draws.
:param _event: the paint event; the whole strip is drawn.
"""
palette = active_palette() or {}
hovered = self.underMouse() or self._pressed_x is not None
line = QColor(palette.get("accent" if hovered else "border_soft",
"#4c8dff" if hovered else "#3a3f4b"))
painter = QPainter(self)
rect = self.rect()
painter.fillRect(rect.center().x(), rect.top(), 1, rect.height(), line)
from .collapse_arrow import paint_collapse_arrow
paint_collapse_arrow(painter, rect, Qt.Horizontal,
not self._collapsed, palette,
hovered or self.hasFocus())
painter.end()