Skip to content

feat: add autocomplete for search engine #586

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,13 @@ def has_path_entry(self, path: Path) -> bool:
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()

def get_paths(self, glob: str | None = None) -> list[str]:
with Session(self.engine) as session:
paths = session.scalars(select(Entry.path)).unique()

path_strings: list[str] = list(map(lambda x: x.as_posix(), paths))
return path_strings

def search_library(
self,
search: FilterState,
Expand Down
9 changes: 7 additions & 2 deletions tagstudio/src/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@

import logging
import typing
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt)
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel)
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter, QCheckBox,
QSpacerItem)
QSpacerItem, QCompleter)
from src.qt.pagination import Pagination
from src.qt.widgets.landing import LandingWidget

Expand Down Expand Up @@ -167,6 +167,11 @@ def setupUi(self, MainWindow):
font2.setBold(False)
self.searchField.setFont(font2)

self.searchFieldCompletionList = QStringListModel()
self.searchFieldCompleter = QCompleter(self.searchFieldCompletionList, self.searchField)
self.searchFieldCompleter.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.searchField.setCompleter(self.searchFieldCompleter)

self.horizontalLayout_2.addWidget(self.searchField)

self.searchButton = QPushButton(self.centralwidget)
Expand Down
59 changes: 59 additions & 0 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import dataclasses
import math
import os
import re
import sys
import time
import webbrowser
Expand Down Expand Up @@ -72,6 +73,7 @@
)
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import LibraryStatus
from src.core.media_types import MediaCategories
from src.core.ts_core import TagStudioCore
from src.core.utils.refresh_dir import RefreshDirTracker
from src.core.utils.web import strip_web_protocol
Expand Down Expand Up @@ -445,6 +447,8 @@ def create_folders_tags_modal():
menu_bar.addMenu(window_menu)
menu_bar.addMenu(help_menu)

self.main_window.searchField.textChanged.connect(self.update_completions_list)

self.preview_panel = PreviewPanel(self.lib, self)
splitter = self.main_window.splitter
splitter.addWidget(self.preview_panel)
Expand Down Expand Up @@ -948,6 +952,61 @@ def select_item(self, grid_index: int, append: bool, bridge: bool):
def set_macro_menu_viability(self):
self.autofill_action.setDisabled(not self.selected)

def update_completions_list(self, text: str) -> None:
matches = re.search(r"(mediatype|filetype|path|tag):(\"?[A-Za-z0-9\ \t]+\"?)?", text)

completion_list: list[str] = []
if len(text) < 3:
completion_list = ["mediatype:", "filetype:", "path:", "tag:"]
self.main_window.searchFieldCompletionList.setStringList(completion_list)

if not matches:
return

query_type: str
query_value: str | None
query_type, query_value = matches.groups()

if not query_value:
return

if query_type == "tag":
completion_list = list(map(lambda x: "tag:" + x.name, self.lib.tags))
Comment on lines +973 to +974
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think using the tag's "display name" here might be better, as that would reduce confusion between identical tag names. It would also be nice if the tag name text was colored in the dropdown, but speaking from personal experience I don't know of a way to do that without creating a completely custom widget from scratch, so I don't expect that from you here.

For the future, I'm picturing the search bar as something a bit more abstracted from the internal search query. For tags, this could mean that the user would see a tag label widget (alongside other query text) in the search bar while internally the query can refer to it by the tag's ID, eliminating any confusion between tags with the same name.
Example:
jQuery-Tags-Input-Plugin-with-Autocomplete-Support-Mab-Tag-Input
This is a bigger undertaking than the scope of this PR though, and having the functionality here is already a great help over having no auto-completion at all.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potentially dumb question: what is a tag's display name? its' shorthand?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just realized that display names didn't make the jump from JSON... If #534 doesn't implement them then they'll need to be implemented in another new PR.

Essentially, they're a version of a Tag's named combined with their first parent tag in parenthesis (with preference to that parent tag's shorthand) to aide in telling tags with the same name apart. In 9.4 they were generated and grabbed from a method rather than being stored directly, which likely contributed to them going unnoticed during the migration so far.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tomorrow I'll look into getting the display names add in on #534, no point in having another PR just for that.

elif query_type == "path":
completion_list = list(map(lambda x: "path:" + x, self.lib.get_paths()))
elif query_type == "mediatype":
single_word_completions = map(
lambda x: "mediatype:" + x.name,
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
single_word_completions_quoted = map(
lambda x: 'mediatype:"' + x.name + '"',
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
multi_word_completions = map(
lambda x: 'mediatype:"' + x.name + '"',
filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES),
)

all_completions = [
single_word_completions,
single_word_completions_quoted,
multi_word_completions,
]
completion_list = [j for i in all_completions for j in i]
elif query_type == "filetype":
extensions_list: set[str] = set()
for media_cat in MediaCategories.ALL_CATEGORIES:
extensions_list = extensions_list | media_cat.extensions
completion_list = list(map(lambda x: "filetype:" + x.replace(".", ""), extensions_list))

update_completion_list: bool = (
completion_list != self.main_window.searchFieldCompletionList.stringList()
or self.main_window.searchFieldCompletionList == []
)
if update_completion_list:
self.main_window.searchFieldCompletionList.setStringList(completion_list)

def update_thumbs(self):
"""Update search thumbnails."""
# start_time = time.time()
Expand Down