mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-20 12:36:19 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0bf6792ed | |||
| 0fbcfdb1ea | |||
| 111ea76884 | |||
| 4aa7b9e043 | |||
| e4851678e1 | |||
| c488e53144 | |||
| 8e3c0fb092 | |||
| 37f8eeac6f | |||
| b83216b11e | |||
| f29f691a79 | |||
| c8ba9b15c2 | |||
| f7b86ebf2a | |||
| 55a057ffe6 | |||
| 210b469bd2 | |||
| d98beaf444 | |||
| 669b95d580 | |||
| cb3164a5ff | |||
| f2cecb2648 | |||
| 34d00d7e9a | |||
| fdc01b7491 | |||
| 51a9c16f50 | |||
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a | |||
| aa2d9d4815 | |||
| 16cfa8d2ff | |||
| 9d5200b2f2 | |||
| f252a86fd5 | |||
| a0fb679729 | |||
| b182b2ff7e | |||
| 308b36b31e |
@@ -8,26 +8,29 @@ on:
|
||||
paths: &on_paths
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/checks_python.yml
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
- .editorconfig
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- 'src/tagstudio/resources/**'
|
||||
- '**.pyi?'
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
paths: *on_paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
run-conditions:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
name: Run Conditions
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -36,14 +39,25 @@ jobs:
|
||||
ruff: ${{ steps.run-conditions.outputs.ruff }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Largest positive number; infinite depth.
|
||||
# Using 0 would grab all branches.
|
||||
# See: https://github.com/actions/checkout/issues/520
|
||||
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
|
||||
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
|
||||
fetch-depth: '2147483647'
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v47.0.6
|
||||
with:
|
||||
fail_on_initial_diff_error: 'true'
|
||||
fail_on_submodule_diff_error: 'true'
|
||||
skip_initial_fetch: 'true'
|
||||
|
||||
# WARNING: Does not support `?` glob operand!
|
||||
files_yaml: |
|
||||
generic:
|
||||
- .github/workflows/checks_python.yml
|
||||
@@ -55,20 +69,51 @@ jobs:
|
||||
- .github/actions/setup-python/action.yml
|
||||
pytest:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- 'src/tagstudio/resources/**'
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
ruff:
|
||||
- .editorconfig
|
||||
|
||||
- name: Set run conditions
|
||||
id: run-conditions
|
||||
env:
|
||||
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
|
||||
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
|
||||
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
|
||||
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
|
||||
run: |
|
||||
pyright=false
|
||||
pytest=false
|
||||
ruff=false
|
||||
if [ "${CHANGED_GENERIC}" = true ]; then
|
||||
pyright=true
|
||||
pytest=true
|
||||
ruff=true
|
||||
else
|
||||
if [ "${CHANGED_PYRIGHT}" = true ]; then
|
||||
pyright=true
|
||||
fi
|
||||
if [ "${CHANGED_PYTEST}" = true ]; then
|
||||
pytest=true
|
||||
fi
|
||||
if [ "${CHANGED_RUFF}" = true ]; then
|
||||
ruff=true
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF >>"${GITHUB_OUTPUT}"
|
||||
pyright=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pyright_any_changed }}
|
||||
pytest=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pytest_any_changed }}
|
||||
ruff=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.ruff_any_changed }}
|
||||
pyright=${pyright}
|
||||
pytest=${pytest}
|
||||
ruff=${ruff}
|
||||
EOF
|
||||
|
||||
check-pyright:
|
||||
concurrency:
|
||||
group: pyright-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Pyright
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.pyright == 'true'
|
||||
@@ -89,6 +134,9 @@ jobs:
|
||||
run: pyright
|
||||
|
||||
check-pytest:
|
||||
concurrency:
|
||||
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -151,6 +199,10 @@ jobs:
|
||||
run: pytest
|
||||
|
||||
check-ruff:
|
||||
concurrency:
|
||||
group: ruff-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Ruff
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.ruff == 'true'
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
---
|
||||
name: REUSE Compliance Check
|
||||
|
||||
on: [pull_request, push]
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in
|
||||
|
||||
## Creating Tags
|
||||
|
||||
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
|
||||
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>N</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
|
||||
|
||||
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
|
||||
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 202
|
||||
DB_VERSION: int = 300
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
|
||||
|
||||
def make_tables(engine: Engine) -> None:
|
||||
logger.info("[Library] Creating DB tables...")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
with engine.connect() as conn:
|
||||
# TODO: this should instead be migrations that create the exact tables that were added in
|
||||
# the respective DB versions
|
||||
Base.metadata.create_all(conn)
|
||||
conn.commit()
|
||||
|
||||
# TODO: this needs to be a migration
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,23 +182,11 @@ class Tag(Base):
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
# TODO - implement this
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
uuid: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
@@ -235,7 +223,6 @@ class Entry(Base):
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt | None = None,
|
||||
@@ -244,7 +231,6 @@ class Entry(Base):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
@@ -16,7 +16,6 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
|
||||
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -41,7 +40,6 @@ class RefreshTracker:
|
||||
entries = [
|
||||
Entry(
|
||||
path=entry_path,
|
||||
folder=unwrap(self.library.folder),
|
||||
fields=[],
|
||||
date_added=dt.now(),
|
||||
)
|
||||
|
||||
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
|
||||
return vcur.patch < vlat.patch
|
||||
else:
|
||||
return vcur.prerelease is not None or vcur.build is not None
|
||||
|
||||
|
||||
def format_duration(duration: int | float) -> str:
|
||||
"""Format a duration in seconds as M:SS or H:MM:SS."""
|
||||
try:
|
||||
seconds = int(float(duration))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
|
||||
except (OverflowError, ValueError):
|
||||
return "-:--"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
autofill_scroll_top_focus_style,
|
||||
autofill_scroll_top_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AutofillLineEdit(QLineEdit):
|
||||
return_pressed = Signal()
|
||||
shift_return_pressed = Signal()
|
||||
shift_holding = Signal(bool)
|
||||
|
||||
def __init__(self, popup: QWidget) -> None:
|
||||
super().__init__()
|
||||
self._popup = popup
|
||||
|
||||
@override
|
||||
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
|
||||
return super().focusOutEvent(arg__1)
|
||||
|
||||
@override
|
||||
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
|
||||
return super().focusInEvent(arg__1)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.shift_holding.emit(True) # noqa: FBT003
|
||||
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.setText("")
|
||||
self.clearFocus()
|
||||
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
|
||||
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
|
||||
self.shift_return_pressed.emit()
|
||||
else:
|
||||
self.return_pressed.emit()
|
||||
|
||||
return super().keyPressEvent(arg__1)
|
||||
|
||||
@override
|
||||
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.shift_holding.emit(False) # noqa: FBT003
|
||||
return super().keyReleaseEvent(arg__1)
|
||||
@@ -0,0 +1,160 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QAction, Qt
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.suggest_box import SuggestBox
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
|
||||
super().__init__(driver, view=view or SuggestBoxView())
|
||||
self._lib = self._driver.lib
|
||||
|
||||
# Context Menu Actions
|
||||
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
|
||||
edit_field_on_add_action.setCheckable(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.addAction(edit_field_on_add_action)
|
||||
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.layout().search_field.addAction(edit_field_on_add_action)
|
||||
edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add)
|
||||
edit_field_on_add_action.triggered.connect(
|
||||
lambda checked: self.toggle_edit_on_field_add(checked)
|
||||
)
|
||||
|
||||
def toggle_edit_on_field_add(self, checked: bool) -> None:
|
||||
"""Toggle the setting for opening the edit window after adding a field."""
|
||||
self._driver.settings.edit_field_on_add = checked
|
||||
self._driver.settings.save()
|
||||
|
||||
@override
|
||||
def _on_item_create(self) -> None:
|
||||
"""Opens panel to create a new field template and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
|
||||
# since the user needs to decide what type of field it should be before it's created.
|
||||
query: str = self.layout().search_field.text()
|
||||
panel = EditFieldTemplateModal()
|
||||
modal = PanelModal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self._create_item_from_modal(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel, item.name, Translations["field_template.edit"], is_savable=True
|
||||
)
|
||||
|
||||
modal.saved.connect(lambda: self._edit_item(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
|
||||
self.item_chosen.emit(item)
|
||||
self.done.emit()
|
||||
|
||||
@override
|
||||
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
|
||||
if query != "":
|
||||
return self._lib.search_field_templates(name=query, limit=0), []
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
|
||||
"""Set the field template of a field template widget at a specific index."""
|
||||
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
|
||||
field_template_widget = underlined_widget.widget
|
||||
assert isinstance(field_template_widget, FieldTemplateWidget)
|
||||
field_template_widget.has_remove = False
|
||||
field_template_widget.set_field_template(item)
|
||||
underlined_widget.setHidden(item is None)
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# TODO: Add tabbing to different items, and use underline to indicate which will be added
|
||||
underlined_widget.toggle_underline(index != 0)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
field_template_widget.on_edit.disconnect()
|
||||
field_template_widget.on_remove.disconnect()
|
||||
field_template_widget.on_click.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
|
||||
field_template_widget.on_click.connect(
|
||||
lambda checked=False, item_=item: self._on_item_chosen(item_)
|
||||
)
|
||||
|
||||
@override
|
||||
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
self._lib.add_field_template(template)
|
||||
self._on_item_chosen(template)
|
||||
self._clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self._on_search_query_changed(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
self._lib.update_field_template(
|
||||
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().content_layout.count() <= index:
|
||||
while self.layout().content_layout.count() <= index:
|
||||
field_template_widget = FieldTemplateWidget()
|
||||
widget = UnderlinedWidget(field_template_widget)
|
||||
widget.setHidden(True)
|
||||
self.layout().content_layout.addWidget(widget)
|
||||
|
||||
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
|
||||
assert isinstance(widget_, UnderlinedWidget)
|
||||
return widget_
|
||||
@@ -3,63 +3,280 @@
|
||||
|
||||
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
import structlog
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QShortcut
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
DatetimeField,
|
||||
DatetimeFieldTemplate,
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
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.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
class PreviewPanel(PreviewPanelView):
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
class _ItemMode(IntEnum):
|
||||
TAG = 1
|
||||
FIELD = 2
|
||||
|
||||
|
||||
class PreviewPanel(QWidget):
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._lib = self._driver.lib
|
||||
self._selected: list[int]
|
||||
self._current_stats: FileAttributeData | None = None
|
||||
|
||||
self._open_tag_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
),
|
||||
self,
|
||||
)
|
||||
self._open_field_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_L,
|
||||
),
|
||||
self,
|
||||
)
|
||||
self.__add_tag_modal.tsp.set_driver(driver)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
@typing.override
|
||||
def _add_field_button_callback(self) -> None:
|
||||
self.__add_field_modal.show()
|
||||
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
|
||||
self._set_item_mode(None)
|
||||
self._connect_callbacks()
|
||||
|
||||
@typing.override
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
self.__add_tag_modal.show()
|
||||
def _connect_callbacks(self) -> None:
|
||||
# Tag Search
|
||||
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
|
||||
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
|
||||
self.layout().tag_search_box.done.connect(self._tag_added_callback)
|
||||
self.layout().containers.on_tags_update.connect(self._update_added_callback)
|
||||
|
||||
# Field Search
|
||||
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
|
||||
self._open_field_search_action.activated.connect(self._open_field_search_callback)
|
||||
self.layout().field_search_box.done.connect(self._field_added_callback)
|
||||
|
||||
# Previews
|
||||
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
|
||||
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
def _set_item_mode(self, mode: _ItemMode | None):
|
||||
def hide_and_disable_buttons():
|
||||
self.layout().add_tag_button.setHidden(True)
|
||||
self.layout().add_tag_button.setEnabled(False)
|
||||
self.layout().add_field_button.setHidden(True)
|
||||
self.layout().add_field_button.setEnabled(False)
|
||||
|
||||
def restore_buttons():
|
||||
self.layout().add_tag_button.setHidden(False)
|
||||
self.layout().add_tag_button.setEnabled(True)
|
||||
self.layout().add_field_button.setHidden(False)
|
||||
self.layout().add_field_button.setEnabled(True)
|
||||
|
||||
if mode == _ItemMode.TAG:
|
||||
self.layout().tag_search_box.added = self.layout().containers.tags
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
self.layout().tag_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
elif mode == _ItemMode.FIELD:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
else:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
restore_buttons()
|
||||
|
||||
def _open_tag_search_callback(self) -> None:
|
||||
self.layout().add_tag_button.setFocus()
|
||||
self.layout().add_tag_button.click()
|
||||
|
||||
def _open_field_search_callback(self) -> None:
|
||||
self.layout().add_field_button.setFocus()
|
||||
self.layout().add_field_button.click()
|
||||
|
||||
def _tag_added_callback(self):
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_tag_button.setFocus()
|
||||
|
||||
def _field_added_callback(self):
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_field_button.setFocus()
|
||||
|
||||
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:
|
||||
if len(self._selected) != 1:
|
||||
return
|
||||
|
||||
if filepath != self.layout().preview_thumb.current_file:
|
||||
return
|
||||
|
||||
if self._current_stats is None:
|
||||
self._current_stats = FileAttributeData()
|
||||
|
||||
if stats.width is not None:
|
||||
self._current_stats.width = stats.width
|
||||
if stats.height is not None:
|
||||
self._current_stats.height = stats.height
|
||||
if stats.duration is not None:
|
||||
self._current_stats.duration = stats.duration
|
||||
|
||||
self.layout().file_attrs.update_stats(filepath, self._current_stats)
|
||||
|
||||
@typing.override
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
|
||||
self.__add_tag_modal.tsp.item_chosen.disconnect()
|
||||
self.layout().field_search_box.item_chosen.disconnect()
|
||||
self.layout().tag_search_box.item_chosen.disconnect()
|
||||
|
||||
self.__add_field_modal.search_panel.field_template_chosen.connect(
|
||||
self._add_field_to_selected
|
||||
)
|
||||
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
|
||||
self.layout().field_search_box.item_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)
|
||||
# TODO: Allow editing of fields across multiple entries at once.
|
||||
if len(self._selected) == 1:
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
if self._driver.settings.edit_field_on_add:
|
||||
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
|
||||
entry_field = None
|
||||
if isinstance(template, TextFieldTemplate):
|
||||
entry_field = entry.text_fields[-1]
|
||||
elif isinstance(template, DatetimeFieldTemplate):
|
||||
entry_field = entry.datetime_fields[-1]
|
||||
if entry_field is not None:
|
||||
self._edit_field(entry.id, entry_field)
|
||||
|
||||
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.
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
|
||||
if type(field) is TextField:
|
||||
edit_modal = PanelModal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_text_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
elif type(field) is DatetimeField:
|
||||
edit_modal = PanelModal(
|
||||
DatetimePicker(self._driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_datetime_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
|
||||
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
|
||||
self._set_item_mode(None)
|
||||
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)
|
||||
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
|
||||
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 containers(self) -> FieldContainers:
|
||||
return self.layout().containers
|
||||
|
||||
@override
|
||||
def layout(self) -> PreviewPanelView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
|
||||
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(PreviewThumbView):
|
||||
__current_file: Path
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__(library, driver)
|
||||
|
||||
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
def display_file(self, filepath: Path) -> FileAttributeData:
|
||||
"""Render a single file preview."""
|
||||
self.__current_file = filepath
|
||||
self._current_file = filepath
|
||||
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
@@ -150,21 +148,26 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
@override
|
||||
def _open_file_action_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@override
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
if self._current_file:
|
||||
open_file(self._current_file, file_manager=True)
|
||||
|
||||
@override
|
||||
def _delete_action_callback(self):
|
||||
if bool(self.__current_file):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
if self._current_file:
|
||||
self.__driver.delete_files_callback(self._current_file)
|
||||
|
||||
@override
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ReturnButton(QPushButton):
|
||||
def __init__(self, *args, **kwargs) -> None: # pyright: ignore
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
|
||||
self.click()
|
||||
|
||||
super().keyPressEvent(arg__1)
|
||||
@@ -52,9 +52,9 @@ class SearchPanel[T](PanelWidget):
|
||||
super().__init__()
|
||||
self.view = view
|
||||
self.is_chooser = is_chooser
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self.view)
|
||||
self.setLayout(QVBoxLayout(self))
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().addWidget(self.view)
|
||||
self.view.connect_callbacks(self)
|
||||
self._driver: QtDriver | None = None
|
||||
self.exclude: list[int] = exclude or []
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
autofill_line_edit_style,
|
||||
autofill_line_edit_top_style,
|
||||
)
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
def _item_id(item: object) -> int:
|
||||
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_id, int):
|
||||
return item_id
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
def _item_name(item: object) -> str:
|
||||
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_name, str):
|
||||
return item_name
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
class SuggestBox[T](QWidget):
|
||||
item_chosen = Signal(object)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._limit = 5
|
||||
self._is_shift_held = False
|
||||
self._search_results: list[T] = []
|
||||
self.added: list[int] = []
|
||||
self.excluded: list[int] = []
|
||||
|
||||
self.setLayout(view)
|
||||
self._connect_callbacks()
|
||||
|
||||
def hide_and_reset(self):
|
||||
self.hide()
|
||||
self.layout().search_field.setDisabled(True)
|
||||
self._on_shift_held(held=False)
|
||||
|
||||
def _connect_callbacks(self) -> None:
|
||||
self.layout().search_field.textChanged.connect(self._on_search_query_changed)
|
||||
self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
|
||||
self.layout().search_field.return_pressed.connect(
|
||||
lambda: self._on_search_query_submitted(self.layout().search_field.text())
|
||||
)
|
||||
self.layout().search_field.shift_return_pressed.connect(
|
||||
lambda: self._on_search_query_submitted(
|
||||
self.layout().search_field.text(), always_create=True
|
||||
)
|
||||
)
|
||||
|
||||
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
|
||||
|
||||
def _on_shift_held(self, held: bool):
|
||||
if held:
|
||||
self._is_shift_held = True
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.3)
|
||||
if self.layout().content_layout.count() > 0:
|
||||
underlined_widget = self.layout().content_layout.itemAt(0).widget()
|
||||
assert isinstance(underlined_widget, UnderlinedWidget)
|
||||
underlined_widget.widget.setGraphicsEffect(opacity_effect)
|
||||
else:
|
||||
self._is_shift_held = False
|
||||
if self.layout().content_layout.count() > 0:
|
||||
underlined_widget = self.layout().content_layout.itemAt(0).widget()
|
||||
assert isinstance(underlined_widget, UnderlinedWidget)
|
||||
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def _clear_search_query(self) -> None:
|
||||
self.layout().search_field.setText("")
|
||||
|
||||
def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_search_query_changed(self, query: str) -> None:
|
||||
self._update_items(query)
|
||||
|
||||
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
|
||||
# Focus search field if no query
|
||||
logger.info("Query submitted")
|
||||
if not query:
|
||||
self.done.emit()
|
||||
self.hide_and_reset()
|
||||
return
|
||||
elif not self.isHidden():
|
||||
self.layout().search_field.setFocus()
|
||||
|
||||
# Create and add item if no search results
|
||||
if (len(self._search_results) <= 0) or always_create:
|
||||
self._on_item_create()
|
||||
else:
|
||||
self._on_item_chosen(self._search_results[0])
|
||||
|
||||
self._clear_search_query()
|
||||
self._update_items()
|
||||
|
||||
def _on_item_create(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _is_excluded(self, item: T) -> bool:
|
||||
return _item_id(item) in self.excluded
|
||||
|
||||
def _update_items(self, query: str | None = None) -> None:
|
||||
"""Update the item list given a search query."""
|
||||
logger.info("[SearchPanel] Updating items", limit=self._limit)
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
search_results: tuple[list[T], list[T]] = self._search_items(query_lower)
|
||||
|
||||
# Sort and prioritize the results
|
||||
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
|
||||
direct_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
|
||||
ancestor_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
raw_results = list(direct_results + ancestor_results)
|
||||
priority_results: set[T] = set()
|
||||
|
||||
if query and query.strip():
|
||||
for raw_item in raw_results:
|
||||
if _item_name(raw_item).lower().startswith(query_lower):
|
||||
priority_results.add(raw_item)
|
||||
|
||||
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
|
||||
item for item in raw_results if item not in priority_results
|
||||
]
|
||||
|
||||
# Target items already added to a selection and move them to the end of the list
|
||||
already_added: list[T] = [i for i in all_results if _item_id(i) in self.added]
|
||||
for item in already_added:
|
||||
if item in all_results:
|
||||
all_results.remove(item)
|
||||
all_results = all_results + already_added
|
||||
|
||||
if self._limit > 0:
|
||||
all_results = all_results[: self._limit]
|
||||
|
||||
self._search_results = all_results
|
||||
logger.info("[SearchPanel] Search results", results=self._search_results)
|
||||
|
||||
for i in range(0, self._limit):
|
||||
item: T | None = all_results[i] if i < len(all_results) else None
|
||||
self._set_item_widget(item=item, index=i)
|
||||
|
||||
if self.layout().content_layout.isEmpty():
|
||||
self.layout().scroll_area.setHidden(True)
|
||||
self.layout().content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
|
||||
else:
|
||||
self.layout().scroll_area.setHidden(False)
|
||||
self.layout().content_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
|
||||
|
||||
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _editing_finished_callback(self):
|
||||
if self.layout().search_field.text() == "":
|
||||
self.done.emit()
|
||||
self.hide_and_reset()
|
||||
|
||||
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
self._update_items()
|
||||
self._on_shift_held(held=False)
|
||||
self.layout().search_field.setDisabled(False)
|
||||
self._clear_search_query()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def layout(self) -> SuggestBoxView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
# When Escape is pressed, focus back on the search box.
|
||||
if event.key() in {
|
||||
QtCore.Qt.Key.Key_Escape,
|
||||
QtCore.Qt.Key.Key_Enter,
|
||||
QtCore.Qt.Key.Key_Return,
|
||||
}:
|
||||
self.hide_and_reset()
|
||||
@@ -0,0 +1,187 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QAction, Qt
|
||||
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.controllers.suggest_box import SuggestBox
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TagSuggestBox(SuggestBox[Tag]):
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
|
||||
super().__init__(driver, view=view or SuggestBoxView())
|
||||
self._driver = driver
|
||||
self._lib = self._driver.lib
|
||||
|
||||
# Context Menu Actions
|
||||
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
|
||||
edit_tag_on_create_action.setCheckable(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.addAction(edit_tag_on_create_action)
|
||||
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.layout().search_field.addAction(edit_tag_on_create_action)
|
||||
edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create)
|
||||
edit_tag_on_create_action.triggered.connect(
|
||||
lambda checked: self.toggle_edit_on_tag_create(checked)
|
||||
)
|
||||
|
||||
def _search_for_tag_callback(self, tag_id: int) -> None:
|
||||
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
|
||||
self._driver.update_browsing_state(
|
||||
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
|
||||
)
|
||||
|
||||
def toggle_edit_on_tag_create(self, checked: bool) -> None:
|
||||
"""Toggle the setting for opening the edit window after creating a tag."""
|
||||
self._driver.settings.edit_tag_on_create = checked
|
||||
self._driver.settings.save()
|
||||
|
||||
@override
|
||||
def _on_item_create(self) -> None:
|
||||
"""Opens panel to create a new tag and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
query: str = self.layout().search_field.text()
|
||||
|
||||
if self._driver.settings.edit_tag_on_create:
|
||||
panel: BuildTagPanel = BuildTagPanel(self._lib)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel, Translations["tag.new"], Translations["tag.new"], is_savable=True
|
||||
)
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self._create_item_from_modal(panel))
|
||||
modal.show()
|
||||
else:
|
||||
tag = Tag(name=query)
|
||||
self._lib.add_tag(tag)
|
||||
self._on_item_chosen(tag)
|
||||
self._clear_search_query()
|
||||
|
||||
@override
|
||||
def _on_item_edit(self, item: Tag) -> None:
|
||||
edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item)
|
||||
edit_tag_modal: PanelModal = PanelModal(
|
||||
edit_tag_panel,
|
||||
self._lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
is_savable=True,
|
||||
)
|
||||
edit_tag_modal.saved.connect(lambda: self._edit_item(edit_tag_panel))
|
||||
edit_tag_modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: Tag) -> None:
|
||||
self.item_chosen.emit(item.id)
|
||||
self.done.emit()
|
||||
|
||||
@override
|
||||
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
|
||||
if query != "":
|
||||
return self._lib.search_tags(name=query, limit=0)
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _set_item_widget(self, item: Tag | None, index: int) -> None:
|
||||
"""Set the tag of a tag widget at a specific index."""
|
||||
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
|
||||
tag_widget = underlined_widget.widget
|
||||
assert isinstance(tag_widget, TagWidget)
|
||||
tag_widget.has_remove = False
|
||||
tag_widget.set_tag(item)
|
||||
underlined_widget.setHidden(item is None)
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.3)
|
||||
if item and item.id in self.added:
|
||||
tag_widget.setGraphicsEffect(opacity_effect)
|
||||
else:
|
||||
tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# TODO: Add tabbing to different items, and use underline to indicate which will be added
|
||||
underlined_widget.toggle_underline(index != 0)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
tag_widget.on_edit.disconnect()
|
||||
tag_widget.bg_button.clicked.disconnect()
|
||||
tag_widget.search_for_tag_action.triggered.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self._on_item_edit(edit_tag))
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
|
||||
@override
|
||||
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self._lib.add_tag(
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
)
|
||||
self._on_item_chosen(tag)
|
||||
self._clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self._on_search_query_changed(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
if not isinstance(edit_item_panel, BuildTagPanel):
|
||||
return
|
||||
|
||||
self._lib.update_tag(
|
||||
tag=edit_item_panel.build_tag(),
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().content_layout.count() <= index:
|
||||
while self.layout().content_layout.count() <= index:
|
||||
tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library)
|
||||
tag_widget.on_remove.connect(self._update_items)
|
||||
widget = UnderlinedWidget(tag_widget)
|
||||
widget.setHidden(True)
|
||||
self.layout().content_layout.addWidget(widget)
|
||||
|
||||
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
|
||||
assert isinstance(widget_, UnderlinedWidget)
|
||||
return widget_
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
|
||||
|
||||
|
||||
class UnderlinedWidget(QWidget):
|
||||
def __init__(self, widget: QWidget) -> None:
|
||||
super().__init__()
|
||||
view = UnderlinedWidgetView(widget)
|
||||
self.setLayout(view)
|
||||
|
||||
def toggle_underline(self, is_hidden: bool) -> None:
|
||||
self.layout().underline.setHidden(is_hidden)
|
||||
|
||||
@property
|
||||
def widget(self) -> QWidget:
|
||||
return self.layout().itemAt(0).widget()
|
||||
|
||||
@override
|
||||
def layout(self) -> UnderlinedWidgetView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
@@ -74,6 +74,8 @@ class GlobalSettings(BaseModel):
|
||||
infinite_scroll: bool = Field(default=True)
|
||||
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
|
||||
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
|
||||
edit_tag_on_create: bool = Field(default=False)
|
||||
edit_field_on_add: bool = Field(default=True)
|
||||
theme: Theme = Field(default=Theme.SYSTEM)
|
||||
splash: Splash = Field(default=Splash.DEFAULT)
|
||||
windows_start_command: bool = Field(default=False)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CollageIconRenderer(QObject):
|
||||
rendered = Signal(Image.Image)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: Library):
|
||||
QObject.__init__(self)
|
||||
self.lib = library
|
||||
|
||||
def render(
|
||||
self,
|
||||
entry_id: int,
|
||||
size: tuple[int, int],
|
||||
data_tint_mode: bool,
|
||||
data_only_mode: bool,
|
||||
keep_aspect: bool,
|
||||
):
|
||||
entry = unwrap(self.lib.get_entry(entry_id))
|
||||
filepath = unwrap(self.lib.library_dir) / entry.path
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
color = "#28bb48" if entry.tags else "#e22c3c"
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logger.info(
|
||||
"Combining icons",
|
||||
entry=entry,
|
||||
color=self.get_file_color(filepath.suffix.lower()),
|
||||
)
|
||||
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
try:
|
||||
with Image.open(filepath) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logger.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
max_frame_seek,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logger.error("Couldn't read entry", entry=entry.path)
|
||||
with Image.open(
|
||||
str(Path(__file__).parents[1] / "resources/qt/images/thumb_broken_512.png")
|
||||
) as pic:
|
||||
pic.thumbnail(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Collage operation cancelled.")
|
||||
except Exception:
|
||||
logger.exception("render failed", entry=entry.path)
|
||||
|
||||
self.done.emit()
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower() == "gif":
|
||||
return "\033[93m"
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
return "\033[37m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
|
||||
return "\033[96m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
@@ -9,8 +9,7 @@ from functools import partial
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -21,7 +20,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
@@ -38,6 +36,7 @@ from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.mixed.text_field import TextContainerWidget
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -48,6 +47,8 @@ logger = structlog.get_logger(__name__)
|
||||
class FieldContainers(QWidget):
|
||||
"""Widget for the tag and field containers displayed inside the Preview Panel."""
|
||||
|
||||
on_tags_update = Signal()
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -58,18 +59,12 @@ class FieldContainers(QWidget):
|
||||
self.common_fields: list = []
|
||||
self.mixed_fields: list = []
|
||||
self.cached_entries: list[Entry] = []
|
||||
self.containers: list[FieldContainer] = []
|
||||
|
||||
self.panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
self._containers: list[FieldContainer] = []
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.scroll_layout.setSpacing(0)
|
||||
self.scroll_layout.setSpacing(6)
|
||||
|
||||
scroll_container: QWidget = QWidget()
|
||||
scroll_container.setObjectName("entryScrollContainer")
|
||||
@@ -92,9 +87,7 @@ class FieldContainers(QWidget):
|
||||
# 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(
|
||||
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
|
||||
)
|
||||
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
|
||||
self.scroll_area.setWidget(scroll_container)
|
||||
|
||||
root_layout = QHBoxLayout(self)
|
||||
@@ -137,8 +130,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._containers) > container_len:
|
||||
for i, c in enumerate(self._containers):
|
||||
if i > (container_len - 1):
|
||||
c.setHidden(True)
|
||||
|
||||
@@ -157,7 +150,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
def hide_containers(self) -> None:
|
||||
"""Hide all field and tag containers."""
|
||||
for c in self.containers:
|
||||
for c in self._containers:
|
||||
c.setHidden(True)
|
||||
|
||||
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
|
||||
@@ -251,6 +244,27 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
self.driver.add_tags_to_selected_callback(tag_ids)
|
||||
|
||||
def update_text_field_callback(
|
||||
self, field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
self, field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
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.
|
||||
|
||||
@@ -261,27 +275,6 @@ class FieldContainers(QWidget):
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
|
||||
def update_text_field_callback(
|
||||
field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_text_container(
|
||||
container: FieldContainer, field: TextField, title: str, is_mixed: bool
|
||||
):
|
||||
@@ -305,14 +298,14 @@ class FieldContainers(QWidget):
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_text_field_callback, field, self.top_entry_id)
|
||||
partial(self.update_text_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -343,14 +336,14 @@ class FieldContainers(QWidget):
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_datetime_field_callback, field, self.top_entry_id)
|
||||
partial(self.update_datetime_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -361,7 +354,7 @@ class FieldContainers(QWidget):
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -373,12 +366,12 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
|
||||
# Create new containers if necessary
|
||||
if len(self.containers) < (index + 1):
|
||||
if len(self._containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self._containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
container = self._containers[index]
|
||||
|
||||
# Set field title
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
@@ -407,12 +400,12 @@ class FieldContainers(QWidget):
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
if len(self._containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self._containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
container = self._containers[index]
|
||||
|
||||
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
|
||||
|
||||
@@ -430,7 +423,10 @@ class FieldContainers(QWidget):
|
||||
inner_widget.set_tags(tags)
|
||||
|
||||
inner_widget.on_update.connect(
|
||||
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
|
||||
lambda: (
|
||||
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
|
||||
self.on_tags_update.emit(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
@@ -480,3 +476,13 @@ class FieldContainers(QWidget):
|
||||
result = remove_mb.exec_()
|
||||
if result == QMessageBox.ButtonRole.ActionRole.value:
|
||||
callback()
|
||||
|
||||
@property
|
||||
def tags(self) -> list[int]:
|
||||
if len(self.cached_entries) <= 0:
|
||||
return []
|
||||
entry = self.cached_entries[0]
|
||||
entry_ = self.lib.get_entry_full(entry.id, with_fields=False)
|
||||
if not entry_:
|
||||
return []
|
||||
return [tag.id for tag in entry_.tags]
|
||||
|
||||
@@ -34,7 +34,9 @@ class FieldContainer(QWidget):
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
button_size = 24
|
||||
button_size = 22
|
||||
icon_margins = 4
|
||||
icon_size = button_size - icon_margins
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
@@ -42,7 +44,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(6, 0, 6, 6)
|
||||
self.inner_layout.setContentsMargins(3, 0, 0, 3)
|
||||
self.inner_layout.setSpacing(0)
|
||||
self.field_container = QWidget()
|
||||
self.field_container.setObjectName("fieldContainer")
|
||||
@@ -82,7 +84,7 @@ class FieldContainer(QWidget):
|
||||
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(20, 20))
|
||||
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)
|
||||
@@ -93,7 +95,7 @@ class FieldContainer(QWidget):
|
||||
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(20, 20))
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,6 @@ import platform
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -20,6 +19,7 @@ from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.str_formatting import format_duration
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -224,15 +224,7 @@ class FileAttributes(QWidget):
|
||||
|
||||
if stats.duration is not None:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
try:
|
||||
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
|
||||
if dur_str.startswith("0:"):
|
||||
dur_str = dur_str[2:]
|
||||
if dur_str.startswith("0"):
|
||||
dur_str = dur_str[1:]
|
||||
except OverflowError:
|
||||
dur_str = "-:--"
|
||||
stats_label_text += f"{dur_str}"
|
||||
stats_label_text += format_duration(stats.duration)
|
||||
|
||||
if font_family:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
|
||||
@@ -500,7 +500,7 @@ class ItemThumb(FlowWidget):
|
||||
):
|
||||
selected = self.driver._selected
|
||||
if len(selected) == 1 and entry_id in selected:
|
||||
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
|
||||
self.driver.main_window.preview_panel.containers.update_toggled_tag(
|
||||
tag_id, toggle_value
|
||||
)
|
||||
|
||||
|
||||
@@ -395,14 +395,15 @@ class JsonMigrationModal(QObject):
|
||||
# Convert JSON Library to SQLite
|
||||
yield Translations["json_migration.creating_database_tables"]
|
||||
self.sql_lib = SqliteLibrary()
|
||||
self.temp_path: Path = (
|
||||
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
|
||||
)
|
||||
temp_filename = "migration_ts_library.sqlite"
|
||||
self.temp_path: Path = self.json_lib.library_dir / TS_FOLDER_NAME / temp_filename
|
||||
if self.temp_path.exists():
|
||||
logger.info('Temporary migration file "temp_path" already exists. Removing...')
|
||||
self.temp_path.unlink()
|
||||
self.sql_lib.open_sqlite_library(
|
||||
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
|
||||
self.sql_lib.create_sqlite_library(
|
||||
self.json_lib.library_dir,
|
||||
in_memory=False,
|
||||
sql_filename=temp_filename,
|
||||
)
|
||||
yield Translations.format(
|
||||
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
|
||||
|
||||
@@ -216,6 +216,20 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
|
||||
)
|
||||
|
||||
# Open Edit Window When Creating a Tag
|
||||
self.edit_tag_on_create_checkbox = QCheckBox()
|
||||
self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
|
||||
form_layout.addRow(
|
||||
Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
|
||||
)
|
||||
|
||||
# Open Edit Window When Adding a Field
|
||||
self.edit_field_on_add_checkbox = QCheckBox()
|
||||
self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add)
|
||||
form_layout.addRow(
|
||||
Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
|
||||
)
|
||||
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
@@ -366,6 +380,8 @@ class SettingsPanel(PanelWidget):
|
||||
"show_filepath": self.filepath_combobox.currentData(),
|
||||
"theme": self.theme_combobox.currentData(),
|
||||
"tag_click_action": self.tag_click_action_combobox.currentData(),
|
||||
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
|
||||
"edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
|
||||
"date_format": self.dateformat_combobox.currentData(),
|
||||
"hour_format": self.hourformat_checkbox.isChecked(),
|
||||
"zero_padding": self.zeropadding_checkbox.isChecked(),
|
||||
@@ -388,6 +404,8 @@ class SettingsPanel(PanelWidget):
|
||||
driver.settings.show_filepath = settings["show_filepath"]
|
||||
driver.settings.theme = settings["theme"]
|
||||
driver.settings.tag_click_action = settings["tag_click_action"]
|
||||
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
|
||||
driver.settings.edit_field_on_add = settings["edit_field_on_add"]
|
||||
driver.settings.date_format = settings["date_format"]
|
||||
driver.settings.hour_format = settings["hour_format"]
|
||||
driver.settings.zero_padding = settings["zero_padding"]
|
||||
|
||||
@@ -125,7 +125,7 @@ class TagColorManager(QWidget):
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
|
||||
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
)
|
||||
@@ -142,7 +142,7 @@ class TagColorManager(QWidget):
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
|
||||
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, override
|
||||
import structlog
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
@@ -148,23 +148,23 @@ class TagWidget(QWidget):
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self._delete_button = QPushButton(self)
|
||||
self._delete_button.setFlat(True)
|
||||
self._delete_button.setText("–")
|
||||
self._delete_button.setHidden(True)
|
||||
self._delete_button.setMinimumSize(22, 22)
|
||||
self._delete_button.setMaximumSize(22, 22)
|
||||
self._delete_button.setFixedSize(22, 22)
|
||||
self._delete_button.clicked.connect(self.on_remove.emit)
|
||||
self._delete_button.setHidden(True)
|
||||
self.inner_layout.addWidget(self._delete_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(44, 22)
|
||||
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
self.bg_button.setFixedHeight(22)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
|
||||
|
||||
@@ -7,12 +7,34 @@ from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QPalette
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.utils.singleton import Singleton
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Palette(metaclass=Singleton):
|
||||
_palette: QPalette | None = None
|
||||
_accent: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def set_palette(palette: QPalette) -> None:
|
||||
Palette._palette = palette
|
||||
|
||||
@staticmethod
|
||||
def accent() -> str:
|
||||
if not Palette._palette:
|
||||
logger.error("[Style] No QPalette set!")
|
||||
return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
|
||||
if not Palette._accent:
|
||||
Palette._accent = (
|
||||
f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
|
||||
)
|
||||
return Palette._accent
|
||||
|
||||
|
||||
class ColorType(IntEnum):
|
||||
PRIMARY = 0
|
||||
TEXT = 1
|
||||
|
||||
@@ -1229,6 +1229,8 @@ class ThumbRenderer(QObject):
|
||||
"QuickLook/Preview.heic",
|
||||
"QuickLook/Thumbnail.jpg",
|
||||
"QuickLook/Thumbnail.heic",
|
||||
"QuickLook/Thumbnail.webp",
|
||||
"QuickLook/Icon.webp",
|
||||
]
|
||||
im: Image.Image | None = None
|
||||
|
||||
|
||||
@@ -51,8 +51,6 @@ from tagstudio.core.library.refresh import RefreshTracker
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.query_lang.util import ParsingError
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
|
||||
# This import has side-effect of importing PySide resources
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
@@ -77,7 +75,7 @@ from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
|
||||
from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.settings_panel import SettingsPanel
|
||||
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color
|
||||
from tagstudio.qt.platform_strings import trash_term
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -274,7 +272,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
dir = QFileDialog.getExistingDirectory(
|
||||
parent=None,
|
||||
caption=Translations["window.title.open_create_library"],
|
||||
dir="/",
|
||||
dir=str(Path.home()),
|
||||
options=QFileDialog.Option.ShowDirsOnly,
|
||||
)
|
||||
if dir not in (None, ""):
|
||||
@@ -303,19 +301,28 @@ class QtDriver(DriverMixin, QObject):
|
||||
elif self.settings.theme == Theme.LIGHT:
|
||||
self.app.styleHints().setColorScheme(Qt.ColorScheme.Light)
|
||||
|
||||
pal: QPalette = self.app.palette()
|
||||
# BUG: Changing the palette in any way here seems to affect the accent colors of certain
|
||||
# widgets, like QLineEdit focused borders and QComboBox highlighted items and borders.
|
||||
# Need to figure out the cause of this.
|
||||
if (
|
||||
platform.system() == "Darwin" or platform.system() == "Windows"
|
||||
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
|
||||
pal: QPalette = self.app.palette()
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e")
|
||||
)
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF")
|
||||
)
|
||||
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
|
||||
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323"))
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
|
||||
)
|
||||
|
||||
self.app.setPalette(pal)
|
||||
Palette.set_palette(pal)
|
||||
self.app.setPalette(pal)
|
||||
|
||||
# Handle OS signals
|
||||
self.setup_signals()
|
||||
@@ -627,8 +634,10 @@ class QtDriver(DriverMixin, QObject):
|
||||
if path_result.success and path_result.library_path:
|
||||
self.open_library(path_result.library_path)
|
||||
|
||||
self.check_for_update()
|
||||
self.main_window.search_field.setFocus()
|
||||
|
||||
self.app.exec()
|
||||
self.check_for_update()
|
||||
self.shutdown()
|
||||
|
||||
def show_error_message(self, error_name: str, error_desc: str | None = None):
|
||||
@@ -954,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
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtGui import QColor, Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
@@ -12,8 +12,10 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
list_button_style,
|
||||
tag_remove_button_style,
|
||||
)
|
||||
|
||||
# TODO: These colors and logic should be moved to and reworked in the stylesheets file.
|
||||
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color: QColor = get_tag_border_color(primary_color)
|
||||
highlight_color: QColor = get_tag_highlight_color(primary_color)
|
||||
@@ -30,7 +32,6 @@ class FieldTemplateWidgetView(QWidget):
|
||||
|
||||
self.__root_layout = QVBoxLayout(self)
|
||||
self.__root_layout.setObjectName("root_layout")
|
||||
|
||||
self.__root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Background button
|
||||
@@ -47,6 +48,8 @@ class FieldTemplateWidgetView(QWidget):
|
||||
self.__inner_layout.setObjectName("inner_layout")
|
||||
self._bg_button.setLayout(self.__inner_layout)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Remove button
|
||||
@@ -56,9 +59,12 @@ class FieldTemplateWidgetView(QWidget):
|
||||
self._delete_button.setHidden(True)
|
||||
self._delete_button.setMinimumSize(22, 22)
|
||||
self._delete_button.setMaximumSize(22, 22)
|
||||
self._delete_button.setStyleSheet(
|
||||
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.__inner_layout.addWidget(self._delete_button)
|
||||
self.__inner_layout.addStretch(1)
|
||||
self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.__connect_callbacks()
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6 import QtCore
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
|
||||
from PySide6.QtGui import QAction, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -182,10 +183,10 @@ class MainMenuBar(QMenuBar):
|
||||
self.new_tag_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
QtCore.Qt.Key.Key_N,
|
||||
)
|
||||
)
|
||||
self.new_tag_action.setToolTip("Ctrl+T")
|
||||
self.new_tag_action.setToolTip("Ctrl+N")
|
||||
self.new_tag_action.setEnabled(False)
|
||||
self.edit_menu.addAction(self.new_tag_action)
|
||||
|
||||
@@ -220,9 +221,8 @@ class MainMenuBar(QMenuBar):
|
||||
|
||||
# Clear Selection
|
||||
self.clear_select_action = QAction(Translations["select.clear"], self)
|
||||
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.clear_select_action.setEnabled(False)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.edit_menu.addAction(self.clear_select_action)
|
||||
|
||||
# Copy Fields
|
||||
@@ -700,10 +700,12 @@ class MainWindow(QMainWindow):
|
||||
self.content_splitter.addWidget(self.entry_list_container)
|
||||
|
||||
def setup_preview_panel(self, driver: "QtDriver"):
|
||||
self.preview_panel = PreviewPanel(driver.lib, driver)
|
||||
self.preview_panel = PreviewPanel(driver)
|
||||
self.content_splitter.addWidget(self.preview_panel)
|
||||
|
||||
def setup_status_bar(self):
|
||||
# BUG: Clicking the status bar does not count as losing focus on other widgets
|
||||
# (for example, the "Add Tag" line edit). Can this be fixed?
|
||||
self.status_bar = QStatusBar(self)
|
||||
self.status_bar.setObjectName("status_bar")
|
||||
status_bar_size_policy = QSizePolicy(
|
||||
@@ -746,3 +748,9 @@ class MainWindow(QMainWindow):
|
||||
def show_hidden_entries(self) -> bool:
|
||||
"""Whether to show entries tagged with hidden tags."""
|
||||
return self.show_hidden_entries_checkbox.isChecked()
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.menu_bar.clear_select_action.trigger()
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -3,32 +3,24 @@
|
||||
|
||||
|
||||
import math
|
||||
import traceback
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.constants import FFMPEG_HELP_URL
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
|
||||
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.stylesheets.stylesheets import button_style, preview_warning_style
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -36,32 +28,40 @@ if typing.TYPE_CHECKING:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PreviewPanelView(QWidget):
|
||||
lib: Library
|
||||
|
||||
_selected: list[int]
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
class PreviewPanelView(QVBoxLayout):
|
||||
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSpacing(6)
|
||||
rm = ResourceManager()
|
||||
|
||||
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
|
||||
def ph_text(key: str) -> str:
|
||||
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
|
||||
|
||||
# Search/Create Boxes
|
||||
self.field_search_box = FieldSuggestBox(
|
||||
driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_fields"))
|
||||
)
|
||||
self.tag_search_box = TagSuggestBox(
|
||||
driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_tags"))
|
||||
)
|
||||
|
||||
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()
|
||||
preview_layout = QVBoxLayout(preview_section)
|
||||
preview_layout.setContentsMargins(0, 0, 0, 0)
|
||||
preview_layout.setSpacing(6)
|
||||
|
||||
self._ffmpeg_warning_widget = QWidget()
|
||||
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
|
||||
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
|
||||
# Warning Banner (Missing FFmpeg, etc.)
|
||||
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",
|
||||
@@ -74,16 +74,16 @@ 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.warning_banner.hide()
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||
|
||||
# File Information
|
||||
info_section = QWidget()
|
||||
info_layout = QVBoxLayout(info_section)
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -93,129 +93,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 = QPushButton(Translations["tag.add"])
|
||||
self.__add_tag_button.setEnabled(False)
|
||||
self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.__add_tag_button.setMinimumHeight(28)
|
||||
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 = QPushButton(Translations["field.add"])
|
||||
self.__add_field_button.setEnabled(False)
|
||||
self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.__add_field_button.setMinimumHeight(28)
|
||||
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_box)
|
||||
|
||||
preview_layout.addWidget(self._thumb)
|
||||
info_layout.addWidget(self._ffmpeg_warning_widget)
|
||||
info_layout.addWidget(self._file_attrs)
|
||||
info_layout.addWidget(self._containers)
|
||||
# Finalize Layout
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def _add_field_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
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._file_attrs.update_stats()
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers()
|
||||
|
||||
self.add_buttons_enabled = False
|
||||
|
||||
# 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 update_preview:
|
||||
stats: FileAttributeData = self._thumb.display_file(filepath)
|
||||
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_buttons_enabled = True
|
||||
|
||||
# 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._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_buttons_enabled = True
|
||||
|
||||
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
|
||||
self.addWidget(splitter)
|
||||
self.addWidget(add_buttons_container)
|
||||
|
||||
@@ -34,11 +34,13 @@ class PreviewThumbView(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
check_ffmpeg = Signal(bool)
|
||||
stats_updated = Signal(Path, FileAttributeData)
|
||||
|
||||
__img_button_size: tuple[int, int]
|
||||
__image_ratio: float
|
||||
|
||||
__filepath: Path | None
|
||||
_current_file: Path | None
|
||||
__should_render_on_resize: bool
|
||||
__rendered_res: tuple[int, int]
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
@@ -47,6 +49,8 @@ class PreviewThumbView(QWidget):
|
||||
self.__img_button_size = (266, 266)
|
||||
self.__image_ratio = 1.0
|
||||
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
self.__image_layout = QStackedLayout(self)
|
||||
self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
@@ -92,6 +96,10 @@ class PreviewThumbView(QWidget):
|
||||
self.__media_player.addAction(open_file_action)
|
||||
self.__media_player.addAction(open_explorer_action)
|
||||
self.__media_player.addAction(delete_action)
|
||||
# QMediaPlayer loads duration asynchronously after setSource().
|
||||
self.__media_player.player.durationChanged.connect(
|
||||
self.__media_player_duration_changed_callback
|
||||
)
|
||||
|
||||
# Need to watch for this to resize the player appropriately.
|
||||
self.__media_player.player.hasVideoChanged.connect(
|
||||
@@ -128,6 +136,16 @@ class PreviewThumbView(QWidget):
|
||||
def __media_player_video_changed_callback(self, video: bool) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
|
||||
filepath = self.__media_player.filepath
|
||||
if filepath is None or duration_ms <= 0:
|
||||
return
|
||||
|
||||
self.stats_updated.emit(
|
||||
filepath,
|
||||
FileAttributeData(duration=duration_ms // 1000),
|
||||
)
|
||||
|
||||
def __thumb_renderer_updated_callback(
|
||||
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
|
||||
) -> None:
|
||||
@@ -142,6 +160,7 @@ class PreviewThumbView(QWidget):
|
||||
layout.addWidget(widget)
|
||||
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
widget.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
page.setLayout(layout)
|
||||
|
||||
def __update_image_size(self, size: tuple[int, int]) -> None:
|
||||
@@ -207,7 +226,8 @@ class PreviewThumbView(QWidget):
|
||||
self.__preview_gif.hide()
|
||||
|
||||
def __render_thumb(self, filepath: Path) -> None:
|
||||
self.__filepath = filepath
|
||||
self.__should_render_on_resize = True
|
||||
|
||||
self.__rendered_res = (
|
||||
math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR),
|
||||
math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR),
|
||||
@@ -221,17 +241,16 @@ class PreviewThumbView(QWidget):
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
def __update_media_player(self, filepath: Path) -> int:
|
||||
"""Display either audio or video.
|
||||
|
||||
Returns the duration of the audio / video.
|
||||
"""
|
||||
def __update_media_player(self, filepath: Path) -> None:
|
||||
"""Display either audio or video."""
|
||||
self.__media_player.play(filepath)
|
||||
return self.__media_player.player.duration() * 1000
|
||||
|
||||
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
self.__switch_preview(MediaType.VIDEO)
|
||||
stats = FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
self.__update_media_player(filepath)
|
||||
stats = FileAttributeData()
|
||||
|
||||
if size is not None:
|
||||
stats.width = size.width()
|
||||
@@ -250,10 +269,13 @@ class PreviewThumbView(QWidget):
|
||||
def _display_audio(self, filepath: Path) -> FileAttributeData:
|
||||
self.__switch_preview(MediaType.AUDIO)
|
||||
self.__render_thumb(filepath)
|
||||
return FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
self.__update_media_player(filepath)
|
||||
return FileAttributeData()
|
||||
|
||||
def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
|
||||
"""Update the animated image preview from a filepath."""
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
stats = FileAttributeData()
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
@@ -296,17 +318,26 @@ class PreviewThumbView(QWidget):
|
||||
def hide_preview(self) -> None:
|
||||
"""Completely hide the file preview."""
|
||||
self.__switch_preview(None)
|
||||
self.__filepath = None
|
||||
self._current_file = None
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
if self.__filepath is not None and self.__rendered_res < self.__img_button_size:
|
||||
self.__render_thumb(self.__filepath)
|
||||
if (
|
||||
self._current_file is not None
|
||||
and self.__should_render_on_resize
|
||||
and self.__rendered_res < self.__img_button_size
|
||||
):
|
||||
self.__render_thumb(self._current_file)
|
||||
|
||||
return super().resizeEvent(event)
|
||||
|
||||
@property
|
||||
def media_player(self) -> MediaPlayer:
|
||||
return self.__media_player
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path | None:
|
||||
return self._current_file
|
||||
|
||||
@@ -8,7 +8,7 @@ from PySide6.QtGui import QColor, QGuiApplication
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color
|
||||
|
||||
# TODO: There's plenty of good opportunities here to consolidate similar styles.
|
||||
# Work should be done to more closely use Qt's theming systems rather than override them.
|
||||
@@ -53,18 +53,29 @@ def button_style() -> str:
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding: 0px 12px;
|
||||
}}
|
||||
QPushButton::hover{{
|
||||
background-color: {Theme.COLOR_HOVER.value};
|
||||
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
|
||||
padding: 0px 8px;
|
||||
}}
|
||||
QPushButton::pressed{{
|
||||
background-color: {Theme.COLOR_PRESSED.value};
|
||||
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
|
||||
outline: none;
|
||||
background-color: palette(light);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
|
||||
padding: 0px 8px;
|
||||
}}
|
||||
QPushButton::focus{{
|
||||
outline: none;
|
||||
border: solid;
|
||||
border-width: 2px;
|
||||
border-color: {Palette.accent()};
|
||||
padding: 0px 8px;
|
||||
}}
|
||||
QPushButton::disabled{{
|
||||
background-color: {Theme.COLOR_DISABLED_BG.value};
|
||||
@@ -72,6 +83,40 @@ def button_style() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def line_edit_style_main() -> str:
|
||||
"""Style used for common QLineEdits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QLineEdit{{
|
||||
background: {bg_color};
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding: 0px 4px;
|
||||
}}
|
||||
QLineEdit::hover{{
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
|
||||
padding: 0px 2px;
|
||||
}}
|
||||
QLineEdit::focus{{
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: {Palette.accent()};
|
||||
padding: 0px 2px;
|
||||
}}
|
||||
QLineEdit::disabled{{
|
||||
background-color: {Theme.COLOR_DISABLED_BG.value};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def checkbox_style() -> str:
|
||||
"""Style used for QCheckBoxes."""
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
@@ -229,6 +274,7 @@ def line_edit_style() -> str:
|
||||
def list_button_style(
|
||||
color: QColor | None = None,
|
||||
border_style: str = "solid",
|
||||
italic: bool = False,
|
||||
) -> str:
|
||||
"""Style used for special QPushButtons found in lists."""
|
||||
if color is None:
|
||||
@@ -243,6 +289,7 @@ def list_button_style(
|
||||
background: rgba{color.toTuple()};
|
||||
color: rgba{text_color.toTuple()};
|
||||
font-weight: 600;
|
||||
{"font: italic;" if italic else ""}
|
||||
border-color: rgba{border_color.toTuple()};
|
||||
border-radius: 6px;
|
||||
border-style: {border_style};
|
||||
@@ -310,9 +357,9 @@ def tag_style(
|
||||
border-radius: 6px;
|
||||
border-style: {border_style};
|
||||
border-width: 2px;
|
||||
font-size: 13px;
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
font-size: 13px
|
||||
}}
|
||||
QPushButton::hover{{
|
||||
border-color: rgba{highlight_color.toTuple()};
|
||||
@@ -323,12 +370,9 @@ def tag_style(
|
||||
border-color: rgba{primary_color.toTuple()};
|
||||
}}
|
||||
QPushButton::focus{{
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
outline-style: solid;
|
||||
outline-width: 1px;
|
||||
outline-radius: 4px;
|
||||
outline-color: rgba{text_color.toTuple()};
|
||||
outline: none;
|
||||
border-width: 3px;
|
||||
border-color: rgba{text_color.toTuple()};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -366,6 +410,13 @@ def tag_remove_button_style(
|
||||
"""
|
||||
|
||||
|
||||
def widget_underline_style() -> str:
|
||||
return f"""
|
||||
background: {Palette.accent()};
|
||||
border-radius: 2px;
|
||||
"""
|
||||
|
||||
|
||||
def title_line_edit_style() -> str:
|
||||
"""Used to mimic an H3-like header style inside a QLineEdit."""
|
||||
return """
|
||||
@@ -374,6 +425,110 @@ def title_line_edit_style() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def inset_container_style(object_name: str = "") -> str:
|
||||
"""Used for darkened inset areas."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QWidget{"#" + object_name if object_name else ""}{{
|
||||
background: {bg_color};
|
||||
border-radius: 6px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# TODO: Combine the autofill styles into one method?
|
||||
def autofill_scroll_top_style(object_name: str = "") -> str:
|
||||
"""Used autofill lists positioned on top of line edits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QWidget{"#" + object_name if object_name else ""}{{
|
||||
background: {bg_color};
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def autofill_scroll_top_focus_style(object_name: str = "") -> str:
|
||||
"""Used autofill lists positioned on top of line edits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QWidget{"#" + object_name if object_name else ""}{{
|
||||
background: {bg_color};
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border: solid;
|
||||
border-width: 2px 2px 0px 2px;
|
||||
border-color: {Palette.accent()};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def autofill_line_edit_style() -> str:
|
||||
"""Used for QLineEdits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QLineEdit{{
|
||||
background: {bg_color};
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
}}
|
||||
QLineEdit::focus{{
|
||||
padding: 4px 4px;
|
||||
border: solid;
|
||||
border-width: 2px;
|
||||
border-color: {Palette.accent()};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def autofill_line_edit_top_style() -> str:
|
||||
"""Used for QLineEdits when there's a top autofill section present."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
QLineEdit{{
|
||||
background: {bg_color};
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
padding: 0px 0px 2px 6px;
|
||||
}}
|
||||
QLineEdit::focus{{
|
||||
padding: 4px 4px;
|
||||
border: solid;
|
||||
border-width: 0px 2px 2px 2px;
|
||||
border-color: {Palette.accent()};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def preview_warning_style() -> str:
|
||||
return f"""
|
||||
QWidget#ffmpeg_widget {{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
autofill_line_edit_style,
|
||||
autofill_scroll_top_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SuggestBoxView(QVBoxLayout):
|
||||
def __init__(self, placeholder_text: str = "") -> None:
|
||||
super().__init__()
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSpacing(0)
|
||||
|
||||
# HACK: The transparent border allows for the focus border color to
|
||||
# still show above the tags at the edges... sort of (overlaps on left when h-scrolling)
|
||||
scroll_area_style = """
|
||||
QScrollArea{
|
||||
background: transparent;
|
||||
border: solid;
|
||||
border-color: transparent;
|
||||
border-width: 0px 2px;
|
||||
padding-left: -2px;
|
||||
}
|
||||
QScrollArea > QWidget > QWidget{
|
||||
background: transparent;
|
||||
}
|
||||
"""
|
||||
|
||||
# Autocomplete ScrollArea
|
||||
contents = QWidget()
|
||||
self.content_layout = QHBoxLayout(contents)
|
||||
self.content_layout.setSpacing(6)
|
||||
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scroll_area_container = QWidget()
|
||||
scroll_area_container.setObjectName("container")
|
||||
scroll_area_container_layout = QHBoxLayout(scroll_area_container)
|
||||
scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scroll_area_container_layout.setSpacing(0)
|
||||
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setStyleSheet(scroll_area_style)
|
||||
scroll_area_container_layout.addWidget(self.scroll_area)
|
||||
self.scroll_area.setWidget(contents)
|
||||
search_bar_height = 28
|
||||
underline_padding = 7
|
||||
self.scroll_area.setMaximumHeight(search_bar_height + underline_padding)
|
||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.verticalScrollBar().setEnabled(False)
|
||||
self.scroll_area.setContentsMargins(0, 0, 0, 0)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
# Search Field
|
||||
self.search_field = AutofillLineEdit(scroll_area_container)
|
||||
self.search_field.setStyleSheet(autofill_line_edit_style())
|
||||
self.search_field.setObjectName("search_field")
|
||||
self.search_field.setMinimumHeight(28)
|
||||
self.search_field.setPlaceholderText(placeholder_text)
|
||||
self.scroll_area.setFocusProxy(self.search_field)
|
||||
|
||||
# Finalize Layout
|
||||
self.addWidget(scroll_area_container)
|
||||
self.addWidget(self.search_field)
|
||||
@@ -39,17 +39,12 @@ class TagBoxWidgetView(FieldWidget):
|
||||
|
||||
for tag in tags_:
|
||||
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
|
||||
|
||||
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
|
||||
|
||||
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
|
||||
|
||||
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
|
||||
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, t=tag: self._on_search(t)
|
||||
)
|
||||
|
||||
self.__root_layout.addWidget(tag_widget)
|
||||
|
||||
def _on_click(self, tag: Tag) -> None:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
@@ -16,6 +18,7 @@ class TagSearchPanelView(SearchPanelView):
|
||||
self.search_field.setPlaceholderText(Translations["home.search_tags"])
|
||||
self.create_button.setText(Translations["tag.create"])
|
||||
|
||||
@override
|
||||
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style
|
||||
|
||||
|
||||
class UnderlinedWidgetView(QVBoxLayout):
|
||||
def __init__(self, widget: QWidget) -> None:
|
||||
super().__init__()
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSpacing(3)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# HACK: I don't know why I can't just use a QFrame for the outline.
|
||||
# The styling and sizing only seems to work if it's something like a QPushButton.
|
||||
self.underline = QPushButton()
|
||||
self.underline.setFlat(True)
|
||||
self.underline.setDisabled(True)
|
||||
self.underline.setMaximumHeight(4)
|
||||
self.underline.setStyleSheet(widget_underline_style())
|
||||
|
||||
self.addWidget(widget)
|
||||
self.addWidget(self.underline)
|
||||
@@ -30,9 +30,9 @@
|
||||
"drop_import.description": "The following files match file paths that already exist in the library",
|
||||
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
|
||||
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
|
||||
"drop_import.progress.label.initial": "Importing New Files...",
|
||||
"drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}",
|
||||
"drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
|
||||
"drop_import.progress.label.initial": "Importing New Files…",
|
||||
"drop_import.progress.label.plural": "Importing New Files…\n{count} Files Imported.{suffix}",
|
||||
"drop_import.progress.label.singular": "Importing New Files…\n1 File imported.{suffix}",
|
||||
"drop_import.progress.window_title": "Import Files",
|
||||
"drop_import.title": "Conflicting File(s)",
|
||||
"edit.color_manager": "Manage Tag Colors",
|
||||
@@ -40,26 +40,26 @@
|
||||
"edit.paste_fields": "Paste Fields",
|
||||
"edit.tag_manager": "Manage Tags",
|
||||
"entries.duplicate.merge": "Merge Duplicate Entries",
|
||||
"entries.duplicate.merge.label": "Merging Duplicate Entries...",
|
||||
"entries.duplicate.merge.label": "Merging Duplicate Entries…",
|
||||
"entries.duplicate.refresh": "Refresh Duplicate Entries",
|
||||
"entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
|
||||
"entries.generic.refresh_alt": "&Refresh",
|
||||
"entries.generic.remove.removing": "Removing Entries",
|
||||
"entries.generic.remove.removing_count": "Removing {count} Entries...",
|
||||
"entries.generic.remove.removing_count": "Removing {count} Entries…",
|
||||
"entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.",
|
||||
"entries.ignored.ignored_count": "Ignored Entries: {count}",
|
||||
"entries.ignored.remove": "Remove Ignored Entries",
|
||||
"entries.ignored.remove_alt": "Remo&ve Ignored Entries",
|
||||
"entries.ignored.scanning": "Scanning Library for Ignored Entries...",
|
||||
"entries.ignored.scanning": "Scanning Library for Ignored Entries…",
|
||||
"entries.ignored.title": "Fix Ignored Entries",
|
||||
"entries.mirror": "&Mirror",
|
||||
"entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?",
|
||||
"entries.mirror.label": "Mirroring {idx}/{total} Entries...",
|
||||
"entries.mirror.label": "Mirroring {idx}/{total} Entries…",
|
||||
"entries.mirror.title": "Mirroring Entries",
|
||||
"entries.mirror.window_title": "Mirror Entries",
|
||||
"entries.remove.plural.confirm": "Are you sure you want to remove these <b>{count}</b> entries from your library? No files on disk will be deleted.",
|
||||
"entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.",
|
||||
"entries.running.dialog.new_entries": "Adding {total} New File Entries...",
|
||||
"entries.running.dialog.new_entries": "Adding {total} New File Entries…",
|
||||
"entries.running.dialog.title": "Adding New File Entries",
|
||||
"entries.tags": "Tags",
|
||||
"entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.<br><br>Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
|
||||
@@ -68,7 +68,7 @@
|
||||
"entries.unlinked.relink.title": "Relinking Entries",
|
||||
"entries.unlinked.remove": "Remove Unlinked Entries",
|
||||
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
|
||||
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
|
||||
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries…",
|
||||
"entries.unlinked.search_and_relink": "&Search && Relink",
|
||||
"entries.unlinked.title": "Fix Unlinked Entries",
|
||||
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
|
||||
@@ -161,9 +161,12 @@
|
||||
"generic.yes": "Yes",
|
||||
"home.search": "Search",
|
||||
"home.search_entries": "Search Entries",
|
||||
"home.search_field_templates": "Search Field Templates",
|
||||
"home.search_field_templates": "Search Field Templates…",
|
||||
"home.search_library": "Search Library",
|
||||
"home.search_tags": "Search Tags",
|
||||
"home.search_or_create_fields": "Search or Create Fields…",
|
||||
"home.search_or_create_tags": "Search or Create Tags…",
|
||||
"home.search_tags": "Search Tags…",
|
||||
"home.search.how_to_exit": "(Esc to Exit)",
|
||||
"home.search.view_limit": "View Limit:",
|
||||
"home.show_hidden_entries": "Show Hidden Entries",
|
||||
"home.thumbnail_size": "Thumbnail Size",
|
||||
@@ -173,8 +176,8 @@
|
||||
"home.thumbnail_size.mini": "Mini Thumbnails",
|
||||
"home.thumbnail_size.small": "Small Thumbnails",
|
||||
"ignore.open_file": "Show \"{ts_ignore}\" File on Disk",
|
||||
"json_migration.checking_for_parity": "Checking for Parity...",
|
||||
"json_migration.creating_database_tables": "Creating SQL Database Tables...",
|
||||
"json_migration.checking_for_parity": "Checking for Parity…",
|
||||
"json_migration.creating_database_tables": "Creating SQL Database Tables…",
|
||||
"json_migration.description": "<br>Start and preview the results of the library migration process. The converted library will <i>not</i> be used unless you click \"Finish Migration\". <br><br>Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"<b>(!)</b>\" symbol next to them.<br><center><i>This process may take up to several minutes for larger libraries.</i></center>",
|
||||
"json_migration.discrepancies_found": "Library Discrepancies Found",
|
||||
"json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.",
|
||||
@@ -189,7 +192,7 @@
|
||||
"json_migration.heading.paths": "Paths:",
|
||||
"json_migration.heading.shorthands": "Shorthands:",
|
||||
"json_migration.info.description": "Library save files created with TagStudio versions <b>9.4 and below</b> will need to be migrated to the new <b>v9.5+</b> format.<br><h2>What you need to know:</h2><ul><li>Your existing library save file will <b><i>NOT</i></b> be deleted</li><li>Your personal files will <b><i>NOT</i></b> be deleted, moved, or modified</li><li>The new v9.5+ save format can not be opened in earlier versions of TagStudio</li></ul><h3>What's changed:</h3><ul><li>\"Tag Fields\" have been replaced by \"Tag Categories\". Instead of adding tags to fields first, tags now get added directly to file entries. They're then automatically organized into categories based on parent tags marked with the new \"Is Category\" property in the tag editing menu. Any tag can be marked as a category, and child tags will sort themselves underneath parent tags marked as categories. The \"Favorite\" and \"Archived\" tags now inherit from a new \"Meta Tags\" tag which is marked as a category by default.</li><li>Tag colors have been tweaked and expanded upon. Some colors have been renamed or consolidated, however all tag colors will still convert to exact or close matches in v9.5.</li></ul><ul>",
|
||||
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries...",
|
||||
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries…",
|
||||
"json_migration.migration_complete": "Migration Complete!",
|
||||
"json_migration.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found",
|
||||
"json_migration.start_and_preview": "Start and Preview",
|
||||
@@ -248,12 +251,12 @@
|
||||
"library_object.slug_required": "ID Slug (Required)",
|
||||
"library.missing": "Library Location is Missing",
|
||||
"library.name": "Library",
|
||||
"library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
|
||||
"library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
|
||||
"library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
|
||||
"library.refresh.scanning_preparing": "Scanning Directories for New Files…\nPreparing…",
|
||||
"library.refresh.scanning.plural": "Scanning Directories for New Files…\n{searched_count} Files Searched, {found_count} New Files Found",
|
||||
"library.refresh.scanning.singular": "Scanning Directories for New Files…\n{searched_count} File Searched, {found_count} New Files Found",
|
||||
"library.refresh.title": "Refreshing Directories",
|
||||
"library.scan_library.title": "Scanning Library",
|
||||
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
|
||||
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries…",
|
||||
"macros.running.dialog.title": "Running Macros on New Entries",
|
||||
"media_player.autoplay": "Autoplay",
|
||||
"media_player.loop": "Loop",
|
||||
@@ -283,7 +286,7 @@
|
||||
"menu.macros": "&Macros",
|
||||
"menu.macros.folders_to_tags": "Folders to Tags",
|
||||
"menu.select": "Select",
|
||||
"menu.settings": "Settings...",
|
||||
"menu.settings": "Settings…",
|
||||
"menu.tools": "&Tools",
|
||||
"menu.tools.fix_duplicate_files": "Fix &Duplicate Files",
|
||||
"menu.tools.fix_ignored_entries": "Fix &Ignored Entries",
|
||||
@@ -315,6 +318,8 @@
|
||||
"settings.dateformat.international": "International",
|
||||
"settings.dateformat.label": "Date Format",
|
||||
"settings.dateformat.system": "System",
|
||||
"settings.edit_field_on_add": "Edit After Adding a Field",
|
||||
"settings.edit_tag_on_create": "Edit After Creating a New Tag",
|
||||
"settings.filepath.label": "Filepath Visibility",
|
||||
"settings.filepath.option.full": "Show Full Paths",
|
||||
"settings.filepath.option.name": "Show Filenames Only",
|
||||
@@ -354,18 +359,18 @@
|
||||
"sorting.direction.ascending": "Ascending",
|
||||
"sorting.direction.descending": "Descending",
|
||||
"sorting.mode.random": "Random",
|
||||
"splash.opening_library": "Opening Library \"{library_path}\"...",
|
||||
"splash.opening_library": "Opening Library \"{library_path}\"…",
|
||||
"status.deleted_file_plural": "Deleted {count} files!",
|
||||
"status.deleted_file_singular": "Deleted 1 file!",
|
||||
"status.deleted_none": "No files deleted.",
|
||||
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
|
||||
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_in_progress": "Saving Library Backup...",
|
||||
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"…",
|
||||
"status.library_backup_in_progress": "Saving Library Backup…",
|
||||
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "Library Closed ({time_span})",
|
||||
"status.library_closing": "Closing Library...",
|
||||
"status.library_closing": "Closing Library…",
|
||||
"status.library_save_success": "Library Saved and Closed!",
|
||||
"status.library_search_query": "Searching Library...",
|
||||
"status.library_search_query": "Searching Library…",
|
||||
"status.library_version_expected": "Expected:",
|
||||
"status.library_version_found": "Found:",
|
||||
"status.library_version_mismatch": "Library Version Mismatch!",
|
||||
|
||||
@@ -20,7 +20,6 @@ sys.path.insert(0, str(CWD.parent))
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
@@ -36,22 +35,18 @@ def file_mediatypes_library():
|
||||
|
||||
status = lib.open_library(Path(""), in_memory=True)
|
||||
assert status.success
|
||||
folder = unwrap(lib.folder)
|
||||
|
||||
entry1 = Entry(
|
||||
folder=folder,
|
||||
path=Path("foo.png"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
entry2 = Entry(
|
||||
folder=folder,
|
||||
path=Path("bar.png"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
entry3 = Entry(
|
||||
folder=folder,
|
||||
path=Path("baz.apng"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
@@ -87,7 +82,6 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
lib = Library()
|
||||
status = lib.open_library(library_path, in_memory=True)
|
||||
assert status.success
|
||||
folder = unwrap(lib.folder)
|
||||
|
||||
tag = Tag(
|
||||
name="foo",
|
||||
@@ -116,7 +110,6 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
# default item with deterministic name
|
||||
entry = Entry(
|
||||
id=1,
|
||||
folder=folder,
|
||||
path=Path("foo.txt"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
@@ -124,7 +117,6 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
|
||||
entry2 = Entry(
|
||||
id=2,
|
||||
folder=folder,
|
||||
path=Path("one/two/bar.md"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,25 +8,21 @@ from tagstudio.core.library.alchemy.fields import BaseField, TextField
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
|
||||
def test_refresh_dupe_files(library: Library):
|
||||
library.library_dir = Path("/tmp/")
|
||||
folder = unwrap(library.folder)
|
||||
|
||||
fields: list[BaseField] = [TextField(name="Title", value="I'm a Test Title")]
|
||||
|
||||
entry = Entry(
|
||||
folder=folder,
|
||||
path=Path("bar/foo.txt"),
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
entry2 = Entry(
|
||||
folder=folder,
|
||||
path=Path("foo/foo.txt"),
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_update_selection_empty(qt_driver: QtDriver):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Clear the library selection (selecting 1 then unselecting 1)
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
@@ -18,26 +18,26 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
for container in panel.field_containers_widget.containers:
|
||||
for container in panel.containers._containers:
|
||||
assert container.isHidden()
|
||||
|
||||
|
||||
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# FieldContainer should show all applicable tags and field containers
|
||||
for container in panel.field_containers_widget.containers:
|
||||
for container in panel.containers._containers:
|
||||
assert not container.isHidden()
|
||||
|
||||
|
||||
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
def test_update_selection_multiple(qt_driver: QtDriver):
|
||||
# TODO: Implement mixed field editing. Currently these containers will be hidden,
|
||||
# same as the empty selection behavior.
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Select the multiple entries
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
@@ -45,12 +45,12 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# FieldContainer should show mixed field editing
|
||||
for container in panel.field_containers_widget.containers:
|
||||
for container in panel.containers._containers:
|
||||
assert container.isHidden()
|
||||
|
||||
|
||||
def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
assert {t.id for t in entry_full.tags} == {1000}
|
||||
|
||||
@@ -59,15 +59,15 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entr
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Add new tag
|
||||
panel.field_containers_widget.add_tags_to_selected(2000)
|
||||
panel.containers.add_tags_to_selected(2000)
|
||||
|
||||
# Then reload entry
|
||||
refreshed_entry: Entry = next(library.all_entries(with_joins=True))
|
||||
refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
|
||||
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
|
||||
|
||||
|
||||
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
assert {t.id for t in entry_full.tags} == {1000}
|
||||
|
||||
@@ -76,16 +76,16 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library,
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Add an existing tag
|
||||
panel.field_containers_widget.add_tags_to_selected(1000)
|
||||
panel.containers.add_tags_to_selected(1000)
|
||||
|
||||
# Then reload entry
|
||||
refreshed_entry = next(library.all_entries(with_joins=True))
|
||||
refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
|
||||
assert {t.id for t in refreshed_entry.tags} == {1000}
|
||||
|
||||
|
||||
def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
all_entries = library.all_entries(with_joins=True)
|
||||
def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
all_entries = qt_driver.lib.all_entries(with_joins=True)
|
||||
|
||||
# We want to verify that tag 1000 is on some, but not all entries already.
|
||||
tag_present_on_some: bool = False
|
||||
@@ -101,15 +101,15 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
assert tag_absent_on_some
|
||||
|
||||
# Select the multiple entries
|
||||
for i, e in enumerate(library.all_entries(with_joins=True), start=0):
|
||||
for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0):
|
||||
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Add new tag
|
||||
panel.field_containers_widget.add_tags_to_selected(1000)
|
||||
panel.containers.add_tags_to_selected(1000)
|
||||
|
||||
# Then reload all entries and recheck the presence of tag 1000
|
||||
refreshed_entries = library.all_entries(with_joins=True)
|
||||
refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
|
||||
tag_present_on_some = False
|
||||
tag_absent_on_some = False
|
||||
|
||||
@@ -123,23 +123,23 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
assert not tag_absent_on_some
|
||||
|
||||
|
||||
def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entries(1, entry_full.id)
|
||||
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
assert len(panel.field_containers_widget.containers) == 3
|
||||
for i, container in enumerate(panel.field_containers_widget.containers):
|
||||
assert len(panel.containers._containers) == 3
|
||||
for i, container in enumerate(panel.containers._containers):
|
||||
match i:
|
||||
case 0:
|
||||
# Check if the container is the Meta Tags category
|
||||
tag: Tag = unwrap(library.get_tag(2))
|
||||
tag: Tag = unwrap(qt_driver.lib.get_tag(2))
|
||||
assert container.title == f"<h4>{tag.name}</h4>"
|
||||
case 1:
|
||||
# Check if the container is the Tags category
|
||||
@@ -151,30 +151,28 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En
|
||||
pass
|
||||
|
||||
|
||||
def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Set tag 1000 (foo) as a category
|
||||
tag: Tag = unwrap(library.get_tag(1000))
|
||||
tag: Tag = unwrap(qt_driver.lib.get_tag(1000))
|
||||
tag.is_category = True
|
||||
library.update_tag(
|
||||
tag,
|
||||
)
|
||||
qt_driver.lib.update_tag(tag)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entries(1, entry_full.id)
|
||||
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
assert len(panel.field_containers_widget.containers) == 3
|
||||
for i, container in enumerate(panel.field_containers_widget.containers):
|
||||
assert len(panel.containers._containers) == 3
|
||||
for i, container in enumerate(panel.containers._containers):
|
||||
match i:
|
||||
case 0:
|
||||
# Check if the container is the Meta Tags category
|
||||
tag_2: Tag = unwrap(library.get_tag(2))
|
||||
tag_2: Tag = unwrap(qt_driver.lib.get_tag(2))
|
||||
assert container.title == f"<h4>{tag_2.name}</h4>"
|
||||
case 1:
|
||||
# Check if the container is the custom "foo" category
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
@@ -8,9 +9,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
)
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu, QMenuBar
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
@@ -60,11 +59,10 @@ def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: Sh
|
||||
)
|
||||
def test_file_path_display(
|
||||
qt_driver: QtDriver,
|
||||
library: Library,
|
||||
filepath_option: ShowFilepathOption,
|
||||
expected_path: Callable[[Library], Path],
|
||||
):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Select 2
|
||||
qt_driver.toggle_item_selection(2, append=False, bridge=False)
|
||||
@@ -73,15 +71,15 @@ def test_file_path_display(
|
||||
qt_driver.settings.show_filepath = filepath_option
|
||||
|
||||
# Apply the mock value
|
||||
entry = library.get_entry(2)
|
||||
entry = qt_driver.lib.get_entry(2)
|
||||
assert isinstance(entry, Entry)
|
||||
filename = entry.path
|
||||
panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage]
|
||||
panel.layout().file_attrs.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
|
||||
# can be imported as a function in the future
|
||||
display_path: Path = expected_path(library)
|
||||
display_path: Path = expected_path(qt_driver.lib)
|
||||
file_str: str = ""
|
||||
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
for i, part in enumerate(display_path.parts):
|
||||
@@ -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 # pyright: ignore[reportPrivateUsage]
|
||||
assert panel.layout().file_attrs.file_label.text() == file_str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -146,7 +144,7 @@ def test_title_update(
|
||||
qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar)
|
||||
|
||||
# Trigger the update
|
||||
qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage]
|
||||
qt_driver._init_library(library_dir, open_status)
|
||||
|
||||
# Assert the title is updated correctly
|
||||
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue]
|
||||
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_update_selection_empty(qt_driver: QtDriver):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Clear the library selection (selecting 1 then unselecting 1)
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
@@ -17,22 +17,27 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Panel should disable UI that allows for entry modification
|
||||
assert not panel.add_buttons_enabled
|
||||
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
|
||||
assert (
|
||||
not panel.layout().add_tag_button.isEnabled()
|
||||
and not panel.layout().add_field_button.isEnabled()
|
||||
)
|
||||
|
||||
|
||||
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Panel should enable UI that allows for entry modification
|
||||
assert panel.add_buttons_enabled
|
||||
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
|
||||
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
|
||||
|
||||
|
||||
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
def test_update_selection_multiple(qt_driver: QtDriver):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
# Select the multiple entries
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
@@ -40,4 +45,5 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
# Panel should enable UI that allows for entry modification
|
||||
assert panel.add_buttons_enabled
|
||||
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
|
||||
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
|
||||
|
||||
+1
-10
@@ -77,7 +77,6 @@ def test_library_add_file(library: Library):
|
||||
"""Check Entry.path handling for insert vs lookup"""
|
||||
entry = Entry(
|
||||
path=Path("bar.txt"),
|
||||
folder=unwrap(library.folder),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
@@ -139,8 +138,7 @@ def test_get_entry(library: Library, entry_min: Entry):
|
||||
|
||||
|
||||
def test_entries_count(library: Library):
|
||||
folder = unwrap(library.folder)
|
||||
entries = [Entry(path=Path(f"{x}.txt"), folder=folder, fields=[]) for x in range(10)]
|
||||
entries = [Entry(path=Path(f"{x}.txt"), fields=[]) for x in range(10)]
|
||||
new_ids = library.add_entries(entries)
|
||||
assert len(new_ids) == 10
|
||||
|
||||
@@ -254,7 +252,6 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr
|
||||
def test_mirror_entry_fields(library: Library):
|
||||
# Create and add entries with fields
|
||||
entry_a = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("title_and_date.txt"),
|
||||
fields=[
|
||||
TextField(name="Title", value="I'm a Test Title"),
|
||||
@@ -262,7 +259,6 @@ def test_mirror_entry_fields(library: Library):
|
||||
],
|
||||
)
|
||||
entry_b = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("notes.txt"),
|
||||
fields=[
|
||||
TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True),
|
||||
@@ -270,7 +266,6 @@ def test_mirror_entry_fields(library: Library):
|
||||
],
|
||||
)
|
||||
entry_c = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("date_published.txt"),
|
||||
fields=[
|
||||
DatetimeField(name="Date Published", value="2000-01-01 12:00:00"),
|
||||
@@ -319,14 +314,11 @@ def test_mirror_entry_fields(library: Library):
|
||||
|
||||
|
||||
def test_merge_entries(library: Library):
|
||||
folder = unwrap(library.folder)
|
||||
|
||||
tag_0: Tag = unwrap(library.add_tag(Tag(id=1010, name="tag_0")))
|
||||
tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1")))
|
||||
tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2")))
|
||||
|
||||
entry_a = Entry(
|
||||
folder=folder,
|
||||
path=Path("a"),
|
||||
fields=[
|
||||
TextField(name="Author", value="Author McAuthorson"),
|
||||
@@ -334,7 +326,6 @@ def test_merge_entries(library: Library):
|
||||
],
|
||||
)
|
||||
entry_b = Entry(
|
||||
folder=folder,
|
||||
path=Path("b"),
|
||||
fields=[TextField(name="Notes", value="test note", is_multiline=True)],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user