refactor(ui): refactor preview panel into new MVC pattern

This commit is contained in:
Travis Abendshien
2026-07-18 16:20:18 -07:00
parent 55a057ffe6
commit f7b86ebf2a
4 changed files with 188 additions and 194 deletions
@@ -4,16 +4,19 @@
import typing
from pathlib import Path
from typing import override
from warnings import catch_warnings
import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -22,93 +25,189 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class PreviewPanel(PreviewPanelView):
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
super().__init__(driver)
self.__current_stats: FileAttributeData | None = None
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
super().__init__()
self._layout = PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio())
self._driver = driver
self._lib = self._driver.lib
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
key = QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
)
self.add_tag_action = QShortcut(key, self)
self._add_tag_action = QShortcut(key, self)
self.__connect_callbacks()
self.setLayout(self._layout)
self._connect_callbacks()
def __connect_callbacks(self) -> None:
self._add_field_button.clicked.connect(self._add_field_button_callback)
self._add_tag_button.clicked.connect(self._add_tag_button_callback)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
self.add_tag_action.activated.connect(self._add_tag_button.setFocus)
self.add_tag_action.activated.connect(self._add_tag_button.click)
self.tag_search_box.done.connect(self.tag_added_callback)
self.tag_search_box.tags_updated.connect(self.update_added_callback)
def _connect_callbacks(self) -> None:
self._layout.add_field_button.clicked.connect(self._add_field_button_callback)
self._layout.add_tag_button.clicked.connect(self._add_tag_button_callback)
self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
self._add_tag_action.activated.connect(self._layout.add_tag_button.setFocus)
self._add_tag_action.activated.connect(self._layout.add_tag_button.click)
self._layout.tag_search_box.done.connect(self.tag_added_callback)
self._layout.tag_search_box.tags_updated.connect(self._update_added_callback)
self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
def _add_field_button_callback(self) -> None:
# self.__add_field_modal.show()
pass
def _add_tag_button_callback(self) -> None:
self.tag_search_box.added = self._containers.tags
self.tag_search_box.layout().search_field.setDisabled(False)
self.tag_search_box.setHidden(False)
self._add_tag_button.setHidden(True)
self._add_field_button.setHidden(True)
self._layout.tag_search_box.added = self._layout.containers.tags
self._layout.tag_search_box.layout().search_field.setDisabled(False)
self._layout.tag_search_box.setHidden(False)
self._layout.add_tag_button.setHidden(True)
self._layout.add_field_button.setHidden(True)
def tag_added_callback(self):
self.tag_search_box.setHidden(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self._layout.tag_search_box.setHidden(True)
self._layout.add_tag_button.setHidden(False)
self._layout.add_field_button.setHidden(False)
self._add_tag_button.setFocus()
self._layout.add_tag_button.setFocus()
def update_added_callback(self):
self.tag_search_box.added = self._containers.tags
def _update_added_callback(self):
self._layout.tag_search_box.added = self._layout.containers.tags
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._thumb.current_file:
if filepath != self._layout.preview_thumb.current_file:
return
if self.__current_stats is None:
self.__current_stats = FileAttributeData()
if self._current_stats is None:
self._current_stats = FileAttributeData()
if stats.width is not None:
self.__current_stats.width = stats.width
self._current_stats.width = stats.width
if stats.height is not None:
self.__current_stats.height = stats.height
self._current_stats.height = stats.height
if stats.duration is not None:
self.__current_stats.duration = stats.duration
self._current_stats.duration = stats.duration
self._file_attrs.update_stats(filepath, self.__current_stats)
self._layout.file_attrs.update_stats(filepath, self._current_stats)
@override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
self.field_search_box.field_template_chosen.disconnect()
self.tag_search_box.item_chosen.disconnect()
self._layout.field_search_box.field_template_chosen.disconnect()
self._layout.tag_search_box.item_chosen.disconnect()
self.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
self.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
self._layout.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
self._layout.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
self._layout.containers.add_field_to_selected(template)
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
self._layout.containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int) -> None:
self._containers.add_tags_to_selected(tag_id)
self._layout.containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
self._layout.containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
self._layout.warning_banner.show()
return
self._ffmpeg_warning_widget.hide()
self._layout.warning_banner.hide()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
try:
# No Items Selected
if len(selected) == 0:
self._layout.preview_thumb.hide_preview()
self._current_stats = None
self._layout.file_attrs.update_stats()
self._layout.file_attrs.update_date_label()
self._layout.containers.hide_containers()
self._layout.add_tag_button.setEnabled(False)
self._layout.add_field_button.setEnabled(False)
self._layout.add_tag_button.setHidden(False)
self._layout.add_field_button.setHidden(False)
self._layout.tag_search_box.hide_and_reset()
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self._lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
if filepath != self._layout.preview_thumb.current_file:
self._current_stats = None
if update_preview:
stats: FileAttributeData = self._layout.preview_thumb.display_file(filepath)
self._current_stats = stats
self._layout.file_attrs.update_stats(filepath, stats)
self._layout.file_attrs.update_date_label(filepath)
self._layout.containers.update_from_entry(entry_id)
self._set_selection_callback()
self._layout.add_tag_button.setEnabled(True)
self._layout.add_field_button.setEnabled(True)
self._layout.add_tag_button.setHidden(False)
self._layout.add_field_button.setHidden(False)
self._layout.tag_search_box.hide_and_reset()
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self._layout.preview_thumb.hide_preview() # TODO: Render mixed selection
self._current_stats = None
self._layout.file_attrs.update_multi_selection(len(selected))
self._layout.file_attrs.update_date_label()
self._layout.containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
self._layout.add_tag_button.setEnabled(True)
self._layout.add_field_button.setEnabled(True)
self._layout.add_tag_button.setHidden(False)
self._layout.add_field_button.setHidden(False)
self._layout.tag_search_box.hide_and_reset()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the preview panel."""
self._layout.preview_thumb.media_player.stop()
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
field = self._layout.add_field_button.isEnabled()
tag = self._layout.add_tag_button.isEnabled()
assert field == tag
return field
@add_buttons_enabled.setter
def add_buttons_enabled(self, enabled: bool) -> None:
self._layout.add_field_button.setEnabled(enabled)
self._layout.add_tag_button.setEnabled(enabled)
@property
def file_attributes_widget(self) -> FileAttributes: # needed for the tests
"""Getter for the file attributes widget."""
return self._layout.file_attrs
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
"""Getter for the field containers widget."""
return self._layout.containers
+1 -1
View File
@@ -963,7 +963,7 @@ class QtDriver(DriverMixin, QObject):
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
self.main_window.preview_panel.preview_thumb.media_player.stop()
self.main_window.preview_panel.stop_media_playback()
msg = Translations.format(
"status.deleting_file", i=i, count=len(pending), path=f
+38 -141
View File
@@ -3,9 +3,7 @@
import math
import traceback
import typing
from pathlib import Path
import structlog
from PySide6.QtCore import Qt
@@ -13,14 +11,12 @@ from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.preview_thumb_controller 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 FileAttributeData, FileAttributes
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
@@ -33,16 +29,17 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class PreviewPanelView(QWidget):
_selected: list[int]
def __init__(self, driver: "QtDriver") -> None:
class PreviewPanelView(QVBoxLayout):
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
super().__init__()
self._lib = driver.lib
# Init Layout
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
rm = ResourceManager()
# Search/Create Boxes
self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
self._lib,
driver.lib,
is_field_template_chooser=True,
view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
)
@@ -55,11 +52,9 @@ class PreviewPanelView(QWidget):
)
self.tag_search_box.hide()
self._thumb = PreviewThumb(self._lib, driver)
self._file_attrs = FileAttributes(self._lib, driver)
self._containers = FieldContainers(
self._lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = FieldContainers(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
@@ -68,11 +63,11 @@ class PreviewPanelView(QWidget):
preview_layout.setSpacing(6)
# Warning Banner (Missing FFmpeg, etc.)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
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)
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
@@ -85,14 +80,14 @@ class PreviewPanelView(QWidget):
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
)
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self._ffmpeg_warning_widget.hide()
self.warning_banner.hide()
# File Information
info_section = QWidget()
@@ -104,136 +99,38 @@ class PreviewPanelView(QWidget):
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
# 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)
self._add_tag_button = ReturnButton(Translations["tag.add"])
self._add_tag_button.setEnabled(False)
self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._add_tag_button.setMinimumHeight(30)
self._add_tag_button.setStyleSheet(button_style())
self.add_tag_button = ReturnButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_tag_button.setMinimumHeight(30)
self.add_tag_button.setStyleSheet(button_style())
self._add_field_button = ReturnButton(Translations["field.add"])
self._add_field_button.setEnabled(False)
self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._add_field_button.setMinimumHeight(30)
self._add_field_button.setStyleSheet(button_style())
self.add_field_button = ReturnButton(Translations["field.add"])
self.add_field_button.setEnabled(False)
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumHeight(30)
self.add_field_button.setStyleSheet(button_style())
add_buttons_layout.addWidget(self._add_tag_button)
add_buttons_layout.addWidget(self._add_field_button)
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)
add_buttons_layout.addWidget(self.tag_search_box)
# add_buttons_layout.addWidget(self.field_search)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
info_layout.addWidget(self._file_attrs)
info_layout.addWidget(self._containers)
preview_layout.addWidget(self.preview_thumb)
info_layout.addWidget(self.warning_banner)
info_layout.addWidget(self.file_attrs)
info_layout.addWidget(self.containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(splitter)
root_layout.addWidget(add_buttons_container)
def _set_selection_callback(self) -> None:
raise NotImplementedError()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
try:
# No Items Selected
if len(selected) == 0:
self._thumb.hide_preview()
self.__current_stats = None
self._file_attrs.update_stats()
self._file_attrs.update_date_label()
self._containers.hide_containers()
self._add_tag_button.setEnabled(False)
self._add_field_button.setEnabled(False)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self._lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
if update_preview:
stats: FileAttributeData = self._thumb.display_file(filepath)
self.__current_stats = stats
self._file_attrs.update_stats(filepath, stats)
self._file_attrs.update_date_label(filepath)
self._containers.update_from_entry(entry_id)
self._set_selection_callback()
self._add_tag_button.setEnabled(True)
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self._thumb.hide_preview() # TODO: Render mixed selection
self.__current_stats = None
self._file_attrs.update_multi_selection(len(selected))
self._file_attrs.update_date_label()
self._containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
self._add_tag_button.setEnabled(True)
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
traceback.print_exc()
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
field = self._add_field_button.isEnabled()
tag = self._add_tag_button.isEnabled()
assert field == tag
return field
@add_buttons_enabled.setter
def add_buttons_enabled(self, enabled: bool) -> None:
self._add_field_button.setEnabled(enabled)
self._add_tag_button.setEnabled(enabled)
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
"""Getter for the file attributes widget."""
return self._file_attrs
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
"""Getter for the field containers widget."""
return self._containers
@property
def preview_thumb(self) -> PreviewThumb:
return self._thumb
# Finalize Layout
self.addWidget(splitter)
self.addWidget(add_buttons_container)
+2 -4
View File
@@ -74,9 +74,7 @@ def test_file_path_display(
entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
panel._file_attributes_widget.update_stats(
filepath=unwrap(qt_driver.lib.library_dir) / filename
)
panel.file_attributes_widget.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
@@ -94,7 +92,7 @@ def test_file_path_display(
file_str += f"<b>{'\u200b'.join(part_)}</b>"
# Assert the file path is displayed correctly
assert panel._file_attributes_widget.file_label.text() == file_str
assert panel.file_attributes_widget.file_label.text() == file_str
@pytest.mark.parametrize(