Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fdc01b7
feat(ui): replace add tag modal with autocomplete search/create bar
CyanVoxel Jul 18, 2026
34d00d7
fix(ui): restore esc to deselect entries behavior
CyanVoxel Jul 18, 2026
f2cecb2
refactor: code cleanup and fixes
CyanVoxel Jul 18, 2026
cb3164a
refactor: RADICAL mvc refactor...
CyanVoxel Jul 18, 2026
669b95d
feat: add setting to open edit window when creating tags
CyanVoxel Jul 18, 2026
d98beaf
feat(ui): add edit tag setting to context menu, tweak translations
CyanVoxel Jul 18, 2026
210b469
chore: remove unused TYPE_CHECKING imports
CyanVoxel Jul 18, 2026
55a057f
chore: remove unused local import
CyanVoxel Jul 18, 2026
f7b86eb
refactor(ui): refactor preview panel into new MVC pattern
CyanVoxel Jul 18, 2026
c8ba9b1
refactor: remove controller suffix from preview_panel.py
CyanVoxel Jul 18, 2026
f29f691
refactor: remove unnecessary methods and properties
CyanVoxel Jul 19, 2026
b83216b
Revert "refactor: remove controller suffix from preview_panel.py"
CyanVoxel Jul 19, 2026
37f8eea
chore: cleanup comments
CyanVoxel Jul 19, 2026
8e3c0fb
feat(ui): add autocomplete search for field templates
CyanVoxel Jul 19, 2026
c488e53
feat(ui): add shortcut for opening field template search bar
CyanVoxel Jul 19, 2026
e485167
fix(ui): update tag search bar when entry tags are updated
CyanVoxel Jul 19, 2026
4aa7b9e
fix(ui): fix issues with tag and field widget appearances
CyanVoxel Jul 19, 2026
111ea76
refactor(ui): use Qt's .layout() method instead of _layout references
CyanVoxel Jul 19, 2026
0fbcfdb
feat(ui): add underline indicator for search bar items
CyanVoxel Jul 19, 2026
a0bf679
ui: misc UI fixes and tweaks
CyanVoxel Jul 19, 2026
99aad21
refactor: remove view parameters
CyanVoxel Jul 21, 2026
fd81304
docs: update docstrings
CyanVoxel Jul 22, 2026
e4d2229
refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, Mod…
CyanVoxel Jul 20, 2026
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
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in

## Creating Tags

Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>N</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.

- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
Expand Down
61 changes: 61 additions & 0 deletions src/tagstudio/qt/controllers/autofill_line_edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only

from typing import override

import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QLineEdit,
QWidget,
)

from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_scroll_top_focus_style,
autofill_scroll_top_style,
)

logger = structlog.get_logger(__name__)


class AutofillLineEdit(QLineEdit):
return_pressed = Signal()
shift_return_pressed = Signal()
shift_holding = Signal(bool)

def __init__(self, popup: QWidget) -> None:
super().__init__()
self._popup = popup

@override
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
return super().focusOutEvent(arg__1)

@override
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
return super().focusInEvent(arg__1)

@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(True) # noqa: FBT003

if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()

return super().keyPressEvent(arg__1)

@override
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(False) # noqa: FBT003
return super().keyReleaseEvent(arg__1)
4 changes: 2 additions & 2 deletions src/tagstudio/qt/controllers/edit_field_template_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ def __on_name_changed(self):

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)
if self.save_button is not None:
self.save_button.setDisabled(is_empty)

def __on_type_changed(self, index: int):
old_type = self.__field_type
Expand Down
156 changes: 156 additions & 0 deletions src/tagstudio/qt/controllers/field_suggest_box.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only


import typing
from typing import override
from warnings import catch_warnings

import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QWidget

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.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.translations import Translations

if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

logger = structlog.get_logger(__name__)


class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
def __init__(self, driver: "QtDriver", placeholder_text: str = ""):
super().__init__(driver, placeholder_text)
self._lib = self._driver.lib

# Context Menu Actions
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
edit_field_on_add_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_field_on_add_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_field_on_add_action)
edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add)
edit_field_on_add_action.triggered.connect(
lambda checked: self.toggle_edit_on_field_add(checked)
)

def toggle_edit_on_field_add(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after adding a field."""
self._driver.settings.edit_field_on_add = checked
self._driver.settings.save()

@override
def _on_item_create(self) -> None:
"""Creates a new field template and adds it to the currently selected entries.

Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
# since the user needs to decide what type of field it should be before it's created.
query: str = self.layout().search_field.text()
panel = EditFieldTemplateModal()
modal = Modal(
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_from_modal(panel))
modal.show()

@override
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True)

modal.saved.connect(lambda: self._edit_item(panel))
modal.show()

@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.item_chosen.emit(item)
self.done.emit()

@override
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
if query != "":
return self._lib.search_field_templates(name=query, limit=0), []
else:
return ([], [])

@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."""
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
field_template_widget = underlined_widget.widget
assert isinstance(field_template_widget, FieldTemplateWidget)
field_template_widget.has_remove = False
field_template_widget.set_field_template(item)
underlined_widget.setHidden(item is None)

if item is None:
return

# TODO: Add tabbing to different items, and use underline to indicate which will be added
underlined_widget.toggle_underline(index != 0)

# Disconnect previous callbacks
with catch_warnings(record=True):
field_template_widget.on_edit.disconnect()
field_template_widget.on_remove.disconnect()
field_template_widget.on_click.disconnect()

# Connect callbacks
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
field_template_widget.on_click.connect(
lambda checked=False, item_=item: self._on_item_chosen(item_)
)

@override
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
self._lib.add_field_template(template)
self._on_item_chosen(template)
self._clear_search_query()

edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())

@override
def _edit_item(self, edit_item_panel: ModalContent) -> None:
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.layout().search_field.text())

@override
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().content_layout.count() <= index:
while self.layout().content_layout.count() <= index:
field_template_widget = FieldTemplateWidget()
widget = UnderlinedWidget(field_template_widget)
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)

widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(widget_, UnderlinedWidget)
return widget_
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
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.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget

logger = structlog.get_logger(__name__)


class FieldTemplateSearchModal(PanelModal):
class FieldTemplateSearchModal(Modal):
def __init__(
self,
library: Library,
Expand All @@ -33,11 +34,7 @@ def __init__(
is_field_template_chooser,
view=FieldTemplateSearchPanelView(is_field_template_chooser),
)
super().__init__(
self.search_panel,
Translations["field.add.plural"],
is_savable=has_save,
)
super().__init__(self.search_panel, Translations["field.add.plural"], is_savable=has_save)


class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
Expand Down Expand Up @@ -76,7 +73,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None:
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)

panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = PanelModal(
modal: Modal = Modal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
Expand All @@ -93,7 +90,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None:
def on_item_edit(self, item: BaseFieldTemplate) -> None:

panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: PanelModal = PanelModal(
modal: Modal = Modal(
panel,
item.name,
Translations["field_template.edit"],
Expand Down Expand Up @@ -157,7 +154,7 @@ def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
)

@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:

if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
Expand All @@ -171,7 +168,7 @@ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -
self.on_search_query_changed(self.get_search_query())

@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
def edit_item(self, edit_item_panel: ModalContent) -> None:
if not isinstance(edit_item_panel, EditFieldTemplateModal):
return

Expand Down
85 changes: 85 additions & 0 deletions src/tagstudio/qt/controllers/modal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only


import contextlib
from typing import Any, override

import structlog
from PySide6 import QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget

from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.modal_view import ModalView

logger = structlog.get_logger(__name__)


class Modal(QWidget):
"""A generic modal window widget with common signals and styling."""

done = Signal()
saved = Signal()
saved_data = Signal(type(Any))

def __init__(
self,
content_widget: ModalContent,
title: str = "",
window_title: str | None = None,
is_savable: bool = False,
inline_title: bool = True,
):
super().__init__()
self.setWindowTitle(title if window_title is None else window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setLayout(
ModalView(
content_widget=content_widget,
title=title,
is_savable=is_savable,
inline_title=inline_title,
)
)

# [Done]
# - OR -
# [Cancel] [Save]
if not is_savable:
done_button = self.layout().content_widget.done_button
if done_button:
done_button.clicked.connect(self.hide)
done_button.clicked.connect(self.done.emit)
else:
cancel_button = self.layout().content_widget.cancel_button
if cancel_button:
cancel_button.clicked.connect(self.hide)
cancel_button.clicked.connect(content_widget.reset)

save_button = self.layout().content_widget.save_button
if save_button:
save_button.clicked.connect(self.hide)
save_button.clicked.connect(self.saved.emit)
save_button.clicked.connect(
lambda: self.saved_data.emit(content_widget.saved_data())
)

content_widget.parent_post_init()

@override
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
with contextlib.suppress(AttributeError):
cancel_button = self.layout().content_widget.cancel_button
if cancel_button:
cancel_button.click()
with contextlib.suppress(AttributeError):
done_button = self.layout().content_widget.done_button
if done_button:
done_button.click()
event.accept()

@override
def layout(self) -> ModalView:
"""Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
Loading
Loading