import sys import os import subprocess import time from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QLabel, QLineEdit, QPushButton, QFrame, QSplitter, QScrollArea, QComboBox, QCheckBox, QGroupBox, QFileDialog, QDialog, QProgressBar, QMessageBox, QTabWidget, QTreeWidget, QTreeWidgetItem, QTextEdit, QGridLayout, QButtonGroup, QSizePolicy, QMenu) from PySide6.QtCore import Qt, QSize, Signal, Slot, QSettings, QPoint, QRect, QTimer, QThreadPool, QRunnable, QEvent, QThread from PySide6.QtGui import QFont, QIcon, QPalette, QColor, QCloseEvent, QPainter, QPainterPath import shutil from data_manager import DataManager from mame_launcher import MameLauncher from rom_manager import RomManager, DownloadWorker from mame_downloader import VgmModDownloadWorker # Linux: no winreg needed winreg = None def _xdg_open(path): """Cross-platform open: uses xdg-open on Linux.""" try: subprocess.Popen(['xdg-open', str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: print(f"xdg-open failed: {e}") def _detect_linux_dark_mode(): """Detect dark mode on Linux desktops (GNOME, KDE, etc.).""" # Try GNOME / GTK color-scheme (GNOME 42+) try: result = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], capture_output=True, text=True, timeout=2 ) if 'dark' in result.stdout.lower(): return True if 'light' in result.stdout.lower() or 'default' in result.stdout.lower(): return False except Exception: pass # Try GNOME / GTK gtk-theme (older GNOME) try: result = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True, text=True, timeout=2 ) if 'dark' in result.stdout.lower(): return True except Exception: pass # Try KDE try: kde_config = os.path.expanduser('~/.config/kdeglobals') if os.path.exists(kde_config): with open(kde_config, 'r') as f: for line in f: if 'ColorScheme' in line and 'Dark' in line: return True except Exception: pass return None # Unknown, use Qt palette fallback class VgmPostProcessWorker(QThread): finished = Signal() def __init__(self, process, src_dir, rom_name, dest_path): super().__init__() self.process = process self.src_dir = src_dir self.rom_name = rom_name self.dest_path = dest_path def run(self): # Wait for MAME to exit self.process.wait() # MAME-VGM mod saves as _0.vgm in the working directory (mame) # Note: sometimes it might be _0.vgm src_file = os.path.join(self.src_dir, f"{self.rom_name}_0.vgm") if os.path.exists(src_file) and self.dest_path: try: dest_dir = os.path.dirname(self.dest_path) if dest_dir: os.makedirs(dest_dir, exist_ok=True) if os.path.exists(self.dest_path): os.remove(self.dest_path) shutil.move(src_file, self.dest_path) print(f"VGM captured and moved to: {self.dest_path}") except Exception as e: print(f"Failed to move VGM file: {e}") self.finished.emit() class RomItemWidget(QWidget): def __init__(self, description, value, exists, parent=None): super().__init__(parent) self.exists = exists layout = QVBoxLayout(self) layout.setContentsMargins(10, 5, 10, 5) layout.setSpacing(2) self.title_label = QLabel(description) self.status_label = QLabel("ROM found" if exists else "ROM missing") layout.addWidget(self.title_label) layout.addWidget(self.status_label) self.apply_theme() def apply_theme(self): is_dark = self.window().is_dark_mode() if hasattr(self.window(), 'is_dark_mode') else True if not hasattr(self.window(), 'is_dark_mode'): # Fallback if window not yet active main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) if main_win and hasattr(main_win, 'is_dark_mode'): is_dark = main_win.is_dark_mode() title_color = ("#ffffff" if self.exists else "#ff4d4d") if is_dark else ("#1a1a1a" if self.exists else "#d32f2f") self.title_label.setStyleSheet(f"font-weight: bold; font-size: 13px; color: {title_color};") self.status_label.setStyleSheet(f"font-size: 11px; color: #888888;") class RomManagerDialog(QDialog): def __init__(self, rom_manager, parent=None): super().__init__(parent) self.rom_manager = rom_manager self.setWindowTitle("ROMs") self.setMinimumSize(650, 550) self.filter_mode = "all" # "all" or "missing" self.init_ui() self.apply_dialog_theme() self.refresh_list() def init_ui(self): self.setObjectName("RomDialog") main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 1. Header with Segmented Control header = QWidget() header.setFixedHeight(50) header_layout = QHBoxLayout(header) self.seg_all = QPushButton("All") self.seg_all.setCheckable(True) self.seg_all.setChecked(True) self.seg_missing = QPushButton("Missing") self.seg_missing.setCheckable(True) self.seg_group = QButtonGroup(self) self.seg_group.addButton(self.seg_all) self.seg_group.addButton(self.seg_missing) self.seg_group.buttonClicked.connect(self.on_filter_changed) self.rom_search = QLineEdit() self.rom_search.setPlaceholderText("Search ROMs...") self.rom_search.setFixedWidth(200) self.rom_search.textChanged.connect(self.refresh_list) header_layout.addWidget(self.rom_search) header_layout.addSpacing(20) header_layout.addWidget(self.seg_all) header_layout.addWidget(self.seg_missing) header_layout.addStretch() main_layout.addWidget(header) # 2. ROM List self.rom_list = QListWidget() self.rom_list.setObjectName("RomList") main_layout.addWidget(self.rom_list) # 3. Progress Area (Hidden by default) self.progress_area = QWidget() self.progress_area.setVisible(False) p_layout = QVBoxLayout(self.progress_area) self.progress_bar = QProgressBar() self.status_label = QLabel("") p_layout.addWidget(self.status_label) p_layout.addWidget(self.progress_bar) main_layout.addWidget(self.progress_area) # 4. Settings Footer footer = QWidget() footer.setObjectName("RomFooter") footer_layout = QVBoxLayout(footer) footer_layout.setContentsMargins(15, 15, 15, 15) url_layout = QHBoxLayout() url_layout.addWidget(QLabel("URL")) self.url_combo = QComboBox() self.url_combo.setEditable(True) self.url_combo.addItems(self.rom_manager.base_urls) url_layout.addWidget(self.url_combo) footer_layout.addLayout(url_layout) type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Type")) self.type_combo = QComboBox() self.type_combo.addItems(["zip", "7z"]) type_layout.addWidget(self.type_combo) type_layout.addStretch() footer_layout.addLayout(type_layout) # 5. Buttons Footer btns_layout = QHBoxLayout() self.refresh_btn = QPushButton("Refresh") self.refresh_btn.clicked.connect(self.refresh_list) self.open_roms_btn = QPushButton("📁 ROMs") self.open_roms_btn.clicked.connect(self.open_roms_folder) self.download_btn = QPushButton("Download Missing") self.download_btn.setObjectName("PrimaryButton") self.download_btn.clicked.connect(self.download_missing) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.reject) btns_layout.addWidget(self.refresh_btn) btns_layout.addWidget(self.open_roms_btn) btns_layout.addStretch() btns_layout.addWidget(self.download_btn) btns_layout.addWidget(self.cancel_btn) footer_layout.addLayout(btns_layout) main_layout.addWidget(footer) self.apply_dialog_theme() def on_filter_changed(self, btn): self.filter_mode = "all" if btn == self.seg_all else "missing" self.refresh_list() def refresh_list(self): self.rom_list.clear() statuses = self.rom_manager.get_rom_status() query = self.rom_search.text().lower() for s in statuses: if self.filter_mode == "missing" and s['exists']: continue if query and query not in s['description'].lower() and query not in s['value'].lower(): continue item = QListWidgetItem(self.rom_list) widget = RomItemWidget(s['description'], s['value'], s['exists']) item.setSizeHint(widget.sizeHint()) self.rom_list.addItem(item) self.rom_list.setItemWidget(item, widget) def open_roms_folder(self): _xdg_open(self.rom_manager.roms_dir) def download_missing(self): primary_url = self.url_combo.currentText() if not primary_url.endswith("/"): primary_url += "/" statuses = self.rom_manager.get_rom_status() self.to_download = [s for s in statuses if not s['exists']] if not self.to_download: QMessageBox.information(self, "Done", "All ROMs are already present!") return self.progress_area.setVisible(True) self.download_total = len(self.to_download) self.download_finished_count = 0 self.download_failed_count = 0 # Reset failed count self.progress_bar.setMaximum(self.download_total) self.progress_bar.setValue(0) # Ultra-fast Concurrent Execution using QThreadPool pool = QThreadPool.globalInstance() # Set to 50 to allow explosive downloading of many small files if pool.maxThreadCount() < 50: pool.setMaxThreadCount(50) for current in self.to_download: value = current['value'] ext = self.type_combo.currentText() # Prepare all possible URLs: Primary (UI) + others from the list urls = [] primary_url = self.url_combo.currentText() if not primary_url.endswith("/"): primary_url += "/" urls.append(f"{primary_url}{value}.{ext}") for base in self.rom_manager.base_urls: if base.strip("/") != primary_url.strip("/"): if not base.endswith("/"): base += "/" urls.append(f"{base}{value}.{ext}") dest = os.path.join(self.rom_manager.roms_dir, f"{value}.{ext}") worker = DownloadWorker(urls, dest, value) # Signal handling for QRunnable via proxy object worker.signals.finished.connect(lambda v, s, w=worker: self.on_concurrent_download_finished(w, v, s)) pool.start(worker) def on_concurrent_download_finished(self, worker, value, success): self.download_finished_count += 1 if not success: self.download_failed_count = getattr(self, "download_failed_count", 0) + 1 self.progress_bar.setValue(self.download_finished_count) self.status_label.setText(f"Finished {self.download_finished_count}/{self.download_total}: {value}") if self.download_finished_count == self.download_total: self.progress_area.setVisible(False) failed = getattr(self, "download_failed_count", 0) if failed > 0: QMessageBox.warning(self, "Finished", f"Downloaded {self.download_total - failed} ROMs, but {failed} failed.\nSome files might not exist on the server.") else: QMessageBox.information(self, "Finished", f"Successfully downloaded all {self.download_total} ROMs!") self.refresh_list() def apply_dialog_theme(self): main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) is_dark = main_win.is_dark_mode() if main_win else True bg_main = "#1e1e1e" if is_dark else "#f5f5f7" bg_list = "#1a1a1a" if is_dark else "#ffffff" border = "#3d3d3d" if is_dark else "#d1d1d1" text = "#eeeeee" if is_dark else "#1a1a1a" btn_bg = "#3d3d3d" if is_dark else "#e0e0e0" self.setStyleSheet(f""" QDialog#RomDialog {{ background-color: {bg_main}; color: {text}; }} #RomList {{ background-color: {bg_list}; border-top: 1px solid {border}; border-bottom: 1px solid {border}; }} #RomFooter {{ background-color: {bg_main}; }} QPushButton {{ background-color: {btn_bg}; border: 1px solid {border}; color: {text}; padding: 6px 12px; border-radius: 4px; }} QPushButton:hover {{ background-color: {"#4d4d4d" if is_dark else "#d0d0d0"}; }} #PrimaryButton {{ background-color: #0078d4; border: none; font-weight: bold; color: white; }} #PrimaryButton:hover {{ background-color: #1a8ad4; }} QLineEdit, QComboBox {{ background-color: {bg_list}; border: 1px solid {border}; border-radius: 4px; padding: 4px; color: {text}; }} QLabel {{ color: {text}; font-size: 12px; }} QProgressBar {{ border: 1px solid {border}; border-radius: 4px; text-align: center; height: 15px; }} QProgressBar::chunk {{ background-color: #0078d4; }} """) # --- Sub-Slot Popup (The popover from Mac version) --- class SubSlotPopup(QDialog): def __init__(self, parent, data, current_slots, on_change_callback): super().__init__(parent) self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.data = data self.current_slots = current_slots self.on_change_callback = on_change_callback self.init_ui() def closeEvent(self, event): if hasattr(self.parent(), 'active_popup') and self.parent().active_popup == self: self.parent().last_popup_close_time = time.time() self.parent().last_popup_id = id(self.data) self.parent().active_popup = None super().closeEvent(event) def init_ui(self): # Overall container to allow for the pointer arrow on top self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 10, 0, 0) # Top margin for arrow self.container = QWidget() self.container.setObjectName("BubbleContainer") self.container.setStyleSheet(""" QWidget#BubbleContainer { background-color: #262626; border: 1px solid #3d3d3d; border-radius: 12px; } """) self.content_layout = QVBoxLayout(self.container) self.content_layout.setContentsMargins(15, 20, 15, 15) self.content_layout.setSpacing(8) # Close button self.close_btn = QPushButton("×", self.container) self.close_btn.setFixedSize(20, 20) self.close_btn.setStyleSheet("color: #aaa; background: #444; border-radius: 10px; border:none; font-weight:bold;") self.close_btn.move(250, 8) self.close_btn.clicked.connect(self.close) if 'slots' in self.data: for slot in self.data['slots']: options = slot.get('options', []) combo = QComboBox() combo.setFixedWidth(180) combo.setFixedHeight(22) combo.setProperty("appleStyle", "slot") slot_name = slot['name'] combo.setObjectName(slot_name) for opt in options: combo.addItem(opt.get('description') or opt['value'] or "—None—", opt['value']) combo.blockSignals(True) val = self.current_slots.get(slot_name) idx = combo.findData(str(val)) if idx < 0: idx = combo.findData(val) if idx >= 0: combo.setCurrentIndex(idx) combo.blockSignals(False) combo.currentIndexChanged.connect(self.on_changed) # Create container with combo and arrow overlay (matching main window) combo_widget = QWidget() combo_widget.setFixedSize(180, 22) combo.setParent(combo_widget) combo.move(0, 0) # Arrow label overlay - narrow blue like Mac arrow_label = QLabel("↕", combo_widget) arrow_label.setFixedSize(20, 20) arrow_label.move(160, 1) # 160 + 20 = 180 arrow_label.setAlignment(Qt.AlignCenter) arrow_label.setStyleSheet(""" background-color: #3b7ee1; color: white; font-size: 12px; font-weight: bold; padding-bottom: 3px; border: none; border-top-right-radius: 3px; border-bottom-right-radius: 3px; """) arrow_label.setAttribute(Qt.WA_TransparentForMouseEvents) self.content_layout.addWidget(combo_widget, 0, Qt.AlignCenter) self.main_layout.addWidget(self.container) self.apply_theme() self.setFixedWidth(280) def paintEvent(self, event): painter = QPainter(self) try: main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) is_dark = main_win.is_dark_mode() if main_win else True painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(QColor("#262626" if is_dark else "#f5f5f7")) painter.setPen(Qt.NoPen) # Draw a triangle pointing up at the middle path = QPainterPath() mw = self.width() / 2 path.moveTo(mw - 10, 11) path.lineTo(mw, 0) path.lineTo(mw + 10, 11) painter.drawPath(path) finally: painter.end() def apply_theme(self): main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) is_dark = main_win.is_dark_mode() if main_win else True bg = "#262626" if is_dark else "#f5f5f7" border = "#3d3d3d" if is_dark else "#d1d1d1" combo_bg = "#3d3d3d" if is_dark else "#ffffff" text = "#eeeeee" if is_dark else "#1a1a1a" self.setStyleSheet(f""" QWidget#BubbleContainer {{ background-color: {bg}; border: 1px solid {border}; border-radius: 12px; }} QComboBox {{ background-color: {combo_bg}; border: 1px solid {border}; border-radius: 4px; padding: 2px 20px 2px 8px; color: {text}; font-size: 11px; min-height: 18px; }} QComboBox::drop-down {{ width: 0px; border: none; }} QComboBox::down-arrow {{ image: none; width: 0px; height: 0px; }} QComboBox:hover {{ border-color: {"#777" if is_dark else "#999"}; }} """) def on_changed(self): combo = self.sender() self.current_slots[combo.objectName()] = combo.currentData() self.on_change_callback() # --- Software List Popup (Overlay) --- class SoftwarePopup(QDialog): def __init__(self, parent): super().__init__(parent) # 使用 Qt.Tool 確保它附屬於主視窗,且不會永遠置頂(Always on Top) self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.NoFocus) self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_ShowWithoutActivating) self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 10, 0, 0) # Top margin for arrow self.container = QFrame() self.container.setObjectName("BubbleContainer") self.layout = QVBoxLayout(self.container) self.layout.setContentsMargins(2, 2, 2, 2) self.list_widget = QListWidget() self.list_widget.setObjectName("SoftwareListPopup") self.layout.addWidget(self.list_widget) self.main_layout.addWidget(self.container) self.apply_theme() self.setFixedHeight(300) def paintEvent(self, event): painter = QPainter(self) try: main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) is_dark = main_win.is_dark_mode() if main_win else True painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(QColor("#262626" if is_dark else "#f5f5f7")) painter.setPen(Qt.NoPen) path = QPainterPath() # Arrow pointing up mw = 40 path.moveTo(mw - 10, 11) path.lineTo(mw, 0) path.lineTo(mw + 10, 11) painter.drawPath(path) finally: painter.end() def apply_theme(self): main_win = next((w for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow)), None) is_dark = main_win.is_dark_mode() if main_win else True bg = "#262626" if is_dark else "#f5f5f7" border = "#3d3d3d" if is_dark else "#d1d1d1" text = "#cccccc" if is_dark else "#1a1a1a" item_border = "#333" if is_dark else "#e0e0e0" sel_bg = "#3b7ee1" self.container.setStyleSheet(f""" QFrame#BubbleContainer {{ background-color: {bg}; border: 1px solid {border}; border-radius: 8px; }} """) self.list_widget.setStyleSheet(f""" QListWidget {{ background: transparent; border: none; color: {text}; font-size: 11px; }} QListWidget::item {{ padding: 6px 12px; border-bottom: 1px solid {item_border}; }} QListWidget::item:selected {{ background-color: {sel_bg}; color: white; border-radius: 4px; }} QListWidget::item:disabled {{ color: {"#555" if is_dark else "#999"}; font-weight: bold; background-color: {"#222" if is_dark else "#eee"}; }} QScrollBar:vertical {{ background: {"#1a1a1a" if is_dark else "#f0f0f0"}; width: 10px; margin: 0; }} QScrollBar::handle:vertical {{ background: {"#444" if is_dark else "#ccc"}; min-height: 20px; border-radius: 5px; margin: 2px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} """) def show_at(self, widget): if self.list_widget.count() == 0: self.hide() return # 僅在尚未顯示或位置需要更新時處理,避免重複抓取導致卡頓 self.setFixedWidth(widget.width()) pos = widget.mapToGlobal(QPoint(0, widget.height() - 5)) self.move(pos) if not self.isVisible(): self.show() self.raise_() class AmpleMainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Ample - Linux Port") self.setMinimumSize(1000, 750) # Paths if getattr(sys, 'frozen', False): # If running as PyInstaller OneDir/OneFile bundle self.app_dir = os.path.dirname(os.path.abspath(sys.executable)) else: # If running from source (development) self.app_dir = os.path.dirname(os.path.abspath(__file__)) # Robustly find Resources path self.resources_path = None curr = self.app_dir for _ in range(3): # Look up to 3 levels up candidate = os.path.join(curr, "Ample", "Resources") if os.path.exists(os.path.join(candidate, "models.plist")): self.resources_path = candidate break curr = os.path.dirname(curr) if not self.resources_path: # Fallback to current working directory candidate = os.path.join(os.getcwd(), "Ample", "Resources") if os.path.exists(os.path.join(candidate, "models.plist")): self.resources_path = candidate print(f"DEBUG: app_dir: {self.app_dir}") print(f"DEBUG: resolved resources_path: {self.resources_path}") mame_bin_dir = os.path.abspath(os.path.join(self.app_dir, "mame")) self.roms_dir = os.path.join(mame_bin_dir, "roms") mame_exe = os.path.join(mame_bin_dir, "mame") hash_path = os.path.join(mame_bin_dir, "hash") self.data_manager = DataManager(self.resources_path, hash_path) self.rom_manager = RomManager(self.resources_path, self.roms_dir) self.launcher = MameLauncher() self.launcher.mame_path = mame_exe self.launcher.working_dir = mame_bin_dir self.selected_software = None # Storage for listname:itemname self.selected_software_desc = "" # Storage for full display name self.active_popup = None # Track current open sub-slot popup self.last_popup_close_time = 0 self.last_popup_id = None self.sw_search = None # Global stylesheet for combos with appleStyle="slot" self.setStyleSheet(""" QComboBox[appleStyle="slot"] { background-color: #3d3d3d; border: 1px solid #555; border-radius: 4px; padding: 2px 20px 2px 8px; color: #eee; font-size: 11px; min-height: 18px; } QComboBox[appleStyle="slot"]::drop-down { width: 0px; border: none; } QComboBox[appleStyle="slot"]::down-arrow { image: none; width: 0px; height: 0px; } """) # Thread management self.active_workers = [] # Settings Persistence self.settings = QSettings(os.path.join(self.roms_dir, "settings.ini"), QSettings.IniFormat) self.selected_machine = None self.current_slots = {} self.current_media = {} self.launcher.working_dir = mame_bin_dir self.init_ui() self.apply_premium_theme() self.load_persistent_settings() # 安裝全域事件過濾器以偵測點擊外部 qApp.installEventFilter(self) # Theme polling self.last_theme_is_dark = self.is_dark_mode() self.theme_timer = QTimer(self) self.theme_timer.timeout.connect(self.check_theme_change) self.theme_timer.start(2000) # Sequentially check for MAME and then ROMs QTimer.singleShot(500, self.run_startup_checks) def is_dark_mode(self): result = _detect_linux_dark_mode() if result is not None: return result return self.palette().color(QPalette.Window).value() < 128 def check_theme_change(self): current_is_dark = self.is_dark_mode() if current_is_dark != self.last_theme_is_dark: self.last_theme_is_dark = current_is_dark self.apply_premium_theme() # Refresh child dialogs and popups if hasattr(self, 'rom_manager_dialog') and self.rom_manager_dialog and self.rom_manager_dialog.isVisible(): self.rom_manager_dialog.apply_dialog_theme() # Also need to refresh top-level widgets that might be using custom items for i in range(self.rom_manager_dialog.rom_list.count()): item = self.rom_manager_dialog.rom_list.item(i) if self.rom_manager_dialog.rom_list.itemWidget(item): self.rom_manager_dialog.rom_list.itemWidget(item).apply_theme() if hasattr(self, 'sw_popup') and self.sw_popup: self.sw_popup.apply_theme() if self.active_popup and self.active_popup.isVisible(): self.active_popup.apply_theme() self.active_popup.update() # Force repaint for triangle def _is_ubuntu(self): """Check if the current OS is Ubuntu.""" try: with open('/etc/os-release', 'r') as f: content = f.read().lower() return 'ubuntu' in content except Exception: return False def run_startup_checks(self): """Sequential startup validation: MAME first, then ROMs.""" if not self.check_for_mame(): # If MAME is missing, focus on that first from PySide6.QtWidgets import QMessageBox if self._is_ubuntu(): reply = QMessageBox.question(self, "MAME Not Found", "MAME executable was not found.\n\n" "Would you like to install MAME via snap?\n" " Command: sudo snap install mame", QMessageBox.Yes | QMessageBox.No) if reply == QMessageBox.Yes: self._snap_install_mame() else: reply = QMessageBox.question(self, "MAME Not Found", "MAME executable was not found.\n\nWould you like to open settings to set MAME path or download it?", QMessageBox.Yes | QMessageBox.No) if reply == QMessageBox.Yes: self.show_settings() return # Only if MAME is found, we check for ROMs self.check_and_auto_roms() def _snap_install_mame(self): """Install MAME via snap with a progress dialog.""" from PySide6.QtWidgets import QProgressDialog progress = QProgressDialog("Installing MAME via snap...", None, 0, 0, self) progress.setWindowTitle("Installing MAME") progress.setMinimumWidth(350) progress.setMinimumDuration(0) progress.setCancelButton(None) # Not cancellable progress.show() QApplication.processEvents() class SnapInstallThread(QThread): result_signal = Signal(int, str, str) # returncode, stdout, stderr def run(self): try: result = subprocess.run( ['sudo', 'snap', 'install', 'mame'], capture_output=True, text=True, timeout=300 ) self.result_signal.emit(result.returncode, result.stdout, result.stderr) except Exception as e: self.result_signal.emit(-1, "", str(e)) def on_install_finished(returncode, stdout, stderr): progress.close() if returncode == 0: QMessageBox.information(self, "Success", "MAME installed successfully via snap!") if self.check_for_mame(): self.check_and_auto_roms() elif returncode == -1: QMessageBox.warning(self, "Install Error", f"Failed to run snap install:\n{stderr}") self.show_settings() else: QMessageBox.warning(self, "Install Failed", f"snap install failed:\n{stderr}\n\nYou can try manually: sudo snap install mame") self.show_settings() self._snap_thread = SnapInstallThread() self._snap_thread.result_signal.connect(on_install_finished) self._snap_thread.start() def check_and_auto_roms(self): statuses = self.rom_manager.get_rom_status() missing = [s for s in statuses if not s['exists']] if missing: # Short timer to show dialog after window is visible # QTimer already imported at top QTimer.singleShot(500, self.show_rom_manager) def open_ample_dir(self): _xdg_open(self.app_dir) def open_help_url(self): _xdg_open("https://github.com/ksherlock/ample/tree/master/AmpleLinux") def init_ui(self): container = QWidget() self.setCentralWidget(container) main_vbox = QVBoxLayout(container) main_vbox.setContentsMargins(0, 0, 0, 0) main_vbox.setSpacing(0) # 1. Toolbar (macOS Style) toolbar = QWidget() toolbar.setObjectName("Toolbar") toolbar.setFixedHeight(60) toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(15, 0, 15, 0) tools = [ ("📂 Ample Dir", self.open_ample_dir), ("🎮 ROMs", self.show_rom_manager), ("⚙️ Settings", self.show_settings), ("📖 Help", self.open_help_url) ] for name, slot in tools: btn = QPushButton(name) btn.setObjectName("ToolbarButton") if slot: btn.clicked.connect(slot) toolbar_layout.addWidget(btn) toolbar_layout.addStretch() main_vbox.addWidget(toolbar) # 2. Splitter for Tree and Main Area self.splitter = QSplitter(Qt.Horizontal) self.splitter.setHandleWidth(1) self.splitter.setObjectName("MainSplitter") # Left Panel: Machine Tree left_panel = QWidget() left_panel.setObjectName("LeftPanel") left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(10, 10, 10, 10) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Find Machine...") self.search_input.setObjectName("SearchInput") self.search_input.textChanged.connect(self.filter_machines) self.machine_tree = QTreeWidget() self.machine_tree.setHeaderHidden(True) self.machine_tree.setObjectName("MachineTree") self.machine_tree.itemClicked.connect(self.on_machine_selected) self.machine_tree.itemDoubleClicked.connect(self.on_tree_double_clicked) self.populate_machine_tree(self.data_manager.models, self.machine_tree.invisibleRootItem()) left_layout.addWidget(self.search_input) left_layout.addWidget(self.machine_tree) self.splitter.addWidget(left_panel) # Right Panel: Compact Configuration Area right_panel = QWidget() right_panel.setObjectName("RightPanel") right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(15, 10, 15, 10) right_layout.setSpacing(5) # Tabs (Centered and Compact) tab_container = QHBoxLayout() self.tabs = QTabWidget() self.tabs.setObjectName("MainTabs") self.tabs.setFixedHeight(120) # Compact height for video/cpu settings self.init_tabs() tab_container.addStretch() tab_container.addWidget(self.tabs) tab_container.addStretch() right_layout.addLayout(tab_container) # Body: Grid for Slots and Media scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet("background: transparent; border: none;") self.options_container = QWidget() self.options_grid = QGridLayout(self.options_container) self.options_grid.setContentsMargins(10, 10, 20, 10) self.options_grid.setSpacing(20) self.options_grid.setColumnStretch(0, 1) self.options_grid.setColumnStretch(1, 1) # Fixed containers to avoid grid stacking issues self.slots_frame = QWidget() self.slots_layout = QVBoxLayout(self.slots_frame) self.slots_layout.setContentsMargins(0, 0, 0, 0) self.slots_layout.setSpacing(10) self.options_grid.addWidget(self.slots_frame, 0, 0) self.media_frame = QWidget() self.media_layout = QVBoxLayout(self.media_frame) self.media_layout.setContentsMargins(0, 0, 0, 0) self.media_layout.setSpacing(10) self.options_grid.addWidget(self.media_frame, 0, 1) # Proportions: tree (managed by splitter), slots(1), media/software(2) self.options_grid.setColumnStretch(0, 1) self.options_grid.setColumnStretch(1, 2) scroll.setWidget(self.options_container) right_layout.addWidget(scroll) # Launch Area (Button only, right-aligned) launch_row = QHBoxLayout() self.launch_btn = QPushButton() self.launch_btn.setObjectName("LaunchButton") self.launch_btn.setFixedSize(110, 32) self.launch_btn.clicked.connect(self.launch_mame) # Internal layout to align icon left and text right btn_layout = QHBoxLayout(self.launch_btn) btn_layout.setContentsMargins(10, 0, 15, 0) icon_lbl = QLabel("🍎") icon_lbl.setAttribute(Qt.WA_TransparentForMouseEvents) icon_lbl.setStyleSheet("background: transparent; border: none; font-size: 14px;") text_lbl = QLabel("Launch") text_lbl.setAttribute(Qt.WA_TransparentForMouseEvents) text_lbl.setStyleSheet("background: transparent; border: none; font-weight: bold; color: white; font-size: 13px;") btn_layout.addWidget(icon_lbl) btn_layout.addStretch() btn_layout.addWidget(text_lbl) launch_row.addStretch() launch_row.addWidget(self.launch_btn) right_layout.addLayout(launch_row) self.splitter.addWidget(right_panel) self.splitter.setStretchFactor(1, 1) # Balanced ratio main_vbox.addWidget(self.splitter) # 4. Command Preview (Full Width Bottom - Mac Style) self.cmd_preview = QTextEdit() self.cmd_preview.setReadOnly(False) self.cmd_preview.setObjectName("CommandPreview") self.cmd_preview.setFixedHeight(65) # Approx 4 lines self.cmd_preview.setAcceptRichText(False) self.cmd_preview.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Keep it clean like Mac main_vbox.addWidget(self.cmd_preview) def populate_machine_tree(self, models, parent_item): if not models: print("DEBUG: No models found to populate machine tree.") return for model in models: item = QTreeWidgetItem(parent_item) item.setText(0, model.get('description', 'Unknown')) if 'value' in model: item.setData(0, Qt.UserRole, model['value']) if 'children' in model: self.populate_machine_tree(model['children'], item) def init_tabs(self): # --- Video Tab --- video_tab = QWidget() v_layout = QVBoxLayout(video_tab) v_layout.setContentsMargins(15, 10, 15, 10) v_layout.setSpacing(6) row1 = QHBoxLayout() row1.setSpacing(10) self.use_bgfx = QCheckBox("BGFX") self.use_bgfx.setChecked(True) self.bgfx_backend = QComboBox() self.bgfx_backend.addItems(["Default", "OpenGL", "Vulkan"]) row1.addWidget(self.use_bgfx) row1.addWidget(QLabel("Backend:")) row1.addWidget(self.bgfx_backend) row1.addSpacing(15) row1.addWidget(QLabel("Effects:")) self.video_effect = QComboBox() self.video_effect.addItems(["Default", "Unfiltered", "HLSL", "CRT Geometry", "CRT Geometry Deluxe", "LCD Grid", "Fighters"]) row1.addWidget(self.video_effect) row1.addStretch() v_layout.addLayout(row1) row2 = QHBoxLayout() row2.setSpacing(10) row2.addWidget(QLabel("Window Mode:")) self.win_mode = QComboBox() self.win_mode.addItems(["Window 1x", "Window 2x", "Window 3x", "Window 4x", "Full Screen"]) self.win_mode.setCurrentIndex(1) row2.addWidget(self.win_mode) self.square_pixels = QCheckBox("Square Pixels") row2.addSpacing(15) row2.addWidget(self.square_pixels) row2.addStretch() v_layout.addLayout(row2) row3 = QHBoxLayout() row3.setSpacing(15) self.capture_mouse = QCheckBox("Capture Mouse") self.disk_sounds = QCheckBox("Disk Sound Effects") row3.addWidget(self.capture_mouse) row3.addWidget(self.disk_sounds) row3.addStretch() v_layout.addLayout(row3) # Connect all for w in [self.use_bgfx, self.bgfx_backend, self.video_effect, self.win_mode, self.square_pixels, self.capture_mouse, self.disk_sounds]: if isinstance(w, QCheckBox): w.stateChanged.connect(lambda: self.update_and_preview()) else: w.currentIndexChanged.connect(lambda: self.update_and_preview()) self.tabs.addTab(video_tab, "Video") # --- CPU Tab --- cpu_tab = QWidget() c_layout = QVBoxLayout(cpu_tab) row_c1 = QHBoxLayout() row_c1.addWidget(QLabel("Speed:")) self.cpu_speed = QComboBox() self.cpu_speed.addItems(["100%", "200%", "300%", "400%", "500%", "No Throttle"]) self.cpu_speed.currentIndexChanged.connect(lambda: self.update_and_preview()) row_c1.addWidget(self.cpu_speed) row_c1.addStretch() c_layout.addLayout(row_c1) row_c2 = QHBoxLayout() self.debugger = QCheckBox("Debug") self.debugger.stateChanged.connect(lambda: self.update_and_preview()) self.rewind = QCheckBox("Rewind") self.rewind.stateChanged.connect(lambda: self.update_and_preview()) row_c2.addWidget(self.debugger) row_c2.addWidget(self.rewind) row_c2.addStretch() c_layout.addLayout(row_c2) self.tabs.addTab(cpu_tab, "CPU") # --- A/V Tab --- av_tab = QWidget() av_layout = QVBoxLayout(av_tab) av_layout.setContentsMargins(15, 10, 15, 10) av_layout.setSpacing(6) def add_av_row(label, attr_prefix): row = QHBoxLayout() cb = QCheckBox(label) edit = QLineEdit() ext = label.split()[-1].lower() edit.setPlaceholderText(f"/path/to/file.{ext}") setattr(self, f"{attr_prefix}_check", cb) setattr(self, f"{attr_prefix}_path", edit) cb.stateChanged.connect(lambda: self.update_and_preview()) edit.textChanged.connect(lambda: self.update_and_preview()) # Make the line edit clickable to open save file dialog def on_click(event): # Use current text directory if valid, else CWD current_path = edit.text() start_dir = current_path if current_path and os.path.dirname(current_path) else os.getcwd() file_path, _ = QFileDialog.getSaveFileName( self, f"Select Output File ({label})", start_dir, f"{ext.upper()} Files (*.{ext});;All Files (*.*)" ) if file_path: # Convert to native separators for Windows consistency file_path = os.path.normpath(file_path) edit.setText(file_path) QLineEdit.mousePressEvent(edit, event) edit.mousePressEvent = on_click row.addWidget(cb) row.addWidget(edit, 1) # Give path field more space av_layout.addLayout(row) add_av_row("Generate AVI", "avi") add_av_row("Generate WAV", "wav") add_av_row("Generate VGM", "vgm") # VGM: Feature not implemented on Linux self.vgm_check.stateChanged.disconnect() def _vgm_not_implemented(state=None): if state is None or state == Qt.Checked.value: self.vgm_check.blockSignals(True) self.vgm_check.setChecked(False) self.vgm_check.blockSignals(False) QMessageBox.information(self, "VGM", "Feature not implemented.") self.vgm_check.stateChanged.connect(_vgm_not_implemented) def _vgm_path_click(event): QMessageBox.information(self, "VGM", "Feature not implemented.") self.vgm_path.mousePressEvent = _vgm_path_click av_layout.addStretch() self.tabs.addTab(av_tab, "A/V") # --- Paths Tab --- paths_tab = QWidget() p_layout = QVBoxLayout(paths_tab) p_layout.setContentsMargins(15, 10, 15, 10) p_layout.setSpacing(6) row_p1 = QHBoxLayout() self.share_dir_check = QCheckBox("Share Directory") self.share_dir_path = QLineEdit() self.share_dir_path.setPlaceholderText("/path/to/directory/") # Make the line edit clickable to open directory selector def share_dir_mouse_press(event): dir_path = QFileDialog.getExistingDirectory(self, "Select Shared Directory", self.share_dir_path.text() or os.getcwd()) if dir_path: self.share_dir_path.setText(os.path.normpath(dir_path)) QLineEdit.mousePressEvent(self.share_dir_path, event) self.share_dir_path.mousePressEvent = share_dir_mouse_press self.share_dir_check.stateChanged.connect(lambda: self.update_and_preview()) self.share_dir_path.textChanged.connect(lambda: self.update_and_preview()) row_p1.addWidget(self.share_dir_check) row_p1.addWidget(self.share_dir_path, 1) p_layout.addLayout(row_p1) p_layout.addStretch() self.tabs.addTab(paths_tab, "Paths") def update_and_preview(self): self.update_command_line() def filter_machines(self, text): query = text.lower() self.filter_tree_item(self.machine_tree.invisibleRootItem(), query) def filter_tree_item(self, item, query): item_text = item.text(0).lower() is_match = query in item_text any_child_match = False for i in range(item.childCount()): if self.filter_tree_item(item.child(i), query): any_child_match = True visible = is_match or any_child_match item.setHidden(not visible) if visible and query: item.setExpanded(True) return visible def on_machine_selected(self, item): # 切換機器時立刻隱藏軟體下拉清單 if hasattr(self, 'sw_popup') and self.sw_popup: self.sw_popup.hide() machine_name = item.data(0, Qt.UserRole) if not machine_name: return self.selected_machine = machine_name self.current_slots = {} # Reset slots for the new machine self.machine_title_bar = item.text(0) self.setWindowTitle(f"Ample - {self.machine_title_bar}") # Sticky Settings: Only keep software selection if the new machine supports the same list if self.selected_software: current_list = self.selected_software.split(':')[0] new_sw_lists = self.data_manager.get_software_lists(machine_name) supported_lists = [sl['name'] for sl in new_sw_lists] if current_list not in supported_lists: self.clear_software_selection() data = self.data_manager.get_machine_description(machine_name) if data: self.current_machine_data = data self.initialize_default_slots(data) self.refresh_ui() # 不再於切換時立即填充軟體清單 (延遲加載以優化效能) if hasattr(self, 'sw_list'): self.sw_list.clear() def initialize_default_slots(self, data, depth=0): if depth > 20: return # Helper to find a shared definition def find_global_def(name): if not self.current_machine_data: return None # 1. Search 'devices' for d in self.current_machine_data.get('devices', []): if d.get('name') == name: return d # 2. Search 'slots' for s in self.current_machine_data.get('slots', []): if s.get('name') == name: return s return None # 1. Process 'slots' if 'slots' in data: for slot in data['slots']: slot_name = slot.get('name') if not slot_name: continue # Default selection if slot_name not in self.current_slots: best_val = None for opt in slot.get('options', []): if opt.get('default'): best_val = opt.get('value') break if best_val is not None: self.current_slots[slot_name] = best_val # Recursion into selected option cur_val = self.current_slots.get(slot_name) for opt in slot.get('options', []): if str(opt.get('value')) == str(cur_val): # A. Recurse into inline slots self.initialize_default_slots(opt, depth + 1) # B. Recurse into devname definition if 'devname' in opt: m_dev = find_global_def(opt['devname']) if m_dev: self.initialize_default_slots(m_dev, depth + 1) break # 2. Process 'devices' - ONLY if not the root machine level # At the root, 'devices' is a catalog of all possible device types. if depth > 0 and 'devices' in data: for dev in data['devices']: self.initialize_default_slots(dev, depth + 1) def on_tree_double_clicked(self, item, column): if item.childCount() == 0: machine_name = item.data(0, Qt.UserRole) if machine_name: self.launch_mame() def update_options_ui(self, data): self.current_machine_data = data self.refresh_ui() def refresh_ui(self): # 0. Re-initialize defaults for any newly appeared slots/devices if self.current_machine_data: self.initialize_default_slots(self.current_machine_data) # 1. Clean the fixed layouts without destroying the frames themselves self.clear_grid(self.slots_layout) self.clear_grid(self.media_layout) # 2. Re-render self.render_slots_ui() self.render_media_ui() self.update_command_line() def render_slots_ui(self): # We now add directly to self.slots_layout self.slots_layout.setContentsMargins(10, 10, 10, 10) self.slots_layout.setSpacing(6) if 'slots' in self.current_machine_data: # 1. RAM Group ram_slot = next((s for s in self.current_machine_data['slots'] if s['name'] == 'ramsize'), None) if ram_slot: self.add_slot_row(self.slots_layout, ram_slot) self.slots_layout.addSpacing(5) # 2. Disk Drives - EXACTLY same structure as add_slot_row # Mac hides popup button but it still takes up space. Hamburger at far right. dd_slot = next((s for s in self.current_machine_data['slots'] if s.get('description') == 'Disk Drives'), None) if dd_slot: row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(5) # Label - IDENTICAL to add_slot_row lbl = QLabel("Disk Drives:") lbl.setFixedWidth(100) lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) lbl.setObjectName("SlotLabel") row.addWidget(lbl) # Invisible container - same size as add_slot_row combo (160px) invisible_container = QLabel("") invisible_container.setFixedWidth(160) invisible_container.setFixedHeight(22) row.addWidget(invisible_container) # Hamburger at FAR RIGHT - SAME position as other rows cur_val = self.current_slots.get(dd_slot['name']) selected_opt = next((o for o in dd_slot['options'] if str(o.get('value')) == str(cur_val)), dd_slot['options'][0]) target_data = selected_opt if 'devname' in selected_opt: devname = selected_opt['devname'] m_dev = next((d for d in self.current_machine_data.get('devices', []) if d.get('name') == devname), None) if m_dev: target_data = m_dev h_btn = self.create_hamburger(target_data) row.addWidget(h_btn) # Insert stretch at index 0 - IDENTICAL to add_slot_row row.insertStretch(0) self.slots_layout.addLayout(row) # 3. All other slots for slot in self.current_machine_data['slots']: if slot['name'] != 'ramsize' and slot.get('description') != 'Disk Drives': self.add_slot_row(self.slots_layout, slot) self.slots_layout.addStretch() def add_slot_row(self, parent_layout, slot): slot_name = slot['name'] desc = slot.get('description') if not desc: return row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) # Explicitly zero margins to match Disk Drives row.setSpacing(5) lbl = QLabel(f"{desc}:") lbl.setFixedWidth(100) lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) lbl.setObjectName("SlotLabel") combo = QComboBox() from PySide6.QtWidgets import QListView lv = QListView() combo.setView(lv) # MacOS list is wide, field is narrow lv.setMinimumWidth(350) is_dark = self.is_dark_mode() lv_bg = "#1a1a1a" if is_dark else "#ffffff" lv_text = "#dddddd" if is_dark else "#1a1a1a" lv_border = "#444444" if is_dark else "#d1d1d1" lv.setStyleSheet(f"background-color: {lv_bg}; color: {lv_text}; border: 1px solid {lv_border}; outline: none;") combo.setObjectName(slot_name) combo.setProperty("appleStyle", "slot") combo.setFixedWidth(160) # Match Mac popup width combo.setFixedHeight(22) if slot.get('default') == "true" or slot.get('default') is True: # Default logic handled via current_slots, but could be reinforced here pass # Use QStandardItemModel for advanced item control (disabling items) from PySide6.QtGui import QStandardItemModel, QStandardItem model = QStandardItemModel() combo.setModel(model) for opt in slot['options']: opt_desc = opt.get('description') or opt['value'] or "—None—" item = QStandardItem(opt_desc) item.setData(opt['value'], Qt.UserRole) # Check for disabled status in plist # XML plist boolean is usually True/False in Python after loading is_disabled = opt.get('disabled', False) if is_disabled: item.setEnabled(False) # Optional: Add visual cue like "(Unsupported)" or color change if style sheet overrides gray item.setForeground(QColor("#888888")) model.appendRow(item) combo.blockSignals(True) val = self.current_slots.get(slot_name) idx = combo.findData(str(val)) if idx < 0: idx = combo.findData(val) if idx >= 0: combo.setCurrentIndex(idx) combo.blockSignals(False) combo.currentIndexChanged.connect(self.on_slot_changed) # Create container with combo and arrow overlay combo_widget = QWidget() combo_widget.setFixedSize(160, 22) combo.setParent(combo_widget) combo.move(0, 0) # Arrow label overlay - narrow blue like Mac arrow_label = QLabel("↕", combo_widget) arrow_label.setFixedSize(20, 20) arrow_label.move(140, 1) # 140 + 20 = 160, narrow and covers right edge arrow_label.setAlignment(Qt.AlignCenter) arrow_label.setStyleSheet(""" background-color: #3b7ee1; color: white; font-size: 12px; font-weight: bold; padding-bottom: 3px; border: none; border-top-right-radius: 3px; border-bottom-right-radius: 3px; """) arrow_label.setAttribute(Qt.WA_TransparentForMouseEvents) # Click through to combo # Order: Label -> ComboWidget -> Hamburger (then addStretch at 0) row.addWidget(lbl) row.addWidget(combo_widget) # Subtle Hamburger - Unified with create_hamburger size selected_opt = next((o for o in slot['options'] if str(o.get('value')) == str(val)), None) has_sub = False target_data = selected_opt if selected_opt: if 'slots' in selected_opt or 'devices' in selected_opt: has_sub = True elif 'devname' in selected_opt: devname = selected_opt['devname'] m_dev = next((d for d in self.current_machine_data.get('devices', []) if d.get('name') == devname), None) if m_dev and ('slots' in m_dev or 'devices' in m_dev): has_sub = True target_data = m_dev if has_sub: sub_btn = self.create_hamburger(target_data) row.addWidget(sub_btn) else: # Invisible placeholder - same size as hamburger for alignment invisible_hamburger = QLabel("") invisible_hamburger.setFixedSize(22, 22) row.addWidget(invisible_hamburger) # KEY FIX: Insert stretch at index 0 to force right-alignment row.insertStretch(0) parent_layout.addLayout(row) def create_hamburger(self, data): btn = QPushButton("≡") btn.setFixedSize(22, 22) btn.setFlat(True) btn.setStyleSheet("color: #999; font-size: 18px; border: none; background: transparent;") btn.clicked.connect(lambda _, d=data: self.show_sub_slots(d, btn)) return btn def gather_active_slots(self, data, depth=0): if depth > 10: return [] slots = [] # Check standard slots if 'slots' in data: for slot in data['slots']: slots.append(slot) selected_val = self.current_slots.get(slot['name']) for opt in slot['options']: if opt['value'] == selected_val: slots.extend(self.gather_active_slots(opt, depth + 1)) break # Check devices if 'devices' in data: for dev in data['devices']: slots.extend(self.gather_active_slots(dev, depth + 1)) return slots def show_sub_slots(self, data, button): # Prevent immediate reopening when clicking the same button to close (race condition) # Windows Qt: Popup auto-hides on mouse press OUTSIDE, then button-click fires. now = time.time() if (now - self.last_popup_close_time < 0.3) and (self.last_popup_id == id(data)): return # If there's an active popup, close it first if self.active_popup is not None: self.active_popup.close() # Note: closeEvent will set self.active_popup = None # Create and show the popup relative to the button popup = SubSlotPopup(self, data, self.current_slots, self.refresh_ui) self.active_popup = popup pos = button.mapToGlobal(QPoint(button.width(), 0)) # Shift a bit to the left to align with Mac bubble popup.move(pos.x() - 100, pos.y() + button.height() + 5) popup.show() def get_total_media(self): total_media = {} def find_global_def(name): if not self.current_machine_data: return None for d in self.current_machine_data.get('devices', []): if d.get('name') == name: return d for s in self.current_machine_data.get('slots', []): if s.get('name') == name: return s return None def aggregate_media(data, depth=0): if depth > 15: return # 1. Media defined here if 'media' in data: for k, v in data['media'].items(): key = k if k == 'cass': key = 'cassette' total_media[key] = total_media.get(key, 0) + v elif 'value' in data and data.get('value') == 'cdrom_2x': # Compatibility fallback for SCSI CD-ROM (2X speed) which lacks explicit media tag in plist total_media['cdrom'] = total_media.get('cdrom', 0) + 1 # 2. Recurse into slots if 'slots' in data: for slot in data['slots']: cur_val = self.current_slots.get(slot['name']) for opt in slot.get('options', []): if str(opt.get('value')) == str(cur_val): # A. Inline aggregate_media(opt, depth + 1) # B. Via devname if 'devname' in opt: m_dev = find_global_def(opt['devname']) if m_dev: aggregate_media(m_dev, depth + 1) break # 3. Recurse into devices - ONLY if not the root machine level if depth > 0 and 'devices' in data: for dev in data['devices']: aggregate_media(dev, depth + 1) if self.current_machine_data: aggregate_media(self.current_machine_data, depth=0) # UI FIX: Cleanup empty entries for k in ['hard', 'cdrom', 'cassette']: if k in total_media and total_media[k] <= 0: total_media.pop(k, None) return total_media def get_filtered_media(self): total_media = self.get_total_media() PREFIX_MAP = { 'floppy_5_25': 'flop', 'floppy_3_5': 'flop', 'hard': 'hard', 'cdrom': 'cdrom', 'cassette': 'cass', 'cass': 'cass' } counters = {"flop": 0, "hard": 0, "cdrom": 0, "cass": 0} active_keys = set() # We must iterate in a consistent order if we want flop1, flop2 etc to be stable # Using the same order as in add_media_group calls media_order = ["floppy_5_25", "floppy_3_5", "hard", "cdrom", "cassette"] for m_type_key in media_order: if m_type_key in total_media: m_prefix = PREFIX_MAP.get(m_type_key, m_type_key) count = total_media[m_type_key] for i in range(count): counters[m_prefix] += 1 idx = counters[m_prefix] key = f"{m_prefix}{idx}" if m_prefix == "cass" and idx == 1 and count == 1: key = "cass" active_keys.add(key) return {k: v for k, v in self.current_media.items() if k in active_keys} def render_media_ui(self): # 1. Clear media layout EXCEPT for Software List at the top (if we want to keep it) # Actually, let's keep it simple: rebuild everything. while self.media_layout.count(): item = self.media_layout.takeAt(0) if item.widget(): item.widget().setParent(None) item.widget().deleteLater() elif item.layout(): self.clear_grid(item.layout()) # 2. Add Software List Search Box (Mac Style) # Software list is now an overlay popup, it won't push down other media. if not hasattr(self, 'sw_popup') or self.sw_popup is None: self.sw_popup = SoftwarePopup(self) self.sw_list = self.sw_popup.list_widget self.sw_list.itemClicked.connect(self.on_software_selected) sw_row = QHBoxLayout() self.sw_search = QLineEdit() self.sw_search.setPlaceholderText("Search Software List...") self.sw_search.setObjectName("SoftwareSearch") self.sw_search.setFixedHeight(24) if self.selected_software: self.sw_search.setText(self.selected_software_desc) self.sw_search.setProperty("hasValue", True) else: self.sw_search.setProperty("hasValue", False) # Clear button within the search box btn_clear = QPushButton("✕") btn_clear.setFixedSize(20, 20) btn_clear.setStyleSheet("background: transparent; border: none; color: #666; font-size: 10px;") btn_clear.clicked.connect(self.clear_software_selection) sw_row.addWidget(self.sw_search) sw_row.addWidget(btn_clear) self.media_layout.addLayout(sw_row) # Behavior: 延遲加載 - 只有在使用者點擊搜尋框時,才真正去抓軟體清單 def on_search_focused(event, original_fn=self.sw_search.focusInEvent): # 如果清單是空的,才需要抓取 (或根據需要重新抓取) if self.sw_list.count() == 0: self.render_software_ui() if self.sw_list.count() > 0: self.sw_popup.show_at(self.sw_search) original_fn(event) self.sw_search.focusInEvent = on_search_focused self.sw_search.textChanged.connect(self.filter_software) # Add small vertical space before drive list self.media_layout.addSpacing(10) total_media = self.get_total_media() # ... (rest of the media rendering) # MAME Prefix Mapping and Index Counters PREFIX_MAP = { 'floppy_5_25': 'flop', 'floppy_3_5': 'flop', 'hard': 'hard', 'cdrom': 'cdrom', 'cassette': 'cass', 'cass': 'cass' } counters = {"flop": 0, "hard": 0, "cdrom": 0, "cass": 0} def add_media_group(target_layout, title, m_type_key): if m_type_key in total_media: m_prefix = PREFIX_MAP.get(m_type_key, m_type_key) is_dark = self.is_dark_mode() row_h = QHBoxLayout() handle = QLabel("⠇") handle.setObjectName("MediaHandle") handle.setFixedWidth(10) row_h.addWidget(handle) lbl = QLabel(f"{title}") lbl.setObjectName("MediaHeader") row_h.addWidget(lbl) row_h.addStretch() target_layout.addLayout(row_h) count = total_media[m_type_key] for i in range(count): counters[m_prefix] += 1 idx = counters[m_prefix] # MAME: cass is just -cass if single, or -cass1. Floppies are -flop1, -flop2... key = f"{m_prefix}{idx}" if m_prefix == "cass" and idx == 1 and count == 1: key = "cass" row = QHBoxLayout() row.setContentsMargins(15, 0, 0, 0) # Indent rows like Mac row.setSpacing(5) lbl_choose = QLabel("Choose...") lbl_choose.setObjectName("SmallDimLabel") lbl_choose.setFixedWidth(65) lbl_choose.setAlignment(Qt.AlignRight | Qt.AlignVCenter) edit = QLineEdit() edit.setPlaceholderText("None") edit.setText(self.current_media.get(key, "")) edit.setFixedHeight(18) edit.setObjectName("MediaEdit") # Blue Double Arrow Button (Select) btn_sel = QPushButton("↕") btn_sel.setFixedSize(20, 18) btn_sel.setStyleSheet(""" QPushButton { background-color: #3b7ee1; color: white; border: none; border-radius: 2px; font-weight: bold; font-size: 12px; padding-bottom: 3px; } QPushButton:hover { background-color: #4a8df0; } """) btn_sel.clicked.connect(lambda _, k=key, e=edit: self.browse_media(k, e)) # Eject Button btn_eject = QPushButton("⏏") btn_eject.setFixedSize(20, 18) btn_eject.setObjectName("EjectButton") btn_eject.clicked.connect(lambda _, k=key, e=edit: self.eject_media(k, e)) row.addWidget(lbl_choose) row.addWidget(edit) row.addWidget(btn_sel) row.addWidget(btn_eject) target_layout.addLayout(row) add_media_group(self.media_layout, "5.25\" Floppies", "floppy_5_25") add_media_group(self.media_layout, "3.5\" Floppies", "floppy_3_5") add_media_group(self.media_layout, "Hard Drives", "hard") add_media_group(self.media_layout, "CD-ROMs", "cdrom") add_media_group(self.media_layout, "Cassettes", "cassette") self.media_layout.addStretch() def clear_software_selection(self): self.selected_software = None self.selected_software_desc = "" if self.sw_search: self.sw_search.clear() self.sw_search.setProperty("hasValue", False) self.sw_search.style().unpolish(self.sw_search) self.sw_search.style().polish(self.sw_search) if hasattr(self, 'sw_popup') and self.sw_popup: self.sw_popup.hide() self.update_command_line() def render_software_ui(self): # Re-populate list and check if we should show it if not hasattr(self, 'sw_list'): return self.sw_list.clear() # 如果沒有選定機器,確保隱藏彈出視窗 if not self.selected_machine: if hasattr(self, 'sw_popup'): self.sw_popup.hide() return # Ensure hash path is set mame_bin_dir = os.path.dirname(self.launcher.mame_path) if mame_bin_dir and mame_bin_dir != ".": self.data_manager.hash_path = os.path.join(mame_bin_dir, "hash") sw_lists = self.data_manager.get_software_lists(self.selected_machine) for sl in sw_lists: header = QListWidgetItem(f"--- {sl['description']} ---") header.setFlags(Qt.NoItemFlags) header.setBackground(QColor("#222")) header.setForeground(QColor("#777")) self.sw_list.addItem(header) for item in sl['items']: li = QListWidgetItem(item['description']) li.setData(Qt.UserRole, f"{sl['name']}:{item['name']}") self.sw_list.addItem(li) # Re-apply filter if text exists (sticky search) if hasattr(self, 'sw_search') and self.sw_search and self.sw_search.text(): self.filter_software(self.sw_search.text()) def filter_software(self, text): query = text.lower() # 先進行過濾計算 visible_count = 0 for i in range(self.sw_list.count()): item = self.sw_list.item(i) data = item.data(Qt.UserRole) if not data: # Header items item.setHidden(True) # 搜尋時隱藏分類標題以簡化 continue visible = query in item.text().lower() or query in data.lower() item.setHidden(not visible) if visible: visible_count += 1 # 根據結果決定是否顯示視窗 if visible_count > 0 and self.sw_search.hasFocus(): if hasattr(self, 'sw_popup'): self.sw_popup.show_at(self.sw_search) else: if hasattr(self, 'sw_popup'): self.sw_popup.hide() def on_software_selected(self, item): data = item.data(Qt.UserRole) if data: self.selected_software = data self.selected_software_desc = item.text() self.sw_search.setText(self.selected_software_desc) # 設置高亮度屬性 self.sw_search.setProperty("hasValue", True) self.sw_search.style().unpolish(self.sw_search) self.sw_search.style().polish(self.sw_search) # 確保選中後隱藏清單 if hasattr(self, 'sw_popup'): self.sw_popup.hide() self.update_command_line() # 清除焦點以確保下次點擊搜尋框能正確觸發 focusInEvent self.sw_search.clearFocus() if hasattr(self, 'sw_popup'): self.sw_popup.hide() def on_slot_changed(self): combo = self.sender() self.current_slots[combo.objectName()] = combo.currentData() # Full refresh because changing a slot might add more slots OR change media self.refresh_ui() def eject_media(self, key, edit): if key in self.current_media: del self.current_media[key] edit.clear() self.update_command_line() def browse_media(self, key, edit): path, _ = QFileDialog.getOpenFileName(self, f"Select file for {key}") if path: edit.setText(path) self.current_media[key] = path self.update_command_line() def update_command_line(self): if not self.selected_machine: return # Filter sticky media to only what's supported by current machine/slots filtered_media = {k: os.path.normpath(v) for k, v in self.get_filtered_media().items()} # Softlist selection soft_list_args = [] if self.selected_software: # IMPORTANT: Do NOT use -flop1 for software list items on Windows. # Positional arguments allow MAME's Software List manager to resolve them. soft_list_args.append(self.selected_software) # Build base args args = self.launcher.build_args(self.selected_machine, self.current_slots, filtered_media, soft_list_args) # Add UI Video options for preview win_mode = self.win_mode.currentText() if "Window" in win_mode: args.append("-window") # Handle scaling (2x, 3x, 4x) try: # Extract multiplier from "Window 2x" -> 2 multiplier_str = win_mode.split("x")[0].split()[-1] multiplier = int(multiplier_str) except (IndexError, ValueError): multiplier = 1 if multiplier > 1: res = self.current_machine_data.get('resolution') if res and len(res) >= 2: base_w = res[0] base_h = res[1] if self.square_pixels.isChecked(): if base_w / base_h > 2.0: # Apple II heuristic for Square Pixels (integer scale) # Base Square (1x) is 560x384 (1x width, 2x height) # User wants Window 2x -> 1120x768 target_w = base_w * multiplier target_h = base_h * 2 * multiplier else: # Standard square pixel machine target_w = base_w * multiplier target_h = base_h * multiplier else: # 4:3 Heuristic for non-square pixel machines like Apple II if base_w / base_h > 2.0: eff_h = base_w * 3 // 4 else: eff_h = base_h target_w = base_w * multiplier target_h = eff_h * multiplier args.extend(["-resolution", f"{target_w}x{target_h}"]) else: args.append("-nomax") else: args.extend(["-nowindow", "-maximize"]) if self.square_pixels.isChecked(): args.extend(["-nounevenstretch"]) if self.use_bgfx.isChecked(): args.extend(["-video", "bgfx"]) backend = self.bgfx_backend.currentText().lower().replace(" ", "") if backend != "default": args.extend(["-bgfx_backend", backend]) effect = self.video_effect.currentText() effect_map = { "Unfiltered": "unfiltered", "HLSL": "hlsl", "CRT Geometry": "crt-geom", "CRT Geometry Deluxe": "crt-geom-deluxe", "LCD Grid": "lcd-grid", "Fighters": "fighters" } if effect in effect_map: args.extend(["-bgfx_screen_chains", effect_map[effect]]) # CPU settings speed_text = self.cpu_speed.currentText() if speed_text == "No Throttle": args.append("-nothrottle") elif speed_text != "100%": try: speed_val = float(speed_text.replace("%", "")) / 100.0 args.extend(["-speed", str(speed_val)]) except ValueError: pass if self.rewind.isChecked(): args.append("-rewind") if self.debugger.isChecked(): args.append("-debug") # Default MAME behaviors to match Mac Ample: use samples only if disk sounds enabled if not self.disk_sounds.isChecked(): args.append("-nosamples") # A/V settings if self.avi_check.isChecked() and self.avi_path.text(): args.extend(["-aviwrite", self.avi_path.text()]) if hasattr(self, 'wav_check') and self.wav_check.isChecked() and self.wav_path.text(): args.extend(["-wavwrite", self.wav_path.text()]) if hasattr(self, 'vgm_check') and self.vgm_check.isChecked() and self.vgm_path.text(): # VGM Mod version only supports -vgmwrite 1 args.extend(["-vgmwrite", "1"]) if self.capture_mouse.isChecked(): args.append("-mouse") if hasattr(self, 'share_dir_check') and self.share_dir_check.isChecked() and self.share_dir_path.text(): args.extend(["-share_directory", os.path.normpath(self.share_dir_path.text())]) # Path Setup: Always specify -inipath and -rompath with full absolute paths # to ensure MAME loads the correct config and ROMs regardless of install location mame_ini_dir = os.path.join(self.app_dir, "mame") mame_roms_dir = os.path.join(self.app_dir, "mame", "roms") args.extend(["-inipath", mame_ini_dir, "-rompath", mame_roms_dir]) # Determine display executable exe_display = "mame" if hasattr(self, 'vgm_check') and self.vgm_check.isChecked(): mame_bin_dir = os.path.dirname(self.launcher.mame_path) if os.path.exists(os.path.join(mame_bin_dir, "mame-vgm")): exe_display = "mame-vgm" import subprocess # Safety import for robust runtime self.cmd_preview.setText(subprocess.list2cmdline([exe_display] + args)) def clear_grid_column(self, col): # Extremely aggressive clearing to prevent widget ghosting item = self.options_grid.itemAtPosition(0, col) if item: w = item.widget() if w: w.setParent(None) w.deleteLater() self.options_grid.removeItem(item) def clear_grid(self, layout): if not layout: return while layout.count(): item = layout.takeAt(0) if item.widget(): item.widget().deleteLater() elif item.layout(): self.clear_grid(item.layout()) # Layout items that are not widgets or layouts are rare but handled by takeAt @Slot() def show_rom_manager(self): self.rom_manager_dialog = RomManagerDialog(self.rom_manager, self) # apply_dialog_theme is already called in RomManagerDialog.__init__ self.rom_manager_dialog.exec() @Slot() def show_settings(self): dialog = QDialog(self) dialog.setWindowTitle("Settings") layout = QVBoxLayout(dialog) path_label = QLabel(f"MAME: {self.launcher.mame_path}") layout.addWidget(path_label) # Bottom status and progress self.settings_status = QLabel("") layout.addWidget(self.settings_status) self.settings_progress = QProgressBar() self.settings_progress.setVisible(False) layout.addWidget(self.settings_progress) # Buttons btn1 = QPushButton("Select MAME...") btn1.clicked.connect(lambda: self.select_mame(dialog, path_label)) layout.addWidget(btn1) # Linux: No auto-download. Show guidance instead. hint_label = QLabel( "💡 Install MAME via your package manager:\n" " Ubuntu/Debian: sudo apt install mame\n" " Fedora: sudo dnf install mame\n" " Arch: sudo pacman -S mame\n" " Flatpak: flatpak install flathub org.mamedev.MAME" ) hint_label.setWordWrap(True) hint_label.setStyleSheet("font-size: 11px; color: #888; padding: 8px;") layout.addWidget(hint_label) # Auto-run check immediately self.check_for_mame(path_label) dialog.exec() # After closing settings, if MAME is now valid, check for ROMs if self.check_for_mame(): self.check_and_auto_roms() def select_mame(self, dialog, label): path, _ = QFileDialog.getOpenFileName(dialog, "Select MAME", "/usr/bin", "All Files (*)") if path: self.launcher.mame_path = path self.check_for_mame(label) # Linux: No download_mame or on_mame_dl_finished needed. # Users install MAME via their system package manager. def check_for_mame(self, label=None): """Helper to check standard paths and update UI.""" potential_paths = [ os.path.join(self.app_dir, "mame", "mame"), os.path.join(self.app_dir, "mame"), ] # Linux: also check common system-wide install paths for sys_path in ['/usr/bin/mame', '/usr/games/mame', '/usr/local/bin/mame']: potential_paths.append(sys_path) # Also try to find mame via PATH (which) try: result = subprocess.run(['which', 'mame'], capture_output=True, text=True, timeout=2) which_path = result.stdout.strip() if which_path and os.path.isfile(which_path): potential_paths.insert(0, which_path) except Exception: pass # Also check current path if it's already set and valid if hasattr(self, 'launcher') and self.launcher.mame_path and os.path.exists(self.launcher.mame_path) and self.launcher.mame_path != "mame": if self.launcher.mame_path not in potential_paths: potential_paths.insert(0, self.launcher.mame_path) for p in potential_paths: if os.path.exists(p) and os.path.isfile(p): self.launcher.mame_path = p self.ensure_mame_ini(p) if label: label.setText(f"MAME: {p} ✅") label.setTextFormat(Qt.RichText) if hasattr(self, 'settings_status'): self.settings_status.setText("MAME detected and configured!") return True if label: label.setText(f"MAME: Not found ❌") label.setTextFormat(Qt.RichText) return False def ensure_mame_ini(self, mame_path): """Generate mame.ini in the AmpleLinux/mame directory if it doesn't exist. Always uses self.app_dir/mame as the working directory, regardless of where the MAME binary is installed (e.g. /snap/bin/mame, /usr/bin/mame). This also serves as a sanity check that MAME runs correctly.""" mame_ini_dir = os.path.join(self.app_dir, "mame") os.makedirs(mame_ini_dir, exist_ok=True) ini_path = os.path.join(mame_ini_dir, "mame.ini") if not os.path.exists(ini_path): print(f"Generating mame.ini in {mame_ini_dir} (mame -cc)...") try: # Run mame -cc in the AmpleLinux/mame directory result = subprocess.run([mame_path, "-cc"], cwd=mame_ini_dir, capture_output=True, text=True) if result.returncode == 0: print(f"mame.ini created successfully in {mame_ini_dir}") else: print(f"mame -cc returned code {result.returncode}: {result.stderr}") except Exception as e: print(f"Failed to generate mame.ini: {e}") def launch_mame(self): if hasattr(self, 'sw_popup') and self.sw_popup: self.sw_popup.hide() # Get command from preview console (User Input is Source of Truth) cmd_str = self.cmd_preview.toPlainText().strip() if not cmd_str: return # EASC PowerBook 0.288 Crash Fallback Check affected_macs = ["macpb160", "macpb180", "macpb165", "macpb165c", "macpb180c"] if self.selected_machine in affected_macs: mame_bin_dir = os.path.dirname(self.launcher.mame_path) alt_mac_exe = os.path.join(mame_bin_dir, "mame_0287") vgm_mac_exe = os.path.join(mame_bin_dir, "mame-vgm") # Check if any fallback is already present has_fallback = os.path.exists(alt_mac_exe) or os.path.exists(vgm_mac_exe) if not has_fallback: msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle("EASC Audio Chip Bug (MAME v0.288)") msg_box.setText(f"The machine '{self.selected_machine}' will crash on MAME v0.288 due to an upstream C++ type mismatch bug.") msg_box.setInformativeText( "To run this model, you can download a compatible older version of MAME (e.g. v0.287) and place the executable named 'mame_0287' inside your 'mame' folder,\n" "or run a compatible sibling model instead (e.g. 'macpb170' or 'macpb140')." ) dl_btn = msg_box.addButton("Download Fallback (v0.287)", QMessageBox.YesRole) force_btn = msg_box.addButton("Force Launch (v0.288)", QMessageBox.NoRole) cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole) msg_box.exec() if msg_box.clickedButton() == dl_btn: _xdg_open("https://wiki.mamedev.org/index.php/SDL_Supported_Platforms") return elif msg_box.clickedButton() == force_btn: pass else: return print(f"Launching custom command: {cmd_str}") # Determine the MAME binary directory mame_bin_dir = os.path.dirname(self.launcher.mame_path) # Parse command string into arguments list safely import shlex try: # posix=True is correct for Linux (handles quoting properly) args = shlex.split(cmd_str, posix=True) except ValueError: # Fallback for unbalanced quotes args = cmd_str.split() if not args: return try: # Resolve executable path from bare filename to absolute path exe_cmd = os.path.basename(args[0]).lower() vgm_exe = None # Start with whatever the user provided target_exe_path = args[0] if exe_cmd == "mame": affected_macs = ["macpb160", "macpb180", "macpb165", "macpb165c", "macpb180c"] alt_mac_exe = os.path.join(mame_bin_dir, "mame_0287") vgm_mac_exe = os.path.join(mame_bin_dir, "mame-vgm") if self.selected_machine in affected_macs: if os.path.exists(alt_mac_exe): target_exe_path = alt_mac_exe print(f"Applying compatibility fallback: using mame_0287 for {self.selected_machine}") elif os.path.exists(vgm_mac_exe): target_exe_path = vgm_mac_exe print(f"Applying compatibility fallback: using mame-vgm for {self.selected_machine}") else: target_exe_path = self.launcher.mame_path else: target_exe_path = self.launcher.mame_path elif exe_cmd == "mame-vgm": path_vgm = os.path.join(mame_bin_dir, "mame-vgm") if os.path.exists(path_vgm): target_exe_path = path_vgm vgm_exe = path_vgm # Update the binary path in the args list args[0] = target_exe_path # Pass the LIST of args to Popen. # subprocess will handle quoting for Windows automatically. proc = subprocess.Popen(args, cwd=mame_bin_dir) if proc and vgm_exe: # If using VGM Mod, we need to move the file after exit worker = VgmPostProcessWorker(proc, mame_bin_dir, self.selected_machine, self.vgm_path.text()) worker.finished.connect(lambda: self.active_workers.remove(worker) if worker in self.active_workers else None) self.active_workers.append(worker) worker.start() except Exception as e: print(f"Error launching MAME: {e}") QMessageBox.critical(self, "Launch Error", f"Failed to launch command:\n{e}") def on_vgm_check_changed(self, state): if state == Qt.Checked.value: mame_bin_dir = os.path.dirname(self.launcher.mame_path) vgm_exe = os.path.join(mame_bin_dir, "mame-vgm") if not os.path.exists(vgm_exe): # Request download res = QMessageBox.question(self, "VGM Support Required", "VGM (Video Game Music) support was removed from MAME after v0.163.\n\n" "The community-supported VGM Mod is available up to v0.280.\n" "Would you like to download and use this version for VGM recording?", QMessageBox.Yes | QMessageBox.No) if res == QMessageBox.Yes: self.download_vgm_mod(mame_bin_dir) else: # Uncheck if user said no self.vgm_check.setChecked(False) self.update_and_preview() def download_vgm_mod(self, dest_dir): # reuse existing progress dialog or create new dialog = QDialog(self) dialog.setWindowTitle("Downloading VGM Mod") dialog.setFixedSize(400, 150) self.apply_premium_theme() # refresh styles layout = QVBoxLayout(dialog) label = QLabel("Initializing download...") layout.addWidget(label) pbar = QProgressBar() layout.addWidget(pbar) status = QLabel("") layout.addWidget(status) worker = VgmModDownloadWorker(dest_dir) worker.progress.connect(lambda d, t: pbar.setValue(int(d*100/t)) if t>0 else None) worker.status.connect(label.setText) worker.finished.connect(lambda s, p: self.on_vgm_dl_finished(worker, s, p, label, dialog)) self.active_workers.append(worker) worker.start() dialog.exec() def on_vgm_dl_finished(self, worker, success, path, label, dialog): if worker in self.active_workers: self.active_workers.remove(worker) if success: QMessageBox.information(self, "Success", "MAME VGM Mod has been installed as mame-vgm") dialog.accept() else: QMessageBox.critical(self, "Error", f"Failed to download VGM Mod: {path}") self.vgm_check.setChecked(False) dialog.reject() def load_persistent_settings(self): """Restore window geometry and splitter state.""" geom = self.settings.value("geometry") if geom: self.restoreGeometry(geom) else: self.resize(1100, 800) splitter_state = self.settings.value("splitterState") if splitter_state: self.splitter.restoreState(splitter_state) # Restore last selected machine last_machine = self.settings.value("lastMachine") if last_machine: item = self.find_item_by_value(self.machine_tree.invisibleRootItem(), last_machine) if item: self.machine_tree.setCurrentItem(item) self.on_machine_selected(item) # Expand to show the selection parent = item.parent() while parent: parent.setExpanded(True) parent = parent.parent() def find_item_by_value(self, parent_item, value): for i in range(parent_item.childCount()): child = parent_item.child(i) if child.data(0, Qt.UserRole) == value: return child res = self.find_item_by_value(child, value) if res: return res return None def moveEvent(self, event): if hasattr(self, 'sw_popup'): self.sw_popup.hide() super().moveEvent(event) def eventFilter(self, obj, event): # 1. 偵測滑鼠點擊主視窗其他地方或外部時,關閉軟體清單 if event.type() == QEvent.MouseButtonPress: if hasattr(self, 'sw_popup') and self.sw_popup.isVisible(): # 取得全域點擊位置 gp = event.globalPos() # 判斷點擊是否在搜尋框或彈出視窗之外 if not self.sw_search.rect().contains(self.sw_search.mapFromGlobal(gp)) and \ not self.sw_popup.rect().contains(self.sw_popup.mapFromGlobal(gp)): self.sw_popup.hide() # 2. 當主視窗失去焦點(例如 Alt-Tab 切換到其他 App)時,隱藏軟體清單 elif event.type() == QEvent.WindowDeactivate: if hasattr(self, 'sw_popup') and self.sw_popup: self.sw_popup.hide() return super().eventFilter(obj, event) def resizeEvent(self, event): if hasattr(self, 'sw_popup'): self.sw_popup.hide() super().resizeEvent(event) def closeEvent(self, event: QCloseEvent): """Save settings before exiting.""" if hasattr(self, 'sw_popup'): self.sw_popup.close() self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("splitterState", self.splitter.saveState()) if self.selected_machine: self.settings.setValue("lastMachine", self.selected_machine) # Clean up threads gracefully for worker in self.active_workers[:]: worker.requestInterruption() if not worker.wait(500): # Don't block forever if download is stuck worker.terminate() worker.wait() event.accept() def apply_premium_theme(self): is_dark = self.is_dark_mode() # Color Palette bg_main = "#1e1e1e" if is_dark else "#f5f5f7" bg_panel = "#1a1a1a" if is_dark else "#ffffff" bg_right = "#2b2b2b" if is_dark else "#f0f0f2" bg_toolbar = "#2d2d2d" if is_dark else "#e5e5e7" bg_tab_pane = "#222" if is_dark else "#ffffff" bg_tab_unselected = "#333" if is_dark else "#e0e0e0" text_primary = "#eeeeee" if is_dark else "#1a1a1a" text_secondary = "#bbbbbb" if is_dark else "#444444" text_dim = "#888888" if is_dark else "#777777" text_tree = "#cccccc" if is_dark else "#222222" border_color = "#3d3d3d" if is_dark else "#d1d1d1" input_bg = "#2d2d2d" if is_dark else "#ffffff" accent = "#0078d4" hover_bg = "#3d3d3d" if is_dark else "#e0e0e0" self.setStyleSheet(f""" * {{ font-family: 'Inter', 'Inter Display', 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft JhengHei', sans-serif; }} QMainWindow {{ background-color: {bg_main}; }} #Toolbar {{ background-color: {bg_toolbar}; border-bottom: 1px solid {border_color}; }} #ToolbarButton {{ background-color: transparent; border: none; color: {text_secondary}; padding: 8px 15px; font-size: 13px; font-weight: bold; border-radius: 4px; }} #ToolbarButton:hover {{ background-color: {hover_bg}; color: {"white" if is_dark else "#000"}; }} #LeftPanel {{ background-color: {bg_panel}; border-right: 1px solid {border_color}; }} #SearchInput {{ background-color: {input_bg}; border: 1px solid {border_color}; border-radius: 5px; padding: 6px 10px; color: {text_primary}; margin-bottom: 5px; }} #CommandPreview {{ background-color: {"#000" if is_dark else "#eee"}; border: none; border-top: 1px solid {border_color}; color: {text_primary if is_dark else "#333"}; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 11px; padding: 2px 10px; }} #MachineTree {{ background-color: transparent; border: none; color: {text_tree}; font-size: 13px; show-decoration-selected: 1; }} #MachineTree::item {{ padding: 5px; }} #MachineTree::item:selected {{ background-color: {accent}; color: white; border-radius: 4px; }} #MachineTree::item:hover:!selected {{ background-color: {hover_bg}; }} #RightPanel {{ background-color: {bg_right}; }} #SmallLabel {{ color: {text_dim}; font-size: 10px; font-weight: bold; margin-top: 5px; }} #SmallDimLabel {{ color: {text_dim}; font-size: 10px; }} #SlotLabel {{ color: {text_secondary}; font-size: 11px; }} #MediaHeader {{ color: {text_primary}; font-size: 11px; font-weight: bold; }} #MediaHandle {{ color: {text_dim}; font-size: 14px; }} #MediaEdit {{ background-color: transparent; border: 1px solid {border_color}; color: {text_secondary}; font-size: 10px; }} #EjectButton {{ background-color: transparent; color: {text_dim}; border: none; font-size: 12px; }} #EjectButton:hover {{ color: {text_primary}; }} QTabWidget {{ background-color: transparent; }} QTabWidget::pane {{ border: 1px solid {border_color}; background-color: {bg_tab_pane}; border-radius: 4px; }} QTabBar::tab {{ background-color: {bg_tab_unselected}; color: {text_dim}; padding: 4px 12px; font-size: 11px; border: 1px solid {border_color}; margin-right: 1px; }} QTabBar::tab:selected {{ background-color: {accent}; color: white; }} QTabBar::tab:hover:!selected {{ background-color: {hover_bg}; }} QDialog, QMessageBox {{ background-color: {bg_main}; color: {text_secondary}; }} QMessageBox QLabel {{ color: {text_secondary}; }} QMessageBox QPushButton {{ background-color: {hover_bg}; color: {text_primary}; padding: 5px 15px; border-radius: 3px; min-width: 70px; }} QMessageBox QPushButton:hover {{ background-color: {accent}; color: white; }} QLabel {{ color: {text_secondary}; font-size: 11px; letter-spacing: 0.2px; }} QCheckBox, QRadioButton {{ color: {text_secondary}; font-size: 11px; spacing: 5px; }} QCheckBox::indicator, QRadioButton::indicator {{ width: 14px; height: 14px; background-color: {input_bg}; border: 1px solid {border_color}; border-radius: 3px; }} QCheckBox::indicator:checked {{ background-color: {accent}; border-color: {accent}; }} QRadioButton::indicator {{ border-radius: 7px; }} QRadioButton::indicator:checked {{ background-color: {accent}; border-color: {accent}; }} QComboBox {{ background-color: {input_bg}; border: 1px solid {border_color}; border-radius: 4px; padding: 2px 8px; color: {text_primary}; font-size: 11px; }} QComboBox QAbstractItemView {{ background-color: {bg_panel}; color: {text_tree}; selection-background-color: {accent}; selection-color: white; border: 1px solid {border_color}; outline: none; }} QComboBox[appleStyle="slot"] {{ background-color: {input_bg if is_dark else "#fff"}; border: 1px solid {border_color}; border-radius: 4px; padding: 1px 4px; color: {text_primary}; font-size: 11px; }} QComboBox[appleStyle="slot"]::drop-down {{ border: none; background-color: #3b7ee1; width: 16px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; }} QComboBox[appleStyle="slot"]::down-arrow {{ image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid white; margin-top: 2px; }} QPushButton#LaunchButton {{ background-color: #f39c12; color: white; border: none; border-radius: 4px; padding: 0; }} QPushButton#LaunchButton:hover {{ background-color: #f79c2a; }} #SoftwareSearch {{ background-color: {input_bg}; border: 1px solid {border_color}; color: {text_primary}; padding-left: 8px; border-radius: 4px; }} QScrollBar:vertical {{ background: {bg_panel}; width: 10px; margin: 0px; }} QScrollBar::handle:vertical {{ background: {hover_bg}; min-height: 20px; border-radius: 5px; margin: 2px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; }} """) def resource_path(relative_path): """ Get absolute path to resource, works for dev and for PyInstaller """ try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) if __name__ == "__main__": # OS X: separate process for GUI; unnecessary on Linux but good practice # multiprocessing.freeze_support() app = QApplication(sys.argv) # Linux: Set explicit app name for Wayland/GNOME grouping app.setDesktopFileName("AmpleLinux") # Set Window Icon (Linux requires this even if PyInstaller has --icon) icon_path = resource_path("ample.png") if os.path.exists(icon_path): app.setWindowIcon(QIcon(icon_path)) else: print(f"Warning: Icon not found at {icon_path}") window = AmpleMainWindow() window.show() sys.exit(app.exec())