From a2f3eb084bfaa40461aa3eb92928bb4816b08a2b Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 7 Jul 2026 14:18:43 -0500 Subject: [PATCH] Fixing some gui icons --- gui/config_tab.py | 12 ++-- gui/platform_glyph.py | 18 +++++- gui/wolf_workflow_tab.py | 9 +-- gui/workflow_tab.py | 32 ++++++++-- scripts/extract_engine_icons.py | 101 ++++++++++++++++++++++++++++++ tests/test_pe_icon.py | 44 +++++++++++++ util/paths.py | 1 + util/pe_icon.py | 107 ++++++++++++++++++++++++++++++++ 8 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 scripts/extract_engine_icons.py create mode 100644 tests/test_pe_icon.py create mode 100644 util/pe_icon.py diff --git a/gui/config_tab.py b/gui/config_tab.py index 6c791e5..cc0937c 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -189,13 +189,13 @@ class ConfigTab(QWidget): self.nav_buttons.append(btn_general) # RPG Maker MV/MZ button - btn_mvmz = self.create_nav_button("🎮", "RPG Maker MV/MZ") + btn_mvmz = self.create_nav_button("🎮", "RPG Maker MV/MZ", engine_key="mvmz") btn_mvmz.clicked.connect(lambda: self.switch_page(1)) nav_layout.addWidget(btn_mvmz) self.nav_buttons.append(btn_mvmz) # Wolf RPG button - btn_wolf = self.create_nav_button("🐺", "Wolf RPG") + btn_wolf = self.create_nav_button("🐺", "Wolf RPG", engine_key="wolf") btn_wolf.clicked.connect(lambda: self.switch_page(2)) nav_layout.addWidget(btn_wolf) self.nav_buttons.append(btn_wolf) @@ -207,7 +207,7 @@ class ConfigTab(QWidget): self.nav_buttons.append(btn_csv) # SRPG Studio button - btn_srpg = self.create_nav_button("⚔️", "SRPG Studio") + btn_srpg = self.create_nav_button("⚔️", "SRPG Studio", engine_key="srpg") btn_srpg.clicked.connect(lambda: self.switch_page(4)) nav_layout.addWidget(btn_srpg) self.nav_buttons.append(btn_srpg) @@ -246,7 +246,7 @@ class ConfigTab(QWidget): # Select first page by default self.switch_page(0) - def create_nav_button(self, icon_text, tooltip): + def create_nav_button(self, icon_text, tooltip, engine_key=None): """Create a navigation button for the top bar.""" from gui.platform_glyph import configure_nav_toolbutton @@ -254,7 +254,9 @@ class ConfigTab(QWidget): btn.setToolTip(tooltip) btn.setFixedSize(50, 50) btn.setCheckable(True) - configure_nav_toolbutton(btn, icon_text, horizontal=True) + configure_nav_toolbutton( + btn, icon_text, horizontal=True, engine_key=engine_key, + ) return btn def switch_page(self, index): diff --git a/gui/platform_glyph.py b/gui/platform_glyph.py index a4b3578..b11d7ca 100644 --- a/gui/platform_glyph.py +++ b/gui/platform_glyph.py @@ -153,8 +153,24 @@ def configure_nav_toolbutton( *, horizontal: bool = False, update_available: bool = False, + engine_key: str | None = None, ) -> None: - """Apply nav icon — on Linux render BMP symbols into fixed pixmaps for alignment.""" + """Apply nav icon - engine PNG when available, else emoji/glyph fallback.""" + from util.paths import ENGINE_ICONS_DIR + + 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)) + 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/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index d3475ca..bd8d5bf 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -83,6 +83,7 @@ from gui.workflow_tab import ( _make_btn, _make_hr, _make_icon_btn, + _make_text_btn, _make_section_label, ) from util import wolf_names @@ -821,17 +822,17 @@ class WolfWorkflowTab(QWidget): row1 = QHBoxLayout() row1.setSpacing(6) - select_all_btn = _make_icon_btn("✓", "Select all importable files") + select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44) select_all_btn.clicked.connect(self._select_all_files) row1.addWidget(select_all_btn) - deselect_all_btn = _make_icon_btn("✗", "Deselect all files") + deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52) deselect_all_btn.clicked.connect(self._deselect_all_files) row1.addWidget(deselect_all_btn) - sel_core = _make_icon_btn("◆", "Core only: databases, common events, names") + sel_core = _make_text_btn("Core", "Core only: databases, common events, names", min_width=52) sel_core.setToolTip("Select core JSON files; deselect map scripts") sel_core.clicked.connect(self._select_core_only) row1.addWidget(sel_core) - import_btn = _make_icon_btn("📥", "Import selected files into files/") + import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64) import_btn.setEnabled(False) import_btn.setToolTip("Replace files/ with exactly the checked files above") import_btn.clicked.connect(lambda _checked=False: self._import_files()) diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 34f9e5f..6e1ccab 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -446,8 +446,30 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton: return btn +_ACTION_BTN_STYLE = ( + "QPushButton{background-color:#2d2d30;color:white;" + "font-weight:bold;font-size:12px;border:1px solid #555555;" + "border-radius:4px;padding:4px 10px;" + "min-height:36px;max-height:36px;}" + "QPushButton:hover{background-color:#3e3e42;border-color:#007acc;}" + "QPushButton:pressed{background-color:#007acc;}" + "QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}" +) + + +def _make_text_btn(label: str, tooltip: str = "", *, min_width: int = 52) -> QPushButton: + """Compact labeled button for file-list actions (All / None / Core / Import).""" + btn = QPushButton(label) + btn.setToolTip(tooltip) + btn.setFont(QFont("Segoe UI", 10)) + btn.setMinimumWidth(min_width) + btn.setFixedHeight(36) + btn.setStyleSheet(_ACTION_BTN_STYLE) + return btn + + def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton: - """Compact icon-only button matching the Translation tab action buttons.""" + """Compact icon-only button (e.g. folder browse).""" from gui.platform_glyph import platform_glyph btn = QPushButton(platform_glyph(icon_text)) @@ -859,20 +881,20 @@ class WorkflowTab(QWidget): # Action row row1 = QHBoxLayout() row1.setSpacing(6) - select_all_btn = _make_icon_btn("✓", "Select all importable files") + select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44) select_all_btn.clicked.connect(self._select_all_files) row1.addWidget(select_all_btn) - deselect_all_btn = _make_icon_btn("✗", "Deselect all files") + deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52) deselect_all_btn.clicked.connect(self._deselect_all_files) row1.addWidget(deselect_all_btn) - sel_core = _make_icon_btn("◆", "Core Only: select database files and deselect maps") + sel_core = _make_text_btn("Core", "Core only: select database files and deselect maps", min_width=52) sel_core.setToolTip("Select only core database files; deselect map files") sel_core.clicked.connect(self._select_core_only) row1.addWidget(sel_core) - import_btn = _make_icon_btn("📥", "Import selected files into files/") + import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64) import_btn.setEnabled(False) import_btn.setToolTip("Replace files/ with exactly the checked files above") import_btn.clicked.connect(lambda _checked=False: self._import_files()) diff --git a/scripts/extract_engine_icons.py b/scripts/extract_engine_icons.py new file mode 100644 index 0000000..ca3a420 --- /dev/null +++ b/scripts/extract_engine_icons.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Extract engine icons from Game.exe files into assets/engine_icons/. + +Default search paths cover common locations on this machine; override with flags: + + python scripts/extract_engine_icons.py + python scripts/extract_engine_icons.py --ace /path/to/ace/Game.exe --srpg /path/to/srpg/Game.exe +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from util.pe_icon import extract_best_icon_png # noqa: E402 +from util.paths import ENGINE_ICONS_DIR # noqa: E402 + +# Known-good sources for bundled engine artwork (first existing path wins). +_DEFAULT_SOURCES: dict[str, list[Path]] = { + "mvmz": [ + Path.home() / "DazedTranslations/Games/Shrift II/Game.exe", + ], + "wolf": [ + Path.home() / "DazedTranslations/Tools/WOLF/Editor/Editor 2.24Z.exe", + Path.home() / "DazedTranslations/Tools/WOLF/Editor/Game.exe", + Path.home() + / "DazedTranslations/Games/[Ryuugames] RY-RJ01633249/RJ01633249/銀眼のライトナ/Game.exe", + ], + "ace": [ + # VX Ace Game.exe (RGSS3 player) - no default install on this machine. + ], + "srpg": [ + # SRPG Studio Game.exe - no default install on this machine. + ], +} + + +def _resolve_source(engine: str, overrides: dict[str, Path | None]) -> Path | None: + if overrides.get(engine): + return overrides[engine] + for candidate in _DEFAULT_SOURCES.get(engine, []): + if candidate.is_file(): + return candidate + return None + + +def extract_engine_icons( + overrides: dict[str, Path | None] | None = None, + *, + dest_dir: Path | None = None, +) -> dict[str, str]: + """Extract icons for each engine; return ``{engine: status_message}``.""" + overrides = overrides or {} + dest_dir = dest_dir or ENGINE_ICONS_DIR + dest_dir.mkdir(parents=True, exist_ok=True) + results: dict[str, str] = {} + + for engine in ("mvmz", "wolf", "ace", "srpg"): + src = _resolve_source(engine, overrides) + out = dest_dir / f"{engine}.png" + if src is None: + results[engine] = "skipped (no source .exe; pass --{0} PATH)".format(engine) + continue + size = extract_best_icon_png(src, out, min_size=64 if engine == "wolf" else 0) + if size is None: + results[engine] = f"failed (no icon in {src})" + else: + results[engine] = f"ok {size[0]}x{size[1]} from {src}" + return results + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mvmz", type=Path, help="RPG Maker MV/MZ Game.exe") + parser.add_argument("--wolf", type=Path, help="Wolf RPG Editor or game Game.exe") + parser.add_argument("--ace", type=Path, help="RPG Maker VX Ace Game.exe") + parser.add_argument("--srpg", type=Path, help="SRPG Studio Game.exe") + parser.add_argument("--dest", type=Path, default=ENGINE_ICONS_DIR, help="Output directory") + args = parser.parse_args(argv) + + overrides = { + "mvmz": args.mvmz, + "wolf": args.wolf, + "ace": args.ace, + "srpg": args.srpg, + } + results = extract_engine_icons(overrides, dest_dir=args.dest) + failed = 0 + for engine, msg in results.items(): + print(f"{engine}: {msg}") + if msg.startswith("failed") or (msg.startswith("skipped") and engine in ("mvmz", "wolf")): + failed += 1 + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pe_icon.py b/tests/test_pe_icon.py new file mode 100644 index 0000000..6662854 --- /dev/null +++ b/tests/test_pe_icon.py @@ -0,0 +1,44 @@ +"""Tests for PE executable icon extraction.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from util.pe_icon import extract_best_icon_png, extract_icon_blobs + +_MVMZ_EXE = Path.home() / "DazedTranslations/Games/Shrift II/Game.exe" +_WOLF_EXE = Path.home() / "DazedTranslations/Tools/WOLF/Editor/Editor 2.24Z.exe" + + +@unittest.skipUnless(_MVMZ_EXE.is_file(), "Shrift II Game.exe not available") +class TestPeIconExtraction(unittest.TestCase): + def test_extract_mvmz_icon(self) -> None: + blobs = extract_icon_blobs(_MVMZ_EXE) + self.assertGreater(len(blobs), 0) + + def test_extract_mvmz_png(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "mvmz.png" + size = extract_best_icon_png(_MVMZ_EXE, out) + self.assertIsNotNone(size) + assert size is not None + self.assertGreaterEqual(size[0], 32) + self.assertTrue(out.is_file()) + + +@unittest.skipUnless(_WOLF_EXE.is_file(), "Wolf Editor not available") +class TestWolfIconExtraction(unittest.TestCase): + def test_extract_wolf_png(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "wolf.png" + size = extract_best_icon_png(_WOLF_EXE, out) + self.assertIsNotNone(size) + assert size is not None + self.assertGreaterEqual(size[0], 16) + self.assertTrue(out.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/util/paths.py b/util/paths.py index a8f1648..20d38c8 100644 --- a/util/paths.py +++ b/util/paths.py @@ -12,6 +12,7 @@ PROMPT_PATH = DATA_DIR / "prompt.txt" LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt" ENV_PATH = PROJECT_ROOT / ".env" ICON_PATH = PROJECT_ROOT / "assets" / "icon.png" +ENGINE_ICONS_DIR = PROJECT_ROOT / "assets" / "engine_icons" _ROOT_DATA_FILES = ( "vocab.txt", diff --git a/util/pe_icon.py b/util/pe_icon.py new file mode 100644 index 0000000..7cdb3c8 --- /dev/null +++ b/util/pe_icon.py @@ -0,0 +1,107 @@ +"""Extract the largest embedded icon from a Windows PE executable as PNG.""" + +from __future__ import annotations + +import io +import struct +from pathlib import Path + +from PIL import Image + +try: + import pefile +except ImportError: # pragma: no cover - pefile is a dev/runtime dependency + pefile = None # type: ignore[assignment] + + +def _raw_icon_to_image(raw: bytes) -> Image.Image | None: + """Decode a single RT_ICON blob (PNG-embedded or BMP) to RGBA.""" + png_start = raw.find(b"\x89PNG") + if png_start >= 0: + return Image.open(io.BytesIO(raw[png_start:])).convert("RGBA") + + if len(raw) < 40: + return None + + bi_size = struct.unpack(" dict[int, bytes]: + """Return ``{resource_id: raw_icon_bytes}`` from *exe_path*.""" + if pefile is None: + raise RuntimeError("pefile is required to extract icons from executables") + + pe = pefile.PE(str(exe_path), fast_load=True) + pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]]) + + blobs: dict[int, bytes] = {} + if hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"): + for entry in pe.DIRECTORY_ENTRY_RESOURCE.entries: + if entry.id != pefile.RESOURCE_TYPE["RT_ICON"]: + continue + for icon_entry in entry.directory.entries: + icon_id = icon_entry.struct.Id + for data_entry in icon_entry.directory.entries: + offset = data_entry.data.struct.OffsetToData + size = data_entry.data.struct.Size + blobs[icon_id] = pe.get_data(offset, size) + pe.close() + return blobs + + +def extract_best_icon_image(exe_path: str | Path) -> Image.Image | None: + """Return the largest decodable icon from *exe_path*, or ``None``.""" + blobs = extract_icon_blobs(exe_path) + if not blobs: + return None + + best_raw = max(blobs.values(), key=len) + return _raw_icon_to_image(best_raw) + + +def extract_best_icon_png(exe_path: str | Path, out_path: str | Path, *, min_size: int = 0) -> tuple[int, int] | None: + """Write the largest icon from *exe_path* to *out_path*; return size or ``None``.""" + img = extract_best_icon_image(exe_path) + if img is None: + return None + if min_size and max(img.size) < min_size: + scale = min_size / max(img.size) + new_w = max(1, int(round(img.width * scale))) + new_h = max(1, int(round(img.height * scale))) + img = img.resize((new_w, new_h), Image.Resampling.NEAREST) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + img.save(out_path) + return img.size