Compare commits

..

2 Commits

Author SHA1 Message Date
Hosted Weblate d8f928cd9d Translated using Weblate (French)
Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Application Strings
2026-07-14 18:02:06 +00:00
Hosted Weblate 8240632534 Translated using Weblate (Spanish)
Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: 2004milenadiaz-source <2004.milena.diaz@gmail.com>
Co-authored-by: Julen Arratibel Etxabe <jarratibeletxabe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Application Strings
2026-07-14 18:02:03 +00:00
33 changed files with 328 additions and 1079 deletions
+1 -1
View File
@@ -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>N</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>T</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 = 300
DB_VERSION: int = 202
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
+31 -39
View File
@@ -18,6 +18,7 @@ from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
from uuid import uuid4
import sqlalchemy
import structlog
@@ -94,6 +95,7 @@ from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.models import (
Entry,
Folder,
Namespace,
Tag,
TagAlias,
@@ -232,6 +234,7 @@ class Library:
library_dir: Path | None = None
engine: Engine | None = None
folder: Folder | None = None
included_files: set[Path] = set()
def __init__(self) -> None:
@@ -256,6 +259,7 @@ class Library:
"""Migrate JSON library data to the SQLite database."""
logger.info("Starting Library Conversion...")
start_time = time.time()
folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4()))
# Tags
for tag in json_lib.tags:
@@ -308,6 +312,7 @@ class Library:
[
Entry(
path=entry.path / entry.filename,
folder=folder,
fields=[],
id=entry.id + 1, # NOTE: JSON IDs start at 0 instead of 1
date_added=datetime.now(),
@@ -474,6 +479,16 @@ class Library:
session.add(Version(key=DB_VERSION_CURRENT_KEY, value=DB_VERSION))
session.flush()
# add folder for current path
folder = Folder(
path=library_dir,
uuid=str(uuid4()),
)
session.add(folder)
session.expunge(folder)
session.flush()
self.folder = folder
# Generate default .ts_ignore file
try:
ts_ignore_template = (
@@ -569,7 +584,6 @@ class Library:
(self.__apply_db200_migration, 200, None), # changes: field tables
(self.__apply_db201_migration, 201, 200), # changes: field tables
(self.__apply_db202_migration, 202, None), # changes: tag_parents
(self.__apply_db300_migration, 300, None), # changes: deletes folders
]
for migration, v, iv in migrations:
if loaded_db_version < v and (iv is None or initial_db_version < iv):
@@ -587,6 +601,22 @@ class Library:
)
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
with Session(self.engine) as session:
# TODO: the folder logic has no use and was never finished, remove it
# check if folder matching current path exists already
# NOTE: this has been causing new Folders to be created when the library is moved, since
# its introduction
self.folder = session.scalar(select(Folder).where(Folder.path == library_dir))
if not self.folder:
folder = Folder(
path=library_dir,
uuid=str(uuid4()),
)
session.add(folder)
session.expunge(folder)
session.commit()
self.folder = folder
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
@@ -876,44 +906,6 @@ class Library:
session.flush()
logger.info("[Library][Migration][202] Verified TagParent table data")
def __apply_db300_migration(self, session: Session, library_dir: Path):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
@property
def field_templates(self) -> Sequence[BaseFieldTemplate]:
with Session(self.engine) as session:
@@ -182,11 +182,23 @@ 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()
@@ -223,6 +235,7 @@ class Entry(Base):
def __init__(
self,
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
@@ -231,6 +244,7 @@ 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()
+2
View File
@@ -16,6 +16,7 @@ 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__)
@@ -40,6 +41,7 @@ class RefreshTracker:
entries = [
Entry(
path=entry_path,
folder=unwrap(self.library.folder),
fields=[],
date_added=dt.now(),
)
@@ -1,61 +0,0 @@
# 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)
@@ -3,98 +3,49 @@
import typing
from pathlib import Path
from typing import override
from warnings import catch_warnings
import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.mixed.file_attributes import FileAttributeData
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.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, driver: "QtDriver") -> None:
super().__init__(driver)
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
self.__current_stats: FileAttributeData | None = None
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
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
key = QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
)
self.add_tag_action = QShortcut(key, self)
self.__connect_callbacks()
def __connect_callbacks(self) -> None:
self._add_field_button.clicked.connect(self._add_field_button_callback)
self._add_tag_button.clicked.connect(self._add_tag_button_callback)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
self.add_tag_action.activated.connect(self._add_tag_button.setFocus)
self.add_tag_action.activated.connect(self._add_tag_button.click)
self.tag_search_box.done.connect(self.tag_added_callback)
self.tag_search_box.tags_updated.connect(self.update_added_callback)
@typing.override
def _add_field_button_callback(self) -> None:
# self.__add_field_modal.show()
pass
self.__add_field_modal.show()
@typing.override
def _add_tag_button_callback(self) -> None:
self.tag_search_box.added = self._containers.tags
self.tag_search_box.layout().search_field.setDisabled(False)
self.tag_search_box.setHidden(False)
self._add_tag_button.setHidden(True)
self._add_field_button.setHidden(True)
self.__add_tag_modal.show()
def tag_added_callback(self):
self.tag_search_box.setHidden(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self._add_tag_button.setFocus()
def update_added_callback(self):
self.tag_search_box.added = self._containers.tags
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._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._file_attrs.update_stats(filepath, self.__current_stats)
@override
@typing.override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
self.field_search_box.field_template_chosen.disconnect()
self.tag_search_box.item_chosen.disconnect()
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
self.__add_tag_modal.tsp.item_chosen.disconnect()
self.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
self.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
self.__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)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
@@ -1,27 +0,0 @@
# 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)
-225
View File
@@ -1,225 +0,0 @@
# 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.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__)
# Only import for type checking/autocompletion, will not be imported at runtime.
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(int)
done = Signal()
tags_updated = Signal()
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
super().__init__()
self._layout = view
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(self._layout)
self._connect_callbacks()
def _connect_callbacks(self) -> None:
self._layout.search_field.textChanged.connect(self.on_search_query_changed)
self._layout.search_field.editingFinished.connect(self.test_editing_finished)
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:
self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect)
else:
self._is_shift_held = False
if self._layout.content_layout.count() > 0:
self._layout.content_layout.itemAt(0).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[reportExplicitAny]
return self.get_item_widget(index, library)
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 test_editing_finished(self):
logger.info("Editing finished")
self.tags_updated.emit()
if self._layout.search_field.text() == "":
self.done.emit()
self.hide_and_reset()
def hide_and_reset(self):
self.hide()
self._layout.search_field.setDisabled(True)
self._on_shift_held(held=False)
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.clear_search_query()
return super().showEvent(event)
@override
def layout(self) -> SuggestBoxView:
return self._layout
@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()
@@ -1,180 +0,0 @@
# 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.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(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."""
tag_widget: TagWidget = self.get_item_widget(index, self._lib)
tag_widget.has_remove = False
tag_widget.set_tag(item)
tag_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
# 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(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) -> TagWidget:
"""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)
tag_widget.setHidden(True)
self._layout.content_layout.addWidget(tag_widget)
tag_widget: QWidget = self._layout.content_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
-1
View File
@@ -74,7 +74,6 @@ 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)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
+11 -12
View File
@@ -10,6 +10,7 @@ from warnings import catch_warnings
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -20,6 +21,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
@@ -36,7 +38,6 @@ 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
@@ -59,6 +60,12 @@ class FieldContainers(QWidget):
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.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
@@ -85,7 +92,9 @@ 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(inset_container_style("entryScrollContainer"))
self.scroll_area.setStyleSheet(
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
)
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
@@ -471,13 +480,3 @@ 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]
-9
View File
@@ -216,13 +216,6 @@ class SettingsPanel(PanelWidget):
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
# Open Edit Window When Creating 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
)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -373,7 +366,6 @@ 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(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -396,7 +388,6 @@ 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.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
+6 -8
View File
@@ -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, QSizePolicy, QVBoxLayout, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
@@ -121,7 +121,6 @@ class TagWidget(QWidget):
# if on_click_callback:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.base_layout = QVBoxLayout(self)
self.base_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.base_layout.setObjectName("baseLayout")
self.base_layout.setContentsMargins(0, 0, 0, 0)
@@ -149,24 +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.setFixedSize(22, 22)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(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.setFixedHeight(22)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.bg_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
-22
View File
@@ -7,34 +7,12 @@ 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
+8 -17
View File
@@ -51,6 +51,8 @@ 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
@@ -75,7 +77,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, Palette, UiColor, get_ui_color
from tagstudio.qt.models.palette import ColorType, 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
@@ -272,7 +274,7 @@ class QtDriver(DriverMixin, QObject):
dir = QFileDialog.getExistingDirectory(
parent=None,
caption=Translations["window.title.open_create_library"],
dir=str(Path.home()),
dir="/",
options=QFileDialog.Option.ShowDirsOnly,
)
if dir not in (None, ""):
@@ -301,28 +303,19 @@ 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")
)
Palette.set_palette(pal)
self.app.setPalette(pal)
self.app.setPalette(pal)
# Handle OS signals
self.setup_signals()
@@ -634,10 +627,8 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
self.main_window.search_field.setFocus()
self.app.exec()
self.check_for_update()
self.app.exec()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
+6 -15
View File
@@ -5,11 +5,10 @@
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, QtGui
from PySide6 import QtCore
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
@@ -183,10 +182,10 @@ class MainMenuBar(QMenuBar):
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_N,
QtCore.Qt.Key.Key_T,
)
)
self.new_tag_action.setToolTip("Ctrl+N")
self.new_tag_action.setToolTip("Ctrl+T")
self.new_tag_action.setEnabled(False)
self.edit_menu.addAction(self.new_tag_action)
@@ -221,8 +220,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.setShortcut(QtCore.Qt.Key.Key_Escape)
self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.edit_menu.addAction(self.clear_select_action)
@@ -701,12 +700,10 @@ class MainWindow(QMainWindow):
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
self.preview_panel = PreviewPanel(driver)
self.preview_panel = PreviewPanel(driver.lib, 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(
@@ -749,9 +746,3 @@ 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)
+71 -63
View File
@@ -10,22 +10,25 @@ from pathlib import Path
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
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_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
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
@@ -34,40 +37,27 @@ logger = structlog.get_logger(__name__)
class PreviewPanelView(QWidget):
lib: Library
_selected: list[int]
def __init__(self, driver: "QtDriver") -> None:
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self._lib = driver.lib
self.lib = library
rm = ResourceManager()
self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
self._lib,
is_field_template_chooser=True,
view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
)
tag_placeholder = " ".join(
[Translations["home.search_or_create_tags"], Translations["home.search.how_to_exit"]]
)
self.tag_search_box = TagSuggestBox(
driver, view=SuggestBoxView(placeholder_text=tag_placeholder)
)
self.tag_search_box.hide()
self._thumb = PreviewThumb(self._lib, driver)
self._file_attrs = FileAttributes(self._lib, driver)
self._thumb = PreviewThumb(self.lib, driver)
self._file_attrs = FileAttributes(self.lib, driver)
self._containers = FieldContainers(
self._lib, driver
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
self.__current_stats: FileAttributeData | None = None
# Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
# Warning Banner (Missing FFmpeg, etc.)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
@@ -92,9 +82,9 @@ class PreviewPanelView(QWidget):
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self._ffmpeg_warning_widget.hide()
# File Information
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -109,22 +99,20 @@ class PreviewPanelView(QWidget):
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
self._add_tag_button = ReturnButton(Translations["tag.add"])
self._add_tag_button.setEnabled(False)
self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._add_tag_button.setMinimumHeight(30)
self._add_tag_button.setStyleSheet(button_style())
self.__add_tag_button = 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_field_button = ReturnButton(Translations["field.add"])
self._add_field_button.setEnabled(False)
self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._add_field_button.setMinimumHeight(30)
self._add_field_button.setStyleSheet(button_style())
self.__add_field_button = 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())
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)
add_buttons_layout.addWidget(self.__add_tag_button)
add_buttons_layout.addWidget(self.__add_field_button)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
@@ -140,6 +128,38 @@ class PreviewPanelView(QWidget):
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)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
def _add_field_button_callback(self) -> None:
raise NotImplementedError()
def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._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._file_attrs.update_stats(filepath, self.__current_stats)
def _set_selection_callback(self) -> None:
raise NotImplementedError()
@@ -161,18 +181,14 @@ class PreviewPanelView(QWidget):
self._file_attrs.update_date_label()
self._containers.hide_containers()
self._add_tag_button.setEnabled(False)
self._add_field_button.setEnabled(False)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
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))
entry: Entry = unwrap(self.lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
filepath: Path = unwrap(self.lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
@@ -185,11 +201,7 @@ class PreviewPanelView(QWidget):
self._set_selection_callback()
self._add_tag_button.setEnabled(True)
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
self.add_buttons_enabled = True
# Multiple Selected Items
elif len(selected) > 1:
@@ -202,11 +214,7 @@ class PreviewPanelView(QWidget):
self._set_selection_callback()
self._add_tag_button.setEnabled(True)
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self.tag_search_box.hide_and_reset()
self.add_buttons_enabled = True
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
@@ -214,15 +222,15 @@ class PreviewPanelView(QWidget):
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
field = self._add_field_button.isEnabled()
tag = self._add_tag_button.isEnabled()
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)
self.__add_field_button.setEnabled(enabled)
self.__add_tag_button.setEnabled(enabled)
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
+11 -159
View 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, Palette, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, 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,29 +53,18 @@ 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{{
outline: none;
background-color: palette(light);
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
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};
@@ -83,40 +72,6 @@ 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))
@@ -274,7 +229,6 @@ 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:
@@ -289,7 +243,6 @@ 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};
@@ -357,9 +310,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()};
@@ -370,9 +323,12 @@ def tag_style(
border-color: rgba{primary_color.toTuple()};
}}
QPushButton::focus{{
outline: none;
border-width: 3px;
border-color: rgba{text_color.toTuple()};
padding-right: 0px;
padding-left: 0px;
outline-style: solid;
outline-width: 1px;
outline-radius: 4px;
outline-color: rgba{text_color.toTuple()};
}}
"""
@@ -418,110 +374,6 @@ 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 {{
@@ -1,83 +0,0 @@
# 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__()
# Init layout
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)
self.scroll_area.setMaximumHeight(28)
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)
+5
View File
@@ -39,12 +39,17 @@ 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,8 +1,6 @@
# 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
@@ -18,7 +16,6 @@ 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
+24 -27
View File
@@ -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,11 +161,9 @@
"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_or_create_tags": "Search or Create Tags",
"home.search_tags": "Search Tags…",
"home.search.how_to_exit": "(Esc to Exit)",
"home.search_tags": "Search Tags",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
@@ -175,8 +173,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.",
@@ -191,7 +189,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",
@@ -250,12 +248,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",
@@ -285,7 +283,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",
@@ -317,7 +315,6 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
"settings.edit_tag_on_create": "Edit Tag After Creation",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
@@ -357,18 +354,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!",
+10 -10
View File
@@ -1,5 +1,5 @@
{
"about.app_cache_path": "Ruta caché aplicación",
"about.app_cache_path": "Ruta de la Caché de la Aplicación",
"about.config_path": "Ruta de Configuración",
"about.description": "TagStudio es una aplicación para organizar fotografías y archivos que utiliza un sistema de etiquetas subyacentes centrado en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente la estructura de tu sistema de archivos.",
"about.documentation": "Documentación",
@@ -8,14 +8,14 @@
"about.title": "Acerca de TagStudio",
"about.version": "Versión",
"about.version.latest": "{built_version} (Última versión: {latest_version})",
"about.website": "Página web",
"about.website": "Página Web",
"app.git": "Commit de Git",
"app.pre_release": "Pre-Lanzamiento",
"app.title": "{base_title} - Biblioteca '{library_dir}'",
"color.color_border": "Usar color secundario para el Borde",
"color.color_border": "Usar Color Secundario para Borde",
"color.confirm_delete": "¿Estás seguro de que quieres eliminar el color \"{color_name}\"?",
"color.delete": "Eliminar Etiqueta",
"color.import_pack": "Importar paquete de colores",
"color.import_pack": "Importar Paquete de Colores",
"color.name": "Nombre",
"color.namespace.delete.prompt": "¿Estás seguro de que quieres eliminar el espacio de nombres de este color? ¡Esto eliminará todos los colores en el espacio de nombres junto con él!",
"color.namespace.delete.title": "Eliminar el espacio de nombres de color",
@@ -25,7 +25,7 @@
"color.primary_required": "Color primario (Obligatorio)",
"color.secondary": "Color secundario",
"color.title.no_color": "Sin color",
"color_manager.title": "Administrar los colores de las etiquetas",
"color_manager.title": "Administrar Colores de Etiquetas",
"dependency.missing.title": "{dependency} no encontrada",
"drop_import.description": "Los siguientes archivos igualan con las rutas de archivos que ya existen en la biblioteca",
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos igualan con las rutas de archivos que ya existen en la biblioteca.",
@@ -227,12 +227,12 @@
"language.tr": "Turco",
"language.zh_Hans": "Chino (simplificado)",
"language.zh_Hant": "Chino (tradicional)",
"library.missing": "Falta la ubicación",
"library.missing": "Falta la Ubicación de la Biblioteca",
"library.name": "Biblioteca",
"library.refresh.scanning.plural": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} archivos buscados, {found_count} nuevos archivos encontrados",
"library.refresh.scanning.singular": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} Archivos buscados, {found_count} Nuevos archivos encontrados",
"library.refresh.scanning_preparing": "Buscar archivos nuevos en los directorios...\nPreparando...",
"library.refresh.title": "Refrescar directorios",
"library.refresh.scanning_preparing": "Buscando archivos nuevos en los directorios...\nPreparando...",
"library.refresh.title": "Refrescando directorios",
"library.scan_library.title": "Escaneando la biblioteca",
"library_info.cleanup": "Limpieza",
"library_info.cleanup.backups": "Reespaldos de la Librería:",
@@ -387,7 +387,7 @@
"tag.is_category": "Es categoría",
"tag.is_hidden": "Está oculto",
"tag.name": "Nombre",
"tag.new": "Nueva etiqueta",
"tag.new": "Nueva Etiqueta",
"tag.parent_tags": "Etiquetas principales",
"tag.parent_tags.add": "Añadir etiquetas principales",
"tag.parent_tags.description": "Esta etiqueta se puede tratar como sustituto de cualquiera de las etiquetas padre en las búsquedas.",
@@ -417,7 +417,7 @@
"view.size.1": "Pequeño",
"view.size.2": "Medio",
"view.size.3": "Grande",
"view.size.4": "Extra grande",
"view.size.4": "Extra Grande",
"window.message.error_opening_library": "Error abriendo la biblioteca.",
"window.title.error": "Error",
"window.title.open_create_library": "Abrir/Crear biblioteca"
@@ -4,6 +4,7 @@
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui met en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
"about.documentation": "Documentation",
"about.module.found": "Trouvé",
"about.modules.title": "Modules optionnels",
"about.title": "À propos de TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
@@ -196,6 +197,36 @@
"json_migration.title.new_lib": "<h2>Bibliothèque v9.5+</h2>",
"json_migration.title.old_lib": "<h2>Bibliothèque v9.4</h2>",
"landing.open_create_library": "Ouvrir/Créer une Bibliothèque {shortcut}",
"language.am": "Amharic",
"language.ceb": "Cébuano",
"language.cs": "Tchèque",
"language.da": "Danois",
"language.de": "Allemand",
"language.el": "Grec",
"language.en": "Anglais",
"language.es": "Espagnol",
"language.fi": "Finnois",
"language.fil": "Philippin",
"language.fr": "Français",
"language.hu": "Hongrois",
"language.is": "Islandais",
"language.it": "Italien",
"language.ja": "Japonais",
"language.nb_NO": "Norvégien (Bokmål)",
"language.nl": "Néerlandais",
"language.pl": "Polonais",
"language.pt": "Portugais",
"language.pt_BR": "Portugais (Brésil)",
"language.qpv": "Viossa",
"language.ro": "Roumain",
"language.ru": "Russe",
"language.sv": "Suédois",
"language.ta": "Tamil",
"language.th": "Thaï",
"language.tok": "Toki Pona",
"language.tr": "Turc",
"language.zh_Hans": "Chinois (Simplifier)",
"language.zh_Hant": "Chinois (Traditionnelle)",
"library.missing": "Emplacement Manquant",
"library.name": "Bibliothèque",
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
@@ -277,6 +308,8 @@
"select.all": "Tout Sélectionner",
"select.clear": "Effacer la Sélection",
"select.inverse": "Inverser la Sélection",
"settings.appearance": "Apparence",
"settings.cached_thumb_resolution.label": "Résolution des vignettes mises en cache",
"settings.clear_thumb_cache.title": "Effacer le cache des vignettes",
"settings.dateformat.english": "Anglais",
"settings.dateformat.international": "International",
@@ -292,6 +325,8 @@
"settings.infinite_scroll": "Défilement continu",
"settings.language": "Langage",
"settings.library": "Paramètres de la Bibliothèque",
"settings.localization": "Localisation",
"settings.media": "Médias",
"settings.open_library_on_start": "Ouvrir la Bibliothèque au Démarrage",
"settings.page_size": "Entités par page",
"settings.restart_required": "Veuillez redémarré TagStudio pour que les changements prenne effet.",
+8
View File
@@ -20,6 +20,7 @@ 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
@@ -35,18 +36,22 @@ 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")],
)
@@ -82,6 +87,7 @@ 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",
@@ -110,6 +116,7 @@ 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")],
)
@@ -117,6 +124,7 @@ 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.
+4
View File
@@ -8,21 +8,25 @@ 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,
)
+30 -27
View File
@@ -2,14 +2,15 @@
# SPDX-License-Identifier: GPL-3.0-only
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):
panel = PreviewPanel(qt_driver)
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -21,8 +22,8 @@ def test_update_selection_empty(qt_driver: QtDriver):
assert container.isHidden()
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -33,10 +34,10 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
assert not container.isHidden()
def test_update_selection_multiple(qt_driver: QtDriver):
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
# TODO: Implement mixed field editing. Currently these containers will be hidden,
# same as the empty selection behavior.
panel = PreviewPanel(qt_driver)
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -48,8 +49,8 @@ def test_update_selection_multiple(qt_driver: QtDriver):
assert container.isHidden()
def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -61,12 +62,12 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.field_containers_widget.add_tags_to_selected(2000)
# Then reload entry
refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
refreshed_entry: Entry = next(library.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, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -78,13 +79,13 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload entry
refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
refreshed_entry = next(library.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):
panel = PreviewPanel(qt_driver)
all_entries = qt_driver.lib.all_entries(with_joins=True)
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)
# We want to verify that tag 1000 is on some, but not all entries already.
tag_present_on_some: bool = False
@@ -100,7 +101,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
assert tag_absent_on_some
# Select the multiple entries
for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0):
for i, e in enumerate(library.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)
@@ -108,7 +109,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
refreshed_entries = library.all_entries(with_joins=True)
tag_present_on_some = False
tag_absent_on_some = False
@@ -122,11 +123,11 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
assert not tag_absent_on_some
def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Ensure the Favorite tag is on entry_full
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -138,7 +139,7 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
match i:
case 0:
# Check if the container is the Meta Tags category
tag: Tag = unwrap(qt_driver.lib.get_tag(2))
tag: Tag = unwrap(library.get_tag(2))
assert container.title == f"<h4>{tag.name}</h4>"
case 1:
# Check if the container is the Tags category
@@ -150,16 +151,18 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
pass
def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Set tag 1000 (foo) as a category
tag: Tag = unwrap(qt_driver.lib.get_tag(1000))
tag: Tag = unwrap(library.get_tag(1000))
tag.is_category = True
qt_driver.lib.update_tag(tag)
library.update_tag(
tag,
)
# Ensure the Favorite tag is on entry_full
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -171,7 +174,7 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
match i:
case 0:
# Check if the container is the Meta Tags category
tag_2: Tag = unwrap(qt_driver.lib.get_tag(2))
tag_2: Tag = unwrap(library.get_tag(2))
assert container.title == f"<h4>{tag_2.name}</h4>"
case 1:
# Check if the container is the custom "foo" category
+11 -11
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false
import os
from collections.abc import Callable
@@ -9,7 +8,9 @@ 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
@@ -59,10 +60,11 @@ 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(qt_driver)
panel = PreviewPanel(library, qt_driver)
# Select 2
qt_driver.toggle_item_selection(2, append=False, bridge=False)
@@ -71,17 +73,15 @@ def test_file_path_display(
qt_driver.settings.show_filepath = filepath_option
# Apply the mock value
entry = qt_driver.lib.get_entry(2)
entry = library.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
panel._file_attributes_widget.update_stats(
filepath=unwrap(qt_driver.lib.library_dir) / filename
)
panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage]
# 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(qt_driver.lib)
display_path: Path = expected_path(library)
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 +94,7 @@ def test_file_path_display(
file_str += f"<b>{'\u200b'.join(part_)}</b>"
# Assert the file path is displayed correctly
assert panel._file_attributes_widget.file_label.text() == file_str
assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize(
@@ -146,7 +146,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)
qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage]
# Assert the title is updated correctly
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue]
+7 -6
View File
@@ -2,13 +2,14 @@
# SPDX-License-Identifier: GPL-3.0-only
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):
panel = PreviewPanel(qt_driver)
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -19,8 +20,8 @@ def test_update_selection_empty(qt_driver: QtDriver):
assert not panel.add_buttons_enabled
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -30,8 +31,8 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
assert panel.add_buttons_enabled
def test_update_selection_multiple(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
+10 -1
View File
@@ -77,6 +77,7 @@ 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")],
)
@@ -138,7 +139,8 @@ def test_get_entry(library: Library, entry_min: Entry):
def test_entries_count(library: Library):
entries = [Entry(path=Path(f"{x}.txt"), fields=[]) for x in range(10)]
folder = unwrap(library.folder)
entries = [Entry(path=Path(f"{x}.txt"), folder=folder, fields=[]) for x in range(10)]
new_ids = library.add_entries(entries)
assert len(new_ids) == 10
@@ -252,6 +254,7 @@ 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"),
@@ -259,6 +262,7 @@ 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),
@@ -266,6 +270,7 @@ 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"),
@@ -314,11 +319,14 @@ 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"),
@@ -326,6 +334,7 @@ def test_merge_entries(library: Library):
],
)
entry_b = Entry(
folder=folder,
path=Path("b"),
fields=[TextField(name="Notes", value="test note", is_multiline=True)],
)