diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py index 143c53935..ec6d6106e 100644 --- a/src/tagstudio/qt/controllers/inspector.py +++ b/src/tagstudio/qt/controllers/inspector.py @@ -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, @@ -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( @@ -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) @@ -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() @@ -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.""" @@ -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 diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index 6ea2600c3..008e6c769 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -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 @@ -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 @@ -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: @@ -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: @@ -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)) @@ -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: diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py index 2b75cb3eb..846204a2a 100644 --- a/src/tagstudio/qt/controllers/preview_thumb.py +++ b/src/tagstudio/qt/controllers/preview_thumb.py @@ -43,7 +43,6 @@ logger = structlog.get_logger(__name__) Image.MAX_IMAGE_PIXELS = None -_DEFAULT_PREVIEW_SIZE = (272, 272) _THUMB_SIZE_FACTOR = 2 @@ -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): @@ -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 @@ -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: @@ -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(): @@ -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) @@ -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, @@ -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()) @@ -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 diff --git a/src/tagstudio/qt/controllers/splitter_corner_handle.py b/src/tagstudio/qt/controllers/splitter_corner_handle.py new file mode 100644 index 000000000..24f867c96 --- /dev/null +++ b/src/tagstudio/qt/controllers/splitter_corner_handle.py @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from collections.abc import Callable +from typing import override + +from PySide6.QtCore import QCoreApplication, QEvent, QObject, QPoint, QPointF, QRect, Qt +from PySide6.QtGui import ( + QEnterEvent, + QMouseEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPen, + QRegion, +) +from PySide6.QtWidgets import QSplitter, QWidget + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.views.styles.palette import Palette +from tagstudio.qt.views.styles.stylesheets import HALF_PAD, RADIUS + + +class SplitterCornerHandle(QWidget): + """A corner handle on a side panel's content that moves the panel's two splitters at once.""" + + _BRACKET_SIDE_LENGTH = 18 + _BRACKET_WIDTH = 3 + _BRACKET_OFFSET = HALF_PAD + _BRACKET_WIDTH / 2 + _GRAB_AREA_WIDTH = 18 # Invisible grab area width + + def __init__( + self, + outer_splitter: QSplitter, + inner_splitter: QSplitter, + content_widget: Callable[[], QWidget | None], + parent: QWidget, + snap_outer: Callable[[int], int] | None = None, + ) -> None: + super().__init__(parent) + self._parent = parent + self._outer_splitter = outer_splitter + self._inner_splitter = inner_splitter + self._content_widget = content_widget + self._snap_outer = snap_outer + self._watched: set[QWidget] = set() + self._is_hovered = False + self._corner = QPointF() + self._drag_start: tuple[QPointF, int, int] | None = None + self._collapsible: list[tuple[QSplitter, int, bool]] = [] + + size = self._GRAB_AREA_WIDTH // 2 + self._BRACKET_SIDE_LENGTH + self._BRACKET_WIDTH + self.resize(size, size) + + outer_splitter.splitterMoved.connect(self.queue_update) + inner_splitter.splitterMoved.connect(self.queue_update) + widget: QWidget | None = inner_splitter + while widget is not None and widget is not parent: + self._watch(widget) + widget = widget.parentWidget() + self.queue_update() + + def _watch(self, widget: QWidget) -> None: + if widget not in self._watched: + widget.installEventFilter(self) + self._watched.add(widget) + + def _outer_handle(self) -> tuple[int, int]: + """The outer handle beside the panel, and 1 if the panel is to its right or -1 if left.""" + panel = next( + i + for i in range(self._outer_splitter.count()) + if unwrap(self._outer_splitter.widget(i)).isAncestorOf(self._inner_splitter) + ) + return (panel, 1) if panel > 0 else (1, -1) + + def _vertex(self, direction: int) -> QPoint: + """Where the grab strips cross in this widget's coordinates.""" + inset = self._GRAB_AREA_WIDTH // 2 + x = inset if direction > 0 else self.width() - inset + return QPoint(x, self.height() - inset) + + def queue_update(self) -> None: + """Schedule an update of this handle's position and visibility.""" + # NOTE: Queuing lets Qt drop requests made while one is already pending. + QCoreApplication.postEvent(self, QEvent(QEvent.Type.LayoutRequest)) + + @override + def event(self, event: QEvent) -> bool: + if event.type() == QEvent.Type.LayoutRequest: + self._update_geometry() + return True + return super().event(event) + + def _update_geometry(self) -> None: + content_widget = self._content_widget() + handle_index, direction = self._outer_handle() + if ( + content_widget is None + or not self._outer_splitter.handle(handle_index).isVisible() + or self._inner_splitter.width() == 0 + ): + self.hide() + return + self._watch(content_widget) + + content_rect = QRect( + content_widget.mapTo(self._parent, QPoint(0, 0)), content_widget.size() + ) + gap = self._BRACKET_OFFSET + corner_x = ( + content_rect.x() - gap + if direction > 0 + else content_rect.x() + content_rect.width() + gap + ) + self._corner = QPointF(corner_x, content_rect.y() + content_rect.height() + gap) + vertex = self._vertex(direction) + self.move((self._corner - vertex).toPoint()) + + # Masks the grabbable area to roughly the handle area, so the empty space in front of the + # content can be clicked through and doesn't intercept the grab. + grab = self._GRAB_AREA_WIDTH + mask = QRegion(vertex.x() - grab // 2, 0, grab, self.height()) + mask = mask.united(QRegion(0, vertex.y() - grab // 2, self.width(), grab)) + self.setMask(mask) + + self.setCursor( + Qt.CursorShape.SizeBDiagCursor if direction > 0 else Qt.CursorShape.SizeFDiagCursor + ) + self.show() + self.raise_() + + @override + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if event.type() in ( + QEvent.Type.Move, + QEvent.Type.Resize, + QEvent.Type.Show, + QEvent.Type.Hide, + ): + self.queue_update() + if ( + event.type() in (QEvent.Type.Enter, QEvent.Type.Leave) + and watched is self._content_widget() + ): + self.update() + return super().eventFilter(watched, event) + + @override + def enterEvent(self, event: QEnterEvent) -> None: + self._is_hovered = True + self.update() + super().enterEvent(event) + + @override + def leaveEvent(self, event: QEvent) -> None: + self._is_hovered = False + self.update() + super().leaveEvent(event) + + @override + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() != Qt.MouseButton.LeftButton: + super().mousePressEvent(event) + return + handle_index, _ = self._outer_handle() + self._drag_start = ( + event.globalPosition(), + self._outer_splitter.handle(handle_index).x(), + self._inner_splitter.handle(1).y(), + ) + # Don't allow panel to be closed by the corner handle, it's weird otherwise. + self._collapsible = [ + (splitter, index, splitter.isCollapsible(index)) + for splitter in (self._outer_splitter, self._inner_splitter) + for index in range(splitter.count()) + ] + for splitter, index, _ in self._collapsible: + splitter.setCollapsible(index, False) # noqa: FBT003 + self.update() + + @override + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._drag_start is None: + super().mouseMoveEvent(event) + return + press, outer_start, inner_start = self._drag_start + delta = (event.globalPosition() - press).toPoint() + handle_index, _ = self._outer_handle() + outer_position = outer_start + delta.x() + if self._snap_outer is not None: + outer_position = self._snap_outer(outer_position) + if outer_position != self._outer_splitter.handle(handle_index).x(): + self._outer_splitter.moveSplitter(outer_position, handle_index) + inner_position = inner_start + delta.y() + if inner_position != self._inner_splitter.handle(1).y(): + self._inner_splitter.moveSplitter(inner_position, 1) + + @override + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + self._drag_start = None + for splitter, index, collapsible in self._collapsible: + splitter.setCollapsible(index, collapsible) + self._collapsible = [] + self.update() + super().mouseReleaseEvent(event) + + @override + def paintEvent(self, event: QPaintEvent) -> None: + content_widget = self._content_widget() + is_content_hovered = content_widget is not None and content_widget.underMouse() + if not (self._is_hovered or is_content_hovered or self._drag_start is not None): + return + _, direction = self._outer_handle() + vertex = self._corner - QPointF(self.pos()) + radius = RADIUS + self._BRACKET_OFFSET + side = self._BRACKET_SIDE_LENGTH + + path = QPainterPath(vertex - QPointF(0, side)) + path.lineTo(vertex - QPointF(0, radius)) + path.quadTo(vertex, vertex + QPointF(direction * radius, 0)) + path.lineTo(vertex + QPointF(direction * side, 0)) + + pen = QPen(Palette.accent(), self._BRACKET_WIDTH) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(pen) + painter.drawPath(path) diff --git a/src/tagstudio/qt/controllers/tiles/tile.py b/src/tagstudio/qt/controllers/tiles/tile.py index 080d067e5..13f00bb1c 100644 --- a/src/tagstudio/qt/controllers/tiles/tile.py +++ b/src/tagstudio/qt/controllers/tiles/tile.py @@ -32,7 +32,9 @@ def __init__(self, title: str = "TILE") -> None: @override def layout(self) -> TileView: - return super().layout() # pyright: ignore[reportReturnType] + layout = super().layout() + assert isinstance(layout, TileView) + return layout def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None: with catch_warnings(record=True): @@ -100,5 +102,5 @@ def leaveEvent(self, event: QEvent) -> None: @override def resizeEvent(self, event: QResizeEvent) -> None: - self.layout().title_widget.setFixedWidth(int(event.size().width() // 1.5)) + self.layout().title_widget.setMaximumWidth(int(event.size().width() // 1.5)) return super().resizeEvent(event) diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index d69bf20e4..87d431a0e 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -482,7 +482,6 @@ def init_library_window(self): # NOTE: Putting this too early will result in a non-responsive white window on start. self.main_window.show() self.main_window.activateWindow() - self.main_window.browsing_area.toggle_landing_page(enabled=True) self.main_window.browsing_area.pagination.index.connect( lambda i: self.page_move(i, absolute=True) @@ -602,7 +601,7 @@ def close_library(self, is_shutdown: bool = False): self.library_info_window.close() self.main_window.thumb_grid.set_entries([]) - self.main_window.browsing_area.toggle_landing_page(enabled=True) + self.main_window.toggle_landing_page(enabled=True) self.main_window.browsing_area.landing_page.animate_logo_in() self.main_window.browsing_area.pagination.setHidden(True) self.main_window.menu_bar.set_library_actions_enabled(False) @@ -1381,7 +1380,7 @@ def _init_library(self, path: Path, open_status: OpenLibraryResult): ) self.update_browsing_state(initial_state) - self.main_window.browsing_area.toggle_landing_page(enabled=False) + self.main_window.toggle_landing_page(enabled=False) self.main_window.thumb_grid.scroll_area.setFocus() return open_status diff --git a/src/tagstudio/qt/views/browsing_area_view.py b/src/tagstudio/qt/views/browsing_area_view.py index feba1ec84..c12f3dbd3 100644 --- a/src/tagstudio/qt/views/browsing_area_view.py +++ b/src/tagstudio/qt/views/browsing_area_view.py @@ -33,5 +33,4 @@ def __init__(self, driver: QtDriver, pixel_ratio: float, pagination: Pagination) self.addWidget(self.thumb_grid.scroll_area) self.landing_page = LandingPage(pixel_ratio) - self.landing_page.setContentsMargins(WIN_PAD, 0, 0, 0) self.addWidget(self.landing_page) diff --git a/src/tagstudio/qt/views/inspector_view.py b/src/tagstudio/qt/views/inspector_view.py index 91bb95d43..6fa177821 100644 --- a/src/tagstudio/qt/views/inspector_view.py +++ b/src/tagstudio/qt/views/inspector_view.py @@ -56,8 +56,8 @@ def ph_text(key: str) -> str: self.containers = TileList(driver.lib, driver) # Visual Preview - preview_section = QWidget() - preview_layout = QVBoxLayout(preview_section) + self.preview_section = QWidget() + preview_layout = QVBoxLayout(self.preview_section) preview_layout.setContentsMargins(0, 0, 0, 0) preview_layout.setSpacing(PAD) @@ -94,9 +94,9 @@ def ph_text(key: str) -> str: info_layout.setContentsMargins(0, 0, 0, 0) info_layout.setSpacing(PAD) - splitter = QSplitter() - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setHandleWidth(PAD * 2) + self.preview_splitter = QSplitter() + self.preview_splitter.setOrientation(Qt.Orientation.Vertical) + self.preview_splitter.setHandleWidth(PAD * 2) # Add Tag/Field Buttons add_buttons_container = QWidget() @@ -127,9 +127,9 @@ def ph_text(key: str) -> str: info_layout.addWidget(self.file_attrs) info_layout.addWidget(self.containers) - splitter.addWidget(preview_section) - splitter.addWidget(info_section) - splitter.setStretchFactor(1, 2) + self.preview_splitter.addWidget(self.preview_section) + self.preview_splitter.addWidget(info_section) + self.preview_splitter.setStretchFactor(1, 2) - self.addWidget(splitter) + self.addWidget(self.preview_splitter) self.addWidget(add_buttons_container) diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py index 29152fe07..4c955dcaf 100644 --- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py +++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py @@ -71,6 +71,10 @@ def ensure_visible(self, index: int, bottom_padding: int = 0) -> None: def items_per_row(self) -> int: return self._grid_metrics(self.geometry().width())[0] + def cell_width(self) -> int: + """The width of an item plus the spacing after it.""" + return self._grid_metrics(self.geometry().width())[1].width() + def indices_in_rect(self, rect: QRect) -> list[int]: """The indices of items that intersect with `rect`.""" # No items means no items diff --git a/src/tagstudio/qt/views/main_window_view.py b/src/tagstudio/qt/views/main_window_view.py index 6cfaf2316..76ca525d2 100644 --- a/src/tagstudio/qt/views/main_window_view.py +++ b/src/tagstudio/qt/views/main_window_view.py @@ -37,6 +37,7 @@ class MainWindowView(QHBoxLayout): ("home.thumbnail_size.mini", 76), ] + extra_input_bar: QWidget extra_input_layout: QHBoxLayout results_label: QLabel show_hidden_entries_widget: QWidget @@ -56,26 +57,29 @@ def __init__(self, driver: QtDriver, pixel_ratio: float) -> None: self.browsing_area = BrowsingArea(driver, pixel_ratio) self.inspector = Inspector(driver) - central_content = QWidget() - central_content.setObjectName("central_content") - central_content_layout = QVBoxLayout(central_content) + self.central_content = QWidget() + self.central_content.setObjectName("central_content") + central_content_layout = QVBoxLayout(self.central_content) central_content_layout.setContentsMargins(0, 0, 0, 0) central_content_layout.setSpacing(PAD) central_content_layout.addWidget(self.search_bar) - central_content_layout.addLayout(self.extra_input_layout) + central_content_layout.addWidget(self.extra_input_bar) central_content_layout.addWidget(self.browsing_area) self.content_splitter = QSplitter() self.content_splitter.setObjectName("content_splitter") self.content_splitter.setHandleWidth(PAD + HALF_PAD) - self.content_splitter.addWidget(central_content) + self.content_splitter.addWidget(self.central_content) self.content_splitter.addWidget(self.inspector) self.content_splitter.setStretchFactor(0, 1) + self.content_splitter.setCollapsible(0, False) # noqa: FBT003 self.addWidget(self.content_splitter) def setup_extra_input_bar(self): """Sets up inputs for sorting settings and thumbnail size.""" - self.extra_input_layout = QHBoxLayout() + self.extra_input_bar = QWidget() + self.extra_input_bar.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.extra_input_layout = QHBoxLayout(self.extra_input_bar) self.extra_input_layout.setObjectName("extra_input_layout") self.extra_input_layout.setContentsMargins(WIN_PAD, 0, 0, 0) diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index cf4d5f0a9..24d729a3f 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -17,12 +17,12 @@ if TYPE_CHECKING: from tagstudio.qt.qt_driver import QtDriver -_DEFAULT_PREVIEW_SIZE = (272, 272) - class PreviewThumbView(QStackedLayout): """The layout for the file preview thumbnail widget.""" + MINIMUM_SIZE = (128, 128) + def __init__(self, driver: QtDriver) -> None: super().__init__() self.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -36,7 +36,7 @@ def __init__(self, driver: QtDriver) -> None: ) self.button_wrapper = QPushButton() - self.button_wrapper.setMinimumSize(*_DEFAULT_PREVIEW_SIZE) + self.button_wrapper.setMinimumSize(*self.MINIMUM_SIZE) self.button_wrapper.setFlat(True) self.button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.button_wrapper.addAction(self.open_file_action) @@ -49,7 +49,7 @@ def __init__(self, driver: QtDriver) -> None: self._stacked_page_setup(self.preview_img_page, self.button_wrapper) self.preview_animation = QLabel() - self.preview_animation.setMinimumSize(*_DEFAULT_PREVIEW_SIZE) + self.preview_animation.setMinimumSize(*self.MINIMUM_SIZE) self.preview_animation.setScaledContents(True) self.preview_animation.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.preview_animation.setCursor(Qt.CursorShape.ArrowCursor) diff --git a/src/tagstudio/qt/views/tiles/tile_view.py b/src/tagstudio/qt/views/tiles/tile_view.py index c79f8fa11..402c1dd98 100644 --- a/src/tagstudio/qt/views/tiles/tile_view.py +++ b/src/tagstudio/qt/views/tiles/tile_view.py @@ -41,14 +41,14 @@ def __init__(self) -> None: 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.title_layout.addWidget(self.title_widget, stretch=1) + self.title_layout.addStretch() 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(auto_theme_overlay(self._rm.copy, inverse=True)) + self.copy_button.setIcon(auto_theme_overlay(self._rm.copy)) self.copy_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE)) self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.copy_button) @@ -58,7 +58,7 @@ def __init__(self) -> None: 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(auto_theme_overlay(self._rm.edit, inverse=True)) + self.edit_button.setIcon(auto_theme_overlay(self._rm.edit)) self.edit_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE)) self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.edit_button) @@ -68,7 +68,7 @@ def __init__(self) -> None: 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(auto_theme_overlay(self._rm.trash, inverse=True)) + self.remove_button.setIcon(auto_theme_overlay(self._rm.trash)) self.remove_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE)) self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.remove_button)