Skip to content

Commit dbf7353

Browse files
authored
fix(ui): improve tagging ux (#784)
* fix(ui): always reset tag search panel when opened * feat: return parent tags in tag search Known issue: this bypasses the tag_limit * refactor: use consistant `datetime` imports * refactor: sort by base tag name to improve performance * fix: escape `&` when displaying tag names * ui: show "create and add" tag with other results * fix: optimize and fix tag result sorting * feat(ui): allow tags in list to be selected and added by keyboard * ui: use `esc` to reset search focus and/or close modal * fix(ui): add pressed+focus styling to "create tag" button * ui: use `esc` key to close `PanelWidget` * ui: move disambiguation button to right side * ui: expand clickable area of "-" tag button, improve styling * ui: add "Ctrl+M" shortcut to open tag manager * fix(ui): show "add tags" window title when accessing from home
1 parent 480328b commit dbf7353

File tree

11 files changed

+188
-120
lines changed

11 files changed

+188
-120
lines changed

tagstudio/src/core/library/alchemy/library.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
func,
3232
or_,
3333
select,
34+
text,
3435
update,
3536
)
3637
from sqlalchemy.exc import IntegrityError
@@ -70,6 +71,18 @@
7071

7172
logger = structlog.get_logger(__name__)
7273

74+
TAG_CHILDREN_QUERY = text("""
75+
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
76+
WITH RECURSIVE ChildTags AS (
77+
SELECT :tag_id AS child_id
78+
UNION ALL
79+
SELECT tp.parent_id AS child_id
80+
FROM tag_parents tp
81+
INNER JOIN ChildTags c ON tp.child_id = c.child_id
82+
)
83+
SELECT * FROM ChildTags;
84+
""") # noqa: E501
85+
7386

7487
def slugify(input_string: str) -> str:
7588
# Convert to lowercase and normalize unicode characters
@@ -752,10 +765,7 @@ def search_library(
752765

753766
return res
754767

755-
def search_tags(
756-
self,
757-
name: str | None,
758-
) -> list[Tag]:
768+
def search_tags(self, name: str | None) -> list[set[Tag]]:
759769
"""Return a list of Tag records matching the query."""
760770
tag_limit = 100
761771

@@ -775,8 +785,23 @@ def search_tags(
775785
)
776786
)
777787

778-
tags = session.scalars(query)
779-
res = list(set(tags))
788+
direct_tags = set(session.scalars(query))
789+
ancestor_tag_ids: list[Tag] = []
790+
for tag in direct_tags:
791+
ancestor_tag_ids.extend(
792+
list(session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag.id}))
793+
)
794+
795+
ancestor_tags = session.scalars(
796+
select(Tag)
797+
.where(Tag.id.in_(ancestor_tag_ids))
798+
.options(selectinload(Tag.parent_tags), selectinload(Tag.aliases))
799+
)
800+
801+
res = [
802+
direct_tags,
803+
{at for at in ancestor_tags if at not in direct_tags},
804+
]
780805

781806
logger.info(
782807
"searching tags",

tagstudio/src/core/library/alchemy/models.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Licensed under the GPL-3.0 License.
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
44

5-
import datetime as dt
5+
from datetime import datetime as dt
66
from pathlib import Path
77

88
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
@@ -185,9 +185,9 @@ class Entry(Base):
185185

186186
path: Mapped[Path] = mapped_column(PathType, unique=True)
187187
suffix: Mapped[str] = mapped_column()
188-
date_created: Mapped[dt.datetime | None]
189-
date_modified: Mapped[dt.datetime | None]
190-
date_added: Mapped[dt.datetime | None]
188+
date_created: Mapped[dt | None]
189+
date_modified: Mapped[dt | None]
190+
date_added: Mapped[dt | None]
191191

192192
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
193193

@@ -222,9 +222,9 @@ def __init__(
222222
folder: Folder,
223223
fields: list[BaseField],
224224
id: int | None = None,
225-
date_created: dt.datetime | None = None,
226-
date_modified: dt.datetime | None = None,
227-
date_added: dt.datetime | None = None,
225+
date_created: dt | None = None,
226+
date_modified: dt | None = None,
227+
date_added: dt | None = None,
228228
) -> None:
229229
self.path = path
230230
self.folder = folder

tagstudio/src/core/library/alchemy/visitors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
logger = structlog.get_logger(__name__)
2424

2525
# TODO: Reevaluate after subtags -> parent tags name change
26-
CHILDREN_QUERY = text("""
26+
TAG_CHILDREN_ID_QUERY = text("""
2727
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
2828
WITH RECURSIVE ChildTags AS (
2929
SELECT :tag_id AS child_id
@@ -151,7 +151,7 @@ def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[in
151151
return tag_ids
152152
outp = []
153153
for tag_id in tag_ids:
154-
outp.extend(list(session.scalars(CHILDREN_QUERY, {"tag_id": tag_id})))
154+
outp.extend(list(session.scalars(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id})))
155155
return outp
156156

157157
def __entry_has_all_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:

tagstudio/src/core/utils/refresh_dir.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import datetime as dt
21
from collections.abc import Iterator
32
from dataclasses import dataclass, field
3+
from datetime import datetime as dt
44
from pathlib import Path
55
from time import time
66

@@ -42,7 +42,7 @@ def save_new_files(self):
4242
path=entry_path,
4343
folder=self.library.folder,
4444
fields=[],
45-
date_added=dt.datetime.now(),
45+
date_added=dt.now(),
4646
)
4747
for entry_path in self.files_not_in_library
4848
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
2+
# Licensed under the GPL-3.0 License.
3+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
4+
5+
6+
def escape_text(text: str):
7+
"""Escapes characters that are problematic in Qt widgets."""
8+
return text.replace("&", "&&")

tagstudio/src/qt/modals/build_tag.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,16 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b
386386
else:
387387
text_color = get_text_color(primary_color, highlight_color)
388388

389+
# Add Tag Widget
390+
tag_widget = TagWidget(
391+
tag,
392+
library=self.lib,
393+
has_edit=False,
394+
has_remove=True,
395+
)
396+
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
397+
row.addWidget(tag_widget)
398+
389399
# Add Disambiguation Tag Button
390400
disam_button = QRadioButton()
391401
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
@@ -412,6 +422,15 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b
412422
f"QRadioButton::hover{{"
413423
f"border-color: rgba{highlight_color.toTuple()};"
414424
f"}}"
425+
f"QRadioButton::pressed{{"
426+
f"background: rgba{border_color.toTuple()};"
427+
f"color: rgba{primary_color.toTuple()};"
428+
f"border-color: rgba{primary_color.toTuple()};"
429+
f"}}"
430+
f"QRadioButton::focus{{"
431+
f"border-color: rgba{highlight_color.toTuple()};"
432+
f"outline:none;"
433+
f"}}"
415434
)
416435

417436
self.disam_button_group.addButton(disam_button)
@@ -421,18 +440,7 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b
421440
disam_button.clicked.connect(lambda checked=False: self.toggle_disam_id(parent_id))
422441
row.addWidget(disam_button)
423442

424-
# Add Tag Widget
425-
tag_widget = TagWidget(
426-
tag,
427-
library=self.lib,
428-
has_edit=False,
429-
has_remove=True,
430-
)
431-
432-
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
433-
row.addWidget(tag_widget)
434-
435-
return disam_button, tag_widget.bg_button, container
443+
return tag_widget.bg_button, disam_button, container
436444

437445
def toggle_disam_id(self, disambiguation_id: int | None):
438446
if self.disambiguation_id == disambiguation_id:

0 commit comments

Comments
 (0)