Fixing some gui icons

This commit is contained in:
DazedAnon 2026-07-07 14:18:43 -05:00
parent f1a7bb93e6
commit a2f3eb084b
8 changed files with 309 additions and 15 deletions

View file

@ -189,13 +189,13 @@ class ConfigTab(QWidget):
self.nav_buttons.append(btn_general) self.nav_buttons.append(btn_general)
# RPG Maker MV/MZ button # 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)) btn_mvmz.clicked.connect(lambda: self.switch_page(1))
nav_layout.addWidget(btn_mvmz) nav_layout.addWidget(btn_mvmz)
self.nav_buttons.append(btn_mvmz) self.nav_buttons.append(btn_mvmz)
# Wolf RPG button # 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)) btn_wolf.clicked.connect(lambda: self.switch_page(2))
nav_layout.addWidget(btn_wolf) nav_layout.addWidget(btn_wolf)
self.nav_buttons.append(btn_wolf) self.nav_buttons.append(btn_wolf)
@ -207,7 +207,7 @@ class ConfigTab(QWidget):
self.nav_buttons.append(btn_csv) self.nav_buttons.append(btn_csv)
# SRPG Studio button # 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)) btn_srpg.clicked.connect(lambda: self.switch_page(4))
nav_layout.addWidget(btn_srpg) nav_layout.addWidget(btn_srpg)
self.nav_buttons.append(btn_srpg) self.nav_buttons.append(btn_srpg)
@ -246,7 +246,7 @@ class ConfigTab(QWidget):
# Select first page by default # Select first page by default
self.switch_page(0) 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.""" """Create a navigation button for the top bar."""
from gui.platform_glyph import configure_nav_toolbutton from gui.platform_glyph import configure_nav_toolbutton
@ -254,7 +254,9 @@ class ConfigTab(QWidget):
btn.setToolTip(tooltip) btn.setToolTip(tooltip)
btn.setFixedSize(50, 50) btn.setFixedSize(50, 50)
btn.setCheckable(True) 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 return btn
def switch_page(self, index): def switch_page(self, index):

View file

@ -153,8 +153,24 @@ def configure_nav_toolbutton(
*, *,
horizontal: bool = False, horizontal: bool = False,
update_available: bool = False, update_available: bool = False,
engine_key: str | None = 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"): if sys.platform.startswith("linux"):
# Scale to the button cell (leave room for the 3px active border). # Scale to the button cell (leave room for the 3px active border).
icon_dim = max(28, min(btn.width(), btn.height()) - 8) icon_dim = max(28, min(btn.width(), btn.height()) - 8)

View file

@ -83,6 +83,7 @@ from gui.workflow_tab import (
_make_btn, _make_btn,
_make_hr, _make_hr,
_make_icon_btn, _make_icon_btn,
_make_text_btn,
_make_section_label, _make_section_label,
) )
from util import wolf_names from util import wolf_names
@ -821,17 +822,17 @@ class WolfWorkflowTab(QWidget):
row1 = QHBoxLayout() row1 = QHBoxLayout()
row1.setSpacing(6) 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) select_all_btn.clicked.connect(self._select_all_files)
row1.addWidget(select_all_btn) 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) deselect_all_btn.clicked.connect(self._deselect_all_files)
row1.addWidget(deselect_all_btn) 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.setToolTip("Select core JSON files; deselect map scripts")
sel_core.clicked.connect(self._select_core_only) sel_core.clicked.connect(self._select_core_only)
row1.addWidget(sel_core) 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.setEnabled(False)
import_btn.setToolTip("Replace files/ with exactly the checked files above") import_btn.setToolTip("Replace files/ with exactly the checked files above")
import_btn.clicked.connect(lambda _checked=False: self._import_files()) import_btn.clicked.connect(lambda _checked=False: self._import_files())

View file

@ -446,8 +446,30 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton:
return btn 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: 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 from gui.platform_glyph import platform_glyph
btn = QPushButton(platform_glyph(icon_text)) btn = QPushButton(platform_glyph(icon_text))
@ -859,20 +881,20 @@ class WorkflowTab(QWidget):
# Action row # Action row
row1 = QHBoxLayout() row1 = QHBoxLayout()
row1.setSpacing(6) 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) select_all_btn.clicked.connect(self._select_all_files)
row1.addWidget(select_all_btn) 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) deselect_all_btn.clicked.connect(self._deselect_all_files)
row1.addWidget(deselect_all_btn) 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.setToolTip("Select only core database files; deselect map files")
sel_core.clicked.connect(self._select_core_only) sel_core.clicked.connect(self._select_core_only)
row1.addWidget(sel_core) 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.setEnabled(False)
import_btn.setToolTip("Replace files/ with exactly the checked files above") import_btn.setToolTip("Replace files/ with exactly the checked files above")
import_btn.clicked.connect(lambda _checked=False: self._import_files()) import_btn.clicked.connect(lambda _checked=False: self._import_files())

View file

@ -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())

44
tests/test_pe_icon.py Normal file
View file

@ -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()

View file

@ -12,6 +12,7 @@ PROMPT_PATH = DATA_DIR / "prompt.txt"
LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt" LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt"
ENV_PATH = PROJECT_ROOT / ".env" ENV_PATH = PROJECT_ROOT / ".env"
ICON_PATH = PROJECT_ROOT / "assets" / "icon.png" ICON_PATH = PROJECT_ROOT / "assets" / "icon.png"
ENGINE_ICONS_DIR = PROJECT_ROOT / "assets" / "engine_icons"
_ROOT_DATA_FILES = ( _ROOT_DATA_FILES = (
"vocab.txt", "vocab.txt",

107
util/pe_icon.py Normal file
View file

@ -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("<I", raw[0:4])[0]
bi_width = struct.unpack("<i", raw[4:8])[0]
bi_height = struct.unpack("<i", raw[8:12])[0]
bi_bpp = struct.unpack("<H", raw[14:16])[0]
actual_h = abs(bi_height) // 2
if bi_width <= 0 or actual_h <= 0:
return None
pixel_offset = bi_size
if bi_bpp <= 8:
num_colors = struct.unpack("<I", raw[32:36])[0] or (2**bi_bpp)
pixel_offset += num_colors * 4
row_bytes = ((bi_width * bi_bpp + 31) // 32) * 4
xor_data = raw[pixel_offset : pixel_offset + row_bytes * actual_h]
if bi_bpp == 32:
img = Image.frombytes("RGBA", (bi_width, actual_h), xor_data[: bi_width * actual_h * 4])
elif bi_bpp == 24:
img = Image.frombytes("RGB", (bi_width, actual_h), xor_data[: bi_width * actual_h * 3]).convert("RGBA")
elif bi_bpp == 8:
num_colors = struct.unpack("<I", raw[32:36])[0] or 256
palette: list[int] = []
for c in range(num_colors):
b, g, r, _ = struct.unpack("BBBB", raw[40 + c * 4 : 44 + c * 4])
palette.extend([r, g, b])
img = Image.frombytes("P", (bi_width, actual_h), xor_data[: bi_width * actual_h])
img.putpalette(palette)
img = img.convert("RGBA")
else:
return None
return img.transpose(Image.FLIP_TOP_BOTTOM)
def extract_icon_blobs(exe_path: str | Path) -> 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