refactor(ui): MVC split pagination widget

This commit is contained in:
Travis Abendshien
2026-09-11 23:22:44 -07:00
parent 45236f2c12
commit aef07affa5
4 changed files with 331 additions and 330 deletions
+1 -1
View File
@@ -41,9 +41,9 @@ from tagstudio.i18n.platform_strings import trash_term
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.banner import Banner
from tagstudio.qt.controllers.inspector import Inspector
from tagstudio.qt.controllers.pagination import Pagination
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
+210
View File
@@ -0,0 +1,210 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
from PySide6.QtCore import QEvent, QObject, Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
from tagstudio.qt.views.pagination_view import PaginationView
from tagstudio.qt.views.styles.stylesheets import pagination_style
class Pagination(QWidget):
HEIGHT = 36
_RIGHT_MARGIN = 6
_SCROLLBAR_WIDTH = 14
index = Signal(int)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.page_count: int = 0
self.current_page_index: int = 0
self.setHidden(True)
self.setObjectName("pagination")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
self.setFixedHeight(self.HEIGHT)
self.setStyleSheet(pagination_style())
self.view = PaginationView()
self.setLayout(self.view)
self._connect_callbacks()
if parent is not None:
parent.installEventFilter(self)
self._sync_geometry()
def _connect_callbacks(self) -> None:
self.view.current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.view.current_page_field.text()) - 1)
)
def update_buttons(self, page_count: int, index: int, emit: bool = True):
if index < 0:
raise ValueError("Negative index detected")
view = self.view
for i in range(0, 10):
if button := self._get_button_at(view.start_buffer_layout, i):
button.setHidden(True)
if button := self._get_button_at(view.end_buffer_layout, i):
button.setHidden(True)
end_page = page_count - 1
# Hide everything if there are only one or less pages.
if page_count <= 1:
self.setHidden(True)
# Enable/Disable Next + Prev Buttons
elif page_count > 1:
if index == 0:
view.prev_button.setDisabled(True)
else:
self._assign_click(view.prev_button, index - 1)
view.prev_button.setDisabled(False)
if index == end_page:
view.next_button.setDisabled(True)
else:
self._assign_click(view.next_button, index + 1)
view.next_button.setDisabled(False)
# Set Ellipses Sizes
if 8 <= page_count <= 11:
end_scale = max(1, page_count - index - 6)
start_scale = max(1, index - 5)
elif page_count > 11:
end_scale = max(1, 7 - index)
start_scale = max(1, (7 - (end_page - index)))
else:
end_scale, start_scale = 1, 1
if page_count >= 8:
end_size = view.BUTTON_SIZE.width() * end_scale + (3 * (end_scale - 1))
start_size = view.BUTTON_SIZE.width() * start_scale + (3 * (start_scale - 1))
view.end_ellipses.setMinimumWidth(end_size)
view.end_ellipses.setMaximumWidth(end_size)
view.start_ellipses.setMinimumWidth(start_size)
view.start_ellipses.setMaximumWidth(start_size)
# Enable/Disable Ellipses
if index <= view.BUFFER_PAGE_COUNT + 1:
view.start_ellipses.setHidden(True)
else:
view.start_ellipses.setHidden(False)
self._assign_click(view.start_button, 0)
if index >= (page_count - view.BUFFER_PAGE_COUNT - 2):
view.end_ellipses.setHidden(True)
else:
view.end_ellipses.setHidden(False)
# Hide/Unhide Start + End Buttons
if index != 0:
view.start_button.setText("1")
self._assign_click(view.start_button, 0)
view.start_button.setHidden(False)
else:
view.start_button.setHidden(True)
if index != page_count - 1:
view.end_button.setText(str(page_count))
self._assign_click(view.end_button, page_count - 1)
view.end_button.setHidden(False)
else:
view.end_button.setHidden(True)
if index == 0 or index == 1:
view.start_buffer_container.setHidden(True)
else:
view.start_buffer_container.setHidden(False)
if index == page_count - 1 or index == page_count - 2:
view.end_buffer_container.setHidden(True)
else:
view.end_buffer_container.setHidden(False)
# Current Field and Buffer Pages
for i in range(0, page_count):
# Set Field
if i == index:
if button := self._get_button_at(view.start_buffer_layout, i):
button.setHidden(True)
if button := self._get_button_at(view.end_buffer_layout, i):
button.setHidden(True)
view.current_page_field.setText(str(i + 1))
start_offset = max(0, (index - 4) - 4)
end_offset = min(page_count - 1, (index + 4) - 4)
if i < index:
if (i != 0) and i >= index - 4:
if button := self._get_button_at(
view.start_buffer_layout, i - start_offset
):
button.setHidden(False)
button.setText(str(i + 1))
self._assign_click(button, i)
else:
if button := self._get_button_at(view.start_buffer_layout, i):
button.setHidden(True)
if button := self._get_button_at(view.end_buffer_layout, i):
button.setHidden(True)
elif i > index:
if i != page_count - 1 and i <= index + 4:
if button := self._get_button_at(view.end_buffer_layout, i - end_offset):
button.setHidden(False)
button.setText(str(i + 1))
self._assign_click(button, i)
else:
if button := self._get_button_at(view.end_buffer_layout, i):
button.setHidden(True)
for j in range(0, view.BUFFER_PAGE_COUNT):
if button := self._get_button_at(
view.end_buffer_layout, i - end_offset + j
):
button.setHidden(True)
if button := self._get_button_at(view.start_buffer_layout, i - 1):
button.setHidden(True)
self.setHidden(False)
self._sync_geometry()
self.raise_()
view.validator.setTop(page_count)
if emit:
self.index.emit(index)
self.current_page_index = index
self.page_count = page_count
def _goto_page(self, index: int):
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButton, index: int):
with catch_warnings(record=True):
button.clicked.disconnect()
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
@staticmethod
def _get_button_at(layout: QHBoxLayout, index: int) -> QPushButton | None:
"""Safely return a button in a layout at a given index, if it exists."""
item = layout.itemAt(index)
widget = item.widget() if item else None
return widget if isinstance(widget, QPushButton) else None
@override
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.parentWidget() and event.type() == QEvent.Type.Resize:
self._sync_geometry()
return super().eventFilter(watched, event)
def _sync_geometry(self):
parent = self.parentWidget()
if parent is None:
return
# Stays out of the way of the entry view scrollbar
right_inset = max(self._RIGHT_MARGIN, self._SCROLLBAR_WIDTH)
width = max(0, parent.width() - right_inset)
self.setGeometry(0, parent.height() - self.HEIGHT, width, self.HEIGHT)
-329
View File
@@ -1,329 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportOptionalMemberAccess=false
from typing import cast, override
from warnings import catch_warnings
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, QObject, QSize, Qt, Signal
from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import pagination_style
# TODO: Split to use MVC guidelines.
class Pagination(QWidget):
"""Widget containing controls for navigating between pages of items."""
HEIGHT = 36 # Button row height (24) plus 6px above and below.
RIGHT_MARGIN = 6
SCROLLBAR_WIDTH = 14
index = Signal(int)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)
self.rm = ResourceManager()
self.page_count: int = 0
self.current_page_index: int = 0
self.buffer_page_count: int = 4
self.button_size = QSize(32, 24)
# ------------ UI EXAMPLE --------------
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
# ^^^^ <-- 2 Buffer Pages
# Center Page Number is Editable Text
# --------------------------------------
# [----------- ROOT LAYOUT ------------]
self.setHidden(True)
self.setObjectName("pagination")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
self.setFixedHeight(self.HEIGHT)
self.setStyleSheet(pagination_style())
self.root_layout = QHBoxLayout(self)
self.root_layout.setContentsMargins(0, 6, 0, 6)
self.root_layout.setSpacing(3)
if parent is not None:
parent.installEventFilter(self)
self._sync_geometry()
# [<] ----------------------------------
self.prev_button = QPushButton()
prev_icon: Image.Image = self.rm.bxs_left_arrow
prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
self.prev_button.setIconSize(QSize(12, 12))
self.prev_button.setMinimumSize(self.button_size)
self.prev_button.setMaximumSize(self.button_size)
# --- [1] ------------------------------
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
# ------ ... ---------------------------
self.start_ellipses = QLabel()
self.start_ellipses.setMinimumSize(self.button_size)
self.start_ellipses.setMaximumSize(self.button_size)
# self.start_ellipses.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.start_ellipses.setText(". . .")
# --------- [3][4] ---------------------
self.start_buffer_container = QWidget()
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.start_buffer_layout.setSpacing(3)
# ---------------- [5] -----------------
self.current_page_field = QLineEdit()
self.current_page_field.setMinimumSize(self.button_size)
self.current_page_field.setMaximumSize(self.button_size)
self.validator = Validator(1, self.page_count)
self.current_page_field.setValidator(self.validator)
self.current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.current_page_field.text()) - 1)
)
# -------------------- [6][7] ----------
self.end_buffer_container = QWidget()
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.end_buffer_layout.setSpacing(3)
# -------------------------- ... -------
self.end_ellipses = QLabel()
self.end_ellipses.setMinimumSize(self.button_size)
self.end_ellipses.setMaximumSize(self.button_size)
# self.end_ellipses.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.end_ellipses.setText(". . .")
# ----------------------------- [42] ---
self.end_button = QPushButton()
self.end_button.setMinimumSize(self.button_size)
self.end_button.setMaximumSize(self.button_size)
# ---------------------------------- [>]
self.next_button = QPushButton()
next_icon: Image.Image = self.rm.bxs_right_arrow
next_icon = auto_theme_overlay(next_icon, use_alpha=False)
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
self.next_button.setIconSize(QSize(12, 12))
self.next_button.setMinimumSize(self.button_size)
self.next_button.setMaximumSize(self.button_size)
# Add Widgets to Root Layout
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.prev_button)
self.root_layout.addWidget(self.start_button)
self.root_layout.addWidget(self.start_ellipses)
self.root_layout.addWidget(self.start_buffer_container)
self.root_layout.addWidget(self.current_page_field)
self.root_layout.addWidget(self.end_buffer_container)
self.root_layout.addWidget(self.end_ellipses)
self.root_layout.addWidget(self.end_button)
self.root_layout.addWidget(self.next_button)
self.root_layout.addStretch(1)
self._populate_buffer_buttons()
def update_buttons(self, page_count: int, index: int, emit: bool = True):
# Guard
if index < 0:
raise ValueError("Negative index detected")
for i in range(0, 10):
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
end_page = page_count - 1
if page_count <= 1:
# Hide everything if there are only one or less pages.
# [-------------- HIDDEN --------------]
self.setHidden(True)
elif page_count > 1:
# Enable/Disable Next+Prev Buttons
if index == 0:
self.prev_button.setDisabled(True)
else:
self._assign_click(self.prev_button, index - 1)
self.prev_button.setDisabled(False)
if index == end_page:
self.next_button.setDisabled(True)
else:
self._assign_click(self.next_button, index + 1)
self.next_button.setDisabled(False)
# Set Ellipses Sizes
if 8 <= page_count <= 11:
end_scale = max(1, page_count - index - 6)
srt_scale = max(1, index - 5)
elif page_count > 11:
end_scale = max(1, 7 - index)
srt_scale = max(1, (7 - (end_page - index)))
if page_count >= 8:
end_size = self.button_size.width() * end_scale + (3 * (end_scale - 1)) # pyright: ignore[reportPossiblyUnboundVariable]
srt_size = self.button_size.width() * srt_scale + (3 * (srt_scale - 1)) # pyright: ignore[reportPossiblyUnboundVariable]
self.end_ellipses.setMinimumWidth(end_size)
self.end_ellipses.setMaximumWidth(end_size)
self.start_ellipses.setMinimumWidth(srt_size)
self.start_ellipses.setMaximumWidth(srt_size)
# Enable/Disable Ellipses
if index <= self.buffer_page_count + 1:
self.start_ellipses.setHidden(True)
else:
self.start_ellipses.setHidden(False)
self._assign_click(self.start_button, 0)
if index >= (page_count - self.buffer_page_count - 2):
self.end_ellipses.setHidden(True)
else:
self.end_ellipses.setHidden(False)
# Hide/Unhide Start+End Buttons
if index != 0:
self.start_button.setText("1")
self._assign_click(self.start_button, 0)
self.start_button.setHidden(False)
else:
self.start_button.setHidden(True)
if index != page_count - 1:
self.end_button.setText(str(page_count))
self._assign_click(self.end_button, page_count - 1)
self.end_button.setHidden(False)
else:
self.end_button.setHidden(True)
if index == 0 or index == 1:
self.start_buffer_container.setHidden(True)
else:
self.start_buffer_container.setHidden(False)
if index == page_count - 1 or index == page_count - 2:
self.end_buffer_container.setHidden(True)
else:
self.end_buffer_container.setHidden(False)
# Current Field and Buffer Pages
sbc = 0
for i in range(0, page_count):
# Set Field
if i == index:
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
sbc += 1
self.current_page_field.setText(str(i + 1))
start_offset = max(0, (index - 4) - 4)
end_offset = min(page_count - 1, (index + 4) - 4)
if i < index:
if (i != 0) and i >= index - 4:
self.start_buffer_layout.itemAt(i - start_offset).widget().setHidden(False)
self.start_buffer_layout.itemAt(i - start_offset).widget().setText( # pyright: ignore[reportAttributeAccessIssue]
str(i + 1)
)
self._assign_click(
cast(
QPushButton,
self.start_buffer_layout.itemAt(i - start_offset).widget(),
),
i,
)
sbc += 1
else:
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
elif i > index:
if i != page_count - 1 and i <= index + 4:
self.end_buffer_layout.itemAt(i - end_offset).widget().setHidden(False)
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
self._assign_click(
cast(
QPushButton,
self.end_buffer_layout.itemAt(i - end_offset).widget(),
),
i,
)
else:
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
for j in range(0, self.buffer_page_count):
if self.end_buffer_layout.itemAt(i - end_offset + j):
self.end_buffer_layout.itemAt(
i - end_offset + j
).widget().setHidden(True)
if self.start_buffer_layout.itemAt(i - 1):
self.start_buffer_layout.itemAt(i - 1).widget().setHidden(True)
self.setHidden(False)
self._sync_geometry()
self.raise_()
self.validator.setTop(page_count)
if emit:
self.index.emit(index)
self.current_page_index = index
self.page_count = page_count
def _goto_page(self, index: int):
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButton, index: int):
with catch_warnings(record=True):
button.clicked.disconnect()
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
def _populate_buffer_buttons(self):
for _ in range(max(self.buffer_page_count * 2, 5)):
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
self.start_buffer_layout.addWidget(button)
end_button = QPushButton()
end_button.setMinimumSize(self.button_size)
end_button.setMaximumSize(self.button_size)
end_button.setHidden(True)
self.end_buffer_layout.addWidget(end_button)
@override
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.parentWidget() and event.type() == QEvent.Type.Resize:
self._sync_geometry()
return super().eventFilter(watched, event)
def _sync_geometry(self):
parent = self.parentWidget()
if parent is None:
return
# Stays out of the way of the entry view scrollbar
right_inset = max(self.RIGHT_MARGIN, self.SCROLLBAR_WIDTH)
width = max(0, parent.width() - right_inset)
self.setGeometry(0, parent.height() - self.HEIGHT, width, self.HEIGHT)
class Validator(QIntValidator):
def __init__(self, bottom: int, top: int) -> None:
super().__init__(bottom, top)
@override
def fixup(self, input: str) -> str:
input = input.strip("0")
return super().fixup(str(self.top()) if input else "1")
+120
View File
@@ -0,0 +1,120 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PIL import Image, ImageQt
from PySide6.QtCore import QSize
from PySide6.QtGui import QIntValidator, QPixmap, Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
class PageValidator(QIntValidator):
@override
def fixup(self, input: str) -> str:
input = input.strip("0")
return super().fixup(str(self.top()) if input else "1")
class PaginationView(QHBoxLayout):
# NOTE: UI Example:
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
# ^^^^ <-- 2 Buffer Pages
# Center Page Number is Editable Text
BUTTON_SIZE = QSize(32, 24)
BUFFER_PAGE_COUNT = 4
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 6, 0, 6)
self.setSpacing(3)
_rm = ResourceManager()
# [<] ----------------------------------
self.prev_button = QPushButton()
prev_icon: Image.Image = auto_theme_overlay(_rm.bxs_left_arrow, use_alpha=False)
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
self.prev_button.setIconSize(QSize(12, 12))
self.prev_button.setMinimumSize(self.BUTTON_SIZE)
self.prev_button.setMaximumSize(self.BUTTON_SIZE)
# --- [1] ------------------------------
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.BUTTON_SIZE)
self.start_button.setMaximumSize(self.BUTTON_SIZE)
# ------ ... ---------------------------
self.start_ellipses = QLabel(". . .")
self.start_ellipses.setMinimumSize(self.BUTTON_SIZE)
self.start_ellipses.setMaximumSize(self.BUTTON_SIZE)
# --------- [3][4] ---------------------
self.start_buffer_container = QWidget()
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.start_buffer_layout.setSpacing(3)
# ---------------- [5] -----------------
self.current_page_field = QLineEdit()
self.current_page_field.setMinimumSize(self.BUTTON_SIZE)
self.current_page_field.setMaximumSize(self.BUTTON_SIZE)
self.current_page_field.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.validator = PageValidator(1, 0)
self.current_page_field.setValidator(self.validator)
# -------------------- [6][7] ----------
self.end_buffer_container = QWidget()
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.end_buffer_layout.setSpacing(3)
# -------------------------- ... -------
self.end_ellipses = QLabel(". . .")
self.end_ellipses.setMinimumSize(self.BUTTON_SIZE)
self.end_ellipses.setMaximumSize(self.BUTTON_SIZE)
# ----------------------------- [42] ---
self.end_button = QPushButton()
self.end_button.setMinimumSize(self.BUTTON_SIZE)
self.end_button.setMaximumSize(self.BUTTON_SIZE)
# ---------------------------------- [>]
self.next_button = QPushButton()
next_icon: Image.Image = auto_theme_overlay(_rm.bxs_right_arrow, use_alpha=False)
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
self.next_button.setIconSize(QSize(12, 12))
self.next_button.setMinimumSize(self.BUTTON_SIZE)
self.next_button.setMaximumSize(self.BUTTON_SIZE)
# Finalize Layout
self.addStretch(1)
self.addWidget(self.prev_button)
self.addWidget(self.start_button)
self.addWidget(self.start_ellipses)
self.addWidget(self.start_buffer_container)
self.addWidget(self.current_page_field)
self.addWidget(self.end_buffer_container)
self.addWidget(self.end_ellipses)
self.addWidget(self.end_button)
self.addWidget(self.next_button)
self.addStretch(1)
self._populate_buffer_buttons()
def _populate_buffer_buttons(self) -> None:
for _ in range(max(self.BUFFER_PAGE_COUNT * 2, 5)):
button = QPushButton()
button.setMinimumSize(self.BUTTON_SIZE)
button.setMaximumSize(self.BUTTON_SIZE)
button.setHidden(True)
self.start_buffer_layout.addWidget(button)
end_button = QPushButton()
end_button.setMinimumSize(self.BUTTON_SIZE)
end_button.setMaximumSize(self.BUTTON_SIZE)
end_button.setHidden(True)
self.end_buffer_layout.addWidget(end_button)