Merge pull request #80 from Loran425/qt-refactor

Qt refactor
This commit is contained in:
Travis Abendshien 2024-04-28 21:22:20 -07:00 committed by GitHub
commit c9c399faa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4529 additions and 4015 deletions

View file

@ -0,0 +1,4 @@
from .open_file import open_file
from .file_opener import FileOpenerHelper, FileOpenerLabel
from .function_iterator import FunctionIterator
from .custom_runnable import CustomRunnable

View file

@ -0,0 +1,19 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal, QRunnable, QObject
class CustomRunnable(QRunnable, QObject):
done = Signal()
def __init__(self, function) -> None:
QRunnable.__init__(self)
QObject.__init__(self)
self.function = function
def run(self):
self.function()
self.done.emit()

View file

@ -0,0 +1,58 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import subprocess
from PySide6.QtWidgets import QLabel
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class FileOpenerHelper():
def __init__(self, filepath:str):
self.filepath = filepath
def set_filepath(self, filepath:str):
self.filepath = filepath
def open_file(self):
if os.path.exists(self.filepath):
os.startfile(self.filepath)
logging.info(f'Opening file: {self.filepath}')
else:
logging.error(f'File not found: {self.filepath}')
def open_explorer(self):
if os.path.exists(self.filepath):
logging.info(f'Opening file: {self.filepath}')
if os.name == 'nt': # Windows
command = f'explorer /select,"{self.filepath}"'
subprocess.run(command, shell=True)
else: # macOS and Linux
command = f'nautilus --select "{self.filepath}"' # Adjust for your Linux file manager if different
if subprocess.run(command, shell=True).returncode == 0:
file_loc = os.path.dirname(self.filepath)
file_loc = os.path.normpath(file_loc)
os.startfile(file_loc)
else:
logging.error(f'File not found: {self.filepath}')
class FileOpenerLabel(QLabel):
def __init__(self, text, parent=None):
super().__init__(text, parent)
def setFilePath(self, filepath):
self.filepath = filepath
def mousePressEvent(self, event):
super().mousePressEvent(event)
opener = FileOpenerHelper(self.filepath)
opener.open_explorer()

View file

@ -0,0 +1,21 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from types import FunctionType
from PySide6.QtCore import Signal, QObject
class FunctionIterator(QObject):
"""Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™"""
value = Signal(object)
def __init__(self, function: FunctionType):
super().__init__()
self.iterable = function
def run(self):
for i in self.iterable():
self.value.emit(i)

View file

@ -0,0 +1,49 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import sys
import traceback
import shutil
import subprocess
def open_file(path: str, file_manager: bool = False):
try:
if sys.platform == "win32":
normpath = os.path.normpath(path)
if file_manager:
command_name = "explorer"
command_args = [f"/select,{normpath}"]
else:
command_name = "start"
# first parameter is for title, NOT filepath
command_args = ["", normpath]
subprocess.Popen([command_name] + command_args, shell=True, close_fds=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_BREAKAWAY_FROM_JOB)
else:
if sys.platform == "darwin":
command_name = "open"
command_args = [path]
if file_manager:
# will reveal in Finder
command_args.append("-R")
else:
if file_manager:
command_name = "dbus-send"
# might not be guaranteed to launch default?
command_args = ["--session", "--dest=org.freedesktop.FileManager1", "--type=method_call",
"/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems",
f"array:string:file://{path}", "string:"]
else:
command_name = "xdg-open"
command_args = [path]
command = shutil.which(command_name)
if command is not None:
subprocess.Popen([command] + command_args, close_fds=True)
else:
logging.info(f"Could not find {command_name} on system PATH")
except:
traceback.print_exc()

View file

@ -0,0 +1,11 @@
from .tag_search import TagSearchPanel
from .build_tag import BuildTagPanel
from .tag_database import TagDatabasePanel
from .add_field import AddFieldModal
from .file_extension import FileExtensionModal
from .delete_unlinked import DeleteUnlinkedEntriesModal
from .relink_unlinked import RelinkUnlinkedEntries
from .fix_unlinked import FixUnlinkedEntriesModal
from .mirror_entities import MirrorEntriesModal
from .fix_dupes import FixDupeFilesModal
from .folders_to_tags import FoldersToTagsModal

View file

@ -0,0 +1,78 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox
from src.core.library import Library
class AddFieldModal(QWidget):
done = Signal(int)
def __init__(self, library:'Library'):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.lib = library
self.setWindowTitle(f'Add Field')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.title_widget = QLabel()
self.title_widget.setObjectName('fieldTitle')
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
'font-weight:bold;'
'font-size:14px;'
'padding-top: 6px'
'')
self.title_widget.setText('Add Field')
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.combo_box = QComboBox()
self.combo_box.setEditable(False)
# self.combo_box.setMaxVisibleItems(5)
self.combo_box.setStyleSheet('combobox-popup:0;')
self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
for df in self.lib.default_fields:
self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
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')
self.cancel_button = QPushButton()
self.cancel_button.setText('Cancel')
self.cancel_button.clicked.connect(self.hide)
# self.cancel_button.clicked.connect(widget.reset)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
self.save_button.setText('Add')
# self.save_button.setAutoDefault(True)
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.save_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.combo_box)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)

View file

@ -0,0 +1,230 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QScrollArea, QFrame, QTextEdit, QComboBox)
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.core.ts_core import TAG_COLORS
from src.qt.widgets import PanelWidget, PanelModal, TagWidget
from src.qt.modals import TagSearchPanel
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class BuildTagPanel(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library, tag_id:int=-1):
super().__init__()
self.lib: Library = library
# self.callback = callback
# self.tag_id = tag_id
self.tag = None
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,0,6,0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 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()
self.name_title.setText('Name')
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_layout.addWidget(self.name_field)
# Shorthand ------------------------------------------------------------
self.shorthand_widget = QWidget()
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
self.shorthand_layout.setStretch(1, 1)
self.shorthand_layout.setContentsMargins(0,0,0,0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.shorthand_title = QLabel()
self.shorthand_title.setText('Shorthand')
self.shorthand_layout.addWidget(self.shorthand_title)
self.shorthand_field = QLineEdit()
self.shorthand_layout.addWidget(self.shorthand_field)
# Aliases --------------------------------------------------------------
self.aliases_widget = QWidget()
self.aliases_layout = QVBoxLayout(self.aliases_widget)
self.aliases_layout.setStretch(1, 1)
self.aliases_layout.setContentsMargins(0,0,0,0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.aliases_title = QLabel()
self.aliases_title.setText('Aliases')
self.aliases_layout.addWidget(self.aliases_title)
self.aliases_field = QTextEdit()
self.aliases_field.setAcceptRichText(False)
self.aliases_field.setMinimumHeight(40)
self.aliases_layout.addWidget(self.aliases_field)
# Subtags ------------------------------------------------------------
self.subtags_widget = QWidget()
self.subtags_layout = QVBoxLayout(self.subtags_widget)
self.subtags_layout.setStretch(1, 1)
self.subtags_layout.setContentsMargins(0,0,0,0)
self.subtags_layout.setSpacing(0)
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.subtags_title = QLabel()
self.subtags_title.setText('Subtags')
self.subtags_layout.addWidget(self.subtags_title)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6,0,6,0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
# self.scroll_area.setMinimumHeight(60)
self.subtags_layout.addWidget(self.scroll_area)
self.subtags_add_button = QPushButton()
self.subtags_add_button.setText('+')
tsp = TagSearchPanel(self.lib)
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
self.add_tag_modal = PanelModal(tsp, 'Add Subtags', 'Add Subtags')
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)
# self.subtags_field = TagBoxWidget()
# self.subtags_field.setMinimumHeight(60)
# self.subtags_layout.addWidget(self.subtags_field)
# Shorthand ------------------------------------------------------------
self.color_widget = QWidget()
self.color_layout = QVBoxLayout(self.color_widget)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0,0,0,0)
self.color_layout.setSpacing(0)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel()
self.color_title.setText('Color')
self.color_layout.addWidget(self.color_title)
self.color_field = QComboBox()
self.color_field.setEditable(False)
self.color_field.setMaxVisibleItems(10)
self.color_field.setStyleSheet('combobox-popup:0;')
for color in TAG_COLORS:
self.color_field.addItem(color.title())
# self.color_field.setProperty("appearance", "flat")
self.color_field.currentTextChanged.connect(lambda c: self.color_field.setStyleSheet(f'''combobox-popup:0;
font-weight:600;
color:{get_tag_color(ColorType.TEXT, c.lower())};
background-color:{get_tag_color(ColorType.PRIMARY, c.lower())};
'''))
self.color_layout.addWidget(self.color_field)
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.subtags_widget)
self.root_layout.addWidget(self.color_widget)
# self.parent().done.connect(self.update_tag)
if tag_id >= 0:
self.tag = self.lib.get_tag(tag_id)
else:
self.tag = Tag(-1, 'New Tag', '', [], [], '')
self.set_tag(self.tag)
def add_subtag_callback(self, tag_id:int):
logging.info(f'adding {tag_id}')
# tag = self.lib.get_tag(self.tag_id)
# TODO: Create a single way to update tags and refresh library data
# new = self.build_tag()
self.tag.add_subtag(tag_id)
# self.tag = new
# self.lib.update_tag(new)
self.set_subtags()
# self.on_edit.emit(self.build_tag())
def remove_subtag_callback(self, tag_id:int):
logging.info(f'removing {tag_id}')
# tag = self.lib.get_tag(self.tag_id)
# TODO: Create a single way to update tags and refresh library data
# new = self.build_tag()
self.tag.remove_subtag(tag_id)
# self.tag = new
# self.lib.update_tag(new)
self.set_subtags()
# self.on_edit.emit(self.build_tag())
def set_subtags(self):
while self.scroll_layout.itemAt(0):
self.scroll_layout.takeAt(0).widget().deleteLater()
logging.info(f'Setting {self.tag.subtag_ids}')
c = QWidget()
l = QVBoxLayout(c)
l.setContentsMargins(0,0,0,0)
l.setSpacing(3)
for tag_id in self.tag.subtag_ids:
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True)
tw.on_remove.connect(lambda checked=False, t=tag_id: self.remove_subtag_callback(t))
l.addWidget(tw)
self.scroll_layout.addWidget(c)
def set_tag(self, tag:Tag):
# tag = self.lib.get_tag(tag_id)
self.name_field.setText(tag.name)
self.shorthand_field.setText(tag.shorthand)
self.aliases_field.setText('\n'.join(tag.aliases))
self.set_subtags()
self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower()))
# self.tag_id = tag.id
def build_tag(self) -> Tag:
# tag: Tag = self.tag
# if self.tag_id >= 0:
# tag = self.lib.get_tag(self.tag_id)
# else:
# tag = Tag(-1, '', '', [], [], '')
new_tag: Tag = Tag(
id=self.tag.id,
name=self.name_field.text(),
shorthand=self.shorthand_field.text(),
aliases=self.aliases_field.toPlainText().split('\n'),
subtags_ids=self.tag.subtag_ids,
color=self.color_field.currentText().lower())
logging.info(f'built {new_tag}')
return new_tag
# NOTE: The callback and signal do the same thing, I'm currently
# transitioning from using callbacks to the Qt method of using signals.
# self.tag_updated.emit(new_tag)
# self.callback(new_tag)
# def on_return(self, callback, text:str):
# if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
# self.search_field.setText('')
# self.update_tags('')
# else:
# self.search_field.setFocus()
# self.parentWidget().hide()

View file

@ -0,0 +1,128 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from PySide6.QtCore import Signal, Qt, QThreadPool
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListView
from src.core.library import ItemType, Library
from src.qt.helpers import CustomRunnable, FunctionIterator
from src.qt.widgets import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class DeleteUnlinkedEntriesModal(QWidget):
done = Signal()
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle(f'Delete Unlinked Entries')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName('descriptionLabel')
self.desc_widget.setWordWrap(True)
self.desc_widget.setText(f'''
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
''')
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
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')
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton()
self.delete_button.setText('&Delete')
self.delete_button.clicked.connect(self.hide)
self.delete_button.clicked.connect(lambda: self.delete_entries())
self.button_layout.addWidget(self.delete_button)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def refresh_list(self):
self.desc_widget.setText(f'''
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
''')
self.model.clear()
for i in self.lib.missing_files:
self.model.appendRow(QStandardItem(i))
def delete_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Deleting Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.lib.ref(pb))
# r.done.connect(lambda: self.done.emit())
# # r.done.connect(lambda: self.model.clear())
# QThreadPool.globalInstance().start(r)
# # r.run()
iterator = FunctionIterator(self.lib.remove_missing_files)
pw = ProgressWidget(
window_title='Deleting Entries',
label_text='',
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.missing_files)
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0]+1))
iterator.value.connect(lambda x: pw.update_label(f'Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries'))
iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
r = CustomRunnable(lambda:iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
# def delete_entries_runnable(self):
# deleted = []
# for i, missing in enumerate(self.lib.missing_files):
# # pb.setValue(i)
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
# try:
# id = self.lib.get_entry_id_from_filepath(missing)
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
# self.lib.remove_entry(id)
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
# deleted.append(missing)
# except KeyError:
# logging.info(
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
# yield i
# for d in deleted:
# self.lib.missing_files.remove(d)
# # self.driver.filter_items('')
# # self.done.emit()

View file

@ -0,0 +1,51 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem
from src.core.library import Library
from src.qt.widgets import PanelWidget
class FileExtensionModal(PanelWidget):
done = Signal()
def __init__(self, library:'Library'):
super().__init__()
self.lib = library
self.setWindowTitle(f'File Extensions')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(200, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.table = QTableWidget(len(self.lib.ignored_extensions), 1)
self.table.horizontalHeader().setVisible(False)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.add_button = QPushButton()
self.add_button.setText('&Add Extension')
self.add_button.clicked.connect(self.add_item)
self.add_button.setDefault(True)
self.add_button.setMinimumWidth(100)
self.root_layout.addWidget(self.table)
self.root_layout.addWidget(self.add_button, alignment=Qt.AlignmentFlag.AlignCenter)
self.refresh_list()
def refresh_list(self):
for i, ext in enumerate(self.lib.ignored_extensions):
self.table.setItem(i, 0, QTableWidgetItem(ext))
def add_item(self):
self.table.insertRow(self.table.rowCount())
def save(self):
self.lib.ignored_extensions.clear()
for i in range(self.table.rowCount()):
ext = self.table.item(i, 0)
if ext and ext.text():
self.lib.ignored_extensions.append(ext.text())

View file

@ -0,0 +1,159 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import os
import typing
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog
from src.core.library import Library
from src.qt.modals import MirrorEntriesModal
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class FixDupeFilesModal(QWidget):
# done = Signal(int)
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver = driver
self.count = -1
self.filename = ''
self.setWindowTitle(f'Fix Duplicate Files')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName('descriptionLabel')
self.desc_widget.setWordWrap(True)
self.desc_widget.setStyleSheet(
# 'background:blue;'
'text-align:left;'
# 'font-weight:bold;'
# 'font-size:14px;'
# 'padding-top: 6px'
'')
self.desc_widget.setText('''TagStudio supports importing DupeGuru results to manage duplicate files.''')
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.dupe_count = QLabel()
self.dupe_count.setObjectName('dupeCountLabel')
self.dupe_count.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
'font-weight:bold;'
'font-size:14px;'
# 'padding-top: 6px'
'')
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel()
self.file_label.setObjectName('fileLabel')
# self.file_label.setStyleSheet(
# # 'background:blue;'
# # 'text-align:center;'
# 'font-weight:bold;'
# 'font-size:14px;'
# # 'padding-top: 6px'
# '')
# self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText('No DupeGuru File Selected')
self.open_button = QPushButton()
self.open_button.setText('&Load DupeGuru File')
self.open_button.clicked.connect(lambda: self.select_file())
self.mirror_button = QPushButton()
self.mirror_modal = MirrorEntriesModal(self.lib, self.driver)
self.mirror_modal.done.connect(lambda: self.refresh_dupes())
self.mirror_button.setText('&Mirror Entries')
self.mirror_button.clicked.connect(lambda: self.mirror_modal.show())
self.mirror_desc = QLabel()
self.mirror_desc.setWordWrap(True)
self.mirror_desc.setText("""Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.""")
# self.mirror_delete_button = QPushButton()
# self.mirror_delete_button.setText('Mirror && Delete')
self.advice_label = QLabel()
self.advice_label.setWordWrap(True)
self.advice_label.setText("""After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's "Fix Unlinked Entries" feature in the Tools menu in order to delete the unlinked Entries.""")
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.done_button = QPushButton()
self.done_button.setText('&Done')
# self.save_button.setAutoDefault(True)
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.done_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.dupe_count)
self.root_layout.addWidget(self.file_label)
self.root_layout.addWidget(self.open_button)
# self.mirror_delete_button.setHidden(True)
self.root_layout.addWidget(self.mirror_button)
self.root_layout.addWidget(self.mirror_desc)
# self.root_layout.addWidget(self.mirror_delete_button)
self.root_layout.addWidget(self.advice_label)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
self.set_dupe_count(self.count)
def select_file(self):
qfd = QFileDialog(self,
'Open DupeGuru Results File',
os.path.normpath(self.lib.library_dir))
qfd.setFileMode(QFileDialog.FileMode.ExistingFile)
qfd.setNameFilter("DupeGuru Files (*.dupeguru)")
if qfd.exec_():
filename = qfd.selectedFiles()
if filename:
self.set_filename(filename[0])
def set_filename(self, filename:str):
if filename:
self.file_label.setText(filename)
else:
self.file_label.setText('No DupeGuru File Selected')
self.filename = filename
self.refresh_dupes()
self.mirror_modal.refresh_list()
def refresh_dupes(self):
self.lib.refresh_dupe_files(self.filename)
self.set_dupe_count(len(self.lib.dupe_files))
def set_dupe_count(self, count:int):
self.count = count
if self.count < 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(f'Duplicate File Matches: N/A')
elif self.count == 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(f'Duplicate File Matches: {count}')
else:
self.mirror_button.setDisabled(False)
self.dupe_count.setText(f'Duplicate File Matches: {count}')

View file

@ -0,0 +1,179 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import typing
from PySide6.QtCore import QThread, Qt, QThreadPool
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from src.core.library import Library
from src.qt.helpers import FunctionIterator, CustomRunnable
from src.qt.modals import DeleteUnlinkedEntriesModal, RelinkUnlinkedEntries
from src.qt.widgets import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class FixUnlinkedEntriesModal(QWidget):
# done = Signal(int)
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver = driver
self.count = -1
self.setWindowTitle(f'Fix Unlinked Entries')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName('descriptionLabel')
self.desc_widget.setWordWrap(True)
self.desc_widget.setStyleSheet(
# 'background:blue;'
'text-align:left;'
# 'font-weight:bold;'
# 'font-size:14px;'
# 'padding-top: 6px'
'')
self.desc_widget.setText('''Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.''')
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.missing_count = QLabel()
self.missing_count.setObjectName('missingCountLabel')
self.missing_count.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
'font-weight:bold;'
'font-size:14px;'
# 'padding-top: 6px'
'')
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
# self.missing_count.setText('Missing Files: N/A')
self.refresh_button = QPushButton()
self.refresh_button.setText('&Refresh')
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
self.search_button = QPushButton()
self.search_button.setText('&Search && Relink')
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
self.relink_class.done.connect(lambda: self.refresh_missing_files())
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
self.manual_button = QPushButton()
self.manual_button.setText('&Manual Relink')
self.delete_button = QPushButton()
self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver)
self.delete_modal.done.connect(lambda: self.set_missing_count(len(self.lib.missing_files)))
self.delete_modal.done.connect(lambda: self.driver.update_thumbs())
self.delete_button.setText('De&lete Unlinked Entries')
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
# self.combo_box = QComboBox()
# self.combo_box.setEditable(False)
# # self.combo_box.setMaxVisibleItems(5)
# self.combo_box.setStyleSheet('combobox-popup:0;')
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
# for df in self.lib.default_fields:
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
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.done_button = QPushButton()
self.done_button.setText('&Done')
# self.save_button.setAutoDefault(True)
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.done_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.missing_count)
self.root_layout.addWidget(self.refresh_button)
self.root_layout.addWidget(self.search_button)
self.manual_button.setHidden(True)
self.root_layout.addWidget(self.manual_button)
self.root_layout.addWidget(self.delete_button)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
self.set_missing_count(self.count)
def refresh_missing_files(self):
logging.info(f'Start RMF: {QThread.currentThread()}')
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Scanning Library')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
iterator = FunctionIterator(self.lib.refresh_missing_files)
pw = ProgressWidget(
window_title='Scanning Library',
label_text=f'Scanning Library for Unlinked Entries...',
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.entries)
)
pw.show()
iterator.value.connect(lambda v: pw.update_progress(v+1))
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
r = CustomRunnable(lambda:iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(),
self.set_missing_count(len(self.lib.missing_files)),
self.delete_modal.refresh_list()))
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
# QThreadPool.globalInstance().start(r)
# # r.run()
# pass
# def update_scan_value(self, pb:QProgressDialog, value=int):
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
# pb.setValue(value)
def set_missing_count(self, count:int):
self.count = count
if self.count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
self.missing_count.setText(f'Unlinked Entries: N/A')
elif self.count == 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
self.missing_count.setText(f'Unlinked Entries: {count}')
else:
self.search_button.setDisabled(False)
self.delete_button.setDisabled(False)
self.missing_count.setText(f'Unlinked Entries: {count}')

View file

@ -0,0 +1,282 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
import typing
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class FoldersToTagsModal(QWidget):
# done = Signal(int)
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.library = library
self.driver = driver
self.count = -1
self.filename = ''
self.setWindowTitle(f'Folders To Tags')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 800)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName('descriptionLabel')
self.desc_widget.setWordWrap(True)
self.desc_widget.setStyleSheet(
# 'background:blue;'
'text-align:left;'
# 'font-weight:bold;'
'font-size:18px;'
# 'padding-top: 6px'
'')
self.desc_widget.setText('''Creates tags based on the folder structure and applies them to entries.\n The Structure below shows all the tags that would be added and to which files they would be added. It being empty means that there are no Tag to be created or assigned''')
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6,0,6,0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.apply_button = QPushButton()
self.apply_button.setText('&Apply')
self.apply_button.clicked.connect(lambda: self.folders_to_tags(self.library))
self.showEvent = self.on_open
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.scroll_area)
self.root_layout.addWidget(self.apply_button)
def on_open(self,event):
for i in reversed(range(self.scroll_layout.count())):
self.scroll_layout.itemAt(i).widget().setParent(None)
data = self.generate_preview_data(self.library)
for folder in data["dirs"].values():
test = self.TreeItemTest(folder,None)
self.scroll_layout.addWidget(test)
def generate_preview_data(self,library:Library):
tree = dict(dirs={},files=[])
def add_tag_to_tree(list:list[Tag]):
branch = tree
for tag in list:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={},tag=tag,files=[])
branch = branch["dirs"][tag.name]
def add_folders_to_tree(list:list[str])->Tag:
branch = tree
for folder in list:
if folder not in branch["dirs"]:
new_tag = Tag(-1, folder,"",[],[],"green")
branch["dirs"][folder] = dict(dirs={},tag=new_tag,files=[])
branch = branch["dirs"][folder]
return branch
for tag in library.tags:
reversed_tag = self.reverse_tag(tag,None)
logging.info(set(map(lambda tag:tag.name ,reversed_tag)))
add_tag_to_tree(reversed_tag)
for entry in library.entries:
folders = entry.path.split("\\")
if len(folders) == 1 and folders[0] == "": continue
branch = add_folders_to_tree(folders)
if branch:
field_indexes = library.get_field_index_in_entry(entry,6)
has_tag=False
for index in field_indexes:
content = library.get_field_attr(entry.fields[index],"content")
for tag_id in content:
tag = library.get_tag(tag_id)
if tag.name == branch["tag"].name:
has_tag=True
break
if not has_tag:
branch["files"].append(entry.filename)
def cut_branches_adding_nothing(branch:dict):
folders = set(branch["dirs"].keys())
for folder in folders:
logging.info(folder)
cut = cut_branches_adding_nothing(branch["dirs"][folder])
if cut:
branch['dirs'].pop(folder)
if not "tag" in branch: return
if branch["tag"].id == -1:#Needs to be first
return False
if len(branch["dirs"].keys()) == 0:
return True
cut_branches_adding_nothing(tree)
return tree
def folders_to_tags(self,library:Library):
logging.info("Converting folders to Tags")
tree = dict(dirs={})
def add_tag_to_tree(list:list[Tag]):
branch = tree
for tag in list:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={},tag=tag)
branch = branch["dirs"][tag.name]
def add_folders_to_tree(list:list[str])->Tag:
branch = tree
for folder in list:
if folder not in branch["dirs"]:
new_tag = Tag(-1, folder,"",[],([branch["tag"].id] if "tag" in branch else []),"")
library.add_tag_to_library(new_tag)
branch["dirs"][folder] = dict(dirs={},tag=new_tag)
branch = branch["dirs"][folder]
return branch["tag"]
for tag in library.tags:
reversed_tag = self.reverse_tag(tag,None)
add_tag_to_tree(reversed_tag)
for entry in library.entries:
folders = entry.path.split("\\")
if len(folders)== 1 and folders[0]=="": continue
tag = add_folders_to_tree(folders)
if tag:
if not entry.has_tag(library,tag.id):
entry.add_tag(library,tag.id,6)
self.close()
logging.info("Done")
def reverse_tag(self,tag:Tag,list:list[Tag]) -> list[Tag]:
if list != None:
list.append(tag)
else:
list = [tag]
if len(tag.subtag_ids) == 0:
list.reverse()
return list
else:
for subtag_id in tag.subtag_ids:
subtag = self.library.get_tag(subtag_id)
return self.reverse_tag(subtag,list)
class ModifiedTagWidget(QWidget): # Needed to be modified because the original searched the display name in the library where it wasn't added yet
def __init__(self, tag:Tag,parentTag:Tag) -> None:
super().__init__()
self.tag = tag
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName('baseLayout')
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if parentTag != None:
text = f"{tag.name} ({parentTag.name})".replace('&', '&&')
else:
text = tag.name.replace('&', '&&')
self.bg_button.setText(text)
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName('innerLayout')
self.inner_layout.setContentsMargins(2, 2, 2, 2)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(math.ceil(22*1.5), 22)
self.bg_button.setStyleSheet(
f'QPushButton{{'
f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};'
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f'font-weight: 600;'
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f'border-radius: 6px;'
f'border-style:inset;'
f'border-width: {math.ceil(1*self.devicePixelRatio())}px;'
f'padding-right: 4px;'
f'padding-bottom: 1px;'
f'padding-left: 4px;'
f'font-size: 13px'
f'}}'
f'QPushButton::hover{{'
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
f'}}')
self.base_layout.addWidget(self.bg_button)
self.setMinimumSize(50,20)
class TreeItemTest(QWidget):
def __init__(self,data:dict,parentTag:Tag):
super().__init__()
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(20,0,0,0)
self.root_layout.setSpacing(1)
self.test = QWidget()
self.root_layout.addWidget(self.test)
self.tag_layout = FlowLayout(self.test)
self.tag_widget = FoldersToTagsModal.ModifiedTagWidget(data["tag"],parentTag)
self.tag_widget.bg_button.clicked.connect(lambda:self.hide_show())
self.tag_layout.addWidget(self.tag_widget)
self.children_widget = QWidget()
self.children_layout = QVBoxLayout(self.children_widget)
self.root_layout.addWidget(self.children_widget)
self.populate(data)
def hide_show(self):
self.children_widget.setHidden(not self.children_widget.isHidden())
def populate(self,data:dict):
for folder in data["dirs"].values():
item = FoldersToTagsModal.TreeItemTest(folder,data["tag"])
self.children_layout.addWidget(item)
for file in data["files"]:
label = QLabel()
label.setText(file)
self.children_layout.addWidget(label)
if len(data["files"]) == 0 and len(data["dirs"].values()) == 0:
self.hide_show()

View file

@ -0,0 +1,127 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from time import sleep
import typing
from PySide6.QtCore import Signal, Qt, QThreadPool
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListView
from src.core.library import Library
from src.qt.helpers import FunctionIterator, CustomRunnable
from src.qt.widgets import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class MirrorEntriesModal(QWidget):
done = Signal()
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle(f'Mirror Entries')
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,6,6,6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName('descriptionLabel')
self.desc_widget.setWordWrap(True)
self.desc_widget.setText(f'''
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
''')
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
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')
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.mirror_button = QPushButton()
self.mirror_button.setText('&Mirror')
self.mirror_button.clicked.connect(self.hide)
self.mirror_button.clicked.connect(lambda: self.mirror_entries())
self.button_layout.addWidget(self.mirror_button)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def refresh_list(self):
self.desc_widget.setText(f'''
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
''')
self.model.clear()
for i in self.lib.dupe_files:
self.model.appendRow(QStandardItem(str(i)))
def mirror_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.dupe_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Mirroring Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.mirror_entries_runnable(pb))
# r.done.connect(lambda: self.done.emit())
# r.done.connect(lambda: self.driver.preview_panel.refresh())
# # r.done.connect(lambda: self.model.clear())
# # QThreadPool.globalInstance().start(r)
# r.run()
iterator = FunctionIterator(self.mirror_entries_runnable)
pw = ProgressWidget(
window_title='Mirroring Entries',
label_text=f'Mirroring 1/{len(self.lib.dupe_files)} Entries...',
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.dupe_files)
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x+1))
iterator.value.connect(lambda x: pw.update_label(f'Mirroring {x+1}/{len(self.lib.dupe_files)} Entries...'))
r = CustomRunnable(lambda:iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(lambda: (
pw.hide(),
pw.deleteLater(),
self.driver.preview_panel.update_widgets(),
self.done.emit()
))
def mirror_entries_runnable(self):
mirrored = []
for i, dupe in enumerate(self.lib.dupe_files):
# pb.setValue(i)
# pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries')
entry_id_1 = self.lib.get_entry_id_from_filepath(
dupe[0])
entry_id_2 = self.lib.get_entry_id_from_filepath(
dupe[1])
self.lib.mirror_entry_fields([entry_id_1, entry_id_2])
sleep(0.005)
yield i
for d in mirrored:
self.lib.dupe_files.remove(d)
# self.driver.filter_items('')
# self.done.emit()

View file

@ -0,0 +1,90 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from PySide6.QtCore import QObject, Signal, QThreadPool
from src.core.library import Library
from src.qt.helpers import FunctionIterator, CustomRunnable
from src.qt.widgets import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class RelinkUnlinkedEntries(QObject):
done = Signal()
def __init__(self, library:'Library', driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver = driver
self.fixed = 0
def repair_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Relinking Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.repair_entries_runnable(pb))
# r.done.connect(lambda: self.done.emit())
# # r.done.connect(lambda: self.model.clear())
# QThreadPool.globalInstance().start(r)
# # r.run()
iterator = FunctionIterator(self.lib.fix_missing_files)
pw = ProgressWidget(
window_title='Relinking Entries',
label_text='',
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.missing_files)
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0]+1))
iterator.value.connect(lambda x: (self.increment_fixed() if x[1] else (), pw.update_label(f'Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked')))
# iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
r = CustomRunnable(lambda:iterator.run())
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed()))
QThreadPool.globalInstance().start(r)
def increment_fixed(self):
self.fixed += 1
def reset_fixed(self):
self.fixed = 0
# def repair_entries_runnable(self, pb: QProgressDialog):
# fixed = 0
# for i in self.lib.fix_missing_files():
# if i[1]:
# fixed += 1
# pb.setValue(i[0])
# pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked')
# for i, missing in enumerate(self.lib.missing_files):
# pb.setValue(i)
# pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries')
# self.lib.fix_missing_files()
# try:
# id = self.lib.get_entry_id_from_filepath(missing)
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
# self.lib.remove_entry(id)
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
# deleted.append(missing)
# except KeyError:
# logging.info(
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
# for d in deleted:
# self.lib.missing_files.remove(d)

View file

@ -0,0 +1,138 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QScrollArea, QFrame
from src.core.library import Library
from src.qt.widgets import PanelWidget, PanelModal, TagWidget
from src.qt.modals import BuildTagPanel
class TagDatabasePanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.first_tag_id = -1
self.tag_limit = 30
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,0,6,0)
self.search_field = QLineEdit()
self.search_field.setObjectName('searchField')
self.search_field.setMinimumSize(QSize(0, 32))
self.search_field.setPlaceholderText('Search Tags')
self.search_field.textEdited.connect(lambda x=self.search_field.text(): self.update_tags(x))
self.search_field.returnPressed.connect(lambda checked=False: self.on_return(self.search_field.text()))
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6,0,6,0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.update_tags('')
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text:str):
if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
self.search_field.setText('')
self.update_tags('')
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query:str):
# for c in self.scroll_layout.children():
# c.widget().deleteLater()
while self.scroll_layout.itemAt(0):
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
self.scroll_layout.takeAt(0).widget().deleteLater()
if query:
first_id_set = False
for tag_id in self.lib.search_tags(query, include_cluster=True)[:self.tag_limit-1]:
if not first_id_set:
self.first_tag_id = tag_id
first_id_set = True
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0,0,0,0)
l.setSpacing(3)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
tw.on_edit.connect(lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id)))
l.addWidget(tw)
self.scroll_layout.addWidget(c)
else:
first_id_set = False
for tag in self.lib.tags:
if not first_id_set:
self.first_tag_id = tag.id
first_id_set = True
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0,0,0,0)
l.setSpacing(3)
tw = TagWidget(self.lib, tag, True, False)
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
l.addWidget(tw)
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
def edit_tag(self, tag_id:int):
btp = BuildTagPanel(self.lib, tag_id)
# btp.on_edit.connect(lambda x: self.edit_tag_callback(x))
self.edit_modal = PanelModal(btp,
self.lib.get_tag(tag_id).display_name(self.lib),
'Edit Tag',
done_callback=(self.update_tags(self.search_field.text())),
has_save=True)
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
#TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead
panel: BuildTagPanel = self.edit_modal.widget
self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp))
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
self.edit_modal.show()
def edit_tag_callback(self, btp:BuildTagPanel):
self.lib.update_tag(btp.build_tag())
self.update_tags(self.search_field.text())
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent

View file

@ -0,0 +1,147 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QScrollArea, QFrame
from src.core.library import Library
from src.core.palette import ColorType, get_tag_color
from src.qt.widgets import PanelWidget, TagWidget
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class TagSearchPanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.first_tag_id = -1
self.tag_limit = 30
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6,0,6,0)
self.search_field = QLineEdit()
self.search_field.setObjectName('searchField')
self.search_field.setMinimumSize(QSize(0, 32))
self.search_field.setPlaceholderText('Search Tags')
self.search_field.textEdited.connect(lambda x=self.search_field.text(): self.update_tags(x))
self.search_field.returnPressed.connect(lambda checked=False: self.on_return(self.search_field.text()))
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6,0,6,0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text:str):
if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
self.tag_chosen.emit(self.first_tag_id)
self.search_field.setText('')
self.update_tags('')
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query:str):
# for c in self.scroll_layout.children():
# c.widget().deleteLater()
while self.scroll_layout.itemAt(0):
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
self.scroll_layout.takeAt(0).widget().deleteLater()
if query:
first_id_set = False
for tag_id in self.lib.search_tags(query, include_cluster=True)[:self.tag_limit-1]:
if not first_id_set:
self.first_tag_id = tag_id
first_id_set = True
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0,0,0,0)
l.setSpacing(3)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False)
ab = QPushButton()
ab.setMinimumSize(23, 23)
ab.setMaximumSize(23, 23)
ab.setText('+')
ab.setStyleSheet(
f'QPushButton{{'
f'background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};'
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};"
f'font-weight: 600;'
f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};"
f'border-radius: 6px;'
f'border-style:solid;'
f'border-width: {math.ceil(1*self.devicePixelRatio())}px;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f'padding-bottom: 5px;'
# f'padding-left: 4px;'
f'font-size: 20px;'
f'}}'
f'QPushButton::hover'
f'{{'
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};"
f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};"
f'background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};'
f'}}')
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
l.addWidget(tw)
l.addWidget(ab)
self.scroll_layout.addWidget(c)
else:
self.first_tag_id = -1
self.search_field.setFocus()
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent

View file

@ -548,4 +548,4 @@ class Validator(QIntValidator):
# print(input)
input = input.strip('0')
print(input)
return super().fixup(str(self.top()) if input else '1')
return super().fixup(str(self.top()) if input else '1')

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
from .fields import FieldContainer, FieldWidget
from .collage_icon import CollageIconRenderer
from .thumb_button import ThumbButton
from .thumb_renderer import ThumbRenderer
from .panel import PanelWidget, PanelModal
from .text_box_edit import EditTextBox
from .text_line_edit import EditTextLine
from .progress import ProgressWidget
from .tag import TagWidget
from .tag_box import TagBoxWidget
from .text import TextWidget
from .item_thumb import ItemThumb
from .preview_panel import PreviewPanel

View file

@ -0,0 +1,141 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import traceback
from pathlib import Path
import cv2
from PIL import Image, ImageChops, UnidentifiedImageError
from PySide6.QtCore import QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QTimer, QSettings
from src.core.library import Library
from src.core.ts_core import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class CollageIconRenderer(QObject):
rendered = Signal(Image.Image)
done = Signal()
def __init__(self, library:Library):
QObject.__init__(self)
self.lib = library
def render(self, entry_id, size:tuple[int,int], data_tint_mode, data_only_mode, keep_aspect):
entry = self.lib.get_entry(entry_id)
filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}')
file_type = os.path.splitext(filepath)[1].lower()[1:]
color: str = ''
try:
if data_tint_mode or data_only_mode:
color = '#000000' # Black (Default)
if entry.fields:
has_any_tags:bool = False
has_content_tags:bool = False
has_meta_tags:bool = False
for field in entry.fields:
if self.lib.get_field_attr(field, 'type') == 'tag_box':
if self.lib.get_field_attr(field, 'content'):
has_any_tags = True
if self.lib.get_field_attr(field, 'id') == 7:
has_content_tags = True
elif self.lib.get_field_attr(field, 'id') == 8:
has_meta_tags = True
if has_content_tags and has_meta_tags:
color = '#28bb48' # Green
elif has_any_tags:
color = '#ffd63d' # Yellow
# color = '#95e345' # Yellow-Green
else:
# color = '#fa9a2c' # Yellow-Orange
color = '#ed8022' # Orange
else:
color = '#e22c3c' # Red
if data_only_mode:
pic: Image = Image.new('RGB', size, color)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
if not data_only_mode:
logging.info(f'\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m')
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
# sys.stdout.flush()
if file_type in IMAGE_TYPES:
with Image.open(os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}')) as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = pic.convert(mode='RGB')
pic = ImageChops.hard_light(pic, Image.new('RGB', size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
elif file_type in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2))
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode='RGB') as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(pic, Image.new('RGB', size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logging.info(f'\n{ERROR} Couldn\'t read {entry.path}{os.sep}{entry.filename}')
with Image.open(os.path.normpath(f'{Path(__file__).parent.parent.parent}/resources/qt/images/thumb_broken_512.png')) as pic:
pic.thumbnail(size)
if data_tint_mode and color:
pic = pic.convert(mode='RGB')
pic = ImageChops.hard_light(pic, Image.new('RGB', size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except KeyboardInterrupt:
# self.quit(save=False, backup=True)
run = False
# clear()
logging.info('\n')
logging.info(f'{INFO} Collage operation cancelled.')
clear_scr=False
except:
logging.info(f'{ERROR} {entry.path}{os.sep}{entry.filename}')
traceback.print_exc()
logging.info('Continuing...')
self.done.emit()
# logging.info('Done!')
def get_file_color(self, ext: str):
if ext.lower().replace('.','',1) == 'gif':
return '\033[93m'
if ext.lower().replace('.','',1) in IMAGE_TYPES:
return '\033[37m'
elif ext.lower().replace('.','',1) in VIDEO_TYPES:
return '\033[96m'
elif ext.lower().replace('.','',1) in DOC_TYPES:
return '\033[92m'
else:
return '\033[97m'

View file

@ -0,0 +1,199 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
import os
from types import FunctionType
from pathlib import Path
from typing import Optional
from PIL import Image, ImageQt
from PySide6.QtCore import Qt,QEvent
from PySide6.QtGui import QPixmap, QEnterEvent
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
class FieldContainer(QWidget):
# TODO: reference a resources folder rather than path.parent.parent.parent.parent?
clipboard_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/clipboard_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25)))
clipboard_icon_128.load()
edit_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25)))
edit_icon_128.load()
trash_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/trash_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25)))
trash_icon_128.load()
def __init__(self, title:str='Field', inline:bool=True) -> None:
super().__init__()
# self.mode:str = mode
self.setObjectName('fieldContainer')
# self.item = item
self.title:str = title
self.inline:bool = inline
# self.editable:bool = editable
self.copy_callback:FunctionType = None
self.edit_callback:FunctionType = None
self.remove_callback:FunctionType = None
button_size = 24
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName('baseLayout')
self.root_layout.setContentsMargins(0, 0, 0, 0)
# self.setStyleSheet('background-color:red;')
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName('innerLayout')
self.inner_layout.setContentsMargins(0,0,0,0)
self.inner_layout.setSpacing(0)
self.inner_container = QWidget()
self.inner_container.setObjectName('innerContainer')
self.inner_container.setLayout(self.inner_layout)
self.root_layout.addWidget(self.inner_container)
self.title_container = QWidget()
# self.title_container.setStyleSheet('background:black;')
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setObjectName('fieldLayout')
self.title_layout.setContentsMargins(0,0,0,0)
self.title_layout.setSpacing(0)
self.inner_layout.addWidget(self.title_container)
self.title_widget = QLabel()
self.title_widget.setMinimumHeight(button_size)
self.title_widget.setObjectName('fieldTitle')
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet('font-weight: bold; font-size: 14px;')
# self.title_widget.setStyleSheet('background-color:orange;')
self.title_widget.setText(title)
# self.inner_layout.addWidget(self.title_widget)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
self.copy_button = QPushButton()
self.copy_button.setMinimumSize(button_size,button_size)
self.copy_button.setMaximumSize(button_size,button_size)
self.copy_button.setFlat(True)
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128)))
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButton()
self.edit_button.setMinimumSize(button_size,button_size)
self.edit_button.setMaximumSize(button_size,button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButton()
self.remove_button.setMinimumSize(button_size,button_size)
self.remove_button.setMaximumSize(button_size,button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128)))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
self.field_container = QWidget()
self.field_container.setObjectName('fieldContainer')
self.field_layout = QHBoxLayout()
self.field_layout.setObjectName('fieldLayout')
self.field_layout.setContentsMargins(0,0,0,0)
self.field_container.setLayout(self.field_layout)
# self.field_container.setStyleSheet('background-color:#666600;')
self.inner_layout.addWidget(self.field_container)
# self.set_inner_widget(mode)
def set_copy_callback(self, callback:Optional[FunctionType]):
try:
self.copy_button.clicked.disconnect()
except RuntimeError:
pass
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback:Optional[FunctionType]):
try:
self.edit_button.clicked.disconnect()
except RuntimeError:
pass
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback:Optional[FunctionType]):
try:
self.remove_button.clicked.disconnect()
except RuntimeError:
pass
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
def set_inner_widget(self, widget:'FieldWidget'):
# widget.setStyleSheet('background-color:green;')
# self.inner_container.dumpObjectTree()
# logging.info('')
if self.field_layout.itemAt(0):
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
# self.field_layout.removeItem(self.field_layout.itemAt(0))
self.field_layout.itemAt(0).widget().deleteLater()
self.field_layout.addWidget(widget)
def get_inner_widget(self) -> Optional['FieldWidget']:
if self.field_layout.itemAt(0):
return self.field_layout.itemAt(0).widget()
return None
def set_title(self, title:str):
self.title = title
self.title_widget.setText(title)
def set_inline(self, inline:bool):
self.inline = inline
# def set_editable(self, editable:bool):
# self.editable = editable
def enterEvent(self, event: QEnterEvent) -> None:
# if self.field_layout.itemAt(1):
# self.field_layout.itemAt(1).
# NOTE: You could pass the hover event to the FieldWidget if needed.
if self.copy_callback:
self.copy_button.setHidden(False)
if self.edit_callback:
self.edit_button.setHidden(False)
if self.remove_callback:
self.remove_button.setHidden(False)
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
if self.copy_callback:
self.copy_button.setHidden(True)
if self.edit_callback:
self.edit_button.setHidden(True)
if self.remove_callback:
self.remove_button.setHidden(True)
return super().leaveEvent(event)
class FieldWidget(QWidget):
field = dict
def __init__(self, title) -> None:
super().__init__()
# self.item = item
self.title = title

View file

@ -0,0 +1,483 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import time
import typing
from types import FunctionType
from pathlib import Path
from typing import Optional
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QSize, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QBoxLayout, QCheckBox
from src.core.library import ItemType, Library
from src.core.ts_core import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.qt.flowlayout import FlowWidget
from src.qt.helpers import FileOpenerHelper
from src.qt.widgets import ThumbRenderer, ThumbButton
if typing.TYPE_CHECKING:
from src.qt.widgets import PreviewPanel
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class ItemThumb(FlowWidget):
"""
The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.).
"""
update_cutoff: float = time.time()
collation_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/collation_icon_128.png'))
collation_icon_128.load()
tag_group_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/tag_group_icon_128.png'))
tag_group_icon_128.load()
small_text_style = (
f'background-color:rgba(0, 0, 0, 128);'
f'font-family:Oxanium;'
f'font-weight:bold;'
f'font-size:12px;'
f'border-radius:3px;'
f'padding-top: 4px;'
f'padding-right: 1px;'
f'padding-bottom: 1px;'
f'padding-left: 1px;'
)
med_text_style = (
f'background-color:rgba(17, 15, 27, 192);'
f'font-family:Oxanium;'
f'font-weight:bold;'
f'font-size:18px;'
f'border-radius:3px;'
f'padding-top: 4px;'
f'padding-right: 1px;'
f'padding-bottom: 1px;'
f'padding-left: 1px;'
)
def __init__(self, mode: Optional[ItemType], library: Library, panel: 'PreviewPanel', thumb_size: tuple[int, int]):
"""Modes: entry, collation, tag_group"""
super().__init__()
self.lib = library
self.panel = panel
self.mode = mode
self.item_id: int = -1
self.isFavorite: bool = False
self.isArchived: bool = False
self.thumb_size:tuple[int,int]= thumb_size
self.setMinimumSize(*thumb_size)
self.setMaximumSize(*thumb_size)
check_size = 24
# self.setStyleSheet('background-color:red;')
# +----------+
# | ARC FAV| Top Right: Favorite & Archived Badges
# | |
# | |
# |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon
# +----------+ Lower Right: Collation Count, Video Length, or Word Count
# Thumbnail ============================================================
# +----------+
# |*--------*|
# || ||
# || ||
# |*--------*|
# +----------+
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName('baseLayout')
# self.base_layout.setRowStretch(1, 2)
self.base_layout.setContentsMargins(0, 0, 0, 0)
# +----------+
# |[~~~~~~~~]|
# | |
# | |
# | |
# +----------+
self.top_layout = QHBoxLayout()
self.top_layout.setObjectName('topLayout')
# self.top_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# self.top_layout.setColumnStretch(1, 2)
self.top_layout.setContentsMargins(6, 6, 6, 6)
self.top_container = QWidget()
self.top_container.setLayout(self.top_layout)
self.base_layout.addWidget(self.top_container)
# +----------+
# |[~~~~~~~~]|
# | ^ |
# | | |
# | v |
# +----------+
self.base_layout.addStretch(2)
# +----------+
# |[~~~~~~~~]|
# | ^ |
# | v |
# |[~~~~~~~~]|
# +----------+
self.bottom_layout = QHBoxLayout()
self.bottom_layout.setObjectName('bottomLayout')
# self.bottom_container.setAlignment(Qt.AlignmentFlag.AlignBottom)
# self.bottom_layout.setColumnStretch(1, 2)
self.bottom_layout.setContentsMargins(6, 6, 6, 6)
self.bottom_container = QWidget()
self.bottom_container.setLayout(self.bottom_layout)
self.base_layout.addWidget(self.bottom_container)
# self.root_layout = QGridLayout(self)
# self.root_layout.setObjectName('rootLayout')
# self.root_layout.setColumnStretch(1, 2)
# self.root_layout.setRowStretch(1, 2)
# self.root_layout.setContentsMargins(6,6,6,6)
# # root_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.thumb_button = ThumbButton(self, thumb_size)
self.renderer = ThumbRenderer()
self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i),
self.update_size(
ts, size=s),
self.set_extension(ext)))
self.thumb_button.setFlat(True)
# self.bg_button.setStyleSheet('background-color:blue;')
# self.bg_button.setLayout(self.root_layout)
self.thumb_button.setLayout(self.base_layout)
# self.bg_button.setMinimumSize(*thumb_size)
# self.bg_button.setMaximumSize(*thumb_size)
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper('')
open_file_action = QAction('Open file', self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction('Open file in explorer', self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
# Static Badges ========================================================
# Item Type Badge ------------------------------------------------------
# Used for showing the Tag Group / Collation icons.
# Mutually exclusive with the File Extension Badge.
self.item_type_badge = QLabel()
self.item_type_badge.setObjectName('itemBadge')
self.item_type_badge.setPixmap(QPixmap.fromImage(ImageQt.ImageQt(
ItemThumb.collation_icon_128.resize((check_size, check_size), Image.Resampling.BILINEAR))))
self.item_type_badge.setMinimumSize(check_size, check_size)
self.item_type_badge.setMaximumSize(check_size, check_size)
# self.root_layout.addWidget(self.item_type_badge, 2, 0)
self.bottom_layout.addWidget(self.item_type_badge)
# File Extension Badge -------------------------------------------------
# Mutually exclusive with the File Extension Badge.
self.ext_badge = QLabel()
self.ext_badge.setObjectName('extBadge')
# self.ext_badge.setText('MP4')
# self.ext_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.ext_badge.setStyleSheet(ItemThumb.small_text_style)
# self.type_badge.setAlignment(Qt.AlignmentFlag.AlignRight)
# self.root_layout.addWidget(self.ext_badge, 2, 0)
self.bottom_layout.addWidget(self.ext_badge)
# self.type_badge.setHidden(True)
# bl_layout.addWidget(self.type_badge)
self.bottom_layout.addStretch(2)
# Count Badge ----------------------------------------------------------
# Used for Tag Group + Collation counts, video length, word count, etc.
self.count_badge = QLabel()
self.count_badge.setObjectName('countBadge')
# self.count_badge.setMaximumHeight(17)
self.count_badge.setText('-:--')
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignBottom)
# self.root_layout.addWidget(self.count_badge, 2, 2)
self.bottom_layout.addWidget(
self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom)
self.top_layout.addStretch(2)
# Intractable Badges ===================================================
self.cb_container = QWidget()
# check_badges.setStyleSheet('background-color:cyan;')
self.cb_layout = QHBoxLayout()
self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft)
self.cb_layout.setContentsMargins(0, 0, 0, 0)
self.cb_layout.setSpacing(6)
self.cb_container.setLayout(self.cb_layout)
# self.cb_container.setHidden(True)
# self.root_layout.addWidget(self.check_badges, 0, 2)
self.top_layout.addWidget(self.cb_container)
# Favorite Badge -------------------------------------------------------
self.favorite_badge = QCheckBox()
self.favorite_badge.setObjectName('favBadge')
self.favorite_badge.setToolTip('Favorite')
self.favorite_badge.setStyleSheet(f'QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}'
f'QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}'
f'QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}'
# f'QCheckBox{{background-color:yellow;}}'
)
self.favorite_badge.setMinimumSize(check_size, check_size)
self.favorite_badge.setMaximumSize(check_size, check_size)
self.favorite_badge.stateChanged.connect(
lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x)))
# self.fav_badge.setContentsMargins(0,0,0,0)
# tr_layout.addWidget(self.fav_badge)
# root_layout.addWidget(self.fav_badge, 0, 2)
self.cb_layout.addWidget(self.favorite_badge)
self.favorite_badge.setHidden(True)
# Archive Badge --------------------------------------------------------
self.archived_badge = QCheckBox()
self.archived_badge.setObjectName('archiveBadge')
self.archived_badge.setToolTip('Archive')
self.archived_badge.setStyleSheet(f'QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}'
f'QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}'
f'QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}'
# f'QCheckBox{{background-color:red;}}'
)
self.archived_badge.setMinimumSize(check_size, check_size)
self.archived_badge.setMaximumSize(check_size, check_size)
# self.archived_badge.clicked.connect(lambda x: self.assign_archived(x))
self.archived_badge.stateChanged.connect(
lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x)))
# tr_layout.addWidget(self.archive_badge)
self.cb_layout.addWidget(self.archived_badge)
self.archived_badge.setHidden(True)
# root_layout.addWidget(self.archive_badge, 0, 2)
# self.dumpObjectTree()
self.set_mode(mode)
def set_mode(self, mode: Optional[ItemType]) -> None:
if mode is None:
self.unsetCursor()
self.thumb_button.setHidden(True)
# self.check_badges.setHidden(True)
# self.ext_badge.setHidden(True)
# self.item_type_badge.setHidden(True)
pass
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(False)
# Count Badge depends on file extension (video length, word count)
self.item_type_badge.setHidden(True)
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
self.count_badge.setHidden(True)
self.ext_badge.setHidden(True)
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(True)
self.ext_badge.setHidden(True)
self.count_badge.setStyleSheet(ItemThumb.med_text_style)
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
# self.cb_container.setHidden(True)
self.ext_badge.setHidden(True)
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
self.mode = mode
# logging.info(f'Set Mode To: {self.mode}')
# def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None:
# """Updates the ItemThumb's visuals."""
# if thumb:
# pass
def set_extension(self, ext: str) -> None:
if ext and ext not in IMAGE_TYPES or ext in ['gif', 'apng']:
self.ext_badge.setHidden(False)
self.ext_badge.setText(ext.upper())
if ext in VIDEO_TYPES + AUDIO_TYPES:
self.count_badge.setHidden(False)
else:
if self.mode == ItemType.ENTRY:
self.ext_badge.setHidden(True)
self.count_badge.setHidden(True)
def set_count(self, count: str) -> None:
if count:
self.count_badge.setHidden(False)
self.count_badge.setText(count)
else:
if self.mode == ItemType.ENTRY:
self.ext_badge.setHidden(True)
self.count_badge.setHidden(True)
def update_thumb(self, timestamp: float, image: QPixmap = None):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}')
if timestamp > ItemThumb.update_cutoff:
self.thumb_button.setIcon(image if image else QPixmap())
# element.repaint()
def update_size(self, timestamp: float, size: QSize):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}')
if timestamp > ItemThumb.update_cutoff:
if self.thumb_button.iconSize != size:
self.thumb_button.setIconSize(size)
self.thumb_button.setMinimumSize(size)
self.thumb_button.setMaximumSize(size)
def update_clickable(self, clickable: FunctionType = None):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}')
try:
self.thumb_button.clicked.disconnect()
except RuntimeError:
pass
if clickable:
self.thumb_button.clicked.connect(clickable)
def update_badges(self):
if self.mode == ItemType.ENTRY:
# logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}')
# logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}')
self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0))
self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1))
def set_item_id(self, id: int):
'''
also sets the filepath for the file opener
'''
self.item_id = id
if(id == -1):
return
entry = self.lib.get_entry(self.item_id)
filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}')
self.opener.set_filepath(filepath)
def assign_favorite(self, value: bool):
# Switching mode to None to bypass mode-specific operations when the
# checkbox's state changes.
mode = self.mode
self.mode = None
self.isFavorite = value
self.favorite_badge.setChecked(value)
if not self.thumb_button.underMouse():
self.favorite_badge.setHidden(not self.isFavorite)
self.mode = mode
def assign_archived(self, value: bool):
# Switching mode to None to bypass mode-specific operations when the
# checkbox's state changes.
mode = self.mode
self.mode = None
self.isArchived = value
self.archived_badge.setChecked(value)
if not self.thumb_button.underMouse():
self.archived_badge.setHidden(not self.isArchived)
self.mode = mode
def show_check_badges(self, show: bool):
if self.mode != ItemType.TAG_GROUP:
self.favorite_badge.setHidden(
True if (not show and not self.isFavorite) else False)
self.archived_badge.setHidden(
True if (not show and not self.isArchived) else False)
def enterEvent(self, event: QEnterEvent) -> None:
self.show_check_badges(True)
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
self.show_check_badges(False)
return super().leaveEvent(event)
def on_archived_check(self, value: bool):
# logging.info(f'Archived Check: {value}, Mode: {self.mode}')
if self.mode == ItemType.ENTRY:
self.isArchived = value
DEFAULT_META_TAG_FIELD = 8
temp = (ItemType.ENTRY,self.item_id)
if list(self.panel.driver.selected).count(temp) > 0: # Is the archived badge apart of the selection?
# Yes, then add archived tag to all selected.
for x in self.panel.driver.selected:
e = self.lib.get_entry(x[1])
if value:
self.archived_badge.setHidden(False)
e.add_tag(self.panel.driver.lib, 0, field_id=DEFAULT_META_TAG_FIELD, field_index=-1)
else:
e.remove_tag(self.panel.driver.lib, 0)
else:
# No, then add archived tag to the entry this badge is on.
e = self.lib.get_entry(self.item_id)
if value:
self.favorite_badge.setHidden(False)
e.add_tag(self.panel.driver.lib, 0, field_id=DEFAULT_META_TAG_FIELD, field_index=-1)
else:
e.remove_tag(self.panel.driver.lib, 0)
if self.panel.isOpen:
self.panel.update_widgets()
self.panel.driver.update_badges()
# def on_archived_uncheck(self):
# if self.mode == SearchItemType.ENTRY:
# self.isArchived = False
# e = self.lib.get_entry(self.item_id)
def on_favorite_check(self, value: bool):
# logging.info(f'Favorite Check: {value}, Mode: {self.mode}')
if self.mode == ItemType.ENTRY:
self.isFavorite = value
DEFAULT_META_TAG_FIELD = 8
temp = (ItemType.ENTRY,self.item_id)
if list(self.panel.driver.selected).count(temp) > 0: # Is the favorite badge apart of the selection?
# Yes, then add favorite tag to all selected.
for x in self.panel.driver.selected:
e = self.lib.get_entry(x[1])
if value:
self.favorite_badge.setHidden(False)
e.add_tag(self.panel.driver.lib, 1, field_id=DEFAULT_META_TAG_FIELD, field_index=-1)
else:
e.remove_tag(self.panel.driver.lib, 1)
else:
# No, then add favorite tag to the entry this badge is on.
e = self.lib.get_entry(self.item_id)
if value:
self.favorite_badge.setHidden(False)
e.add_tag(self.panel.driver.lib, 1, field_id=DEFAULT_META_TAG_FIELD, field_index=-1)
else:
e.remove_tag(self.panel.driver.lib, 1)
if self.panel.isOpen:
self.panel.update_widgets()
self.panel.driver.update_badges()
# def on_favorite_uncheck(self):
# if self.mode == SearchItemType.ENTRY:
# self.isFavorite = False
# e = self.lib.get_entry(self.item_id)
# e.remove_tag(1)

View file

@ -0,0 +1,99 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from types import FunctionType
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
class PanelModal(QWidget):
saved = Signal()
# 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,
done_callback:FunctionType=None,
# cancel_callback:FunctionType=None,
save_callback:FunctionType=None,has_save:bool=False):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.widget = widget
self.setWindowTitle(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(
# 'background:blue;'
# 'text-align:center;'
'font-weight:bold;'
'font-size:14px;'
'padding-top: 6px'
'')
self.title_widget.setText(title)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
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):
self.done_button = QPushButton()
self.done_button.setText('Done')
self.done_button.setAutoDefault(True)
self.done_button.clicked.connect(self.hide)
if done_callback:
self.done_button.clicked.connect(done_callback)
self.button_layout.addWidget(self.done_button)
if (save_callback or has_save):
self.cancel_button = QPushButton()
self.cancel_button.setText('Cancel')
self.cancel_button.clicked.connect(self.hide)
self.cancel_button.clicked.connect(widget.reset)
# self.cancel_button.clicked.connect(cancel_callback)
self.button_layout.addWidget(self.cancel_button)
if (save_callback or has_save):
self.save_button = QPushButton()
self.save_button.setText('Save')
self.save_button.setAutoDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(self.saved.emit)
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)
widget.done.connect(lambda: save_callback(widget.get_content()))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(widget)
self.root_layout.setStretch(1,2)
self.root_layout.addWidget(self.button_container)
class PanelWidget(QWidget):
"""
Used for widgets that go in a modal panel, ex. for editing or searching.
"""
done = Signal()
def __init__(self):
super().__init__()
def get_content(self)-> str:
pass
def reset(self):
pass

View file

@ -0,0 +1,797 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import time
import typing
from types import FunctionType
from datetime import datetime as dt
import cv2
from PIL import Image, UnidentifiedImageError
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtGui import QResizeEvent, QAction
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame,
QSplitter, QSizePolicy, QMessageBox)
from humanfriendly import format_size
from src.core.library import Entry, ItemType, Library
from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES
from src.qt.helpers import FileOpenerLabel, FileOpenerHelper, open_file
from src.qt.modals import AddFieldModal
from src.qt.widgets import (ThumbRenderer, FieldContainer, TagBoxWidget, TextWidget, PanelModal, EditTextBox,
EditTextLine, ItemThumb)
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class PreviewPanel(QWidget):
"""The Preview Panel Widget."""
tags_updated = Signal()
def __init__(self, library: Library, driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver:QtDriver = driver
self.initialized = False
self.isOpen: bool = False
# self.filepath = None
# self.item = None # DEPRECATED, USE self.selected
self.common_fields = []
self.mixed_fields = []
self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items
self.tag_callback = None
self.containers: list[QWidget] = []
self.img_button_size: tuple[int, int] = (266, 266)
self.image_ratio: float = 1.0
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
self.image_container = QWidget()
image_layout = QHBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
splitter = QSplitter()
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
self.preview_img = QPushButton()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper('')
self.open_file_action = QAction('Open file', self)
self.open_explorer_action = QAction('Open file in explorer', self)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.tr = ThumbRenderer()
self.tr.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
self.tr.updated_ratio.connect(lambda ratio: (self.set_image_ratio(ratio),
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()), ratio)))
splitter.splitterMoved.connect(lambda: self.update_image_size((self.image_container.size().width(), self.image_container.size().height())))
splitter.addWidget(self.image_container)
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
self.file_label = FileOpenerLabel('Filename')
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.file_label.setStyleSheet('font-weight: bold; font-size: 12px')
self.dimensions_label = QLabel('Dimensions')
self.dimensions_label.setWordWrap(True)
# self.dim_label.setTextInteractionFlags(
# Qt.TextInteractionFlag.TextSelectableByMouse)
self.dimensions_label.setStyleSheet(ItemThumb.small_text_style)
# small_text_style = (
# f'background-color:rgba(17, 15, 27, 192);'
# f'font-family:Oxanium;'
# f'font-weight:bold;'
# f'font-size:12px;'
# f'border-radius:3px;'
# f'padding-top: 4px;'
# f'padding-right: 1px;'
# f'padding-bottom: 1px;'
# f'padding-left: 1px;'
# )
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(6,1,6,6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName('entryScrollContainer')
scroll_container.setLayout(self.scroll_layout)
# scroll_container.setStyleSheet('background:#080716; border-radius:12px;')
scroll_container.setStyleSheet(
'background:#00000000;'
'border-style:none;'
f'QScrollBar::{{background:red;}}'
)
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0,0,0,0)
info_layout.setSpacing(6)
self.setStyleSheet(
'background:#00000000;'
f'QScrollBar::{{background:red;}}'
)
scroll_area = QScrollArea()
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShadow(QFrame.Shadow.Plain)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setStyleSheet(
'background:#55000000;'
'border-radius:12px;'
'border-style:solid;'
'border-width:1px;'
'border-color:#11FFFFFF;'
# f'QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{border: none;background: none;}}'
# f'QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{border: none;background: none;color: none;}}'
f'QScrollBar::{{background:red;}}'
)
scroll_area.setWidget(scroll_container)
info_layout.addWidget(self.file_label)
info_layout.addWidget(self.dimensions_label)
info_layout.addWidget(scroll_area)
splitter.addWidget(info_section)
root_layout.addWidget(splitter)
splitter.setStretchFactor(1, 2)
self.afb_container = QWidget()
self.afb_layout = QVBoxLayout(self.afb_container)
self.afb_layout.setContentsMargins(0,12,0,0)
self.add_field_button = QPushButton()
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumSize(96, 28)
self.add_field_button.setMaximumSize(96, 28)
self.add_field_button.setText('Add Field')
self.add_field_button.setStyleSheet(
f'QPushButton{{'
# f'background: #1E1A33;'
# f'color: #CDA7F7;'
f'font-weight: bold;'
# f"border-color: #2B2547;"
f'border-radius: 6px;'
f'border-style:solid;'
# f'border-width:{math.ceil(1*self.devicePixelRatio())}px;'
'background:#55000000;'
'border-width:1px;'
'border-color:#11FFFFFF;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
# f'padding-bottom: 5px;'
# f'padding-left: 4px;'
f'font-size: 13px;'
f'}}'
f'QPushButton::hover'
f'{{'
f'background: #333333;'
f'}}')
self.afb_layout.addWidget(self.add_field_button)
self.afm = AddFieldModal(self.lib)
self.place_add_field_button()
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()))
def resizeEvent(self, event: QResizeEvent) -> None:
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()))
return super().resizeEvent(event)
def get_preview_size(self) -> tuple[int, int]:
return (self.image_container.size().width(), self.image_container.size().height())
def set_image_ratio(self, ratio:float):
# logging.info(f'Updating Ratio to: {ratio} #####################################################')
self.image_ratio = ratio
def update_image_size(self, size:tuple[int, int], ratio:float = None):
if ratio:
self.set_image_ratio(ratio)
# self.img_button_size = size
# logging.info(f'')
# self.preview_img.setMinimumSize(64,64)
adj_width = size[0]
adj_height = size[1]
# Landscape
if self.image_ratio > 1:
# logging.info('Landscape')
adj_height = size[0] * (1/self.image_ratio)
# Portrait
elif self.image_ratio <= 1:
# logging.info('Portrait')
adj_width = size[1] * self.image_ratio
if adj_width > size[0]:
adj_height = adj_height * (size[0]/adj_width)
adj_width = size[0]
elif adj_height > size[1]:
adj_width = adj_width * (size[1]/adj_height)
adj_height = size[1]
# adj_width = min(adj_width, self.image_container.size().width())
# adj_height = min(adj_width, self.image_container.size().height())
# self.preview_img.setMinimumSize(s)
# self.preview_img.setMaximumSize(s_max)
adj_size = QSize(adj_width, adj_height)
self.img_button_size = (adj_width, adj_height)
self.preview_img.setMaximumSize(adj_size)
self.preview_img.setIconSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
# if type(self.item) == Entry:
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
# self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio())
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
# logging.info(f' Max Button Size: {size}')
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
# logging.info(f'')
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
def place_add_field_button(self):
self.scroll_layout.addWidget(self.afb_container)
self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter)
try:
self.afm.done.disconnect()
self.add_field_button.clicked.disconnect()
except RuntimeError:
pass
# self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets()))
self.afm.done.connect(lambda f: (self.add_field_to_selected(f), self.update_widgets()))
self.add_field_button.clicked.connect(self.afm.show)
def add_field_to_selected(self, field_id: int):
"""Adds an entry field to one or more selected items."""
added = set()
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added:
self.lib.add_field_to_entry(item_pair[1], field_id)
added.add(item_pair[1])
# def update_widgets(self, item: Union[Entry, Collation, Tag]):
def update_widgets(self):
"""
Renders the panel's widgets with the newest data from the Library.
"""
logging.info(f'[ENTRY PANEL] UPDATE WIDGETS ({self.driver.selected})' )
self.isOpen = True
# self.tag_callback = tag_callback if tag_callback else None
window_title = ''
# 0 Selected Items
if len(self.driver.selected) == 0:
if len(self.selected) != 0 or not self.initialized:
self.file_label.setText(f"No Items Selected")
self.file_label.setFilePath('')
self.dimensions_label.setText("")
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), '', (512, 512), ratio, True)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
for i, c in enumerate(self.containers):
c.setHidden(True)
self.selected = list(self.driver.selected)
self.add_field_button.setHidden(True)
# 1 Selected Item
elif len(self.driver.selected) == 1:
# 1 Selected Entry
if self.driver.selected[0][0] == ItemType.ENTRY:
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
# If a new selection is made, update the thumbnail and filepath.
if (len(self.selected) == 0
or self.selected != self.driver.selected):
filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
self.file_label.setFilePath(filepath)
window_title = filepath
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), filepath, (512, 512), ratio)
self.file_label.setText("\u200b".join(filepath))
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
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 somewhere else, this is just here temporarily.
extension = os.path.splitext(filepath)[1][1:].lower()
try:
image = None
if extension in IMAGE_TYPES:
image = Image.open(filepath)
if image.mode == 'RGBA':
new_bg = Image.new('RGB', image.size, color='#222222')
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != 'RGB':
image = image.convert(mode='RGB')
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2))
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Stats for specific file types are displayed here.
if extension in (IMAGE_TYPES + VIDEO_TYPES):
self.dimensions_label.setText(f"{extension.upper()}{format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px")
else:
self.dimensions_label.setText(f"{extension.upper()}")
if not image:
self.dimensions_label.setText(f"{extension.upper()}{format_size(os.stat(filepath).st_size)}")
raise UnidentifiedImageError
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
pass
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.preview_img.clicked.connect(
lambda checked=False, filepath=filepath: open_file(filepath))
self.selected = list(self.driver.selected)
for i, f in enumerate(item.fields):
self.write_container(i, f)
# 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)
# 1 Selected Collation
elif self.driver.selected[0][0] == ItemType.COLLATION:
pass
# 1 Selected Tag
elif self.driver.selected[0][0] == ItemType.TAG_GROUP:
pass
# Multiple Selected Items
elif len(self.driver.selected) > 1:
if self.selected != self.driver.selected:
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.file_label.setFilePath('')
self.dimensions_label.setText("")
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), '', (512, 512), ratio, True)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.common_fields = []
self.mixed_fields = []
for i, item_pair in enumerate(self.driver.selected):
if item_pair[0] == ItemType.ENTRY:
item = self.lib.get_entry(item_pair[1])
if i == 0:
for f in item.fields:
self.common_fields.append(f)
else:
common_to_remove = []
for f in self.common_fields:
# Common field found (Same ID, identical content)
if f not in item.fields:
common_to_remove.append(f)
# Mixed field found (Same ID, different content)
if self.lib.get_field_index_in_entry(item, self.lib.get_field_attr(f, 'id')):
# if self.lib.get_field_attr(f, 'type') == ('tag_box'):
# pass
# logging.info(f)
# logging.info(type(f))
f_stripped = {self.lib.get_field_attr(f, 'id'):None}
if f_stripped not in self.mixed_fields and (f not in self.common_fields or f in common_to_remove):
# and (f not in self.common_fields or f in common_to_remove)
self.mixed_fields.append(f_stripped)
self.common_fields = [f for f in self.common_fields if f not in common_to_remove]
order: list[int] = (
[0] +
[1, 2] +
[9, 17, 18, 19, 20] +
[8, 7, 6] +
[4] +
[3, 21] +
[10, 14, 11, 12, 13, 22] +
[5]
)
self.mixed_fields = sorted(self.mixed_fields, key=lambda x: order.index(self.lib.get_field_attr(x, 'id')))
self.selected = list(self.driver.selected)
for i, f in enumerate(self.common_fields):
logging.info(f'ci:{i}, f:{f}')
self.write_container(i, f)
for i, f in enumerate(self.mixed_fields, start = len(self.common_fields)):
logging.info(f'mi:{i}, f:{f}')
self.write_container(i, f, 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
# # Uninitialized or New Item:
# if not self.item or self.item.id != item.id:
# # logging.info(f'Uninitialized or New Item ({item.id})')
# if type(item) == Entry:
# # New Entry: Render preview and update filename label
# filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
# window_title = filepath
# ratio: float = self.devicePixelRatio()
# self.tr.render_big(time.time(), filepath, (512, 512), ratio)
# self.file_label.setText("\u200b".join(filepath))
# # TODO: Deal with this later.
# # https://stackoverflow.com/questions/64252654/pyqt5-drag-and-drop-into-system-file-explorer-with-delayed-encoding
# # https://doc.qt.io/qtforpython-5/PySide2/QtCore/QMimeData.html#more
# # drag = QDrag(self.preview_img)
# # mime = QMimeData()
# # mime.setUrls([filepath])
# # drag.setMimeData(mime)
# # drag.exec_(Qt.DropAction.CopyAction)
# try:
# self.preview_img.clicked.disconnect()
# except RuntimeError:
# pass
# self.preview_img.clicked.connect(
# lambda checked=False, filepath=filepath: open_file(filepath))
# for i, f in enumerate(item.fields):
# self.write_container(item, i, f)
# self.item = item
# # try:
# # self.tags_updated.disconnect()
# # except RuntimeError:
# # pass
# # if self.tag_callback:
# # # logging.info(f'[UPDATE CONTAINER] Updating Callback for {item.id}: {self.tag_callback}')
# # self.tags_updated.connect(self.tag_callback)
# # Initialized, Updating:
# elif self.item and self.item.id == item.id:
# # logging.info(f'Initialized Item, Updating! ({item.id})')
# for i, f in enumerate(item.fields):
# self.write_container(item, i, f)
# # Hide leftover containers
# if len(self.containers) > len(self.item.fields):
# for i, c in enumerate(self.containers):
# if i > (len(self.item.fields) - 1):
# c.setHidden(True)
self.setWindowTitle(window_title)
self.show()
def set_tags_updated_slot(self, slot: object):
"""
Replacement for tag_callback.
"""
try:
self.tags_updated.disconnect()
except RuntimeError:
pass
logging.info(f'[UPDATE CONTAINER] Setting tags updated slot')
self.tags_updated.connect(slot)
# def write_container(self, item:Union[Entry, Collation, Tag], index, field):
def write_container(self, index, field, mixed=False):
"""Updates/Creates data for a FieldContainer."""
# logging.info(f'[ENTRY PANEL] WRITE CONTAINER')
# Remove 'Add Field' button from scroll_layout, to be re-added later.
self.scroll_layout.takeAt(self.scroll_layout.count()-1).widget()
container: FieldContainer = None
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
# container.inner_layout.removeItem(container.inner_layout.itemAt(1))
# container.setHidden(False)
if self.lib.get_field_attr(field, 'type') == 'tag_box':
# logging.info(f'WRITING TAGBOX FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Tag Box)"
if not mixed:
item = self.lib.get_entry(self.selected[0][1]) # TODO TODO TODO: TEMPORARY
if type(container.get_inner_widget()) == TagBoxWidget:
inner_container: TagBoxWidget = container.get_inner_widget()
inner_container.set_item(item)
inner_container.set_tags(self.lib.get_field_attr(field, 'content'))
try:
inner_container.updated.disconnect()
except RuntimeError:
pass
# inner_container.updated.connect(lambda f=self.filepath, i=item: self.write_container(item, index, field))
else:
inner_container = TagBoxWidget(item, title, index, self.lib, self.lib.get_field_attr(field, 'content'), self.driver)
container.set_inner_widget(inner_container)
inner_container.field = field
inner_container.updated.connect(lambda: (self.write_container(index, field), self.tags_updated.emit()))
# if type(item) == Entry:
# NOTE: Tag Boxes have no Edit Button (But will when you can convert field types)
# f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.set_copy_callback(None)
container.set_edit_callback(None)
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
container.set_copy_callback(None)
container.set_edit_callback(None)
container.set_remove_callback(None)
self.tags_updated.emit()
# self.dynamic_widgets.append(inner_container)
elif self.lib.get_field_attr(field, 'type') in 'text_line':
# logging.info(f'WRITING TEXTLINE FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ''
if not mixed:
text = self.lib.get_field_attr(
field, 'content').replace('\r', '\n')
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
if not mixed:
modal = PanelModal(EditTextLine(self.lib.get_field_attr(field, 'content')),
title=title,
window_title=f'Edit {self.lib.get_field_attr(field, "name")}',
save_callback=(lambda content: (self.update_field(field, content), self.update_widgets()))
)
container.set_edit_callback(modal.show)
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.set_copy_callback(None)
else:
container.set_edit_callback(None)
container.set_copy_callback(None)
container.set_remove_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
elif self.lib.get_field_attr(field, 'type') in 'text_box':
# logging.info(f'WRITING TEXTBOX FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ''
if not mixed:
text = self.lib.get_field_attr(
field, 'content').replace('\r', '\n')
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Text Box)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
if not mixed:
container.set_copy_callback(None)
modal = PanelModal(EditTextBox(self.lib.get_field_attr(field, 'content')),
title=title,
window_title=f'Edit {self.lib.get_field_attr(field, "name")}',
save_callback=(lambda content: (self.update_field(field, content), self.update_widgets()))
)
container.set_edit_callback(modal.show)
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
else:
container.set_edit_callback(None)
container.set_copy_callback(None)
container.set_remove_callback(None)
elif self.lib.get_field_attr(field, 'type') == 'collation':
# logging.info(f'WRITING COLLATION FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
collation = self.lib.get_collation(self.lib.get_field_attr(field, 'content'))
title = f"{self.lib.get_field_attr(field, 'name')} (Collation)"
text: str = (f'{collation.title} ({len(collation.e_ids_and_pages)} Items)')
if len(self.selected) == 1:
text += f' - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}'
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
container.set_copy_callback(None)
# container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
elif self.lib.get_field_attr(field, 'type') == 'datetime':
# logging.info(f'WRITING DATETIME FOR ITEM {item.id}')
if not mixed:
try:
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
# TODO: Localize this and/or add preferences.
date = dt.strptime(self.lib.get_field_attr(
field, 'content'), '%Y-%m-%d %H:%M:%S')
title = f"{self.lib.get_field_attr(field, 'name')} (Date)"
inner_container = TextWidget(title, date.strftime('%D - %r'))
container.set_inner_widget(inner_container)
except:
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Date) (Unknown Format)"
inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content')))
# if type(item) == Entry:
container.set_copy_callback(None)
container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Date)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
container.set_copy_callback(None)
container.set_edit_callback(None)
container.set_remove_callback(None)
else:
# logging.info(f'[ENTRY PANEL] Unknown Type: {self.lib.get_field_attr(field, "type")}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Unknown Field Type)"
inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content')))
container.set_inner_widget(inner_container)
# if type(item) == Entry:
container.set_copy_callback(None)
container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
# callback = lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.setHidden(False)
self.place_add_field_button()
def remove_field(self, field:object):
"""Removes a field from all selected Entries, given a field object."""
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
try:
index = entry.fields.index(field)
updated_badges = False
if 8 in entry.fields[index].keys() and (1 in entry.fields[index][8] or 0 in entry.fields[index][8]):
updated_badges = True
# TODO: Create a proper Library/Entry method to manage fields.
entry.fields.pop(index)
if updated_badges:
self.driver.update_badges()
except ValueError:
logging.info(f'[PREVIEW PANEL][ERROR?] Tried to remove field from Entry ({entry.id}) that never had it')
pass
def update_field(self, field:object, content):
"""Removes a field from all selected Entries, given a field object."""
field = dict(field)
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
try:
logging.info(field)
index = entry.fields.index(field)
self.lib.update_entry_field(entry.id, index, content, 'replace')
except ValueError:
logging.info(f'[PREVIEW PANEL][ERROR] Tried to update field from Entry ({entry.id}) that never had it')
pass
def remove_message_box(self, prompt:str, callback:FunctionType) -> int:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle('Remove Field')
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton('&Cancel', QMessageBox.ButtonRole.DestructiveRole)
remove_button = remove_mb.addButton('&Remove', QMessageBox.ButtonRole.RejectRole)
# remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
remove_mb.setDefaultButton(cancel_button)
result = remove_mb.exec_()
# logging.info(result)
if result == 1:
callback()

View file

@ -0,0 +1,33 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QProgressDialog
class ProgressWidget(QWidget):
"""Prebuilt thread-safe progress bar widget."""
def __init__(self, window_title:str, label_text:str, cancel_button_text:Optional[str], minimum:int, maximum:int):
super().__init__()
self.root = QVBoxLayout(self)
self.pb = QProgressDialog(
labelText=label_text,
minimum=minimum,
cancelButtonText=cancel_button_text,
maximum=maximum
)
self.root.addWidget(self.pb)
self.setFixedSize(432, 112)
self.setWindowFlags(self.pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.setWindowTitle(window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
def update_label(self, text:str):
self.pb.setLabelText(text)
def update_progress(self, value:int):
self.pb.setValue(value)

View file

@ -0,0 +1,239 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
import os
from types import FunctionType
from pathlib import Path
from PIL import Image
from PySide6.QtCore import Signal, Qt, QEvent
from PySide6.QtGui import QEnterEvent, QAction
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
class TagWidget(QWidget):
edit_icon_128: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png')).resize((math.floor(14*1.25),math.floor(14*1.25)))
edit_icon_128.load()
on_remove = Signal()
on_click = Signal()
on_edit = Signal()
def __init__(self, library:Library, tag:Tag, has_edit:bool, has_remove:bool, on_remove_callback:FunctionType=None, on_click_callback:FunctionType=None, on_edit_callback:FunctionType=None) -> None:
super().__init__()
self.lib = library
self.tag = tag
self.has_edit:bool = has_edit
self.has_remove:bool = has_remove
# self.bg_label = QLabel()
# self.setStyleSheet('background-color:blue;')
# if on_click_callback:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName('baseLayout')
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
self.bg_button.setText(tag.display_name(self.lib).replace('&', '&&'))
if has_edit:
edit_action = QAction('Edit', self)
edit_action.triggered.connect(on_edit_callback)
edit_action.triggered.connect(self.on_edit.emit)
self.bg_button.addAction(edit_action)
# if on_click_callback:
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
# if has_remove:
# remove_action = QAction('Remove', self)
# # remove_action.triggered.connect(on_remove_callback)
# remove_action.triggered.connect(self.on_remove.emit())
# self.bg_button.addAction(remove_action)
search_for_tag_action = QAction('Search for Tag', self)
# search_for_tag_action.triggered.connect(on_click_callback)
search_for_tag_action.triggered.connect(self.on_click.emit)
self.bg_button.addAction(search_for_tag_action)
add_to_search_action = QAction('Add to Search', self)
self.bg_button.addAction(add_to_search_action)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName('innerLayout')
self.inner_layout.setContentsMargins(2, 2, 2, 2)
# self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
# self.inner_container = QWidget()
# self.inner_container.setLayout(self.inner_layout)
# self.base_layout.addWidget(self.inner_container)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(math.ceil(22*1.5), 22)
# self.bg_button.setStyleSheet(
# f'QPushButton {{'
# f'border: 2px solid #8f8f91;'
# f'border-radius: 6px;'
# f'background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,stop: 0 {ColorType.PRIMARY}, stop: 1 {ColorType.BORDER});'
# f'min-width: 80px;}}')
self.bg_button.setStyleSheet(
# f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};'
f'QPushButton{{'
f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};'
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f'font-weight: 600;'
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f'border-radius: 6px;'
f'border-style:solid;'
f'border-width: {math.ceil(1*self.devicePixelRatio())}px;'
# f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
# f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'border-right:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'padding-top: 0.5px;'
f'padding-right: 4px;'
f'padding-bottom: 1px;'
f'padding-left: 4px;'
f'font-size: 13px'
f'}}'
f'QPushButton::hover{{'
# f'background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
# f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
f'}}')
# self.renderer = ThumbRenderer()
# self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i),
# self.update_size(
# ts, size=s),
# self.set_extension(ext)))
# self.bg_button.setLayout(self.base_layout)
self.base_layout.addWidget(self.bg_button)
# self.setMinimumSize(self.bg_button.size())
# logging.info(tag.color)
if has_remove:
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText('')
self.remove_button.setHidden(True)
self.remove_button.setStyleSheet(f'color: {get_tag_color(ColorType.PRIMARY, tag.color)};'
f"background: {get_tag_color(ColorType.TEXT, tag.color)};"
# f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f"border-color: {get_tag_color(ColorType.BORDER, tag.color)};"
f'font-weight: 800;'
# f"border-color:{'black' if color not in [
# 'black', 'gray', 'dark gray',
# 'cool gray', 'warm gray', 'blue',
# 'purple', 'violet'] else 'white'};"
f'border-radius: 4px;'
# f'border-style:solid;'
f'border-width:0;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f'padding-bottom: 4px;'
# f'padding-left: 4px;'
f'font-size: 14px')
self.remove_button.setMinimumSize(19,19)
self.remove_button.setMaximumSize(19,19)
# self.remove_button.clicked.connect(on_remove_callback)
self.remove_button.clicked.connect(self.on_remove.emit)
# NOTE: No more edit button! Just make it a right-click option.
# self.edit_button = QPushButton(self)
# self.edit_button.setFlat(True)
# self.edit_button.setText('Edit')
# self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
# self.edit_button.setIconSize(QSize(14,14))
# self.edit_button.setHidden(True)
# self.edit_button.setStyleSheet(f'color: {color};'
# f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f'font-weight: 600;'
# # f"border-color:{'black' if color not in [
# # 'black', 'gray', 'dark gray',
# # 'cool gray', 'warm gray', 'blue',
# # 'purple', 'violet'] else 'white'};"
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
# f'border-radius: 4px;'
# # f'border-style:solid;'
# # f'border-width:1px;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
# f'padding-bottom: 3px;'
# f'padding-left: 4px;'
# f'font-size: 14px')
# self.edit_button.setMinimumSize(18,18)
# # self.edit_button.setMaximumSize(18,18)
# self.inner_layout.addWidget(self.edit_button)
if has_remove:
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
# self.set_click(on_click_callback)
self.bg_button.clicked.connect(self.on_click.emit)
# self.setMinimumSize(50,20)
# def set_name(self, name:str):
# self.bg_label.setText(str)
# def on_remove(self):
# if self.item and self.item[0] == ItemType.ENTRY:
# if self.field_index >= 0:
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index)
# else:
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id)
# def set_click(self, callback):
# try:
# self.bg_button.clicked.disconnect()
# except RuntimeError:
# pass
# if callback:
# self.bg_button.clicked.connect(callback)
# def set_click(self, function):
# try:
# self.bg.clicked.disconnect()
# except RuntimeError:
# pass
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
# # self.bg.clicked.connect(function)
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(False)
# self.edit_button.setHidden(False)
self.update()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(True)
# self.edit_button.setHidden(True)
self.update()
return super().leaveEvent(event)

View file

@ -0,0 +1,161 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
import typing
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QPushButton
from src.core.library import Library, Tag
from src.qt.flowlayout import FlowLayout
from src.qt.widgets import FieldWidget, TagWidget, PanelModal
from src.qt.modals import BuildTagPanel, TagSearchPanel
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class TagBoxWidget(FieldWidget):
updated = Signal()
def __init__(self, item, title, field_index, library:Library, tags:list[int], driver:'QtDriver') -> None:
super().__init__(title)
# QObject.__init__(self)
self.item = item
self.lib = library
self.driver = driver # Used for creating tag click callbacks that search entries for that tag.
self.field_index = field_index
self.tags:list[int] = tags
self.setObjectName('tagBox')
self.base_layout = FlowLayout()
self.base_layout.setGridEfficiency(False)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
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: #1E1A33;'
# f'color: #CDA7F7;'
f'font-weight: bold;'
# f"border-color: #2B2547;"
f'border-radius: 6px;'
f'border-style:solid;'
f'border-width:{math.ceil(1*self.devicePixelRatio())}px;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f'padding-bottom: 5px;'
# f'padding-left: 4px;'
f'font-size: 20px;'
f'}}'
f'QPushButton::hover'
f'{{'
# f'background: #2B2547;'
f'}}')
tsp = TagSearchPanel(self.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(self.add_modal.show)
self.set_tags(tags)
# self.add_button.setHidden(True)
def set_item(self, item):
self.item = item
def set_tags(self, tags:list[int]):
logging.info(f'[TAG BOX WIDGET] SET TAGS: T:{tags} for E:{self.item.id}')
is_recycled = False
if self.base_layout.itemAt(0):
# logging.info(type(self.base_layout.itemAt(0).widget()))
while self.base_layout.itemAt(0) and self.base_layout.itemAt(1):
# logging.info(f"I'm deleting { self.base_layout.itemAt(0).widget()}")
self.base_layout.takeAt(0).widget().deleteLater()
is_recycled = True
for tag in tags:
# TODO: Remove space from the special search here (tag_id:x) once that system is finalized.
# tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True,
# on_remove_callback=lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit()),
# on_click_callback=lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)),
# on_edit_callback=lambda checked=False, t=tag: (self.edit_tag(t))
# )
tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True)
tw.on_click.connect(lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)))
tw.on_remove.connect(lambda checked=False, t=tag: (self.remove_tag(t)))
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t)))
self.base_layout.addWidget(tw)
self.tags = tags
# 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()
def edit_tag(self, tag_id:int):
btp = BuildTagPanel(self.lib, tag_id)
# btp.on_edit.connect(lambda x: self.edit_tag_callback(x))
self.edit_modal = PanelModal(btp,
self.lib.get_tag(tag_id).display_name(self.lib),
'Edit Tag',
done_callback=(self.driver.preview_panel.update_widgets),
has_save=True)
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
panel: BuildTagPanel = self.edit_modal.widget
self.edit_modal.saved.connect(lambda: self.lib.update_tag(btp.build_tag()))
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
self.edit_modal.show()
def add_tag_callback(self, tag_id):
# self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True))
# self.tags.append(tag)
logging.info(f'[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}')
logging.info(f'[TAG BOX WIDGET] SELECTED T:{self.driver.selected}')
id = list(self.field.keys())[0]
for x in self.driver.selected:
self.driver.lib.get_entry(x[1]).add_tag(self.driver.lib, tag_id, field_id=id, field_index=-1)
self.updated.emit()
if tag_id == 0 or tag_id == 1:
self.driver.update_badges()
# if type((x[0]) == ThumbButton):
# # TODO: Remove space from the special search here (tag_id:x) once that system is finalized.
# logging.info(f'I want to add tag ID {tag_id} to entry {self.item.filename}')
# self.updated.emit()
# if tag_id not in self.tags:
# self.tags.append(tag_id)
# self.set_tags(self.tags)
# elif type((x[0]) == ThumbButton):
def edit_tag_callback(self, tag:Tag):
self.lib.update_tag(tag)
def remove_tag(self, tag_id):
logging.info(f'[TAG BOX WIDGET] SELECTED T:{self.driver.selected}')
id = list(self.field.keys())[0]
for x in self.driver.selected:
index = self.driver.lib.get_field_index_in_entry(self.driver.lib.get_entry(x[1]),id)
self.driver.lib.get_entry(x[1]).remove_tag(self.driver.lib, tag_id,field_index=index[0])
self.updated.emit()
if tag_id == 0 or tag_id == 1:
self.driver.update_badges()
# def show_add_button(self, value:bool):
# self.add_button.setHidden(not value)

View file

@ -0,0 +1,31 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
from src.qt.widgets import FieldWidget
class TextWidget(FieldWidget):
def __init__(self, title, text:str) -> None:
super().__init__(title)
# self.item = item
self.setObjectName('textBox')
# self.setStyleSheet('background-color:purple;')
self.base_layout = QHBoxLayout()
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.text_label = QLabel()
# self.text_label.textFormat(Qt.TextFormat.RichText)
self.text_label.setStyleSheet('font-size: 12px')
self.text_label.setWordWrap(True)
self.text_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.base_layout.addWidget(self.text_label)
self.set_text(text)
def set_text(self, text:str):
self.text_label.setText(text)

View file

@ -0,0 +1,27 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtWidgets import QVBoxLayout, QPlainTextEdit
from src.qt.widgets import PanelWidget
class EditTextBox(PanelWidget):
def __init__(self, text):
super().__init__()
# self.setLayout()
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)

View file

@ -0,0 +1,28 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtWidgets import QVBoxLayout, QLineEdit
from src.qt.widgets import PanelWidget
class EditTextLine(PanelWidget):
def __init__(self, text):
super().__init__()
# self.setLayout()
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.text_edit.returnPressed.connect(self.done.emit)
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)

View file

@ -0,0 +1,73 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6 import QtCore
from PySide6.QtCore import QEvent
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath
from PySide6.QtWidgets import QWidget, QPushButton
class ThumbButton(QPushButton):
def __init__(self, parent:QWidget, thumb_size:tuple[int,int]) -> None:
super().__init__(parent)
self.thumb_size:tuple[int,int] = thumb_size
self.hovered = False
self.selected = False
# self.clicked.connect(lambda checked: self.set_selected(True))
def paintEvent(self, event:QEvent) -> None:
super().paintEvent(event)
if self.hovered or self.selected:
painter = QPainter()
painter.begin(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
path = QPainterPath()
width = 3
radius = 6
path.addRoundedRect(QtCore.QRectF(width/2,width/2,self.thumb_size[0]-width, self.thumb_size[1]-width), radius, radius)
# color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6')
# pen = QPen(color, width)
# painter.setPen(pen)
# # brush.setColor(fill)
# painter.drawPath(path)
if self.selected:
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight)
color = QColor('#bb4ff0')
color.setAlphaF(0.5)
pen = QPen(color, width)
painter.setPen(pen)
painter.fillPath(path, color)
painter.drawPath(path)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
color = QColor('#bb4ff0') if not self.hovered else QColor('#55bbf6')
pen = QPen(color, width)
painter.setPen(pen)
painter.drawPath(path)
elif self.hovered:
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
color = QColor('#55bbf6')
pen = QPen(color, width)
painter.setPen(pen)
painter.drawPath(path)
painter.end()
def enterEvent(self, event: QEnterEvent) -> None:
self.hovered = True
self.repaint()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
self.hovered = False
self.repaint()
return super().leaveEvent(event)
def set_selected(self, value:bool) -> None:
self.selected = value
self.repaint()

View file

@ -0,0 +1,420 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import ctypes
import logging
import math
import os
from pathlib import Path
import cv2
from PIL import Image, ImageChops, UnidentifiedImageError, ImageQt, ImageDraw, ImageFont, ImageEnhance
from PySide6.QtCore import QObject, Signal, QSize
from PySide6.QtGui import QPixmap
from src.core.ts_core import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES
ERROR = f'[ERROR]'
WARNING = f'[WARNING]'
INFO = f'[INFO]'
logging.basicConfig(format="%(message)s", level=logging.INFO)
class ThumbRenderer(QObject):
# finished = Signal()
updated = Signal(float, QPixmap, QSize, str)
updated_ratio = Signal(float)
# updatedImage = Signal(QPixmap)
# updatedSize = Signal(QSize)
thumb_mask_512: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_512.png'))
thumb_mask_512.load()
thumb_mask_hl_512: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_hl_512.png'))
thumb_mask_hl_512.load()
thumb_loading_512: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_loading_512.png'))
thumb_loading_512.load()
thumb_broken_512: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_broken_512.png'))
thumb_broken_512.load()
thumb_file_default_512: Image.Image = Image.open(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_file_default_512.png'))
thumb_file_default_512.load()
# thumb_debug: Image.Image = Image.open(os.path.normpath(
# f'{Path(__file__).parent.parent.parent}/resources/qt/images/temp.jpg'))
# thumb_debug.load()
# TODO: Make dynamic font sized given different pixel ratios
font_pixel_ratio: float = 1
ext_font = ImageFont.truetype(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*font_pixel_ratio))
def __init__(self):
QObject.__init__(self)
def render(self, timestamp: float, filepath, base_size: tuple[int, int], pixelRatio: float, isLoading=False):
"""Renders an entry/element thumbnail for the GUI."""
adj_size: int = 1
image = None
pixmap = None
final = None
extension: str = None
broken_thumb = False
# adj_font_size = math.floor(12 * pixelRatio)
if ThumbRenderer.font_pixel_ratio != pixelRatio:
ThumbRenderer.font_pixel_ratio = pixelRatio
ThumbRenderer.ext_font = ImageFont.truetype(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*ThumbRenderer.font_pixel_ratio))
if isLoading or filepath:
adj_size = math.ceil(base_size[0] * pixelRatio)
if isLoading:
li: Image.Image = ThumbRenderer.thumb_loading_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
qim = ImageQt.ImageQt(li)
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
elif filepath:
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3)
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
extension = os.path.splitext(filepath)[1][1:].lower()
try:
# Images =======================================================
if extension in IMAGE_TYPES:
image = Image.open(filepath)
# image = self.thumb_debug
if image.mode == 'RGBA':
# logging.info(image.getchannel(3).tobytes())
new_bg = Image.new('RGB', image.size, color='#222222')
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != 'RGB':
image = image.convert(mode='RGB')
# Videos =======================================================
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2))
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Plain Text ===================================================
elif extension in PLAINTEXT_TYPES:
try:
text: str = extension
with open(filepath, 'r', encoding='utf-8') as text_file:
text = text_file.read(256)
bg = Image.new('RGB',(256,256), color='#222222')
draw = ImageDraw.Draw(bg)
draw.text((16,16), text, file=(255,255,255))
image = bg
except:
logging.info(f'[ThumbRenderer][ERROR]: Coulnd\'t render thumbnail for {filepath}')
# No Rendered Thumbnail ========================================
else:
image = ThumbRenderer.thumb_file_default_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
if not image:
raise UnidentifiedImageError
orig_x, orig_y = image.size
new_x, new_y = (adj_size, adj_size)
if orig_x > orig_y:
new_x = adj_size
new_y = math.ceil(adj_size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
# img_ratio = new_x / new_y
image = image.resize(
(new_x, new_y), resample=Image.Resampling.BILINEAR)
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0]-1), 0))
bl = image.getpixel((0, (image.size[1]-1)))
br = image.getpixel(((image.size[0]-1), (image.size[1]-1)))
bg = Image.new(mode='RGB', size=(2, 2))
bg.paste(tl, (0, 0, 2, 2))
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize((adj_size, adj_size),
resample=Image.Resampling.BICUBIC)
bg.paste(image, box=(
(adj_size-image.size[0])//2, (adj_size-image.size[1])//2))
bg.putalpha(mask)
final = bg
else:
image.putalpha(mask)
final = image
hl_soft = hl.copy()
hl_soft.putalpha(ImageEnhance.Brightness(
hl.getchannel(3)).enhance(.5))
final.paste(ImageChops.soft_light(final, hl_soft),
mask=hl_soft.getchannel(3))
# hl_add = hl.copy()
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
# final.paste(hl_add, mask=hl_add.getchannel(3))
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
broken_thumb = True
final = ThumbRenderer.thumb_broken_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
qim = ImageQt.ImageQt(final)
if image:
image.close()
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
if pixmap:
self.updated.emit(timestamp, pixmap, QSize(*base_size), extension)
else:
self.updated.emit(timestamp, QPixmap(),
QSize(*base_size), extension)
def render_big(self, timestamp: float, filepath, base_size: tuple[int, int], pixelRatio: float, isLoading=False):
"""Renders a large, non-square entry/element thumbnail for the GUI."""
adj_size: int = 1
image: Image.Image = None
pixmap: QPixmap = None
final: Image.Image = None
extension: str = None
broken_thumb = False
img_ratio = 1
# adj_font_size = math.floor(12 * pixelRatio)
if ThumbRenderer.font_pixel_ratio != pixelRatio:
ThumbRenderer.font_pixel_ratio = pixelRatio
ThumbRenderer.ext_font = ImageFont.truetype(os.path.normpath(
f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*ThumbRenderer.font_pixel_ratio))
if isLoading or filepath:
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixelRatio)
if isLoading:
adj_size = math.ceil((512 * pixelRatio))
final: Image.Image = ThumbRenderer.thumb_loading_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
qim = ImageQt.ImageQt(final)
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
self.updated_ratio.emit(1)
elif filepath:
# mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3)
# hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR)
extension = os.path.splitext(filepath)[1][1:].lower()
try:
# Images =======================================================
if extension in IMAGE_TYPES:
image = Image.open(filepath)
# image = self.thumb_debug
if image.mode == 'RGBA':
# logging.info(image.getchannel(3).tobytes())
new_bg = Image.new('RGB', image.size, color='#222222')
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != 'RGB':
image = image.convert(mode='RGB')
# Videos =======================================================
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2))
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Plain Text ===================================================
elif extension in PLAINTEXT_TYPES:
try:
text: str = extension
with open(filepath, 'r', encoding='utf-8') as text_file:
text = text_file.read(256)
bg = Image.new('RGB',(256,256), color='#222222')
draw = ImageDraw.Draw(bg)
draw.text((16,16), text, file=(255,255,255))
image = bg
except:
logging.info(f'[ThumbRenderer][ERROR]: Coulnd\'t render thumbnail for {filepath}')
# No Rendered Thumbnail ========================================
else:
image = ThumbRenderer.thumb_file_default_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
if not image:
raise UnidentifiedImageError
orig_x, orig_y = image.size
if orig_x < adj_size and orig_y < adj_size:
new_x, new_y = (adj_size, adj_size)
if orig_x > orig_y:
new_x = adj_size
new_y = math.ceil(adj_size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
else:
new_x, new_y = (adj_size, adj_size)
if orig_x > orig_y:
new_x = adj_size
new_y = math.ceil(adj_size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
self.updated_ratio.emit(new_x / new_y)
image = image.resize(
(new_x, new_y), resample=Image.Resampling.BILINEAR)
# image = image.resize(
# (new_x, new_y), resample=Image.Resampling.BILINEAR)
# if image.size != (adj_size, adj_size):
# # Old 1 color method.
# # bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# # bg.thumbnail((1, 1))
# # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# # Small gradient background. Looks decent, and is only a one-liner.
# # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# # Four-Corner Gradient Background.
# # Not exactly a one-liner, but it's (subjectively) really cool.
# tl = image.getpixel((0, 0))
# tr = image.getpixel(((image.size[0]-1), 0))
# bl = image.getpixel((0, (image.size[1]-1)))
# br = image.getpixel(((image.size[0]-1), (image.size[1]-1)))
# bg = Image.new(mode='RGB', size=(2, 2))
# bg.paste(tl, (0, 0, 2, 2))
# bg.paste(tr, (1, 0, 2, 2))
# bg.paste(bl, (0, 1, 2, 2))
# bg.paste(br, (1, 1, 2, 2))
# bg = bg.resize((adj_size, adj_size),
# resample=Image.Resampling.BICUBIC)
# bg.paste(image, box=(
# (adj_size-image.size[0])//2, (adj_size-image.size[1])//2))
# bg.putalpha(mask)
# final = bg
# else:
# image.putalpha(mask)
# final = image
# hl_soft = hl.copy()
# hl_soft.putalpha(ImageEnhance.Brightness(
# hl.getchannel(3)).enhance(.5))
# final.paste(ImageChops.soft_light(final, hl_soft),
# mask=hl_soft.getchannel(3))
# hl_add = hl.copy()
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
# final.paste(hl_add, mask=hl_add.getchannel(3))
scalar = 4
rec: Image.Image = Image.new('RGB', tuple(
[d * scalar for d in image.size]), 'black')
draw = ImageDraw.Draw(rec)
draw.rounded_rectangle(
(0, 0)+rec.size, (base_size[0]//32) * scalar * pixelRatio, fill='red')
rec = rec.resize(
tuple([d // scalar for d in rec.size]), resample=Image.Resampling.BILINEAR)
# final = image
final = Image.new('RGBA', image.size, (0, 0, 0, 0))
# logging.info(rec.size)
# logging.info(image.size)
final.paste(image, mask=rec.getchannel(0))
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
broken_thumb = True
self.updated_ratio.emit(1)
final = ThumbRenderer.thumb_broken_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR)
# if extension in VIDEO_TYPES + ['gif', 'apng'] or broken_thumb:
# idk = ImageDraw.Draw(final)
# # idk.textlength(file_type)
# ext_offset_x = idk.textlength(
# text=extension.upper(), font=ThumbRenderer.ext_font) / 2
# ext_offset_x = math.floor(ext_offset_x * (1/pixelRatio))
# x_margin = math.floor(
# (adj_size-((base_size[0]//6)+ext_offset_x) * pixelRatio))
# y_margin = math.floor(
# (adj_size-((base_size[0]//8)) * pixelRatio))
# stroke_width = round(2 * pixelRatio)
# fill = 'white' if not broken_thumb else '#E32B41'
# idk.text((x_margin, y_margin), extension.upper(
# ), fill=fill, font=ThumbRenderer.ext_font, stroke_width=stroke_width, stroke_fill=(0, 0, 0))
qim = ImageQt.ImageQt(final)
if image:
image.close()
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
if pixmap:
# logging.info(final.size)
# self.updated.emit(pixmap, QSize(*final.size))
self.updated.emit(timestamp, pixmap, QSize(math.ceil(
adj_size * 1/pixelRatio), math.ceil(final.size[1] * 1/pixelRatio)), extension)
else:
self.updated.emit(timestamp, QPixmap(),
QSize(*base_size), extension)