From b12ddd7cc88b75ee48f06c431ca05a882236f330 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:17:24 -0700 Subject: [PATCH 01/14] ui: tweak UI, full height inspector, floating pagination bar --- src/tagstudio/qt/controllers/main_window.py | 72 +++++++++---------- src/tagstudio/qt/mixed/pagination.py | 39 ++++++++-- src/tagstudio/qt/qt_driver.py | 64 ++--------------- src/tagstudio/qt/views/inspector_view.py | 2 +- .../qt/views/layouts/thumb_grid_layout.py | 11 ++- src/tagstudio/qt/views/preview_thumb_view.py | 17 +++-- src/tagstudio/qt/views/styles/stylesheets.py | 14 ++++ src/tagstudio/resources/translations/en.json | 10 --- 8 files changed, 107 insertions(+), 122 deletions(-) diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index dd75ee33ef..86c5fd5db1 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -31,7 +31,6 @@ QSizePolicy, QSpacerItem, QSplitter, - QStatusBar, QVBoxLayout, QWidget, ) @@ -479,6 +478,7 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None: # initialized in setup_extra_input_bar self.extra_input_layout: QHBoxLayout + self.results_label: QLabel self.sorting_mode_combobox: QComboBox self.sorting_direction_combobox: QComboBox self.thumb_size_combobox: QComboBox @@ -486,6 +486,8 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None: # initialized in setup_content self.content_layout: QHBoxLayout self.content_splitter: QSplitter + self.central_content: QWidget + self.central_content_layout: QVBoxLayout # initialized in setup_entry_list self.entry_list_container: QWidget @@ -503,14 +505,12 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None: if not self.objectName(): self.setObjectName("MainWindow") - self.resize(1316, 740) + self.resize(1280, 720) self.setup_menu_bar() self.setup_central_widget(driver) - self.setup_status_bar() - QMetaObject.connectSlotsByName(self) # NOTE: These are old attempts to allow for a translucent/acrylic @@ -540,6 +540,7 @@ def setup_central_widget(self, driver: QtDriver): self.central_widget.setObjectName("central_widget") self.central_layout = QGridLayout(self.central_widget) self.central_layout.setObjectName("central_layout") + self.central_layout.setContentsMargins(0, 9, 0, 0) self.setup_search_bar() self.setup_extra_input_bar() @@ -551,6 +552,7 @@ def setup_search_bar(self): self.search_bar_layout = QHBoxLayout() self.search_bar_layout.setObjectName("search_bar_layout") self.search_bar_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) + self.search_bar_layout.setContentsMargins(9, 0, 0, 0) self.back_button = QPushButton(self.central_widget) back_icon: Image.Image = self.rm.bxs_left_arrow @@ -588,12 +590,19 @@ def setup_search_bar(self): self.search_button.setMinimumSize(QSize(0, 32)) self.search_bar_layout.addWidget(self.search_button) - self.central_layout.addLayout(self.search_bar_layout, 3, 0, 1, 1) - def setup_extra_input_bar(self): """Sets up inputs for sorting settings and thumbnail size.""" self.extra_input_layout = QHBoxLayout() self.extra_input_layout.setObjectName("extra_input_layout") + self.extra_input_layout.setContentsMargins(9, 0, 0, 0) + + self.results_label = QLabel("") + self.results_label.setObjectName("results_label") + self.extra_input_layout.addWidget(self.results_label) + + self.extra_input_layout.addItem( + QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + ) ## Show hidden entries checkbox self.show_hidden_entries_widget = QWidget() @@ -614,11 +623,6 @@ def setup_extra_input_bar(self): self.extra_input_layout.addWidget(self.show_hidden_entries_widget) - ## Spacer - self.extra_input_layout.addItem( - QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - ) - ## Sorting Mode Dropdown self.sorting_mode_combobox = QComboBox(self.central_widget) self.sorting_mode_combobox.setObjectName("sorting_mode_combobox") @@ -655,28 +659,39 @@ def setup_extra_input_bar(self): self.thumb_size_combobox.addItem(size[0], size[1]) self.thumb_size_combobox.setCurrentIndex(2) # Default: Medium - self.central_layout.addLayout(self.extra_input_layout, 5, 0, 1, 1) - def setup_content(self, driver: QtDriver): self.content_layout = QHBoxLayout() self.content_layout.setObjectName("content_layout") + self.content_layout.setContentsMargins(0, 0, 0, 0) self.content_splitter = QSplitter() self.content_splitter.setObjectName("content_splitter") self.content_splitter.setHandleWidth(12) + self.central_content = QWidget() + self.central_content.setObjectName("central_content") + self.central_content_layout = QVBoxLayout(self.central_content) + self.central_content_layout.setObjectName("central_content_layout") + self.central_content_layout.setContentsMargins(0, 0, 0, 0) + self.central_content_layout.setSpacing(6) + self.central_content_layout.addLayout(self.search_bar_layout) + self.central_content_layout.addLayout(self.extra_input_layout) + self.setup_entry_list(driver) + self.content_splitter.addWidget(self.central_content) + self.setup_preview_panel(driver) self.content_splitter.setStretchFactor(0, 1) self.content_layout.addWidget(self.content_splitter) - self.central_layout.addLayout(self.content_layout, 10, 0, 1, 1) + self.central_layout.addLayout(self.content_layout, 0, 0, 1, 1) def setup_entry_list(self, driver: QtDriver): self.entry_list_container = QWidget() self.entry_list_layout = QVBoxLayout(self.entry_list_container) self.entry_list_layout.setSpacing(0) + self.entry_list_layout.setContentsMargins(9, 0, 0, 0) self.entry_scroll_area = QScrollArea() self.entry_scroll_area.setObjectName("entry_scroll_area") @@ -691,8 +706,10 @@ def setup_entry_list(self, driver: QtDriver): self.thumb_grid = QWidget() self.thumb_grid.setObjectName("thumb_grid") - self.thumb_layout = ThumbGridLayout(driver, self.entry_scroll_area) - self.thumb_layout.setSpacing(min(self.thumb_size // 10, 12)) + # Padding so floating pagination bar doesn't block bottom of scroll contents + self.thumb_layout = ThumbGridLayout( + driver, self.entry_scroll_area, bottom_padding=Pagination.HEIGHT + ) self.thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.thumb_grid.setLayout(self.thumb_layout) self.entry_scroll_area.setWidget(self.thumb_grid) @@ -701,34 +718,15 @@ def setup_entry_list(self, driver: QtDriver): self.entry_list_layout.addWidget(self.banners) self.entry_list_layout.addWidget(self.entry_scroll_area) - self.landing_widget = LandingWidget(driver, self.devicePixelRatio()) self.entry_list_layout.addWidget(self.landing_widget) - - self.pagination = Pagination() - self.entry_list_layout.addWidget(self.pagination) - - self.content_splitter.addWidget(self.entry_list_container) + self.pagination = Pagination(self.entry_list_container) + self.central_content_layout.addWidget(self.entry_list_container) def setup_preview_panel(self, driver: QtDriver): self.preview_panel = Inspector(driver) self.content_splitter.addWidget(self.preview_panel) - def setup_status_bar(self): - # BUG: Clicking the status bar does not count as losing focus on other widgets - # (for example, the "Add Tag" line edit). Can this be fixed? - self.status_bar = QStatusBar(self) - self.status_bar.setObjectName("status_bar") - status_bar_size_policy = QSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum - ) - status_bar_size_policy.setHorizontalStretch(0) - status_bar_size_policy.setVerticalStretch(0) - status_bar_size_policy.setHeightForWidth(self.status_bar.sizePolicy().hasHeightForWidth()) - self.status_bar.setSizePolicy(status_bar_size_policy) - self.status_bar.setSizeGripEnabled(False) - self.setStatusBar(self.status_bar) - # endregion def toggle_landing_page(self, enabled: bool): diff --git a/src/tagstudio/qt/mixed/pagination.py b/src/tagstudio/qt/mixed/pagination.py index 78cf7316d3..d1a26bcd15 100644 --- a/src/tagstudio/qt/mixed/pagination.py +++ b/src/tagstudio/qt/mixed/pagination.py @@ -7,18 +7,23 @@ from warnings import catch_warnings from PIL import Image, ImageQt -from PySide6.QtCore import QSize, Signal +from PySide6.QtCore import QEvent, QObject, QSize, Qt, Signal from PySide6.QtGui import QIntValidator, QPixmap -from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget +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: @@ -37,11 +42,18 @@ def __init__(self, parent: QWidget | None = None) -> None: # [----------- 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.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) - self.root_layout.setContentsMargins(0, 6, 0, 0) + 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 @@ -60,6 +72,7 @@ def __init__(self, parent: QWidget | None = None) -> None: 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] --------------------- @@ -88,6 +101,7 @@ def __init__(self, parent: QWidget | None = None) -> None: 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] --- @@ -258,6 +272,8 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True): 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: @@ -287,6 +303,21 @@ def _populate_buffer_buttons(self): 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: diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index df9bd5db0d..d4004eb177 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -670,7 +670,6 @@ def on_visible_changed(entry_id: int | None): self.shutdown() def show_error_message(self, error_name: str, error_desc: str | None = None): - self.main_window.status_bar.showMessage(error_name, Qt.AlignmentFlag.AlignLeft) self.main_window.landing_widget.set_status_label(error_name) self.main_window.setWindowTitle(f"{self.base_title} - {error_name}") @@ -706,7 +705,7 @@ def _update_browsing_state(): .with_show_hidden_entries(self.main_window.show_hidden_entries) ) except ParsingError as e: - self.main_window.status_bar.showMessage( + self.main_window.results_label.setText( f"{Translations['status.results.invalid_syntax']} " f'"{self.main_window.search_field.text()}"' ) @@ -813,9 +812,6 @@ def close_library(self, is_shutdown: bool = False): if (sync_banner := self._sync_banner) is not None: sync_banner.hide_banner(force=True) - self.main_window.status_bar.showMessage(Translations["status.library_closing"]) - start_time = time.time() - if not is_shutdown: self.cached_values.remove(AppCacheItems.LAST_LIBRARY) self.cached_values.sync() @@ -823,6 +819,7 @@ def close_library(self, is_shutdown: bool = False): # Reset library state self.main_window.preview_panel.set_selection(self.selected) self.main_window.search_field.setText("") + self.main_window.results_label.setText("") scrollbar: QScrollArea = self.main_window.entry_scroll_area scrollbar.verticalScrollBar().setValue(0) self.__reset_navigation() @@ -876,26 +873,9 @@ def close_library(self, is_shutdown: bool = False): if self.main_window.menu_bar.add_tag_to_selected_action: self.main_window.menu_bar.add_tag_to_selected_action.setEnabled(False) - end_time = time.time() - self.main_window.status_bar.showMessage( - Translations.format( - "status.library_closed", time_span=format_timespan(end_time - start_time) - ) - ) - def backup_library(self): logger.info("Backing Up Library...") - self.main_window.status_bar.showMessage(Translations["status.library_backup_in_progress"]) - start_time = time.time() - target_path = Library.save_library_backup_to_disk(unwrap(self.lib.library_dir)) - end_time = time.time() - self.main_window.status_bar.showMessage( - Translations.format( - "status.library_backup_success", - path=target_path, - time_span=format_timespan(end_time - start_time), - ) - ) + Library.save_library_backup_to_disk(unwrap(self.lib.library_dir)) def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True): """Emit any connected signals for updating badge icons.""" @@ -983,7 +963,6 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = """ entry: Entry | None = None pending: list[tuple[int | None, Path]] = [] - deleted_count: int = 0 selected = self.selected library_dir = unwrap(self.lib.library_dir) @@ -1007,37 +986,17 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = return_code == QMessageBox.ButtonRole.DestructiveRole.value and return_code != QMessageBox.ButtonRole.ActionRole.value ): - for i, tup in enumerate(pending): - e_id, f = tup + for e_id, f in pending: if (origin_path == f) or (not origin_path): self.main_window.preview_panel.stop_media_playback() - msg = Translations.format( - "status.deleting_file", i=i, count=len(pending), path=f - ) - self.main_window.status_bar.showMessage(msg) - self.main_window.status_bar.repaint() - if e_id is not None: self.lib.remove_entries([e_id]) - if delete_file(library_dir / f): - deleted_count += 1 + delete_file(library_dir / f) self.clear_select_action_callback() self.update_browsing_state() - if deleted_count > 0 and deleted_count != len(pending): - msg = Translations.format("status.deleted_partial_warning", count=deleted_count) - else: - index = min(deleted_count, 2) - msg = ( - Translations["status.deleted_none"], - Translations["status.deleted_file_singular"], - Translations.format("status.deleted_file_plural", count=deleted_count), - )[index] - self.main_window.status_bar.showMessage(msg) - self.main_window.status_bar.repaint() - def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int: """A confirmation dialogue box for deleting files. @@ -1343,9 +1302,6 @@ def sorting_mode_callback(self): def thumb_size_callback(self, size: int): """Perform actions needed when the thumbnail size selection is changed.""" - spacing_divisor: int = 10 - min_spacing: int = 12 - self.update_thumbs() blank_icon: QIcon = QIcon() for it in self.main_window.thumb_layout._item_thumbs: @@ -1355,9 +1311,6 @@ def thumb_size_callback(self, size: int): it.setFixedSize(self.main_window.thumb_size, self.main_window.thumb_size) it.thumb_button.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size) it.set_filename_visibility(it.show_filename_label) - self.main_window.thumb_layout.setSpacing( - min(self.main_window.thumb_size // spacing_divisor, min_spacing) - ) def show_hidden_entries_callback(self): logger.info("Show Hidden Entries Changed", exclude=self.main_window.show_hidden_entries) @@ -1625,10 +1578,6 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: self.main_window.search_field.setText(self.browsing_history.current.query or "") - # inform user about running search - self.main_window.status_bar.showMessage(Translations["status.library_search_query"]) - self.main_window.status_bar.repaint() - # search the library start_time = time.time() Ignore.get_patterns(self.lib.library_dir, include_global=True) @@ -1637,7 +1586,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: end_time = time.time() # inform user about completed search - self.main_window.status_bar.showMessage( + self.main_window.results_label.setText( Translations.format( "status.results_found", count=results.total_count, @@ -1790,7 +1739,6 @@ def open_library(self, path: Path) -> None: ) message = Translations.format("splash.opening_library", library_path=library_dir_display) self.main_window.landing_widget.set_status_label(message) - self.main_window.status_bar.showMessage(message, 3) self.main_window.repaint() if self.lib.library_dir: diff --git a/src/tagstudio/qt/views/inspector_view.py b/src/tagstudio/qt/views/inspector_view.py index 3195d0e262..de30b38187 100644 --- a/src/tagstudio/qt/views/inspector_view.py +++ b/src/tagstudio/qt/views/inspector_view.py @@ -30,7 +30,7 @@ class InspectorView(QVBoxLayout): def __init__(self, driver: QtDriver, pixel_ratio: float) -> None: super().__init__() - self.setContentsMargins(0, 0, 0, 0) + self.setContentsMargins(0, 0, 9, 9) self.setSpacing(6) rm = ResourceManager() diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py index dc1f69191e..9754bdacce 100644 --- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py +++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py @@ -24,13 +24,18 @@ class ThumbGridLayout(QLayout): + SPACING = 9 + # Id of first visible entry visible_changed = Signal(int) - def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None: + def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: int = 0) -> None: super().__init__(None) + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(self.SPACING) self.driver: QtDriver = driver self.scroll_area: QScrollArea = scroll_area + self._bottom_padding: int = bottom_padding self._item_thumbs: list[ItemThumb] = [] self._items: list[QLayoutItem] = [] @@ -183,8 +188,8 @@ def heightForWidth(self, arg__1: int) -> int: width = arg__1 per_row, _, height_offset = self._size(width) if per_row == 0: - return height_offset - return math.ceil(len(self._entry_ids) / per_row) * height_offset + return height_offset + self._bottom_padding + return math.ceil(len(self._entry_ids) / per_row) * height_offset + self._bottom_padding @override def setGeometry(self, arg__1: QRect) -> None: diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index 13f6c9c323..5fdb5f3b4f 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -47,7 +47,6 @@ class PreviewThumbView(QWidget): check_ffmpeg = Signal(bool) stats_updated = Signal(Path, FileAttributeData) - __img_button_size: tuple[int, int] __image_ratio: float _current_file: Path | None @@ -58,7 +57,7 @@ def __init__(self, library: Library, driver: QtDriver) -> None: super().__init__() self._driver = driver - self.__img_button_size = (266, 266) + self._preview_size: tuple[int, int] = (272, 272) self.__image_ratio = 1.0 self.__should_render_on_resize = False @@ -79,7 +78,7 @@ def __init__(self, library: Library, driver: QtDriver) -> None: delete_action.triggered.connect(self._delete_action_callback) self.__button_wrapper = QPushButton() - self.__button_wrapper.setMinimumSize(*self.__img_button_size) + self.__button_wrapper.setMinimumSize(*self._preview_size) self.__button_wrapper.setFlat(True) self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.__button_wrapper.addAction(open_file_action) @@ -93,7 +92,7 @@ def __init__(self, library: Library, driver: QtDriver) -> None: self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper) self.__preview_gif = QLabel() - self.__preview_gif.setMinimumSize(*self.__img_button_size) + self.__preview_gif.setMinimumSize(*self._preview_size) self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor) self.__preview_gif.addAction(open_file_action) @@ -129,7 +128,7 @@ def __init__(self, library: Library, driver: QtDriver) -> None: self.__image_layout.addWidget(self.__preview_gif_page) self.__image_layout.addWidget(self.__media_player_page) - self.setMinimumSize(*self.__img_button_size) + self.setMinimumSize(*self._preview_size) self.hide_preview() @@ -194,7 +193,7 @@ def __update_image_size(self, size: tuple[int, int]) -> None: adj_size = QSize(int(adj_width), int(adj_height)) - self.__img_button_size = (int(adj_width), int(adj_height)) + self._preview_size = (int(adj_width), int(adj_height)) self.__button_wrapper.setMaximumSize(adj_size) self.__button_wrapper.setIconSize(adj_size) self.__preview_gif.setMaximumSize(adj_size) @@ -243,8 +242,8 @@ def __render_thumb(self, filepath: Path) -> None: self.__should_render_on_resize = True self.__rendered_res = ( - math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR), - math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), + math.ceil(self._preview_size[0] * THUMB_SIZE_FACTOR), + math.ceil(self._preview_size[1] * THUMB_SIZE_FACTOR), ) # TODO: Make driver update the cache manager reference here instead of passing the driver. @@ -343,7 +342,7 @@ def resizeEvent(self, event: QResizeEvent) -> None: if ( self._current_file is not None and self.__should_render_on_resize - and self.__rendered_res < self.__img_button_size + and self.__rendered_res < self._preview_size ): self.__render_thumb(self._current_file) diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py index e41e96069a..7bd512503d 100644 --- a/src/tagstudio/qt/views/styles/stylesheets.py +++ b/src/tagstudio/qt/views/styles/stylesheets.py @@ -726,6 +726,20 @@ def banner_progress_style() -> str: """ +def pagination_style() -> str: + """Style for the pagination bar.""" + bg = QColor(banner_progress_bg_color()) + bg.setAlpha(200) + border = "rgba(200, 200, 200, 30)" + + return f""" + QWidget#pagination {{ + background-color: rgba{bg.toTuple()}; + border-top: 1px solid {border}; + }} + """ + + def banner_progress_chunk_color() -> QColor: """Fill color for the banner's custom-painted progress bar chunk.""" is_dark = _is_dark_theme() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 45d352b50d..b0a12aef29 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -382,17 +382,7 @@ "sorting.direction.descending": "Descending", "sorting.mode.random": "Random", "splash.opening_library": "Opening Library \"{library_path}\"…", - "status.deleted_file_plural": "Deleted {count} files!", - "status.deleted_file_singular": "Deleted 1 file!", - "status.deleted_none": "No files deleted.", - "status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.", - "status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"…", - "status.library_backup_in_progress": "Saving Library Backup…", - "status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})", - "status.library_closed": "Library Closed ({time_span})", - "status.library_closing": "Closing Library…", "status.library_save_success": "Library Saved and Closed!", - "status.library_search_query": "Searching Library…", "status.library_version_dev": "This library is using an unsupported format used for development and can't be opened.", "status.library_version_expected": "Supported Versions:", "status.library_version_found": "Library Version:", From 40f4cf1bfe9b6c1766d918dc23c38d33283f8e93 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:16:59 -0700 Subject: [PATCH 02/14] ui: update search results text style --- src/tagstudio/qt/qt_driver.py | 9 ++++++--- src/tagstudio/resources/translations/en.json | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index d4004eb177..ec8964b7d1 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -1586,11 +1586,14 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: end_time = time.time() # inform user about completed search + time_span = f"({format_timespan(end_time - start_time).title()})" + count = f"{results.total_count:,}" self.main_window.results_label.setText( Translations.format( - "status.results_found", - count=results.total_count, - time_span=format_timespan(end_time - start_time), + # Show "X Results" on query, "X Items" on none + "status.results_found" if state and state.query else "status.items", + count=count, + time_span=time_span, ) ) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index b0a12aef29..42d84dc44b 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -382,13 +382,13 @@ "sorting.direction.descending": "Descending", "sorting.mode.random": "Random", "splash.opening_library": "Opening Library \"{library_path}\"…", + "status.items": "{count} Items {time_span}", "status.library_save_success": "Library Saved and Closed!", "status.library_version_dev": "This library is using an unsupported format used for development and can't be opened.", "status.library_version_expected": "Supported Versions:", "status.library_version_found": "Library Version:", "status.library_version_newer": "This library was saved using a newer version of TagStudio and can't be opened in this version.", - "status.results": "Results", - "status.results_found": "{count} Results Found ({time_span})", + "status.results_found": "{count} Results {time_span}", "status.results.invalid_syntax": "Invalid Search Syntax:", "tag_manager.title": "Library Tags", "tag.add": "Add Tag", From de9ec99c492257e9aa0660dee0a949370cee4e81 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:22:44 -0700 Subject: [PATCH 03/14] refactor(ui): MVC split pagination widget --- src/tagstudio/qt/controllers/main_window.py | 2 +- src/tagstudio/qt/controllers/pagination.py | 210 +++++++++++++ src/tagstudio/qt/mixed/pagination.py | 329 -------------------- src/tagstudio/qt/views/pagination_view.py | 120 +++++++ 4 files changed, 331 insertions(+), 330 deletions(-) create mode 100644 src/tagstudio/qt/controllers/pagination.py delete mode 100644 src/tagstudio/qt/mixed/pagination.py create mode 100644 src/tagstudio/qt/views/pagination_view.py diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index 86c5fd5db1..0a6e42ee73 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -41,9 +41,9 @@ from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.banner_stack import BannerStack 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 diff --git a/src/tagstudio/qt/controllers/pagination.py b/src/tagstudio/qt/controllers/pagination.py new file mode 100644 index 0000000000..7f46f5d58d --- /dev/null +++ b/src/tagstudio/qt/controllers/pagination.py @@ -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) diff --git a/src/tagstudio/qt/mixed/pagination.py b/src/tagstudio/qt/mixed/pagination.py deleted file mode 100644 index d1a26bcd15..0000000000 --- a/src/tagstudio/qt/mixed/pagination.py +++ /dev/null @@ -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") diff --git a/src/tagstudio/qt/views/pagination_view.py b/src/tagstudio/qt/views/pagination_view.py new file mode 100644 index 0000000000..d945838144 --- /dev/null +++ b/src/tagstudio/qt/views/pagination_view.py @@ -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) From d42c7e12d5826848059dc551bea43872c8d9facc Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:11:12 -0700 Subject: [PATCH 04/14] refactor(ui): MVC split + rename FieldContainer(s) to EntryDataBox(List) --- README.md | 8 +- docs/entries.md | 4 +- docs/fields.md | 2 +- docs/preview-support.md | 12 +- docs/roadmap.md | 2 +- docs/tags.md | 2 +- docs/usage.md | 2 +- src/tagstudio/previews/file_renderer.py | 4 +- .../qt/controllers/entry_data_box.py | 98 +++++++++ .../entry_data_box_list.py} | 111 ++++------ src/tagstudio/qt/controllers/inspector.py | 10 +- src/tagstudio/qt/controllers/main_window.py | 12 +- src/tagstudio/qt/mixed/color_box.py | 9 +- src/tagstudio/qt/mixed/data_box.py | 14 ++ src/tagstudio/qt/mixed/field_widget.py | 189 ------------------ src/tagstudio/qt/mixed/folders_to_tags.py | 4 +- src/tagstudio/qt/mixed/item_thumb.py | 4 +- .../qt/mixed/mirror_entries_modal.py | 2 +- src/tagstudio/qt/mixed/settings_panel.py | 2 +- src/tagstudio/qt/mixed/tag_color_manager.py | 14 +- src/tagstudio/qt/mixed/text_field.py | 4 +- src/tagstudio/qt/qt_driver.py | 26 +-- .../qt/views/entry_data_box_list_view.py | 40 ++++ src/tagstudio/qt/views/entry_data_box_view.py | 83 ++++++++ src/tagstudio/qt/views/inspector_view.py | 4 +- src/tagstudio/qt/views/preview_thumb_view.py | 2 +- src/tagstudio/qt/views/styles/stylesheets.py | 6 +- src/tagstudio/qt/views/tag_box_view.py | 4 +- ...tainers.py => test_entry_data_box_list.py} | 26 +-- 29 files changed, 351 insertions(+), 349 deletions(-) create mode 100644 src/tagstudio/qt/controllers/entry_data_box.py rename src/tagstudio/qt/{mixed/field_containers.py => controllers/entry_data_box_list.py} (81%) create mode 100644 src/tagstudio/qt/mixed/data_box.py delete mode 100644 src/tagstudio/qt/mixed/field_widget.py create mode 100644 src/tagstudio/qt/views/entry_data_box_list_view.py create mode 100644 src/tagstudio/qt/views/entry_data_box_view.py rename tests/qt/{test_field_containers.py => test_entry_data_box_list.py} (91%) diff --git a/README.md b/README.md index 57b2d3b94e..40ccdafead 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Tags currently consist of the following attributes: - **Parent Tags**: Other tags in which this tag inherits from. In practice, this means that this tag can be substituted in searches for any listed parent tags. - Parent tags checked with the "disambiguation" checkbox next to them will be used to help disambiguate tag names that may not be unique. - For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)". -- **Is Category**: A property that when checked, treats this tag as a category in the preview panel. +- **Is Category**: A property that when checked, treats this tag as a category in the Inspector panel. Fields, like tags, are additional pieces of custom metadata that you can add to your file entries. Fields currently have several hardcoded names (e.g. "Title", "Author", "Series") but custom field names are planned for an upcoming update. @@ -108,7 +108,7 @@ You can manage your library of tags from opening the "Tag Manager" panel from Ed ### Editing Tags -To edit a tag, click on it inside the preview panel or right-click the tag and select "Edit Tag" from the context menu. +To edit a tag, click on it inside the Inspector or right-click the tag and select "Edit Tag" from the context menu. ### Adding Tags to File Entries @@ -116,11 +116,11 @@ Access the "Add Tag" search box by either clicking on the "Add Tag" button at th From here you can search for existing tags or create a new one if the one you're looking for doesn't exist. Click the "+" button next to any tags you want to the currently selected file entries. To quickly add the top result, press the Enter/Return key to add the top-most tag and reset the tag search. Press Enter/Return once more to close the dialog box. By using this method, you can quickly add various tags in quick succession just by using the keyboard! -To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears. +To remove a tag from a file entry, hover over the tag in the Inspector and click on the "-" icon that appears. ### Adding Fields to File Entries -To add a metadata field to a file entry, start by clicking the "Add Field" button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry +To add a metadata field to a file entry, start by clicking the "Add Field" button at the bottom of the Inspector. From the dropdown menu, select the type of metadata field you’d like to add to the entry ### Editing Fields diff --git a/docs/entries.md b/docs/entries.md index 7b11939ff8..fd5d072093 100644 --- a/docs/entries.md +++ b/docs/entries.md @@ -16,11 +16,11 @@ File entry data is stored within the `ts_library.sqlite` file inside each librar ## Appearance -File entries appear as thumbnails inside the grid display. The inspector shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields. +File entries appear as thumbnails inside the grid display. The Inspector panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields. ## Unlinked Entries -If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the inspector. +If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the Inspector. To fix file entries that have become unlinked, select the "Fix Unlinked Entries" option from the Tools menu. From there, refresh the unlinked entry count and choose whether to search and relink you files, and/or delete the file entries from your library. This will NOT delete or modify any files on disk. diff --git a/docs/fields.md b/docs/fields.md index 9f3e45727d..70eca99fdc 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -12,7 +12,7 @@ Fields are extra pieces of information you can add to [file entries](./entries.m Unlike tags, fields are based on [templates](#templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from. -To add a field to an entry, click the "Add Field" button at the bottom of the inspector. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**. +To add a field to an entry, click the "Add Field" button at the bottom of the Inspector panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
![Fields Example](assets/fields_example.png) diff --git a/docs/preview-support.md b/docs/preview-support.md index df64c56db7..9d9315467b 100644 --- a/docs/preview-support.md +++ b/docs/preview-support.md @@ -12,7 +12,7 @@ TagStudio offers built-in preview and thumbnail support for a wide variety of fi ### :material-image-outline: Images -Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Preview Panel. Animated images will play in the Preview Panel. +Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Inspector panel. Animated images will play in the Inspector. | Filetype | Extensions | Animation | | -------------------- | -------------------------------------------------- | :---------------------------------: | @@ -47,7 +47,7 @@ Images will generate thumbnails the first time they are viewed or since the last ### :material-movie-open: Videos -Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#optional-dependencies) installed on your system. +Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Inspector requires [FFmpeg](install.md#optional-dependencies) installed on your system. | Filetype | Extensions | Dependencies | | --------------------- | ----------------------- | :----------: | @@ -65,7 +65,7 @@ Video thumbnails will default to the closest viable frame from the middle of the ### :material-sine-wave: Audio -Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached. +Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Inspector if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached. | Filetype | Extensions | Dependencies | | ------------------- | ------------------------ | :----------: | @@ -103,7 +103,7 @@ Preview support for office documents or well-known project file formats varies b ### :material-archive: Archives -Archive thumbnails will display the first image from the archive within the Preview Panel. +Archive thumbnails will display the first image from the archive within the Inspector. | Filetype | Extensions | | -------- | -------------- | @@ -129,7 +129,7 @@ Archive thumbnails will display the first image from the archive within the Prev ### :material-format-font: Fonts -Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel. +Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Inspector. | Filetype | Extensions | | -------------------- | ----------------- | @@ -143,7 +143,7 @@ Font thumbnails will use a "Aa" example preview of the font, with a full alphanu !!! info "Plain Text Support" TagStudio supports the *vast* majority of files considered to be "[plain text](https://en.wikipedia.org/wiki/Plain_text)". If an extension or format is not listed here, odds are it's still supported anyway. -Text files render the first 256 bytes of text information to an image preview for thumbnails and the Preview Panel. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features. +Text files render the first 256 bytes of text information to an image preview for thumbnails and the Inspector. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features. | Filetype | Extensions | Syntax Highlighting | | ---------- | --------------------------------------------- | :--------------------------------: | diff --git a/docs/roadmap.md b/docs/roadmap.md index b1a0db9785..fcc1409569 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -203,7 +203,7 @@ Discrete library objects representing [attributes](