From c781f66ae75eef16c02174410914bac6c9082801 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 7 Jul 2026 14:39:56 -0500 Subject: [PATCH] Fix icons to work in both linux and windows via qtawesome --- gui/config_tab.py | 32 ++--- gui/csv_tab.py | 30 ++--- gui/log_viewer.py | 23 ++-- gui/platform_glyph.py | 23 +++- gui/qt_icons.py | 253 +++++++++++++++++++++++++++++++++++++++ gui/rpgmaker_tab.py | 30 ++--- gui/srpg_tab.py | 16 ++- gui/translation_tab.py | 120 ++++++++----------- gui/wolf_tab.py | 15 ++- gui/wolf_workflow_tab.py | 6 +- gui/workflow_tab.py | 31 +++-- requirements.txt | 2 + tests/test_qt_icons.py | 62 ++++++++++ 13 files changed, 498 insertions(+), 145 deletions(-) create mode 100644 gui/qt_icons.py create mode 100644 tests/test_qt_icons.py diff --git a/gui/config_tab.py b/gui/config_tab.py index cc0937c..f3f1182 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -13,7 +13,6 @@ from PyQt5.QtWidgets import ( ) from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread from PyQt5.QtGui import QIcon -from gui.platform_glyph import platform_glyph from dotenv import load_dotenv, set_key, dotenv_values from gui.rpgmaker_tab import RPGMakerTab @@ -96,17 +95,18 @@ class ModelFetchThread(QThread): def create_section_header(title): """Create a clean section header without boxes.""" - label = QLabel(platform_glyph(title)) - label.setStyleSheet(""" - QLabel { - font-size: 13px; - font-weight: bold; - color: #007acc; - padding: 8px 0px 5px 0px; - background-color: transparent; - } - """) - return label + from gui.qt_icons import make_section_header + + return make_section_header( + title, + "QLabel {" + "font-size: 13px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 8px 0px 5px 0px;" + "background-color: transparent;" + "}", + ) def create_horizontal_line(): """Create a horizontal separator line.""" @@ -598,11 +598,15 @@ class ConfigTab(QWidget): button_layout = QHBoxLayout() button_layout.setSpacing(10) - reset_button = QPushButton(platform_glyph("🔄 Reset to Defaults")) + from gui import qt_icons + + reset_button = QPushButton() + qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc") reset_button.clicked.connect(self.reset_to_defaults_with_save) reset_button.setMinimumHeight(32) - save_button = QPushButton("💾 Save Changes") + save_button = QPushButton() + qt_icons.apply_button_icon(save_button, "💾 Save Changes", color="#cccccc") save_button.clicked.connect(lambda: self.save_to_env(show_message=True)) save_button.setMinimumHeight(32) diff --git a/gui/csv_tab.py b/gui/csv_tab.py index a173583..6179dc8 100644 --- a/gui/csv_tab.py +++ b/gui/csv_tab.py @@ -8,8 +8,6 @@ from PyQt5.QtWidgets import ( QPushButton, QLabel, QMessageBox, QSpinBox, QFrame, QComboBox ) from PyQt5.QtCore import Qt, pyqtSignal -from gui.platform_glyph import platform_glyph - try: from .config_integration import ConfigIntegration except ImportError: @@ -18,17 +16,18 @@ except ImportError: def create_section_label(text): """Create a section label for grouping settings.""" - label = QLabel(platform_glyph(text)) - label.setStyleSheet(""" - QLabel { - font-size: 12px; - font-weight: bold; - color: #007acc; - padding: 5px 0px 3px 0px; - background-color: transparent; - } - """) - return label + from gui.qt_icons import make_section_header + + return make_section_header( + text, + "QLabel {" + "font-size: 12px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 5px 0px 3px 0px;" + "background-color: transparent;" + "}", + ) def create_horizontal_line(): @@ -248,7 +247,10 @@ class CSVTab(QWidget): button_layout = QHBoxLayout() button_layout.setSpacing(10) - self.reset_button = QPushButton(platform_glyph("🔄 Reset to Defaults")) + from gui.qt_icons import apply_button_icon + + self.reset_button = QPushButton() + apply_button_icon(self.reset_button, "🔄 Reset to Defaults", color="#cccccc") self.reset_button.clicked.connect(self.reset_to_defaults_with_message) self.reset_button.setMaximumWidth(180) self.reset_button.setMinimumHeight(32) diff --git a/gui/log_viewer.py b/gui/log_viewer.py index 1db9e8e..3a2ac1a 100644 --- a/gui/log_viewer.py +++ b/gui/log_viewer.py @@ -7,7 +7,6 @@ from PyQt5.QtWidgets import ( ) from PyQt5.QtCore import Qt, pyqtSignal, QTimer from PyQt5.QtGui import QTextCursor, QFont, QTextCharFormat -from gui.platform_glyph import platform_glyph from pathlib import Path import datetime import html @@ -35,16 +34,18 @@ class LogViewer(QWidget): layout.setContentsMargins(0, 0, 0, 0) # Simple header to match left-side styling (use same look as create_section_header) - header = QLabel(platform_glyph("📝 Translation Log")) - header.setStyleSheet(""" - QLabel { - font-size: 13px; - font-weight: bold; - color: #007acc; - padding: 8px 0px 5px 0px; - background-color: transparent; - } - """) + from gui.qt_icons import make_section_header + + header = make_section_header( + "📝 Translation Log", + "QLabel {" + "font-size: 13px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 8px 0px 5px 0px;" + "background-color: transparent;" + "}", + ) layout.addWidget(header, 0) # Match spacing used in left column so the gap between header and diff --git a/gui/platform_glyph.py b/gui/platform_glyph.py index b11d7ca..5fd3bb4 100644 --- a/gui/platform_glyph.py +++ b/gui/platform_glyph.py @@ -158,9 +158,10 @@ def configure_nav_toolbutton( """Apply nav icon - engine PNG when available, else emoji/glyph fallback.""" from util.paths import ENGINE_ICONS_DIR + icon_dim = max(28, min(btn.width(), btn.height()) - 8) + engine_png = (ENGINE_ICONS_DIR / f"{engine_key}.png") if engine_key else None if engine_png is not None and engine_png.is_file(): - icon_dim = max(28, min(btn.width(), btn.height()) - 8) icon = QIcon(str(engine_png)) btn.setIcon(icon) btn.setIconSize(QSize(icon_dim, icon_dim)) @@ -171,6 +172,26 @@ def configure_nav_toolbutton( )) return + # Preferred path: render a real vector icon via qtawesome so the glyph is + # identical on Windows and Linux (no tofu, no emoji font dependency). + from gui import qt_icons + + icon_name = qt_icons.icon_for_emoji(icon_text) + if qt_icons.HAS_QTA and icon_name: + off_color = "#ff8a80" if update_available else "#cccccc" + on_color = "#ff5252" if update_available else "#ffffff" + btn.setIcon(qt_icons.two_state_icon( + icon_name, icon_dim, off_color=off_color, on_color=on_color, + disabled_color="#666666", + )) + btn.setIconSize(QSize(icon_dim, icon_dim)) + btn.setText("") + btn.setToolButtonStyle(Qt.ToolButtonIconOnly) + btn.setStyleSheet(_nav_toolbutton_stylesheet( + horizontal=horizontal, icon_only=True, update_available=update_available, + )) + return + if sys.platform.startswith("linux"): # Scale to the button cell (leave room for the 3px active border). icon_dim = max(28, min(btn.width(), btn.height()) - 8) diff --git a/gui/qt_icons.py b/gui/qt_icons.py new file mode 100644 index 0000000..8c24a78 --- /dev/null +++ b/gui/qt_icons.py @@ -0,0 +1,253 @@ +"""Centralized icon helpers backed by qtawesome. + +qtawesome renders icons from bundled fonts (Material Design Icons, etc.) into +real ``QIcon``/``QPixmap`` objects, so they look identical on Windows and Linux +and avoid the emoji "tofu" problem on Linux font stacks. + +Everything degrades gracefully: if qtawesome is unavailable the helpers fall +back to the legacy ``platform_glyph`` text so the UI still renders. +""" + +from __future__ import annotations + +import re + +from PyQt5.QtCore import QSize, Qt +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtWidgets import QHBoxLayout, QLabel, QWidget + +try: + import qtawesome as qta + + HAS_QTA = True +except Exception: # pragma: no cover - only when the optional dep is missing + qta = None # type: ignore[assignment] + HAS_QTA = False + +# Neutral light gray that matches the app's default text on dark surfaces. +DEFAULT_COLOR = "#cccccc" +ACCENT_COLOR = "#007acc" + +# Map the emoji/symbols used across the GUI to Material Design Icon names. +# Multi-character keys (e.g. "►►", "⚙️" with variation selector) are matched +# before single characters, so order-independent longest-match lookup works. +EMOJI_ICON: dict[str, str] = { + "►►": "mdi6.fast-forward", + "⚙️": "mdi6.cog", + "🖥️": "mdi6.monitor", + "🖼️": "mdi6.image-multiple-outline", + "⚔️": "mdi6.sword-cross", + "⚠️": "mdi6.alert-outline", + "🌐": "mdi6.web", + "⚡": "mdi6.lightning-bolt", + "⚙": "mdi6.cog", + "🔧": "mdi6.wrench-outline", + "🔑": "mdi6.key-variant", + "📝": "mdi6.text-box-outline", + "💰": "mdi6.currency-usd", + "🖥": "mdi6.monitor", + "📁": "mdi6.folder-open-outline", + "📂": "mdi6.folder-open-outline", + "📄": "mdi6.file-document-outline", + "🗺": "mdi6.map-outline", + "❓": "mdi6.help-circle-outline", + "💬": "mdi6.message-text-outline", + "🖼": "mdi6.image-multiple-outline", + "🎮": "mdi6.gamepad-variant-outline", + "📚": "mdi6.bookshelf", + "👤": "mdi6.account", + "📋": "mdi6.clipboard-text-outline", + "📊": "mdi6.table-column", + "📤": "mdi6.export", + "📥": "mdi6.import", + "🔄": "mdi6.refresh", + "↺": "mdi6.refresh", + "↻": "mdi6.refresh", + "🔍": "mdi6.magnify", + "💾": "mdi6.content-save", + "🗑️": "mdi6.delete-outline", + "🗑": "mdi6.delete-outline", + "➕": "mdi6.plus", + "🛑": "mdi6.stop-circle-outline", + "⚔": "mdi6.sword-cross", + "⬇": "mdi6.download", + "⬆": "mdi6.upload", + "↓": "mdi6.import", + "↑": "mdi6.export", + "►": "mdi6.play", + "▶": "mdi6.play", + "◆": "mdi6.diamond-stone", + "☑": "mdi6.checkbox-marked-outline", + "☐": "mdi6.checkbox-blank-outline", + "▼": "mdi6.chevron-down", + "◄": "mdi6.chevron-left", + "←": "mdi6.arrow-left", + "→": "mdi6.arrow-right", + "✓": "mdi6.check-bold", + "✔": "mdi6.check-bold", + "✅": "mdi6.check-circle-outline", + "✗": "mdi6.close-thick", + "✕": "mdi6.close-thick", + "❌": "mdi6.close-circle-outline", + "⚠": "mdi6.alert-outline", +} + +# Leading symbol/emoji cluster at the start of a button/header label. +_LEADING_SYMBOLS = re.compile( + r"^[\s]*(" + r"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U00002190-\U000021FF" + r"\U00002300-\U000023FF\U000025A0-\U000025FF\U00002B00-\U00002BFF" + r"\uFE0F\u2028\u2029]+" + r")\s*" +) + + +# File-list categories -> (icon name, tint color). +_FILE_CATEGORY_ICONS: dict[str, tuple[str, str]] = { + "core": ("mdi6.file-document-outline", "#9cdcfe"), + "map": ("mdi6.map-outline", "#c5c5c0"), + "other": ("mdi6.help-circle-outline", "#888888"), +} + + +def file_category_icon(category: str) -> QIcon: + """Return the list icon for a file category (``core`` / ``map`` / other).""" + name, color = _FILE_CATEGORY_ICONS.get(category, _FILE_CATEGORY_ICONS["other"]) + return get_icon(name, color=color) + + +def get_icon(name: str, color: str = DEFAULT_COLOR, **kwargs) -> QIcon: + """Return a qtawesome ``QIcon`` (empty ``QIcon`` if unavailable).""" + if not HAS_QTA: + return QIcon() + try: + return qta.icon(name, color=color, **kwargs) + except Exception: + return QIcon() + + +def get_pixmap(name: str, size: int, color: str = DEFAULT_COLOR) -> QPixmap: + """Render a qtawesome icon to a square ``QPixmap`` (empty if unavailable).""" + if not HAS_QTA: + return QPixmap() + try: + return qta.icon(name, color=color).pixmap(QSize(size, size)) + except Exception: + return QPixmap() + + +def two_state_icon( + name: str, + size: int, + *, + off_color: str, + on_color: str, + disabled_color: str | None = None, +) -> QIcon: + """Build a nav ``QIcon`` whose On (checked) pixmap uses a brighter color.""" + icon = QIcon() + if not HAS_QTA: + return icon + off_pix = get_pixmap(name, size, off_color) + on_pix = get_pixmap(name, size, on_color) + dis_pix = get_pixmap(name, size, disabled_color or off_color) + icon.addPixmap(off_pix, QIcon.Normal, QIcon.Off) + icon.addPixmap(on_pix, QIcon.Active, QIcon.Off) + icon.addPixmap(on_pix, QIcon.Normal, QIcon.On) + icon.addPixmap(on_pix, QIcon.Active, QIcon.On) + icon.addPixmap(on_pix, QIcon.Selected, QIcon.On) + icon.addPixmap(dis_pix, QIcon.Disabled, QIcon.Off) + return icon + + +def split_leading_symbol(text: str) -> tuple[str | None, str]: + """Split a leading emoji/symbol cluster from *text*. + + Returns ``(icon_name_or_None, remaining_text)``. The icon name is resolved + from :data:`EMOJI_ICON` using a longest-match on the leading cluster, then a + first-character fallback. + """ + match = _LEADING_SYMBOLS.match(text) + if not match: + return None, text + cluster = match.group(1).strip() + rest = text[match.end():].strip() + + if cluster in EMOJI_ICON: + return EMOJI_ICON[cluster], rest + # Try progressively shorter prefixes, then the first character. + for length in range(len(cluster), 0, -1): + prefix = cluster[:length] + if prefix in EMOJI_ICON: + return EMOJI_ICON[prefix], rest + if cluster and cluster[0] in EMOJI_ICON: + return EMOJI_ICON[cluster[0]], rest + return None, rest + + +def icon_for_emoji(emoji: str) -> str | None: + """Return the MDI icon name mapped to *emoji*, or ``None``.""" + name, _ = split_leading_symbol(emoji) + return name + + +def apply_button_icon(btn, raw_text: str, color: str = DEFAULT_COLOR) -> None: + """Set a labeled button's icon from its leading emoji and strip the emoji. + + Falls back to the legacy ``platform_glyph`` text when qtawesome is missing or + no icon mapping exists. + """ + from gui.platform_glyph import platform_glyph + + icon_name, rest = split_leading_symbol(raw_text) + if HAS_QTA and icon_name and rest: + btn.setIcon(get_icon(icon_name, color=color)) + btn.setText(rest) + return + if HAS_QTA and icon_name and not rest: + # Icon-only button. + btn.setIcon(get_icon(icon_name, color=color)) + btn.setText("") + return + btn.setText(platform_glyph(raw_text)) + + +def make_section_header( + title: str, + text_stylesheet: str, + *, + icon_color: str = ACCENT_COLOR, + icon_px: int = 15, +) -> QWidget: + """Build a section header row: a qtawesome icon (from the leading emoji) plus + the styled title text. + + When qtawesome is unavailable, falls back to a plain ``QLabel`` using the + legacy ``platform_glyph`` text so behavior is unchanged. + """ + from gui.platform_glyph import platform_glyph + + icon_name, rest = split_leading_symbol(title) + + if not (HAS_QTA and icon_name): + label = QLabel(platform_glyph(title)) + label.setStyleSheet(text_stylesheet) + return label + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + icon_label = QLabel() + icon_label.setPixmap(get_pixmap(icon_name, icon_px, icon_color)) + icon_label.setStyleSheet("background: transparent; padding: 0;") + icon_label.setAlignment(Qt.AlignVCenter) + layout.addWidget(icon_label, 0, Qt.AlignVCenter) + + text_label = QLabel(rest) + text_label.setStyleSheet(text_stylesheet) + layout.addWidget(text_label, 0, Qt.AlignVCenter) + layout.addStretch() + + return container diff --git a/gui/rpgmaker_tab.py b/gui/rpgmaker_tab.py index 60c8418..28ac2e2 100644 --- a/gui/rpgmaker_tab.py +++ b/gui/rpgmaker_tab.py @@ -10,8 +10,6 @@ from PyQt5.QtWidgets import ( QTextEdit, QSpinBox, QFrame, QGridLayout ) from PyQt5.QtCore import Qt, pyqtSignal -from gui.platform_glyph import platform_glyph - try: from .config_integration import ConfigIntegration except ImportError: @@ -26,17 +24,18 @@ except Exception: def create_section_label(text): """Create a section label for grouping settings.""" - label = QLabel(platform_glyph(text)) - label.setStyleSheet(""" - QLabel { - font-size: 13px; - font-weight: bold; - color: #007acc; - padding: 6px 0px 4px 0px; - background-color: transparent; - } - """) - return label + from gui.qt_icons import make_section_header + + return make_section_header( + text, + "QLabel {" + "font-size: 13px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 6px 0px 4px 0px;" + "background-color: transparent;" + "}", + ) class RPGMakerTab(QWidget): @@ -456,7 +455,10 @@ class RPGMakerTab(QWidget): # Reset button button_layout = QHBoxLayout() - self.reset_button = QPushButton(platform_glyph("🔄 Reset to Defaults")) + from gui.qt_icons import apply_button_icon + + self.reset_button = QPushButton() + apply_button_icon(self.reset_button, "🔄 Reset to Defaults", color="#cccccc") self.reset_button.clicked.connect(self.reset_to_defaults_with_message) self.reset_button.setMinimumHeight(32) self.reset_button.setMaximumWidth(160) diff --git a/gui/srpg_tab.py b/gui/srpg_tab.py index 476f497..1f31cc9 100644 --- a/gui/srpg_tab.py +++ b/gui/srpg_tab.py @@ -9,8 +9,6 @@ from PyQt5.QtWidgets import ( ) from PyQt5.QtCore import pyqtSignal import re -from gui.platform_glyph import platform_glyph - try: from util.defaults import DEFAULTS as CANONICAL_DEFAULTS except Exception: @@ -19,9 +17,12 @@ except Exception: def create_section_label(text): """Create a styled section header label.""" - label = QLabel(platform_glyph(text)) - label.setStyleSheet("font-size: 12px; font-weight: bold; color: #007acc; padding: 2px 0px;") - return label + from gui.qt_icons import make_section_header + + return make_section_header( + text, + "font-size: 12px; font-weight: bold; color: #007acc; padding: 2px 0px;", + ) class SRPGTab(QWidget): @@ -83,7 +84,10 @@ class SRPGTab(QWidget): button_layout = QHBoxLayout() button_layout.setSpacing(10) - self.reset_btn = QPushButton(platform_glyph("🔄 Reset to Defaults")) + from gui.qt_icons import apply_button_icon + + self.reset_btn = QPushButton() + apply_button_icon(self.reset_btn, "🔄 Reset to Defaults", color="#cccccc") self.reset_btn.clicked.connect(self.reset_to_defaults_with_message) self.reset_btn.setMaximumWidth(180) self.reset_btn.setMinimumHeight(32) diff --git a/gui/translation_tab.py b/gui/translation_tab.py index fa7ddd0..5f249b1 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -28,10 +28,10 @@ from PyQt5.QtWidgets import ( QSplitter, QFileDialog, QComboBox, QCheckBox, QProgressBar, QFrame, QFormLayout, QStackedWidget ) from PyQt5.QtWidgets import QSizePolicy -from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QMutex, QProcess, QEvent, QRect, QSettings +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QMutex, QProcess, QEvent, QRect, QSettings, QSize from PyQt5.QtGui import QFont from gui.log_viewer import LogViewer -from gui.platform_glyph import platform_glyph +from gui import qt_icons def _strip_ansi(text): @@ -42,17 +42,16 @@ def _strip_ansi(text): def create_section_header(title): """Create a clean section header without boxes.""" - label = QLabel(platform_glyph(title)) - label.setStyleSheet(""" - QLabel { - font-size: 13px; - font-weight: bold; - color: #007acc; - padding: 8px 0px 5px 0px; - background-color: transparent; - } - """) - return label + return qt_icons.make_section_header( + title, + "QLabel {" + "font-size: 13px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 8px 0px 5px 0px;" + "background-color: transparent;" + "}", + ) def create_horizontal_line(): """Create a horizontal separator line.""" @@ -844,53 +843,31 @@ class TranslationTab(QWidget): } """ - select_all_btn = QPushButton("✓") - select_all_btn.setToolTip("Select all files") - select_all_btn.clicked.connect(self.select_all_files) - select_all_btn.setStyleSheet(first_button_style) - file_buttons.addWidget(select_all_btn) - - deselect_all_btn = QPushButton("✗") - deselect_all_btn.setToolTip("Deselect all files") - deselect_all_btn.clicked.connect(self.deselect_all_files) - deselect_all_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(deselect_all_btn) - - add_files_btn = QPushButton(platform_glyph("➕")) - add_files_btn.setToolTip("Add files to translate") - add_files_btn.clicked.connect(self.add_input_files) - add_files_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(add_files_btn) - - remove_files_btn = QPushButton("🗑️") - remove_files_btn.setToolTip("Remove selected files") - remove_files_btn.clicked.connect(self.remove_selected_files) - remove_files_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(remove_files_btn) - - open_folder_btn = QPushButton(platform_glyph("📁")) - open_folder_btn.setToolTip("Open files folder in explorer") - open_folder_btn.clicked.connect(self.open_input_folder) - open_folder_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(open_folder_btn) - - refresh_btn = QPushButton(platform_glyph("🔄")) - refresh_btn.setToolTip("Refresh file list") - refresh_btn.clicked.connect(self.refresh_file_lists) - refresh_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(refresh_btn) + _icon_size = QSize(18, 18) - self.sidebar_export_btn = QPushButton("📤") - self.sidebar_export_btn.setToolTip("Export selected files → Game Folder\nCopy translated files for the checked items into your game's data directory") - self.sidebar_export_btn.clicked.connect(self._export_selected_files) - self.sidebar_export_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(self.sidebar_export_btn) + def _icon_button(glyph, tooltip, slot, style): + btn = QPushButton() + qt_icons.apply_button_icon(btn, glyph, color="#dddddd") + btn.setIconSize(_icon_size) + btn.setToolTip(tooltip) + btn.clicked.connect(slot) + btn.setStyleSheet(style) + file_buttons.addWidget(btn) + return btn - pricing_test_btn = QPushButton("💰") - pricing_test_btn.setToolTip("Check live pricing for the current model") - pricing_test_btn.clicked.connect(self._check_model_pricing) - pricing_test_btn.setStyleSheet(icon_button_style) - file_buttons.addWidget(pricing_test_btn) + select_all_btn = _icon_button("✓", "Select all files", self.select_all_files, first_button_style) + deselect_all_btn = _icon_button("✗", "Deselect all files", self.deselect_all_files, icon_button_style) + add_files_btn = _icon_button("➕", "Add files to translate", self.add_input_files, icon_button_style) + remove_files_btn = _icon_button("🗑️", "Remove selected files", self.remove_selected_files, icon_button_style) + open_folder_btn = _icon_button("📁", "Open files folder in explorer", self.open_input_folder, icon_button_style) + refresh_btn = _icon_button("🔄", "Refresh file list", self.refresh_file_lists, icon_button_style) + self.sidebar_export_btn = _icon_button( + "📤", + "Export selected files → Game Folder\nCopy translated files for the checked items into your game's data directory", + self._export_selected_files, + icon_button_style, + ) + pricing_test_btn = _icon_button("💰", "Check live pricing for the current model", self._check_model_pricing, icon_button_style) # Add stretch to push buttons to top file_buttons.addStretch() @@ -1122,22 +1099,26 @@ class TranslationTab(QWidget): # Summary button (shown after completion) - icon-only # Use a simple left-arrow for the back action and place it on the left - self.reset_view_button = QPushButton("←") + self.reset_view_button = QPushButton() + qt_icons.apply_button_icon(self.reset_view_button, "←", color="#dddddd") self.reset_view_button.setToolTip("Back to File Selection") self.reset_view_button.clicked.connect(self.reset_to_file_view) self.reset_view_button.setVisible(False) # Button to open the translations (translated) folder - icon-only - self.open_translations_button = QPushButton(platform_glyph("📂")) + self.open_translations_button = QPushButton() + qt_icons.apply_button_icon(self.open_translations_button, "📂", color="#dddddd") self.open_translations_button.setToolTip("Open the translated files folder") self.open_translations_button.clicked.connect(self.open_output_folder) self.open_translations_button.setVisible(False) # Sync translated/ → files/ (RPG Maker only) - self.sync_translated_button = QPushButton(platform_glyph("🔄")) + self.sync_translated_button = QPushButton() + qt_icons.apply_button_icon(self.sync_translated_button, "🔄", color="#dddddd") self.sync_translated_button.setToolTip("Sync translated/ → files/\nCopy translated files back into files/ so the next phase starts from the latest state") self.sync_translated_button.clicked.connect(self._sync_translated_to_files) self.sync_translated_button.setVisible(False) # Export active files → game folder (RPG Maker only) - self.export_active_button = QPushButton("📤") + self.export_active_button = QPushButton() + qt_icons.apply_button_icon(self.export_active_button, "📤", color="#dddddd") self.export_active_button.setToolTip("Export translated files → Game Folder\nCopy the files from this translation run into your game's data directory") self.export_active_button.clicked.connect(self._export_last_run_files) self.export_active_button.setVisible(False) @@ -1169,11 +1150,10 @@ class TranslationTab(QWidget): self.open_translations_button.setStyleSheet(icon_btn_style) self.sync_translated_button.setStyleSheet(icon_btn_style) self.export_active_button.setStyleSheet(icon_btn_style) - # Ensure emoji/icons are readable - self.reset_view_button.setFont(QFont('Segoe UI', 12)) - self.open_translations_button.setFont(QFont('Segoe UI', 12)) - self.sync_translated_button.setFont(QFont('Segoe UI', 12)) - self.export_active_button.setFont(QFont('Segoe UI', 12)) + # Size the icons to fill the compact buttons. + for _b in (self.reset_view_button, self.open_translations_button, + self.sync_translated_button, self.export_active_button): + _b.setIconSize(QSize(20, 20)) # Create the stop button here so it sits in the same row as the # back/open buttons. Use a compact icon style to match them but @@ -1202,12 +1182,12 @@ class TranslationTab(QWidget): # Use a clear stop-sign emoji so the glyph is rendered as a stop icon # and not as a colored square on some platforms. - self.stop_button = QPushButton(platform_glyph("🛑")) + self.stop_button = QPushButton() + qt_icons.apply_button_icon(self.stop_button, "🛑", color="#ffffff") self.stop_button.setToolTip("Stop Translation") self.stop_button.clicked.connect(self.stop_translation) self.stop_button.setStyleSheet(stop_button_style) - # Slightly larger font for the emoji to make it visually clear - self.stop_button.setFont(QFont('Segoe UI', 14)) + self.stop_button.setIconSize(QSize(20, 20)) self.stop_button.setVisible(False) # Place both buttons on the left and totals on the right diff --git a/gui/wolf_tab.py b/gui/wolf_tab.py index e3c2554..1dafa84 100644 --- a/gui/wolf_tab.py +++ b/gui/wolf_tab.py @@ -9,7 +9,6 @@ from PyQt5.QtWidgets import ( ) from PyQt5.QtCore import pyqtSignal import re -from gui.platform_glyph import platform_glyph try: from util.defaults import DEFAULTS as CANONICAL_DEFAULTS except Exception: @@ -17,9 +16,12 @@ except Exception: def create_section_label(text): """Create a styled section header label.""" - label = QLabel(platform_glyph(text)) - label.setStyleSheet("font-size: 12px; font-weight: bold; color: #007acc; padding: 2px 0px;") - return label + from gui.qt_icons import make_section_header + + return make_section_header( + text, + "font-size: 12px; font-weight: bold; color: #007acc; padding: 2px 0px;", + ) class WolfTab(QWidget): config_changed = pyqtSignal() @@ -184,7 +186,10 @@ class WolfTab(QWidget): button_layout = QHBoxLayout() button_layout.setSpacing(10) - self.reset_btn = QPushButton(platform_glyph("🔄 Reset to Defaults")) + from gui.qt_icons import apply_button_icon + + self.reset_btn = QPushButton() + apply_button_icon(self.reset_btn, "🔄 Reset to Defaults", color="#cccccc") self.reset_btn.clicked.connect(self.reset_to_defaults_with_message) self.reset_btn.setMaximumWidth(180) self.reset_btn.setMinimumHeight(32) diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index bd8d5bf..34639c9 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -1134,10 +1134,12 @@ class WolfWorkflowTab(QWidget): items = list_wolf_json_files(work_dir, manifest) self._file_items = items self.file_list.clear() + from gui.qt_icons import file_category_icon + for item in items: cat = item["category"] - icon = "📄" if cat == "core" else ("🗺" if cat == "map" else "❓") - lw = QListWidgetItem(f"{icon} {item['name']} ({item['size_kb']:.1f} KB)") + lw = QListWidgetItem(f"{item['name']} ({item['size_kb']:.1f} KB)") + lw.setIcon(file_category_icon(cat)) lw.setData(Qt.UserRole, item) lw.setFlags(lw.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) lw.setCheckState(Qt.Checked if item["default"] else Qt.Unchecked) diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 6e1ccab..7577f5d 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -28,7 +28,7 @@ from util.vocab import read_game_vocab, write_game_vocab import jsbeautifier -from PyQt5.QtCore import Qt, QEvent, QSettings, QThread, QTimer, pyqtSignal +from PyQt5.QtCore import Qt, QEvent, QSettings, QSize, QThread, QTimer, pyqtSignal from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QApplication, @@ -398,7 +398,7 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton: Dark utility colours (max channel < 115) use the flat sidebar style. Action colours use flat dark bg + coloured outline + coloured text. """ - btn = QPushButton(text) + btn = QPushButton() try: c = color.lstrip("#") if len(c) == 3: @@ -409,6 +409,7 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton: r = g = b = 0 is_flat = False _PAD = "padding:6px 14px;" + _icon_color = "#cccccc" if is_flat: btn.setStyleSheet( f"QPushButton{{background-color:#2d2d30;color:#cccccc;" @@ -425,6 +426,7 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton: gt = min(255, g + 80) bt = min(255, b + 80) text_color = f"#{rt:02x}{gt:02x}{bt:02x}" + _icon_color = text_color base = 0x2d rh = min(255, int(base + (r - base) * 0.18)) gh = min(255, int(base + (g - base) * 0.18)) @@ -443,6 +445,11 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton: f"QPushButton:pressed{{background-color:#1a1a1a;}}" f"QPushButton:disabled{{background-color:#2d2d30;color:#555555;border-color:#444444;}}" ) + from gui import qt_icons + + qt_icons.apply_button_icon(btn, text, color=_icon_color) + if not btn.icon().isNull(): + btn.setIconSize(QSize(16, 16)) return btn @@ -470,9 +477,11 @@ def _make_text_btn(label: str, tooltip: str = "", *, min_width: int = 52) -> QPu def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton: """Compact icon-only button (e.g. folder browse).""" - from gui.platform_glyph import platform_glyph + from gui import qt_icons - btn = QPushButton(platform_glyph(icon_text)) + btn = QPushButton() + qt_icons.apply_button_icon(btn, icon_text, color="#dddddd") + btn.setIconSize(QSize(18, 18)) btn.setToolTip(tooltip) btn.setFont(QFont("Segoe UI", 12)) btn.setFixedSize(40, 36) @@ -1848,7 +1857,10 @@ class WorkflowTab(QWidget): import_row = QHBoxLayout() import_row.setSpacing(8) import_row.setAlignment(Qt.AlignVCenter) - import_btn = QPushButton("↓ Import Selected → files/") + from gui import qt_icons + + import_btn = QPushButton() + qt_icons.apply_button_icon(import_btn, "↓ Import Selected → files/", color="#4da8f0") import_btn.setStyleSheet( "QPushButton{background-color:#2d2d30;color:#4da8f0;border:1px solid #007acc;" "padding:0px;border-radius:4px;font-size:12px;font-weight:bold;" @@ -1862,7 +1874,8 @@ class WorkflowTab(QWidget): import_btn.clicked.connect(lambda _checked=False: self._import_files()) self._register_import_button(import_btn) import_row.addWidget(import_btn) - clear_translated_btn = QPushButton("✕ Clear translated/") + clear_translated_btn = QPushButton() + qt_icons.apply_button_icon(clear_translated_btn, "✕ Clear translated/", color="#cc4444") clear_translated_btn.setStyleSheet( "QPushButton{background-color:#2d2d30;color:#cc4444;border:1px solid #8b0000;" "padding:0px;border-radius:4px;font-size:12px;font-weight:bold;" @@ -3381,10 +3394,12 @@ class WorkflowTab(QWidget): self._file_items = items self.file_list.clear() + from gui.qt_icons import file_category_icon + for item in items: cat = item["category"] - icon = "📄" if cat == "core" else ("🗺" if cat == "map" else "❓") - lw = QListWidgetItem(f"{icon} {item['name']} ({item['size_kb']:.1f} KB)") + lw = QListWidgetItem(f"{item['name']} ({item['size_kb']:.1f} KB)") + lw.setIcon(file_category_icon(cat)) lw.setData(Qt.UserRole, item) lw.setFlags(lw.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) lw.setCheckState(Qt.Checked if item["default"] else Qt.Unchecked) diff --git a/requirements.txt b/requirements.txt index ad0e17b..0903e6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,6 @@ tiktoken>=0.8.0 tqdm>=4.65.0 jsbeautifier>=1.14.0 pillow>=12.1.0 +pefile>=2023.2.7 PyQt5>=5.15.0 +qtawesome>=1.3.0 diff --git a/tests/test_qt_icons.py b/tests/test_qt_icons.py new file mode 100644 index 0000000..0450c7a --- /dev/null +++ b/tests/test_qt_icons.py @@ -0,0 +1,62 @@ +"""Tests for the shared qtawesome icon helpers.""" + +from __future__ import annotations + +import os +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + from PyQt5.QtWidgets import QApplication, QPushButton + + _HAS_QT = True +except Exception: # pragma: no cover - PyQt5 not installed + _HAS_QT = False + + +@unittest.skipUnless(_HAS_QT, "PyQt5 not available") +class TestQtIcons(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls._app = QApplication.instance() or QApplication([]) + + def test_split_leading_symbol(self) -> None: + from gui.qt_icons import split_leading_symbol + + self.assertEqual(split_leading_symbol("🔑 API Configuration"), + ("mdi6.key-variant", "API Configuration")) + self.assertEqual(split_leading_symbol("►► Run Both"), + ("mdi6.fast-forward", "Run Both")) + self.assertEqual(split_leading_symbol("Next →"), (None, "Next →")) + # Unmapped leading symbol: emoji is stripped, no icon returned. + name, rest = split_leading_symbol("Plain label") + self.assertIsNone(name) + self.assertEqual(rest, "Plain label") + + def test_file_category_icon_non_null(self) -> None: + from gui.qt_icons import HAS_QTA, file_category_icon + + for category in ("core", "map", "other", "unknown-category"): + icon = file_category_icon(category) + if HAS_QTA: + self.assertFalse(icon.isNull(), category) + + def test_apply_button_icon_strips_emoji(self) -> None: + from gui.qt_icons import HAS_QTA, apply_button_icon + + btn = QPushButton() + apply_button_icon(btn, "💾 Save vocab.txt", color="#cccccc") + if HAS_QTA: + self.assertEqual(btn.text(), "Save vocab.txt") + self.assertFalse(btn.icon().isNull()) + + def test_make_section_header_returns_widget(self) -> None: + from gui.qt_icons import make_section_header + + w = make_section_header("🌐 Translation Settings", "color:#007acc;") + self.assertIsNotNone(w) + + +if __name__ == "__main__": + unittest.main()