feat: add field template editor, editable field names (#1396)

* feat: add basic field template editor

* fix: fix various issues with adding templates, reduce reused code

* feat: add field name editing on entries

* ui: add multiline checkbox to field template editor

* refactor: move stylesheets to central file

* fix(ui): fix untranslated key

* docs: update field documentation
This commit is contained in:
Travis Abendshien
2026-06-28 01:45:32 -07:00
committed by GitHub
parent 0f319985c4
commit 1b0bbba080
63 changed files with 1623 additions and 1270 deletions

BIN
docs/assets/add_fields.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -8,24 +8,53 @@ icon: material/text-box
# :material-text-box: Fields
Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
## Field Types
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
### Text Line
<figure markdown="span">
![Fields Example](assets/fields_example.png)
<figcaption>Example of tags and various fields on a file entry.</figcaption>
</figure>
A string of text, displayed as a single line.
## :material-text-box-plus-outline: Field Templates
- e.g: Title, Author, Artist, URL, etc.
Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own.
### Text Box
Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags.
A long string of text displayed as a box of text.
<figure markdown="span">
![Field Template Manager](assets/field_template_manager.png)
<figcaption>Field Template Manager from <b>Edit -> Manage Field Templates</b>.</figcaption>
</figure>
- e.g: Description, Notes, etc.
<figure markdown="span">
![Field Template Editor](assets/field_template_editor.png)
<figcaption>The field template editor, shown creating a new "Citations" field.</figcaption>
</figure>
### Datetime
## :material-format-list-bulleted-type: Field Types
A date and time value.
Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes.
- e.g: Date Published, Date Taken, etc.
### :material-text-box: Text
Text fields contain a piece of text with the option to display it either a single line or a multiline body of text.
| Option | Value | Description |
| --------- | ---------- | ------------------------------------------------------------------------ |
| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. |
<figure markdown="span">
![Text Field Editor](assets/text_field_editor.png)
<figcaption>The text field editor, editing a "Comments" field on an entry.</figcaption>
</figure>
### :material-calendar-month: Datetime
Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings.
<figure markdown="span">
![Datetime Field Editor](assets/datetime_field_editor.png)
<figcaption>The datetime field editor, expanded to show the date picker.</figcaption>
</figure>

View File

@@ -8,7 +8,10 @@ COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio"
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
DOCS_URL = "https://docs.tagstud.io"
DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -17,9 +20,7 @@ COLLAGE_FOLDER_NAME: str = "collages"
IGNORE_NAME: str = ".ts_ignore"
THUMB_CACHE_NAME: str = "thumbs"
FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
)
FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
@@ -31,5 +32,4 @@ TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
RESERVED_NAMESPACE_PREFIX = "tagstudio"

View File

@@ -30,6 +30,7 @@ from sqlalchemy import (
Engine,
NullPool,
ScalarResult,
Update,
and_,
asc,
create_engine,
@@ -1313,6 +1314,114 @@ class Library:
return direct_tags, descendant_tags
def add_field_template(self, field_template: BaseFieldTemplate) -> BaseFieldTemplate | None:
"""Add a new field template to the library."""
if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))):
logger.error("[Library] BaseFieldTemplate attempted to be added to the library.")
return None
with Session(self.engine) as session:
try:
session.add(field_template)
session.flush()
make_transient(field_template)
session.commit()
return field_template
except IntegrityError as e:
logger.error(e)
session.rollback()
return None
def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool:
"""Update a field template in the library.
old_field_class:str
field_template: BaseFieldTemplate
"""
with Session(self.engine) as session:
logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}")
is_same_type: bool = old_field_type == field_template.class_name
try:
update_stmt: Update | None = None
# If the template is changing type, remove the old one and add the updated
# template to the proper table.
if not is_same_type:
old_template: BaseFieldTemplate | None = None
if old_field_type == "TextFieldTemplate":
old_template = session.scalar(
select(TextFieldTemplate)
.where(TextFieldTemplate.id == field_template.id)
.limit(1)
)
elif old_field_type == "DatetimeFieldTemplate":
old_template = session.scalar(
select(DatetimeFieldTemplate)
.where(DatetimeFieldTemplate.id == field_template.id)
.limit(1)
)
if old_template is None:
logger.error("[Library] old_template is None")
return False
session.delete(old_template)
session.flush()
field_template.id = None # The id should not transfer between tables
session.add(field_template)
session.commit()
# Otherwise, update the existing template in-place
elif isinstance(field_template, TextFieldTemplate):
update_stmt = (
update(TextFieldTemplate)
.where(TextFieldTemplate.id == field_template.id)
.values(name=field_template.name, is_multiline=field_template.is_multiline)
)
elif isinstance(field_template, DatetimeFieldTemplate):
update_stmt = (
update(DatetimeFieldTemplate)
.where(DatetimeFieldTemplate.id == field_template.id)
.values(name=field_template.name)
)
if is_same_type:
if update_stmt is None:
return False
session.execute(update_stmt)
session.commit()
except IntegrityError as e:
logger.error(e)
session.rollback()
return False
return True
def remove_field_template(self, field_template: BaseFieldTemplate) -> bool:
"""Remove a field template from the library."""
with Session(self.engine) as session:
try:
session_item: BaseFieldTemplate | None = None
if isinstance(field_template, TextFieldTemplate):
session_item = session.scalar(
select(TextFieldTemplate)
.where(TextFieldTemplate.id == field_template.id)
.limit(1)
)
elif isinstance(field_template, DatetimeFieldTemplate):
session_item = session.scalar(
select(DatetimeFieldTemplate)
.where(DatetimeFieldTemplate.id == field_template.id)
.limit(1)
)
if session_item is not None:
session.delete(session_item)
session.commit()
except IntegrityError as e:
logger.error(e)
session.rollback()
return False
return True
def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]:
"""Return field template rows matching the query, detached from the session."""
if limit <= 0:
@@ -1320,7 +1429,7 @@ class Library:
search_query: str = name.lower() if name else ""
def sort_key(template: BaseFieldTemplate) -> tuple:
def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]:
text = template.name.lower()
if not search_query:
return (text,)
@@ -1431,7 +1540,12 @@ class Library:
session.commit()
def update_text_field(
self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool
self,
entry_ids: list[int] | int,
field: TextField,
name: str,
value: str,
is_multiline: bool,
):
"""Update a TextField field on one or more Entries."""
if isinstance(entry_ids, int):
@@ -1443,7 +1557,7 @@ class Library:
update_stmt = (
update(field_type)
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
.values(value=value, is_multiline=is_multiline)
.values(name=name, value=value, is_multiline=is_multiline)
)
session.execute(update_stmt)
@@ -1453,6 +1567,7 @@ class Library:
self,
entry_ids: list[int] | int,
field: DatetimeField,
name: str,
value: datetime,
):
"""Update a DatetimeField field on one or more Entries."""
@@ -1465,7 +1580,7 @@ class Library:
update_stmt = (
update(field_type)
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
.values(value=value)
.values(name=name, value=value)
)
session.execute(update_stmt)

View File

@@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from typing import Any, override
from PySide6.QtCore import Signal
from PySide6.QtGui import QMouseEvent
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self):
super().__init__()
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
super().__init__(*args, **kwarg)
@override
def mousePressEvent(self, ev: QMouseEvent):

View File

@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from tagstudio.core.library.alchemy.fields import (
BaseFieldTemplate,
DatetimeFieldTemplate,
TextFieldTemplate,
)
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
class EditFieldTemplateModal(EditFieldTemplateModalView):
field_type_map: dict[str, str] = {
"TextFieldTemplate": Translations["field_type.text"],
"DatetimeFieldTemplate": Translations["field_type.datetime"],
}
DEFAULT_TYPE_INDEX = 0
def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
super().__init__()
self.__field_id: int | None = field_template.id if field_template else None
self.__field_name: str = ""
self.__field_type: str | None = field_template.class_name if field_template else None
self.old_field_type: str = ""
for k, v in EditFieldTemplateModal.field_type_map.items():
self._type_combobox.addItem(v, k)
self.__connect_callbacks()
self.set_field_template(field_template)
self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX)
def __connect_callbacks(self) -> None:
self.name_field.textChanged.connect(self.__on_name_changed)
self._type_combobox.currentIndexChanged.connect(self.__on_type_changed)
def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None:
"""Populate the modal with pre-existing field template values, or fallback to defaults."""
logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template)
# Indicates a new template, set default values
if field_template is None:
self.__field_name = Translations["field_template.new"]
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[
EditFieldTemplateModal.DEFAULT_TYPE_INDEX
]
return
# Populate common values for any field type
else:
self.__field_name = field_template.name
self.__field_type = field_template.class_name
self.old_field_type = field_template.class_name # Only set on init
# Update widgets
self.name_field.setText(self.__field_name)
self._type_combobox.setCurrentIndex(
list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name)
)
# Populate values for specific field types
if isinstance(field_template, TextFieldTemplate):
self._multiline_checkbox.setChecked(field_template.is_multiline)
def __on_name_changed(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def __on_type_changed(self, index: int):
old_type = self.__field_type
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
if old_type == self.__field_type:
logger.info(f"old type {old_type}, new type {self.__field_type}")
return
if old_type == "TextFieldTemplate":
self._text_field_attributes_widget.hide()
# NOTE: Future options specific to other type will go here.
if self.__field_type == "TextFieldTemplate":
self._text_field_attributes_widget.show()
def build_field_template(self) -> BaseFieldTemplate:
if self.__field_type == "TextFieldTemplate":
return TextFieldTemplate(
id=self.__field_id,
name=self.name_field.text(),
is_multiline=self._multiline_checkbox.isChecked(),
)
elif self.__field_type == "DatetimeFieldTemplate":
return DatetimeFieldTemplate(
id=self.__field_id,
name=self.name_field.text(),
)
else:
logger.warning(
"[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate",
field_type=self.__field_type,
example=TextFieldTemplate,
)
return TextFieldTemplate(
name=self.name_field.text(),
is_multiline=self._multiline_checkbox.isChecked(),
)

View File

@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from tagstudio.qt.views.edit_text_view import EditTextView
logger = structlog.get_logger(__name__)
class EditText(EditTextView):
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
super().__init__()
self.name_field.setText(name)
self.text = text
self.is_multiline: bool = is_multiline
self.multiline_checkbox.setChecked(is_multiline)
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
if self.is_multiline:
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text or "")
else:
self.text_box.hide()
self.text_line.setText(self.text or "")
def on_multiline_checked(self, checked: bool):
was_multiline = self.is_multiline
self.is_multiline = checked
if was_multiline:
self.text = self.text_box.toPlainText()
self.text_box.hide()
self.text_line.setText(self.text)
self.text_line.show()
self.text_line_stretch.show()
else:
self.text = self.text_line.text()
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text)
self.text_box.show()
@override
def parent_post_init(self):
if self.is_multiline:
self.text_box.setFocus()
else:
self.text_line.setFocus()
@override
def saved_data(self) -> dict[str, str | bool]:
return {
"name": self.name_field.text(),
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
"is_multiline": self.is_multiline,
}
@override
def reset(self):
self.text_box.setPlainText(self.text or "")

View File

@@ -2,13 +2,16 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox
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.search_panel_controller import SearchPanel
from tagstudio.qt.translations import Translations
@@ -23,9 +26,7 @@ class FieldTemplateSearchModal(PanelModal):
self,
library: Library,
is_field_template_chooser: bool = True,
done_callback=None,
save_callback=None,
has_save=False,
has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
library,
@@ -35,9 +36,7 @@ class FieldTemplateSearchModal(PanelModal):
super().__init__(
self.search_panel,
Translations["field.add.plural"],
done_callback=done_callback,
save_callback=save_callback,
has_save=has_save,
is_savable=has_save,
)
@@ -60,34 +59,79 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
self._create_and_add_button_label_key = "field_template.create_add"
@override
def _get_max_limit(self) -> int:
return len(self.__lib.field_templates)
def on_item_create(self) -> None:
# TODO: Allow creation of field templates
pass
@override
def on_item_create(self, add_to_entry: bool = False) -> 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?
"""
query: str = self.get_search_query()
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = 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(panel, choose_item=add_to_entry))
modal.show()
@override
def on_item_edit(self, item: BaseFieldTemplate) -> None:
# TODO: Allow creation of field templates
pass
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_remove(self, item: BaseFieldTemplate) -> None:
if self.is_chooser:
return
# TODO: Allow creation of field templates
pass
message_box = QMessageBox(
QMessageBox.Icon.Question,
Translations["field_template.delete"],
Translations.format("field_template.confirm_delete", field_template_name=item.name),
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
def on_item_create_and_add(self) -> None:
# TODO: Allow creation of field templates
pass
result = message_box.exec()
if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_field_template(item)
self.update_items(self.get_search_query())
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.field_template_chosen.emit(item)
@override
def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
@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."""
field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib)
@@ -97,25 +141,41 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
if item is None:
return
# field_template_widget.has_remove = not self.is_chooser
field_template_widget.has_remove = not self.is_chooser
# Disconnect previous callbacks
with catch_warnings(record=True):
# tag_widget.on_edit.disconnect()
# tag_widget.on_remove.disconnect()
field_template_widget.on_edit.disconnect()
field_template_widget.on_remove.disconnect()
field_template_widget.on_click.disconnect()
# Connect callbacks
# tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
# tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
field_template_widget.on_edit.connect(lambda item_=item: self.on_item_edit(item_))
field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_))
field_template_widget.on_click.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
lambda checked=False, item_=item: self._on_item_chosen(item_)
)
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
# TODO: Allow creation of field templates
pass
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
self.__lib.add_field_template(template)
if choose_item:
self._on_item_chosen(template)
self.clear_search_query()
edit_item_panel.hide()
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
# TODO: Allow creation of field templates
pass
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.search_field.text())

View File

@@ -1,6 +1,11 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QAction, QEnterEvent
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
@@ -11,6 +16,15 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
super().__init__()
self.__field_template: BaseFieldTemplate | None = None
self.has_remove: bool = False
# Add actions
edit_action = QAction(self)
edit_action.setText(Translations["generic.edit"])
edit_action.triggered.connect(self.on_edit.emit)
self.addAction(edit_action)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
self.__field_template = field_template
@@ -20,3 +34,17 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown")
self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})")
@override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self._delete_button.setHidden(False)
self.update()
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)

View File

@@ -13,6 +13,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
count_text: str = Translations.format(
"entries.ignored.ignored_count", count=count if count >= 0 else ""
)
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
self.ignored_count_label.setText(header(count_text, 3))
def update_driver_widgets(self):
if (

View File

@@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils import file_opener
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
title: str = Translations.format(
"library_info.title", library_dir=self.lib.library_dir.stem
)
self.title_label.setText(f"<h2>{title}</h2>")
self.title_label.setText(header(title, 2))
def update_stats(self):
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")

View File

@@ -10,6 +10,7 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -89,7 +90,7 @@ class PagedPanel(QWidget):
# Update Title
self.setWindowTitle(frame.title)
self.title_label.setText(f"<h1>{frame.title}</h1>")
self.title_label.setText(header(frame.title, 1))
# Update Body Widget
if self.body_layout.itemAt(0):
@@ -107,7 +108,7 @@ class PagedPanel(QWidget):
if isinstance(item, QWidget):
self.button_nav_layout.addWidget(item)
item.setHidden(False)
elif isinstance(item, int):
elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance]
self.button_nav_layout.addStretch(item)
@override

View File

@@ -42,11 +42,11 @@ class PreviewPanel(PreviewPanelView):
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._fields.add_field_to_selected(template)
self._containers.add_field_to_selected(template)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int) -> None:
self._fields.add_tags_to_selected(tag_id)
self._containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])

View File

@@ -2,16 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QVBoxLayout
from PySide6.QtWidgets import QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
@@ -22,7 +23,7 @@ if TYPE_CHECKING:
def _item_id(item: object) -> int:
item_id: Any = getattr(item, "id") # noqa: B009
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_id, int):
return item_id
@@ -31,7 +32,7 @@ def _item_id(item: object) -> int:
def _item_name(item: object) -> str:
item_name: Any = getattr(item, "name") # noqa: B009
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_name, str):
return item_name
@@ -93,15 +94,13 @@ class SearchPanel[T](PanelWidget):
def clear_search_query(self) -> None:
self.view.clear_search_query()
def get_item_widget(self, index: int, library: Any):
def get_item_widget(self, index: int, library: Library):
return self.view.get_item_widget(index, library)
def set_driver(self, driver: "QtDriver") -> None:
self._driver = driver
def on_limit_changed(self, index: int) -> None:
logger.info("[SearchPanel] Updating limit")
# Method was called outside the limit_combobox callback
if index != self.view.get_limit_index():
self.view.set_limit_index(index)
@@ -130,33 +129,30 @@ class SearchPanel[T](PanelWidget):
# Focus search field if no query
if not query:
self.search_field.setFocus()
parent = self.parentWidget()
if parent is not None:
parent: QWidget | None = self.parentWidget()
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
parent.hide()
return
# Create and add item if no search results
if len(self._search_results) <= 0:
self.on_item_create_and_add()
self.on_item_create(add_to_entry=True)
elif self.is_chooser:
self._on_item_chosen(self._search_results[0])
self.clear_search_query()
self.update_items()
def on_item_create(self) -> None:
def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_edit(self, item: T) -> None:
def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_remove(self, item: T) -> None:
def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_create_and_add(self) -> None:
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None:
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
@@ -215,18 +211,20 @@ class SearchPanel[T](PanelWidget):
if query and query.strip():
self.view.add_create_and_add_button()
def search_items(self, query: str) -> tuple[list[T], list[T]]:
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:
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_items()
self.view.scroll_to(0)
self.view.clear_search_query()
return super().showEvent(event)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
# When Escape is pressed, focus back on the search box.
# If focus is already on the search box, close the modal.
@@ -236,8 +234,8 @@ class SearchPanel[T](PanelWidget):
else:
self.view.focus_search_box(select_all=True)
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def edit_item(self, edit_item_panel: PanelWidget) -> None:
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import TYPE_CHECKING, override
import structlog
@@ -77,20 +78,20 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
done_callback=self.on_update.emit,
has_save=True,
)
# TODO - this was update_tag()
edit_modal.saved.connect(
lambda: self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
alias_names=set(build_tag_panel.alias_names),
alias_ids=set(build_tag_panel.alias_ids),
)
is_savable=True,
)
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
alias_names=set(build_tag_panel.alias_names),
alias_ids=set(build_tag_panel.alias_ids),
)
self.on_update.emit()
@override
def _on_search(self, tag: Tag) -> None:
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")

View File

@@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, override
from warnings import catch_warnings
import structlog
@@ -33,9 +33,7 @@ class TagSearchModal(PanelModal):
library: Library,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
done_callback=None,
save_callback=None,
has_save=False,
has_save: bool = False,
):
self.tsp = TagSearchPanel(
library,
@@ -46,9 +44,7 @@ class TagSearchModal(PanelModal):
super().__init__(
self.tsp,
Translations["tag.add.plural"],
done_callback=done_callback,
save_callback=save_callback,
has_save=has_save,
is_savable=has_save,
)
@@ -70,28 +66,39 @@ class TagSearchPanel(SearchPanel[Tag]):
self._unlimited_limit_item_label = Translations["tag.all_tags"]
self._create_and_add_button_label_key = "tag.create_add"
@override
def _get_max_limit(self) -> int:
return len(self.__lib.tags)
def on_item_create(self) -> None:
@override
def on_item_create(self, add_to_entry: bool = False) -> 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?
"""
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
build_tag_modal: PanelModal = PanelModal(
build_tag_panel,
panel: BuildTagPanel = BuildTagPanel(self.__lib)
modal: PanelModal = PanelModal(
panel,
Translations["tag.new"],
has_save=True,
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
is_savable=True,
)
if query.strip():
build_tag_panel.name_field.setText(query)
panel.name_field.setText(query)
build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal))
build_tag_modal.show()
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
modal.show()
@override
def on_item_edit(self, item: Tag) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
@@ -101,12 +108,13 @@ class TagSearchPanel(SearchPanel[Tag]):
edit_tag_panel,
self.__lib.tag_display_name(item),
Translations["tag.edit"],
has_save=True,
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
edit_tag_modal.show()
@override
def _on_item_remove(self, item: Tag) -> None:
if self.is_chooser:
return
@@ -115,49 +123,29 @@ class TagSearchPanel(SearchPanel[Tag]):
return
message_box = QMessageBox(
QMessageBox.Question, # type: ignore
QMessageBox.Icon.Question,
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
QMessageBox.Ok | QMessageBox.Cancel, # type: ignore
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
result = message_box.exec()
if result != QMessageBox.Ok: # type: ignore
if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_tag(item.id)
self.update_items(self.get_search_query())
def on_item_create_and_add(self) -> None:
"""Opens "Create Tag" panel to create and add a new tag with given name."""
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
logger.info("Create and Add Tag", name=query)
build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
build_tag_modal: PanelModal = PanelModal(
build_tag_panel,
Translations["tag.new"],
Translations["tag.add"],
has_save=True,
)
if query.strip():
build_tag_panel.name_field.setText(query)
build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True))
build_tag_modal.show()
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
@override
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
@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)
@@ -195,39 +183,41 @@ class TagSearchPanel(SearchPanel[Tag]):
else:
tag_widget.search_for_tag_action.setEnabled(False)
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if isinstance(build_item_modal.widget, BuildTagPanel):
tag: Tag = build_item_modal.widget.build_tag()
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self.__lib.add_tag(
tag,
parent_ids=build_item_modal.widget.parent_ids,
alias_names=build_item_modal.widget.alias_names,
alias_ids=build_item_modal.widget.alias_ids,
parent_ids=edit_item_panel.parent_ids,
alias_names=edit_item_panel.alias_names,
alias_ids=edit_item_panel.alias_ids,
)
if choose_item:
self._on_item_chosen(tag)
self.clear_search_query()
build_item_modal.hide()
edit_item_panel.hide()
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
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,
alias_names=edit_item_panel.alias_names,
alias_ids=edit_item_panel.alias_ids,
)
self.update_items(self.search_field.text())
def search_for_tag(self, tag_id: int) -> None:

View File

@@ -8,7 +8,7 @@ from shutil import which
from PIL import ImageQt
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QGuiApplication, QPalette, QPixmap
from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtWidgets import (
QFormLayout,
QHBoxLayout,
@@ -19,16 +19,23 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.constants import COPYRIGHT, VERSION, VERSION_BRANCH
from tagstudio.core.enums import Theme
from tagstudio.core.constants import (
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
VERSION,
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.clickable_label import ClickableLabel
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
class AboutModal(QWidget):
@@ -42,18 +49,6 @@ class AboutModal(QWidget):
self.rm: ResourceManager = ResourceManager()
pixel_ratio = self.devicePixelRatio()
# TODO: There should be a global button theme somewhere.
self.form_content_style = (
f"background-color:{
Theme.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
};"
"border-radius:3px;"
"font-weight: 500;"
"padding: 2px;"
)
self.setStyleSheet("QLabel {color: white}")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
@@ -84,7 +79,6 @@ class AboutModal(QWidget):
# Version --------------------------------------------------------------
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# self.version_label.setStyleSheet("QLabel {color: #9782ff}")
# Copyright ------------------------------------------------------------
self.copyright_label = QLabel(COPYRIGHT)
@@ -128,54 +122,55 @@ class AboutModal(QWidget):
# Version
version_title = QLabel(Translations["about.version"])
most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN")
version_content_style = self.form_content_style
if most_recent_release == VERSION:
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "?")
version_content_style = form_content_style()
if latest_version == VERSION:
version_content = QLabel(f"{VERSION}")
else:
version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})")
version_content_style += "color: #d9534f;"
version_content = QLabel(
Translations.format(
"about.version.latest", built_version=VERSION, latest_version=latest_version
)
)
version_content_style += f"color: {red};"
version_content.setStyleSheet(version_content_style)
version_content.setMaximumWidth(version_content.sizeHint().width())
self.system_info_layout.addRow(version_title, version_content)
# Config Path
config_path_title = QLabel(f"{Translations['about.config_path']}")
config_path_content = ClickableLabel()
config_path_content.setText(f"{config_path}") # TODO: Pass in constructor after #1386
config_path_content = ClickableLabel(f"{config_path}")
config_path_content.clicked.connect(lambda: open_file(config_path, file_manager=True))
config_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
config_path_content.setStyleSheet(self.form_content_style)
config_path_content.setWordWrap(True)
config_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(config_path_title, config_path_content)
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
# FFmpeg Status
ffmpeg_path_title = QLabel("FFmpeg")
ffmpeg_path_content = ClickableLabel()
ffmpeg_path_content.setText(f"{ffmpeg_status}") # TODO: Pass in constructor after #1386
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
if ffmpeg_location:
ffmpeg_path_content.clicked.connect(
lambda: open_file(ffmpeg_location, file_manager=True)
)
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffmpeg_path_content.setStyleSheet(self.form_content_style)
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
ffmpeg_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
# FFprobe Status
ffprobe_path_title = QLabel("FFprobe")
ffprobe_path_content = ClickableLabel()
ffprobe_path_content.setText(f"{ffprobe_status}") # TODO: Pass in constructor after #1386
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
if ffprobe_location:
ffprobe_path_content.clicked.connect(
lambda: open_file(ffprobe_location, file_manager=True)
)
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffprobe_path_content.setStyleSheet(self.form_content_style)
ffprobe_path_content.setStyleSheet(form_content_style())
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
@@ -190,19 +185,16 @@ class AboutModal(QWidget):
lambda: open_file(ripgrep_location, file_manager=True)
)
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ripgrep_path_content.setStyleSheet(self.form_content_style)
ripgrep_path_content.setStyleSheet(form_content_style())
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
# Links ----------------------------------------------------------------
repo_link = "https://github.com/TagStudioDev/TagStudio"
docs_link = "https://docs.tagstud.io"
discord_link = "https://discord.com/invite/hRNnVKhF2G"
self.links_label = QLabel(
f'<p><a href="{repo_link}">GitHub</a> | '
f'<a href="{docs_link}">{Translations["about.documentation"]}</a> | '
f'<a href="{discord_link}">Discord</a></p>'
f'<p><a href="{GITHUB_REPO_URL}">GitHub</a> | '
f'<a href="{DOCS_URL}">{Translations["about.documentation"]}</a> | '
f'<a href="{DISCORD_URL}">Discord</a></p>'
)
self.links_label.setStyleSheet("QLabel {color: #809782ff}")
self.links_label.setWordWrap(True)

View File

@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -39,10 +40,9 @@ class AddFieldModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel(Translations["field.add"])
self.title_widget = QLabel(header(Translations["field.add"], 3))
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_widget = QListWidget()

View File

@@ -3,6 +3,7 @@
import contextlib
from typing import override
import structlog
from PySide6.QtCore import Qt, Signal
@@ -24,14 +25,14 @@ from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
line_edit_style,
list_button_style,
)
logger = structlog.get_logger(__name__)
@@ -129,43 +130,12 @@ class BuildColorPanel(PanelWidget):
color=QColor(unwrap(self.preview_button.tag_color_group).secondary)
if unwrap(self.preview_button.tag_color_group).secondary
else None,
color_border=checked,
)
)
self.border_layout.addWidget(self.border_checkbox)
self.border_label = QLabel(Translations["color.color_border"])
self.border_layout.addWidget(self.border_label)
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
self.border_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.border_checkbox.setStyleSheet(checkbox_style())
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.preview_widget)
@@ -222,89 +192,20 @@ class BuildColorPanel(PanelWidget):
def update_primary(self, color: QColor):
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
highlight_color = get_highlight_color(color)
text_color = get_text_color(color, highlight_color)
border_color = get_border_color(color)
hex_code = color.name().upper()
self.primary_button.setText(hex_code)
self.primary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color.toTuple()};"
f"border-color: rgba{color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.primary_button.setStyleSheet(list_button_style(color))
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
def update_secondary(self, color: QColor | None = None):
logger.info("[BuildColorPanel] Updating Secondary", color=color)
color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
highlight_color = get_highlight_color(color_)
text_color = get_text_color(color_, highlight_color)
border_color = get_border_color(color_)
hex_code = "" if not color else color.name().upper()
self.secondary_button.setText(
Translations["color.title.no_color"] if not color else hex_code
)
self.secondary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color_.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color_.toTuple()};"
f"border-color: rgba{color_.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.secondary_button.setText(hex_code if color else Translations["color.title.no_color"])
self.secondary_button.setStyleSheet(list_button_style(color_))
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_known_colors(self):
@@ -346,17 +247,9 @@ class BuildColorPanel(PanelWidget):
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
self.slug_field.setText(slug)
self.update_preview_text()
@@ -393,13 +286,7 @@ class BuildColorPanel(PanelWidget):
)
return (self.color_group, new_color)
@override
def parent_post_init(self):
# self.setTabOrder(self.name_field, self.shorthand_field)
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
# self.setTabOrder(self.color_button, self.panel_cancel_button)
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()

View File

@@ -3,6 +3,7 @@
import contextlib
from typing import override
from uuid import uuid4
import structlog
@@ -12,9 +13,9 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
@@ -111,17 +112,9 @@ class BuildNamespacePanel(PanelWidget):
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
self.slug_field.setText(slug)
@@ -156,6 +149,7 @@ class BuildNamespacePanel(PanelWidget):
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
return namespace
@override
def parent_post_init(self):
self.setTabOrder(self.name_field, self.slug_field)
self.name_field.selectAll()

View File

@@ -3,6 +3,7 @@
import sys
from collections.abc import Callable
from typing import cast, override
import structlog
@@ -24,7 +25,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.core.utils.types import unwrap
@@ -33,27 +33,38 @@ from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_widget import (
TagWidget,
get_border_color,
get_highlight_color,
get_primary_color,
get_text_color,
get_tag_border_color,
get_tag_highlight_color,
get_tag_primary_color,
get_tag_text_color,
)
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_radio_button_style,
header,
line_edit_style,
)
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
logger = structlog.get_logger(__name__)
class CustomTableItem(QLineEdit):
def __init__(self, text, on_return, on_backspace, parent=None):
def __init__(
self,
text: str,
on_return: Callable[..., None],
on_backspace: Callable[..., None],
parent: QWidget | None = None,
):
super().__init__(parent)
self.setText(text)
self.on_return = on_return
self.on_backspace = on_backspace
self.on_return: Callable[..., None] = on_return
self.on_backspace: Callable[..., None] = on_backspace
def set_id(self, id):
def set_id(self, id: int):
self.id = id
@override
@@ -194,9 +205,9 @@ class BuildTagPanel(PanelWidget):
self.tag_color_selection,
chose_tag_color_title,
chose_tag_color_title,
done_callback=lambda: self.choose_color_callback(
self.tag_color_selection.selected_color
),
)
self.choose_color_modal.done.connect(
lambda: self.choose_color_callback(self.tag_color_selection.selected_color)
)
self.color_button.button.clicked.connect(self.choose_color_modal.show)
self.color_layout.addWidget(self.color_button)
@@ -211,38 +222,7 @@ class BuildTagPanel(PanelWidget):
self.cat_title = QLabel(Translations["tag.is_category"])
self.cat_checkbox = QCheckBox()
self.cat_checkbox.setFixedSize(22, 22)
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
self.cat_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.cat_checkbox.setStyleSheet(checkbox_style())
self.cat_layout.addWidget(self.cat_checkbox)
self.cat_layout.addWidget(self.cat_title)
@@ -256,33 +236,7 @@ class BuildTagPanel(PanelWidget):
self.hidden_title = QLabel(Translations["tag.is_hidden"])
self.hidden_checkbox = QCheckBox()
self.hidden_checkbox.setFixedSize(22, 22)
self.hidden_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.hidden_checkbox.setStyleSheet(checkbox_style())
self.hidden_layout.addWidget(self.hidden_checkbox)
self.hidden_layout.addWidget(self.hidden_title)
@@ -294,14 +248,14 @@ class BuildTagPanel(PanelWidget):
self.root_layout.addWidget(self.aliases_add_button)
self.root_layout.addWidget(self.parent_tags_widget)
self.root_layout.addWidget(self.color_widget)
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
self.parent_ids: set[int] = set()
self.alias_ids: list[int] = []
self.alias_names: list[str] = []
self.new_alias_names: dict = {}
self.new_alias_names: dict[int, str] = {}
self.new_item_id = sys.maxsize
self.set_tag(tag or Tag(name=Translations["tag.new"]))
@@ -317,7 +271,7 @@ class BuildTagPanel(PanelWidget):
item = self.aliases_table.cellWidget(i, 1)
if (
isinstance(item, CustomTableItem)
and cast(CustomTableItem, item).id == cast(CustomTableItem, focused_widget).id
and item.id == cast(CustomTableItem, focused_widget).id
):
cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click()
remove_row = i
@@ -359,7 +313,7 @@ class BuildTagPanel(PanelWidget):
item = self.aliases_table.cellWidget(row, 1)
item.setFocus()
def remove_alias_callback(self, alias_name: str, alias_id: int):
def remove_alias_callback(self, alias_id: int):
logger.info("remove_alias_callback")
self.alias_ids.remove(alias_id)
@@ -407,13 +361,13 @@ class BuildTagPanel(PanelWidget):
row.setSpacing(3)
# Init Colors
primary_color = get_primary_color(tag)
primary_color = get_tag_primary_color(tag)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
@@ -422,7 +376,7 @@ class BuildTagPanel(PanelWidget):
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
# Add Tag Widget
tag_widget = TagWidget(
@@ -445,35 +399,7 @@ class BuildTagPanel(PanelWidget):
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
)
self.disam_button_group.addButton(disam_button)
@@ -530,7 +456,7 @@ class BuildTagPanel(PanelWidget):
for alias_id in self.alias_ids:
alias = self.lib.get_alias(self.tag.id, alias_id)
alias_name = alias.name if alias else self.new_alias_names[alias_id]
alias_name: str = alias.name if alias else self.new_alias_names[alias_id]
# handel when an alias name changes
if alias_id in self.new_alias_names:
@@ -539,9 +465,7 @@ class BuildTagPanel(PanelWidget):
self.alias_names.append(alias_name)
remove_btn = QPushButton("-")
remove_btn.clicked.connect(
lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id)
)
remove_btn.clicked.connect(lambda id=alias_id: self.remove_alias_callback(id))
row = self.aliases_table.rowCount()
new_item = CustomTableItem(alias_name, self.enter, self.backspace)
@@ -595,11 +519,7 @@ class BuildTagPanel(PanelWidget):
def on_name_changed(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
@@ -619,6 +539,7 @@ class BuildTagPanel(PanelWidget):
logger.info("built tag", tag=tag)
return tag
@override
def parent_post_init(self):
self.setTabOrder(self.name_field, self.shorthand_field)
self.setTabOrder(self.shorthand_field, self.aliases_add_button)

View File

@@ -10,16 +10,15 @@ from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -43,34 +42,6 @@ class ColorBoxWidget(FieldWidget):
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
super().__init__(title)
self.add_button_stylesheet = (
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 2px;"
f"padding-left: 4px;"
f"font-size: 15px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::pressed{{"
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::focus{{"
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"outline:none;"
f"}}"
)
self.setObjectName("colorBox")
self.base_layout = FlowLayout()
self.base_layout.enable_grid_optimizations(value=True)
@@ -114,7 +85,7 @@ class ColorBoxWidget(FieldWidget):
add_button.setText("+")
add_button.setFlat(True)
add_button.setFixedSize(22, 22)
add_button.setStyleSheet(self.add_button_stylesheet)
add_button.setStyleSheet(add_button_style())
add_button.clicked.connect(
lambda: self.edit_color(
TagColorGroup(
@@ -134,7 +105,7 @@ class ColorBoxWidget(FieldWidget):
self.edit_modal = PanelModal(
build_color_panel,
"Edit Color",
has_save=True,
is_savable=True,
)
self.edit_modal.saved.connect(

View File

@@ -3,14 +3,14 @@
import typing
from collections.abc import Callable
from datetime import datetime as dt
from typing import cast
from typing import cast, override
from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -40,11 +40,16 @@ def qdtf2dtf(dtf: str) -> str:
class DatetimePicker(PanelWidget):
def __init__(self, driver: "QtDriver", datetime: dt | str):
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
super().__init__()
self.setMinimumSize(300, 60)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.name_field = QLineEdit()
self.name_field.setStyleSheet(title_line_edit_style())
self.name_field.setText(name)
if isinstance(datetime, str):
datetime = DatetimePicker.string2dt(datetime)
self.datetime_edit = QDateTimeEdit()
@@ -55,20 +60,24 @@ class DatetimePicker(PanelWidget):
self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format))
self.initial_value = datetime
self.root_layout.addWidget(self.name_field)
self.root_layout.addWidget(self.datetime_edit)
def get_content(self):
return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime()))
@override
def saved_data(self) -> dict[str, str]:
return {
"name": self.name_field.text(),
"value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())),
}
@override
def parent_post_init(self):
self.datetime_edit.setFocus()
@override
def reset(self):
self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value))
def add_callback(self, callback: Callable, event: str = "returnPressed"):
if event == "returnPressed":
pass
else:
raise ValueError(f"unknown event type: {event}")
@staticmethod
def qdt2dt(qdt: QDateTime) -> dt:
return cast(dt, qdt.toPython())

View File

@@ -2,10 +2,10 @@
# SPDX-License-Identifier: GPL-3.0-only
import sys
import typing
from collections.abc import Callable
from datetime import datetime as dt
from functools import partial
from warnings import catch_warnings
import structlog
@@ -31,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import (
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.edit_text_controller import EditText
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextWidget
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
from tagstudio.qt.views.panel_modal import PanelModal
if typing.TYPE_CHECKING:
@@ -47,7 +46,7 @@ logger = structlog.get_logger(__name__)
class FieldContainers(QWidget):
"""The Preview Panel Widget."""
"""Widget for the tag and field containers displayed inside the Preview Panel."""
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
@@ -102,6 +101,11 @@ class FieldContainers(QWidget):
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(self.scroll_area)
@property
def top_entry_id(self) -> int:
"""Get the topmost entry ID in the (cached) selected entries."""
return self.cached_entries[0].id
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
@@ -130,7 +134,7 @@ class FieldContainers(QWidget):
# Write field container(s)
for index, field in enumerate(entry_fields, start=container_index):
self.write_container(index, field, is_mixed=False)
self.write_field_container(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self.containers) > container_len:
@@ -233,36 +237,142 @@ class FieldContainers(QWidget):
)
self.lib.add_field_to_entries(entry_id, field_template.to_field())
def add_tags_to_selected(self, tags: int | list[int]) -> None:
def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
"""Add list of tags to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
"""
if isinstance(tags, int):
tags = [tags]
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
"[FieldContainers][add_tags_to_selected]",
selected=self.driver.selected,
tags=tags,
tag_ids=tag_ids,
)
self.driver.add_tags_to_selected_callback(tags)
self.driver.add_tags_to_selected_callback(tag_ids)
def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a FieldContainer.
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
Args:
index(int): The container index.
field(BaseField): The type of field to write to.
field(BaseField): The field to write in this container.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
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
):
container.set_title(field.name)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = (field.value or "").replace("\r", "\n")
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
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(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),
)
)
def write_datetime_container(
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
if not is_mixed:
try:
assert field.value is not None
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
text = str(field.value)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
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(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),
)
)
def write_unknown_container():
container.set_title(field.name)
inner_widget = TextContainerWidget(title, field.name)
container.set_inner_widget(inner_widget)
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),
)
)
logger.info(
"[FieldContainers][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
# Create new containers if necessary
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
@@ -274,156 +384,27 @@ class FieldContainers(QWidget):
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
title = f"{field.name} ({Translations[field_name_key]})"
# Single-line Text
if type(field) is TextField and not field.is_multiline:
container.set_title(field.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = field.value or ""
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextLine(field.value),
title=title,
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_text_field(field, content, is_multiline=False),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
if "pytest" in sys.modules:
# for better testability
container.modal = modal # pyright: ignore[reportAttributeAccessIssue]
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
# Multiline Text
elif type(field) is TextField and field.is_multiline:
container.set_title(field.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = (field.value or "").replace("\r", "\n")
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextBox(field.value),
title=title,
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_text_field(field, content, is_multiline=True),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
# Write containers
if type(field) is TextField:
write_text_container(container, field, title, is_mixed)
elif type(field) is DatetimeField:
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
if not is_mixed:
container.set_title(field.name)
container.set_inline(False)
try:
assert field.value is not None
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
text = str(field.value)
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
modal = PanelModal(
DatetimePicker(self.driver, field.value or dt.now()),
title=f"Edit {field.name}",
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_datetime_field(field, content),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
write_datetime_container(container, field, title, is_mixed)
else:
logger.warning(
"[FieldContainers][write_container] Unknown Field", field=field
) # TODO: Localize this
container.set_title(field.name)
container.set_inline(False)
inner_widget = TextWidget(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
write_unknown_container()
container.setHidden(False)
def write_tag_container(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
"""Update/Create tag data for a FieldContainer.
"""Update/Create tag data for a tag FieldContainer.
Args:
index(int): The container index.
tags(set[Tag]): The list of tags for this container.
category_tag(Tag|None): The category tag this container represents.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self.containers) < (index + 1):
@@ -433,10 +414,7 @@ class FieldContainers(QWidget):
else:
container = self.containers[index]
container.set_title(
"Tags" if not category_tag else category_tag.name
) # TODO: Localize this
container.set_inline(False)
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
if not is_mixed:
inner_widget = container.get_inner_widget()
@@ -446,10 +424,7 @@ class FieldContainers(QWidget):
inner_widget.on_update.disconnect()
else:
inner_widget = TagBoxWidget(
"Tags", # TODO: Localize this
self.driver,
)
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)
@@ -458,15 +433,15 @@ class FieldContainers(QWidget):
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
)
else:
text = "<i>Mixed Data</i>"
inner_widget = TextWidget("Mixed Tags", text)
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
container.set_inner_widget(inner_widget)
container.set_edit_callback()
container.set_remove_callback()
container.setHidden(False)
def remove_field(self, field: BaseField) -> None:
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
@@ -476,24 +451,26 @@ class FieldContainers(QWidget):
entry_ids = [e.id for e in self.cached_entries]
self.lib.remove_entry_field(field, entry_ids)
def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None:
def _update_text_field(
self, field: TextField, name: str, value: str, is_multiline: bool
) -> None:
"""Update a text field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_text_field(entry_ids, field, value, is_multiline)
self.lib.update_text_field(entry_ids, field, name, value, is_multiline)
def update_datetime_field(self, field: DatetimeField, value: str) -> None:
def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None:
"""Update a datetime field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value))
def remove_message_box(self, prompt: str, callback: Callable) -> None:
def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
remove_mb.setWindowTitle(Translations["Remove Field"])
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole

View File

@@ -12,9 +12,9 @@ from PySide6.QtCore import QEvent, QSize, Qt
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.enums import Theme
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.stylesheets.stylesheets import container_style, header
logger = structlog.get_logger(__name__)
@@ -26,23 +26,11 @@ class FieldContainer(QWidget):
trash_icon = auto_theme_overlay(rm.trash, inverse=True)
# TODO: There should be a global button theme somewhere.
container_style = (
f"QWidget#fieldContainer{{"
"border-radius:4px;"
f"}}"
f"QWidget#fieldContainer::hover{{"
f"background-color:{Theme.COLOR_HOVER.value};"
f"}}"
f"QWidget#fieldContainer::pressed{{"
f"background-color:{Theme.COLOR_PRESSED.value};"
f"}}"
)
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
self.setObjectName("fieldContainer")
self.title: str = title
self.inline: bool = inline
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
@@ -119,7 +107,7 @@ class FieldContainer(QWidget):
self.inner_layout.addWidget(self.field)
self.set_title(title)
self.setStyleSheet(FieldContainer.container_style)
self.setStyleSheet(container_style())
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
@@ -159,12 +147,9 @@ class FieldContainer(QWidget):
return None
def set_title(self, title: str) -> None:
self.title = self.title = f"<h4>{title}</h4>"
self.title = header(title, 4)
self.title_widget.setText(self.title)
def set_inline(self, inline: bool) -> None:
self.inline = inline
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the FieldWidget if needed.

View File

@@ -11,13 +11,12 @@ from datetime import timedelta
from pathlib import Path
import structlog
from humanfriendly import format_size
from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType]
from PIL import ImageFont
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from tagstudio.core.enums import ShowFilepathOption, Theme
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
@@ -25,6 +24,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel
from tagstudio.qt.views.stylesheets.stylesheets import properties_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -48,26 +48,8 @@ class FileAttributes(QWidget):
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
label_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
)
self.date_style = "font-size:12px;"
self.date_style = "font-size: 12px;"
self.file_label_style = "font-size: 12px"
self.properties_style = (
f"background-color:{label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
self.file_label = FileOpenerLabel()
self.file_label.setObjectName("filenameLabel")
@@ -93,7 +75,7 @@ class FileAttributes(QWidget):
self.dimensions_label = QLabel()
self.dimensions_label.setObjectName("dimensionsLabel")
self.dimensions_label.setWordWrap(True)
self.dimensions_label.setStyleSheet(self.properties_style)
self.dimensions_label.setStyleSheet(properties_style())
self.dimensions_label.setHidden(True)
self.date_container = QWidget()

View File

@@ -19,6 +19,7 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -49,7 +50,6 @@ class FixDupeFilesModal(QWidget):
self.dupe_count = QLabel()
self.dupe_count.setObjectName("dupeCountLabel")
self.dupe_count.setStyleSheet("font-weight:bold;font-size:14px;")
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"])
@@ -119,13 +119,19 @@ class FixDupeFilesModal(QWidget):
def set_dupe_count(self, count: int):
if count < 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"])
self.dupe_count.setText(
header(Translations["file.duplicates.matches_uninitialized"], 4)
)
elif count == 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
self.dupe_count.setText(
header(Translations.format("file.duplicates.matches", count=count), 4)
)
else:
self.mirror_button.setDisabled(False)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
self.dupe_count.setText(
header(Translations.format("file.duplicates.matches", count=count), 4)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802

View File

@@ -15,6 +15,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -148,7 +149,7 @@ class FixUnlinkedEntriesModal(QWidget):
count_text: str = Translations.format(
"entries.unlinked.unlinked_count", count=count if count >= 0 else ""
)
self.unlinked_count_label.setText(f"<h3>{count_text}</h3>")
self.unlinked_count_label.setText(header(count_text, 3))
@override
def showEvent(self, event: QtGui.QShowEvent) -> None:

View File

@@ -29,6 +29,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.stylesheets.stylesheets import header
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -56,7 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
return branch
@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6")
@deprecated("Will be replaced with upcoming 'Macros' feature.")
def folders_to_tags(library: Library):
logger.info("Converting folders to Tags")
tree = BranchData()
@@ -177,10 +178,9 @@ class FoldersToTagsModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel(Translations["folders_to_tags.title"])
self.title_widget = QLabel(header(Translations["folders_to_tags.title"], 3))
self.title_widget.setObjectName("title")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.desc_widget = QLabel()

View File

@@ -11,10 +11,10 @@ from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.clickable_label import ClickableLabel
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:

View File

@@ -48,6 +48,7 @@ from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -364,7 +365,7 @@ class JsonMigrationModal(QObject):
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
pb.setLabelText(header(x, 4)),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
@@ -386,7 +387,7 @@ class JsonMigrationModal(QObject):
QThreadPool.globalInstance().start(r)
except Exception as e:
logger.error("[MigrationModal][Iterator] Error:", error=e)
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
pb.setLabelText(header(type(e).__name__, 4))
pb.setMinimum(1)
pb.setValue(1)
@@ -410,7 +411,7 @@ class JsonMigrationModal(QObject):
)
self.sql_lib.migrate_json_to_sqlite(self.json_lib)
yield Translations["json_migration.checking_for_parity"]
check_set = set()
check_set: set[bool] = set()
check_set.add(self.check_field_parity())
check_set.add(self.check_path_parity())
check_set.add(self.check_name_parity())
@@ -522,7 +523,7 @@ class JsonMigrationModal(QObject):
def assert_ignore_parity(self) -> None:
compiled_pats = fnmatch.compile(
ignore_to_glob(
Ignore._load_ignore_file(
Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage]
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
)
),

View File

@@ -357,9 +357,9 @@ class SettingsPanel(PanelWidget):
modal = PanelModal(
widget=settings_panel,
window_title=Translations["settings.title"],
done_callback=lambda: settings_panel.update_settings(driver),
has_save=True,
is_savable=True,
)
modal.done.connect(lambda: settings_panel.update_settings(driver))
modal.title_widget.setVisible(False)
return modal

View File

@@ -11,12 +11,14 @@ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
tag_remove_button_style,
tag_style,
)
logger = structlog.get_logger(__name__)
@@ -101,76 +103,25 @@ class TagColorLabel(QWidget):
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color if not (color and color.secondary) else QColor(color.secondary)
)
text_color: QColor
if color and color.secondary:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
self.bg_button.setText(escape_text(color.name))
@@ -183,13 +134,15 @@ class TagColorLabel(QWidget):
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
@typing.override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(False)
self.update()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
@typing.override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(True)
self.update()

View File

@@ -3,7 +3,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, override
from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6 import QtCore, QtGui
@@ -28,6 +28,7 @@ from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -62,7 +63,7 @@ class TagColorManager(QWidget):
self.title_label = QLabel()
self.title_label.setObjectName("titleLabel")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title_label.setText(f"<h3>{Translations['color_manager.title']}</h3>")
self.title_label.setText(header(Translations["color_manager.title"], 3))
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -176,7 +177,7 @@ class TagColorManager(QWidget):
self.create_namespace_modal = PanelModal(
build_namespace_panel,
Translations["namespace.create.title"],
has_save=True,
is_savable=True,
)
self.create_namespace_modal.saved.connect(
@@ -189,7 +190,7 @@ class TagColorManager(QWidget):
self.create_namespace_modal.show()
def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None:
def delete_namespace_dialog(self, prompt: str, callback: Callable[..., Any]) -> None: # pyright: ignore[reportExplicitAny]
message_box = QMessageBox()
message_box.setText(prompt)
message_box.setWindowTitle(Translations["color.namespace.delete.title"])
@@ -207,13 +208,13 @@ class TagColorManager(QWidget):
callback()
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
def showEvent(self, event: QtGui.QShowEvent) -> None:
if not self.is_initialized:
self.setup_color_groups()
return super().showEvent(event)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114
self.done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:

View File

@@ -11,9 +11,14 @@ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
tag_style,
)
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -66,11 +71,11 @@ class TagColorPreview(QWidget):
primary_color = self._get_primary_color(color_group)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color_group and color_group.secondary and color_group.color_border)
else (QColor(color_group.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (color_group and color_group.secondary)
else QColor(color_group.secondary)
@@ -79,32 +84,10 @@ class TagColorPreview(QWidget):
if color_group and color_group.secondary:
text_color = QColor(color_group.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 8px;"
f"padding-left: 8px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
# Add back the padding if the hint is generated while the button has focus (no padding)
self.button.setMinimumWidth(

View File

@@ -20,11 +20,16 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.mixed.tag_widget import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
)
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
logger = structlog.get_logger(__name__)
@@ -67,9 +72,7 @@ class TagColorSelection(PanelWidget):
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
for group, colors in tag_color_groups.items():
display_name: str = self.lib.get_namespace_name(group)
self.scroll_layout.addWidget(
QLabel(f"<h4>{display_name if display_name else group}</h4>")
)
self.scroll_layout.addWidget(QLabel(header(display_name if display_name else group, 4)))
color_box_widget = QWidget()
color_group_layout = FlowLayout()
color_group_layout.setSpacing(4)
@@ -79,54 +82,31 @@ class TagColorSelection(PanelWidget):
for color in colors:
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color if not (color and color.secondary) else QColor(color.secondary)
)
text_color: QColor
if color and color.secondary:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
radio_button = QRadioButton()
radio_button.setObjectName(f"{color.namespace}.{color.slug}")
radio_button.setToolTip(color.name)
radio_button.setFixedSize(24, 24)
bottom_color: str = (
f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else ""
)
radio_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"{bottom_color}"
f"border-radius: 3px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 12px;"
f"height: 12px;"
f"border-radius: 1px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
color_swatch_style(
primary_color,
text_color,
border_color,
highlight_color,
text_color if color.secondary else None,
)
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)
@@ -136,7 +116,7 @@ class TagColorSelection(PanelWidget):
def add_no_color_widget(self):
no_color_str: str = Translations["color.title.no_color"]
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
self.scroll_layout.addWidget(QLabel(header(no_color_str, 4)))
color_box_widget = QWidget()
color_group_layout = FlowLayout()
color_group_layout.setSpacing(4)
@@ -145,45 +125,20 @@ class TagColorSelection(PanelWidget):
color_box_widget.setLayout(color_group_layout)
color = None
primary_color = self._get_primary_color(color)
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
border_color = get_tag_border_color(primary_color)
highlight_color = get_tag_highlight_color(primary_color)
text_color: QColor
if color and color.secondary and color.color_border:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
radio_button = QRadioButton()
radio_button.setObjectName("None") # NOTE: Internal use, no translation needed.
radio_button.setObjectName("None")
radio_button.setToolTip(no_color_str)
radio_button.setFixedSize(24, 24)
radio_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 3px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 12px;"
f"height: 12px;"
f"border-radius: 1px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
color_swatch_style(primary_color, text_color, border_color, highlight_color)
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)

View File

@@ -15,6 +15,14 @@ from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_primary_color,
get_tag_text_color,
tag_remove_button_style,
tag_style,
)
logger = structlog.get_logger(__name__)
@@ -156,15 +164,15 @@ class TagWidget(QWidget):
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setMinimumSize(22, 22)
self.remove_button.setMaximumSize(22, 22)
self.remove_button.clicked.connect(self.on_remove.emit)
self.remove_button.setHidden(True)
self.inner_layout.addWidget(self.remove_button)
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.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)
@@ -188,13 +196,13 @@ class TagWidget(QWidget):
if not tag:
return
primary_color = get_primary_color(tag)
primary_color = get_tag_primary_color(tag)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
@@ -203,65 +211,14 @@ class TagWidget(QWidget):
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
self._delete_button.setStyleSheet(
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
if self.lib:
@@ -275,52 +232,13 @@ class TagWidget(QWidget):
@override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(False)
self._delete_button.setHidden(False)
self.update()
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(True)
self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)
def get_primary_color(tag: Tag) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag.color
else tag.color.primary
)
return primary_color
def get_border_color(primary_color: QColor) -> QColor:
border_color: QColor = QColor(primary_color)
border_color.setRed(min(border_color.red() + 20, 255))
border_color.setGreen(min(border_color.green() + 20, 255))
border_color.setBlue(min(border_color.blue() + 20, 255))
return border_color
def get_highlight_color(primary_color: QColor) -> QColor:
highlight_color: QColor = QColor(primary_color)
highlight_color = highlight_color.toHsl()
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
highlight_color = highlight_color.toRgb()
return highlight_color
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
# logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness())
if primary_color.lightness() > 120:
text_color = QColor(primary_color)
text_color = text_color.toHsl()
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
return text_color.toRgb()
else:
return highlight_color

View File

@@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.mixed.field_widget import FieldWidget
class TextWidget(FieldWidget):
def __init__(self, title, text: str) -> None:
class TextContainerWidget(FieldWidget):
def __init__(self, title: str, text: str) -> None:
super().__init__(title)
self.setObjectName("textBox")
self.base_layout = QHBoxLayout()

View File

@@ -83,7 +83,7 @@ class Translator:
for k, v in self._strings.items():
self._strings[k] = remove_mnemonic_marker(v)
def __format(self, text: str, **kwargs) -> str:
def __format(self, text: str, **kwargs: ...) -> str:
try:
return text.format(**kwargs)
except (KeyError, ValueError):
@@ -93,11 +93,11 @@ class Translator:
kwargs=kwargs,
language=self.__lang,
)
params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}")
params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") # pyright: ignore[reportExplicitAny]
params.update(kwargs)
return text.format_map(params)
def format(self, key: str, **kwargs) -> str:
def format(self, key: str, **kwargs: ...) -> str:
return self.__format(self[key], **kwargs)
def __getitem__(self, key: str) -> str:

View File

@@ -24,7 +24,7 @@ from typing import TypeVar
from warnings import catch_warnings
import structlog
from humanfriendly import format_size, format_timespan
from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType]
from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal
from PySide6.QtGui import (
QColor,
@@ -45,7 +45,8 @@ from PySide6.QtWidgets import (
QScrollArea,
)
import tagstudio.qt.resources_rc # noqa: F401
# This import has side-effect of importing PySide resources
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
@@ -64,11 +65,7 @@ from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
from tagstudio.qt.controllers.field_template_search_panel_controller import (
FieldTemplateSearchPanel,
)
# this import has side-effect of import PySide resources
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
@@ -102,6 +99,7 @@ from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSea
from tagstudio.qt.views.main_window import MainWindow
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.splash import SplashScreen
from tagstudio.qt.views.stylesheets.stylesheets import header
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
BADGE_TAGS = {
@@ -376,10 +374,12 @@ class QtDriver(DriverMixin, QObject):
view=TagSearchPanelView(is_tag_chooser=False),
),
title=Translations["tag_manager.title"],
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
is_savable=False,
)
self.tag_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
),
has_save=False,
)
)
# Initialize the Color Group Manager panel
@@ -393,10 +393,12 @@ class QtDriver(DriverMixin, QObject):
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
),
title=Translations["field_template_manager.title"],
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
is_savable=False,
)
self.field_template_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
),
has_save=False,
)
)
# Initialize the Tag Search panel
@@ -741,7 +743,7 @@ class QtDriver(DriverMixin, QObject):
self.ignore_modal = PanelModal(
panel,
Translations["menu.edit.ignore_files"],
has_save=True,
is_savable=True,
)
self.ignore_modal.saved.connect(panel.save)
self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show)
@@ -880,7 +882,7 @@ class QtDriver(DriverMixin, QObject):
panel,
Translations["tag.new"],
Translations["tag.add"],
has_save=True,
is_savable=True,
)
self.modal.saved.connect(
@@ -1013,9 +1015,8 @@ class QtDriver(DriverMixin, QObject):
perm_warning_msg = Translations.format(
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
)
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
f"{perm_warning_msg}</h4>"
perm_warning: str = header(
perm_warning_msg, 4, get_ui_color(ColorType.PRIMARY, UiColor.RED)
)
msg = QMessageBox()
@@ -1032,8 +1033,8 @@ class QtDriver(DriverMixin, QObject):
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations['trash.dialog.disambiguation_warning.singular']}</h4>"
f"{header(msg_text, 3)}"
f"{header(Translations['trash.dialog.disambiguation_warning.singular'], 4)}"
f"{filename if filename else ''}"
f"{perm_warning}<br>"
)
@@ -1044,8 +1045,8 @@ class QtDriver(DriverMixin, QObject):
trash_term=trash_term(),
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations['trash.dialog.disambiguation_warning.plural']}</h4>"
f"{header(msg_text, 3)}"
f"{header(Translations['trash.dialog.disambiguation_warning.plural'], 4)}"
f"{perm_warning}<br>"
)
@@ -1068,9 +1069,7 @@ class QtDriver(DriverMixin, QObject):
pw.update_label(Translations["library.refresh.scanning_preparing"])
pw.show()
iterator = FunctionIterator(
lambda lib=unwrap(self.lib.library_dir): tracker.refresh_dir(lib) # noqa: B008
)
iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib))
iterator.value.connect(
lambda x: (
pw.update_progress(x + 1),

View File

@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
logger = structlog.get_logger(__name__)
class EditFieldTemplateModalView(PanelWidget):
def __init__(self) -> None:
super().__init__()
# Layout Init
self.setMinimumSize(460, 200)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Field Name
self._name_widget = QWidget()
self._name_layout = QVBoxLayout(self._name_widget)
self._name_layout.setStretch(1, 1)
self._name_layout.setContentsMargins(0, 0, 0, 0)
self._name_layout.setSpacing(0)
self._name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._name_title = QLabel(Translations["field.name"])
self._name_layout.addWidget(self._name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.setPlaceholderText(Translations["field.field_name_required"])
self._name_layout.addWidget(self.name_field)
# Field Type
self._type_widget = QWidget()
self._type_layout = QVBoxLayout(self._type_widget)
self._type_layout.setStretch(1, 1)
self._type_layout.setContentsMargins(0, 0, 0, 0)
self._type_layout.setSpacing(0)
self._type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._type_title = QLabel(Translations["field.type"])
self._type_layout.addWidget(self._type_title)
self._type_combobox = QComboBox()
self._type_combobox.setMinimumWidth(120)
self._type_layout.addWidget(self._type_combobox)
# Text Field Attributes --------------------------------------------------------------------
self._text_field_attributes_widget = QWidget()
self._text_field_attributes_layout = QHBoxLayout(self._text_field_attributes_widget)
self._text_field_attributes_layout.setStretch(1, 1)
self._text_field_attributes_layout.setContentsMargins(0, 0, 0, 0)
self._text_field_attributes_layout.setSpacing(6)
# Is Multiline
self._multiline_widget = QWidget()
self._multiline_layout = QHBoxLayout(self._multiline_widget)
self._multiline_layout.setStretch(1, 1)
self._multiline_layout.setContentsMargins(0, 0, 0, 0)
self._multiline_layout.setSpacing(6)
self._multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
self._multiline_checkbox = QCheckBox()
self._multiline_checkbox.setFixedSize(22, 22)
self._multiline_checkbox.setStyleSheet(checkbox_style())
self._multiline_title.clicked.connect(self._multiline_checkbox.click)
self._multiline_layout.addWidget(self._multiline_checkbox)
self._multiline_layout.addWidget(self._multiline_title)
self._text_field_attributes_layout.addWidget(self._multiline_widget)
# NOTE: Future options specific to other type will go in their own sections,
# following the pattern with text fields above.
# Add Widgets to Layout ====================================================================
self.root_layout.addWidget(self._name_widget)
self.root_layout.addWidget(self._type_widget)
self.root_layout.addWidget(self._text_field_attributes_widget)

View File

@@ -1,25 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
class EditTextBox(PanelWidget):
def __init__(self, text):
super().__init__()
self.setMinimumSize(480, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.text = text
self.text_edit = QPlainTextEdit()
self.text_edit.setPlainText(text)
self.root_layout.addWidget(self.text_edit)
def get_content(self) -> str:
return self.text_edit.toPlainText()
def reset(self):
self.text_edit.setPlainText(self.text)

View File

@@ -1,33 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from PySide6.QtWidgets import QLineEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
class EditTextLine(PanelWidget):
def __init__(self, text):
super().__init__()
self.setMinimumWidth(480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.text = text
self.text_edit = QLineEdit()
self.text_edit.setText(text)
self.root_layout.addWidget(self.text_edit)
def get_content(self) -> str:
return self.text_edit.text()
def reset(self):
self.text_edit.setText(self.text)
def add_callback(self, callback: Callable, event: str = "returnPressed"):
if event == "returnPressed":
self.text_edit.returnPressed.connect(callback)
else:
raise ValueError(f"unknown event type: {event}")

View File

@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtGui import Qt
from PySide6.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLineEdit,
QPlainTextEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style, title_line_edit_style
class EditTextView(PanelWidget):
def __init__(self):
super().__init__()
self.setMinimumSize(480, 240)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.name_field = QLineEdit()
self.name_field.setStyleSheet(title_line_edit_style())
self.text_box = QPlainTextEdit()
self.text_line = QLineEdit()
self.text_line_stretch = QWidget()
self.text_line_stretch.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
# Is Multiline
self.multiline_widget = QWidget()
self.multiline_layout = QHBoxLayout(self.multiline_widget)
self.multiline_layout.setStretch(1, 1)
self.multiline_layout.setContentsMargins(0, 0, 0, 0)
self.multiline_layout.setSpacing(6)
self.multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
self.multiline_checkbox = QCheckBox()
self.multiline_checkbox.setFixedSize(22, 22)
self.multiline_checkbox.setStyleSheet(checkbox_style())
self.multiline_title.clicked.connect(self.multiline_checkbox.click)
self.multiline_layout.addWidget(self.multiline_checkbox)
self.multiline_layout.addWidget(self.multiline_title)
self.root_layout.addWidget(self.name_field)
self.root_layout.addWidget(self.text_box)
self.root_layout.setStretch(2, 1)
self.root_layout.addWidget(self.text_line)
self.root_layout.addWidget(self.text_line_stretch)
self.root_layout.setStretch(4, 1)
self.root_layout.addWidget(self.multiline_widget)

View File

@@ -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 FieldTemplateSearchPanelView(SearchPanelView):
self.search_field.setPlaceholderText(Translations["home.search_field_templates"])
self.create_button.setText(Translations["field_template.create"])
@override
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index

View File

@@ -5,41 +5,19 @@ from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
list_button_style,
)
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color: QColor = get_border_color(primary_color)
highlight_color: QColor = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
FIELD_TEMPLATE_BUTTON_STYLESHEET = f"""
QPushButton{{
background-color: {Theme.COLOR_BG.value};
font-weight: 600;
border-radius: 6px;
padding-right: 4px;
padding-left: 4px;
font-size: 13px;
text-align: center;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
}}
QPushButton::pressed{{
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: QColor = get_tag_border_color(primary_color)
highlight_color: QColor = get_tag_highlight_color(primary_color)
text_color: QColor = get_tag_text_color(primary_color, highlight_color)
class FieldTemplateWidgetView(QWidget):
@@ -63,7 +41,7 @@ class FieldTemplateWidgetView(QWidget):
self._bg_button.setMinimumSize(44, 22)
self._bg_button.setMinimumHeight(22)
self._bg_button.setMaximumHeight(22)
self._bg_button.setStyleSheet(FIELD_TEMPLATE_BUTTON_STYLESHEET)
self._bg_button.setStyleSheet(list_button_style())
self.__inner_layout = QHBoxLayout()
self.__inner_layout.setObjectName("inner_layout")
@@ -72,18 +50,18 @@ class FieldTemplateWidgetView(QWidget):
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
# Remove button
self.__remove_button = QPushButton(self)
self.__remove_button.setFlat(True)
self.__remove_button.setText("")
self.__remove_button.setHidden(True)
self.__remove_button.setMinimumSize(22, 22)
self.__remove_button.setMaximumSize(22, 22)
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.__inner_layout.addWidget(self.__remove_button)
self.__inner_layout.addWidget(self._delete_button)
self.__inner_layout.addStretch(1)
self.__connect_callbacks()
def __connect_callbacks(self) -> None:
self._bg_button.clicked.connect(self.on_click.emit)
self.__remove_button.clicked.connect(self.on_remove.emit)
self._delete_button.clicked.connect(self.on_remove.emit)

View File

@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.platform_strings import open_file_str
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -65,7 +66,7 @@ class LibraryInfoWindowView(QWidget):
self.stats_layout.setContentsMargins(0, 0, 0, 0)
self.stats_layout.setSpacing(12)
self.stats_label = QLabel(f"<h3>{Translations['library_info.stats']}</h3>")
self.stats_label = QLabel(header(Translations["library_info.stats"], 3))
self.stats_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stats_grid: QWidget = QWidget()
@@ -223,7 +224,7 @@ class LibraryInfoWindowView(QWidget):
self.cleanup_layout.setContentsMargins(0, 0, 0, 0)
self.cleanup_layout.setSpacing(12)
self.cleanup_label = QLabel(f"<h3>{Translations['library_info.cleanup']}</h3>")
self.cleanup_label = QLabel(header(Translations["library_info.cleanup"], 3))
self.cleanup_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.cleanup_grid: QWidget = QWidget()

View File

@@ -10,7 +10,7 @@ import structlog
from PIL import Image, ImageQt
from PySide6 import QtCore
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QColor, QPixmap
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -35,18 +35,17 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum, TagColorEnum
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.mnemonics import assign_mnemonics
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -589,11 +588,6 @@ class MainWindow(QMainWindow):
self.extra_input_layout = QHBoxLayout()
self.extra_input_layout.setObjectName("extra_input_layout")
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
## Show hidden entries checkbox
self.show_hidden_entries_widget = QWidget()
self.show_hidden_entries_layout = QHBoxLayout(self.show_hidden_entries_widget)
@@ -604,33 +598,7 @@ class MainWindow(QMainWindow):
self.show_hidden_entries_title = QLabel(Translations["home.show_hidden_entries"])
self.show_hidden_entries_checkbox = QCheckBox()
self.show_hidden_entries_checkbox.setFixedSize(22, 22)
self.show_hidden_entries_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.show_hidden_entries_checkbox.setStyleSheet(checkbox_style())
self.show_hidden_entries_checkbox.setChecked(False) # Default: No

View File

@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from typing import override
import contextlib
from typing import Any, override
import structlog
from PySide6 import QtCore, QtGui
@@ -16,18 +16,19 @@ logger = structlog.get_logger(__name__)
class PanelModal(QWidget):
saved = Signal()
"""A generic reusable modal panel widget."""
done = Signal()
saved = Signal()
saved_data = Signal(type(Any))
# TODO: Separate callbacks from the buttons you want, and just generally
# figure out what you want from this.
def __init__(
self,
widget: "PanelWidget",
title: str = "",
window_title: str | None = None,
done_callback: Callable[[], None] | None = None,
save_callback: Callable[[str], None] | None = None,
has_save: bool = False,
is_savable: bool = False,
inline_title: bool = True,
):
# [Done]
# - OR -
@@ -37,37 +38,24 @@ class PanelModal(QWidget):
self.setWindowTitle(title if window_title is None else window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 6)
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
self.title_widget.setText(title)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
if not (save_callback or has_save):
if not is_savable:
self.done_button = QPushButton(Translations["generic.done"])
self.done_button.setAutoDefault(True)
self.done_button.clicked.connect(self.hide)
if done_callback:
self.done_button.clicked.connect(done_callback)
self.done_button.clicked.connect(self.done.emit)
self.widget.panel_done_button = self.done_button
self.button_layout.addWidget(self.done_button)
if save_callback or has_save:
else:
self.cancel_button = QPushButton(Translations["generic.cancel"])
self.cancel_button.clicked.connect(self.hide)
self.cancel_button.clicked.connect(widget.reset)
# self.cancel_button.clicked.connect(cancel_callback)
self.widget.panel_cancel_button = self.cancel_button
self.button_layout.addWidget(self.cancel_button)
@@ -75,23 +63,19 @@ class PanelModal(QWidget):
self.save_button.setAutoDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(self.saved.emit)
self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data()))
self.widget.panel_save_button = self.save_button
if done_callback:
self.save_button.clicked.connect(done_callback)
if save_callback:
self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.save_button)
# trigger save button actions when pressing enter in the widget
self.widget.add_callback(lambda: self.save_button.click())
if inline_title:
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px")
self.title_widget.setText(title)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.root_layout.addWidget(self.title_widget)
if save_callback is not None:
widget.done.connect(lambda: save_callback(widget.get_content()))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(widget)
widget.parent_modal = self
self.root_layout.setStretch(1, 2)
@@ -100,9 +84,9 @@ class PanelModal(QWidget):
@override
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
if self.cancel_button:
with contextlib.suppress(AttributeError):
self.cancel_button.click()
elif self.done_button:
with contextlib.suppress(AttributeError):
self.done_button.click()
event.accept()
@@ -110,7 +94,6 @@ class PanelModal(QWidget):
class PanelWidget(QWidget):
"""Used for widgets that go in a modal panel, ex. for editing or searching."""
done = Signal()
parent_modal: PanelModal | None = None
panel_save_button: QPushButton | None = None
panel_cancel_button: QPushButton | None = None
@@ -119,8 +102,8 @@ class PanelWidget(QWidget):
def __init__(self):
super().__init__()
def get_content(self) -> str:
return ""
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
return None
def reset(self) -> None:
pass
@@ -128,9 +111,6 @@ class PanelWidget(QWidget):
def parent_post_init(self) -> None:
pass
def add_callback(self, callback: Callable[[], None], event: str = "returnPressed"):
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:

View File

@@ -16,45 +16,20 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.enums import Theme
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.preview_thumb_controller import PreviewThumb
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import button_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
BUTTON_STYLE: str = f"""
QPushButton{{
background-color: {Theme.COLOR_BG.value};
border-radius: 6px;
font-weight: 500;
text-align: center;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
}}
QPushButton::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
}}
"""
class PreviewPanelView(QWidget):
lib: Library
@@ -67,7 +42,7 @@ class PreviewPanelView(QWidget):
self.__thumb = PreviewThumb(self.lib, driver)
self.__file_attrs = FileAttributes(self.lib, driver)
self._fields = FieldContainers(
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
@@ -94,20 +69,20 @@ class PreviewPanelView(QWidget):
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.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.setStyleSheet(button_style())
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.__file_attrs)
info_layout.addWidget(self._fields)
info_layout.addWidget(self._containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
@@ -148,7 +123,7 @@ class PreviewPanelView(QWidget):
self.__thumb.hide_preview()
self.__file_attrs.update_stats()
self.__file_attrs.update_date_label()
self._fields.hide_containers()
self._containers.hide_containers()
self.add_buttons_enabled = False
@@ -163,7 +138,7 @@ class PreviewPanelView(QWidget):
stats: FileAttributeData = self.__thumb.display_file(filepath)
self.__file_attrs.update_stats(filepath, stats)
self.__file_attrs.update_date_label(filepath)
self._fields.update_from_entry(entry_id)
self._containers.update_from_entry(entry_id)
self._set_selection_callback()
@@ -175,7 +150,7 @@ class PreviewPanelView(QWidget):
self.__thumb.hide_preview() # TODO: Render mixed selection
self.__file_attrs.update_multi_selection(len(selected))
self.__file_attrs.update_date_label()
self._fields.hide_containers() # TODO: Allow for mixed editing
self._containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
@@ -205,7 +180,7 @@ class PreviewPanelView(QWidget):
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
"""Getter for the field containers widget."""
return self._fields
return self._containers
@property
def preview_thumb(self) -> PreviewThumb:

View File

@@ -19,7 +19,7 @@ from tagstudio.qt.mixed.media_player import MediaPlayer
from tagstudio.qt.platform_strings import open_file_str, trash_term
from tagstudio.qt.previews.renderer import ThumbRenderer
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
from tagstudio.qt.views.stylesheets.rounded_pixmap_style import RoundedPixmapStyle
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -16,46 +16,14 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import list_button_style
if TYPE_CHECKING:
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
CREATE_BUTTON_STYLESHEET: str = f"""
QPushButton{{
background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};
font-weight: 600;
border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};
border-radius: 6px;
border-style: dashed;
border-width: 2px;
padding-right: 4px;
padding-bottom: 1px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
}}
QPushButton::pressed{{
background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
}}
QPushButton::focus{{
border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
outline: none;
}}
"""
class SearchPanelView(PanelWidget):
def __init__(self, is_chooser: bool) -> None:
@@ -120,7 +88,7 @@ class SearchPanelView(PanelWidget):
self.create_and_add_button = QPushButton()
self.create_and_add_button.setFlat(True)
self.create_and_add_button.setMinimumSize(22, 22)
self.create_and_add_button.setStyleSheet(CREATE_BUTTON_STYLESHEET)
self.create_and_add_button.setStyleSheet(list_button_style(border_style="dashed"))
@property
def scroll_layout(self) -> QVBoxLayout:
@@ -130,7 +98,7 @@ class SearchPanelView(PanelWidget):
def scroll_area(self) -> QScrollArea:
return self.__scroll_area
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None:
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.search_field.textChanged.connect(controller.on_search_query_changed)
@@ -139,7 +107,9 @@ class SearchPanelView(PanelWidget):
)
self.create_button.clicked.connect(controller.on_item_create)
self.create_and_add_button.clicked.connect(controller.on_item_create_and_add)
self.create_and_add_button.clicked.connect(
lambda: controller.on_item_create(add_to_entry=True)
)
def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
# Remove existing limit items
@@ -171,7 +141,7 @@ class SearchPanelView(PanelWidget):
def scroll_to(self, position: int) -> None:
self.__scroll_area.verticalScrollBar().setValue(position)
def get_item_widget(self, index: int, library: Library | None) -> Any:
def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny]
raise NotImplementedError()
def add_create_and_add_button(self) -> None:

View File

@@ -0,0 +1,431 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
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
# 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.
def add_button_style() -> str:
"""Style used for tag-like "Add" buttons [+]."""
return f"""
QPushButton{{
background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};
font-weight: 600;
border-color: {get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};
border-radius: 6px;
border-style: solid;
border-width: 2px;
padding-right: 4px;
padding-bottom: 2px;
padding-left: 4px;
font-size: 15px
}}
QPushButton::hover{{
border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
}}
QPushButton::pressed{{
background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
}}
QPushButton::focus{{
border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
outline: none;
}}
"""
def button_style() -> str:
"""Style used for common QPushButtons."""
return f"""
QPushButton{{
background-color: {Theme.COLOR_BG.value};
border-radius: 6px;
font-weight: 500;
text-align: center;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
}}
QPushButton::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
}}
QPushButton::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))
border_color = get_tag_border_color(primary_color)
highlight_color = get_tag_highlight_color(primary_color)
text_color: QColor = get_tag_text_color(primary_color, highlight_color)
return f"""
QCheckBox{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QCheckBox::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QCheckBox::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QCheckBox::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QCheckBox::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
def colored_radio_button_style(
primary_color: QColor,
text_color: QColor,
border_color: QColor,
highlight_color: QColor,
) -> str:
return f"""
QRadioButton{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QRadioButton::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QRadioButton::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QRadioButton::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QRadioButton::pressed{{
background: rgba{border_color.toTuple()};
color: rgba{primary_color.toTuple()};
border-color: rgba{primary_color.toTuple()};
}}
QRadioButton::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
def color_swatch_style(
primary_color: QColor,
text_color: QColor,
border_color: QColor,
highlight_color: QColor,
bottom_color: QColor | None = None,
) -> str:
"""A style used for color swatches (aka special QRadioButtons)."""
bottom_color_str: str = (
f"border-bottom-color: rgba{bottom_color.toTuple()};" if bottom_color else ""
)
return f"""
QRadioButton{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
{bottom_color_str}
border-radius: 3px;
border-style: solid;
border-width: 2px;
}}
QRadioButton::indicator{{
width: 12px;
height: 12px;
border-radius: 1px;
margin: 4px;
}}
QRadioButton::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QRadioButton::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QRadioButton::focus{{
outline-style: solid;
outline-width: 2px;
outline-radius: 3px;
outline-color: rgba{highlight_color.toTuple()};
}}
"""
def container_style() -> str:
"""Style used for field containers."""
return f"""
QWidget#fieldContainer{{
border-radius: 4px;
}}
QWidget#fieldContainer::hover{{
background-color: {Theme.COLOR_HOVER.value};
}}
QWidget#fieldContainer::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
}}
"""
def form_content_style() -> str:
return f"""
background-color: {
Theme.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
};
border-radius: 3px;
font-weight: 500;
padding: 1px;
"""
def line_edit_style() -> str:
"""Style used for QLineEdits."""
return f"""
border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)};
border-radius: 2px
"""
def list_button_style(
color: QColor | None = None,
border_style: str = "solid",
) -> str:
"""Style used for special QPushButtons found in lists."""
if color is None:
color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
highlight_color = get_tag_highlight_color(color)
text_color = get_tag_text_color(color, highlight_color)
border_color = get_tag_border_color(color)
return f"""
QPushButton{{
background: rgba{color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
padding-right: 4px;
padding-bottom: 1px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QPushButton::pressed{{
background: rgba{highlight_color.toTuple()};
color: rgba{color.toTuple()};
border-color: rgba{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()};
}}
"""
def properties_style() -> str:
"""Style used for small labels such as file properties."""
label_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
)
return f"""
background-color: {label_bg_color};
color: #FFFFFF;
font-family: Oxanium;
font-weight: bold;
font-size: 12px;
border-radius: 3px;
padding-top: 4px;
padding-right: 1px;
padding-bottom: 1px;
padding-left: 1px;
"""
def tag_style(
primary_color: QColor,
text_color: QColor,
border_color: QColor,
highlight_color: QColor,
border_style: str = "solid",
) -> str:
"""Style used for TagWidgets."""
return f"""
QPushButton{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
padding-right: 4px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QPushButton::pressed{{
background: rgba{highlight_color.toTuple()};
color: rgba{primary_color.toTuple()};
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()};
}}
"""
def tag_remove_button_style(
primary_color: QColor, text_color: QColor, border_color: QColor, highlight_color: QColor
) -> str:
"""Style used for "Remove" buttons on TagWidgets [-]."""
return f"""
QPushButton{{
color: rgba{primary_color.toTuple()};
background: rgba{text_color.toTuple()};
font-weight: 800;
border-radius: 5px;
border-width: 4;
border-color: rgba(0,0,0,0);
padding-bottom: 4px;
font-size: 14px
}}
QPushButton::hover{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{highlight_color.toTuple()};
border-width: 2;
border-radius: 6px;
}}
QPushButton::pressed{{
background: rgba{border_color.toTuple()};
color: rgba{highlight_color.toTuple()};
}}
QPushButton::focus{{
background: rgba{border_color.toTuple()};
outline: none;
}}
"""
def title_line_edit_style() -> str:
"""Used to mimic an H3-like header style inside a QLineEdit."""
return """
font-weight: bold;
font-size: 16px;
"""
def header(string: str, level: int, color: str | None = None) -> str:
"""Wrap a string in HTML header tags.
Args:
string (str): The string to format.
level (int): A value between 1 and 6 denoting the header level.
For example, "1" will create <h1> tags, "6" will create <h6> tags, etc.
color: Optional color string to pass as an inline HTML style.
"""
if level < 1:
level = 1
elif level > 6:
level = 6
style_tag: str = ""
if color is not None:
style_tag = f" style='color: {color}'"
return f"<h{level}{style_tag}>{string}</h{level}>"
def get_tag_primary_color(tag: Tag) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag.color
else tag.color.primary
)
return primary_color
def get_tag_border_color(primary_color: QColor) -> QColor:
border_color: QColor = QColor(primary_color)
border_color.setRed(min(border_color.red() + 20, 255))
border_color.setGreen(min(border_color.green() + 20, 255))
border_color.setBlue(min(border_color.blue() + 20, 255))
return border_color
def get_tag_highlight_color(primary_color: QColor) -> QColor:
highlight_color: QColor = QColor(primary_color)
highlight_color = highlight_color.toHsl()
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
highlight_color = highlight_color.toRgb()
return highlight_color
def get_tag_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
if primary_color.lightness() > 120:
text_color = QColor(primary_color)
text_color = text_color.toHsl()
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
return text_color.toRgb()
else:
return highlight_color

View File

@@ -1,11 +1,12 @@
{
"about.config_path": "Config Path",
"about.app_cache_path": "App Cache Path",
"about.config_path": "Config Path",
"about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
"about.documentation": "Documentation",
"about.module.found": "Found",
"about.title": "About TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
@@ -35,7 +36,6 @@
"drop_import.title": "Conflicting File(s)",
"edit.color_manager": "Manage Tag Colors",
"edit.copy_fields": "Copy Fields",
"edit.field_template_manager": "Manage Field Templates",
"edit.paste_fields": "Paste Fields",
"edit.tag_manager": "Manage Tags",
"entries.duplicate.merge": "Merge Duplicate Entries",
@@ -75,8 +75,12 @@
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field_template_manager.title": "Library Field Templates",
"field_template.all_field_templates": "All Field Templates",
"field_template.confirm_delete": "Are you sure you want to delete the field template \"{field_template_name}\"?",
"field_template.create": "Create Field Template",
"field_template.create_add": "Create && Add \"{query}\"",
"field_template.delete": "Delete Field Template",
"field_template.edit": "Edit Field Template",
"field_template.new": "New Field Template",
"field_type.datetime": "Datetime",
"field_type.text": "Text",
"field_type.unknown": "Unknown Type",
@@ -85,9 +89,13 @@
"field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?",
"field.copy": "Copy Field",
"field.edit": "Edit Field",
"field.field_name_required": "Field Name (Required)",
"field.mixed_data": "Mixed Data",
"field.name": "Name",
"field.paste": "Paste Field",
"field.remove": "Remove Field",
"field.text.is_multiline": "Multiline",
"field.type": "Type",
"file.date_added": "Date Added",
"file.date_created": "Date Created",
"file.date_modified": "Date Modified",
@@ -347,6 +355,7 @@
"tag.parent_tags": "Parent Tags",
"tag.parent_tags.add": "Add Parent Tag(s)",
"tag.parent_tags.description": "This tag can be treated as a substitute for any of these Parent Tags in searches.",
"tag.properties": "Properties",
"tag.remove": "Remove Tag",
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",

View File

@@ -80,7 +80,7 @@ def test_build_tag_panel_remove_alias_callback(
alias: TagAlias = unwrap(library.get_alias(tag.id, tag.alias_ids[0]))
panel.remove_alias_callback(alias.name, alias.id)
panel.remove_alias_callback(alias.id)
assert len(panel.alias_ids) == 1
assert len(panel.alias_names) == 1

View File

@@ -225,7 +225,9 @@ def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_fu
def test_update_entry_field(library: Library, entry_full: Entry):
title_field = entry_full.text_fields[0]
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
library.update_text_field(
entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline
)
entry = next(library.all_entries(with_joins=True))
assert entry.text_fields[0].value == "new value"
@@ -241,7 +243,9 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr
library.add_field_to_entries(entry_full.id, field=empty_title)
# update one of the fields
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
library.update_text_field(
entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline
)
# Then only one should be updated
entry = next(library.all_entries(with_joins=True))