diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 7c21c9cd..e43f19e5 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -432,7 +432,9 @@ class Library: selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tags).options( - selectinload(Tag.aliases), selectinload(Tag.subtags) + selectinload(Tag.aliases), + selectinload(Tag.subtags), + selectinload(Tag.parent_tags), ), ) entry = session.scalar(statement) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index c69eeac2..89b258e0 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -31,8 +31,10 @@ from src.qt.translations import Translations from src.core.library.alchemy.models import Entry from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.modals.add_field import AddFieldModal +from src.core.library.alchemy.models import Entry, Tag from src.qt.widgets.fields import FieldContainer from src.qt.widgets.panel import PanelModal +from src.qt.widgets.tag_box import TagBoxWidget from src.qt.widgets.text import TextWidget from src.qt.widgets.text_box_edit import EditTextBox from src.qt.widgets.text_line_edit import EditTextLine @@ -100,19 +102,6 @@ class FieldContainers(QWidget): ) self.scroll_area.setWidget(scroll_container) - self.afb_container = QWidget() - self.afb_layout = QVBoxLayout(self.afb_container) - self.afb_layout.setContentsMargins(0, 12, 0, 0) - - self.add_field_button = QPushButtonWrapper() - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumSize(96, 28) - self.add_field_button.setMaximumSize(96, 28) - Translations.translate_qobject(self.add_field_button, "library.field.add") - self.afb_layout.addWidget(self.add_field_button) - self.add_field_modal = AddFieldModal(self.lib) - self.place_add_field_button() - root_layout = QHBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) @@ -121,48 +110,81 @@ class FieldContainers(QWidget): """Update tags and fields from a single Entry source.""" self.selected = [self.lib.get_entry_full(entry.id)] logger.info( - "[Field Containers] Updating Selection", + "[FieldContainers] Updating Selection", entry=entry, fields=entry.fields, tags=entry.tags, ) entry_ = self.selected[0] - for idx, field in enumerate(entry_.fields): - self.write_container(idx, field, is_mixed=False) + container_len: int = len(entry_.fields) + # for index, tag in enumerate(entry_.tags): + # self.write_tag_container(index, ) + # TODO: Break up into categories + container_index = 0 if entry_.tags: - # TODO: Display the tag categories - pass + categories = self.get_tag_categories(entry_.tags) + for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): + self.write_tag_container( + container_index, tags=tags, category_tag=cat, is_mixed=False + ) + container_index += 1 + container_len += 1 + for index, field in enumerate(entry_.fields, start=container_index): + self.write_container(index, field, is_mixed=False) # Hide leftover containers - if len(self.containers) > len(entry_.fields): + if len(self.containers) > container_len: for i, c in enumerate(self.containers): - if i > (len(entry_.fields) - 1): + if i > (container_len - 1): c.setHidden(True) - self.add_field_button.setHidden(False) + def get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: + cats: dict[Tag, set[Tag | None]] = {} + cats[None] = set() + + # Initialize all categories from parents + for tag in tags: + for p_tag in list(tag.subtags) + [tag]: + logger.info(f"[{tag.name}] is {p_tag.name} a category? ({p_tag.is_category})") + if p_tag.is_category: + cats[p_tag] = set() + logger.info("Blank Tag Categories", cats=cats) + + for tag in tags: + is_general = True + for p_tag in list(cats.keys()): + logger.info(f"[{tag.name}] Checking category tag key {p_tag}") + if not p_tag: + pass + elif p_tag in tag.subtags: + cats[p_tag].add(tag) + is_general = False + elif tag == p_tag: + cats[p_tag].add(tag) + is_general = False + pass + if is_general: + cats[None].add(tag) + + empty: list[Tag] = [] + for k, v in list(cats.items()): + if not v: + empty.append(k) + for key in empty: + cats.pop(key, None) + + logger.info("Tag Categories", cats=cats) + return cats def remove_field_prompt(self, name: str) -> str: return Translations.translate_formatted("library.field.confirm_remove", name=name) - def place_add_field_button(self): - self.scroll_layout.addWidget(self.afb_container) - self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) - - if self.add_field_modal.is_connected: - self.add_field_modal.done.disconnect() - if self.add_field_button.is_connected: - self.add_field_button.clicked.disconnect() - - self.add_field_modal.done.connect( - lambda f: (self.add_field_to_selected(f), self.update_from_entry(self.selected[0])) - ) - self.add_field_modal.is_connected = True - self.add_field_button.clicked.connect(self.add_field_modal.show) - def add_field_to_selected(self, field_list: list): """Add list of entry fields to one or more selected items.""" - logger.info("add_field_to_selected", selected=self.selected, fields=field_list) + logger.info( + "[FieldContainers][add_field_to_selected]", selected=self.selected, fields=field_list + ) for entry in self.selected: for field_item in field_list: self.lib.add_entry_field_type( @@ -170,6 +192,15 @@ class FieldContainers(QWidget): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) + def add_tags_to_selected(self, tags: list[int]): + """Add list of tags to one or more selected items.""" + logger.info("[FieldContainers][add_tags_to_selected]", selected=self.selected, tags=tags) + for entry in self.selected: + self.lib.add_tags_to_entry( + entry.id, + tag_ids=tags, + ) + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. @@ -180,8 +211,7 @@ class FieldContainers(QWidget): If True, field is not present in all selected items. """ - # Remove 'Add Field' button from scroll_layout, to be re-added later. - self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() + logger.info("[write_field_container]", index=index) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -189,59 +219,6 @@ class FieldContainers(QWidget): else: container = self.containers[index] - # if isinstance(field, TagBoxField): - # container.set_title(field.type.name) - # container.set_inline(False) - # title = f"{field.type.name} (Tag Box)" - # - # if not is_mixed: - # inner_container = container.get_inner_widget() - # if isinstance(inner_container, TagBoxWidget): - # inner_container.set_field(field) - # inner_container.set_tags(list(field.tags)) - # - # try: - # inner_container.updated.disconnect() - # except RuntimeError: - # logger.error("Failed to disconnect inner_container.updated") - # - # else: - # inner_container = TagBoxWidget( - # field, - # title, - # self.driver, - # ) - # - # container.set_inner_widget(inner_container) - # - # inner_container.updated.connect( - # lambda: ( - # self.write_container(index, field), - # self.update_widgets(), - # ) - # ) - # # NOTE: Tag Boxes have no Edit Button - # (But will when you can convert field types) - # container.set_remove_callback( - # lambda: self.remove_message_box( - # prompt=self.remove_field_prompt(field.type.name), - # callback=lambda: ( - # self.remove_field(field), - # self.update_selected_entry(self.driver), - # # reload entry and its fields - # self.update_widgets(), - # ), - # ) - # ) - # else: - # text = "Mixed Data" - # title = f"{field.type.name} (Wacky Tag Box)" - # inner_container = TextWidget(title, text) - # container.set_inner_widget(inner_container) - # - # self.tags_updated.emit() - # # self.dynamic_widgets.append(inner_container) - # elif field.type.type == FieldTypeEnum.TEXT_LINE: if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) container.set_inline(False) @@ -254,8 +231,8 @@ class FieldContainers(QWidget): text = "Mixed Data" title = f"{field.type.name} ({field.type.type.value})" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextLine(field.value), @@ -294,8 +271,8 @@ class FieldContainers(QWidget): else: text = "Mixed Data" title = f"{field.type.name} (Text Box)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), @@ -328,15 +305,15 @@ class FieldContainers(QWidget): # TODO: Localize this and/or add preferences. date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S") title = f"{field.type.name} (Date)" - inner_container = TextWidget(title, date.strftime("%D - %r")) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, date.strftime("%D - %r")) + container.set_inner_widget(inner_widget) except Exception: container.set_title(field.type.name) # container.set_editable(False) container.set_inline(False) title = f"{field.type.name} (Date) (Unknown Format)" - inner_container = TextWidget(title, str(field.value)) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, str(field.value)) + container.set_inner_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( @@ -350,15 +327,15 @@ class FieldContainers(QWidget): else: text = "Mixed Data" title = f"{field.type.name} (Wacky Date)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) else: - logger.warning("write_container - unknown field", field=field) + logger.warning("[FieldContainers][write_container] Unknown Field", field=field) container.set_title(field.type.name) container.set_inline(False) title = f"{field.type.name} (Unknown Field Type)" - inner_container = TextWidget(title, field.type.name) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, field.type.name) + container.set_inner_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), @@ -371,11 +348,67 @@ class FieldContainers(QWidget): container.edit_button.setHidden(True) container.setHidden(False) - self.place_add_field_button() + + def write_tag_container( + self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False + ): + """Update/Create tag data for a 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. + """ + logger.info("[write_tag_container]", index=index) + if len(self.containers) < (index + 1): + container = FieldContainer() + self.containers.append(container) + self.scroll_layout.addWidget(container) + else: + container = self.containers[index] + + container.set_title("Tags" if not category_tag else category_tag.name) + container.set_inline(False) + + if not is_mixed: + inner_widget = container.get_inner_widget() + + if isinstance(inner_widget, TagBoxWidget): + inner_widget.set_tags(tags) + try: + inner_widget.updated.disconnect() + except RuntimeError: + logger.error("[FieldContainers] Failed to disconnect inner_container.updated") + + else: + inner_widget = TagBoxWidget( + tags, + "Tags", + self.driver, + ) + container.set_inner_widget(inner_widget) + + inner_widget.updated.connect( + lambda: ( + self.write_tag_container(index, tags, category_tag), + self.update_from_entry(self.selected[0]), + ) + ) + else: + text = "Mixed Data" + inner_widget = TextWidget("Mixed Tags", text) + container.set_inner_widget(inner_widget) + + self.tags_updated.emit() + container.edit_button.setHidden(True) + container.setHidden(False) def remove_field(self, field: BaseField): """Remove a field from all selected Entries.""" - logger.info("removing field", field=field, selected=self.selected) + logger.info("[FieldContainers] Removing Field", field=field, selected=self.selected) entry_ids = [e.id for e in self.selected] self.lib.remove_entry_field(field, entry_ids) @@ -421,344 +454,6 @@ class FieldContainers(QWidget): if self.is_connected: self.tags_updated.disconnect() - logger.info("[UPDATE CONTAINER] Setting tags updated slot") + logger.info("[FieldContainers][set_tags_updated_slot] Setting tags updated slot") self.tags_updated.connect(slot) self.is_connected = True - - # def update_widgets(self): - # """Render the panel widgets with the newest data from the Library.""" - # logger.info("update_widgets", selected=self.driver.selected) - # # self.is_open = True - # # self.tag_callback = tag_callback if tag_callback else None - # # window_title = "" - - # # # update list of libraries - # # self.fill_libs_widget(self.libs_layout) - - # # if not self.driver.selected: - # # if self.selected or not self.initialized: - # # # self.file_label.setText("No Items Selected") - # # # self.file_label.set_file_path("") - # # # self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - - # # # self.dimensions_label.setText("") - # # # self.update_date_label() - # # # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - # # # self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - - # # # ratio = self.devicePixelRatio() - # # # self.thumb_renderer.render( - # # # time.time(), - # # # "", - # # # (512, 512), - # # # ratio, - # # # is_loading=True, - # # # update_on_ratio_change=True, - # # # ) - # # # if self.preview_img.is_connected: - # # # self.preview_img.clicked.disconnect() - # # # for c in self.containers: - # # # c.setHidden(True) - # # # self.preview_img.show() - # # # self.preview_vid.stop() - # # # self.preview_vid.hide() - # # # self.media_player.hide() - # # # self.media_player.stop() - # # # self.preview_gif.hide() - # # # self.selected = list(self.driver.selected) - # # self.add_field_button.setHidden(True) - - # # # common code - # # self.initialized = True - # # self.setWindowTitle(window_title) - # # self.show() - # # return True - - # # reload entry and fill it into the grid again - # # TODO - do this more granular - # # TODO - Entry reload is maybe not necessary - # for grid_idx in self.driver.selected: - # entry = self.driver.frame_content[grid_idx] - # results = self.lib.search_library(FilterState(id=entry.id)) - # logger.info( - # "found item", - # entries=len(results.items), - # grid_idx=grid_idx, - # lookup_id=entry.id, - # ) - # self.driver.frame_content[grid_idx] = results[0] - - # if len(self.driver.selected) == 1: - # # 1 Selected Entry - # selected_idx = self.driver.selected[0] - # item = self.driver.frame_content[selected_idx] - - # self.preview_img.show() - # self.preview_vid.stop() - # self.preview_vid.hide() - # self.media_player.stop() - # self.media_player.hide() - # self.preview_gif.hide() - - # # If a new selection is made, update the thumbnail and filepath. - # if not self.selected or self.selected != self.driver.selected: - # filepath = self.lib.library_dir / item.path - # self.file_label.set_file_path(filepath) - # ratio = self.devicePixelRatio() - # self.thumb_renderer.render( - # time.time(), - # filepath, - # (512, 512), - # ratio, - # update_on_ratio_change=True, - # ) - # file_str: str = "" - # separator: str = f"{os.path.sep}" # Gray - # for i, part in enumerate(filepath.parts): - # part_ = part.strip(os.path.sep) - # if i != len(filepath.parts) - 1: - # file_str += f"{"\u200b".join(part_)}{separator}" - # else: - # file_str += f"
{"\u200b".join(part_)}" - # self.file_label.setText(file_str) - # self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - - # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - # self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - - # self.opener = FileOpenerHelper(filepath) - # self.open_file_action.triggered.connect(self.opener.open_file) - # self.open_explorer_action.triggered.connect(self.opener.open_explorer) - - # # TODO: Do this all somewhere else, this is just here temporarily. - # ext: str = filepath.suffix.lower() - # try: - # if MediaCategories.is_ext_in_category( - # ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - # ): - # if self.preview_gif.movie(): - # self.preview_gif.movie().stop() - # self.gif_buffer.close() - - # image: Image.Image = Image.open(filepath) - # anim_image: Image.Image = image - # image_bytes_io: io.BytesIO = io.BytesIO() - # anim_image.save( - # image_bytes_io, - # "GIF", - # lossless=True, - # save_all=True, - # loop=0, - # disposal=2, - # ) - # image_bytes_io.seek(0) - # ba: bytes = image_bytes_io.read() - - # self.gif_buffer.setData(ba) - # movie = QMovie(self.gif_buffer, QByteArray()) - # self.preview_gif.setMovie(movie) - # movie.start() - - # self.resizeEvent( - # QResizeEvent( - # QSize(image.width, image.height), - # QSize(image.width, image.height), - # ) - # ) - # self.preview_img.hide() - # self.preview_vid.hide() - # self.preview_gif.show() - - # image = None - # if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES): #noqa: E501 - # image = Image.open(str(filepath)) - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES): - # try: - # with rawpy.imread(str(filepath)) as raw: - # rgb = raw.postprocess() - # image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") #noqa: E501 - # except ( - # rawpy._rawpy.LibRawIOError, - # rawpy._rawpy.LibRawFileUnsupportedError, - # ): - # pass - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES): - # self.media_player.show() - # self.media_player.play(filepath) - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.VIDEO_TYPES - # ) and is_readable_video(filepath): - # video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - # video.set( - # cv2.CAP_PROP_POS_FRAMES, - # (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - # ) - # success, frame = video.read() - # frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # image = Image.fromarray(frame) - # if success: - # self.preview_img.hide() - # self.preview_vid.play(filepath, QSize(image.width, image.height)) - # self.resizeEvent( - # QResizeEvent( - # QSize(image.width, image.height), - # QSize(image.width, image.height), - # ) - # ) - # self.preview_vid.show() - - # # Stats for specific file types are displayed here. - # if image and ( - # MediaCategories.is_ext_in_category( - # ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - # ) - # or MediaCategories.is_ext_in_category( - # ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - # ) - # or MediaCategories.is_ext_in_category( - # ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - # ) - # ): - # self.dimensions_label.setText( - # f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - # f"{image.width} x {image.height} px" - # ) - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.FONT_TYPES, mime_fallback=True - # ): - # try: - # font = ImageFont.truetype(filepath) - # self.dimensions_label.setText( - # f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - # f"{font.getname()[0]} ({font.getname()[1]}) " - # ) - # except OSError: - # self.dimensions_label.setText( - # f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - # ) - # logger.info( - # f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}" - # ) - # else: - # self.dimensions_label.setText(f"{ext.upper()[1:]}") - # self.dimensions_label.setText( - # f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - # ) - # self.update_date_label(filepath) - - # if not filepath.is_file(): - # raise FileNotFoundError - - # except (FileNotFoundError, cv2.error) as e: - # self.dimensions_label.setText(f"{ext.upper()[1:]}") - # logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - # self.update_date_label() - # except ( - # UnidentifiedImageError, - # DecompressionBombError, - # ) as e: - # self.dimensions_label.setText( - # f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - # ) - # logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - # self.update_date_label(filepath) - - # if self.preview_img.is_connected: - # self.preview_img.clicked.disconnect() - # self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth)) #noqa: E501 - # self.preview_img.is_connected = True - - # self.selected = self.driver.selected - # logger.info( - # "rendering item fields", - # item=item.id, - # fields=[x.type_key for x in item.fields], - # ) - # for idx, field in enumerate(item.fields): - # self.write_container(idx, field) - - # # Hide leftover containers - # if len(self.containers) > len(item.fields): - # for i, c in enumerate(self.containers): - # if i > (len(item.fields) - 1): - # c.setHidden(True) - - # self.add_field_button.setHidden(False) - - # # Multiple Selected Items - # elif len(self.driver.selected) > 1: - # self.preview_img.show() - # self.preview_gif.hide() - # self.preview_vid.stop() - # self.preview_vid.hide() - # self.media_player.stop() - # self.media_player.hide() - # self.update_date_label() - # if self.selected != self.driver.selected: - # self.file_label.setText(f"{len(self.driver.selected)} Items Selected") - # self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - # self.file_label.set_file_path("") - # self.dimensions_label.setText("") - - # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - # self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - - # ratio = self.devicePixelRatio() - # self.thumb_renderer.render( - # time.time(), - # "", - # (512, 512), - # ratio, - # is_loading=True, - # update_on_ratio_change=True, - # ) - # if self.preview_img.is_connected: - # self.preview_img.clicked.disconnect() - # self.preview_img.is_connected = False - - # # fill shared fields from first item - # first_item = self.driver.frame_content[self.driver.selected[0]] - # common_fields = [f for f in first_item.fields] - # mixed_fields = [] - - # # iterate through other items - # for grid_idx in self.driver.selected[1:]: - # item = self.driver.frame_content[grid_idx] - # item_field_types = {f.type_key for f in item.fields} - # for f in common_fields[:]: - # if f.type_key not in item_field_types: - # common_fields.remove(f) - # mixed_fields.append(f) - - # self.common_fields = common_fields - # self.mixed_fields = sorted(mixed_fields, key=lambda x: x.type.position) - - # self.selected = list(self.driver.selected) - # logger.info( - # "update_widgets common_fields", - # common_fields=self.common_fields, - # ) - # for i, f in enumerate(self.common_fields): - # self.write_container(i, f) - - # logger.info( - # "update_widgets mixed_fields", - # mixed_fields=self.mixed_fields, - # start=len(self.common_fields), - # ) - # for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)): - # self.write_container(i, f, is_mixed=True) - - # # Hide leftover containers - # if len(self.containers) > len(self.common_fields) + len(self.mixed_fields): - # for i, c in enumerate(self.containers): - # if i > (len(self.common_fields) + len(self.mixed_fields) - 1): - # c.setHidden(True) - - # self.add_field_button.setHidden(False) - - # self.initialized = True - - # self.setWindowTitle(window_title) - # self.show() - # return True diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index ed572750..19820115 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -112,7 +112,6 @@ class FileAttributes(QWidget): def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" - logger.info(filepath) if filepath and filepath.is_file(): created: dt = None if platform.system() == "Windows" or platform.system() == "Darwin": @@ -139,8 +138,6 @@ class FileAttributes(QWidget): def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): """Render the panel widgets with the newest data from the Library.""" - logger.info("update_stats", selected=filepath) - if not stats: stats = {} diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index a34c7bcf..f651b187 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -11,6 +11,12 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QHBoxLayout, QSplitter, QVBoxLayout, QWidget from src.core.library.alchemy.library import Library from src.core.library.alchemy.models import Entry +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.modals.add_field import AddFieldModal +from src.qt.modals.tag_search import TagSearchPanel +from src.qt.widgets.panel import PanelModal + +# from src.qt.modals.add_tag import AddTagModal from src.qt.widgets.preview.field_containers import FieldContainers from src.qt.widgets.preview.file_attributes import FileAttributes from src.qt.widgets.preview.preview_thumb import PreviewThumb @@ -34,12 +40,18 @@ class PreviewPanel(QWidget): self.driver: QtDriver = driver self.initialized = False self.is_open: bool = False - # self.selected: list[int] = [] # New way of tracking items self.thumb = PreviewThumb(library, driver) self.file_attrs = FileAttributes(library, driver) self.fields = FieldContainers(library, driver) + tsp = TagSearchPanel(self.driver.lib) + # tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) + self.add_tag_modal = PanelModal(tsp, "Add Tags", "Add Tags") + + # self.add_tag_modal = AddTagModal(self.lib) + self.add_field_modal = AddFieldModal(self.lib) + preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) preview_layout.setContentsMargins(0, 0, 0, 0) @@ -61,6 +73,22 @@ class PreviewPanel(QWidget): # ) # ) # ) + add_buttons_container = QWidget() + add_buttons_layout = QHBoxLayout(add_buttons_container) + add_buttons_layout.setContentsMargins(0, 0, 0, 0) + add_buttons_layout.setSpacing(6) + + self.add_tag_button = QPushButtonWrapper() + self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_tag_button.setText("Add Tag") + + self.add_field_button = QPushButtonWrapper() + self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_field_button.setText("Add Field") + + add_buttons_layout.addWidget(self.add_tag_button) + add_buttons_layout.addWidget(self.add_field_button) + preview_layout.addWidget(self.thumb) preview_layout.addWidget(self.thumb.media_player) info_layout.addWidget(self.file_attrs) @@ -71,9 +99,10 @@ class PreviewPanel(QWidget): # splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) - root_layout = QHBoxLayout(self) + root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(splitter) + root_layout.addWidget(add_buttons_container) def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" @@ -92,11 +121,12 @@ class PreviewPanel(QWidget): ext: str = filepath.suffix.lower() stats: dict = self.thumb.update_preview(filepath, ext) - logger.info("stats", stats=stats, ext=ext) try: self.file_attrs.update_stats(filepath, ext, stats) self.file_attrs.update_date_label(filepath) self.fields.update_from_entry(entry) + self.update_add_tag_button(entry) + self.update_add_field_button(entry) except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) traceback.print_exc() @@ -114,3 +144,42 @@ class PreviewPanel(QWidget): # self.fields.update_widgets() return True + + def update_add_field_button(self, entry: Entry): + if self.add_field_modal.is_connected: + self.add_field_modal.done.disconnect() + if self.add_field_button.is_connected: + self.add_field_button.clicked.disconnect() + + self.add_field_modal.done.connect( + lambda f: ( + self.fields.add_field_to_selected(f), + self.fields.update_from_entry(entry), + ) + ) + self.add_field_modal.is_connected = True + self.add_field_button.clicked.connect(self.add_field_modal.show) + + def update_add_tag_button(self, entry: Entry): + # if self.add_tag_modal.is_connected: + # self.add_tag_modal.done.disconnect() + if self.add_tag_button.is_connected: + self.add_tag_button.clicked.disconnect() + + self.add_tag_modal.widget.tag_chosen.connect( + lambda t: ( + self.fields.add_tags_to_selected(t), + self.fields.update_from_entry(entry), + ) + ) + # self.add_tag_modal.is_connected = True + + self.add_tag_button.clicked.connect( + lambda: ( + # self.add_tag_modal.widget.update_tags(), + self.add_tag_modal.show(), + ) + ) + self.add_tag_button.is_connected = True + + # self.add_field_button.clicked.connect(self.add_tag_modal.show) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index d31cefbb..42b034c2 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -1,192 +1,176 @@ -# # Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# # Licensed under the GPL-3.0 License. -# # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio -# import math -# import typing +import typing -# import structlog -# from PySide6.QtCore import Qt, Signal -# from PySide6.QtWidgets import QPushButton -# from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE -# from src.core.library import Entry, Tag -# from src.core.library.alchemy.enums import FilterState +import structlog +from PySide6.QtCore import Signal +from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from src.core.library import Tag +from src.core.library.alchemy.enums import FilterState +from src.qt.flowlayout import FlowLayout +from src.qt.modals.build_tag import BuildTagPanel +from src.qt.widgets.fields import FieldWidget +from src.qt.widgets.panel import PanelModal +from src.qt.widgets.tag import TagWidget + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) -# # from src.core.library.alchemy.fields import TagBoxField -# from src.qt.flowlayout import FlowLayout -# from src.qt.modals.build_tag import BuildTagPanel -# from src.qt.modals.tag_search import TagSearchPanel -# from src.qt.translations import Translations -# from src.qt.widgets.fields import FieldWidget -# from src.qt.widgets.panel import PanelModal -# from src.qt.widgets.tag import TagWidget +class TagBoxWidget(FieldWidget): + updated = Signal() + error_occurred = Signal(Exception) + def __init__( + self, + tags: set[Tag], + title: str, + driver: "QtDriver", + ) -> None: + super().__init__(title) -# if typing.TYPE_CHECKING: -# from src.qt.ts_qt import QtDriver + self.tags: set[Tag] = tags + self.driver = ( + driver # Used for creating tag click callbacks that search entries for that tag. + ) + self.setObjectName("tagBox") + self.base_layout = FlowLayout() + self.base_layout.enable_grid_optimizations(value=False) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.base_layout) -# logger = structlog.get_logger(__name__) + # self.add_button = QPushButton() + # self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) + # self.add_button.setMinimumSize(23, 23) + # self.add_button.setMaximumSize(23, 23) + # self.add_button.setText("+") + # self.add_button.setStyleSheet( + # f"QPushButton{{" + # f"background: #1e1e1e;" + # f"color: #FFFFFF;" + # f"font-weight: bold;" + # f"border-color: #333333;" + # f"border-radius: 6px;" + # f"border-style:solid;" + # f"border-width:{math.ceil(self.devicePixelRatio())}px;" + # f"padding-bottom: 5px;" + # f"font-size: 20px;" + # f"}}" + # f"QPushButton::hover" + # f"{{" + # f"border-color: #CCCCCC;" + # f"background: #555555;" + # f"}}" + # ) + # tsp = TagSearchPanel(self.driver.lib) + # tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) + # self.add_modal = PanelModal(tsp, title, "Add Tags") + # self.add_button.clicked.connect( + # lambda: ( + # tsp.update_tags(), + # self.add_modal.show(), + # ) + # ) + self.set_tags(self.tags) -# class TagBoxWidget(FieldWidget): -# updated = Signal() -# error_occurred = Signal(Exception) + def set_tags(self, tags: typing.Iterable[Tag]): + tags_ = sorted(list(tags), key=lambda tag: tag.name) + logger.info("[TagBoxWidget] Tags:", tags=tags) + # is_recycled = False + while self.base_layout.itemAt(0): + self.base_layout.takeAt(0).widget().deleteLater() + # is_recycled = True -# def __init__( -# self, -# field: TagBoxField, -# title: str, -# driver: "QtDriver", -# ) -> None: -# super().__init__(title) + for tag in tags_: + tag_widget = TagWidget(tag, has_edit=True, has_remove=True) + tag_widget.on_click.connect( + lambda 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)), + ) + ) -# assert isinstance(field, TagBoxField), f"field is {type(field)}" + tag_widget.on_remove.connect( + lambda tag_id=tag.id: ( + self.remove_tag(tag_id), + self.driver.preview_panel.update_widgets(), + ) + ) + tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) + self.base_layout.addWidget(tag_widget) -# self.field = field -# self.driver = ( -# driver # Used for creating tag click callbacks that search entries for that tag. -# ) -# self.setObjectName("tagBox") -# self.base_layout = FlowLayout() -# self.base_layout.enable_grid_optimizations(value=False) -# self.base_layout.setContentsMargins(0, 0, 0, 0) -# self.setLayout(self.base_layout) + # # Move or add the '+' button. + # if is_recycled: + # self.base_layout.addWidget(self.base_layout.takeAt(0).widget()) + # else: + # self.base_layout.addWidget(self.add_button) + # Handles an edge case where there are no more tags and the '+' button + # doesn't move all the way to the left. + if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1): + self.base_layout.update() -# self.add_button = QPushButton() -# self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) -# self.add_button.setMinimumSize(23, 23) -# self.add_button.setMaximumSize(23, 23) -# self.add_button.setText("+") -# self.add_button.setStyleSheet( -# f"QPushButton{{" -# f"background: #1e1e1e;" -# f"color: #FFFFFF;" -# f"font-weight: bold;" -# f"border-color: #333333;" -# f"border-radius: 6px;" -# f"border-style:solid;" -# f"border-width:{math.ceil(self.devicePixelRatio())}px;" -# f"padding-bottom: 5px;" -# f"font-size: 20px;" -# f"}}" -# f"QPushButton::hover" -# f"{{" -# f"border-color: #CCCCCC;" -# f"background: #555555;" -# f"}}" -# ) -# tsp = TagSearchPanel(self.driver.lib) -# tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) -# self.add_modal = PanelModal(tsp, title) -# Translations.translate_with_setter(self.add_modal.setWindowTitle, "tag.add.plural") -# self.add_button.clicked.connect( -# lambda: ( -# tsp.update_tags(), -# self.add_modal.show(), -# ) -# ) + def edit_tag(self, tag: Tag): + assert isinstance(tag, Tag), f"tag is {type(tag)}" + build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) + self.edit_modal = PanelModal( + build_tag_panel, + tag.name, # TODO - display name including subtags + "Edit Tag", + done_callback=self.driver.preview_panel.update_widgets, + has_save=True, + ) + # TODO - this was update_tag() + self.edit_modal.saved.connect( + lambda: self.driver.lib.update_tag( + build_tag_panel.build_tag(), + subtag_ids=set(build_tag_panel.subtag_ids), + alias_names=set(build_tag_panel.alias_names), + alias_ids=set(build_tag_panel.alias_ids), + ) + ) + self.edit_modal.show() -# self.set_tags(field.tags) + def add_tag_callback(self, tag_id: int): + logger.info("[TagBoxWidget] add_tag_callback", tag_id=tag_id, selected=self.driver.selected) -# def set_field(self, field: TagBoxField): -# self.field = field + # tag = self.driver.lib.get_tag(tag_id=tag_id) + for entry_id in self.driver.selected: + # entry: Entry = self.driver.frame_content[entry.id] + self.driver.lib.add_tags_to_entry(entry_id, tag_id) -# def set_tags(self, tags: typing.Iterable[Tag]): -# tags_ = sorted(list(tags), key=lambda tag: tag.name) -# is_recycled = False -# while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): -# self.base_layout.takeAt(0).widget().deleteLater() -# is_recycled = True + if not self.driver.lib.add_tags_to_entry(entry_id, tag_id): + # TODO - add some visible error + self.error_occurred.emit(Exception("Failed to add tag")) -# for tag in tags_: -# tag_widget = TagWidget(tag, has_edit=True, has_remove=True) -# tag_widget.on_click.connect( -# lambda 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)), -# ) -# ) + self.updated.emit() + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): + self.driver.update_badges() -# tag_widget.on_remove.connect( -# lambda tag_id=tag.id: ( -# self.remove_tag(tag_id), -# self.driver.preview_panel.update_widgets(), -# ) -# ) -# tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) -# self.base_layout.addWidget(tag_widget) + def edit_tag_callback(self, tag: Tag): + self.driver.lib.update_tag(tag) -# # Move or add the '+' button. -# if is_recycled: -# self.base_layout.addWidget(self.base_layout.takeAt(0).widget()) -# else: -# self.base_layout.addWidget(self.add_button) + def remove_tag(self, tag_id: int): + logger.info( + "[TagBoxWidget] remove_tag", + selected=self.driver.selected, + # field_type=self.field.type, + ) -# # Handles an edge case where there are no more tags and the '+' button -# # doesn't move all the way to the left. -# if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1): -# self.base_layout.update() + for entry_id in self.driver.selected: + # entry = self.driver.frame_content[entry_id] + self.driver.lib.remove_tags_from_entry(entry_id, tag_id) + # self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) + self.updated.emit() -# self.edit_modal = PanelModal( -# build_tag_panel, -# title=tag.name, # TODO - display name including subtags -# done_callback=self.driver.preview_panel.update_widgets, -# has_save=True, -# ) -# Translations.translate_with_setter(self.edit_modal.setWindowTitle, "tag.edit") -# # TODO - this was update_tag() -# self.edit_modal.saved.connect( -# lambda: self.driver.lib.update_tag( -# build_tag_panel.build_tag(), -# subtag_ids=set(build_tag_panel.subtag_ids), -# alias_names=set(build_tag_panel.alias_names), -# alias_ids=set(build_tag_panel.alias_ids), -# ) -# ) -# self.edit_modal.show() - -# def edit_tag(self, tag: Tag): -# assert isinstance(tag, Tag), f"tag is {type(tag)}" -# build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) - - -# def add_tag_callback(self, tag_id: int): -# logger.info("add_tag_callback", tag_id=tag_id, selected=self.driver.selected) - -# tag = self.driver.lib.get_tag(tag_id=tag_id) -# for idx in self.driver.selected: -# entry: Entry = self.driver.frame_content[idx] - -# if not self.driver.lib.add_field_tag(entry, tag, self.field.type_key): -# # TODO - add some visible error -# self.error_occurred.emit(Exception("Failed to add tag")) - -# self.updated.emit() - -# if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): -# self.driver.update_badges() - -# def edit_tag_callback(self, tag: Tag): -# self.driver.lib.update_tag(tag) - -# def remove_tag(self, tag_id: int): -# logger.info( -# "remove_tag", -# selected=self.driver.selected, -# field_type=self.field.type, -# ) - -# for grid_idx in self.driver.selected: -# entry = self.driver.frame_content[grid_idx] -# self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) - -# self.updated.emit() - -# if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): -# self.driver.update_badges() + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): + self.driver.update_badges()