diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py new file mode 100644 index 00000000..c406a523 --- /dev/null +++ b/tagstudio/src/core/enums.py @@ -0,0 +1,16 @@ +import enum + + +class SettingItems(str, enum.Enum): + """List of setting item names.""" + + START_LOAD_LAST = "start_load_last" + LAST_LIBRARY = "last_library" + LIBS_LIST = "libs_list" + WINDOW_SHOW_LIBS = "window_show_libs" + + +class Theme(str, enum.Enum): + COLOR_BG = "#65000000" + COLOR_HOVER = "#65AAAAAA" + COLOR_PRESSED = "#65EEEEEE" diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 8d626c52..eb029248 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -16,7 +16,7 @@ import time import webbrowser from datetime import datetime as dt from pathlib import Path -from queue import Empty, Queue +from queue import Queue from typing import Optional from PIL import Image @@ -46,6 +46,7 @@ from PySide6.QtWidgets import ( ) from humanfriendly import format_timespan +from src.core.enums import SettingItems from src.core.library import ItemType from src.core.ts_core import ( PLAINTEXT_TYPES, @@ -88,7 +89,9 @@ from src.qt.modals.file_extension import FileExtensionModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.folders_to_tags import FoldersToTagsModal -import src.qt.resources_rc + +# this import has side-effect of import PySide resources +import src.qt.resources_rc # pylint: disable=unused-import # SIGQUIT is not defined on Windows if sys.platform == "win32": @@ -282,6 +285,7 @@ class QtDriver(QObject): edit_menu = QMenu("&Edit", menu_bar) tools_menu = QMenu("&Tools", menu_bar) macros_menu = QMenu("&Macros", menu_bar) + window_menu = QMenu("&Window", menu_bar) help_menu = QMenu("&Help", menu_bar) # File Menu ============================================================ @@ -376,6 +380,18 @@ class QtDriver(QObject): tag_database_action.triggered.connect(lambda: self.show_tag_database()) edit_menu.addAction(tag_database_action) + check_action = QAction("Open library on start", self) + check_action.setCheckable(True) + check_action.setChecked( + self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool) + ) + check_action.triggered.connect( + lambda checked: self.settings.setValue( + SettingItems.START_LOAD_LAST, checked + ) + ) + window_menu.addAction(check_action) + # Tools Menu =========================================================== fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar) fue_modal = FixUnlinkedEntriesModal(self.lib, self) @@ -423,6 +439,20 @@ class QtDriver(QObject): macros_menu.addAction(self.sort_fields_action) folders_to_tags_action = QAction("Create Tags From Folders", menu_bar) + show_libs_list_action = QAction("Show Recent Libraries", menu_bar) + show_libs_list_action.setCheckable(True) + show_libs_list_action.setChecked( + self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool) + ) + show_libs_list_action.triggered.connect( + lambda checked: ( + self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), + self.toggle_libs_list(checked), + ) + ) + window_menu.addAction(show_libs_list_action) + + folders_to_tags_action = QAction("Folders to Tags", menu_bar) ftt_modal = FoldersToTagsModal(self.lib, self) folders_to_tags_action.triggered.connect(lambda: ftt_modal.show()) macros_menu.addAction(folders_to_tags_action) @@ -440,6 +470,7 @@ class QtDriver(QObject): menu_bar.addMenu(edit_menu) menu_bar.addMenu(tools_menu) menu_bar.addMenu(macros_menu) + menu_bar.addMenu(window_menu) menu_bar.addMenu(help_menu) self.preview_panel = PreviewPanel(self.lib, self) @@ -458,6 +489,31 @@ class QtDriver(QObject): self.thumb_renderers: list[ThumbRenderer] = [] self.collation_thumb_size = math.ceil(self.thumb_size * 2) + self.init_library_window() + + lib = None + if self.args.open: + lib = self.args.open + elif self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool): + lib = self.settings.value(SettingItems.LAST_LIBRARY) + + if lib: + self.splash.showMessage( + f'Opening Library "{lib}"...', + int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), + QColor("#9782ff"), + ) + self.open_library(lib) + + if self.args.ci: + # gracefully terminate the app in CI environment + self.thumb_job_queue.put((self.SIGTERM.emit, [])) + + app.exec() + + self.shutdown() + + def init_library_window(self): self._init_thumb_grid() # TODO: Put this into its own method that copies the font file(s) into memory @@ -510,31 +566,12 @@ class QtDriver(QObject): self.splash.finish(self.main_window) self.preview_panel.update_widgets() - # Check if a library should be opened on startup, args should override last_library - # TODO: check for behavior (open last, open default, start empty) - if ( - self.args.open - or self.settings.contains("last_library") - and os.path.isdir(self.settings.value("last_library")) - ): - if self.args.open: - lib = self.args.open - elif self.settings.value("last_library"): - lib = self.settings.value("last_library") - self.splash.showMessage( - f'Opening Library "{lib}"...', - int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), - QColor("#9782ff"), - ) - self.open_library(lib) - - if self.args.ci: - # gracefully terminate the app in CI environment - self.thumb_job_queue.put((self.SIGTERM.emit, [])) - - app.exec() - - self.shutdown() + def toggle_libs_list(self, value: bool): + if value: + self.preview_panel.libs_flow_container.show() + else: + self.preview_panel.libs_flow_container.hide() + self.preview_panel.update() def callback_library_needed_check(self, func): """Check if loaded library has valid path before executing the button function""" @@ -548,7 +585,7 @@ class QtDriver(QObject): """Save Library on Application Exit""" if self.lib.library_dir: self.save_library() - self.settings.setValue("last_library", self.lib.library_dir) + self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) self.settings.sync() logging.info("[SHUTDOWN] Ending Thumbnail Threads...") for _ in self.thumb_threads: @@ -597,7 +634,7 @@ class QtDriver(QObject): self.main_window.statusbar.showMessage(f"Closing & Saving Library...") start_time = time.time() self.save_library(show_status=False) - self.settings.setValue("last_library", self.lib.library_dir) + self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) self.settings.sync() self.lib.clear_internal_vars() @@ -709,7 +746,7 @@ class QtDriver(QObject): iterator.value.connect(lambda x: pw.update_progress(x + 1)) iterator.value.connect( lambda x: pw.update_label( - f'Scanning Directories for New Files...\n{x+1} File{"s" if x+1 != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found' + f'Scanning Directories for New Files...\n{x + 1} File{"s" if x + 1 != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found' ) ) r = CustomRunnable(lambda: iterator.run()) @@ -760,7 +797,7 @@ class QtDriver(QObject): iterator.value.connect(lambda x: pw.update_progress(x + 1)) iterator.value.connect( lambda x: pw.update_label( - f"Running Configured Macros on {x+1}/{len(new_ids)} New Entries" + f"Running Configured Macros on {x + 1}/{len(new_ids)} New Entries" ) ) r = CustomRunnable(lambda: iterator.run()) @@ -1297,6 +1334,38 @@ class QtDriver(QObject): # self.update_thumbs() + def remove_recent_library(self, item_key: str): + self.settings.beginGroup(SettingItems.LIBS_LIST) + self.settings.remove(item_key) + self.settings.endGroup() + self.settings.sync() + + def update_libs_list(self, path: str | Path): + """add library to list in SettingItems.LIBS_LIST""" + ITEMS_LIMIT = 5 + path = Path(path) + + self.settings.beginGroup(SettingItems.LIBS_LIST) + + all_libs = {str(time.time()): str(path)} + + for item_key in self.settings.allKeys(): + item_path = self.settings.value(item_key) + if Path(item_path) != path: + all_libs[item_key] = item_path + + # sort items, most recent first + all_libs = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) + + # remove previously saved items + self.settings.clear() + + for item_key, item_value in all_libs[:ITEMS_LIMIT]: + self.settings.setValue(item_key, item_value) + + self.settings.endGroup() + self.settings.sync() + def open_library(self, path): """Opens a TagStudio library.""" if self.lib.library_dir: @@ -1314,7 +1383,7 @@ class QtDriver(QObject): # self.lib.refresh_missing_files() # title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\'' # self.main_window.setWindowTitle(title_text) - pass + self.update_libs_list(path) else: logging.info( diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 81d8c97a..a2491624 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -28,6 +28,7 @@ from PySide6.QtWidgets import ( ) from humanfriendly import format_size +from src.core.enums import SettingItems, Theme from src.core.library import Entry, ItemType, Library from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file @@ -41,6 +42,7 @@ from src.qt.widgets.text_box_edit import EditTextBox from src.qt.widgets.text_line_edit import EditTextLine from src.qt.widgets.item_thumb import ItemThumb + # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -74,17 +76,10 @@ class PreviewPanel(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.open_file_action = QAction("Open file", self) self.open_explorer_action = QAction("Open file in explorer", self) @@ -111,16 +106,6 @@ class PreviewPanel(QWidget): ) ) - 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) @@ -137,7 +122,7 @@ class PreviewPanel(QWidget): # Qt.TextInteractionFlag.TextSelectableByMouse) properties_style = ( - f"background-color:#65000000;" + f"background-color:{Theme.COLOR_BG.value};" f"font-family:Oxanium;" f"font-weight:bold;" f"font-size:12px;" @@ -177,19 +162,48 @@ class PreviewPanel(QWidget): # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. scroll_area.setStyleSheet( - f"QWidget#entryScrollContainer{{" - "background:#65000000;" + "QWidget#entryScrollContainer{" + f"background: {Theme.COLOR_BG.value};" "border-radius:6px;" - f"}}" + "}" ) 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) + # keep list of rendered libraries to avoid needless re-rendering + self.render_libs = set() + self.libs_layout = QVBoxLayout() + self.fill_libs_widget(self.libs_layout) + + self.libs_flow_container: QWidget = QWidget() + self.libs_flow_container.setObjectName("librariesList") + self.libs_flow_container.setLayout(self.libs_layout) + self.libs_flow_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + + # set initial visibility based on settings + if not self.driver.settings.value( + SettingItems.WINDOW_SHOW_LIBS, True, type=bool + ): + self.libs_flow_container.hide() + + splitter = QSplitter() + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setHandleWidth(12) + splitter.splitterMoved.connect( + lambda: self.update_image_size( + ( + self.image_container.size().width(), + self.image_container.size().height(), + ) + ) + ) + + splitter.addWidget(self.image_container) + splitter.addWidget(info_section) + splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) self.afb_container = QWidget() @@ -208,6 +222,107 @@ class PreviewPanel(QWidget): (self.image_container.size().width(), self.image_container.size().height()) ) + root_layout = QHBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(splitter) + + def fill_libs_widget(self, layout: QVBoxLayout): + settings = self.driver.settings + settings.beginGroup(SettingItems.LIBS_LIST) + lib_items: dict[str, tuple[str, str]] = {} + for item_tstamp in settings.allKeys(): + val = settings.value(item_tstamp) + cut_val = val + if len(val) > 45: + cut_val = f"{val[0:10]} ... {val[-10:]}" + lib_items[item_tstamp] = (val, cut_val) + + settings.endGroup() + + new_keys = set(lib_items.keys()) + if new_keys == self.render_libs: + # no need to re-render + return + + # sort lib_items by the key + libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True) + + self.render_libs = new_keys + self._fill_libs_widget(libs_sorted, layout) + + def _fill_libs_widget( + self, libraries: list[tuple[str, tuple[str, str]]], layout: QVBoxLayout + ): + def clear_layout(layout_item: QVBoxLayout): + for i in reversed(range(layout_item.count())): + child = layout_item.itemAt(i) + if child.widget() is not None: + child.widget().deleteLater() + elif child.layout() is not None: + clear_layout(child.layout()) + + # remove any potential previous items + clear_layout(layout) + + label = QLabel("Recent Libraries") + label.setAlignment(Qt.AlignCenter) + + row_layout = QHBoxLayout() + row_layout.addWidget(label) + layout.addLayout(row_layout) + + def set_button_style(btn: QPushButton, extras: list[str] | None = None): + base_style = [ + f"background-color:{Theme.COLOR_BG.value};", + "border-radius:6px;", + "text-align: left;", + "padding-top: 3px;", + "padding-left: 6px;", + "padding-bottom: 4px;", + ] + + full_style_rows = base_style + (extras or []) + + btn.setStyleSheet( + ( + "QPushButton{" + f"{''.join(full_style_rows)}" + "}" + f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}" + f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}" + ) + ) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + + for item_key, (full_val, cut_val) in libraries: + button = QPushButton(text=cut_val) + button.setObjectName(f"path{item_key}") + + def open_library_button_clicked(path): + return lambda: self.driver.open_library(path) + + button.clicked.connect(open_library_button_clicked(full_val)) + set_button_style(button) + + button_remove = QPushButton("➖") + button_remove.setCursor(Qt.CursorShape.PointingHandCursor) + button_remove.setFixedWidth(30) + set_button_style(button_remove) + + def remove_recent_library_clicked(key: str): + return lambda: ( + self.driver.remove_recent_library(key), + self.fill_libs_widget(self.libs_layout), + ) + + button_remove.clicked.connect(remove_recent_library_clicked(item_key)) + + row_layout = QHBoxLayout() + row_layout.addWidget(button) + row_layout.addWidget(button_remove) + + layout.addLayout(row_layout) + def resizeEvent(self, event: QResizeEvent) -> None: self.update_image_size( (self.image_container.size().width(), self.image_container.size().height()) @@ -309,6 +424,9 @@ class PreviewPanel(QWidget): # self.tag_callback = tag_callback if tag_callback else None window_title = "" + # update list of libraries + self.fill_libs_widget(self.libs_layout) + # 0 Selected Items if not self.driver.selected: if self.selected or not self.initialized: