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