From 61b9fcf764b5dfacd7cb9614a68795b39d53588b Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Mon, 17 Feb 2025 14:45:30 -0800
Subject: [PATCH] feat(ui): add language setting (#803)
* feat(ui): add language setting
* translations: implement remaining todos
* ui: show language names in settings instead of codes
* translations: add Dutch setting, anticipating #798
---
tagstudio/resources/translations/en.json | 5 ++
tagstudio/src/core/enums.py | 1 +
tagstudio/src/qt/modals/settings_panel.py | 73 +++++++++++++++++++
tagstudio/src/qt/translations.py | 18 +++--
tagstudio/src/qt/ts_qt.py | 53 ++++++++++----
.../src/qt/widgets/preview/file_attributes.py | 19 +++--
tagstudio/src/qt/widgets/preview_panel.py | 4 +-
7 files changed, 144 insertions(+), 29 deletions(-)
create mode 100644 tagstudio/src/qt/modals/settings_panel.py
diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json
index 0962a594..4fe852de 100644
--- a/tagstudio/resources/translations/en.json
+++ b/tagstudio/resources/translations/en.json
@@ -199,6 +199,7 @@
"menu.macros.folders_to_tags": "Folders to Tags",
"menu.macros": "&Macros",
"menu.select": "Select",
+ "menu.settings": "Settings...",
"menu.tools.fix_duplicate_files": "Fix Duplicate &Files",
"menu.tools.fix_unlinked_entries": "Fix &Unlinked Entries",
"menu.tools": "&Tools",
@@ -209,6 +210,7 @@
"namespace.create.title": "Create Namespace",
"namespace.new.button": "New Namespace",
"namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!",
+ "preview.multiple_selection": "{count} Items Selected",
"preview.no_selection": "No Items Selected",
"select.add_tag_to_selected": "Add Tag to Selected",
"select.all": "Select All",
@@ -216,9 +218,12 @@
"edit.copy_fields": "Copy Fields",
"edit.paste_fields": "Paste Fields",
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
+ "settings.language": "Language",
"settings.open_library_on_start": "Open Library on Start",
+ "settings.restart_required": "Please restart TagStudio for changes to take effect.",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
+ "settings.title": "Settings",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py
index ea68838b..84debc91 100644
--- a/tagstudio/src/core/enums.py
+++ b/tagstudio/src/core/enums.py
@@ -17,6 +17,7 @@ class SettingItems(str, enum.Enum):
SHOW_FILENAMES = "show_filenames"
AUTOPLAY = "autoplay_videos"
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
+ LANGUAGE = "language"
class Theme(str, enum.Enum):
diff --git a/tagstudio/src/qt/modals/settings_panel.py b/tagstudio/src/qt/modals/settings_panel.py
new file mode 100644
index 00000000..a499f6dd
--- /dev/null
+++ b/tagstudio/src/qt/modals/settings_panel.py
@@ -0,0 +1,73 @@
+# Copyright (C) 2025 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 QComboBox, QFormLayout, QLabel, QVBoxLayout, QWidget
+from src.core.enums import SettingItems
+from src.qt.translations import Translations
+from src.qt.widgets.panel import PanelWidget
+
+
+class SettingsPanel(PanelWidget):
+ def __init__(self, driver):
+ super().__init__()
+ self.driver = driver
+ self.setMinimumSize(320, 200)
+ self.root_layout = QVBoxLayout(self)
+ self.root_layout.setContentsMargins(6, 0, 6, 0)
+
+ self.form_container = QWidget()
+ self.form_layout = QFormLayout(self.form_container)
+ self.form_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.restart_label = QLabel()
+ self.restart_label.setHidden(True)
+ Translations.translate_qobject(self.restart_label, "settings.restart_required")
+ self.restart_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ language_label = QLabel()
+ Translations.translate_qobject(language_label, "settings.language")
+ self.languages = {
+ # "Cantonese (Traditional)": "yue_Hant", # Empty
+ "Chinese (Traditional)": "zh_Hant",
+ # "Czech": "cs", # Minimal
+ # "Danish": "da", # Minimal
+ "Dutch": "nl",
+ "English": "en",
+ "Filipino": "fil",
+ "French": "fr",
+ "German": "de",
+ "Hungarian": "hu",
+ # "Italian": "it", # Minimal
+ "Norwegian Bokmål": "nb_NO",
+ "Polish": "pl",
+ "Portuguese (Brazil)": "pt_BR",
+ # "Portuguese (Portugal)": "pt", # Empty
+ "Russian": "ru",
+ "Spanish": "es",
+ "Swedish": "sv",
+ "Tamil": "ta",
+ "Toki Pona": "tok",
+ "Turkish": "tr",
+ }
+ self.language_combobox = QComboBox()
+ self.language_combobox.addItems(list(self.languages.keys()))
+ current_lang: str = str(
+ driver.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str)
+ )
+ current_lang = "en" if current_lang not in self.languages.values() else current_lang
+ self.language_combobox.setCurrentIndex(list(self.languages.values()).index(current_lang))
+ self.language_combobox.currentIndexChanged.connect(
+ lambda: self.restart_label.setHidden(False)
+ )
+ self.form_layout.addRow(language_label, self.language_combobox)
+
+ self.root_layout.addWidget(self.form_container)
+ self.root_layout.addStretch(1)
+ self.root_layout.addWidget(self.restart_label)
+
+ def get_language(self) -> str:
+ values: list[str] = list(self.languages.values())
+ return values[self.language_combobox.currentIndex()]
diff --git a/tagstudio/src/qt/translations.py b/tagstudio/src/qt/translations.py
index 5d2dcea0..fae788ac 100644
--- a/tagstudio/src/qt/translations.py
+++ b/tagstudio/src/qt/translations.py
@@ -70,16 +70,20 @@ class Translator:
Also formats the translation with the given keyword arguments.
"""
- if key in self._strings:
- self._strings[key].changed.connect(lambda text: setter(self.__format(text, **kwargs)))
+ # TODO: Fix so deleted Qt objects aren't referenced any longer
+ # if key in self._strings:
+ # self._strings[key].changed.connect(lambda text: setter(self.__format(text, **kwargs)))
setter(self.translate_formatted(key, **kwargs))
def __format(self, text: str, **kwargs) -> str:
try:
return text.format(**kwargs)
- except KeyError:
- logger.warning(
- "Error while formatting translation.", text=text, kwargs=kwargs, language=self._lang
+ except (KeyError, ValueError):
+ logger.error(
+ "[Translations] Error while formatting translation.",
+ text=text,
+ kwargs=kwargs,
+ language=self._lang,
)
return text
@@ -87,9 +91,7 @@ class Translator:
return self.__format(self[key], **kwargs)
def __getitem__(self, key: str) -> str:
- # return "???"
- return self._strings[key].value if key in self._strings else "Not Translated"
+ return self._strings[key].value if key in self._strings else f"[{key}]"
Translations = Translator()
-# Translations.change_language("de")
diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py
index 8e64c27d..f02cf8ed 100644
--- a/tagstudio/src/qt/ts_qt.py
+++ b/tagstudio/src/qt/ts_qt.py
@@ -87,6 +87,7 @@ from src.qt.modals.file_extension import FileExtensionModal
from src.qt.modals.fix_dupes import FixDupeFilesModal
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from src.qt.modals.folders_to_tags import FoldersToTagsModal
+from src.qt.modals.settings_panel import SettingsPanel
from src.qt.modals.tag_color_manager import TagColorManager
from src.qt.modals.tag_database import TagDatabasePanel
from src.qt.modals.tag_search import TagSearchPanel
@@ -197,6 +198,10 @@ class QtDriver(DriverMixin, QObject):
)
self.config_path = self.settings.fileName()
+ Translations.change_language(
+ str(self.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str))
+ )
+
# NOTE: This should be a per-library setting rather than an application setting.
thumb_cache_size_limit: int = int(
str(
@@ -366,19 +371,6 @@ class QtDriver(DriverMixin, QObject):
file_menu.addMenu(self.open_recent_library_menu)
self.update_recent_lib_menu()
- open_on_start_action = QAction(self)
- Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
- open_on_start_action.setCheckable(True)
- open_on_start_action.setChecked(
- bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
- )
- open_on_start_action.triggered.connect(
- lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
- )
- file_menu.addAction(open_on_start_action)
-
- file_menu.addSeparator()
-
self.save_library_backup_action = QAction(menu_bar)
Translations.translate_qobject(self.save_library_backup_action, "menu.file.save_backup")
self.save_library_backup_action.triggered.connect(
@@ -397,6 +389,23 @@ class QtDriver(DriverMixin, QObject):
self.save_library_backup_action.setEnabled(False)
file_menu.addAction(self.save_library_backup_action)
+ file_menu.addSeparator()
+ settings_action = QAction(self)
+ Translations.translate_qobject(settings_action, "menu.settings")
+ settings_action.triggered.connect(self.open_settings_modal)
+ file_menu.addAction(settings_action)
+
+ open_on_start_action = QAction(self)
+ Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
+ open_on_start_action.setCheckable(True)
+ open_on_start_action.setChecked(
+ bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
+ )
+ open_on_start_action.triggered.connect(
+ lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
+ )
+ file_menu.addAction(open_on_start_action)
+
file_menu.addSeparator()
self.refresh_dir_action = QAction(menu_bar)
@@ -1830,6 +1839,24 @@ class QtDriver(DriverMixin, QObject):
self.settings.sync()
self.update_recent_lib_menu()
+ def open_settings_modal(self):
+ # TODO: Implement a proper settings panel, and don't re-create it each time it's opened.
+ settings_panel = SettingsPanel(self)
+ modal = PanelModal(
+ widget=settings_panel,
+ done_callback=lambda: self.update_language_settings(settings_panel.get_language()),
+ has_save=False,
+ )
+ Translations.translate_with_setter(modal.setTitle, "settings.title")
+ Translations.translate_with_setter(modal.setWindowTitle, "settings.title")
+ modal.show()
+
+ def update_language_settings(self, language: str):
+ Translations.change_language(language)
+
+ self.settings.setValue(SettingItems.LANGUAGE, language)
+ self.settings.sync()
+
def open_library(self, path: Path) -> None:
"""Open a TagStudio library."""
translation_params = {"key": "splash.opening_library", "library_path": str(path)}
diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py
index fa1a6c7e..50f3205f 100644
--- a/tagstudio/src/qt/widgets/preview/file_attributes.py
+++ b/tagstudio/src/qt/widgets/preview/file_attributes.py
@@ -23,6 +23,7 @@ from src.core.enums import Theme
from src.core.library.alchemy.library import Library
from src.core.media_types import MediaCategories
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
+from src.qt.translations import Translations
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -108,16 +109,22 @@ class FileAttributes(QWidget):
created = dt.fromtimestamp(filepath.stat().st_ctime)
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
self.date_created_label.setText(
- f"Date Created: {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate
+ f"{Translations["file.date_created"]}: "
+ f"{dt.strftime(created, "%a, %x, %X")}"
)
self.date_modified_label.setText(
- f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate
+ f"{Translations["file.date_modified"]}: "
+ f"{dt.strftime(modified, "%a, %x, %X")}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
- self.date_created_label.setText("Date Created: N/A") # TODO: Translate
- self.date_modified_label.setText("Date Modified: N/A") # TODO: Translate
+ self.date_created_label.setText(
+ f"{Translations["file.date_created"]}: N/A"
+ )
+ self.date_modified_label.setText(
+ f"{Translations["file.date_modified"]}: N/A"
+ )
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
@@ -132,7 +139,7 @@ class FileAttributes(QWidget):
if not filepath:
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.file_label.setText("No Items Selected") # TODO: Translate
+ self.file_label.setText(f"{Translations["preview.no_selection"]}")
self.file_label.set_file_path("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.dimensions_label.setText("")
@@ -221,7 +228,7 @@ class FileAttributes(QWidget):
"""Format attributes for multiple selected items."""
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.file_label.setText(f"{count} Items Selected") # TODO: Translate
+ Translations.translate_qobject(self.file_label, "preview.multiple_selection", count=count)
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.file_label.set_file_path("")
self.dimensions_label.setText("")
diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py
index ab1791a9..9df1222d 100644
--- a/tagstudio/src/qt/widgets/preview_panel.py
+++ b/tagstudio/src/qt/widgets/preview_panel.py
@@ -105,14 +105,14 @@ class PreviewPanel(QWidget):
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_tag_button.setMinimumHeight(28)
self.add_tag_button.setStyleSheet(PreviewPanel.button_style)
- self.add_tag_button.setText("Add Tag") # TODO: Translate
+ Translations.translate_qobject(self.add_tag_button, "tag.add")
self.add_field_button = QPushButton()
self.add_field_button.setEnabled(False)
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumHeight(28)
self.add_field_button.setStyleSheet(PreviewPanel.button_style)
- self.add_field_button.setText("Add Field") # TODO: Translate
+ Translations.translate_qobject(self.add_field_button, "library.field.add")
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)