mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2024-07-30 21:27:34 +00:00
commit
c9c399faa9
33 changed files with 4529 additions and 4015 deletions
4
tagstudio/src/qt/helpers/__init__.py
Normal file
4
tagstudio/src/qt/helpers/__init__.py
Normal 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
|
19
tagstudio/src/qt/helpers/custom_runnable.py
Normal file
19
tagstudio/src/qt/helpers/custom_runnable.py
Normal 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()
|
58
tagstudio/src/qt/helpers/file_opener.py
Normal file
58
tagstudio/src/qt/helpers/file_opener.py
Normal 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()
|
21
tagstudio/src/qt/helpers/function_iterator.py
Normal file
21
tagstudio/src/qt/helpers/function_iterator.py
Normal 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)
|
49
tagstudio/src/qt/helpers/open_file.py
Normal file
49
tagstudio/src/qt/helpers/open_file.py
Normal 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()
|
11
tagstudio/src/qt/modals/__init__.py
Normal file
11
tagstudio/src/qt/modals/__init__.py
Normal 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
|
78
tagstudio/src/qt/modals/add_field.py
Normal file
78
tagstudio/src/qt/modals/add_field.py
Normal 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)
|
230
tagstudio/src/qt/modals/build_tag.py
Normal file
230
tagstudio/src/qt/modals/build_tag.py
Normal 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()
|
128
tagstudio/src/qt/modals/delete_unlinked.py
Normal file
128
tagstudio/src/qt/modals/delete_unlinked.py
Normal 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()
|
51
tagstudio/src/qt/modals/file_extension.py
Normal file
51
tagstudio/src/qt/modals/file_extension.py
Normal 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())
|
159
tagstudio/src/qt/modals/fix_dupes.py
Normal file
159
tagstudio/src/qt/modals/fix_dupes.py
Normal 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}')
|
179
tagstudio/src/qt/modals/fix_unlinked.py
Normal file
179
tagstudio/src/qt/modals/fix_unlinked.py
Normal 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}')
|
282
tagstudio/src/qt/modals/folders_to_tags.py
Normal file
282
tagstudio/src/qt/modals/folders_to_tags.py
Normal 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()
|
127
tagstudio/src/qt/modals/mirror_entities.py
Normal file
127
tagstudio/src/qt/modals/mirror_entities.py
Normal 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()
|
90
tagstudio/src/qt/modals/relink_unlinked.py
Normal file
90
tagstudio/src/qt/modals/relink_unlinked.py
Normal 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)
|
138
tagstudio/src/qt/modals/tag_database.py
Normal file
138
tagstudio/src/qt/modals/tag_database.py
Normal 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
|
147
tagstudio/src/qt/modals/tag_search.py
Normal file
147
tagstudio/src/qt/modals/tag_search.py
Normal 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
|
|
@ -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
13
tagstudio/src/qt/widgets/__init__.py
Normal file
13
tagstudio/src/qt/widgets/__init__.py
Normal 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
|
141
tagstudio/src/qt/widgets/collage_icon.py
Normal file
141
tagstudio/src/qt/widgets/collage_icon.py
Normal 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'
|
199
tagstudio/src/qt/widgets/fields.py
Normal file
199
tagstudio/src/qt/widgets/fields.py
Normal 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
|
483
tagstudio/src/qt/widgets/item_thumb.py
Normal file
483
tagstudio/src/qt/widgets/item_thumb.py
Normal 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)
|
99
tagstudio/src/qt/widgets/panel.py
Normal file
99
tagstudio/src/qt/widgets/panel.py
Normal 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
|
797
tagstudio/src/qt/widgets/preview_panel.py
Normal file
797
tagstudio/src/qt/widgets/preview_panel.py
Normal 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()
|
33
tagstudio/src/qt/widgets/progress.py
Normal file
33
tagstudio/src/qt/widgets/progress.py
Normal 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)
|
239
tagstudio/src/qt/widgets/tag.py
Normal file
239
tagstudio/src/qt/widgets/tag.py
Normal 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)
|
161
tagstudio/src/qt/widgets/tag_box.py
Normal file
161
tagstudio/src/qt/widgets/tag_box.py
Normal 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)
|
31
tagstudio/src/qt/widgets/text.py
Normal file
31
tagstudio/src/qt/widgets/text.py
Normal 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)
|
27
tagstudio/src/qt/widgets/text_box_edit.py
Normal file
27
tagstudio/src/qt/widgets/text_box_edit.py
Normal 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)
|
28
tagstudio/src/qt/widgets/text_line_edit.py
Normal file
28
tagstudio/src/qt/widgets/text_line_edit.py
Normal 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)
|
73
tagstudio/src/qt/widgets/thumb_button.py
Normal file
73
tagstudio/src/qt/widgets/thumb_button.py
Normal 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()
|
420
tagstudio/src/qt/widgets/thumb_renderer.py
Normal file
420
tagstudio/src/qt/widgets/thumb_renderer.py
Normal 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)
|
Loading…
Reference in a new issue