From a46145a5b61e5a4be40f516d1ea8dd8fde61a8a2 Mon Sep 17 00:00:00 2001 From: Dixing Xu Date: Fri, 6 Nov 2026 06:42:06 +0000 Subject: [PATCH] perf(ui): build info popovers on first click -- tag editor 41% faster EditTagPanelView built both info popovers (ParentTagsInfoView and CategoriesInfoView) on every panel construction, although they stay hidden until the user clicks an info button. InfoButton now also accepts a function that returns the popover layout and builds the popover on the first click. Passing a layout keeps working. Building the edit tag panel for a new tag drops from 36.35 ms to 21.41 ms (median of 60). The popover content and the panel layout do not change. Co-Authored-By: Aiden --- src/tagstudio/qt/controllers/info_button.py | 63 ++++++++++++------- src/tagstudio/qt/views/edit_tag_panel_view.py | 7 ++- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/tagstudio/qt/controllers/info_button.py b/src/tagstudio/qt/controllers/info_button.py index f63f2caab..b3fbc1ac8 100644 --- a/src/tagstudio/qt/controllers/info_button.py +++ b/src/tagstudio/qt/controllers/info_button.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only +from collections.abc import Callable from typing import override from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QVariantAnimation @@ -52,7 +53,16 @@ class InfoButton(QAbstractButton): _SHADOW_ALPHA = 160 _WINDOW_MARGIN = 12 - def __init__(self, content: QLayout, title: str, popover_width: int = 360) -> None: + def __init__( + self, + content: QLayout | Callable[[], QLayout], + title: str, + popover_width: int = 360, + ) -> None: + """Create the button. + + `content` may be a layout factory, in which case the popover is only built on first open. + """ super().__init__() self.setToolTip(Translations.format("info.tooltip.about", title=title)) self.setAccessibleName(self.toolTip()) @@ -60,9 +70,25 @@ def __init__(self, content: QLayout, title: str, popover_width: int = 360) -> No self.setCursor(Qt.CursorShape.PointingHandCursor) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._content = content + self._popover_width = popover_width + self._popover: QFrame | None = None + + self._is_open = False + self._update_icon(hovered=False) + self._fade = QVariantAnimation(self) + self._fade.setDuration(self._FADE_DURATION) + self._fade.valueChanged.connect(self._set_opacity) + self._fade.finished.connect(self._on_fade_finished) + + self.clicked.connect(self._toggle_popover) + + def _build_popover(self) -> QFrame: + """Build the hidden popover, deferred until it is first shown.""" + content = self._content if isinstance(self._content, QLayout) else self._content() self._popover = QFrame(self) self._popover.setObjectName("info_popover") - self._popover.setFixedWidth(popover_width) + self._popover.setFixedWidth(self._popover_width) self._popover.setStyleSheet(info_popover_style()) self._popover.setLayout(content) self._popover.hide() @@ -72,29 +98,23 @@ def __init__(self, content: QLayout, title: str, popover_width: int = 360) -> No self._fade_effect.setOffset(0, 3) self._fade_effect.setColor(QColor(0, 0, 0, self._SHADOW_ALPHA)) self._popover.setGraphicsEffect(self._fade_effect) - - self._is_open = False - self._update_icon(hovered=False) - self._fade = QVariantAnimation(self) - self._fade.setDuration(self._FADE_DURATION) - self._fade.valueChanged.connect(self._set_opacity) - self._fade.finished.connect(self._on_fade_finished) - - self.clicked.connect(self._toggle_popover) + return self._popover def _toggle_popover(self) -> None: if self._is_open: self._hide_popover() return + popover = self._popover or self._build_popover() + # Overlay the popover inside the current window, below the button. # If there's no room below, flip it above. window = self.window() - if self._popover.parent() is not window: - self._popover.setParent(window) - width = self._popover.width() - height = self._popover.heightForWidth(width) - self._popover.resize(width, height) + if popover.parent() is not window: + popover.setParent(window) + width = popover.width() + height = popover.heightForWidth(width) + popover.resize(width, height) pos = self.mapTo(window, QPoint(self.width() - width, self.height() + self._BUTTON_GAP)) if pos.y() + height > window.height() - self._WINDOW_MARGIN: pos.setY(self.mapTo(window, QPoint(0, 0)).y() - height - self._BUTTON_GAP) @@ -103,9 +123,9 @@ def _toggle_popover(self) -> None: ) pos.setY(max(self._WINDOW_MARGIN, pos.y())) - self._popover.move(pos) - self._popover.raise_() - self._popover.show() + popover.move(pos) + popover.raise_() + popover.show() self._is_open = True self._update_icon(self.underMouse()) self._fade_to(1.0) @@ -131,7 +151,7 @@ def _set_opacity(self, opacity: float) -> None: def _on_fade_finished(self) -> None: if not self._is_open: - self._popover.hide() + unwrap(self._popover).hide() def _update_icon(self, hovered: bool) -> None: rm = ResourceManager() @@ -163,7 +183,8 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: if isinstance(event, QMouseEvent) and event.type() == QEvent.Type.MouseButtonPress: click_pos = event.globalPosition().toPoint() button_rect = QRect(self.mapToGlobal(QPoint(0, 0)), self.size()) - popover_rect = QRect(self._popover.mapToGlobal(QPoint(0, 0)), self._popover.size()) + popover = unwrap(self._popover) + popover_rect = QRect(popover.mapToGlobal(QPoint(0, 0)), popover.size()) # Clicks outside the popover or the ⓘ button will close it if not button_rect.contains(click_pos) and not popover_rect.contains(click_pos): self._hide_popover() diff --git a/src/tagstudio/qt/views/edit_tag_panel_view.py b/src/tagstudio/qt/views/edit_tag_panel_view.py index c54cd09df..06a3cf0d3 100644 --- a/src/tagstudio/qt/views/edit_tag_panel_view.py +++ b/src/tagstudio/qt/views/edit_tag_panel_view.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from collections.abc import Callable from PySide6.QtCore import Qt from PySide6.QtWidgets import ( @@ -74,7 +75,7 @@ def __init__(self) -> None: parent_tags_layout.setSpacing(0) parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) parent_tags_layout.addWidget( - self._title_row(Translations["tag.parent_tags"], ParentTagsInfoView()) + self._title_row(Translations["tag.parent_tags"], ParentTagsInfoView) ) parent_tags_scroll_contents = QWidget() @@ -94,7 +95,7 @@ def __init__(self) -> None: category_layout.setSpacing(0) category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) category_layout.addWidget( - self._title_row(Translations["tag.categories"], CategoriesInfoView()) + self._title_row(Translations["tag.categories"], CategoriesInfoView) ) category_layout.addWidget(self._subtitle(Translations["tag.categories.subtitle"])) @@ -163,7 +164,7 @@ def _scroll_area(self, contents: QWidget) -> QScrollArea: scroll_area.setWidget(contents) return scroll_area - def _title_row(self, title: str, info: QLayout) -> QWidget: + def _title_row(self, title: str, info: Callable[[], QLayout]) -> QWidget: widget = QWidget() layout = QHBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0)