Skip to content

refactor(preview_thumb): mvc split #978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1193cda
refactor: basic split
Computerdores Jun 7, 2025
caa2a32
fix: renaming and usage test didn't work for the tests
Computerdores Jun 7, 2025
f3cd938
fix: tests
Computerdores Jun 7, 2025
b115a6a
refactor: restructuring
Computerdores Jun 7, 2025
47a365e
refactor: further separation and lots of related changes
Computerdores Jun 7, 2025
f079312
refactor: remove last reference to a widget from controller
Computerdores Jun 7, 2025
1e2864c
refactor: address todo
Computerdores Jun 7, 2025
892eeab
fix: failing tests and mypy compaint
Computerdores Jun 7, 2025
60bd9ac
refactor: move control logic to controller
Computerdores Jun 7, 2025
3bdb69b
refactor: more readable button style
Computerdores Jun 8, 2025
fcc48be
Merge branch 'main' into refactor/mvc-split-preview-panel
Computerdores Jul 4, 2025
18508a1
Merge branch 'main' into refactor/mvc-split-preview-panel
Computerdores Jul 6, 2025
512001d
refactor: move existing code to view
Computerdores Jul 6, 2025
e879374
refactor: move existing code to controller
Computerdores Jul 6, 2025
a0cf0a9
Merge both moves
Computerdores Jul 6, 2025
68145ec
fix: imports
Computerdores Jul 6, 2025
b0dc497
refactor: make methods private by default
Computerdores Jul 6, 2025
1307a09
refactor: privatise fields
Computerdores Jul 6, 2025
b4d8a98
refactor: reduce code duplication
Computerdores Jul 6, 2025
e4e78a8
refactor: consolidate and sort display methods
Computerdores Jul 6, 2025
214e517
refactor: remove needless setting of delete action text
Computerdores Jul 6, 2025
1f533a2
refactor: extract control logic from _display_file
Computerdores Jul 6, 2025
88c7dbe
refactor: use MediaType for __switch_preview
Computerdores Jul 6, 2025
24bcb87
fix: import in preview_panel_view.py
Computerdores Jul 6, 2025
9b84bf1
refactor: remove unnecessary wrapper on view side
Computerdores Jul 6, 2025
306c54c
refactor: move image data retrieval to control side
Computerdores Jul 6, 2025
88bf66c
refactor: move audio / video specific code to the respective method
Computerdores Jul 6, 2025
6dbda48
refactor: remove superfluos methods
Computerdores Jul 6, 2025
6a1be92
refactor: this and that
Computerdores Jul 6, 2025
17e03f4
refactor: use proper type instead of dict for file stats
Computerdores Jul 6, 2025
95b5a4a
refactor: extract gif parsing to controller
Computerdores Jul 6, 2025
2b6d5ae
refactor: extract video size extraction to controller
Computerdores Jul 6, 2025
c3cf796
doc: add rule of thumb to Qt MVC Style Guide
Computerdores Jul 6, 2025
c70ed9b
doc: change rule of thumb from note to tip
Computerdores Jul 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,7 @@ Observe the following key aspects of this example:
- Defines the interface the callbacks
- Enforces that UI events be handled

> [!TIP]
> A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.

[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ qt_api = "pyside6"

[tool.pyright]
ignore = [".venv/**"]
include = ["src/tagstudio/**"]
include = ["src/tagstudio", "tests"]
extraPaths = ["src/tagstudio", "tests"]
reportAny = false
reportIgnoreCommentWithoutRule = false
reportImplicitStringConcatenation = false
Expand Down
2 changes: 1 addition & 1 deletion src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ def get_entry_full(
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable]
tags = set(session.scalars(tag_stmt)) # pyright: ignore[reportPossiblyUnboundVariable]
end_time = time.time()
logger.info(
f"[Library] Time it took to get entry: "
Expand Down
32 changes: 18 additions & 14 deletions src/tagstudio/core/media_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ class MediaCategory:
name: str
is_iana: bool = False

def contains(self, ext: str, mime_fallback: bool = False) -> bool:
"""Check if an extension is a member of this MediaCategory.

Args:
ext (str): File extension with a leading "." and in all lowercase.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
if ext in self.extensions:
return True
elif mime_fallback and self.is_iana:
mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type is not None and mime_type.startswith(self.media_type.value):
return True
return False


class MediaCategories:
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
Expand Down Expand Up @@ -635,16 +650,11 @@ def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
media_types: set[MediaType] = set()
# mime_guess: bool = False

for cat in MediaCategories.ALL_CATEGORIES:
if ext in cat.extensions:
if cat.contains(ext, mime_fallback):
media_types.add(cat.media_type)
elif mime_fallback and cat.is_iana:
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type and mime_type.startswith(cat.media_type.value):
media_types.add(cat.media_type)
# mime_guess = True

return media_types

@staticmethod
Expand All @@ -656,10 +666,4 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool =
media_cat (MediaCategory): The MediaCategory to to check for extension membership.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
if ext in media_cat.extensions:
return True
elif mime_fallback and media_cat.is_iana:
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type and mime_type.startswith(media_cat.media_type.value):
return True
return False
return media_cat.contains(ext, mime_fallback)
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import io
from pathlib import Path
from typing import TYPE_CHECKING

import cv2
import rawpy
import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import QSize

from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories
from tagstudio.qt.helpers.file_opener import open_file
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData

if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

logger = structlog.get_logger(__name__)
Image.MAX_IMAGE_PIXELS = None


class PreviewThumb(PreviewThumbView):
__current_file: Path

def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)

self.__driver: QtDriver = driver

def __get_image_stats(self, filepath: Path) -> FileAttributeData:
"""Get width and height of an image as dict."""
stats = FileAttributeData()
ext = filepath.suffix.lower()

if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True):
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess()
image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
stats.width = image.width
stats.height = image.height
except (
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
FileNotFoundError,
):
pass
elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True):
try:
image = Image.open(str(filepath))
stats.width = image.width
stats.height = image.height
except (
DecompressionBombError,
FileNotFoundError,
NotImplementedError,
UnidentifiedImageError,
) as e:
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True):
pass # TODO

return stats

def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
"""Loads an animated image and returns gif data and size, if successful."""
ext = filepath.suffix.lower()

try:
image: Image.Image = Image.open(filepath)

if ext == ".apng":
image_bytes_io = io.BytesIO()
image.save(
image_bytes_io,
"GIF",
lossless=True,
save_all=True,
loop=0,
disposal=2,
)
image.close()
image_bytes_io.seek(0)
return (image_bytes_io.read(), (image.width, image.height))
else:
image.close()
with open(filepath, "rb") as f:
return (f.read(), (image.width, image.height))

except (UnidentifiedImageError, FileNotFoundError) as e:
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
return None

def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
return (success, QSize(image.width, image.height))

def display_file(self, filepath: Path) -> FileAttributeData:
"""Render a single file preview."""
self.__current_file = filepath

ext = filepath.suffix.lower()

# Video
if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video(
filepath
):
size: QSize | None = None
try:
success, size = self.__get_video_res(str(filepath))
if not success:
size = None
except cv2.error as e:
logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e)

return self._display_video(filepath, size)
# Audio
elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True):
return self._display_audio(filepath)
# Animated Images
elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True):
if (ret := self.__get_gif_data(filepath)) and (
stats := self._display_gif(ret[0], ret[1])
) is not None:
return stats
else:
self._display_image(filepath)
return self.__get_image_stats(filepath)
# Other Types (Including Images)
else:
self._display_image(filepath)
return self.__get_image_stats(filepath)

def _open_file_action_callback(self):
open_file(self.__current_file)

def _open_explorer_action_callback(self):
open_file(self.__current_file, file_manager=True)

def _delete_action_callback(self):
if bool(self.__current_file):
self.__driver.delete_files_callback(self.__current_file)

def _button_wrapper_callback(self):
open_file(self.__current_file)
47 changes: 47 additions & 0 deletions src/tagstudio/qt/controller/widgets/preview_panel_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import typing
from warnings import catch_warnings

from PySide6.QtWidgets import QListWidgetItem

from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.modals.add_field import AddFieldModal
from tagstudio.qt.modals.tag_search import TagSearchModal
from tagstudio.qt.view.widgets.preview_panel_view import PreviewPanelView

if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver


class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)

self.__add_field_modal = AddFieldModal(self.lib)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)

def _add_field_button_callback(self):
self.__add_field_modal.show()

def _add_tag_button_callback(self):
self.__add_tag_modal.show()

def _set_selection_callback(self):
with catch_warnings(record=True):
self.__add_field_modal.done.disconnect()
self.__add_tag_modal.tsp.tag_chosen.disconnect()

self.__add_field_modal.done.connect(self._add_field_to_selected)
self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected)

def _add_field_to_selected(self, field_list: list[QListWidgetItem]):
self._fields.add_field_to_selected(field_list)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])

def _add_tag_to_selected(self, tag_id: int):
self._fields.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
2 changes: 2 additions & 0 deletions src/tagstudio/qt/helpers/qbutton_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class QPushButtonWrapper(QPushButton):
the warning that is triggered by disconnecting a signal that is not currently connected.
"""

is_connected: bool

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_connected = False
2 changes: 1 addition & 1 deletion src/tagstudio/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@

from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.pagination import Pagination
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.landing import LandingWidget
from tagstudio.qt.widgets.preview_panel import PreviewPanel

# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
Expand Down
7 changes: 3 additions & 4 deletions src/tagstudio/qt/modals/build_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.modals.tag_color_selection import TagColorSelection
from tagstudio.qt.modals.tag_search import TagSearchPanel
from tagstudio.qt.modals.tag_search import TagSearchModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
from tagstudio.qt.widgets.tag import (
Expand Down Expand Up @@ -166,9 +166,8 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
if tag is not None:
exclude_ids.append(tag.id)

tsp = TagSearchPanel(self.lib, exclude_ids)
tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.add_tag_modal = PanelModal(tsp, Translations["tag.parent_tags.add"])
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)

# Color ----------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion src/tagstudio/qt/modals/folders_to_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ def __init__(self, library: "Library", driver: "QtDriver"):
def on_apply(self):
folders_to_tags(self.library)
self.close()
self.driver.main_window.preview_panel.update_widgets(update_preview=False)
self.driver.main_window.preview_panel.set_selection(
self.driver.selected, update_preview=False
)

@override
def showEvent(self, event: QtGui.QShowEvent):
Expand Down
2 changes: 1 addition & 1 deletion src/tagstudio/qt/modals/mirror_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def displayed_text(x):
pw.from_iterable_function(
self.mirror_entries_runnable,
displayed_text,
self.driver.main_window.preview_panel.update_widgets,
lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection(s),
self.done.emit,
)

Expand Down
2 changes: 1 addition & 1 deletion src/tagstudio/qt/modals/settings_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ def update_settings(self, driver: "QtDriver"):
# Apply changes
# Show File Path
driver.update_recent_lib_menu()
driver.main_window.preview_panel.update_widgets()
driver.main_window.preview_panel.set_selection(self.driver.selected)
library_directory = driver.lib.library_dir
if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS:
display_path = library_directory or ""
Expand Down
4 changes: 2 additions & 2 deletions src/tagstudio/qt/modals/tag_color_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def setup_color_groups(self):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.fields.update_from_entry(
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
Expand All @@ -141,7 +141,7 @@ def setup_color_groups(self):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.fields.update_from_entry(
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
Expand Down
Loading