Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 66 additions & 4 deletions src/tagstudio/qt/controllers/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QWidget
from PySide6.QtGui import QResizeEvent, QShortcut
from PySide6.QtWidgets import QSplitter, QWidget

from tagstudio.core.library.alchemy.fields import (
BaseField,
Expand Down Expand Up @@ -50,7 +50,10 @@ def __init__(self, driver: QtDriver) -> None:
self._driver = driver
self._lib = self._driver.lib
self._settings = self._driver.settings

self._current_stats: FileAttributeData | None = None
self._preview_height_limit: int | None = None # Max dragged preview area height
self._preview_outdated = False # Preview can become outdated if the Inspector is collapsed

self._open_tag_search_action = QShortcut(
QtCore.QKeyCombination(
Expand Down Expand Up @@ -85,6 +88,8 @@ def _connect_callbacks(self) -> None:
# Previews
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
self.layout().preview_thumb.aspect_ratio_changed.connect(self._fit_preview_height)
self.layout().preview_splitter.splitterMoved.connect(self._on_preview_splitter_moved)

self._driver.entry_state.tags_changed.connect(self._on_tags_changed)
self._driver.selection.changed.connect(self.update_from_selection)
Expand Down Expand Up @@ -257,7 +262,10 @@ def update_from_selection(self, update_preview: bool = True) -> None:
if filepath != self.layout().preview_thumb.current_file:
self._current_stats = None

if update_preview:
if update_preview and self.width() == 0:
self.layout().preview_thumb.hide_preview()
self._preview_outdated = True
elif update_preview:
self.layout().preview_thumb.display_file(filepath, entry.date_modified)
if self._current_stats is None:
self._current_stats = FileAttributeData()
Expand All @@ -280,6 +288,7 @@ def update_from_selection(self, update_preview: bool = True) -> None:

except Exception as e:
logger.error("[Inspector] Error updating selection", error=e)
self._fit_preview_height()

def stop_media_playback(self) -> None:
"""Stop any media playback in the inspector."""
Expand All @@ -289,6 +298,59 @@ def stop_media_playback(self) -> None:
def containers(self) -> TileList:
return self.layout().containers

def _content_height(self) -> int | None:
"""How tall the current preview is when it fills the preview area's width."""
thumb = self.layout().preview_thumb
if thumb.current_file is None or thumb.width() == 0:
return None
return round(thumb.width() / thumb.aspect_ratio)

def _fit_preview_height(self) -> None:
"""Resize the preview area to its content, up to the dragged height or else a square."""
if self.width() == 0:
return
splitter = self.layout().preview_splitter
preview_height, info_height = splitter.sizes()
height = self._preview_height_limit
if height is None:
height = self.layout().preview_thumb.width()
if (content_height := self._content_height()) is not None:
height = min(height, content_height)
if height != preview_height:
splitter.setSizes([height, preview_height + info_height - height])

def _on_preview_splitter_moved(self) -> None:
preview_height = self.layout().preview_splitter.sizes()[0]
content_height = self._content_height()
if content_height is None or preview_height < content_height:
self._preview_height_limit = preview_height
else:
self._preview_height_limit = max(self._preview_height_limit or 0, content_height)
self._fit_preview_height()

@property
def is_preview_showing(self) -> bool:
"""Whether the Inspector is showing a file preview."""
return self.layout().preview_thumb.current_file is not None

@property
def preview_section(self) -> QWidget:
return self.layout().preview_section

@property
def preview_splitter(self) -> QSplitter:
return self.layout().preview_splitter

@override
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._fit_preview_height()
if self._preview_outdated and self.width() > 0:
self._preview_outdated = False
self.update_from_selection()

@override
def layout(self) -> InspectorView:
return super().layout() # pyright: ignore[reportReturnType]
layout = super().layout()
assert isinstance(layout, InspectorView)
return layout
72 changes: 71 additions & 1 deletion src/tagstudio/qt/controllers/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
# SPDX-License-Identifier: GPL-3.0-only


import math
import typing
from typing import override

import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent, QMouseEvent
from PySide6.QtWidgets import QMainWindow, QWidget

Expand All @@ -16,6 +17,7 @@
from tagstudio.qt.controllers.inspector import Inspector
from tagstudio.qt.controllers.menu_bar import MenuBar
from tagstudio.qt.controllers.search_bar import SearchBar
from tagstudio.qt.controllers.splitter_corner_handle import SplitterCornerHandle
from tagstudio.qt.controllers.thumb_grid import ThumbGrid
from tagstudio.qt.mixed.drop_import_modal import DropImportModal
from tagstudio.qt.views.main_window_view import MainWindowView
Expand Down Expand Up @@ -48,8 +50,16 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
central_widget.setObjectName("central_widget")
central_widget.setLayout(MainWindowView(driver, self.devicePixelRatio()))
self.setCentralWidget(central_widget)
self._corner_handle = SplitterCornerHandle(
self.layout().content_splitter,
self.inspector.preview_splitter,
self._corner_handle_content_widget,
central_widget,
snap_outer=self._snapped_splitter_position,
)

self._connect_callbacks()
self.toggle_landing_page(enabled=True)

@override
def layout(self) -> MainWindowView:
Expand All @@ -76,6 +86,9 @@ def _connect_callbacks(self) -> None:
view.thumb_size_combobox.currentIndexChanged.connect(
lambda: self.thumb_grid.set_thumb_size(self.thumb_size)
)
view.content_splitter.splitterMoved.connect(self._snap_content_splitter)
self._driver.selection.changed.connect(self._corner_handle.queue_update)
self.thumb_grid.scroll_area.installEventFilter(self)

@property
def search_bar(self) -> SearchBar:
Expand Down Expand Up @@ -112,6 +125,14 @@ def show_hidden_entries(self) -> bool:
"""Whether to show entries tagged with hidden tags."""
return self.layout().show_hidden_entries_checkbox.isChecked()

def toggle_landing_page(self, enabled: bool) -> None:
"""Show the landing page and hide any library controls and panels."""
view = self.layout()
view.search_bar.setHidden(enabled)
view.extra_input_bar.setHidden(enabled)
view.inspector.setHidden(enabled)
view.browsing_area.toggle_landing_page(enabled)

def set_sorting_mode(self, mode: SortingModeEnum) -> None:
self.layout().sorting_mode_combobox.setCurrentIndex(list(SortingModeEnum).index(mode))

Expand All @@ -130,6 +151,55 @@ def decrease_thumb_size(self) -> None:
if not (new_val + 1) > len(MainWindowView.THUMB_SIZES):
combobox.setCurrentIndex(new_val)

def _snap_content_splitter(self) -> None:
"""Snap the splitter so it fits a whole number of thumbnail grid columns."""
splitter = self.layout().content_splitter
position = splitter.handle(1).x()
extra = position - self._snapped_splitter_position(position)
if extra:
grid_size, inspector_size = splitter.sizes()
splitter.setSizes([grid_size - extra, inspector_size + extra])

def _snapped_splitter_position(self, position: int) -> int:
"""The nearest splitter position that fits a whole number of thumbnail grid columns."""
if not self.thumb_grid.isVisible():
return position
view = self.layout()
shift = position - view.content_splitter.handle(1).x()
grid_size, inspector_size = view.content_splitter.sizes()
grid_size += shift
inspector_size -= shift
if inspector_size <= 0: # Collapsed by dragging the splitter all the way
return position
grid_width = self.thumb_grid.layout().geometry().width() + shift
cell_width = self.thumb_grid.layout().cell_width()
columns = max(1, round(grid_width / cell_width))
# Round down when the Inspector can't shrink enough to fit another column
if (
inspector_size + grid_width - columns * cell_width
< self.inspector.minimumSizeHint().width()
):
columns = max(1, grid_width // cell_width)
# Round up when the grid's side can't shrink enough to lose a column
elif (
grid_size - grid_width + columns * cell_width
< view.central_content.minimumSizeHint().width()
):
columns = math.ceil(grid_width / cell_width)
return position - (grid_width - columns * cell_width)

def _corner_handle_content_widget(self) -> QWidget | None:
if not self.inspector.is_preview_showing:
return None
return self.inspector.preview_section

@override
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
# Snap once the grid has been laid out
if watched is self.thumb_grid.scroll_area and event.type() == QEvent.Type.Show:
QTimer.singleShot(0, self._snap_content_splitter)
return super().eventFilter(watched, event)

@override
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.ForwardButton:
Expand Down
26 changes: 18 additions & 8 deletions src/tagstudio/qt/controllers/preview_thumb.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
logger = structlog.get_logger(__name__)
Image.MAX_IMAGE_PIXELS = None

_DEFAULT_PREVIEW_SIZE = (272, 272)
_THUMB_SIZE_FACTOR = 2


Expand All @@ -63,6 +62,7 @@ class PreviewThumb(QWidget):
_animation_loaded = Signal(Path, object)
_video_probed = Signal(Path, object, object)
check_ffmpeg = Signal(bool)
aspect_ratio_changed = Signal()
stats_updated = Signal(Path, FileAttributeData)

def __init__(self, driver: QtDriver):
Expand All @@ -77,7 +77,7 @@ def __init__(self, driver: QtDriver):
self._current_file: Path | None = None
self._date_modified: float | None = None
self._image_ratio: float = 1.0
self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE
self._preview_size: tuple[int, int] = PreviewThumbView.MINIMUM_SIZE
self._render_timestamp: float = 0.0
self._rendered_res: tuple[int, int] = (0, 0)
self._should_render_on_resize: bool = False
Expand Down Expand Up @@ -157,7 +157,7 @@ def _video_probed_callback(
if preview == _PreviewType.VIDEO and stats is not None:
# Display the video's size and duration
if stats.width and stats.height:
self._image_ratio = stats.width / stats.height
self._set_image_ratio(stats.width / stats.height)
self._update_image_size((self.size().width(), self.size().height()))
self.stats_updated.emit(filepath, stats)
elif preview == _PreviewType.AUDIO:
Expand All @@ -183,9 +183,14 @@ def _file_renderer_updated_callback(

self._source_pixmap = img
self._border_color = border_color
self._image_ratio = img.width() / img.height()
self._set_image_ratio(img.width() / img.height())
self._update_image_size((self.size().width(), self.size().height()), refresh_icon=True)

def _set_image_ratio(self, ratio: float) -> None:
if ratio != self._image_ratio:
self._image_ratio = ratio
self.aspect_ratio_changed.emit()

def _update_icon(self) -> None:
button = self.layout().button_wrapper
if self._source_pixmap.isNull():
Expand Down Expand Up @@ -218,10 +223,10 @@ def _update_image_size(self, size: tuple[int, int], refresh_icon: bool = False)
scaled_height: float = size[1]
# Landscape
if self._image_ratio > 1:
scaled_height = size[0] * (1 / self._image_ratio)
scaled_height = round(size[0] / self._image_ratio)
# Portrait
elif self._image_ratio <= 1:
scaled_width = size[1] * self._image_ratio
scaled_width = round(size[1] * self._image_ratio)

if scaled_width > size[0]:
scaled_height = scaled_height * (size[0] / scaled_width)
Expand Down Expand Up @@ -316,11 +321,12 @@ def _render_placeholder(self, filepath: Path) -> None:
right away instead of a loading graphic.
Some filetypes shouldn't display a loading graphic ever, like fonts.
"""
side = min(self.width(), self.height())
self._file_renderer.render(
self._driver.cache_manager,
time.time(),
filepath,
_DEFAULT_PREVIEW_SIZE,
(side, side),
self.devicePixelRatio(),
is_placeholder=True,
date_modified=self._date_modified,
Expand Down Expand Up @@ -371,7 +377,7 @@ def _play_animation(self, data: bytes, size: tuple[int, int]) -> bool:
self._animation_buffer.close()

self._animation_size = QSize(*size)
self._image_ratio = self._animation_size.width() / self._animation_size.height()
self._set_image_ratio(self._animation_size.width() / self._animation_size.height())

self._animation_buffer.setData(data)
movie = QMovie(self._animation_buffer, QByteArray())
Expand Down Expand Up @@ -437,6 +443,10 @@ def resizeEvent(self, event: QResizeEvent) -> None:

return super().resizeEvent(event)

@property
def aspect_ratio(self) -> float:
return self._image_ratio

@property
def media_player(self) -> MediaPlayer:
return self.layout().media_player
Expand Down
Loading
Loading