mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-06-26 08:59:06 +00:00
feat: add field name editing on entries
This commit is contained in:
@@ -1540,7 +1540,12 @@ class Library:
|
||||
session.commit()
|
||||
|
||||
def update_text_field(
|
||||
self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: TextField,
|
||||
name: str,
|
||||
value: str,
|
||||
is_multiline: bool,
|
||||
):
|
||||
"""Update a TextField field on one or more Entries."""
|
||||
if isinstance(entry_ids, int):
|
||||
@@ -1552,7 +1557,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value, is_multiline=is_multiline)
|
||||
.values(name=name, value=value, is_multiline=is_multiline)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
@@ -1562,6 +1567,7 @@ class Library:
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: DatetimeField,
|
||||
name: str,
|
||||
value: datetime,
|
||||
):
|
||||
"""Update a DatetimeField field on one or more Entries."""
|
||||
@@ -1574,7 +1580,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value)
|
||||
.values(name=name, value=value)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
|
||||
67
src/tagstudio/qt/controllers/edit_text_controller.py
Normal file
67
src/tagstudio/qt/controllers/edit_text_controller.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.qt.views.edit_text_view import EditTextView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditText(EditTextView):
|
||||
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
|
||||
super().__init__()
|
||||
self.name_field.setText(name)
|
||||
|
||||
self.text = text
|
||||
self.is_multiline: bool = is_multiline
|
||||
|
||||
self.multiline_checkbox.setChecked(is_multiline)
|
||||
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
|
||||
|
||||
if self.is_multiline:
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
else:
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text or "")
|
||||
|
||||
def on_multiline_checked(self, checked: bool):
|
||||
was_multiline = self.is_multiline
|
||||
self.is_multiline = checked
|
||||
|
||||
if was_multiline:
|
||||
self.text = self.text_box.toPlainText()
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text)
|
||||
self.text_line.show()
|
||||
self.text_line_stretch.show()
|
||||
else:
|
||||
self.text = self.text_line.text()
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text)
|
||||
self.text_box.show()
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
if self.is_multiline:
|
||||
self.text_box.setFocus()
|
||||
else:
|
||||
self.text_line.setFocus()
|
||||
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str | bool]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
|
||||
"is_multiline": self.is_multiline,
|
||||
}
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
@@ -2,7 +2,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
@@ -27,8 +26,6 @@ class FieldTemplateSearchModal(PanelModal):
|
||||
self,
|
||||
library: Library,
|
||||
is_field_template_chooser: bool = True,
|
||||
done_callback: Callable[..., None] | None = None,
|
||||
save_callback: Callable[..., None] | None = None,
|
||||
has_save: bool = False,
|
||||
) -> None:
|
||||
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
|
||||
@@ -39,9 +36,7 @@ class FieldTemplateSearchModal(PanelModal):
|
||||
super().__init__(
|
||||
self.search_panel,
|
||||
Translations["field.add.plural"],
|
||||
done_callback=done_callback,
|
||||
save_callback=save_callback,
|
||||
has_save=has_save,
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +82,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
Translations["field_template.add"]
|
||||
if add_to_entry
|
||||
else Translations["field_template.new"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
@@ -104,7 +99,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
panel,
|
||||
item.name,
|
||||
Translations["field_template.edit"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
modal.saved.connect(lambda: self.edit_item(panel))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
@@ -77,20 +78,20 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
build_tag_panel,
|
||||
self.__driver.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
done_callback=self.on_update.emit,
|
||||
has_save=True,
|
||||
)
|
||||
# TODO - this was update_tag()
|
||||
edit_modal.saved.connect(
|
||||
lambda: self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
alias_names=set(build_tag_panel.alias_names),
|
||||
alias_ids=set(build_tag_panel.alias_ids),
|
||||
)
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
|
||||
self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
alias_names=set(build_tag_panel.alias_names),
|
||||
alias_ids=set(build_tag_panel.alias_ids),
|
||||
)
|
||||
self.on_update.emit()
|
||||
|
||||
@override
|
||||
def _on_search(self, tag: Tag) -> None:
|
||||
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
@@ -34,8 +33,6 @@ class TagSearchModal(PanelModal):
|
||||
library: Library,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
done_callback: Callable[..., None] | None = None,
|
||||
save_callback: Callable[..., None] | None = None,
|
||||
has_save: bool = False,
|
||||
):
|
||||
self.tsp = TagSearchPanel(
|
||||
@@ -47,9 +44,7 @@ class TagSearchModal(PanelModal):
|
||||
super().__init__(
|
||||
self.tsp,
|
||||
Translations["tag.add.plural"],
|
||||
done_callback=done_callback,
|
||||
save_callback=save_callback,
|
||||
has_save=has_save,
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
@@ -94,7 +89,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
@@ -113,7 +108,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
edit_tag_panel,
|
||||
self.__lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
|
||||
|
||||
@@ -32,6 +32,7 @@ from tagstudio.qt.mixed.tag_widget import (
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -135,37 +136,7 @@ class BuildColorPanel(PanelWidget):
|
||||
self.border_layout.addWidget(self.border_checkbox)
|
||||
self.border_label = QLabel(Translations["color.color_border"])
|
||||
self.border_layout.addWidget(self.border_label)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
self.border_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.border_checkbox.setStyleSheet(checkbox_style())
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.preview_widget)
|
||||
|
||||
@@ -25,7 +25,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
@@ -39,9 +38,10 @@ from tagstudio.qt.mixed.tag_widget import (
|
||||
get_primary_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -201,9 +201,9 @@ class BuildTagPanel(PanelWidget):
|
||||
self.tag_color_selection,
|
||||
chose_tag_color_title,
|
||||
chose_tag_color_title,
|
||||
done_callback=lambda: self.choose_color_callback(
|
||||
self.tag_color_selection.selected_color
|
||||
),
|
||||
)
|
||||
self.choose_color_modal.done.connect(
|
||||
lambda: self.choose_color_callback(self.tag_color_selection.selected_color)
|
||||
)
|
||||
self.color_button.button.clicked.connect(self.choose_color_modal.show)
|
||||
self.color_layout.addWidget(self.color_button)
|
||||
@@ -218,38 +218,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_title = QLabel(Translations["tag.is_category"])
|
||||
self.cat_checkbox = QCheckBox()
|
||||
self.cat_checkbox.setFixedSize(22, 22)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
|
||||
self.cat_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.cat_checkbox.setStyleSheet(checkbox_style())
|
||||
self.cat_layout.addWidget(self.cat_checkbox)
|
||||
self.cat_layout.addWidget(self.cat_title)
|
||||
|
||||
@@ -263,33 +232,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.hidden_title = QLabel(Translations["tag.is_hidden"])
|
||||
self.hidden_checkbox = QCheckBox()
|
||||
self.hidden_checkbox.setFixedSize(22, 22)
|
||||
|
||||
self.hidden_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.hidden_checkbox.setStyleSheet(checkbox_style())
|
||||
self.hidden_layout.addWidget(self.hidden_checkbox)
|
||||
self.hidden_layout.addWidget(self.hidden_title)
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class ColorBoxWidget(FieldWidget):
|
||||
self.edit_modal = PanelModal(
|
||||
build_color_panel,
|
||||
"Edit Color",
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.edit_modal.saved.connect(
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from typing import cast
|
||||
from typing import cast, override
|
||||
|
||||
from PySide6.QtCore import QDateTime
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
|
||||
@@ -40,11 +39,16 @@ def qdtf2dtf(dtf: str) -> str:
|
||||
|
||||
|
||||
class DatetimePicker(PanelWidget):
|
||||
def __init__(self, driver: "QtDriver", datetime: dt | str):
|
||||
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
|
||||
super().__init__()
|
||||
self.setMinimumSize(300, 60)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px")
|
||||
self.name_field.setText(name)
|
||||
|
||||
if isinstance(datetime, str):
|
||||
datetime = DatetimePicker.string2dt(datetime)
|
||||
self.datetime_edit = QDateTimeEdit()
|
||||
@@ -55,20 +59,24 @@ class DatetimePicker(PanelWidget):
|
||||
self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format))
|
||||
|
||||
self.initial_value = datetime
|
||||
self.root_layout.addWidget(self.name_field)
|
||||
self.root_layout.addWidget(self.datetime_edit)
|
||||
|
||||
def get_content(self):
|
||||
return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime()))
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())),
|
||||
}
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.datetime_edit.setFocus()
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value))
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
if event == "returnPressed":
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"unknown event type: {event}")
|
||||
|
||||
@staticmethod
|
||||
def qdt2dt(qdt: QDateTime) -> dt:
|
||||
return cast(dt, qdt.toPython())
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import sys
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from functools import partial
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -31,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.mixed.text_field import TextWidget
|
||||
from tagstudio.qt.mixed.text_field import TextContainerWidget
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
|
||||
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -47,7 +46,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainers(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
"""Widget for the tag and field containers displayed inside the Preview Panel."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
@@ -102,6 +101,11 @@ class FieldContainers(QWidget):
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.addWidget(self.scroll_area)
|
||||
|
||||
@property
|
||||
def top_entry_id(self) -> int:
|
||||
"""Get the topmost entry ID in the (cached) selected entries."""
|
||||
return self.cached_entries[0].id
|
||||
|
||||
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
|
||||
"""Update tags and fields from a single Entry source."""
|
||||
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
|
||||
@@ -130,7 +134,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
# Write field container(s)
|
||||
for index, field in enumerate(entry_fields, start=container_index):
|
||||
self.write_container(index, field, is_mixed=False)
|
||||
self.write_field_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
if len(self.containers) > container_len:
|
||||
@@ -247,22 +251,128 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
self.driver.add_tags_to_selected_callback(tag_ids)
|
||||
|
||||
def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a FieldContainer.
|
||||
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a field FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
field(BaseField): The type of field to write to.
|
||||
field(BaseField): The field to write in this container.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
|
||||
def update_text_field_callback(
|
||||
field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_text_container(
|
||||
container: FieldContainer, field: TextField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_text_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_datetime_container(
|
||||
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
if not is_mixed:
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
DatetimePicker(self.driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_datetime_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_unknown_container():
|
||||
container.set_title(field.name)
|
||||
inner_widget = TextContainerWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[FieldContainers][write_container]",
|
||||
index=index,
|
||||
name=field.name,
|
||||
type=field.class_name,
|
||||
)
|
||||
|
||||
# Create new containers if necessary
|
||||
if len(self.containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
@@ -274,156 +384,27 @@ class FieldContainers(QWidget):
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
title = f"{field.name} ({Translations[field_name_key]})"
|
||||
|
||||
# Single-line Text
|
||||
if type(field) is TextField and not field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = field.value or ""
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextLine(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=False),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
if "pytest" in sys.modules:
|
||||
# for better testability
|
||||
container.modal = modal # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Multiline Text
|
||||
elif type(field) is TextField and field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextBox(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=True),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Write containers
|
||||
if type(field) is TextField:
|
||||
write_text_container(container, field, title, is_mixed)
|
||||
elif type(field) is DatetimeField:
|
||||
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
|
||||
if not is_mixed:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
modal = PanelModal(
|
||||
DatetimePicker(self.driver, field.value or dt.now()),
|
||||
title=f"Edit {field.name}",
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_datetime_field(field, content),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
write_datetime_container(container, field, title, is_mixed)
|
||||
else:
|
||||
logger.warning(
|
||||
"[FieldContainers][write_container] Unknown Field", field=field
|
||||
) # TODO: Localize this
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
inner_widget = TextWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
write_unknown_container()
|
||||
|
||||
container.setHidden(False)
|
||||
|
||||
def write_tag_container(
|
||||
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
|
||||
) -> None:
|
||||
"""Update/Create tag data for a FieldContainer.
|
||||
"""Update/Create tag data for a tag FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
tags(set[Tag]): The list of tags for this container.
|
||||
category_tag(Tag|None): The category tag this container represents.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
@@ -433,10 +414,7 @@ class FieldContainers(QWidget):
|
||||
else:
|
||||
container = self.containers[index]
|
||||
|
||||
container.set_title(
|
||||
"Tags" if not category_tag else category_tag.name
|
||||
) # TODO: Localize this
|
||||
container.set_inline(False)
|
||||
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
|
||||
|
||||
if not is_mixed:
|
||||
inner_widget = container.get_inner_widget()
|
||||
@@ -446,10 +424,7 @@ class FieldContainers(QWidget):
|
||||
inner_widget.on_update.disconnect()
|
||||
|
||||
else:
|
||||
inner_widget = TagBoxWidget(
|
||||
"Tags", # TODO: Localize this
|
||||
self.driver,
|
||||
)
|
||||
inner_widget = TagBoxWidget(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)
|
||||
@@ -458,15 +433,15 @@ class FieldContainers(QWidget):
|
||||
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
inner_widget = TextWidget("Mixed Tags", text)
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
inner_widget = TextContainerWidget("Mixed Tags", text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
container.set_edit_callback()
|
||||
container.set_remove_callback()
|
||||
container.setHidden(False)
|
||||
|
||||
def remove_field(self, field: BaseField) -> None:
|
||||
def _remove_field(self, field: BaseField) -> None:
|
||||
"""Remove a field from all selected Entries."""
|
||||
logger.info(
|
||||
"[FieldContainers] Removing Field",
|
||||
@@ -476,24 +451,26 @@ class FieldContainers(QWidget):
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
self.lib.remove_entry_field(field, entry_ids)
|
||||
|
||||
def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None:
|
||||
def _update_text_field(
|
||||
self, field: TextField, name: str, value: str, is_multiline: bool
|
||||
) -> None:
|
||||
"""Update a text field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_text_field(entry_ids, field, value, is_multiline)
|
||||
self.lib.update_text_field(entry_ids, field, name, value, is_multiline)
|
||||
|
||||
def update_datetime_field(self, field: DatetimeField, value: str) -> None:
|
||||
def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None:
|
||||
"""Update a datetime field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
|
||||
self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value))
|
||||
|
||||
def remove_message_box(self, prompt: str, callback: Callable) -> None:
|
||||
def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None:
|
||||
remove_mb = QMessageBox()
|
||||
remove_mb.setText(prompt)
|
||||
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
|
||||
remove_mb.setWindowTitle(Translations["Remove Field"])
|
||||
remove_mb.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = remove_mb.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
|
||||
@@ -53,7 +53,6 @@ class FieldContainer(QWidget):
|
||||
super().__init__()
|
||||
self.setObjectName("fieldContainer")
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
@@ -170,9 +169,6 @@ class FieldContainer(QWidget):
|
||||
self.title = self.title = f"<h4>{title}</h4>"
|
||||
self.title_widget.setText(self.title)
|
||||
|
||||
def set_inline(self, inline: bool) -> None:
|
||||
self.inline = inline
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
|
||||
@@ -356,9 +356,9 @@ class SettingsPanel(PanelWidget):
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
window_title=Translations["settings.title"],
|
||||
done_callback=lambda: settings_panel.update_settings(driver),
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
modal.done.connect(lambda: settings_panel.update_settings(driver))
|
||||
modal.title_widget.setVisible(False)
|
||||
|
||||
return modal
|
||||
|
||||
@@ -176,7 +176,7 @@ class TagColorManager(QWidget):
|
||||
self.create_namespace_modal = PanelModal(
|
||||
build_namespace_panel,
|
||||
Translations["namespace.create.title"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.create_namespace_modal.saved.connect(
|
||||
|
||||
@@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel
|
||||
from tagstudio.qt.mixed.field_widget import FieldWidget
|
||||
|
||||
|
||||
class TextWidget(FieldWidget):
|
||||
def __init__(self, title, text: str) -> None:
|
||||
class TextContainerWidget(FieldWidget):
|
||||
def __init__(self, title: str, text: str) -> None:
|
||||
super().__init__(title)
|
||||
self.setObjectName("textBox")
|
||||
self.base_layout = QHBoxLayout()
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import TypeVar
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from humanfriendly import format_size, format_timespan
|
||||
from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType]
|
||||
from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
@@ -45,7 +45,8 @@ from PySide6.QtWidgets import (
|
||||
QScrollArea,
|
||||
)
|
||||
|
||||
import tagstudio.qt.resources_rc # noqa: F401
|
||||
# This import has side-effect of importing PySide resources
|
||||
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
|
||||
@@ -64,11 +65,7 @@ from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.cache_manager import CacheManager
|
||||
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import (
|
||||
FieldTemplateSearchPanel,
|
||||
)
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
|
||||
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
|
||||
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
|
||||
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
|
||||
@@ -376,10 +373,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
view=TagSearchPanelView(is_tag_chooser=False),
|
||||
),
|
||||
title=Translations["tag_manager.title"],
|
||||
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
is_savable=False,
|
||||
)
|
||||
self.tag_manager_panel.done.connect(
|
||||
lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
self.selected, update_preview=False
|
||||
),
|
||||
has_save=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize the Color Group Manager panel
|
||||
@@ -393,10 +392,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
|
||||
),
|
||||
title=Translations["field_template_manager.title"],
|
||||
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
is_savable=False,
|
||||
)
|
||||
self.field_template_manager_panel.done.connect(
|
||||
lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
self.selected, update_preview=False
|
||||
),
|
||||
has_save=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize the Tag Search panel
|
||||
@@ -741,7 +742,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.ignore_modal = PanelModal(
|
||||
panel,
|
||||
Translations["menu.edit.ignore_files"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
self.ignore_modal.saved.connect(panel.save)
|
||||
self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show)
|
||||
@@ -880,7 +881,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.modal.saved.connect(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from typing import Any, override
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
|
||||
super().__init__(*args, **kwarg)
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent):
|
||||
|
||||
@@ -29,31 +29,31 @@ class EditFieldTemplateModalView(PanelWidget):
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Field Name -------------------------------------------------------------------------------
|
||||
self.__name_widget = QWidget()
|
||||
self.__name_layout = QVBoxLayout(self.__name_widget)
|
||||
self.__name_layout.setStretch(1, 1)
|
||||
self.__name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.__name_layout.setSpacing(0)
|
||||
self.__name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.__name_title = QLabel(Translations["field.name"])
|
||||
self.__name_layout.addWidget(self.__name_title)
|
||||
self._name_widget = QWidget()
|
||||
self._name_layout = QVBoxLayout(self._name_widget)
|
||||
self._name_layout.setStretch(1, 1)
|
||||
self._name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._name_layout.setSpacing(0)
|
||||
self._name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self._name_title = QLabel(Translations["field.name"])
|
||||
self._name_layout.addWidget(self._name_title)
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setFixedHeight(24)
|
||||
self.name_field.setPlaceholderText(Translations["field.field_name_required"])
|
||||
self.__name_layout.addWidget(self.name_field)
|
||||
self._name_layout.addWidget(self.name_field)
|
||||
|
||||
# Field Type -------------------------------------------------------------------------------
|
||||
self.__type_widget = QWidget()
|
||||
self.__type_layout = QVBoxLayout(self.__type_widget)
|
||||
self.__type_layout.setStretch(1, 1)
|
||||
self.__type_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.__type_layout.setSpacing(0)
|
||||
self.__type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.__type_title = QLabel(Translations["field.type"])
|
||||
self.__type_layout.addWidget(self.__type_title)
|
||||
self._type_widget = QWidget()
|
||||
self._type_layout = QVBoxLayout(self._type_widget)
|
||||
self._type_layout.setStretch(1, 1)
|
||||
self._type_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._type_layout.setSpacing(0)
|
||||
self._type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self._type_title = QLabel(Translations["field.type"])
|
||||
self._type_layout.addWidget(self._type_title)
|
||||
self._type_combobox = QComboBox()
|
||||
self.__type_layout.addWidget(self._type_combobox)
|
||||
self._type_layout.addWidget(self._type_combobox)
|
||||
|
||||
# Add Widgets to Layout ====================================================================
|
||||
self.root_layout.addWidget(self.__name_widget)
|
||||
self.root_layout.addWidget(self.__type_widget)
|
||||
self.root_layout.addWidget(self._name_widget)
|
||||
self.root_layout.addWidget(self._type_widget)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
|
||||
|
||||
class EditTextBox(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
self.setMinimumSize(480, 480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.text = text
|
||||
self.text_edit = QPlainTextEdit()
|
||||
self.text_edit.setPlainText(text)
|
||||
self.root_layout.addWidget(self.text_edit)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self.text_edit.toPlainText()
|
||||
|
||||
def reset(self):
|
||||
self.text_edit.setPlainText(self.text)
|
||||
@@ -1,33 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtWidgets import QLineEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
|
||||
|
||||
class EditTextLine(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
self.setMinimumWidth(480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.text = text
|
||||
self.text_edit = QLineEdit()
|
||||
self.text_edit.setText(text)
|
||||
self.root_layout.addWidget(self.text_edit)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self.text_edit.text()
|
||||
|
||||
def reset(self):
|
||||
self.text_edit.setText(self.text)
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
if event == "returnPressed":
|
||||
self.text_edit.returnPressed.connect(callback)
|
||||
else:
|
||||
raise ValueError(f"unknown event type: {event}")
|
||||
60
src/tagstudio/qt/views/edit_text_view.py
Normal file
60
src/tagstudio/qt/views/edit_text_view.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from PySide6.QtGui import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
|
||||
|
||||
class EditTextView(PanelWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumSize(480, 240)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px")
|
||||
|
||||
self.text_box = QPlainTextEdit()
|
||||
self.text_line = QLineEdit()
|
||||
self.text_line_stretch = QWidget()
|
||||
self.text_line_stretch.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
# Is Multiline
|
||||
self.multiline_widget = QWidget()
|
||||
self.multiline_layout = QHBoxLayout(self.multiline_widget)
|
||||
self.multiline_layout.setStretch(1, 1)
|
||||
self.multiline_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.multiline_layout.setSpacing(6)
|
||||
self.multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
|
||||
self.multiline_checkbox = QCheckBox()
|
||||
self.multiline_checkbox.setFixedSize(22, 22)
|
||||
self.multiline_checkbox.setStyleSheet(checkbox_style())
|
||||
self.multiline_title.clicked.connect(self.multiline_checkbox.click)
|
||||
self.multiline_layout.addWidget(self.multiline_checkbox)
|
||||
self.multiline_layout.addWidget(self.multiline_title)
|
||||
|
||||
self.root_layout.addWidget(self.name_field)
|
||||
self.root_layout.addWidget(self.text_box)
|
||||
self.root_layout.setStretch(2, 1)
|
||||
self.root_layout.addWidget(self.text_line)
|
||||
self.root_layout.addWidget(self.text_line_stretch)
|
||||
self.root_layout.setStretch(4, 1)
|
||||
self.root_layout.addWidget(self.multiline_widget)
|
||||
@@ -1,6 +1,8 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
@@ -16,6 +18,7 @@ class FieldTemplateSearchPanelView(SearchPanelView):
|
||||
self.search_field.setPlaceholderText(Translations["home.search_field_templates"])
|
||||
self.create_button.setText(Translations["field_template.create"])
|
||||
|
||||
@override
|
||||
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
|
||||
@@ -10,7 +10,7 @@ import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
|
||||
from PySide6.QtGui import QAction, QColor, QPixmap
|
||||
from PySide6.QtGui import QAction, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -35,18 +35,17 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.enums import SortingModeEnum, TagColorEnum
|
||||
from tagstudio.core.library.alchemy.enums import SortingModeEnum
|
||||
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
|
||||
from tagstudio.qt.mixed.landing import LandingWidget
|
||||
from tagstudio.qt.mixed.pagination import Pagination
|
||||
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
|
||||
from tagstudio.qt.mnemonics import assign_mnemonics
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.platform_strings import trash_term
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -589,11 +588,6 @@ class MainWindow(QMainWindow):
|
||||
self.extra_input_layout = QHBoxLayout()
|
||||
self.extra_input_layout.setObjectName("extra_input_layout")
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
|
||||
## Show hidden entries checkbox
|
||||
self.show_hidden_entries_widget = QWidget()
|
||||
self.show_hidden_entries_layout = QHBoxLayout(self.show_hidden_entries_widget)
|
||||
@@ -604,33 +598,7 @@ class MainWindow(QMainWindow):
|
||||
self.show_hidden_entries_title = QLabel(Translations["home.show_hidden_entries"])
|
||||
self.show_hidden_entries_checkbox = QCheckBox()
|
||||
self.show_hidden_entries_checkbox.setFixedSize(22, 22)
|
||||
|
||||
self.show_hidden_entries_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.show_hidden_entries_checkbox.setStyleSheet(checkbox_style())
|
||||
|
||||
self.show_hidden_entries_checkbox.setChecked(False) # Default: No
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import override
|
||||
import contextlib
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
@@ -16,18 +16,19 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PanelModal(QWidget):
|
||||
saved = Signal()
|
||||
"""A generic reusable modal panel widget."""
|
||||
|
||||
done = Signal()
|
||||
saved = Signal()
|
||||
saved_data = Signal(type(Any))
|
||||
|
||||
# TODO: Separate callbacks from the buttons you want, and just generally
|
||||
# figure out what you want from this.
|
||||
def __init__(
|
||||
self,
|
||||
widget: "PanelWidget",
|
||||
title: str = "",
|
||||
window_title: str | None = None,
|
||||
done_callback: Callable[..., None] | None = None,
|
||||
save_callback: Callable[..., None] | None = None,
|
||||
has_save: bool = False,
|
||||
is_savable: bool = False,
|
||||
inline_title: bool = True,
|
||||
):
|
||||
# [Done]
|
||||
# - OR -
|
||||
@@ -37,37 +38,24 @@ class PanelModal(QWidget):
|
||||
self.setWindowTitle(title if window_title is None else window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 6)
|
||||
|
||||
self.title_widget = QLabel()
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
|
||||
self.title_widget.setText(title)
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
# self.cancel_button = QPushButton()
|
||||
# self.cancel_button.setText('Cancel')
|
||||
|
||||
if not (save_callback or has_save):
|
||||
if not is_savable:
|
||||
self.done_button = QPushButton(Translations["generic.done"])
|
||||
self.done_button.setAutoDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
if done_callback:
|
||||
self.done_button.clicked.connect(done_callback)
|
||||
self.done_button.clicked.connect(self.done.emit)
|
||||
self.widget.panel_done_button = self.done_button
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
if save_callback or has_save:
|
||||
else:
|
||||
self.cancel_button = QPushButton(Translations["generic.cancel"])
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.cancel_button.clicked.connect(widget.reset)
|
||||
# self.cancel_button.clicked.connect(cancel_callback)
|
||||
self.widget.panel_cancel_button = self.cancel_button
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
@@ -75,23 +63,19 @@ class PanelModal(QWidget):
|
||||
self.save_button.setAutoDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(self.saved.emit)
|
||||
self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data()))
|
||||
self.widget.panel_save_button = self.save_button
|
||||
|
||||
if done_callback:
|
||||
self.save_button.clicked.connect(done_callback)
|
||||
|
||||
if save_callback:
|
||||
self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
# trigger save button actions when pressing enter in the widget
|
||||
self.widget.add_callback(lambda: self.save_button.click())
|
||||
if inline_title:
|
||||
self.title_widget = QLabel()
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px")
|
||||
self.title_widget.setText(title)
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
|
||||
if save_callback is not None:
|
||||
widget.done.connect(lambda: save_callback(widget.get_content()))
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(widget)
|
||||
widget.parent_modal = self
|
||||
self.root_layout.setStretch(1, 2)
|
||||
@@ -100,9 +84,9 @@ class PanelModal(QWidget):
|
||||
|
||||
@override
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
if self.cancel_button:
|
||||
with contextlib.suppress(AttributeError):
|
||||
self.cancel_button.click()
|
||||
elif self.done_button:
|
||||
with contextlib.suppress(AttributeError):
|
||||
self.done_button.click()
|
||||
event.accept()
|
||||
|
||||
@@ -110,7 +94,6 @@ class PanelModal(QWidget):
|
||||
class PanelWidget(QWidget):
|
||||
"""Used for widgets that go in a modal panel, ex. for editing or searching."""
|
||||
|
||||
done = Signal()
|
||||
parent_modal: PanelModal | None = None
|
||||
panel_save_button: QPushButton | None = None
|
||||
panel_cancel_button: QPushButton | None = None
|
||||
@@ -119,8 +102,8 @@ class PanelWidget(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_content(self) -> str:
|
||||
return ""
|
||||
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
@@ -128,9 +111,6 @@ class PanelWidget(QWidget):
|
||||
def parent_post_init(self) -> None:
|
||||
pass
|
||||
|
||||
def add_callback(self, callback: Callable[..., None], event: str = "returnPressed"): # pyright: ignore[reportUnusedParameter]
|
||||
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
|
||||
@@ -19,7 +19,7 @@ from tagstudio.qt.mixed.media_player import MediaPlayer
|
||||
from tagstudio.qt.platform_strings import open_file_str, trash_term
|
||||
from tagstudio.qt.previews.renderer import ThumbRenderer
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from tagstudio.qt.views.stylesheets.rounded_pixmap_style import RoundedPixmapStyle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
41
src/tagstudio/qt/views/stylesheets/stylesheets.py
Normal file
41
src/tagstudio/qt/views/stylesheets/stylesheets.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
|
||||
|
||||
def checkbox_style() -> str:
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
return (
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
@@ -92,6 +92,7 @@
|
||||
"field.name": "Name",
|
||||
"field.paste": "Paste Field",
|
||||
"field.remove": "Remove Field",
|
||||
"field.text.is_multiline": "Multiline",
|
||||
"field.type": "Type",
|
||||
"file.date_added": "Date Added",
|
||||
"file.date_created": "Date Created",
|
||||
|
||||
@@ -225,7 +225,9 @@ def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_fu
|
||||
def test_update_entry_field(library: Library, entry_full: Entry):
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
|
||||
library.update_text_field(
|
||||
entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline
|
||||
)
|
||||
|
||||
entry = next(library.all_entries(with_joins=True))
|
||||
assert entry.text_fields[0].value == "new value"
|
||||
@@ -241,7 +243,9 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr
|
||||
library.add_field_to_entries(entry_full.id, field=empty_title)
|
||||
|
||||
# update one of the fields
|
||||
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
|
||||
library.update_text_field(
|
||||
entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline
|
||||
)
|
||||
|
||||
# Then only one should be updated
|
||||
entry = next(library.all_entries(with_joins=True))
|
||||
|
||||
Reference in New Issue
Block a user