From 037ebde7d7f804d46cdbe9aca0acf057c9935113 Mon Sep 17 00:00:00 2001 From: python357-1 Date: Mon, 23 Dec 2024 20:18:59 -0600 Subject: [PATCH] feat: settings menu MVP --- .python-version | 1 + requirements.txt | 6 +- tagstudio/src/core/driver.py | 15 ++--- tagstudio/src/core/settings/__init__.py | 4 +- tagstudio/src/core/settings/libsettings.py | 6 ++ tagstudio/src/core/settings/tssettings.py | 33 ++++++---- tagstudio/src/core/tscacheddata.py | 45 +++++++++++++ tagstudio/src/qt/modals/settings_modal.py | 24 +++++-- tagstudio/src/qt/ts_qt.py | 77 ++++++++-------------- tagstudio/src/qt/widgets/preview_panel.py | 49 +++++++++----- tagstudio/src/qt/widgets/video_player.py | 9 +-- tagstudio/tests/test_driver.py | 29 ++++---- 12 files changed, 184 insertions(+), 114 deletions(-) create mode 100644 .python-version create mode 100644 tagstudio/src/core/settings/libsettings.py create mode 100644 tagstudio/src/core/tscacheddata.py diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..04e207918 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.8 diff --git a/requirements.txt b/requirements.txt index a2d3d8b6e..dc7d90e22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,9 @@ PySide6==6.8.0.1 rawpy==0.22.0 SQLAlchemy==2.0.34 structlog==24.4.0 -typing_extensions>=3.10.0.0,<=4.11.0 +typing_extensions ujson>=5.8.0,<=5.9.0 vtf2img==0.1.0 -toml==0.10.2 \ No newline at end of file +toml==0.10.2 +appdirs==1.4.4 +pydantic==2.10.4 \ No newline at end of file diff --git a/tagstudio/src/core/driver.py b/tagstudio/src/core/driver.py index 1561fbc92..c01188eb7 100644 --- a/tagstudio/src/core/driver.py +++ b/tagstudio/src/core/driver.py @@ -1,16 +1,17 @@ from pathlib import Path import structlog -from PySide6.QtCore import QSettings from src.core.constants import TS_FOLDER_NAME -from src.core.enums import SettingItems from src.core.library.alchemy.library import LibraryStatus +from src.core.settings import TSSettings +from src.core.tscacheddata import TSCachedData logger = structlog.get_logger(__name__) class DriverMixin: - settings: QSettings + settings: TSSettings + cache: TSCachedData def evaluate_path(self, open_path: str | None) -> LibraryStatus: """Check if the path of library is valid.""" @@ -20,17 +21,15 @@ def evaluate_path(self, open_path: str | None) -> LibraryStatus: if not library_path.exists(): logger.error("Path does not exist.", open_path=open_path) return LibraryStatus(success=False, message="Path does not exist.") - elif self.settings.value( - SettingItems.START_LOAD_LAST, defaultValue=True, type=bool - ) and self.settings.value(SettingItems.LAST_LIBRARY): - library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY))) + elif self.settings.open_last_loaded_on_startup and self.cache.last_lib: + library_path = Path(str(self.cache.last_library)) if not (library_path / TS_FOLDER_NAME).exists(): logger.error( "TagStudio folder does not exist.", library_path=library_path, ts_folder=TS_FOLDER_NAME, ) - self.settings.setValue(SettingItems.LAST_LIBRARY, "") + self.cache.last_library = "" # dont consider this a fatal error, just skip opening the library library_path = None diff --git a/tagstudio/src/core/settings/__init__.py b/tagstudio/src/core/settings/__init__.py index 32f4a7ea9..b984cdf01 100644 --- a/tagstudio/src/core/settings/__init__.py +++ b/tagstudio/src/core/settings/__init__.py @@ -1 +1,3 @@ -__all__ = ["tssettings"] +from .tssettings import TSSettings + +__all__ = ["TSSettings"] diff --git a/tagstudio/src/core/settings/libsettings.py b/tagstudio/src/core/settings/libsettings.py new file mode 100644 index 000000000..1fcc48b92 --- /dev/null +++ b/tagstudio/src/core/settings/libsettings.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LibSettings(BaseModel): + # Cant think of any library-specific properties lol + test_prop: bool = False diff --git a/tagstudio/src/core/settings/tssettings.py b/tagstudio/src/core/settings/tssettings.py index 2d56f7303..4f8d4ece0 100644 --- a/tagstudio/src/core/settings/tssettings.py +++ b/tagstudio/src/core/settings/tssettings.py @@ -11,24 +11,25 @@ class TSSettings(BaseModel): dark_mode: bool = Field(default=False) language: str = Field(default="en-US") + # settings from the old SettingItem enum + open_last_loaded_on_startup: bool = Field(default=False) + show_library_list: bool = Field(default=True) + autoplay: bool = Field(default=False) + + filename: str = Field() + @staticmethod def read_settings(path: Path | str, **kwargs) -> "TSSettings": - # library = kwargs.get("library") settings_data: dict[str, any] = dict() if path.exists(): - with open(path, "rb").read() as filecontents: + with open(path, "rb") as file: + filecontents = file.read() if len(filecontents.strip()) != 0: settings_data = toml.loads(filecontents.decode("utf-8")) - # if library: #TODO: add library-specific settings - # lib_settings_path = Path(library.folder / "settings.toml") - # lib_settings_data: dict[str, any] - # if lib_settings_path.exists: - # with open(lib_settings_path, "rb") as filedata: - # lib_settings_data = tomllib.load(filedata) - # lib_settings = TSSettings(**lib_settings_data) - - return TSSettings(**settings_data) + settings_data["filename"] = str(path) + settings = TSSettings(**settings_data) + return settings def to_dict(self) -> dict[str, any]: d = dict[str, any]() @@ -37,6 +38,14 @@ def to_dict(self) -> dict[str, any]: return d - def save(self, path: Path | str) -> None: + def save(self, path: Path | str | None) -> None: + if isinstance(path, str): + path = Path(path) + + if path is None: + path = self.filename + if not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: toml.dump(self.to_dict(), f) diff --git a/tagstudio/src/core/tscacheddata.py b/tagstudio/src/core/tscacheddata.py new file mode 100644 index 000000000..bee8d68b4 --- /dev/null +++ b/tagstudio/src/core/tscacheddata.py @@ -0,0 +1,45 @@ +from datetime import datetime +from pathlib import Path + +import structlog +import toml +from appdirs import user_cache_dir +from pydantic import BaseModel, ConfigDict, Field +from src.core.library.alchemy.library import Library + +logger = structlog.get_logger(__name__) + +cache_dir = Path(user_cache_dir()) / ".TagStudio" +cache_location = cache_dir / "cache.toml" + + +class TSCachedData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + last_library: Library | None = Field(default=None) + library_history: dict[datetime, str] = Field(default_factory=dict[datetime, str]) + + path: str = Field() + + @staticmethod + def open(path: str | None = None) -> "TSCachedData": + file: str | None = None + + if path is None: + if not Path(cache_dir).exists(): + logger.info("Cache directory does not exist - creating", path=cache_dir) + Path.mkdir(cache_dir) + if not Path(cache_location).exists(): + logger.info("Cache file does not exist - creating", path=cache_location) + open(cache_location, "w").close() + file = str(cache_location) + else: + file = path + + data = toml.load(file) + data["path"] = str(path) if path is not None else str(cache_location) + cached_data = TSCachedData(**data) + return cached_data + + def save(self): + with open(self.path, "wb") as f: + f.writelines(toml.dumps(self)) diff --git a/tagstudio/src/qt/modals/settings_modal.py b/tagstudio/src/qt/modals/settings_modal.py index f3772b7b6..c712f9cd6 100644 --- a/tagstudio/src/qt/modals/settings_modal.py +++ b/tagstudio/src/qt/modals/settings_modal.py @@ -7,12 +7,12 @@ QLabel, QVBoxLayout, ) -from src.core.settings import TSSettings +from src.core.settings import tssettings from src.qt.widgets.panel import PanelWidget class SettingsModal(PanelWidget): - def __init__(self, settings: TSSettings): + def __init__(self, settings: tssettings): super().__init__() self.tempSettings = copy.deepcopy(settings) @@ -29,7 +29,7 @@ def __init__(self, settings: TSSettings): self.darkMode_Value.setChecked(self.tempSettings.dark_mode) self.darkMode_Value.stateChanged.connect( - lambda state: self.set_property("dark_mode", bool(state)) + lambda state: setattr(self.tempSettings, "dark_mode", bool(state)) ) # --- @@ -49,15 +49,29 @@ def __init__(self, settings: TSSettings): self.language_Value.addItems(language_list) self.language_Value.setCurrentIndex(language_list.index(self.tempSettings.language)) self.language_Value.currentTextChanged.connect( - lambda text: self.set_property("language", text) + lambda text: setattr(self.tempSettings, "language", text) + ) + + # --- + self.show_library_list_Label = QLabel() + self.show_library_list_Value = QCheckBox() + self.show_library_list_Row = QHBoxLayout() + self.show_library_list_Row.addWidget(self.show_library_list_Label) + self.show_library_list_Row.addWidget(self.show_library_list_Value) + self.show_library_list_Label.setText("Load library list on startup:") + self.show_library_list_Value.setChecked(self.tempSettings.show_library_list) + + self.show_library_list_Value.stateChanged.connect( + lambda state: setattr(self.tempSettings, "show_library_list", bool(state)) ) # --- self.main.addLayout(self.darkMode_Row) self.main.addLayout(self.language_Row) + self.main.addLayout(self.show_library_list_Row) def set_property(self, prop_name: str, value: any) -> None: setattr(self.tempSettings, prop_name, value) - def get_content(self) -> TSSettings: + def get_content(self) -> tssettings: return self.tempSettings diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 908f826c0..2448d7157 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -9,6 +9,7 @@ import ctypes import dataclasses +import datetime import math import os import re @@ -27,7 +28,6 @@ from PySide6 import QtCore from PySide6.QtCore import ( QObject, - QSettings, Qt, QThread, QThreadPool, @@ -67,7 +67,7 @@ VERSION_BRANCH, ) from src.core.driver import DriverMixin -from src.core.enums import LibraryPrefs, MacroID, SettingItems +from src.core.enums import LibraryPrefs, MacroID from src.core.library.alchemy import Library from src.core.library.alchemy.enums import ( FieldTypeEnum, @@ -79,6 +79,7 @@ from src.core.media_types import MediaCategories from src.core.settings import TSSettings from src.core.ts_core import TagStudioCore +from src.core.tscacheddata import TSCachedData from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout @@ -171,19 +172,17 @@ def __init__(self, backend, args): if not path.exists(): logger.warning("Config File does not exist creating", path=path) logger.info("Using Config File", path=path) - self.settings = QSettings(str(path), QSettings.Format.IniFormat) + self.settings = TSSettings.read_settings(path) else: - self.settings = QSettings( - QSettings.Format.IniFormat, - QSettings.Scope.UserScope, - "TagStudio", - "TagStudio", - ) + path = Path.home() / ".TagStudio" / "config.toml" + self.settings = TSSettings.read_settings(path) logger.info( "Config File not specified, using default one", - filename=self.settings.fileName(), + filename=self.settings.filename, ) + self.cache = TSCachedData.open() + def init_workers(self): """Init workers for rendering thumbnails.""" if not self.thumb_threads: @@ -245,13 +244,6 @@ def start(self) -> None: self.main_window.dragMoveEvent = self.drag_move_event # type: ignore[method-assign] self.main_window.dropEvent = self.drop_event # type: ignore[method-assign] - self.settings_path = ( - Path.home() / ".config/TagStudio" / "settings.toml" - ) # TODO: put this somewhere else - self.newSettings = TSSettings.read_settings( - self.settings_path - ) # TODO: make this cross-platform - splash_pixmap = QPixmap(":/images/splash.png") splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) self.splash = QSplashScreen(splash_pixmap, Qt.WindowType.WindowStaysOnTopHint) @@ -381,11 +373,9 @@ def start(self) -> None: check_action = QAction("Open library on start", self) check_action.setCheckable(True) - check_action.setChecked( - bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) - ) + check_action.setChecked(self.settings.open_last_loaded_on_startup) check_action.triggered.connect( - lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) + lambda checked: setattr(self.settings, "open_last_loaded_on_startup", checked) ) window_menu.addAction(check_action) @@ -424,12 +414,10 @@ def create_dupe_files_modal(): show_libs_list_action = QAction("Show Recent Libraries", menu_bar) show_libs_list_action.setCheckable(True) - show_libs_list_action.setChecked( - bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) - ) + show_libs_list_action.setChecked(self.settings.show_library_list) show_libs_list_action.triggered.connect( lambda checked: ( - self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), + setattr(self.settings, "show_library_list", checked), self.toggle_libs_list(checked), ) ) @@ -597,7 +585,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.statusbar.showMessage("Closing Library...") start_time = time.time() - self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir)) + self.cache.last_library = self.lib.library_dir self.settings.sync() self.lib.close() @@ -654,7 +642,7 @@ def add_tag_action_callback(self): def open_settings_menu(self): self.modal = PanelModal( - SettingsModal(self.newSettings), + SettingsModal(self.settings), "Settings", "Settings", has_save=True, @@ -664,8 +652,8 @@ def open_settings_menu(self): self.modal.show() def update_settings(self, settings: TSSettings): - self.newSettings = settings - self.newSettings.save(self.settings_path) + self.settings = settings + self.settings.save(self.settings.filename) def select_all_action_callback(self): self.selected = list(range(0, len(self.frame_content))) @@ -1177,37 +1165,24 @@ def filter_items(self, filter: FilterState | None = None) -> None: self.pages_count, self.filter.page_index, emit=False ) - 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 remove_recent_library(self, item_key: str) -> None: + self.cache.library_history.pop(datetime.datetime.strptime(item_key)) def update_libs_list(self, path: Path | str): - """Add library to list in SettingItems.LIBS_LIST.""" item_limit: int = 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 = str(self.settings.value(item_key, type=str)) - if Path(item_path) != path: - all_libs[item_key] = item_path + for access_time in self.cache.library_history: + lib = self.cache.library_history[access_time] + if Path(lib) != path: + all_libs[str(access_time)] = lib - # sort items, most recent first all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) - - # remove previously saved items - self.settings.remove("") - - for item_key, item_value in all_libs_list[:item_limit]: - self.settings.setValue(item_key, item_value) - - self.settings.endGroup() - self.settings.sync() + self.cache.library_history = {} + for key, value in all_libs_list[:item_limit]: + self.cache.library_history[key] = value def open_library(self, path: Path) -> None: """Open a TagStudio library.""" diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 7e8e0c8b3..3efc74859 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -35,7 +35,7 @@ from src.core.constants import ( TS_FOLDER_NAME, ) -from src.core.enums import SettingItems, Theme +from src.core.enums import Theme from src.core.library.alchemy.fields import ( BaseField, DatetimeField, @@ -248,9 +248,7 @@ def __init__(self, library: Library, driver: "QtDriver"): ) # set initial visibility based on settings - if not self.driver.settings.value( - SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool - ): + if not self.driver.settings.show_library_list: self.libs_flow_container.hide() splitter = QSplitter() @@ -306,29 +304,48 @@ def remove_field_prompt(self, name: str) -> str: return f'Are you sure you want to remove field "{name}"?' 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 = str(settings.value(item_tstamp, type=str)) - cut_val = val - if len(val) > 45: - cut_val = f"{val[0:10]} ... {val[-10:]}" - lib_items[item_tstamp] = (val, cut_val) - - settings.endGroup() + for access_time in self.driver.cache.library_history: + lib_dir = self.driver.cache.library_history[access_time] + cut_val = lib_dir + if len(str(lib_dir)) > 45: + cut_val = f"{lib_dir[0:10]} ... {lib_dir[-10:]}" + lib_items[str(access_time)] = (str(lib_dir), cut_val) 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, 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 = str(settings.value(item_tstamp, type=str)) + # 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())): diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 2b4434b1d..c6cacb681 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -28,7 +28,6 @@ from PySide6.QtMultimediaWidgets import QGraphicsVideoItem from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import QGraphicsScene, QGraphicsView -from src.core.enums import SettingItems from src.qt.helpers.file_opener import FileOpenerHelper from src.qt.platform_strings import PlatformStrings @@ -118,9 +117,7 @@ def __init__(self, driver: "QtDriver") -> None: autoplay_action = QAction("Autoplay", self) autoplay_action.setCheckable(True) self.addAction(autoplay_action) - autoplay_action.setChecked( - self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore - ) + autoplay_action.setChecked(self.driver.settings.autoplay) autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action @@ -139,8 +136,8 @@ def close(self, *args, **kwargs) -> None: def toggle_autoplay(self) -> None: """Toggle the autoplay state of the video.""" - self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked()) - self.driver.settings.sync() + self.driver.settings.autoplay = self.autoplay.isChecked() + self.driver.settings.save() def check_media_status(self, media_status: QMediaPlayer.MediaStatus) -> None: if media_status == QMediaPlayer.MediaStatus.EndOfMedia: diff --git a/tagstudio/tests/test_driver.py b/tagstudio/tests/test_driver.py index 240406f93..c09f83f4e 100644 --- a/tagstudio/tests/test_driver.py +++ b/tagstudio/tests/test_driver.py @@ -2,21 +2,23 @@ from pathlib import Path from tempfile import TemporaryDirectory -from PySide6.QtCore import QSettings from src.core.constants import TS_FOLDER_NAME from src.core.driver import DriverMixin -from src.core.enums import SettingItems from src.core.library.alchemy.library import LibraryStatus +from src.core.settings import TSSettings +from src.core.tscacheddata import TSCachedData class TestDriver(DriverMixin): - def __init__(self, settings): + def __init__(self, settings, cache: TSCachedData | None = None): self.settings = settings + if cache: + self.cache = cache def test_evaluate_path_empty(): # Given - settings = QSettings() + settings = TSSettings(**dict()) driver = TestDriver(settings) # When @@ -28,7 +30,7 @@ def test_evaluate_path_empty(): def test_evaluate_path_missing(): # Given - settings = QSettings() + settings = TSSettings(**dict()) driver = TestDriver(settings) # When @@ -40,9 +42,10 @@ def test_evaluate_path_missing(): def test_evaluate_path_last_lib_not_exists(): # Given - settings = QSettings() - settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/") - driver = TestDriver(settings) + settings = TSSettings(**dict()) + cache = TSCachedData() + cache.last_library = "/0/4/5/1/" + driver = TestDriver(settings, cache) # When result = driver.evaluate_path(None) @@ -54,13 +57,13 @@ def test_evaluate_path_last_lib_not_exists(): def test_evaluate_path_last_lib_present(): # Given with TemporaryDirectory() as tmpdir: - settings_file = tmpdir + "/test_settings.ini" - settings = QSettings(settings_file, QSettings.Format.IniFormat) - settings.setValue(SettingItems.LAST_LIBRARY, tmpdir) - settings.sync() + settings_file = tmpdir + "/test_settings.toml" + cache = TSCachedData.open(settings_file) + cache.last_library = tmpdir + cache.save() makedirs(Path(tmpdir) / TS_FOLDER_NAME) - driver = TestDriver(settings) + driver = TestDriver(TSSettings(**dict()), cache) # When result = driver.evaluate_path(None)