Fixing some gui icons
This commit is contained in:
parent
f1a7bb93e6
commit
a2f3eb084b
8 changed files with 309 additions and 15 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
101
scripts/extract_engine_icons.py
Normal file
101
scripts/extract_engine_icons.py
Normal 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
44
tests/test_pe_icon.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
107
util/pe_icon.py
Normal file
107
util/pe_icon.py
Normal 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
|
||||
Loading…
Reference in a new issue