refactor(ui): MVC split + rename FieldContainer(s) to EntryDataBox(List)

This commit is contained in:
Travis Abendshien
2026-09-12 08:11:12 -07:00
parent 2471ce41f1
commit 4eac495c46
23 changed files with 335 additions and 333 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
@@ -0,0 +1,98 @@
# 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.entry_data_box_view import EntryDataBoxView
from tagstudio.qt.views.styles.stylesheets import container_style, header
class EntryDataBox(QWidget):
def __init__(self, title: str = "DATA BOX") -> None:
super().__init__()
self.setObjectName("entry_data_box")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
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
self.view = EntryDataBoxView()
self.setLayout(self.view)
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.view.copy_button.clicked.disconnect()
self.copy_callback = callback
if callback:
self.view.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.view.edit_button.clicked.disconnect()
self.edit_callback = callback
if callback:
self.view.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.view.remove_button.clicked.disconnect()
self.remove_callback = callback
if callback:
self.view.remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: QWidget) -> None:
if item := self.view.data_layout.itemAt(0):
old: QWidget = unwrap(item.widget())
self.view.data_layout.removeWidget(old)
old.deleteLater()
self.view.data_layout.addWidget(widget)
def get_inner_widget(self) -> QWidget | None:
if item := self.view.data_layout.itemAt(0):
return item.widget()
return None
def set_title(self, title: str) -> None:
self.title = header(title, 4)
self.view.title_widget.setText(self.title)
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the EntryDataBox if needed.
if self.copy_callback:
self.view.copy_button.setHidden(False)
if self.edit_callback:
self.view.edit_button.setHidden(False)
if self.remove_callback:
self.view.remove_button.setHidden(False)
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.copy_callback:
self.view.copy_button.setHidden(True)
if self.edit_callback:
self.view.edit_button.setHidden(True)
if self.remove_callback:
self.view.remove_button.setHidden(True)
return super().leaveEvent(event)
@override
def resizeEvent(self, event: QResizeEvent) -> None:
self.view.title_widget.setFixedWidth(int(event.size().width() // 1.5))
return super().resizeEvent(event)
@@ -9,16 +9,8 @@ from functools import partial
from warnings import catch_warnings
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,
@@ -31,12 +23,12 @@ from tagstudio.core.library.alchemy.models import Entry, Tag
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.entry_data_box import EntryDataBox
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_box import TagBoxWidget
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.entry_data_box_list_view import EntryDataBoxListView
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 EntryDataBoxList(QWidget):
"""Widget for the tag and field boxes displayed inside the Inspector."""
on_tags_update = Signal()
@@ -57,43 +48,13 @@ class FieldContainers(QWidget):
self.driver: QtDriver = driver
self.initialized = False
self.is_open: bool = False
self.common_fields: list = []
self.mixed_fields: list = []
self.common_fields: list = [] # TODO: Reimplement
self.mixed_fields: list = [] # TODO: Reimplement
self.cached_entries: list[Entry] = []
self._containers: list[FieldContainer] = []
self._data_boxes: list[EntryDataBox] = []
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
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)
self.view = EntryDataBoxListView()
self.setLayout(self.view)
@property
def top_entry_id(self) -> int:
@@ -102,7 +63,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("[EntryDataBoxList] Updating Selection", entry_id=entry_id)
entry = unwrap(self.lib.get_entry_full(entry_id))
self.cached_entries = [entry]
@@ -131,8 +92,8 @@ class FieldContainers(QWidget):
self.write_field_container(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._data_boxes) > container_len:
for i, c in enumerate(self._data_boxes):
if i > (container_len - 1):
c.setHidden(True)
@@ -151,7 +112,7 @@ class FieldContainers(QWidget):
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self._containers:
for c in self._data_boxes:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -215,7 +176,7 @@ class FieldContainers(QWidget):
assert isinstance(field_templates, list)
logger.info(
"[FieldContainers][add_field_to_selected]",
"[EntryDataBoxList][add_field_to_selected]",
selected=self.driver.selected,
fields=[
(field_template.class_name, field_template.id) for field_template in field_templates
@@ -225,7 +186,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",
"[EntryDataBoxList][add_field_to_selected] Adding field",
name=field_template.name,
type=field_template.class_name,
)
@@ -239,7 +200,7 @@ class FieldContainers(QWidget):
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
"[FieldContainers][add_tags_to_selected]",
"[EntryDataBoxList][add_tags_to_selected]",
selected=self.driver.selected,
tag_ids=tag_ids,
)
@@ -267,7 +228,7 @@ class FieldContainers(QWidget):
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.
"""Update/Create data for a field EntryDataBox.
Args:
index(int): The container index.
@@ -277,7 +238,7 @@ class FieldContainers(QWidget):
"""
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
container: EntryDataBox, field: TextField, title: str, is_mixed: bool
):
container.set_title(field.name)
@@ -311,7 +272,7 @@ class FieldContainers(QWidget):
)
def write_datetime_container(
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
container: EntryDataBox, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
@@ -360,19 +321,19 @@ class FieldContainers(QWidget):
)
logger.info(
"[FieldContainers][write_container]",
"[EntryDataBoxList][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._data_boxes) < (index + 1):
container = EntryDataBox()
self._data_boxes.append(container)
self.view.scroll_layout.addWidget(container)
else:
container = self._containers[index]
container = self._data_boxes[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -391,7 +352,7 @@ class FieldContainers(QWidget):
def write_tag_container(
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 EntryDataBox.
Args:
index(int): The container index.
@@ -400,13 +361,13 @@ 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("[EntryDataBoxList][write_tag_container]", index=index)
if len(self._data_boxes) < (index + 1):
container = EntryDataBox()
self._data_boxes.append(container)
self.view.scroll_layout.addWidget(container)
else:
container = self._containers[index]
container = self._data_boxes[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
@@ -441,7 +402,7 @@ class FieldContainers(QWidget):
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
"[EntryDataBoxList] Removing Field",
field=field,
selected=[x.path for x in self.cached_entries],
)
+5 -5
View File
@@ -27,9 +27,9 @@ from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
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.entry_data_box_list import EntryDataBoxList
from tagstudio.qt.controllers.modal import Modal
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 EntryDataBoxList.
# It's possible to reuse it later, after an EntryDataBoxList 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) -> EntryDataBoxList:
return self.layout().containers
@override
+6 -6
View File
@@ -495,8 +495,8 @@ 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():
@@ -676,7 +676,7 @@ class MainWindow(QMainWindow):
self.setup_entry_list(driver)
self.content_splitter.addWidget(self.central_content)
self.setup_preview_panel(driver)
self.setup_inspector(driver)
self.content_splitter.setStretchFactor(0, 1)
self.content_layout.addWidget(self.content_splitter)
@@ -719,9 +719,9 @@ class MainWindow(QMainWindow):
self.pagination = Pagination(self.entry_list_container)
self.central_content_layout.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: QtDriver):
self.preview_panel = Inspector(driver)
self.content_splitter.addWidget(self.preview_panel)
def setup_inspector(self, driver: QtDriver):
self.inspector = Inspector(driver)
self.content_splitter.addWidget(self.inspector)
# endregion
+4 -5
View File
@@ -11,11 +11,10 @@ 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.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.data_box import DataBox
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
@@ -27,7 +26,7 @@ logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class ColorBoxWidget(FieldWidget):
class ColorBoxWidget(DataBox):
updated = Signal()
def __init__(
@@ -59,8 +58,8 @@ 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]
while (item := self.base_layout.itemAt(0)) and (widget := item.widget()):
widget.deleteLater()
for color in colors_:
color_widget = TagColorLabel(
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtWidgets import QWidget
logger = structlog.get_logger(__name__)
class DataBox(QWidget):
def __init__(self, title: str) -> None:
super().__init__()
self.title: str = title
-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,
)
+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 ""
+7 -7
View File
@@ -25,10 +25,10 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.enums import ThemePalette
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.entry_data_box import EntryDataBox
from tagstudio.qt.controllers.modal import Modal
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__)
@@ -126,15 +126,15 @@ 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
),
)
)
field_container = FieldContainer(self.driver.lib.get_namespace_name(group))
field_container.set_inner_widget(color_box)
entry_data_box = EntryDataBox(self.driver.lib.get_namespace_name(group))
entry_data_box.set_inner_widget(color_box)
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
field_container.set_remove_callback(
entry_data_box.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(entry_data_box)
if all_default:
ns_container = QWidget()
+2 -2
View File
@@ -7,11 +7,11 @@ import re
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.data_box import DataBox
# TODO: Split to use MVC guidelines.
class TextContainerWidget(FieldWidget):
class TextContainerWidget(DataBox):
def __init__(self, title: str, text: str) -> None:
super().__init__(title)
self.setObjectName("textBox")
+12 -12
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),
)
)
@@ -800,7 +800,7 @@ class QtDriver(DriverMixin, QObject):
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
@@ -830,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:
@@ -908,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."""
@@ -917,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
@@ -972,7 +972,7 @@ class QtDriver(DriverMixin, QObject):
):
for e_id, f in pending:
if (origin_path == f) or (not origin_path):
self.main_window.preview_panel.stop_media_playback()
self.main_window.inspector.stop_media_playback()
if e_id is not None:
self.lib.remove_entries([e_id])
@@ -1436,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.
@@ -1465,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:
@@ -1872,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(
@@ -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 inset_container_style
class EntryDataBoxListView(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(3, 3, 3, 3)
self.scroll_layout.setSpacing(6)
scroll_container = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
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)
self.addWidget(self.scroll_area)
@@ -0,0 +1,83 @@
# 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
# TODO: There should be a global button theme somewhere.
_BUTTON_SIZE = 22
_ICON_MARGIN = 4
_ICON_SIZE = _BUTTON_SIZE - _ICON_MARGIN
class EntryDataBoxView(QVBoxLayout):
_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(3, 0, 0, 3)
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(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.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 -2
View File
@@ -12,11 +12,11 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidg
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.entry_data_box_list import EntryDataBoxList
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.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.stylesheets import button_style, preview_warning_style
@@ -47,7 +47,7 @@ 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 = EntryDataBoxList(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
+1 -1
View File
@@ -42,7 +42,7 @@ 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)
+3 -3
View File
@@ -258,13 +258,13 @@ def color_swatch_style(
def container_style() -> str:
"""Style used for field containers."""
return f"""
QWidget#fieldContainer{{
QWidget#entry_data_box{{
border-radius: 4px;
}}
QWidget#fieldContainer::hover{{
QWidget#entry_data_box::hover{{
background-color: {ThemePalette.COLOR_HOVER.value};
}}
QWidget#fieldContainer::pressed{{
QWidget#entry_data_box::pressed{{
background-color: {ThemePalette.COLOR_PRESSED.value};
}}
"""
+2 -2
View File
@@ -9,7 +9,7 @@ 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.data_box import DataBox
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
@@ -20,7 +20,7 @@ logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidgetView(FieldWidget):
class TagBoxWidgetView(DataBox):
__lib: Library
def __init__(self, title: str, driver: QtDriver) -> None:
@@ -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._data_boxes:
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._data_boxes:
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._data_boxes:
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._data_boxes) == 3
for i, container in enumerate(panel.containers._data_boxes):
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._data_boxes) == 3
for i, container in enumerate(panel.containers._data_boxes):
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._data_boxes) == 1
assert panel.containers._data_boxes[0].title == "<h4>Tags</h4>"