Compare commits

...

10 Commits

Author SHA1 Message Date
Travis Abendshien b85a26065e refactor: more tweaks in pagination.py 2026-09-15 23:15:55 -07:00
Travis Abendshien 87f43d81f2 refactor: move leftover controller logic on TagDataView to TagData 2026-09-15 23:15:55 -07:00
Travis Abendshien 83b976b840 fix: clean up tile_list.py, remove redundant disconnect step 2026-09-15 23:15:55 -07:00
Travis Abendshien 5fbe8f7ce6 fix: wacky loop in color_data.py 2026-09-15 23:15:55 -07:00
Travis Abendshien 514455c8a3 refactor(ui): rename FieldContainers to Tiles 2026-09-15 23:15:55 -07:00
Travis Abendshien df6999afc4 refactor(ui): use constants for common padding and radius values 2026-09-15 23:15:54 -07:00
Travis Abendshien b2346b3ae1 refactor(ui): MVC split + rename FieldContainer(s) to EntryDataBox(List) 2026-09-15 23:15:54 -07:00
Travis Abendshien cce3929790 refactor(ui): MVC split pagination widget 2026-09-15 23:15:54 -07:00
Travis Abendshien e411fd8ed7 ui: update search results text style 2026-09-15 23:15:54 -07:00
Travis Abendshien 763f3a1d4a ui: tweak UI, full height inspector, floating pagination bar 2026-09-15 23:15:54 -07:00
45 changed files with 1093 additions and 1077 deletions
+6 -6
View File
@@ -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 panel.
| 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 panel 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 panel 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 panel.
| 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 panel.
| 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 panel. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features.
| Filetype | Extensions | Syntax Highlighting |
| ---------- | --------------------------------------------- | :--------------------------------: |
+2 -2
View File
@@ -139,7 +139,7 @@ class FileRenderer:
Args:
size (tuple[int, int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
scale_radius (bool): Option to scale the radius up (Used for Preview Panel).
scale_radius (bool): Option to scale the radius up (Used by the Inspector).
"""
thumb_scale: int = 512
radius_scale: float = 1
@@ -223,7 +223,7 @@ class FileRenderer:
Args:
size (tuple[int,int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
radius_scale (float): The scale factor of the border radius (Used by Preview Panel).
radius_scale (float): The scale factor of the border radius (Used by the Inspector).
"""
smooth_factor: int = 2
radius_factor: int = 8
+6 -6
View File
@@ -20,7 +20,8 @@ from PySide6.QtWidgets import QVBoxLayout, QWidget
from tagstudio.qt.views.banner_view import BannerView
from tagstudio.qt.views.styles.stylesheets import (
BANNER_CORNER_RADIUS,
PAD,
RADIUS,
banner_notice_bg_color,
banner_notice_style,
banner_progress_bg_color,
@@ -52,7 +53,7 @@ class _BannerBackground(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addRoundedRect(QRectF(self.rect()), BANNER_CORNER_RADIUS, BANNER_CORNER_RADIUS)
path.addRoundedRect(QRectF(self.rect()), RADIUS, RADIUS)
painter.fillPath(path, self._bg_color)
painter.end()
@@ -61,8 +62,7 @@ class Banner(QWidget):
"""A notification banner with an optional progress bar, action button, and close button."""
CONTENT_HEIGHT = 36
GAP = 6
HEIGHT = CONTENT_HEIGHT + GAP
HEIGHT = CONTENT_HEIGHT + PAD
ANIMATION_MS = 250
COLOR_ANIMATION_MS = 250
MIN_VISIBLE_MS = 3000
@@ -78,7 +78,7 @@ class Banner(QWidget):
self.setMaximumHeight(0)
outer_layout = QVBoxLayout(self)
outer_layout.setContentsMargins(0, 0, 0, self.GAP)
outer_layout.setContentsMargins(0, 0, 0, PAD)
outer_layout.setSpacing(0)
self._background = _BannerBackground(self)
@@ -95,7 +95,7 @@ class Banner(QWidget):
self._progress_bg_color = banner_progress_bg_color()
self._background.setStyleSheet(self._notice_style)
self._background.set_bg_color(self._notice_bg_color)
self.view.progress_bar.set_corner_radius(BANNER_CORNER_RADIUS)
self.view.progress_bar.set_corner_radius(RADIUS)
self.view.progress_bar.set_chunk_color(banner_progress_chunk_color())
self._card_color_anim = QVariantAnimation(self)
+5 -5
View File
@@ -28,8 +28,8 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_list import TileList
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.views.inspector_view import InspectorView
@@ -186,8 +186,8 @@ class Inspector(QWidget):
self.layout().containers.update_from_entry(self._selected[0])
def _edit_field(self, entry_id: int, field: BaseField) -> None:
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
# It's possible to reuse it later, after a FieldContainers refactor.
# TODO: A lot of this code is similar to or straight up shared with TileList.
# It's possible to reuse it later, after a TileList refactor.
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
if type(field) is TextField:
@@ -274,14 +274,14 @@ class Inspector(QWidget):
self._set_selection_callback()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
logger.error("[Inspector] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the inspector."""
self.layout().preview_thumb.media_player.stop()
@property
def containers(self) -> FieldContainers:
def containers(self) -> TileList:
return self.layout().containers
@override
+45 -47
View File
@@ -31,7 +31,6 @@ from PySide6.QtWidgets import (
QSizePolicy,
QSpacerItem,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
@@ -42,13 +41,13 @@ 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
from tagstudio.qt.views.styles.stylesheets import checkbox_style
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, WIN_PAD, checkbox_style
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -475,6 +474,7 @@ class MainWindow(QMainWindow):
# 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
@@ -482,6 +482,8 @@ class MainWindow(QMainWindow):
# 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
@@ -493,20 +495,18 @@ class MainWindow(QMainWindow):
self.landing_widget: LandingWidget
self.pagination: Pagination
# initialized in setup_preview_panel
self.preview_panel: Inspector
# initialized in setup_inspector
self.inspector: Inspector
# endregion
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
@@ -536,6 +536,7 @@ class MainWindow(QMainWindow):
self.central_widget.setObjectName("central_widget")
self.central_layout = QGridLayout(self.central_widget)
self.central_layout.setObjectName("central_layout")
self.central_layout.setContentsMargins(0, WIN_PAD, 0, 0)
self.setup_search_bar()
self.setup_extra_input_bar()
@@ -547,6 +548,7 @@ class MainWindow(QMainWindow):
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(WIN_PAD, 0, 0, 0)
self.back_button = QPushButton(self.central_widget)
back_icon: Image.Image = self.rm.bxs_left_arrow
@@ -584,19 +586,26 @@ class MainWindow(QMainWindow):
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(WIN_PAD, 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()
self.show_hidden_entries_layout = QHBoxLayout(self.show_hidden_entries_widget)
self.show_hidden_entries_layout.setStretch(1, 1)
self.show_hidden_entries_layout.setContentsMargins(0, 0, 0, 0)
self.show_hidden_entries_layout.setSpacing(6)
self.show_hidden_entries_layout.setSpacing(PAD)
self.show_hidden_entries_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.show_hidden_entries_title = QLabel(Translations["home.show_hidden_entries"])
self.show_hidden_entries_checkbox = QCheckBox()
@@ -610,11 +619,6 @@ class MainWindow(QMainWindow):
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")
@@ -651,28 +655,39 @@ class MainWindow(QMainWindow):
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.content_splitter.setHandleWidth(PAD + HALF_PAD)
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(PAD)
self.central_content_layout.addLayout(self.search_bar_layout)
self.central_content_layout.addLayout(self.extra_input_layout)
self.setup_entry_list(driver)
self.setup_preview_panel(driver)
self.content_splitter.addWidget(self.central_content)
self.setup_inspector(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(WIN_PAD, 0, 0, 0)
self.entry_scroll_area = QScrollArea()
self.entry_scroll_area.setObjectName("entry_scroll_area")
@@ -687,8 +702,10 @@ class MainWindow(QMainWindow):
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)
@@ -697,33 +714,14 @@ class MainWindow(QMainWindow):
self.entry_list_layout.addWidget(self.banner)
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_container)
self.central_content_layout.addWidget(self.entry_list_container)
self.pagination = Pagination()
self.entry_list_layout.addWidget(self.pagination)
self.content_splitter.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)
def setup_inspector(self, driver: QtDriver):
self.inspector = Inspector(driver)
self.content_splitter.addWidget(self.inspector)
# endregion
+211
View File
@@ -0,0 +1,211 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
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
logger = structlog.get_logger(__name__)
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.setHidden(True)
self.setObjectName("pagination")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
self.setFixedHeight(self.HEIGHT)
self.setStyleSheet(pagination_style())
self.setLayout(PaginationView())
self._connect_callbacks()
if parent is not None:
parent.installEventFilter(self)
self._sync_geometry()
@override
def layout(self) -> PaginationView:
return super().layout() # pyright: ignore[reportReturnType]
def _connect_callbacks(self) -> None:
self.layout().current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.layout().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.layout()
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 - view.BUFFER_PAGE_COUNT * 2)
end_offset = min(page_count - 1, index)
if i < index:
if (i != 0) and i >= index - view.BUFFER_PAGE_COUNT:
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 + view.BUFFER_PAGE_COUNT:
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)
def _goto_page(self, index: int):
self.update_buttons(self.layout().validator.top(), 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)
-101
View File
@@ -1,101 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import TYPE_CHECKING, override
import structlog
from PySide6.QtCore import Signal
from tagstudio.core.enums import TagClickActionOption
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidget(TagBoxWidgetView):
on_update = Signal()
__entries: list[int] = []
def __init__(self, title: str, driver: QtDriver):
super().__init__(title, driver)
self.__driver = driver
def set_entries(self, entries: list[int]) -> None:
self.__entries = entries
@override
def _on_click(self, tag: Tag) -> None:
match self.__driver.settings.tag_click_action:
case TagClickActionOption.OPEN_EDIT:
self._on_edit(tag)
case TagClickActionOption.SET_SEARCH:
self.__driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
)
case TagClickActionOption.ADD_TO_SEARCH:
# NOTE: modifying the ast and then setting that would be nicer
# than this string manipulation, but also much more complex,
# due to needing to implement a visitor that turns an AST to a string
# So if that exists when you read this, change the following accordingly.
current = self.__driver.browsing_history.current
suffix = unwrap(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current).query
)
self.__driver.update_browsing_state(
current.with_search_query(
f"{current.query} {suffix}" if current.query else suffix
)
)
@override
def _on_remove(self, tag: Tag) -> None:
logger.info(
"[TagBoxWidget] remove_tag",
selected=self.__entries,
)
for entry_id in self.__entries:
self.__driver.lib.remove_tags_from_entries(entry_id, tag.id)
self.on_update.emit()
@override
def _on_edit(self, tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag)
edit_modal = Modal(
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()
@override
def _on_search(self, tag: Tag) -> None:
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
self.__driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
)
@@ -4,6 +4,7 @@
import typing
from collections.abc import Iterable
from typing import override
import structlog
from PySide6.QtCore import Signal
@@ -11,14 +12,13 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.styles.stylesheets import add_button_style
from tagstudio.qt.views.tiles.color_data_view import ColorDataView
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -26,8 +26,9 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class ColorBoxWidget(FieldWidget):
class ColorData(TileData):
"""An inner widget for color names that goes in a Tile widget."""
updated = Signal()
def __init__(
@@ -41,16 +42,15 @@ class ColorBoxWidget(FieldWidget):
self.lib: Library = library
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
super().__init__(title)
self.setObjectName("colorBox")
self.base_layout = FlowLayout()
self.base_layout.enable_grid_optimizations(value=True)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
super().__init__(title, ColorDataView())
self.setObjectName("color_data")
self.set_colors(self.colors)
@override
def layout(self) -> ColorDataView:
return super().layout() # pyright: ignore[reportReturnType]
def set_colors(self, colors: Iterable[TagColorGroup]):
colors_ = sorted(
list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace)
@@ -59,8 +59,10 @@ class ColorBoxWidget(FieldWidget):
max_width = 60
color_widgets: list[TagColorLabel] = []
while self.base_layout.itemAt(0):
unwrap(self.base_layout.takeAt(0)).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
layout = self.layout()
while item := layout.takeAt(0):
if widget := item.widget():
widget.deleteLater()
for color in colors_:
color_widget = TagColorLabel(
@@ -76,7 +78,7 @@ class ColorBoxWidget(FieldWidget):
color_widget.on_remove.connect(lambda c=color: self.delete_color(c))
color_widgets.append(color_widget)
self.base_layout.addWidget(color_widget)
self.layout().addWidget(color_widget)
for color_widget in color_widgets:
color_widget.setFixedWidth(max_width)
@@ -98,7 +100,7 @@ class ColorBoxWidget(FieldWidget):
)
)
)
self.base_layout.addWidget(add_button)
self.layout().addWidget(add_button)
def edit_color(self, color_group: TagColorGroup):
build_color_panel = BuildColorPanel(self.lib, color_group)
@@ -132,6 +134,6 @@ class ColorBoxWidget(FieldWidget):
if result != QMessageBox.ButtonRole.ActionRole.value:
return
logger.info("[ColorBoxWidget] Removing color", color=color_group)
logger.info("[ColorData] Removing color", color=color_group)
self.lib.delete_color(color_group)
self.updated.emit()
@@ -0,0 +1,122 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterable
from functools import partial
from typing import TYPE_CHECKING, override
import structlog
from PySide6.QtCore import Signal
from tagstudio.core.enums import TagClickActionOption
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.tiles.tag_data_view import TagDataView
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
class TagData(TileData):
"""An inner widget for tags that go in a Tile widget."""
on_update = Signal()
def __init__(self, title: str, driver: QtDriver):
self._driver = driver
self._lib = driver.lib
self._entries: list[int] = []
super().__init__(title, TagDataView())
self.setObjectName("tag_data")
@override
def layout(self) -> TagDataView:
return super().layout() # pyright: ignore[reportReturnType]
def set_entries(self, entries: list[int]) -> None:
self._entries = entries
def set_tags(self, tags: Iterable[Tag]) -> None:
tags_ = sorted(list(tags), key=lambda tag: self._lib.tag_display_name(tag))
logger.info("[TagData] Tags:", tags=tags)
layout = self.layout()
while item := layout.takeAt(0):
if widget := item.widget():
widget.deleteLater()
for tag in tags_:
tag_widget = TagWidget(tag, library=self._lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
match self._driver.settings.tag_click_action:
case TagClickActionOption.OPEN_EDIT:
self._on_edit(tag)
case TagClickActionOption.SET_SEARCH:
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current)
)
case TagClickActionOption.ADD_TO_SEARCH:
# NOTE: modifying the ast and then setting that would be nicer
# than this string manipulation, but also much more complex,
# due to needing to implement a visitor that turns an AST to a string
# So if that exists when you read this, change the following accordingly.
current = self._driver.browsing_history.current
suffix = unwrap(
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current).query
)
self._driver.update_browsing_state(
current.with_search_query(
f"{current.query} {suffix}" if current.query else suffix
)
)
def _on_remove(self, tag: Tag) -> None:
logger.info("[TagData] remove_tag", selected=self._entries)
for entry_id in self._entries:
self._driver.lib.remove_tags_from_entries(entry_id, tag.id)
self.on_update.emit()
def _on_edit(self, tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self._driver.lib, tag=tag)
edit_modal = Modal(
build_tag_panel,
self._driver.lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self._driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()
def _on_search(self, tag: Tag) -> None:
self._driver.main_window.search_field.setText(f"tag_id:{tag.id}")
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current)
)
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import re
from typing import override
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.views.tiles.text_data_view import TextDataView
class TextData(TileData):
"""An inner widget for any text that goes in a Tile widget.
Can include text fields, date fields, etc.
"""
def __init__(self, title: str, text: str) -> None:
super().__init__(title, TextDataView())
self.setObjectName("text_data")
self.set_text(text)
@override
def layout(self) -> TextDataView:
return super().layout() # pyright: ignore[reportReturnType]
def set_text(self, text: str):
text = linkify(text)
self.layout().text_label.setText(text)
# Regex from https://stackoverflow.com/a/6041965
def linkify(text: str):
url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
return re.sub(
url_pattern,
lambda url: f'<a href="{url.group(0)}">{url.group(0)}</a>',
text,
flags=re.IGNORECASE,
)
+104
View File
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from typing import override
from warnings import catch_warnings
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QEnterEvent, QResizeEvent
from PySide6.QtWidgets import QWidget
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.views.styles.stylesheets import container_style, header
from tagstudio.qt.views.tiles.tile_view import TileView
class Tile(QWidget):
"""Wraps a title, action buttons, and data such as text or tags inside a single widget."""
def __init__(self, title: str = "TILE") -> None:
super().__init__()
self.setObjectName("tile")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
self.setLayout(TileView())
self.set_title(title)
self.setStyleSheet(container_style())
@override
def layout(self) -> TileView:
return super().layout() # pyright: ignore[reportReturnType]
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.layout().copy_button.clicked.disconnect()
self.copy_callback = callback
if callback:
self.layout().copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.layout().edit_button.clicked.disconnect()
self.edit_callback = callback
if callback:
self.layout().edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.layout().remove_button.clicked.disconnect()
self.remove_callback = callback
if callback:
self.layout().remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: QWidget) -> None:
if item := self.layout().data_layout.itemAt(0):
old: QWidget = unwrap(item.widget())
self.layout().data_layout.removeWidget(old)
old.deleteLater()
self.layout().data_layout.addWidget(widget)
def get_inner_widget(self) -> QWidget | None:
if item := self.layout().data_layout.itemAt(0):
return item.widget()
return None
def set_title(self, title: str) -> None:
self.title = header(title, 4)
self.layout().title_widget.setText(self.title)
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the inner widget if needed.
layout = self.layout()
if self.copy_callback:
layout.copy_button.setHidden(False)
if self.edit_callback:
layout.edit_button.setHidden(False)
if self.remove_callback:
layout.remove_button.setHidden(False)
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
layout = self.layout()
if self.copy_callback:
layout.copy_button.setHidden(True)
if self.edit_callback:
layout.edit_button.setHidden(True)
if self.remove_callback:
layout.remove_button.setHidden(True)
return super().leaveEvent(event)
@override
def resizeEvent(self, event: QResizeEvent) -> None:
self.layout().title_widget.setFixedWidth(int(event.size().width() // 1.5))
return super().resizeEvent(event)
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QLayout, QWidget
class TileData(QWidget):
"""A base class for widgets that go in a Tile widget."""
def __init__(self, title: str, view: QLayout) -> None:
super().__init__()
self.title: str = title
self.setLayout(view)
@@ -6,19 +6,11 @@ import typing
from collections.abc import Callable
from datetime import datetime as dt
from functools import partial
from warnings import catch_warnings
from typing import override
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QMessageBox,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.library.alchemy.fields import (
BaseField,
@@ -32,11 +24,11 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_box import TagBoxWidget
from tagstudio.qt.controllers.tiles.tag_data import TagData
from tagstudio.qt.controllers.tiles.text_data import TextData
from tagstudio.qt.controllers.tiles.tile import Tile
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.views.styles.stylesheets import inset_container_style
from tagstudio.qt.views.tiles.tile_list_view import TileListView
if typing.TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -44,9 +36,8 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class FieldContainers(QWidget):
"""Widget for the tag and field containers displayed inside the Preview Panel."""
class TileList(QWidget):
"""A styled list of Tile widgets."""
on_tags_update = Signal()
@@ -55,45 +46,16 @@ class FieldContainers(QWidget):
self.lib = library
self.driver: QtDriver = driver
self.initialized = False
self.is_open: bool = False
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
self._containers: list[FieldContainer] = []
self._tiles: list[Tile] = []
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(6)
# TODO: Reimplement mixed entry editing
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
self.setLayout(TileListView())
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.setSpacing(0)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("entryScrollArea")
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# NOTE: I would rather have this style applied to the scroll_area
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(self.scroll_area)
@override
def layout(self) -> TileListView:
return super().layout() # pyright: ignore[reportReturnType]
@property
def top_entry_id(self) -> int:
@@ -102,7 +64,7 @@ class FieldContainers(QWidget):
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
logger.warning("[TileList] Updating Selection", entry_id=entry_id)
entry = unwrap(self.lib.get_entry_full(entry_id))
self.cached_entries = [entry]
@@ -118,9 +80,7 @@ class FieldContainers(QWidget):
if entry_tags:
categories = self.get_tag_categories(entry_tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
self.write_tag_container(
container_index, tags=tags, category_tag=cat, is_mixed=False
)
self.write_tag_tile(container_index, tags=tags, category_tag=cat, is_mixed=False)
container_index += 1
container_len += 1
if update_badges:
@@ -128,11 +88,11 @@ class FieldContainers(QWidget):
# Write field container(s)
for index, field in enumerate(entry_fields, start=container_index):
self.write_field_container(index, field, is_mixed=False)
self.write_field_tile(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self._containers) > container_len:
for i, c in enumerate(self._containers):
if len(self._tiles) > container_len:
for i, c in enumerate(self._tiles):
if i > (container_len - 1):
c.setHidden(True)
@@ -151,7 +111,7 @@ class FieldContainers(QWidget):
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self._containers:
for c in self._tiles:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -215,7 +175,7 @@ class FieldContainers(QWidget):
assert isinstance(field_templates, list)
logger.info(
"[FieldContainers][add_field_to_selected]",
"[TileList][add_field_to_selected]",
selected=self.driver.selected,
fields=[
(field_template.class_name, field_template.id) for field_template in field_templates
@@ -225,7 +185,7 @@ class FieldContainers(QWidget):
for entry_id in self.driver.selected:
for field_template in field_templates:
logger.info(
"[FieldContainers][add_field_to_selected] Adding field",
"[TileList][add_field_to_selected] Adding field",
name=field_template.name,
type=field_template.class_name,
)
@@ -239,7 +199,7 @@ class FieldContainers(QWidget):
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
"[FieldContainers][add_tags_to_selected]",
"[TileList][add_tags_to_selected]",
selected=self.driver.selected,
tag_ids=tag_ids,
)
@@ -266,8 +226,8 @@ class FieldContainers(QWidget):
self._remove_field(field)
self.update_from_entry(entry_id)
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
def write_field_tile(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field Tile.
Args:
index(int): The container index.
@@ -276,9 +236,7 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
):
def write_text_tile(container: Tile, field: TextField, title: str, is_mixed: bool):
container.set_title(field.name)
# Normalize line endings in any text content.
@@ -288,7 +246,7 @@ class FieldContainers(QWidget):
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -311,7 +269,7 @@ class FieldContainers(QWidget):
)
def write_datetime_container(
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
container: Tile, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
@@ -326,7 +284,7 @@ class FieldContainers(QWidget):
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -350,7 +308,7 @@ class FieldContainers(QWidget):
def write_unknown_container():
container.set_title(field.name)
inner_widget = TextContainerWidget(title, field.name)
inner_widget = TextData(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
@@ -360,19 +318,19 @@ class FieldContainers(QWidget):
)
logger.info(
"[FieldContainers][write_container]",
"[TileList][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
# Create new containers if necessary
if len(self._containers) < (index + 1):
container = FieldContainer()
self._containers.append(container)
self.scroll_layout.addWidget(container)
if len(self._tiles) < (index + 1):
container = Tile()
self._tiles.append(container)
self.layout().scroll_layout.addWidget(container)
else:
container = self._containers[index]
container = self._tiles[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -380,7 +338,7 @@ class FieldContainers(QWidget):
# Write containers
if type(field) is TextField:
write_text_container(container, field, title, is_mixed)
write_text_tile(container, field, title, is_mixed)
elif type(field) is DatetimeField:
write_datetime_container(container, field, title, is_mixed)
else:
@@ -388,10 +346,10 @@ class FieldContainers(QWidget):
container.setHidden(False)
def write_tag_container(
def write_tag_tile(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
"""Update/Create tag data for a tag FieldContainer.
"""Update/Create tag data for a tag Tile.
Args:
index(int): The container index.
@@ -400,38 +358,33 @@ class FieldContainers(QWidget):
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self._containers) < (index + 1):
container = FieldContainer()
self._containers.append(container)
self.scroll_layout.addWidget(container)
logger.info("[TileList][write_tag_tile]", index=index)
if len(self._tiles) < (index + 1):
container = Tile()
self._tiles.append(container)
self.layout().scroll_layout.addWidget(container)
else:
container = self._containers[index]
container = self._tiles[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
if not is_mixed:
inner_widget = container.get_inner_widget()
if isinstance(inner_widget, TagBoxWidget):
with catch_warnings(record=True):
inner_widget.on_update.disconnect()
else:
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
if not isinstance(inner_widget, TagData):
inner_widget = TagData(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
inner_widget.on_update.connect(
lambda: (
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
self.on_tags_update.emit(),
)
)
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)
inner_widget.on_update.connect(
lambda: (
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
self.on_tags_update.emit(),
)
)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
inner_widget = TextData("Mixed Tags", text) # NOTE: Unlocalized but unused
container.set_inner_widget(inner_widget)
container.set_edit_callback()
@@ -441,7 +394,7 @@ class FieldContainers(QWidget):
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
"[TileList] Removing Field",
field=field,
selected=[x.path for x in self.cached_entries],
)
@@ -470,9 +423,9 @@ class FieldContainers(QWidget):
remove_mb.setWindowTitle(Translations["Remove Field"])
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
)
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.DestructiveRole)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
-189
View File
@@ -1,189 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from typing import override
from warnings import catch_warnings
import structlog
from PIL import ImageQt
from PySide6.QtCore import QEvent, QSize, Qt
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.utils.types import unwrap
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 container_style, header
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class FieldContainer(QWidget):
rm: ResourceManager = ResourceManager()
copy_icon = auto_theme_overlay(rm.copy, inverse=True)
edit_icon = auto_theme_overlay(rm.edit, inverse=True)
trash_icon = auto_theme_overlay(rm.trash, inverse=True)
# TODO: There should be a global button theme somewhere.
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
self.setObjectName("fieldContainer")
self.title: str = title
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
button_size = 22
icon_margins = 4
icon_size = button_size - icon_margins
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(3, 0, 0, 3)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
self.field_container.setLayout(self.inner_layout)
self.root_layout.addWidget(self.field_container)
self.title_container = QWidget()
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.title_layout.setObjectName("fieldLayout")
self.title_layout.setContentsMargins(0, 0, 0, 0)
self.title_layout.setSpacing(0)
self.inner_layout.addWidget(self.title_container)
self.title_widget = QLabel()
self.title_widget.setMinimumHeight(button_size)
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setText(title)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
self.copy_button = QPushButton()
self.copy_button.setObjectName("copyButton")
self.copy_button.setMinimumSize(button_size, button_size)
self.copy_button.setMaximumSize(button_size, button_size)
self.copy_button.setFlat(True)
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.copy_icon)))
self.copy_button.setIconSize(QSize(20, 20))
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButton()
self.edit_button.setObjectName("editButton")
self.edit_button.setMinimumSize(button_size, button_size)
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
self.edit_button.setIconSize(QSize(icon_size, icon_size))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButton()
self.remove_button.setObjectName("removeButton")
self.remove_button.setMinimumSize(button_size, button_size)
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
self.remove_button.setIconSize(QSize(icon_size, icon_size))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
self.field = QWidget()
self.field.setObjectName("field")
self.field_layout = QHBoxLayout()
self.field_layout.setObjectName("fieldLayout")
self.field_layout.setContentsMargins(0, 0, 0, 0)
self.field.setLayout(self.field_layout)
self.inner_layout.addWidget(self.field)
self.set_title(title)
self.setStyleSheet(container_style())
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.copy_button.clicked.disconnect()
self.copy_callback = callback
if callback:
self.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.edit_button.clicked.disconnect()
self.edit_callback = callback
if callback:
self.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.remove_button.clicked.disconnect()
self.remove_callback = callback
if callback:
self.remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: FieldWidget) -> None:
item = self.field_layout.itemAt(0)
if item:
old: QWidget = unwrap(item.widget())
self.field_layout.removeWidget(old)
old.deleteLater()
self.field_layout.addWidget(widget)
def get_inner_widget(self) -> QWidget | None:
item = self.field_layout.itemAt(0)
if item:
return item.widget()
return None
def set_title(self, title: str) -> None:
self.title = header(title, 4)
self.title_widget.setText(self.title)
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the FieldWidget if needed.
if self.copy_callback:
self.copy_button.setHidden(False)
if self.edit_callback:
self.edit_button.setHidden(False)
if self.remove_callback:
self.remove_button.setHidden(False)
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.copy_callback:
self.copy_button.setHidden(True)
if self.edit_callback:
self.edit_button.setHidden(True)
if self.remove_callback:
self.remove_button.setHidden(True)
return super().leaveEvent(event)
@override
def resizeEvent(self, event: QResizeEvent) -> None:
self.title_widget.setFixedWidth(int(event.size().width() // 1.5))
return super().resizeEvent(event)
class FieldWidget(QWidget):
def __init__(self, title: str) -> None:
super().__init__()
self.title: str = title
+1 -3
View File
@@ -229,9 +229,7 @@ class FoldersToTagsModal(QWidget):
def on_apply(self):
folders_to_tags(self.library)
self.close()
self.driver.main_window.preview_panel.set_selection(
self.driver.selected, update_preview=False
)
self.driver.main_window.inspector.set_selection(self.driver.selected, update_preview=False)
@override
def showEvent(self, event: QtGui.QShowEvent):
+1 -3
View File
@@ -492,9 +492,7 @@ class ItemThumb(FlowWidget):
):
selected = self.driver._selected
if len(selected) == 1 and entry_id in selected:
self.driver.main_window.preview_panel.containers.update_toggled_tag(
tag_id, toggle_value
)
self.driver.main_window.inspector.containers.update_toggled_tag(tag_id, toggle_value)
@override
def mouseMoveEvent(self, event: QMouseEvent) -> None:
@@ -87,7 +87,7 @@ class MirrorEntriesModal(QWidget):
pw.from_iterable_function(
self.mirror_entries_runnable,
displayed_text,
lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection(s),
lambda s=self.driver.selected: self.driver.main_window.inspector.set_selection(s),
self.done.emit,
)
-298
View File
@@ -1,298 +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 QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
# TODO: Split to use MVC guidelines.
class Pagination(QWidget):
"""Widget containing controls for navigating between pages of items."""
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.root_layout = QHBoxLayout(self)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
self.root_layout.setContentsMargins(0, 6, 0, 0)
self.root_layout.setSpacing(3)
# [<] ----------------------------------
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.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.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.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)
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")
+1 -1
View File
@@ -429,7 +429,7 @@ class SettingsPanel(ModalContent):
# Apply changes
# Show File Path
driver.update_recent_lib_menu()
driver.main_window.preview_panel.set_selection(self.driver.selected)
driver.main_window.inspector.set_selection(self.driver.selected)
library_directory = driver.lib.library_dir
if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS:
display_path = library_directory or ""
+10 -10
View File
@@ -26,9 +26,9 @@ from tagstudio.core.enums import ThemePalette
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.color_data import ColorData
from tagstudio.qt.controllers.tiles.tile import Tile
from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel
from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.views.styles.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -119,22 +119,22 @@ class TagColorManager(QWidget):
for group, colors in self.driver.lib.tag_color_groups.items():
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
all_default = False
color_box = ColorBoxWidget(group, colors, self.driver.lib)
color_box.updated.connect(
color_data = ColorData(group, colors, self.driver.lib)
color_data.updated.connect(
lambda: (
self.reset(),
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
)
field_container = FieldContainer(self.driver.lib.get_namespace_name(group))
field_container.set_inner_widget(color_box)
tile = Tile(self.driver.lib.get_namespace_name(group))
tile.set_inner_widget(color_data)
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
field_container.set_remove_callback(
tile.set_remove_callback(
lambda checked=False, g=group: self.delete_namespace_dialog(
prompt=Translations["color.namespace.delete.prompt"],
callback=lambda namespace=g: (
@@ -143,14 +143,14 @@ class TagColorManager(QWidget):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
)
)
self.scroll_layout.addWidget(field_container)
self.scroll_layout.addWidget(tile)
if all_default:
ns_container = QWidget()
-43
View File
@@ -1,43 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import re
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.mixed.field_widget import FieldWidget
# TODO: Split to use MVC guidelines.
class TextContainerWidget(FieldWidget):
def __init__(self, title: str, text: str) -> None:
super().__init__(title)
self.setObjectName("textBox")
self.base_layout = QHBoxLayout()
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.text_label = QLabel()
self.text_label.setStyleSheet("font-size: 12px")
self.text_label.setWordWrap(True)
self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
self.text_label.setOpenExternalLinks(True)
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.base_layout.addWidget(self.text_label)
self.set_text(text)
def set_text(self, text: str):
text = linkify(text)
self.text_label.setText(text)
# Regex from https://stackoverflow.com/a/6041965
def linkify(text: str):
url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
return re.sub(
url_pattern,
lambda url: f'<a href="{url.group(0)}">{url.group(0)}</a>',
text,
flags=re.IGNORECASE,
)
+24 -73
View File
@@ -387,7 +387,7 @@ class QtDriver(DriverMixin, QObject):
tsp.search_for_tag.connect(self.search_for_tag_callback)
self.tag_manager = Modal(tsp, Translations["tag_manager.title"])
self.tag_manager.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
lambda checked=False: self.main_window.inspector.set_selection(
self.selected, update_preview=False
)
)
@@ -406,7 +406,7 @@ class QtDriver(DriverMixin, QObject):
is_savable=False,
)
self.field_template_manager.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
lambda checked=False: self.main_window.inspector.set_selection(
self.selected, update_preview=False
)
)
@@ -422,7 +422,7 @@ class QtDriver(DriverMixin, QObject):
add_tag_tsp.item_chosen.connect(
lambda chosen_tag: (
self.add_tags_to_selected_callback([chosen_tag]),
self.main_window.preview_panel.set_selection(self.selected, update_preview=False),
self.main_window.inspector.set_selection(self.selected, update_preview=False),
)
)
@@ -654,7 +654,6 @@ class QtDriver(DriverMixin, QObject):
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}")
@@ -689,7 +688,7 @@ class QtDriver(DriverMixin, QObject):
.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()}"'
)
@@ -797,15 +796,13 @@ class QtDriver(DriverMixin, QObject):
self._banner_context = None
self.main_window.banner.hide_banner(force=True)
self.main_window.status_bar.showMessage(Translations["status.library_closing"])
start_time = time.time()
self.cached_values.setValue(AppCacheItems.LAST_LIBRARY, str(self.lib.library_dir))
self.cached_values.sync()
# Reset library state
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.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()
@@ -833,7 +830,7 @@ class QtDriver(DriverMixin, QObject):
self.library_info_window.close()
self.main_window.thumb_layout.set_entries([])
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.set_selection(self.selected)
self.main_window.toggle_landing_page(enabled=True)
self.main_window.pagination.setHidden(True)
try:
@@ -860,26 +857,9 @@ class QtDriver(DriverMixin, QObject):
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."""
@@ -928,7 +908,7 @@ class QtDriver(DriverMixin, QObject):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
self.main_window.inspector.set_selection(self.selected, update_preview=False)
def select_inverse_action_callback(self):
"""Invert the selection of all visible items."""
@@ -937,14 +917,14 @@ class QtDriver(DriverMixin, QObject):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
self.main_window.inspector.set_selection(self.selected, update_preview=False)
def clear_select_action_callback(self):
self.clear_selected()
self.set_select_actions_visibility()
self.set_clipboard_menu_viability()
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.set_selection(self.selected)
def add_tags_to_selected_callback(self, tag_ids: list[int]):
selected: list[int] = self.selected
@@ -967,7 +947,6 @@ class QtDriver(DriverMixin, QObject):
"""
entry: Entry | None = None
pending: list[tuple[int | None, Path]] = []
deleted_count: int = 0
selected = self.selected
library_dir = unwrap(self.lib.library_dir)
@@ -991,37 +970,17 @@ class QtDriver(DriverMixin, QObject):
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()
self.main_window.inspector.stop_media_playback()
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.
@@ -1398,9 +1357,6 @@ class QtDriver(DriverMixin, QObject):
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:
@@ -1410,9 +1366,6 @@ class QtDriver(DriverMixin, QObject):
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)
@@ -1483,7 +1436,7 @@ class QtDriver(DriverMixin, QObject):
if TAG_FAVORITE in self.copy_buffer["tags"]:
self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False)
else:
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.set_selection(self.selected)
def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
"""Toggle the selection of an item in the Thumbnail Grid.
@@ -1512,7 +1465,7 @@ class QtDriver(DriverMixin, QObject):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.set_selection(self.selected)
def set_clipboard_menu_viability(self):
if len(self.selected) == 1:
@@ -1680,10 +1633,6 @@ class QtDriver(DriverMixin, QObject):
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)
@@ -1692,11 +1641,14 @@ class QtDriver(DriverMixin, QObject):
end_time = time.time()
# inform user about completed search
self.main_window.status_bar.showMessage(
time_span = f"<a style=color:'gray'>({format_timespan(end_time - start_time).title()})</a>"
count = f"<b>{results.total_count:,}</b>"
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,
)
)
@@ -1814,7 +1766,6 @@ class QtDriver(DriverMixin, QObject):
)
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:
@@ -1921,7 +1872,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.menu_bar.folders_to_tags_action.setEnabled(True)
self.main_window.menu_bar.library_info_action.setEnabled(True)
self.main_window.preview_panel.set_selection(self.selected)
self.main_window.inspector.set_selection(self.selected)
# page (re)rendering, extract eventually
initial_state = BrowsingState(
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.styles.stylesheets import checkbox_style
from tagstudio.qt.views.styles.stylesheets import PAD, checkbox_style
logger = structlog.get_logger(__name__)
@@ -29,7 +29,7 @@ class EditFieldTemplateModalView(ModalContent):
# Layout Init
self.setMinimumSize(460, 200)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Field Name
@@ -64,14 +64,14 @@ class EditFieldTemplateModalView(ModalContent):
self._text_field_attributes_layout = QHBoxLayout(self._text_field_attributes_widget)
self._text_field_attributes_layout.setStretch(1, 1)
self._text_field_attributes_layout.setContentsMargins(0, 0, 0, 0)
self._text_field_attributes_layout.setSpacing(6)
self._text_field_attributes_layout.setSpacing(PAD)
# Is Multiline
self._multiline_widget = QWidget()
self._multiline_layout = QHBoxLayout(self._multiline_widget)
self._multiline_layout.setStretch(1, 1)
self._multiline_layout.setContentsMargins(0, 0, 0, 0)
self._multiline_layout.setSpacing(6)
self._multiline_layout.setSpacing(PAD)
self._multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
self._multiline_checkbox = QCheckBox()
+2 -2
View File
@@ -16,7 +16,7 @@ from PySide6.QtWidgets import (
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.styles.stylesheets import checkbox_style, title_line_edit_style
from tagstudio.qt.views.styles.stylesheets import PAD, checkbox_style, title_line_edit_style
class EditTextView(ModalContent):
@@ -24,7 +24,7 @@ class EditTextView(ModalContent):
super().__init__()
self.setMinimumSize(480, 240)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.name_field = QLineEdit()
self.name_field.setStyleSheet(title_line_edit_style())
@@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWi
from tagstudio.core.library.alchemy.library import Library
from tagstudio.i18n.translations import Translations
from tagstudio.qt.views.styles.stylesheets import PAD
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -27,7 +27,7 @@ class FixIgnoredEntriesModalView(QWidget):
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.root_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.ignored_desc_widget = QLabel(Translations["entries.ignored.description"])
self.ignored_desc_widget.setObjectName("ignoredDescriptionLabel")
@@ -43,7 +43,7 @@ class FixIgnoredEntriesModalView(QWidget):
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
self.done_button = QPushButton(Translations["generic.done_alt"])
@@ -59,9 +59,9 @@ class FixIgnoredEntriesModalView(QWidget):
self.root_layout.addWidget(self.button_container)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
else:
pass
return super().keyPressEvent(event)
+3 -2
View File
@@ -16,6 +16,7 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.styles.stylesheets import PAD
logger = structlog.get_logger(__name__)
@@ -30,7 +31,7 @@ class IgnoreModalView(ModalContent):
self.setMinimumSize(640, 460)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.text_edit = QPlainTextEdit()
@@ -44,6 +45,6 @@ class IgnoreModalView(ModalContent):
Translations.format("ignore.open_file", ts_ignore=IGNORE_NAME)
)
# Add Widgets to Layout ================================================
# Finalize Layout
self.root_layout.addWidget(self.text_edit)
self.root_layout.addWidget(self.open_button)
+16 -10
View File
@@ -16,10 +16,16 @@ from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.qt.controllers.preview_thumb import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.controllers.tiles.tile_list import TileList
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.stylesheets import button_style, preview_warning_style
from tagstudio.qt.views.styles.stylesheets import (
HALF_PAD,
PAD,
WIN_PAD,
button_style,
preview_warning_style,
)
if typing.TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -30,8 +36,8 @@ logger = structlog.get_logger(__name__)
class InspectorView(QVBoxLayout):
def __init__(self, driver: QtDriver, pixel_ratio: float) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
self.setContentsMargins(0, 0, WIN_PAD, WIN_PAD)
self.setSpacing(PAD)
rm = ResourceManager()
# Search/Create Boxes
@@ -47,19 +53,19 @@ class InspectorView(QVBoxLayout):
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = FieldContainers(driver.lib, driver)
self.containers = TileList(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
preview_layout.setSpacing(PAD)
# Warning Banner (Missing FFmpeg, etc.)
self.warning_banner = QWidget()
self.warning_banner.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
ffmpeg_warning_layout.setContentsMargins(HALF_PAD, HALF_PAD, HALF_PAD, HALF_PAD)
self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
@@ -86,17 +92,17 @@ class InspectorView(QVBoxLayout):
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.setSpacing(6)
info_layout.setSpacing(PAD)
splitter = QSplitter()
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
splitter.setHandleWidth(PAD * 2)
# Add Tag/Field Buttons
add_buttons_container = QWidget()
add_buttons_layout = QHBoxLayout(add_buttons_container)
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
add_buttons_layout.setSpacing(PAD)
self.add_tag_button = ReturnButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
@@ -24,13 +24,18 @@ if TYPE_CHECKING:
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 @@ class ThumbGridLayout(QLayout):
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:
@@ -24,7 +24,7 @@ from PySide6.QtWidgets import (
from tagstudio.i18n.platform_strings import open_file_str
from tagstudio.i18n.translations import Translations
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import header
from tagstudio.qt.views.styles.stylesheets import PAD, header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -42,7 +42,7 @@ class LibraryInfoWindowView(QWidget):
self.setWindowTitle("Library Information")
self.setMinimumSize(800, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.root_layout.setContentsMargins(PAD, PAD, PAD, PAD)
row_height: int = 22
icon_margin: int = 4
@@ -58,7 +58,7 @@ class LibraryInfoWindowView(QWidget):
self.body_widget = QWidget()
self.body_layout = QHBoxLayout(self.body_widget)
self.body_layout.setContentsMargins(0, 0, 0, 0)
self.body_layout.setSpacing(6)
self.body_layout.setSpacing(PAD)
# Statistics -----------------------------------------------------------
self.stats_widget = QWidget()
@@ -440,7 +440,7 @@ class LibraryInfoWindowView(QWidget):
# Details --------------------------------------------------------------
self.details_container = QWidget()
self.details_layout = QHBoxLayout(self.details_container)
self.details_layout.setContentsMargins(6, 0, 6, 0)
self.details_layout.setContentsMargins(PAD, 0, PAD, 0)
opacity_effect_details = QGraphicsOpacityEffect(self)
opacity_effect_details.setOpacity(0.5)
@@ -451,7 +451,7 @@ class LibraryInfoWindowView(QWidget):
# Buttons --------------------------------------------------------------
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
self.close_button = QPushButton(Translations["generic.close"])
+3 -3
View File
@@ -8,7 +8,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWi
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.styles.stylesheets import header
from tagstudio.qt.views.styles.stylesheets import PAD, header
logger = structlog.get_logger(__name__)
@@ -25,11 +25,11 @@ class ModalView(QVBoxLayout):
):
super().__init__()
self.content_widget = content_widget
self.setContentsMargins(6, 6 if inline_title else 12, 6, 6)
self.setContentsMargins(PAD, PAD if inline_title else PAD * 2, PAD, PAD)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
# [Done]
+121
View File
@@ -0,0 +1,121 @@
# 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
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD
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, PAD, 0, PAD)
self.setSpacing(HALF_PAD)
_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)
+9 -10
View File
@@ -42,12 +42,11 @@ class PreviewType(Enum):
# TODO: Use newer MVC style guidelines
class PreviewThumbView(QWidget):
"""The Preview Panel Widget."""
"""The file preview thumbnail widget."""
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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 @@ class PreviewThumbView(QWidget):
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)
+4 -4
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.i18n.translations import Translations
from tagstudio.qt.views.styles.stylesheets import list_button_style
from tagstudio.qt.views.styles.stylesheets import PAD, list_button_style
if TYPE_CHECKING:
pass
@@ -29,13 +29,13 @@ class SearchPanelView(QVBoxLayout):
) -> None:
self.is_chooser: bool = is_chooser
super().__init__()
self.setContentsMargins(6, 0, 6, 0)
self.setContentsMargins(PAD, 0, PAD, 0)
# Limit container
self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.setSpacing(PAD * 2)
self.limit_layout.addStretch(1)
self.limit_title = QLabel(Translations["home.search.view_limit"])
self.limit_layout.addWidget(self.limit_title)
@@ -57,7 +57,7 @@ class SearchPanelView(QVBoxLayout):
# Scroll area
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setContentsMargins(PAD, 0, PAD, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.scroll_contents)
@@ -23,7 +23,7 @@ def auto_theme_overlay(
Args:
image (Image): The PIL Image object to apply an overlay to.
inverse (bool): Option inverse the overlay color relative to the current theme.
inverse (bool): Option to invert the overlay color relative to the current theme.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
+50 -32
View File
@@ -19,8 +19,12 @@ from tagstudio.qt.views.styles.palette import (
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
# Shared with RoundedProgressBar.set_corner_radius() so both use the exact same corner arc.
BANNER_CORNER_RADIUS = 6
RADIUS = 6 # Standard corner radius
INNER_RADIUS = RADIUS // 2 # Inner/small corder radius
PAD = 6 # Standard padding value
HALF_PAD = PAD // 2
WIN_PAD = 12 # Padding used for window edge margins
def add_button_style() -> str:
@@ -31,7 +35,7 @@ def add_button_style() -> str:
color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};
font-weight: 600;
border-color: {get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};
border-radius: 6px;
border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
padding-right: 4px;
@@ -59,7 +63,7 @@ def button_style() -> str:
return f"""
QPushButton{{
background-color: {ThemePalette.COLOR_BG.value};
border-radius: 6px;
border-radius: {RADIUS}px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
@@ -103,7 +107,7 @@ def line_edit_style_main() -> str:
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
border-radius: {RADIUS}px;
font-weight: 500;
text-align: center;
padding: 0px 4px;
@@ -150,7 +154,7 @@ def colored_checkbox_style(
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -184,7 +188,7 @@ def colored_radio_button_style(
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -230,7 +234,7 @@ def color_swatch_style(
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
{bottom_color_str}
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -249,22 +253,22 @@ def color_swatch_style(
QRadioButton::focus{{
outline-style: solid;
outline-width: 2px;
outline-radius: 3px;
outline-radius: {INNER_RADIUS}px;
outline-color: rgba{highlight_color.toTuple()};
}}
"""
def container_style() -> str:
"""Style used for field containers."""
"""Style used for Tile widgets."""
return f"""
QWidget#fieldContainer{{
QWidget#tile{{
border-radius: 4px;
}}
QWidget#fieldContainer::hover{{
QWidget#tile::hover{{
background-color: {ThemePalette.COLOR_HOVER.value};
}}
QWidget#fieldContainer::pressed{{
QWidget#tile::pressed{{
background-color: {ThemePalette.COLOR_PRESSED.value};
}}
"""
@@ -278,7 +282,7 @@ def form_content_style() -> str:
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else ThemePalette.COLOR_BG_LIGHT.value
};
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
font-weight: 500;
padding: 1px;
}}
@@ -313,7 +317,7 @@ def list_button_style(
font-weight: 600;
{"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-radius: {RADIUS}px;
border-style: {border_style};
border-width: 2px;
padding-right: 4px;
@@ -354,7 +358,7 @@ def properties_style() -> str:
font-family: Oxanium;
font-weight: bold;
font-size: 12px;
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
padding-top: 4px;
padding-right: 1px;
padding-bottom: 1px;
@@ -376,7 +380,7 @@ def tag_style(
color: rgba{text_color.toTuple()};
font-weight: 600;
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-radius: {RADIUS}px;
border-style: {border_style};
border-width: 2px;
font-size: 13px;
@@ -419,7 +423,7 @@ def tag_remove_button_style(
color: rgba{text_color.toTuple()};
border-color: rgba{highlight_color.toTuple()};
border-width: 2;
border-radius: 6px;
border-radius: {RADIUS}px;
}}
QPushButton::pressed{{
background: rgba{border_color.toTuple()};
@@ -458,7 +462,7 @@ def inset_container_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-radius: 6px;
border-radius: {RADIUS}px;
}}
"""
@@ -475,8 +479,8 @@ def autofill_scroll_top_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: {RADIUS}px;
border-top-right-radius: {RADIUS}px;
border: none;
}}
"""
@@ -493,8 +497,8 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: {RADIUS}px;
border-top-right-radius: {RADIUS}px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: rgba{Palette.accent().toTuple()};
@@ -513,8 +517,8 @@ def autofill_line_edit_style() -> str:
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
padding: 3px 6px;
border-radius: {RADIUS}px;
padding: {HALF_PAD}px {PAD}px;
}}
QLineEdit::focus{{
padding: 4px 4px;
@@ -538,9 +542,9 @@ def autofill_line_edit_top_style() -> str:
background: {bg_color};
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 0px 0px 2px 6px;
border-bottom-left-radius: {RADIUS}px;
border-bottom-right-radius: {RADIUS}px;
padding: 0px 0px 2px {RADIUS}px;
}}
QLineEdit::focus{{
padding: 4px 4px;
@@ -555,7 +559,7 @@ def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
background: {get_ui_color(ColorType.DARK_ACCENT, UiColor.RED)};
border-radius: 6px;
border-radius: {RADIUS}px;
}}
"""
@@ -598,7 +602,7 @@ def banner_close_button_style() -> str:
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
}}
{_banner_button_hover_style("bannerCloseButton")}
"""
@@ -614,7 +618,7 @@ def banner_action_button_style() -> str:
background-color: rgba{accent.toTuple()};
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
padding: 4px 8px;
outline: none;
}}
@@ -655,7 +659,7 @@ def banner_close_button_progress_style() -> str:
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
border-radius: {INNER_RADIUS}px;
}}
QPushButton#bannerCloseButton::hover {{
background-color: {hover};
@@ -688,6 +692,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()
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import QFrame, QHBoxLayout, QSizePolicy, QVBoxLayout, QWi
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.controllers.horizontal_scroll_area import HorizontalScrollArea
from tagstudio.qt.views.styles.stylesheets import (
PAD,
autofill_line_edit_style,
autofill_scroll_top_style,
)
@@ -36,7 +37,7 @@ class SuggestBoxView(QVBoxLayout):
# Autocomplete ScrollArea
contents = QWidget()
self.content_layout = QHBoxLayout(contents)
self.content_layout.setSpacing(6)
self.content_layout.setSpacing(PAD)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
self.content_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container = QWidget()
-61
View File
@@ -1,61 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterable
from typing import TYPE_CHECKING
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidgetView(FieldWidget):
__lib: Library
def __init__(self, title: str, driver: QtDriver) -> None:
super().__init__(title)
self.__lib = driver.lib
self.__root_layout = FlowLayout()
self.__root_layout.enable_grid_optimizations(value=False)
self.__root_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.__root_layout)
def set_tags(self, tags: Iterable[Tag]) -> None:
tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag))
logger.info("[TagBoxWidget] Tags:", tags=tags)
while self.__root_layout.itemAt(0):
self.__root_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
raise NotImplementedError
def _on_remove(self, tag: Tag) -> None:
raise NotImplementedError
def _on_edit(self, tag: Tag) -> None:
raise NotImplementedError
def _on_search(self, tag: Tag) -> None:
raise NotImplementedError
+3 -3
View File
@@ -10,6 +10,7 @@ from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEve
from PySide6.QtWidgets import QPushButton, QWidget
from tagstudio.qt.views.styles.palette import Palette
from tagstudio.qt.views.styles.stylesheets import RADIUS
# TODO: Use newer MVC style guidelines
@@ -46,7 +47,6 @@ class ThumbButton(QPushButton):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
width = 3
radius = 6
path.addRoundedRect(
QtCore.QRectF(
width / 2,
@@ -54,8 +54,8 @@ class ThumbButton(QPushButton):
self.thumb_size[0] - width,
self.thumb_size[1] - width,
),
radius,
radius,
RADIUS,
RADIUS,
)
if self.selected:
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
class ColorDataView(FlowLayout):
"""The layout used for a ColorData widget."""
def __init__(self) -> None:
super().__init__()
self.enable_grid_optimizations(value=True)
self.setContentsMargins(0, 0, 0, 0)
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
class TagDataView(FlowLayout):
"""The layout used for a TagData widget."""
def __init__(self) -> None:
super().__init__()
self.enable_grid_optimizations(value=False)
self.setContentsMargins(0, 0, 0, 0)
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
class TextDataView(QHBoxLayout):
"""The layout used for a TextData widget."""
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.text_label = QLabel()
self.text_label.setStyleSheet("font-size: 12px")
self.text_label.setWordWrap(True)
self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
self.text_label.setOpenExternalLinks(True)
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.addWidget(self.text_label)
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, inset_container_style
class TileListView(QHBoxLayout):
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(HALF_PAD, HALF_PAD, HALF_PAD, HALF_PAD)
self.scroll_layout.setSpacing(PAD)
scroll_container = QWidget()
scroll_container.setObjectName("tile_scroll_container")
scroll_container.setLayout(self.scroll_layout)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("tile_scroll_area")
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# NOTE: I would rather have this style applied to the scroll_area
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
self.scroll_area.setStyleSheet(inset_container_style("tile_scroll_container"))
self.scroll_area.setWidget(scroll_container)
self.addWidget(self.scroll_area)
+86
View File
@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PIL import ImageQt
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, 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 HALF_PAD
# TODO: There should be a global button theme somewhere.
_BUTTON_SIZE = 22
_ICON_MARGIN = 4
_ICON_SIZE = _BUTTON_SIZE - _ICON_MARGIN
class TileView(QVBoxLayout):
"""The layout used for a Tile widget."""
_rm = ResourceManager()
copy_icon = auto_theme_overlay(_rm.copy, inverse=True)
edit_icon = auto_theme_overlay(_rm.edit, inverse=True)
trash_icon = auto_theme_overlay(_rm.trash, inverse=True)
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.inner_layout = QVBoxLayout()
self.inner_layout.setContentsMargins(HALF_PAD, 0, 0, HALF_PAD)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setLayout(self.inner_layout)
self.addWidget(self.field_container)
self.title_container = QWidget()
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.title_layout.setContentsMargins(0, 0, 0, 0)
self.title_layout.setSpacing(0)
self.inner_layout.addWidget(self.title_container)
self.title_widget = QLabel()
self.title_widget.setMinimumHeight(_BUTTON_SIZE)
self.title_widget.setWordWrap(True)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
self.copy_button = QPushButton()
self.copy_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.copy_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.copy_button.setFlat(True)
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.copy_icon)))
self.copy_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButton()
self.edit_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.edit_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon)))
self.edit_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButton()
self.remove_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.remove_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon)))
self.remove_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
self.data = QWidget()
self.data_layout = QHBoxLayout()
self.data_layout.setContentsMargins(0, 0, 0, 0)
self.data.setLayout(self.data_layout)
self.inner_layout.addWidget(self.data)
+2 -12
View File
@@ -373,22 +373,12 @@
"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.items": "{count} Items {time_span}",
"status.library_save_success": "Library Saved and Closed!",
"status.library_search_query": "Searching Library…",
"status.library_version_expected": "Expected:",
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
"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",
@@ -21,7 +21,7 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
for container in panel.containers._containers:
for container in panel.containers._tiles:
assert container.isHidden()
@@ -33,7 +33,7 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should show all applicable tags and field containers
for container in panel.containers._containers:
for container in panel.containers._tiles:
assert not container.isHidden()
@@ -48,7 +48,7 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should show mixed field editing
for container in panel.containers._containers:
for container in panel.containers._tiles:
assert container.isHidden()
@@ -137,8 +137,8 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -170,8 +170,8 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -206,5 +206,5 @@ def test_exclude_tag_category(
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
assert len(panel.containers._containers) == 1
assert panel.containers._containers[0].title == "<h4>Tags</h4>"
assert len(panel.containers._tiles) == 1
assert panel.containers._tiles[0].title == "<h4>Tags</h4>"