refactor(ui): rename FieldContainers to Tiles

This commit is contained in:
Travis Abendshien
2026-09-14 15:44:28 -07:00
parent 0f3d610696
commit ca143139cb
22 changed files with 316 additions and 275 deletions
+4 -4
View File
@@ -27,8 +27,8 @@ from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.entry_data_box_list import EntryDataBoxList
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_list import TileList
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.views.inspector_view import InspectorView
@@ -186,8 +186,8 @@ class Inspector(QWidget):
self.layout().containers.update_from_entry(self._selected[0])
def _edit_field(self, entry_id: int, field: BaseField) -> None:
# TODO: A lot of this code is similar to or straight up shared with EntryDataBoxList.
# It's possible to reuse it later, after an EntryDataBoxList refactor.
# TODO: A lot of this code is similar to or straight up shared with TileList.
# It's possible to reuse it later, after a TileList refactor.
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
if type(field) is TextField:
@@ -281,7 +281,7 @@ class Inspector(QWidget):
self.layout().preview_thumb.media_player.stop()
@property
def containers(self) -> EntryDataBoxList:
def containers(self) -> TileList:
return self.layout().containers
@override
+2 -2
View File
@@ -47,7 +47,7 @@ from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import PAD, WIN_PAD, checkbox_style
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, WIN_PAD, checkbox_style
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -662,7 +662,7 @@ class MainWindow(QMainWindow):
self.content_splitter = QSplitter()
self.content_splitter.setObjectName("content_splitter")
self.content_splitter.setHandleWidth(PAD * 2)
self.content_splitter.setHandleWidth(PAD + HALF_PAD)
self.central_content = QWidget()
self.central_content.setObjectName("central_content")
+8 -5
View File
@@ -30,24 +30,27 @@ class Pagination(QWidget):
self.setFixedHeight(self.HEIGHT)
self.setStyleSheet(pagination_style())
self.view = PaginationView()
self.setLayout(self.view)
self.setLayout(PaginationView())
self._connect_callbacks()
if parent is not None:
parent.installEventFilter(self)
self._sync_geometry()
@override
def layout(self) -> PaginationView:
return super().layout() # pyright: ignore[reportReturnType]
def _connect_callbacks(self) -> None:
self.view.current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.view.current_page_field.text()) - 1)
self.layout().current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.layout().current_page_field.text()) - 1)
)
def update_buttons(self, page_count: int, index: int, emit: bool = True):
if index < 0:
raise ValueError("Negative index detected")
view = self.view
view = self.layout()
for i in range(0, 10):
if button := self._get_button_at(view.start_buffer_layout, i):
button.setHidden(True)
@@ -4,6 +4,7 @@
import typing
from collections.abc import Iterable
from typing import override
import structlog
from PySide6.QtCore import Signal
@@ -13,11 +14,11 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.data_box import DataBox
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.styles.stylesheets import add_button_style
from tagstudio.qt.views.tiles.color_data_view import ColorDataView
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -25,8 +26,9 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class ColorBoxWidget(DataBox):
class ColorData(TileData):
"""An inner widget for color names that goes in a Tile widget."""
updated = Signal()
def __init__(
@@ -40,16 +42,15 @@ class ColorBoxWidget(DataBox):
self.lib: Library = library
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
super().__init__(title)
self.setObjectName("colorBox")
self.base_layout = FlowLayout()
self.base_layout.enable_grid_optimizations(value=True)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
super().__init__(title, ColorDataView())
self.setObjectName("color_data")
self.set_colors(self.colors)
@override
def layout(self) -> ColorDataView:
return super().layout() # pyright: ignore[reportReturnType]
def set_colors(self, colors: Iterable[TagColorGroup]):
colors_ = sorted(
list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace)
@@ -58,7 +59,7 @@ class ColorBoxWidget(DataBox):
max_width = 60
color_widgets: list[TagColorLabel] = []
while (item := self.base_layout.itemAt(0)) and (widget := item.widget()):
while (item := self.layout().itemAt(0)) and (widget := item.widget()):
widget.deleteLater()
for color in colors_:
@@ -75,7 +76,7 @@ class ColorBoxWidget(DataBox):
color_widget.on_remove.connect(lambda c=color: self.delete_color(c))
color_widgets.append(color_widget)
self.base_layout.addWidget(color_widget)
self.layout().addWidget(color_widget)
for color_widget in color_widgets:
color_widget.setFixedWidth(max_width)
@@ -97,7 +98,7 @@ class ColorBoxWidget(DataBox):
)
)
)
self.base_layout.addWidget(add_button)
self.layout().addWidget(add_button)
def edit_color(self, color_group: TagColorGroup):
build_color_panel = BuildColorPanel(self.lib, color_group)
@@ -131,6 +132,6 @@ class ColorBoxWidget(DataBox):
if result != QMessageBox.ButtonRole.ActionRole.value:
return
logger.info("[ColorBoxWidget] Removing color", color=color_group)
logger.info("[ColorData] Removing color", color=color_group)
self.lib.delete_color(color_group)
self.updated.emit()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterable
from functools import partial
from typing import TYPE_CHECKING, override
@@ -13,8 +14,9 @@ from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
from tagstudio.qt.views.tiles.tag_data_view import TagDataView
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -22,62 +24,73 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidget(TagBoxWidgetView):
class TagData(TileData):
"""An inner widget for tags that go in a Tile widget."""
on_update = Signal()
__entries: list[int] = []
def __init__(self, title: str, driver: QtDriver):
super().__init__(title, driver)
self.__driver = driver
def set_entries(self, entries: list[int]) -> None:
self.__entries = entries
self._driver = driver
self._entries: list[int] = []
super().__init__(title, TagDataView(driver.lib))
self._connect_callbacks()
@override
def layout(self) -> TagDataView:
return super().layout() # pyright: ignore[reportReturnType]
def _connect_callbacks(self) -> None:
self.layout().tag_clicked.connect(self._on_click)
self.layout().tag_removed.connect(self._on_remove)
self.layout().tag_edited.connect(self._on_edit)
self.layout().tag_searched.connect(self._on_search)
def set_entries(self, entries: list[int]) -> None:
self._entries = entries
def set_tags(self, tags: Iterable[Tag]) -> None:
self.layout().set_tags(tags)
def _on_click(self, tag: Tag) -> None:
match self.__driver.settings.tag_click_action:
match self._driver.settings.tag_click_action:
case TagClickActionOption.OPEN_EDIT:
self._on_edit(tag)
case TagClickActionOption.SET_SEARCH:
self.__driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current)
)
case TagClickActionOption.ADD_TO_SEARCH:
# NOTE: modifying the ast and then setting that would be nicer
# than this string manipulation, but also much more complex,
# due to needing to implement a visitor that turns an AST to a string
# So if that exists when you read this, change the following accordingly.
current = self.__driver.browsing_history.current
current = self._driver.browsing_history.current
suffix = unwrap(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current).query
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current).query
)
self.__driver.update_browsing_state(
self._driver.update_browsing_state(
current.with_search_query(
f"{current.query} {suffix}" if current.query else suffix
)
)
@override
def _on_remove(self, tag: Tag) -> None:
logger.info(
"[TagBoxWidget] remove_tag",
selected=self.__entries,
"[TagData] remove_tag",
selected=self._entries,
)
for entry_id in self.__entries:
self.__driver.lib.remove_tags_from_entries(entry_id, tag.id)
for entry_id in self._entries:
self._driver.lib.remove_tags_from_entries(entry_id, tag.id)
self.on_update.emit()
@override
def _on_edit(self, tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag)
build_tag_panel = BuildTagPanel(self._driver.lib, tag=tag)
edit_modal = Modal(
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
self._driver.lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
@@ -85,7 +98,7 @@ class TagBoxWidget(TagBoxWidgetView):
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self.__driver.lib.update_tag(
self._driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
@@ -93,9 +106,8 @@ class TagBoxWidget(TagBoxWidgetView):
)
self.on_update.emit()
@override
def _on_search(self, tag: Tag) -> None:
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
self.__driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
self._driver.main_window.search_field.setText(f"tag_id:{tag.id}")
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current)
)
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import re
from typing import override
from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.views.tiles.text_data_view import TextDataView
class TextData(TileData):
"""An inner widget for any text that goes in a Tile widget.
Can include text fields, date fields, etc.
"""
def __init__(self, title: str, text: str) -> None:
super().__init__(title, TextDataView())
self.setObjectName("text_data")
self.set_text(text)
@override
def layout(self) -> TextDataView:
return super().layout() # pyright: ignore[reportReturnType]
def set_text(self, text: str):
text = linkify(text)
self.layout().text_label.setText(text)
# Regex from https://stackoverflow.com/a/6041965
def linkify(text: str):
url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
return re.sub(
url_pattern,
lambda url: f'<a href="{url.group(0)}">{url.group(0)}</a>',
text,
flags=re.IGNORECASE,
)
@@ -11,88 +11,93 @@ from PySide6.QtGui import QEnterEvent, QResizeEvent
from PySide6.QtWidgets import QWidget
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.views.entry_data_box_view import EntryDataBoxView
from tagstudio.qt.views.styles.stylesheets import container_style, header
from tagstudio.qt.views.tiles.tile_view import TileView
class EntryDataBox(QWidget):
def __init__(self, title: str = "DATA BOX") -> None:
class Tile(QWidget):
"""Wraps a title, action buttons, and data such as text or tags inside a single widget."""
def __init__(self, title: str = "TILE") -> None:
super().__init__()
self.setObjectName("entry_data_box")
self.setObjectName("tile")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
self.title: str = title
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
self.view = EntryDataBoxView()
self.setLayout(self.view)
self.setLayout(TileView())
self.set_title(title)
self.setStyleSheet(container_style())
@override
def layout(self) -> TileView:
return super().layout() # pyright: ignore[reportReturnType]
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.view.copy_button.clicked.disconnect()
self.layout().copy_button.clicked.disconnect()
self.copy_callback = callback
if callback:
self.view.copy_button.clicked.connect(callback)
self.layout().copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.view.edit_button.clicked.disconnect()
self.layout().edit_button.clicked.disconnect()
self.edit_callback = callback
if callback:
self.view.edit_button.clicked.connect(callback)
self.layout().edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
self.view.remove_button.clicked.disconnect()
self.layout().remove_button.clicked.disconnect()
self.remove_callback = callback
if callback:
self.view.remove_button.clicked.connect(callback)
self.layout().remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: QWidget) -> None:
if item := self.view.data_layout.itemAt(0):
if item := self.layout().data_layout.itemAt(0):
old: QWidget = unwrap(item.widget())
self.view.data_layout.removeWidget(old)
self.layout().data_layout.removeWidget(old)
old.deleteLater()
self.view.data_layout.addWidget(widget)
self.layout().data_layout.addWidget(widget)
def get_inner_widget(self) -> QWidget | None:
if item := self.view.data_layout.itemAt(0):
if item := self.layout().data_layout.itemAt(0):
return item.widget()
return None
def set_title(self, title: str) -> None:
self.title = header(title, 4)
self.view.title_widget.setText(self.title)
self.layout().title_widget.setText(self.title)
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the EntryDataBox if needed.
# NOTE: You could pass the hover event to the inner widget if needed.
if self.copy_callback:
self.view.copy_button.setHidden(False)
self.layout().copy_button.setHidden(False)
if self.edit_callback:
self.view.edit_button.setHidden(False)
self.layout().edit_button.setHidden(False)
if self.remove_callback:
self.view.remove_button.setHidden(False)
self.layout().remove_button.setHidden(False)
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.copy_callback:
self.view.copy_button.setHidden(True)
self.layout().copy_button.setHidden(True)
if self.edit_callback:
self.view.edit_button.setHidden(True)
self.layout().edit_button.setHidden(True)
if self.remove_callback:
self.view.remove_button.setHidden(True)
self.layout().remove_button.setHidden(True)
return super().leaveEvent(event)
@override
def resizeEvent(self, event: QResizeEvent) -> None:
self.view.title_widget.setFixedWidth(int(event.size().width() // 1.5))
self.layout().title_widget.setFixedWidth(int(event.size().width() // 1.5))
return super().resizeEvent(event)
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QLayout, QWidget
class TileData(QWidget):
"""A base class for widgets that go in a Tile widget."""
def __init__(self, title: str, view: QLayout) -> None:
super().__init__()
self.title: str = title
self.setLayout(view)
@@ -6,6 +6,7 @@ import typing
from collections.abc import Callable
from datetime import datetime as dt
from functools import partial
from typing import override
from warnings import catch_warnings
import structlog
@@ -23,12 +24,12 @@ from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.entry_data_box import EntryDataBox
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_box import TagBoxWidget
from tagstudio.qt.controllers.tiles.tag_data import TagData
from tagstudio.qt.controllers.tiles.text_data import TextData
from tagstudio.qt.controllers.tiles.tile import Tile
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.views.entry_data_box_list_view import EntryDataBoxListView
from tagstudio.qt.views.tiles.tile_list_view import TileListView
if typing.TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -36,8 +37,8 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class EntryDataBoxList(QWidget):
"""Widget for the tag and field boxes displayed inside the Inspector."""
class TileList(QWidget):
"""A styled list of Tile widgets."""
on_tags_update = Signal()
@@ -51,10 +52,13 @@ class EntryDataBoxList(QWidget):
self.common_fields: list = [] # TODO: Reimplement
self.mixed_fields: list = [] # TODO: Reimplement
self.cached_entries: list[Entry] = []
self._data_boxes: list[EntryDataBox] = []
self._tiles: list[Tile] = []
self.view = EntryDataBoxListView()
self.setLayout(self.view)
self.setLayout(TileListView())
@override
def layout(self) -> TileListView:
return super().layout() # pyright: ignore[reportReturnType]
@property
def top_entry_id(self) -> int:
@@ -63,7 +67,7 @@ class EntryDataBoxList(QWidget):
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[EntryDataBoxList] Updating Selection", entry_id=entry_id)
logger.warning("[TileList] Updating Selection", entry_id=entry_id)
entry = unwrap(self.lib.get_entry_full(entry_id))
self.cached_entries = [entry]
@@ -79,9 +83,7 @@ class EntryDataBoxList(QWidget):
if entry_tags:
categories = self.get_tag_categories(entry_tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
self.write_tag_container(
container_index, tags=tags, category_tag=cat, is_mixed=False
)
self.write_tag_tile(container_index, tags=tags, category_tag=cat, is_mixed=False)
container_index += 1
container_len += 1
if update_badges:
@@ -89,11 +91,11 @@ class EntryDataBoxList(QWidget):
# Write field container(s)
for index, field in enumerate(entry_fields, start=container_index):
self.write_field_container(index, field, is_mixed=False)
self.write_field_tile(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self._data_boxes) > container_len:
for i, c in enumerate(self._data_boxes):
if len(self._tiles) > container_len:
for i, c in enumerate(self._tiles):
if i > (container_len - 1):
c.setHidden(True)
@@ -112,7 +114,7 @@ class EntryDataBoxList(QWidget):
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self._data_boxes:
for c in self._tiles:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -176,7 +178,7 @@ class EntryDataBoxList(QWidget):
assert isinstance(field_templates, list)
logger.info(
"[EntryDataBoxList][add_field_to_selected]",
"[TileList][add_field_to_selected]",
selected=self.driver.selected,
fields=[
(field_template.class_name, field_template.id) for field_template in field_templates
@@ -186,7 +188,7 @@ class EntryDataBoxList(QWidget):
for entry_id in self.driver.selected:
for field_template in field_templates:
logger.info(
"[EntryDataBoxList][add_field_to_selected] Adding field",
"[TileList][add_field_to_selected] Adding field",
name=field_template.name,
type=field_template.class_name,
)
@@ -200,7 +202,7 @@ class EntryDataBoxList(QWidget):
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
"[EntryDataBoxList][add_tags_to_selected]",
"[TileList][add_tags_to_selected]",
selected=self.driver.selected,
tag_ids=tag_ids,
)
@@ -227,8 +229,8 @@ class EntryDataBoxList(QWidget):
self._remove_field(field)
self.update_from_entry(entry_id)
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field EntryDataBox.
def write_field_tile(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field Tile.
Args:
index(int): The container index.
@@ -237,9 +239,7 @@ class EntryDataBoxList(QWidget):
If True, field is not present in all selected items.
"""
def write_text_container(
container: EntryDataBox, field: TextField, title: str, is_mixed: bool
):
def write_text_tile(container: Tile, field: TextField, title: str, is_mixed: bool):
container.set_title(field.name)
# Normalize line endings in any text content.
@@ -249,7 +249,7 @@ class EntryDataBoxList(QWidget):
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -272,7 +272,7 @@ class EntryDataBoxList(QWidget):
)
def write_datetime_container(
container: EntryDataBox, field: DatetimeField, title: str, is_mixed: bool
container: Tile, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
@@ -287,7 +287,7 @@ class EntryDataBoxList(QWidget):
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -311,7 +311,7 @@ class EntryDataBoxList(QWidget):
def write_unknown_container():
container.set_title(field.name)
inner_widget = TextContainerWidget(title, field.name)
inner_widget = TextData(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
@@ -321,19 +321,19 @@ class EntryDataBoxList(QWidget):
)
logger.info(
"[EntryDataBoxList][write_container]",
"[TileList][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
# Create new containers if necessary
if len(self._data_boxes) < (index + 1):
container = EntryDataBox()
self._data_boxes.append(container)
self.view.scroll_layout.addWidget(container)
if len(self._tiles) < (index + 1):
container = Tile()
self._tiles.append(container)
self.layout().scroll_layout.addWidget(container)
else:
container = self._data_boxes[index]
container = self._tiles[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -341,7 +341,7 @@ class EntryDataBoxList(QWidget):
# Write containers
if type(field) is TextField:
write_text_container(container, field, title, is_mixed)
write_text_tile(container, field, title, is_mixed)
elif type(field) is DatetimeField:
write_datetime_container(container, field, title, is_mixed)
else:
@@ -349,10 +349,10 @@ class EntryDataBoxList(QWidget):
container.setHidden(False)
def write_tag_container(
def write_tag_tile(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
"""Update/Create tag data for a tag EntryDataBox.
"""Update/Create tag data for a tag Tile.
Args:
index(int): The container index.
@@ -361,25 +361,25 @@ class EntryDataBoxList(QWidget):
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
"""
logger.info("[EntryDataBoxList][write_tag_container]", index=index)
if len(self._data_boxes) < (index + 1):
container = EntryDataBox()
self._data_boxes.append(container)
self.view.scroll_layout.addWidget(container)
logger.info("[TileList][write_tag_tile]", index=index)
if len(self._tiles) < (index + 1):
container = Tile()
self._tiles.append(container)
self.layout().scroll_layout.addWidget(container)
else:
container = self._data_boxes[index]
container = self._tiles[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
if not is_mixed:
inner_widget = container.get_inner_widget()
if isinstance(inner_widget, TagBoxWidget):
if isinstance(inner_widget, TagData):
with catch_warnings(record=True):
inner_widget.on_update.disconnect()
else:
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
inner_widget = TagData(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)
@@ -392,7 +392,7 @@ class EntryDataBoxList(QWidget):
)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
inner_widget = TextData("Mixed Tags", text) # NOTE: Unlocalized but unused
container.set_inner_widget(inner_widget)
container.set_edit_callback()
@@ -402,7 +402,7 @@ class EntryDataBoxList(QWidget):
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[EntryDataBoxList] Removing Field",
"[TileList] Removing Field",
field=field,
selected=[x.path for x in self.cached_entries],
)
-14
View File
@@ -1,14 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtWidgets import QWidget
logger = structlog.get_logger(__name__)
class DataBox(QWidget):
def __init__(self, title: str) -> None:
super().__init__()
self.title: str = title
+8 -8
View File
@@ -25,10 +25,10 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.enums import ThemePalette
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.entry_data_box import EntryDataBox
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.color_data import ColorData
from tagstudio.qt.controllers.tiles.tile import Tile
from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel
from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.views.styles.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -119,8 +119,8 @@ class TagColorManager(QWidget):
for group, colors in self.driver.lib.tag_color_groups.items():
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
all_default = False
color_box = ColorBoxWidget(group, colors, self.driver.lib)
color_box.updated.connect(
color_data = ColorData(group, colors, self.driver.lib)
color_data.updated.connect(
lambda: (
self.reset(),
self.setup_color_groups(),
@@ -131,10 +131,10 @@ class TagColorManager(QWidget):
),
)
)
entry_data_box = EntryDataBox(self.driver.lib.get_namespace_name(group))
entry_data_box.set_inner_widget(color_box)
tile = Tile(self.driver.lib.get_namespace_name(group))
tile.set_inner_widget(color_data)
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
entry_data_box.set_remove_callback(
tile.set_remove_callback(
lambda checked=False, g=group: self.delete_namespace_dialog(
prompt=Translations["color.namespace.delete.prompt"],
callback=lambda namespace=g: (
@@ -150,7 +150,7 @@ class TagColorManager(QWidget):
)
)
self.scroll_layout.addWidget(entry_data_box)
self.scroll_layout.addWidget(tile)
if all_default:
ns_container = QWidget()
-43
View File
@@ -1,43 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import re
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.mixed.data_box import DataBox
# TODO: Split to use MVC guidelines.
class TextContainerWidget(DataBox):
def __init__(self, title: str, text: str) -> None:
super().__init__(title)
self.setObjectName("textBox")
self.base_layout = QHBoxLayout()
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.text_label = QLabel()
self.text_label.setStyleSheet("font-size: 12px")
self.text_label.setWordWrap(True)
self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
self.text_label.setOpenExternalLinks(True)
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.base_layout.addWidget(self.text_label)
self.set_text(text)
def set_text(self, text: str):
text = linkify(text)
self.text_label.setText(text)
# Regex from https://stackoverflow.com/a/6041965
def linkify(text: str):
url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
return re.sub(
url_pattern,
lambda url: f'<a href="{url.group(0)}">{url.group(0)}</a>',
text,
flags=re.IGNORECASE,
)
+2 -2
View File
@@ -12,11 +12,11 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidg
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.entry_data_box_list import EntryDataBoxList
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.qt.controllers.preview_thumb import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.controllers.tiles.tile_list import TileList
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.stylesheets import (
@@ -53,7 +53,7 @@ class InspectorView(QVBoxLayout):
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = EntryDataBoxList(driver.lib, driver)
self.containers = TileList(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
@@ -23,7 +23,7 @@ def auto_theme_overlay(
Args:
image (Image): The PIL Image object to apply an overlay to.
inverse (bool): Option inverse the overlay color relative to the current theme.
inverse (bool): Option to invert the overlay color relative to the current theme.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
+4 -4
View File
@@ -260,15 +260,15 @@ def color_swatch_style(
def container_style() -> str:
"""Style used for field containers."""
"""Style used for Tile widgets."""
return f"""
QWidget#entry_data_box{{
QWidget#tile{{
border-radius: 4px;
}}
QWidget#entry_data_box::hover{{
QWidget#tile::hover{{
background-color: {ThemePalette.COLOR_HOVER.value};
}}
QWidget#entry_data_box::pressed{{
QWidget#tile::pressed{{
background-color: {ThemePalette.COLOR_PRESSED.value};
}}
"""
-61
View File
@@ -1,61 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterable
from typing import TYPE_CHECKING
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.mixed.data_box import DataBox
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidgetView(DataBox):
__lib: Library
def __init__(self, title: str, driver: QtDriver) -> None:
super().__init__(title)
self.__lib = driver.lib
self.__root_layout = FlowLayout()
self.__root_layout.enable_grid_optimizations(value=False)
self.__root_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.__root_layout)
def set_tags(self, tags: Iterable[Tag]) -> None:
tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag))
logger.info("[TagBoxWidget] Tags:", tags=tags)
while self.__root_layout.itemAt(0):
self.__root_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
raise NotImplementedError
def _on_remove(self, tag: Tag) -> None:
raise NotImplementedError
def _on_edit(self, tag: Tag) -> None:
raise NotImplementedError
def _on_search(self, tag: Tag) -> None:
raise NotImplementedError
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
class ColorDataView(FlowLayout):
"""The layout used for a ColorData widget."""
def __init__(self) -> None:
super().__init__()
self.enable_grid_optimizations(value=True)
self.setContentsMargins(0, 0, 0, 0)
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterable
import structlog
from PySide6.QtCore import Signal
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
logger = structlog.get_logger(__name__)
class TagDataView(FlowLayout):
"""The layout used for a TagData widget."""
tag_clicked = Signal(Tag)
tag_removed = Signal(Tag)
tag_edited = Signal(Tag)
tag_searched = Signal(Tag)
def __init__(self, library: Library) -> None:
super().__init__()
self._lib = library
self.enable_grid_optimizations(value=False)
self.setContentsMargins(0, 0, 0, 0)
def set_tags(self, tags: Iterable[Tag]) -> None:
tags_ = sorted(list(tags), key=lambda tag: self._lib.tag_display_name(tag))
logger.info("[TagData] Tags:", tags=tags)
while self.itemAt(0):
self.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
for tag in tags_:
tag_widget = TagWidget(tag, library=self._lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self.tag_clicked.emit(t))
tag_widget.on_remove.connect(lambda t=tag: self.tag_removed.emit(t))
tag_widget.on_edit.connect(lambda t=tag: self.tag_edited.emit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self.tag_searched.emit(t)
)
self.addWidget(tag_widget)
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
class TextDataView(QHBoxLayout):
"""The layout used for a TextData widget."""
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.text_label = QLabel()
self.text_label.setStyleSheet("font-size: 12px")
self.text_label.setWordWrap(True)
self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
self.text_label.setOpenExternalLinks(True)
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.addWidget(self.text_label)
@@ -8,7 +8,7 @@ from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVB
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, inset_container_style
class EntryDataBoxListView(QHBoxLayout):
class TileListView(QHBoxLayout):
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
@@ -19,11 +19,11 @@ class EntryDataBoxListView(QHBoxLayout):
self.scroll_layout.setSpacing(PAD)
scroll_container = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setObjectName("tile_scroll_container")
scroll_container.setLayout(self.scroll_layout)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("entryScrollArea")
self.scroll_area.setObjectName("tile_scroll_area")
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
@@ -34,7 +34,7 @@ class EntryDataBoxListView(QHBoxLayout):
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
self.scroll_area.setStyleSheet(inset_container_style("tile_scroll_container"))
self.scroll_area.setWidget(scroll_container)
self.addWidget(self.scroll_area)
@@ -17,7 +17,9 @@ _ICON_MARGIN = 4
_ICON_SIZE = _BUTTON_SIZE - _ICON_MARGIN
class EntryDataBoxView(QVBoxLayout):
class TileView(QVBoxLayout):
"""The layout used for a Tile widget."""
_rm = ResourceManager()
copy_icon = auto_theme_overlay(_rm.copy, inverse=True)
edit_icon = auto_theme_overlay(_rm.edit, inverse=True)
@@ -21,7 +21,7 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
for container in panel.containers._data_boxes:
for container in panel.containers._tiles:
assert container.isHidden()
@@ -33,7 +33,7 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should show all applicable tags and field containers
for container in panel.containers._data_boxes:
for container in panel.containers._tiles:
assert not container.isHidden()
@@ -48,7 +48,7 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should show mixed field editing
for container in panel.containers._data_boxes:
for container in panel.containers._tiles:
assert container.isHidden()
@@ -137,8 +137,8 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._data_boxes) == 3
for i, container in enumerate(panel.containers._data_boxes):
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -170,8 +170,8 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._data_boxes) == 3
for i, container in enumerate(panel.containers._data_boxes):
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -206,5 +206,5 @@ def test_exclude_tag_category(
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
assert len(panel.containers._data_boxes) == 1
assert panel.containers._data_boxes[0].title == "<h4>Tags</h4>"
assert len(panel.containers._tiles) == 1
assert panel.containers._tiles[0].title == "<h4>Tags</h4>"