8bitlenser/lenser/gui.py
2026-07-03 19:35:35 -07:00

1122 lines
48 KiB
Python

"""PyQt5 GUI: open an image, tune the conversion with a live C64 preview, export a disk image."""
from __future__ import annotations
import os
import sys
import traceback
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
from . import gallery, imageprep, platforms, slideshow
from .convert import render_preview
from .diskimage import have_c1541
from .palette import COLOR_NAMES
from .viewer.assemble import have_xa
DITHER_CHOICES = ["bayer", "bluenoise", "yliluoma", "floyd", "atkinson",
"stucki", "jarvis", "sierra", "sierra_lite", "burkes",
"riemersma", "ostromoukhov", "none"]
ASPECT_CHOICES = ["fit", "fill", "stretch"]
BASE_CHOICES = ["grayscale", *COLOR_NAMES] # mono base / Atari hue
# Full machine names shown in the platform selector (value stays the short code).
PLATFORM_NAMES = {
"c64": "Commodore 64", "atari": "Atari 800/XL/XE", "apple": "Apple II",
"ti99": "Texas Instruments TI-99/4A", "coco": "Tandy Color Computer 2",
"bbc": "BBC Micro", "coleco": "ColecoVision", "a2600": "Atari 2600",
"intv": "Mattel Intellivision", "vic20": "Commodore VIC-20",
"spectrum": "Sinclair ZX Spectrum", "a5200": "Atari 5200",
"a7800": "Atari 7800", "c128": "Commodore 128", "c16": "Commodore 16",
"plus4": "Commodore Plus/4", "cpc": "Amstrad CPC",
"coco3": "Tandy Color Computer 3", "nes": "Nintendo Entertainment System",
"iigs": "Apple IIGS", "pet2001": "Commodore PET 2001",
"pet4032": "Commodore PET 4032", "pet8032": "Commodore PET 8032",
"superpet": "Commodore SuperPET", "sms": "Sega Master System",
"amiga": "Commodore Amiga", "ansi": "ANSI / BBS art (CP437)",
}
def numpy_to_pixmap(rgb: np.ndarray) -> QtGui.QPixmap:
rgb = np.ascontiguousarray(rgb)
h, w, _ = rgb.shape
img = QtGui.QImage(rgb.data, w, h, 3 * w, QtGui.QImage.Format_RGB888)
return QtGui.QPixmap.fromImage(img.copy())
# Small, original brand-evocative chips for the platform selector: a coloured
# rounded square + a short monogram in a colour associated with the maker. These
# are drawn from scratch to *evoke* each brand, not reproductions of any logo.
# (bg, fg, label) keyed by platform code; grouped by manufacturer.
_BRAND_ICONS = {
# Commodore (indigo "C=")
**{c: ("#3f51b5", "#ffffff", "C=") for c in
("c64", "c128", "c16", "plus4", "vic20",
"pet2001", "pet4032", "pet8032", "superpet")},
"amiga": ("#111111", "#e53935", "A"), # Commodore Amiga (red on black)
# Atari (red)
**{c: ("#c62828", "#ffffff", "A") for c in ("atari", "a2600", "a5200", "a7800")},
# Apple (green era)
"apple": ("#7ab648", "#ffffff", "II"),
"iigs": ("#7ab648", "#ffffff", "GS"),
# everyone else
"ti99": ("#cc0000", "#ffffff", "TI"), # Texas Instruments
"coco": ("#c8102e", "#ffffff", "TRS"), # Tandy / Radio Shack
"coco3": ("#c8102e", "#ffffff", "TRS"),
"bbc": ("#111111", "#ffffff", "BBC"), # Acorn / BBC
"coleco": ("#e53935", "#ffffff", "CV"), # ColecoVision
"intv": ("#1565c0", "#ffffff", "M"), # Mattel
"spectrum": ("#111111", "#ffffff", "ZX"), # Sinclair
"nes": ("#e60012", "#ffffff", "N"), # Nintendo
"sms": ("#0060a8", "#ffffff", "S"), # Sega
"cpc": ("#1b3a6b", "#ffffff", "CPC"), # Amstrad
"ansi": ("#0a0a0a", "#33ff33", ""), # ANSI/BBS terminal (CP437 shade)
}
def _brand_icon(code: str, px: int = 18) -> QtGui.QIcon:
"""A tiny brand-evocative chip icon for one platform (empty icon if unknown)."""
spec = _BRAND_ICONS.get(code)
if spec is None:
return QtGui.QIcon()
bg, fg, label = spec
dpr = 2 # render at 2x for a crisp downscale
size = px * dpr
pm = QtGui.QPixmap(size, size)
pm.fill(QtCore.Qt.transparent)
p = QtGui.QPainter(pm)
p.setRenderHint(QtGui.QPainter.Antialiasing, True)
p.setRenderHint(QtGui.QPainter.TextAntialiasing, True)
rect = QtCore.QRectF(dpr, dpr, size - 2 * dpr, size - 2 * dpr)
p.setPen(QtCore.Qt.NoPen)
p.setBrush(QtGui.QColor(bg))
p.drawRoundedRect(rect, 4 * dpr, 4 * dpr)
# shrink the (bold) monogram until it fits the chip width
font = QtGui.QFont()
font.setBold(True)
fs = int(size * 0.64)
while fs > 4:
font.setPixelSize(fs)
if QtGui.QFontMetrics(font).horizontalAdvance(label) <= rect.width() * 0.82:
break
fs -= 1
p.setFont(font)
p.setPen(QtGui.QColor(fg))
p.drawText(rect, QtCore.Qt.AlignCenter, label)
p.end()
pm.setDevicePixelRatio(dpr)
return QtGui.QIcon(pm)
def _home(name: str) -> str:
"""A suggested save path: the user's home directory + ``name`` (so every Save
dialog opens in $HOME by default)."""
return os.path.join(os.path.expanduser("~"), name)
def _load_bold_font(size: int):
"""A bold TrueType font at ``size`` px (for the contact-sheet title), falling
back to PIL's built-in font if no system bold font is available."""
from PIL import ImageFont
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"DejaVuSans-Bold.ttf"):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
try:
return ImageFont.load_default(size) # Pillow >= 10.1
except Exception:
return ImageFont.load_default()
class ConvertWorker(QtCore.QThread):
"""Runs one conversion off the UI thread."""
done = QtCore.pyqtSignal(object) # Conversion
failed = QtCore.pyqtSignal(str)
def __init__(self, path, params):
super().__init__()
self.path = path
self.params = params
def run(self):
try:
p = self.params
prep = imageprep.PrepOptions(
aspect=p["aspect"], brightness=p["brightness"],
contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"],
tint=p["tint"], red=p["red"], green=p["green"], blue=p["blue"],
)
conv = platforms.convert(
p["platform"], self.path, p["mode"], p["palette"], p["dither"],
p["intensive"], prep, p["base_name"],
)
self.done.emit(conv)
except Exception:
self.failed.emit(traceback.format_exc())
class GalleryWorker(QtCore.QThread):
"""Renders every Mode x Palette x Dither variation across CPU cores."""
result = QtCore.pyqtSignal(int, str, str, str, float, object)
progress = QtCore.pyqtSignal(int, int)
finished_all = QtCore.pyqtSignal()
def __init__(self, path, prep_kwargs, platform, base_name):
super().__init__()
self.path = path
self.prep_kwargs = prep_kwargs
self.platform = platform
self.base_name = base_name
self._cancel = False
def cancel(self):
self._cancel = True
def run(self):
from concurrent.futures import ProcessPoolExecutor, as_completed
combos = gallery.combos(self.platform)
args = [(self.path, self.platform, m, p, d, self.prep_kwargs, self.base_name)
for (m, p, d) in combos]
total = len(combos)
done = 0
try:
with ProcessPoolExecutor() as ex:
futs = {ex.submit(gallery.render_variation, a): i
for i, a in enumerate(args)}
for fut in as_completed(futs):
if self._cancel:
ex.shutdown(wait=False, cancel_futures=True)
break
i = futs[fut]
try:
m, p, d, err, rgb = fut.result()
except Exception:
continue
done += 1
self.result.emit(i, m, p, d, err, rgb)
self.progress.emit(done, total)
finally:
self.finished_all.emit()
class VariationsDialog(QtWidgets.QDialog):
"""Contact sheet of every Mode x Palette x Dither combination to pick from."""
def __init__(self, path, prep_kwargs, platform="c64", base_name="grayscale",
parent=None, cached=None):
super().__init__(parent)
self.setWindowTitle("Explore variations -- pick the best looking one")
self.resize(940, 680)
self.path = path
self.choice = None
self.cells = {}
self.best = (float("inf"), None)
self.collected = {} # index -> (m, p, d, err, rgb)
self.worker = None
self._combos = gallery.combos(platform)
outer = QtWidgets.QVBoxLayout(self)
self.info = QtWidgets.QLabel("Rendering variations... click any thumbnail to choose it")
outer.addWidget(self.info)
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget()
self.grid = QtWidgets.QGridLayout(inner)
scroll.setWidget(inner)
outer.addWidget(scroll, 1)
# floating 2x magnifier shown while hovering a thumbnail (closer inspection)
self._zoom = QtWidgets.QLabel(self)
self._zoom.setWindowFlags(QtCore.Qt.ToolTip)
self._zoom.setAlignment(QtCore.Qt.AlignCenter)
self._zoom.setStyleSheet("border: 1px solid #888; background: #111;")
self._zoom.hide()
cols = 4
for i, (m, p, d) in enumerate(self._combos):
btn = QtWidgets.QToolButton()
btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
btn.setIconSize(QtCore.QSize(200, 125))
btn.setText(f"{m} - {p} - {d}\n(rendering...)")
btn.setEnabled(False)
btn.clicked.connect(lambda _checked, idx=i: self._choose(idx))
btn._zoom_pm = None # 2x pixmap, set once rendered
btn.installEventFilter(self) # hover -> show/hide magnifier
self.cells[i] = btn
self.grid.addWidget(btn, i // cols, i % cols)
btns = QtWidgets.QHBoxLayout()
self.sheet_btn = QtWidgets.QPushButton("Contact Sheet...")
self.sheet_btn.setToolTip("Save one PNG with every variation at full size, "
"for side-by-side comparison.")
self.sheet_btn.clicked.connect(self._save_contact_sheet)
self.sheet_btn.setEnabled(False)
btns.addWidget(self.sheet_btn)
btns.addStretch(1)
cancel = QtWidgets.QPushButton("Cancel")
cancel.clicked.connect(self.reject)
btns.addWidget(cancel)
outer.addLayout(btns)
if cached and len(cached) == len(self._combos):
for i, vals in cached.items():
self._populate(i, *vals)
self._on_done() # show "best" marker + final message
else:
self.worker = GalleryWorker(path, prep_kwargs, platform, base_name)
self.worker.result.connect(self._on_result)
self.worker.progress.connect(self._on_progress)
self.worker.finished_all.connect(self._on_done)
self.worker.start()
def _populate(self, i, m, p, d, err, rgb):
btn = self.cells[i]
pm = numpy_to_pixmap(rgb).scaled(200, 125, QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation)
btn.setIcon(QtGui.QIcon(pm))
# 2x version for the hover magnifier (twice the thumbnail's box)
btn._zoom_pm = numpy_to_pixmap(rgb).scaled(
400, 250, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
btn.setText(f"{m} - {p} - {d}\ndE {err:.1f}")
btn.setEnabled(True)
btn.setProperty("combo", (m, p, d))
self.collected[i] = (m, p, d, err, rgb)
if err < self.best[0]:
self.best = (err, i)
def _on_result(self, i, m, p, d, err, rgb):
self._populate(i, m, p, d, err, rgb)
def _on_progress(self, done, total):
self.info.setText(f"Rendering variations... {done}/{total} "
f"-- click any thumbnail to choose it")
def _on_done(self):
msg = "Click the variation you like best."
if self.best[1] is not None:
m, p, d = self.cells[self.best[1]].property("combo")
self.cells[self.best[1]].setText(
self.cells[self.best[1]].text() + " *best*")
msg += f" (lowest error: {m} / {p} / {d})"
self.info.setText(msg)
self.sheet_btn.setEnabled(bool(self.collected))
def _save_contact_sheet(self):
"""Compose every collected variation (at full preview size) into one PNG
grid -- same order as the on-screen sheet -- for side-by-side comparison."""
if not self.collected:
return
stem = os.path.splitext(os.path.basename(self.path or "image"))[0]
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self, "Save contact sheet", _home(f"8bitlenser_{stem}.png"),
"PNG image (*.png)")
if not path:
return
if not path.lower().endswith(".png"):
path += ".png"
try:
from PIL import Image, ImageDraw, ImageFont
titlefont = _load_bold_font(26)
items = [self.collected[i] for i in sorted(self.collected)]
best_i = self.best[1]
best_pos = sorted(self.collected).index(best_i) if best_i is not None else -1
cw = max(rgb.shape[1] for *_, rgb in items)
ch = max(rgb.shape[0] for *_, rgb in items)
pad, lblh, cols, titleh = 10, 18, 4, 44
cellw, cellh = cw + 2 * pad, ch + lblh + 2 * pad
rows = (len(items) + cols - 1) // cols
sheet = Image.new("RGB", (cols * cellw, titleh + rows * cellh), (24, 24, 24))
draw = ImageDraw.Draw(sheet)
draw.text((pad, (titleh - 26) // 2), os.path.abspath(self.path or ""),
fill=(255, 255, 255), font=titlefont)
for pos, (m, p, d, err, rgb) in enumerate(items):
r, c = divmod(pos, cols)
x0, y0 = c * cellw + pad, titleh + r * cellh + pad
star = " *best*" if pos == best_pos else ""
draw.text((x0, y0), f"{m}/{p}/{d} dE {err:.1f}{star}",
fill=(255, 230, 120) if star else (220, 220, 220))
tile = Image.fromarray(np.ascontiguousarray(rgb), "RGB")
ox = x0 + (cw - rgb.shape[1]) // 2
oy = y0 + lblh + (ch - rgb.shape[0]) // 2
sheet.paste(tile, (ox, oy))
sheet.save(path)
self.info.setText(f"Saved contact sheet ({len(items)} variations) to {path}")
except Exception:
QtWidgets.QMessageBox.critical(self, "Contact sheet failed",
traceback.format_exc())
def eventFilter(self, obj, event):
"""Show a 2x magnifier when the pointer enters a rendered thumbnail."""
et = event.type()
if et == QtCore.QEvent.Enter and getattr(obj, "_zoom_pm", None) is not None:
self._show_zoom(obj)
elif et == QtCore.QEvent.Leave and obj is not self._zoom:
self._zoom.hide()
return super().eventFilter(obj, event)
def _show_zoom(self, btn):
"""Pop the magnified pixmap just above ``btn`` (below if there's no room),
clamped to the screen so it never lands under the pointer."""
pm = btn._zoom_pm
self._zoom.setPixmap(pm)
self._zoom.resize(pm.size())
screen = QtWidgets.QApplication.desktop().availableGeometry(btn)
cx = btn.mapToGlobal(btn.rect().center()).x()
x = max(screen.left(), min(cx - pm.width() // 2, screen.right() - pm.width()))
y = btn.mapToGlobal(btn.rect().topLeft()).y() - pm.height() - 6
if y < screen.top(): # no room above -> drop below
y = btn.mapToGlobal(btn.rect().bottomLeft()).y() + 6
self._zoom.move(x, y)
self._zoom.show()
self._zoom.raise_()
def _choose(self, i):
self.choice = self.cells[i].property("combo")
self.accept()
def reject(self):
if self.worker:
self.worker.cancel()
self.worker.wait(2000)
super().reject()
class SlideshowDialog(QtWidgets.QDialog):
"""Arrange a queue of slides, size them against a disk format (refusing to
overfill), set the advance behaviour, and export/run one drive image.
Operates on the parent's live ``slides`` list (reorder/remove happen in
place), where each entry is {"item": SlideItem, "conv": Conversion,
"thumb": rgb}.
"""
def __init__(self, slides, platform, parent=None):
super().__init__(parent)
self.setWindowTitle("Slideshow")
self.resize(580, 540)
self.slides = slides
self.platform = platform
self._build()
self._refresh()
def _build(self):
lay = QtWidgets.QVBoxLayout(self)
lay.addWidget(QtWidgets.QLabel(
f"Platform: {PLATFORM_NAMES.get(self.platform, self.platform)} "
"(one platform per slideshow)"))
self.listw = QtWidgets.QListWidget()
self.listw.setIconSize(QtCore.QSize(96, 60))
lay.addWidget(self.listw, 1)
rowbtns = QtWidgets.QHBoxLayout()
for text, fn in (("Move up", lambda: self._move(-1)),
("Move down", lambda: self._move(1)),
("Remove", self._remove)):
b = QtWidgets.QPushButton(text)
b.clicked.connect(fn)
rowbtns.addWidget(b)
lay.addLayout(rowbtns)
form = QtWidgets.QFormLayout()
self.advance_cb = QtWidgets.QComboBox()
self.advance_cb.addItems(list(slideshow.ADVANCE_MODES))
self.advance_cb.setCurrentText("both")
self.advance_cb.setToolTip("key = wait for a keypress; seconds = auto after "
"N s; both = either (keys still work).")
self.seconds_sb = QtWidgets.QSpinBox()
self.seconds_sb.setRange(1, 3600)
self.seconds_sb.setValue(10)
self.seconds_sb.setSuffix(" s")
adv_row = QtWidgets.QHBoxLayout()
adv_row.addWidget(self.advance_cb)
adv_row.addWidget(self.seconds_sb)
self.format_cb = QtWidgets.QComboBox()
self.format_cb.addItems(slideshow.disk_formats(self.platform))
self.loop_cb = QtWidgets.QCheckBox("Loop back to the first image")
self.loop_cb.setChecked(True)
form.addRow("Advance", adv_row)
form.addRow("Disk format", self.format_cb)
form.addRow("", self.loop_cb)
lay.addLayout(form)
self.advance_cb.currentTextChanged.connect(self._adv_changed)
self.format_cb.currentTextChanged.connect(self._refresh)
self.loop_cb.stateChanged.connect(self._refresh)
# set the spinbox enable directly (meter/_refresh aren't built yet)
self.seconds_sb.setEnabled(self.advance_cb.currentText() in ("seconds", "both"))
self.meter = QtWidgets.QLabel("")
lay.addWidget(self.meter)
btns = QtWidgets.QHBoxLayout()
self.run_btn = QtWidgets.QPushButton("Run in emulator")
self.run_btn.clicked.connect(self._run)
self.export_btn = QtWidgets.QPushButton("Export slideshow disk...")
self.export_btn.clicked.connect(self._export)
close = QtWidgets.QPushButton("Close")
close.clicked.connect(self.accept)
btns.addWidget(self.run_btn)
btns.addStretch(1)
btns.addWidget(self.export_btn)
btns.addWidget(close)
lay.addLayout(btns)
def _adv_changed(self, text):
self.seconds_sb.setEnabled(text in ("seconds", "both"))
self._refresh()
def _selected(self):
row = self.listw.currentRow()
return row if 0 <= row < len(self.slides) else -1
def _move(self, delta):
i = self._selected()
j = i + delta
if i < 0 or not (0 <= j < len(self.slides)):
return
self.slides[i], self.slides[j] = self.slides[j], self.slides[i]
self._refresh()
self.listw.setCurrentRow(j)
def _remove(self):
i = self._selected()
if i >= 0:
del self.slides[i]
self._refresh()
def _viewer_len(self):
if not self.slides:
return 0
try:
return slideshow.viewer_length(self._show(),
[s["conv"] for s in self.slides],
self._video())
except Exception:
return 320 # xa unavailable; a safe over-estimate for sizing
def _show(self):
return slideshow.Slideshow(
platform=self.platform, disk_format=self.format_cb.currentText(),
advance=self.advance_cb.currentText(), seconds=self.seconds_sb.value(),
loop=self.loop_cb.isChecked(), items=[s["item"] for s in self.slides])
def _video(self):
p = self.parent()
return p.video_cb.currentText() if p is not None else "pal"
def _refresh(self):
fmt = self.format_cb.currentText()
self.listw.clear()
for i, s in enumerate(self.slides):
it, conv = s["item"], s["conv"]
blk = slideshow.item_blocks(self.platform, fmt, len(conv.data))
item = QtWidgets.QListWidgetItem(
f"{i + 1:02d}. {it.mode} / {it.dither} {blk} blocks "
f"{os.path.basename(it.source_path)}")
if s.get("thumb") is not None:
item.setIcon(QtGui.QIcon(numpy_to_pixmap(s["thumb"])))
self.listw.addItem(item)
if not self.slides:
self.meter.setText("No images yet -- add them from the main window.")
self.meter.setStyleSheet("")
self.export_btn.setEnabled(False)
self.run_btn.setEnabled(False)
return
try:
slideshow.check_modes(self.platform, [s["conv"] for s in self.slides])
except ValueError as e:
self.meter.setText(str(e))
self.meter.setStyleSheet("font-weight:bold;color:#c02020;")
self.export_btn.setEnabled(False)
self.run_btn.setEnabled(False)
return
b = slideshow.budget(self.platform, fmt,
[slideshow.image_nbytes(s["conv"]) for s in self.slides],
self._viewer_len())
verdict = "fits" if b.fits else f"OVER -- {b.reason}"
self.meter.setText(
f"{len(self.slides)} images {b.used_blocks}/{b.total_blocks} blocks "
f"{b.files}/{b.file_cap} files {verdict}")
self.meter.setStyleSheet(
"font-weight:bold;color:%s;" % ("#2a8a2a" if b.fits else "#c02020"))
self.export_btn.setEnabled(b.fits)
self.run_btn.setEnabled(b.fits and platforms.has_emulator(self.platform))
def _build_disk_to(self, path):
show = self._show()
return slideshow.build_disk(show, path, convs=[s["conv"] for s in self.slides],
video=self._video())
def _export(self):
fmt = self.format_cb.currentText()
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self, "Export slideshow disk", _home(f"slideshow.{fmt}"),
f"Disk image (*.{fmt})")
if not path:
return
try:
out = self._build_disk_to(path)
QtWidgets.QMessageBox.information(
self, "Exported",
f"Wrote {out}\n\n{len(self.slides)} images.\n"
'On the C64 / emulator:\n LOAD"*",8,1 then RUN')
except Exception:
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
def _run(self):
from . import mame
p = self.parent()
try:
import tempfile
fmt = self.format_cb.currentText()
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix="slideshow_")
os.close(fd)
self._build_disk_to(path)
if p is not None:
p._temp_disks.append(path)
mame.kill(p._emu_proc)
p._emu_proc = platforms.run_emulator(
self.platform, path, self.slides[0]["conv"].mode, self._video())
self.parent().status.showMessage("Launched slideshow in emulator.")
except Exception:
QtWidgets.QMessageBox.critical(self, "Run failed", traceback.format_exc())
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8 Bit Lenser -- modern images to retro disks")
self.resize(980, 620)
self.source_path = None
self.last_conv = None
self.worker = None
self.pending = False
self._gallery_key = None # cache key for Explore variations
self._gallery_results = None # cached {index: (m, p, d, err, rgb)}
self._slides = [] # slideshow queue (see SlideshowDialog)
self._slideshow_platform = None # locked to the first added slide
self._build_ui()
self.setAcceptDrops(True) # allow dragging an image onto the window
self.src_label["label"].setText("(no image)\n\nclick “Open image…” "
"or drag an image here")
self._check_tools()
# ---- UI construction ----
def _build_ui(self):
central = QtWidgets.QWidget()
self.setCentralWidget(central)
root = QtWidgets.QHBoxLayout(central)
# previews
previews = QtWidgets.QVBoxLayout()
self.src_label = self._make_preview("Original")
self.c64_label = self._make_preview("Preview")
previews.addWidget(self.src_label["box"])
previews.addWidget(self.c64_label["box"])
root.addLayout(previews, 3)
# controls
panel = QtWidgets.QVBoxLayout()
root.addLayout(panel, 1)
open_btn = QtWidgets.QPushButton("Open image...")
open_btn.clicked.connect(self.open_image)
panel.addWidget(open_btn)
self.explore_btn = QtWidgets.QPushButton("Explore variations...")
self.explore_btn.setToolTip(
"Render every Mode x Palette x Dither combination, pick the best,\n"
"then fine-tune brightness/contrast/etc.")
self.explore_btn.clicked.connect(self.explore_variations)
self.explore_btn.setEnabled(False)
panel.addWidget(self.explore_btn)
form = QtWidgets.QFormLayout()
self.platform_cb = QtWidgets.QComboBox()
self.platform_cb.setIconSize(QtCore.QSize(18, 18))
# list alphabetically by the name shown (code kept as the item data),
# each prefixed by a small brand-evocative icon
for code in sorted(platforms.PLATFORMS,
key=lambda c: PLATFORM_NAMES.get(c, c).lower()):
self.platform_cb.addItem(_brand_icon(code),
PLATFORM_NAMES.get(code, code), code)
self.platform_cb.setCurrentIndex(self.platform_cb.findData("c64"))
self.mode_cb = self._combo(platforms.modes("c64"), "multicolor")
self.format_cb = self._combo(platforms.disk_formats("c64"), "d64")
self.palette_cb = self._combo(platforms.palettes("c64"), "colodore")
self.dither_cb = self._combo(DITHER_CHOICES, "atkinson")
self.base_cb = self._combo(BASE_CHOICES, "grayscale")
self.video_cb = self._combo(["pal", "ntsc"], "pal")
self.aspect_cb = self._combo(ASPECT_CHOICES, "fit")
self.layout_cb = self._combo(["unified", "separate"], "unified")
self.display_cb = self._combo(["key", "forever", "seconds"], "key")
self.seconds_sb = QtWidgets.QSpinBox()
self.seconds_sb.setRange(1, 3600)
self.seconds_sb.setValue(10)
self.seconds_sb.setSuffix(" s")
self.seconds_sb.setEnabled(False)
self.display_cb.currentTextChanged.connect(
lambda t: self.seconds_sb.setEnabled(t == "seconds"))
disp_row = QtWidgets.QHBoxLayout()
disp_row.addWidget(self.display_cb)
disp_row.addWidget(self.seconds_sb)
form.addRow("Platform", self.platform_cb)
form.addRow("Mode", self.mode_cb)
form.addRow("Disk format", self.format_cb)
form.addRow("Palette", self.palette_cb)
form.addRow("Dither", self.dither_cb)
form.addRow("Mono/hue base", self.base_cb)
form.addRow("Video", self.video_cb)
form.addRow("Aspect", self.aspect_cb)
form.addRow("Display", disp_row)
form.addRow("Viewer", self.layout_cb)
panel.addLayout(form)
self.platform_cb.currentIndexChanged.connect(self._on_platform_changed)
self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)")
self.intensive_cb.setChecked(True) # default on -- best quality
self.intensive_cb.stateChanged.connect(self.schedule_convert)
panel.addWidget(self.intensive_cb)
self.sliders = {}
self._slider_defaults = {}
for key, lo, hi, default, label in [
("brightness", 50, 200, 100, "bri"), ("contrast", 50, 200, 100, "con"),
("saturation", 0, 200, 100, "sat"), ("gamma", 50, 200, 100, "gam"),
("tint", -180, 180, 0, "tint"),
("red", 0, 200, 100, "red"), ("green", 0, 200, 100, "grn"),
("blue", 0, 200, 100, "blue")]:
self._slider_defaults[key] = default
panel.addLayout(self._slider(key, lo, hi, default, label))
reset_btn = QtWidgets.QPushButton("Reset adjustments")
reset_btn.setToolTip("Reset brightness/contrast/tint/levels to defaults.")
reset_btn.clicked.connect(self.reset_sliders)
panel.addWidget(reset_btn)
# ---- slideshow queue ----
self.add_slide_btn = QtWidgets.QPushButton("Add to slideshow")
self.add_slide_btn.setToolTip("Add the current image + its settings as a "
"slide in the slideshow queue.")
self.add_slide_btn.clicked.connect(self.add_to_slideshow)
self.add_slide_btn.setEnabled(False)
panel.addWidget(self.add_slide_btn)
self.slideshow_btn = QtWidgets.QPushButton("Slideshow (0)...")
self.slideshow_btn.setToolTip("Arrange the queued images, size them against "
"a disk, set advancing, and export one drive image.")
self.slideshow_btn.clicked.connect(self.open_slideshow)
panel.addWidget(self.slideshow_btn)
for cb in (self.mode_cb, self.format_cb, self.palette_cb, self.dither_cb,
self.base_cb, self.aspect_cb):
cb.currentIndexChanged.connect(self.schedule_convert)
panel.addStretch(1)
self.vice_btn = QtWidgets.QPushButton("Run in emulator")
self.vice_btn.setToolTip("Build a temporary disk/cartridge and boot it "
"in MAME.")
self.vice_btn.clicked.connect(self.run_in_vice)
self.vice_btn.setEnabled(False)
panel.addWidget(self.vice_btn)
self.export_btn = QtWidgets.QPushButton("Export disk image...")
self.export_btn.clicked.connect(self.export)
self.export_btn.setEnabled(False)
panel.addWidget(self.export_btn)
self.status = self.statusBar()
self._temp_disks = []
self._emu_proc = None
def _make_preview(self, title):
box = QtWidgets.QGroupBox(title)
lay = QtWidgets.QVBoxLayout(box)
label = QtWidgets.QLabel("(no image)")
label.setAlignment(QtCore.Qt.AlignCenter)
label.setMinimumSize(320, 200)
label.setStyleSheet("background:#202020;color:#888;")
lay.addWidget(label)
return {"box": box, "label": label}
def _combo(self, items, default):
cb = QtWidgets.QComboBox()
cb.addItems(items)
if default in items:
cb.setCurrentText(default)
return cb
def _slider(self, key, lo, hi, default=100, label=None):
row = QtWidgets.QHBoxLayout()
lab = QtWidgets.QLabel(label or key[:4])
lab.setMinimumWidth(34)
row.addWidget(lab)
s = QtWidgets.QSlider(QtCore.Qt.Horizontal)
s.setRange(lo, hi)
s.setValue(default)
s.sliderReleased.connect(self.schedule_convert)
self.sliders[key] = s
row.addWidget(s)
return row
def reset_sliders(self):
"""Reset every adjustment slider to its default and re-render once."""
for key, slider in self.sliders.items():
slider.blockSignals(True)
slider.setValue(self._slider_defaults[key])
slider.blockSignals(False)
self.schedule_convert()
def _check_tools(self):
from . import mame
missing = []
if not have_xa():
missing.append("xa (xa65 assembler, for the 6502 viewers)")
if not have_c1541():
missing.append("c1541 (VICE package, builds C64 disks)")
if not mame.have_mame():
missing.append("mame (runs every platform's image)")
if missing:
QtWidgets.QMessageBox.warning(
self, "Missing tools",
"Export / Run need these tools on PATH:\n " + "\n ".join(missing) +
"\n\nInstall: sudo apt install xa65 vice mame")
def _on_platform_changed(self):
"""Repopulate platform-specific choices and re-render."""
plat = self.platform_cb.currentData()
for cb, items in ((self.mode_cb, platforms.modes(plat)),
(self.palette_cb, platforms.palettes(plat)),
(self.format_cb, platforms.disk_formats(plat))):
cb.blockSignals(True)
cb.clear()
cb.addItems(items)
cb.blockSignals(False)
self._gallery_key = None # invalidate variations cache
self._retarget_slideshow(plat) # keep the slideshow on the selected platform
self.schedule_convert()
# ---- params ----
def params(self):
return {
"platform": self.platform_cb.currentData(),
"mode": self.mode_cb.currentText(),
"palette": self.palette_cb.currentText(),
"dither": self.dither_cb.currentText(),
"base_name": self.base_cb.currentText(),
"aspect": self.aspect_cb.currentText(),
"intensive": self.intensive_cb.isChecked(),
"brightness": self.sliders["brightness"].value() / 100.0,
"contrast": self.sliders["contrast"].value() / 100.0,
"saturation": self.sliders["saturation"].value() / 100.0,
"gamma": self.sliders["gamma"].value() / 100.0,
"tint": float(self.sliders["tint"].value()), # degrees
"red": self.sliders["red"].value() / 100.0,
"green": self.sliders["green"].value() / 100.0,
"blue": self.sliders["blue"].value() / 100.0,
}
# ---- actions ----
def open_image(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, "Open image", "",
"Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.tif *.tiff);;"
"All files (*)")
if path:
self.load_path(path)
def load_path(self, path):
"""Load an image (from the file chooser or a drag-and-drop) and convert."""
# Load via Pillow, not QPixmap: Qt's bundled plugins can't read webp/tiff
# (and ignore EXIF orientation), so those originals showed up blank.
try:
from PIL import Image, ImageOps
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
pm = numpy_to_pixmap(np.asarray(im, dtype=np.uint8))
except Exception:
QtWidgets.QMessageBox.warning(
self, "Cannot open image",
f"Could not read this image:\n{path}\n\n{traceback.format_exc()}")
return
self.source_path = path
self.explore_btn.setEnabled(True)
self.src_label["label"].setPixmap(
pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation))
self.schedule_convert()
# ---- drag and drop ----
@staticmethod
def _dropped_image_path(mime):
"""Return the first local image-file path in a drag, or None."""
exts = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff")
if not mime.hasUrls():
return None
for url in mime.urls():
if url.isLocalFile() and url.toLocalFile().lower().endswith(exts):
return url.toLocalFile()
return None
def dragEnterEvent(self, event):
if self._dropped_image_path(event.mimeData()):
event.acceptProposedAction()
def dragMoveEvent(self, event):
if self._dropped_image_path(event.mimeData()):
event.acceptProposedAction()
def dropEvent(self, event):
path = self._dropped_image_path(event.mimeData())
if path:
event.acceptProposedAction()
self.load_path(path)
def explore_variations(self):
if not self.source_path:
return
p = self.params()
prep_kwargs = dict(aspect=p["aspect"], brightness=p["brightness"],
contrast=p["contrast"], saturation=p["saturation"],
gamma=p["gamma"], tint=p["tint"], red=p["red"],
green=p["green"], blue=p["blue"])
plat, base = p["platform"], p["base_name"]
# Reuse cached results when source, platform, base and prep are unchanged.
key = (self.source_path, plat, base, tuple(sorted(prep_kwargs.items())))
cached = self._gallery_results if self._gallery_key == key else None
dlg = VariationsDialog(self.source_path, prep_kwargs, plat, base, self,
cached=cached)
result = dlg.exec_()
if len(dlg.collected) == len(gallery.combos(plat)):
self._gallery_key = key
self._gallery_results = dict(dlg.collected)
if result == QtWidgets.QDialog.Accepted and dlg.choice:
mode, palette, dither = dlg.choice
# apply choice, then let the focused live preview + tuning take over
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
cb.blockSignals(True)
self.mode_cb.setCurrentText(mode)
self.palette_cb.setCurrentText(palette)
self.dither_cb.setCurrentText(dither)
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
cb.blockSignals(False)
self.status.showMessage(
f"Chosen: {mode} / {palette} / {dither}. "
f"Now fine-tune brightness/contrast/saturation/gamma.")
self.schedule_convert()
# ---- slideshow ----
def _update_slideshow_btn(self):
self.slideshow_btn.setText(f"Slideshow ({len(self._slides)})...")
def _retarget_slideshow(self, new_plat):
"""Re-convert the queued slides so the slideshow follows the main platform.
Called when the platform changes: each slide keeps its source image, image
adjustments (prep) and dither, but its mode/palette fall back to the new
platform's defaults when the old ones don't exist there. Converts every
slide up front and only commits if all succeed, so a failure leaves the
queue untouched. No-op unless there is a queue and the new platform can
host a slideshow (switching to a non-slideshow platform leaves it as-is)."""
if (not self._slides or new_plat == self._slideshow_platform
or not slideshow.supports_slideshow(new_plat)):
return
modes, palettes = platforms.modes(new_plat), platforms.palettes(new_plat)
intensive = self.intensive_cb.isChecked()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
try:
rebuilt = []
for s in self._slides:
old = s["item"]
mode = old.mode if old.mode in modes else modes[0]
palette = old.palette if old.palette in palettes else palettes[0]
conv = platforms.convert(new_plat, old.source_path, mode, palette,
old.dither, intensive, old.prep, old.mono_base)
item = slideshow.SlideItem(
source_path=old.source_path, mode=mode, palette=palette,
dither=old.dither, mono_base=old.mono_base, prep=old.prep)
thumb = render_preview(conv, conv.meta.get("palette", palette), scale=1)
rebuilt.append({"item": item, "conv": conv, "thumb": thumb})
except Exception:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.warning(
self, "Could not retarget slideshow",
"The queued images could not all be converted for "
f"{PLATFORM_NAMES.get(new_plat, new_plat)}, so the slideshow was "
"left on the previous platform.\n\n" + traceback.format_exc())
return
QtWidgets.QApplication.restoreOverrideCursor()
self._slides[:] = rebuilt # mutate in place (shared reference)
self._slideshow_platform = new_plat
self.status.showMessage(
f"Slideshow retargeted to {PLATFORM_NAMES.get(new_plat, new_plat)} "
f"({len(rebuilt)} slide(s) re-converted).")
def add_to_slideshow(self):
if not self.last_conv or not self.source_path:
return
plat = self.platform_cb.currentData()
if not slideshow.supports_slideshow(plat):
QtWidgets.QMessageBox.warning(
self, "Not supported",
"Slideshows are only supported on disk-based platforms "
"(C64, C128, Atari, BBC, Apple, IIGS, Amiga).")
return
if self._slides and plat != self._slideshow_platform:
QtWidgets.QMessageBox.warning(
self, "One platform per slideshow",
f"This slideshow is for "
f"{PLATFORM_NAMES.get(self._slideshow_platform, self._slideshow_platform)}."
"\nRemove all its images before switching platforms.")
return
p = self.params()
prep = imageprep.PrepOptions(
aspect=p["aspect"], brightness=p["brightness"], contrast=p["contrast"],
saturation=p["saturation"], gamma=p["gamma"], tint=p["tint"],
red=p["red"], green=p["green"], blue=p["blue"])
item = slideshow.SlideItem(
source_path=self.source_path, mode=p["mode"], palette=p["palette"],
dither=p["dither"], mono_base=p["base_name"], prep=prep)
thumb = render_preview(self.last_conv,
self.last_conv.meta.get("palette", "colodore"), scale=1)
self._slides.append({"item": item, "conv": self.last_conv, "thumb": thumb})
self._slideshow_platform = plat
self._update_slideshow_btn()
self.status.showMessage(
f"Added slide {len(self._slides)} ({item.mode}/{item.dither}). "
f"{len(self._slides)} image(s) in the slideshow.")
def open_slideshow(self):
if not self._slides:
QtWidgets.QMessageBox.information(
self, "Slideshow empty",
"Add images first with the “Add to slideshow” button.")
return
dlg = SlideshowDialog(self._slides, self._slideshow_platform, self)
dlg.exec_()
if not self._slides:
self._slideshow_platform = None
self._update_slideshow_btn()
def schedule_convert(self):
if not self.source_path:
return
if self.worker and self.worker.isRunning():
self.pending = True # coalesce: re-run when current finishes
return
self.status.showMessage("Converting...")
self.worker = ConvertWorker(self.source_path, self.params())
self.worker.done.connect(self.on_converted)
self.worker.failed.connect(self.on_failed)
self.worker.finished.connect(self._worker_finished)
self.worker.start()
def _worker_finished(self):
if self.pending:
self.pending = False
self.schedule_convert()
def on_converted(self, conv):
self.last_conv = conv
rgb = render_preview(conv, conv.meta.get("palette", "colodore"), scale=2)
pm = numpy_to_pixmap(rgb)
self.c64_label["label"].setPixmap(
pm.scaled(self.c64_label["label"].size(), QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation))
self.export_btn.setEnabled(True)
self.vice_btn.setEnabled(platforms.has_emulator(self.platform_cb.currentData()))
self.add_slide_btn.setEnabled(
slideshow.supports_slideshow(self.platform_cb.currentData()))
self.status.showMessage(
f"{conv.mode}: mean dE {conv.error:.1f} "
f"(palette {conv.meta.get('palette')}, dither {conv.meta.get('dither')})")
def on_failed(self, msg):
self.status.showMessage("Conversion failed")
QtWidgets.QMessageBox.critical(self, "Conversion failed", msg)
def export(self):
if not self.last_conv:
return
plat = self.platform_cb.currentData()
fmt = self.format_cb.currentText()
suggested = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self, "Export disk image", _home(f"{suggested}.{fmt}"),
f"Disk image (*.{fmt})")
if not path:
return
try:
out = platforms.export(plat, self.last_conv, path, fmt,
self.source_path, self.video_cb.currentText(),
display=self.display_cb.currentText(),
seconds=self.seconds_sb.value(),
layout=self.layout_cb.currentText())
self.status.showMessage(f"Wrote {out}")
hint = {"c64": ' LOAD"*",8,1 then RUN',
"ti99": " pick it from the TI menu (item 2)",
"ansi": " open it in an ANSI viewer, or display/upload it "
"on your BBS"}.get(
plat, " (self-booting -- just insert the disk/cartridge)")
QtWidgets.QMessageBox.information(
self, "Exported", f"Wrote {out}\n\nOn the machine / emulator:\n{hint}")
except Exception:
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
def run_in_vice(self):
if not self.last_conv:
return
plat = self.platform_cb.currentData()
if not platforms.has_emulator(plat):
QtWidgets.QMessageBox.warning(
self, "Emulator not found",
"MAME was not found on PATH.\n"
"Install it with: sudo apt install mame")
return
try:
import tempfile
fmt = self.format_cb.currentText()
stem = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix=f"{stem}_")
os.close(fd)
standard = self.video_cb.currentText()
platforms.export(plat, self.last_conv, path, fmt, self.source_path, standard,
display=self.display_cb.currentText(),
seconds=self.seconds_sb.value(),
layout=self.layout_cb.currentText())
self._temp_disks.append(path)
from . import mame
mame.kill(self._emu_proc) # close any previous MAME window
self._emu_proc = platforms.run_emulator(
plat, path, self.last_conv.mode, standard)
self.status.showMessage(f"Launched {plat} emulator ({standard}).")
except Exception:
QtWidgets.QMessageBox.critical(self, "Run in emulator failed",
traceback.format_exc())
def closeEvent(self, event):
from . import mame
mame.kill(self._emu_proc) # SIGKILL the emulator on exit
for path in self._temp_disks:
try:
os.remove(path)
except OSError:
pass
super().closeEvent(event)
def main(argv=None):
app = QtWidgets.QApplication(argv if argv is not None else sys.argv)
win = MainWindow()
win.show()
return app.exec_()
if __name__ == "__main__":
raise SystemExit(main())