refactor(ui): recycle tag list in TagSearchPanel (#788)

* feat(ui): recycle tag list in `TagSearchPanel`

* chore: address mypy warnings

* fix: order results from sql before limiting

* fix(ui): check for self.exclude before remaking sets

* fix(ui): only init tag manager and file ext manager once

* fix(ui:): remove redundant tag search panel updates

* update code comments and docstrings

* feat(ui): add tag view limit dropdown

* ensure disconnection of file_extension_panel.saved
This commit is contained in:
Travis Abendshien
2025-02-05 19:15:28 -08:00
committed by GitHub
parent 26d3b1908b
commit 466af1e6a6
7 changed files with 261 additions and 149 deletions

View File

@@ -212,6 +212,7 @@
"tag.add.plural": "Add Tags",
"tag.add": "Add Tag",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
@@ -228,6 +229,7 @@
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",

View File

@@ -765,16 +765,16 @@ class Library:
return res
def search_tags(self, name: str | None) -> list[set[Tag]]:
def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]:
"""Return a list of Tag records matching the query."""
tag_limit = 100
with Session(self.engine) as session:
query = select(Tag).outerjoin(TagAlias)
query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name))
query = query.options(
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
).limit(tag_limit)
)
if limit > 0:
query = query.limit(limit)
if name:
query = query.where(
@@ -806,6 +806,7 @@ class Library:
logger.info(
"searching tags",
search=name,
limit=limit,
statement=str(query),
results=len(res),
)

View File

@@ -18,19 +18,19 @@ logger = structlog.get_logger(__name__)
# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
# will most likely be enabled in every case
# and the possibilty of disabling it can therefore be removed
# and the possibility of disabling it can therefore be removed
class TagDatabasePanel(TagSearchPanel):
def __init__(self, library: Library):
def __init__(self, driver, library: Library):
super().__init__(library, is_tag_chooser=False)
self.driver = driver
self.create_tag_button = QPushButton()
Translations.translate_qobject(self.create_tag_button, "tag.create")
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
self.root_layout.addWidget(self.create_tag_button)
self.update_tags()
def build_tag(self, name: str):
panel = BuildTagPanel(self.lib)
@@ -39,7 +39,7 @@ class TagDatabasePanel(TagSearchPanel):
has_save=True,
)
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.new")
if name.strip():
panel.name_field.setText(name)

View File

@@ -3,7 +3,9 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import contextlib
import typing
from warnings import catch_warnings
import src.qt.modals.build_tag as build_tag
import structlog
@@ -11,8 +13,10 @@ from PySide6 import QtCore, QtGui
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
@@ -21,7 +25,7 @@ from PySide6.QtWidgets import (
)
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from src.core.library import Library, Tag
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.enums import FilterState, TagColorEnum
from src.core.palette import ColorType, get_tag_color
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelModal, PanelWidget
@@ -44,6 +48,11 @@ class TagSearchPanel(PanelWidget):
is_tag_chooser: bool
exclude: list[int]
_limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]]
_default_limit_idx: int = 0 # 50 Tag Limit (Default)
cur_limit_idx: int = _default_limit_idx
tag_limit: int | str = _limit_items[_default_limit_idx]
def __init__(
self,
library: Library,
@@ -52,14 +61,37 @@ class TagSearchPanel(PanelWidget):
):
super().__init__()
self.lib = library
self.driver = None
self.exclude = exclude or []
self.is_tag_chooser = is_tag_chooser
self.create_button_in_layout: bool = False
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.addStretch(1)
self.limit_title = QLabel()
Translations.translate_qobject(self.limit_title, "tag.view_limit")
self.limit_layout.addWidget(self.limit_title)
self.limit_combobox = QComboBox()
self.limit_combobox.setEditable(False)
self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
self.limit_combobox.currentIndexChanged.connect(self.update_limit)
self.previous_limit: int = (
TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
)
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)
self.search_field = QLineEdit()
self.search_field.setObjectName("searchField")
self.search_field.setMinimumSize(QSize(0, 32))
@@ -79,53 +111,19 @@ class TagSearchPanel(PanelWidget):
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.root_layout.addWidget(self.limit_container)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
def __build_tag_widget(self, tag: Tag):
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=True,
has_remove=has_remove_button,
)
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
# NOTE: A solution to this would be to pass the driver to TagSearchPanel, however that
# creates an exponential amount of work trying to fix the preexisting tests.
# tag_widget.search_for_tag_action.triggered.connect(
# lambda checked=False, tag_id=tag.id: (
# self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
# self.driver.filter_items(FilterState.from_tag_id(tag_id)),
# )
# )
tag_id = tag.id
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
return tag_widget
def build_create_tag_button(self, query: str | None):
"""Constructs a Create Tag Button."""
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def set_driver(self, driver):
"""Set the QtDriver for this search panel. Used for main window operations."""
self.driver = driver
def build_create_button(self, query: str | None):
"""Constructs a "Create & Add Tag" QPushButton."""
create_button = QPushButton(self)
Translations.translate_qobject(create_button, "tag.create_add", query=query)
create_button.setFlat(True)
inner_layout = QHBoxLayout()
inner_layout.setObjectName("innerLayout")
inner_layout.setContentsMargins(2, 2, 2, 2)
create_button.setLayout(inner_layout)
create_button.setMinimumSize(22, 22)
create_button.setStyleSheet(
@@ -156,10 +154,7 @@ class TagSearchPanel(PanelWidget):
f"}}"
)
create_button.clicked.connect(lambda: self.create_and_add_tag(query))
row.addWidget(create_button)
return container
return create_button
def create_and_add_tag(self, name: str):
"""Opens "Create Tag" panel to create and add a new tag with given name."""
@@ -188,26 +183,34 @@ class TagSearchPanel(PanelWidget):
self.build_tag_modal.name_field.setText(name)
self.add_tag_modal.saved.connect(on_tag_modal_saved)
self.add_tag_modal.save_button.setFocus()
self.add_tag_modal.show()
def update_tags(self, query: str | None = None):
logger.info("[Tag Search Super Class] Updating Tags")
"""Update the tag list given a search query."""
logger.info("[TagSearchPanel] Updating Tags")
# TODO: Look at recycling rather than deleting and re-initializing
while self.scroll_layout.count():
self.scroll_layout.takeAt(0).widget().deleteLater()
# Remove the "Create & Add" button if one exists
create_button: QPushButton | None = None
if self.create_button_in_layout and self.scroll_layout.count():
create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore
create_button.deleteLater()
self.create_button_in_layout = False
# Get results for the search query
query_lower = "" if not query else query.lower()
tag_results: list[set[Tag]] = self.lib.search_tags(name=query)
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
# Only use the tag limit if it's an actual number (aka not "All Tags")
tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit)
if self.exclude:
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
# Sort and prioritize the results
results_0 = list(tag_results[0])
results_0.sort(key=lambda tag: tag.name.lower())
results_1 = list(tag_results[1])
results_1.sort(key=lambda tag: tag.name.lower())
raw_results = list(results_0 + results_1)[:100]
raw_results = list(results_0 + results_1)
priority_results: set[Tag] = set()
all_results: list[Tag] = []
@@ -219,18 +222,99 @@ class TagSearchPanel(PanelWidget):
all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [
r for r in raw_results if r not in priority_results
]
if tag_limit > 0:
all_results = all_results[:tag_limit]
if all_results:
self.first_tag_id = None
self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
for tag in all_results:
self.scroll_layout.addWidget(self.__build_tag_widget(tag))
else:
self.first_tag_id = None
# Update every tag widget with the new search result data
norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags)
norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags)
range_limit = max(norm_previous, norm_limit)
for i in range(0, range_limit):
tag = None
with contextlib.suppress(IndexError):
tag = all_results[i]
self.set_tag_widget(tag=tag, index=i)
self.previous_limit = tag_limit
# Add back the "Create & Add" button
if query and query.strip():
c = self.build_create_tag_button(query)
self.scroll_layout.addWidget(c)
cb: QPushButton = self.build_create_button(query)
with catch_warnings(record=True):
cb.clicked.disconnect()
cb.clicked.connect(lambda: self.create_and_add_tag(query or ""))
Translations.translate_qobject(cb, "tag.create_add", query=query)
self.scroll_layout.addWidget(cb)
self.create_button_in_layout = True
def set_tag_widget(self, tag: Tag | None, index: int):
"""Set the tag of a tag widget at a specific index."""
# Create any new tag widgets needed up to the given index
if self.scroll_layout.count() <= index:
while self.scroll_layout.count() <= index:
new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib)
new_tw.setHidden(True)
self.scroll_layout.addWidget(new_tw)
# Assign the tag to the widget at the given index.
tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore
tag_widget.set_tag(tag)
# Set tag widget viability and potentially return early
tag_widget.setHidden(bool(not tag))
if not tag:
return
# Configure any other aspects of the tag widget
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
tag_widget.has_remove = has_remove_button
with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.on_remove.disconnect()
tag_widget.bg_button.clicked.disconnect()
tag_id = tag.id
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
if self.driver:
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=tag.id: (
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
tag_widget.search_for_tag_action.setEnabled(False)
def update_limit(self, index: int):
logger.info("[TagSearchPanel] Updating tag limit")
TagSearchPanel.cur_limit_idx = index
if index < len(self._limit_items) - 1:
TagSearchPanel.tag_limit = int(self._limit_items[index])
else:
TagSearchPanel.tag_limit = -1
# Method was called outside the limit_combobox callback
if index != self.limit_combobox.currentIndex():
self.limit_combobox.setCurrentIndex(index)
if self.previous_limit == TagSearchPanel.tag_limit:
return
self.update_tags(self.search_field.text())
def on_return(self, text: str):
if text:
@@ -246,7 +330,9 @@ class TagSearchPanel(PanelWidget):
self.parentWidget().hide()
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_limit(TagSearchPanel.cur_limit_idx)
self.update_tags()
self.scroll_area.verticalScrollBar().setValue(0)
self.search_field.setText("")
self.search_field.setFocus()
return super().showEvent(event)

View File

@@ -16,6 +16,7 @@ import sys
import time
from pathlib import Path
from queue import Queue
from warnings import catch_warnings
# this import has side-effect of import PySide resources
import src.qt.resources_rc # noqa: F401
@@ -136,6 +137,8 @@ class QtDriver(DriverMixin, QObject):
SIGTERM = Signal()
preview_panel: PreviewPanel
tag_manager_panel: PanelModal
file_extension_panel: PanelModal | None = None
tag_search_panel: TagSearchPanel
add_tag_modal: PanelModal
@@ -291,8 +294,20 @@ class QtDriver(DriverMixin, QObject):
icon.addFile(str(icon_path))
app.setWindowIcon(icon)
# Initialize the main window's tag search panel
# Initialize the Tag Manager panel
self.tag_manager_panel = PanelModal(
widget=TagDatabasePanel(self, self.lib),
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
has_save=False,
)
Translations.translate_with_setter(self.tag_manager_panel.setTitle, "tag_manager.title")
Translations.translate_with_setter(
self.tag_manager_panel.setWindowTitle, "tag_manager.title"
)
# Initialize the Tag Search panel
self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True)
self.tag_search_panel.set_driver(self)
self.add_tag_modal = PanelModal(
widget=self.tag_search_panel,
title=Translations.translate_formatted("tag.add.plural"),
@@ -487,13 +502,12 @@ class QtDriver(DriverMixin, QObject):
Translations.translate_qobject(
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
)
self.manage_file_ext_action.triggered.connect(self.show_file_extension_modal)
edit_menu.addAction(self.manage_file_ext_action)
self.manage_file_ext_action.setEnabled(False)
self.tag_manager_action = QAction(menu_bar)
Translations.translate_qobject(self.tag_manager_action, "menu.edit.manage_tags")
self.tag_manager_action.triggered.connect(lambda: self.show_tag_manager())
self.tag_manager_action.triggered.connect(self.tag_manager_panel.show)
self.tag_manager_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
@@ -750,6 +764,27 @@ class QtDriver(DriverMixin, QObject):
self.splash.finish(self.main_window)
def init_file_extension_manager(self):
"""Initialize the File Extension panel."""
if self.file_extension_panel:
with catch_warnings(record=True):
self.manage_file_ext_action.triggered.disconnect()
self.file_extension_panel.saved.disconnect()
self.file_extension_panel.deleteLater()
self.file_extension_panel = None
panel = FileExtensionModal(self.lib)
self.file_extension_panel = PanelModal(
panel,
has_save=True,
)
Translations.translate_with_setter(self.file_extension_panel.setTitle, "ignore_list.title")
Translations.translate_with_setter(
self.file_extension_panel.setWindowTitle, "ignore_list.title"
)
self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items()))
self.manage_file_ext_action.triggered.connect(self.file_extension_panel.show)
def show_grid_filenames(self, value: bool):
for thumb in self.item_thumbs:
thumb.set_filename_visibility(value)
@@ -902,28 +937,6 @@ class QtDriver(DriverMixin, QObject):
for entry_id in self.selected:
self.lib.add_tags_to_entry(entry_id, tag_ids)
def show_tag_manager(self):
self.modal = PanelModal(
widget=TagDatabasePanel(self.lib),
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
has_save=False,
)
Translations.translate_with_setter(self.modal.setTitle, "tag_manager.title")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag_manager.title")
self.modal.show()
def show_file_extension_modal(self):
panel = FileExtensionModal(self.lib)
self.modal = PanelModal(
panel,
has_save=True,
)
Translations.translate_with_setter(self.modal.setTitle, "ignore_list.title")
Translations.translate_with_setter(self.modal.setWindowTitle, "ignore_list.title")
self.modal.saved.connect(lambda: (panel.save(), self.filter_items()))
self.modal.show()
def add_new_files_callback(self):
"""Run when user initiates adding new files to the Library."""
tracker = RefreshDirTracker(self.lib)
@@ -1668,6 +1681,8 @@ class QtDriver(DriverMixin, QObject):
)
self.main_window.setAcceptDrops(True)
self.init_file_extension_manager()
self.selected.clear()
self.set_select_actions_visibility()
self.save_library_backup_action.setEnabled(True)

View File

@@ -211,9 +211,4 @@ class PreviewPanel(QWidget):
)
)
self.add_tag_button.clicked.connect(
lambda: (
self.tag_search_panel.update_tags(),
self.add_tag_modal.show(),
)
)
self.add_tag_button.clicked.connect(self.add_tag_modal.show)

View File

@@ -105,7 +105,7 @@ class TagWidget(QWidget):
def __init__(
self,
tag: Tag,
tag: Tag | None,
has_edit: bool,
has_remove: bool,
library: "Library | None" = None,
@@ -127,10 +127,7 @@ class TagWidget(QWidget):
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if self.lib:
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
else:
self.bg_button.setText(escape_text(tag.name))
if has_edit:
edit_action = QAction(self)
edit_action.setText(Translations.translate_formatted("generic.edit"))
@@ -153,9 +150,38 @@ 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.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
self.set_tag(tag)
def set_tag(self, tag: Tag | None) -> None:
self.tag = tag
if not tag:
return
primary_color = get_primary_color(tag)
border_color = (
get_border_color(primary_color)
@@ -200,55 +226,42 @@ class TagWidget(QWidget):
f"outline:none;"
f"}}"
)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
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"}}"
)
if has_remove:
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
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.remove_button.setMinimumSize(22, 22)
self.remove_button.setMaximumSize(22, 22)
self.remove_button.clicked.connect(self.on_remove.emit)
if self.lib:
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
else:
self.bg_button.setText(escape_text(tag.name))
if has_remove:
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
if self.has_remove: