diff --git a/README.md b/README.md index d542c6f..f90e493 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ the name is terrible, i know. ![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png) -Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as -`.iso` files. It scans your discs, verifies them against the full Redump catalog -(Redump DATs), reports what's missing, and retags/renames discs to a canonical -scheme. It also fetches cover art and metadata, syncs discs and save-data with a -real PSP, can pull missing discs from your own Redump 7z archive collection, and -converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. +A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP +UMD **games** and **videos** as `.iso` files. It scans your discs, verifies them +against the Redump/No-Intro catalogs, reports what's missing, and retags/renames +discs to a canonical scheme. It fetches cover art and metadata, syncs discs and +save-data with a real PSP, can pull missing discs from a Redump 7z archive +collection, downloads PSN content from NoPayStation, and converts PlayStation 1 +ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. ![PSP Preservation Tool](img/qt-gui.png) -* Use default app theme for Qt. +* Dark theme with a blue accent — the Library tab in cover view. * ## Features @@ -35,9 +36,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. ## Requirements -- Linux + Python 3 -- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`) -- `7z` (p7zip) — used to peek/extract disc images from store archives +- Python 3.10+ on Linux or Windows +- `PySide6`, `curl_cffi`, `pycryptodome` (`pip install -r requirements.txt`) +- `7z` (p7zip / 7-Zip) — used to peek/extract disc images from store archives - PPSSPP (optional) — to launch discs from the details panel NoPayStation `.pkg` files are decrypted by the bundled **pure-Python @@ -66,14 +67,15 @@ python3 -m app.main Redump lacks — keeps serial-based matching working). - videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has no UMD Video DAT). -2. Ingest them into the local SQLite library: +2. Start the app — any DATs missing from the library are ingested automatically + at startup (no manual step). You can also re-ingest on demand: ```bash python3 -m app.ingest ``` -3. Open the GUI and point **Settings → General → Games/Videos directory** at - your disc folders (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`). +3. Point **Settings → General → Games/Videos directory** at your disc folders + (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`). 4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to `data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1 directory**, **PS1 output directory** and **PS1 base.pbp** in @@ -105,19 +107,22 @@ Discs are matched by hash first (most precise), then serial, then filename. - `data/ps1_templates/` — optional PS1 Classics art templates (`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back to drawn art when they're absent. -- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop - `PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline). +- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev; + a packaged build stores them under the state dir instead). - `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept only as an optional fallback decryptor; the app normally uses its own pure-Python decryptor (`app/pkg.py`) and never needs Wine. See `data/bin/ATTRIBUTION.md`. -- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): +- State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve` + on Windows, or `$PSP_STATE_DIR`): - `library.sqlite` — the catalog + library database. - `covers/` — downloaded cover cache. + - `data/` — user-writable data in packaged builds (credentials, manual + covers, PS1 templates, NPS TSV cache). SQLite is kept on a local disk because SQLite locking is unreliable over -network filesystems; the DATs, manual covers and credentials stay in `data/` -(which may be a network share). +network filesystems. Read-only resources (DATs, the POPS loader, icons) are +bundled with the app; user-writable data lives in the state dir. ## Configuration @@ -146,7 +151,8 @@ QSettings under `PSPPreserve`) taking precedence where noted. | `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL | | `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) | | `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) | -| `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file | +| `PSP_USER_DATA_DIR` | `/data` (packaged) / `./data` (dev) | user-writable data (credentials, manual covers, PS1 templates, NPS cache) | +| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file | | `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) | | `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) | @@ -235,7 +241,7 @@ Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc PBP); disc data is DEFLATE-compressed (level 1 by default, `--complevel 0-9`). -## Store — archive.org and local folder +## Store — four sources The **Store** tab downloads missing discs from a Redump 7z archive collection (`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three diff --git a/app/config.py b/app/config.py index 850297e..cdf6c6a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,20 @@ """Central configuration, driven by environment variables.""" import os +import sys from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parent.parent +# PyInstaller sets ``sys.frozen`` and unpacks bundled files to ``sys._MEIPASS``. +# Read-only resources (DATs, loader, icons) then live in the bundle; otherwise +# they live in the project checkout. +IS_FROZEN = getattr(sys, "frozen", False) +_BUNDLE_DIR = getattr(sys, "_MEIPASS", None) + +PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent + + +def resource_path(*parts): + """Absolute path to a read-only bundled resource (works when frozen).""" + return PROJECT_ROOT.joinpath(*parts) DATA_DIR = Path(os.environ.get("PSP_DATA_DIR", str(PROJECT_ROOT / "data"))) DATS_DIR = Path(os.environ.get("PSP_DATS_DIR", str(DATA_DIR / "dats"))) @@ -12,9 +24,12 @@ def _default_state_dir(): """Local state directory (fast, reliable local disk). SQLite locking is unreliable over network filesystems (SMB/FUSE), so the - library DB and the cover cache live on local storage while DATs, manual - covers and credentials stay in ``DATA_DIR`` (which may be a network share). + library DB and the cover cache live on local storage while read-only DATs + stay bundled with the app. """ + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home()) + return Path(base) / "PSPPreserve" xdg = os.environ.get("XDG_STATE_HOME") base = Path(xdg) if xdg else Path.home() / ".local" / "state" return base / "psp-preserve" @@ -23,9 +38,19 @@ def _default_state_dir(): STATE_DIR = Path(os.environ.get("PSP_STATE_DIR", str(_default_state_dir()))) DB_PATH = Path(os.environ.get("PSP_DB_PATH", str(STATE_DIR / "library.sqlite"))) COVERS_DIR = Path(os.environ.get("PSP_COVERS_DIR", str(STATE_DIR / "covers"))) +# User-writable data (credentials, manual covers, PS1 templates, NPS TSV cache). +# In dev this is the checkout's ``data/``; when frozen the bundle is read-only, +# so it points at local state that survives app upgrades. +USER_DATA_DIR = Path(os.environ.get( + "PSP_USER_DATA_DIR", str(DATA_DIR if not IS_FROZEN else STATE_DIR / "data"))) LEGACY_DB_PATH = DATA_DIR / "library.sqlite" LEGACY_COVERS_DIR = DATA_DIR / "covers" + +def user_data_path(*parts): + """Absolute path to a user-writable data file/folder (works when frozen).""" + return USER_DATA_DIR.joinpath(*parts) + # Redump 7z store (browsable in the Store tab). Env override; GUI setting wins. STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/media/downloads/Sony - PlayStation Portable")) diff --git a/app/covers.py b/app/covers.py index bd7abaa..4c077cd 100644 --- a/app/covers.py +++ b/app/covers.py @@ -52,7 +52,7 @@ _IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/" _TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie" _TMDB_IMAGE = "https://image.tmdb.org/t/p/" _GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1" -_MANUAL_DIR = config.DATA_DIR / "manual_covers" +_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers" _OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip" _LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip" @@ -811,7 +811,7 @@ def compose_game_cover(key_art_bytes): return bytes(ba) -_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates" +_PS1_TEMPLATE_DIR = config.USER_DATA_DIR / "ps1_templates" def _ps1_template_bytes(name): diff --git a/app/gui.py b/app/gui.py index 800a837..8e2ccae 100644 --- a/app/gui.py +++ b/app/gui.py @@ -21,7 +21,7 @@ from PySide6.QtWidgets import ( QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) -from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, theme, transfers +from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, transfers from .db import get_connection, init_db log = logging.getLogger("gui") @@ -622,7 +622,7 @@ class GameDetailsPanel(QWidget): content = QWidget() self.layout = QVBoxLayout(content) - self.layout.setContentsMargins(6, 6, 6, 6) + self.layout.setContentsMargins(6, 6, 16, 6) self.layout.setSpacing(6) scroll.setWidget(content) @@ -681,32 +681,28 @@ class GameDetailsPanel(QWidget): self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP") self.layout.addWidget(self.convert_btn) - btn_grid = QGridLayout() - btn_grid.setHorizontalSpacing(6) - btn_grid.setVerticalSpacing(6) self.link_btn = QPushButton("Scrape…") self.link_btn.clicked.connect(self.link_requested.emit) - btn_grid.addWidget(self.link_btn, 0, 0) + self.layout.addWidget(self.link_btn) self.edit_btn = QPushButton("Edit details…") self.edit_btn.clicked.connect(self.edit_requested.emit) - btn_grid.addWidget(self.edit_btn, 0, 1) + self.layout.addWidget(self.edit_btn) self.copy_btn = QPushButton("Copy to PSP") self.copy_btn.clicked.connect(self.copy_requested.emit) self.copy_btn.setEnabled(False) self.copy_btn.setToolTip("Copy this disc to the connected PSP") - btn_grid.addWidget(self.copy_btn, 1, 0) + self.layout.addWidget(self.copy_btn) self.delete_btn = QPushButton("Delete") self.delete_btn.clicked.connect(self.delete_requested.emit) self.delete_btn.setStyleSheet("color: #e06c6c;") self.delete_btn.setToolTip("Permanently delete this disc from disk") - btn_grid.addWidget(self.delete_btn, 1, 1) + self.layout.addWidget(self.delete_btn) self.del_psp_btn = QPushButton("Delete from PSP") self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit) self.del_psp_btn.setEnabled(False) self.del_psp_btn.setStyleSheet("color: #e06c6c;") self.del_psp_btn.setToolTip("Delete this disc from the connected PSP") - btn_grid.addWidget(self.del_psp_btn, 2, 0) - self.layout.addLayout(btn_grid) + self.layout.addWidget(self.del_psp_btn) self.layout.addStretch(1) @@ -1207,7 +1203,7 @@ class SettingsPage(QWidget): hint = QLabel( "Manual UMD covers: drop images into " - f"{config.DATA_DIR / 'manual_covers'} " + f"{config.USER_DATA_DIR / 'manual_covers'} " "(subfolders games/ and videos/)." ) hint.setWordWrap(True) @@ -1450,7 +1446,7 @@ class StoreDetailsPanel(QWidget): outer.addWidget(scroll) content = QWidget() self.layout = QVBoxLayout(content) - self.layout.setContentsMargins(6, 6, 6, 6) + self.layout.setContentsMargins(6, 6, 16, 6) self.layout.setSpacing(6) scroll.setWidget(content) @@ -2503,8 +2499,10 @@ class QueuePage(QWidget): self.table = QTableWidget(0, 7) self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""]) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents) - self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.Fixed) + self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.Fixed) + self.table.setColumnWidth(5, 80) + self.table.setColumnWidth(6, 34) self.table.verticalHeader().setVisible(False) layout.addWidget(self.table) @@ -2531,11 +2529,14 @@ class QueuePage(QWidget): self.table.setCellWidget(row, 3, bar) self.table.setItem(row, 4, QTableWidgetItem(job["dest"])) cancel = QPushButton("Cancel") + cancel.setFixedHeight(22) + cancel.setStyleSheet("padding: 0px 8px;") cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid)) self.table.setCellWidget(row, 5, cancel) delete = QPushButton("✕") delete.setToolTip("Remove this entry from the queue history") - delete.setFixedWidth(28) + delete.setFixedSize(22, 22) + delete.setStyleSheet("padding: 0px;") delete.clicked.connect(lambda _=False, jid=job["id"]: self.queue.remove(jid)) self.table.setCellWidget(row, 6, delete) self._rows[job["id"]] = row @@ -4092,7 +4093,7 @@ class MainWindow(QMainWindow): layout.setContentsMargins(8, 8, 0, 8) self.tabs = QTabWidget() - self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }") + self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }") layout.addWidget(self.tabs) self.queue = queue.TransferQueue(self) self.queue.job_finished.connect(self._on_queue_job_done) @@ -4175,7 +4176,7 @@ class MainWindow(QMainWindow): splitter = QSplitter(Qt.Horizontal) self.library_subtabs = QTabWidget() - self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }") + self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }") self.library_subtabs.addTab(self._build_library_view(), "Library") self.library_subtabs.addTab(self._build_scan_view(), "Scan") splitter.addWidget(self.library_subtabs) @@ -5310,8 +5311,14 @@ def _start_bundle_check(window): def main(): logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s") init_db() # migrate legacy on-share DB/covers to local storage, then schema + # Auto-ingest any missing DATs so scanning matches against the catalog + # even on a fresh/reset database (no manual `python -m app.ingest` step). + n = ingest.ensure_catalog() + if n: + log.info("ingested %d DAT(s) into the catalog", n) log.info("starting psp-preserve (db: %s)", config.DB_PATH) app = QApplication(sys.argv) + style.apply_theme(app) icon_path = config.PROJECT_ROOT / "img" / "app-icon.png" if icon_path.is_file(): app.setWindowIcon(QIcon(str(icon_path))) diff --git a/app/ingest.py b/app/ingest.py index 847eaa6..ca2078c 100644 --- a/app/ingest.py +++ b/app/ingest.py @@ -88,6 +88,42 @@ def ingest_dat(conn, path): return dat_name, system, release_count, rom_count +def ensure_catalog(dats_dir=None, conn=None): + """Ingest any DAT files missing from the DB (idempotent, cheap when current). + + Called once at startup so a fresh/reset database still has the catalog and + scanning produces matches instead of everything coming back ``unknown``. + Returns the number of DATs ingested. + """ + dats_dir = Path(dats_dir) if dats_dir else config.DATS_DIR + if not dats_dir.is_dir(): + return 0 + files = sorted(p for p in dats_dir.glob("*.dat") if p.is_file()) + if not files: + return 0 + + own = conn is None + if own: + conn = get_connection(config.DB_PATH) + try: + known = {r["source_path"] for r in conn.execute("SELECT source_path FROM dats")} + count = 0 + for path in files: + if str(path) in known: + continue + try: + ingest_dat(conn, path) + except Exception as exc: # noqa: BLE001 - a bad DAT shouldn't block startup + print(f"warning: could not ingest {path.name}: {exc}", file=sys.stderr) + continue + count += 1 + conn.commit() + return count + finally: + if own: + conn.close() + + def main(argv=None): parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite") parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)") diff --git a/app/launcher.py b/app/launcher.py index 0d96f02..1edd2f1 100644 --- a/app/launcher.py +++ b/app/launcher.py @@ -48,8 +48,10 @@ def launch_ppsspp(iso, exe=""): else: return False, "PPSSPP not found. Install it or set its path in Settings → Saves." try: - subprocess.Popen(cmd, start_new_session=True, env=_env(), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + kwargs = {"env": _env(), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if os.name != "nt": + kwargs["start_new_session"] = True # POSIX-only: detach from our process group + subprocess.Popen(cmd, **kwargs) return True, "" except OSError as exc: return False, str(exc) diff --git a/app/nopaystation.py b/app/nopaystation.py index 14e9214..a47a495 100644 --- a/app/nopaystation.py +++ b/app/nopaystation.py @@ -56,7 +56,7 @@ def tsv_url(content_type: str, base: str = "") -> str: def _tsv_dir() -> Path: - d = config.DATA_DIR / "nps" + d = config.USER_DATA_DIR / "nps" d.mkdir(parents=True, exist_ok=True) return d diff --git a/app/ps1_convert.py b/app/ps1_convert.py index ddb0789..4f70f07 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -40,6 +40,7 @@ import io import os import re import struct +import sys import zlib from dataclasses import dataclass, field from pathlib import Path @@ -88,7 +89,9 @@ SYNC_PATTERN = b"\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00" # (ARK/PRO/ME) loads this ELF directly as the DATA.PSP section, sidestepping # the base PBP's encrypted ~PSP loader which frequently fails to link on # 6.60/6.61 (error 80020148 "game could not be started"). -_LOADER_DIR = Path(__file__).resolve().parent.parent / "data" / "loader" +_BUNDLE_DIR = getattr(sys, "_MEIPASS", None) +_APP_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent +_LOADER_DIR = _APP_ROOT / "data" / "loader" BUNDLED_DATA_PSP = _LOADER_DIR / "DATA.PSP" BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN" diff --git a/app/sources.py b/app/sources.py index 9c9513c..5892e81 100644 --- a/app/sources.py +++ b/app/sources.py @@ -11,7 +11,7 @@ from pathlib import Path from . import config CREDENTIALS_PATH = Path( - os.environ.get("PSP_CREDENTIALS", str(config.DATA_DIR / "credentials.json")) + os.environ.get("PSP_CREDENTIALS", str(config.USER_DATA_DIR / "credentials.json")) ) FIELDS = [ diff --git a/app/style.py b/app/style.py new file mode 100644 index 0000000..8676005 --- /dev/null +++ b/app/style.py @@ -0,0 +1,313 @@ +"""Global dark theme for the GUI (Fusion style + palette + stylesheet). + +Applied once at startup from ``gui.main``. The per-widget ``setStyleSheet`` +colors sprinkled through ``gui.py`` (``#999`` secondary text, ``#e06c6c`` delete +buttons, ``#14151a`` preview panels) were written for a dark background, so a +single cohesive dark theme here makes the whole UI consistent. +""" +from PySide6.QtGui import QColor, QPalette + +# Design tokens +BG = "#1e1f24" # window background +SURFACE = "#26272e" # cards, inputs, table headers +RAISED = "#2d2e36" # buttons, combos, menus +BORDER = "#3a3b44" +BORDER_SOFT = "#33343d" +TEXT = "#e6e6ea" +TEXT_DIM = "#9a9ba5" +TEXT_FAINT = "#6f7079" +ACCENT = "#4a9eff" +ACCENT_HOVER = "#6bb0ff" + +_STYLESHEET = f""" +QMainWindow {{ + background: {BG}; +}} +QWidget {{ + color: {TEXT}; +}} + +/* --- Tabs --- */ +QTabWidget::pane {{ + border: 1px solid {BORDER}; + border-radius: 6px; + top: -1px; +}} +QTabBar::tab {{ + background: transparent; + color: {TEXT_DIM}; + padding: 8px 18px; + border: none; + border-bottom: 2px solid transparent; + margin-right: 2px; +}} +QTabBar::tab:hover:!selected {{ + color: {TEXT}; +}} +QTabBar::tab:selected {{ + color: {TEXT}; + border-bottom: 2px solid {ACCENT}; +}} + +/* --- Buttons --- */ +QPushButton {{ + background: {RAISED}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 6px 12px; +}} +QPushButton:hover {{ + background: #343640; + border-color: {ACCENT_HOVER}; +}} +QPushButton:pressed {{ + background: {SURFACE}; +}} +QPushButton:disabled {{ + color: {TEXT_FAINT}; + background: {SURFACE}; + border-color: {BORDER_SOFT}; +}} +QPushButton:checked {{ + background: {ACCENT}; + border-color: {ACCENT}; + color: #101116; +}} + +/* --- Inputs --- */ +QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 5px 8px; + selection-background-color: {ACCENT}; + selection-color: #101116; +}} +QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus, QTextEdit:focus {{ + border-color: {ACCENT}; +}} +QLineEdit:disabled, QSpinBox:disabled {{ + color: {TEXT_FAINT}; +}} + +/* --- Combo boxes --- */ +QComboBox {{ + background: {RAISED}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 4px 10px; +}} +QComboBox:hover {{ + border-color: {ACCENT_HOVER}; +}} +QComboBox::drop-down {{ + border: none; + width: 22px; +}} +QComboBox::down-arrow {{ + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid {TEXT_DIM}; + margin-right: 6px; +}} +QComboBox QAbstractItemView {{ + background: {RAISED}; + border: 1px solid {BORDER}; + border-radius: 6px; + selection-background-color: rgba(74, 158, 255, 0.25); + selection-color: {TEXT}; + outline: 0; +}} + +/* --- Tables --- */ +QTableWidget {{ + background: transparent; + border: none; + gridline-color: {BORDER_SOFT}; +}} +QHeaderView::section {{ + background: {SURFACE}; + color: {TEXT_DIM}; + border: none; + border-bottom: 1px solid {BORDER}; + padding: 6px 8px; +}} +QTableWidget::item {{ + padding: 4px 6px; +}} +QTableWidget::item:selected {{ + background: rgba(74, 158, 255, 0.22); + color: {TEXT}; +}} +QTableCornerButton::section {{ + background: {SURFACE}; + border: none; + border-bottom: 1px solid {BORDER}; +}} + +/* --- Cover grid / lists --- */ +QListWidget {{ + background: transparent; + border: none; +}} +QListWidget::item {{ + border-radius: 6px; +}} +QListWidget::item:hover:!selected {{ + background: rgba(255, 255, 255, 0.04); +}} +QListWidget::item:selected {{ + background: rgba(74, 158, 255, 0.22); +}} + +/* --- Cards (details panels use QScrollArea) --- */ +QScrollArea {{ + background: {SURFACE}; + border: none; +}} + +/* --- Menus --- */ +QMenu {{ + background: {RAISED}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 4px; +}} +QMenu::item {{ + padding: 6px 26px 6px 12px; + border-radius: 4px; +}} +QMenu::item:selected {{ + background: rgba(74, 158, 255, 0.25); +}} +QMenu::separator {{ + height: 1px; + background: {BORDER}; + margin: 4px 8px; +}} + +/* --- Scrollbars --- */ +QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 0; +}} +QScrollBar::handle:vertical {{ + background: {BORDER}; + border-radius: 5px; + min-height: 30px; +}} +QScrollBar::handle:vertical:hover {{ + background: {ACCENT}; +}} +QScrollBar:horizontal {{ + background: transparent; + height: 10px; + margin: 0; +}} +QScrollBar::handle:horizontal {{ + background: {BORDER}; + border-radius: 5px; + min-width: 30px; +}} +QScrollBar::handle:horizontal:hover {{ + background: {ACCENT}; +}} +QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0; + height: 0; +}} +QScrollBar::add-page, QScrollBar::sub-page {{ + background: transparent; +}} + +/* --- Splitter --- */ +QSplitter::handle {{ + background: transparent; +}} +QSplitter::handle:horizontal {{ + width: 1px; +}} +QSplitter::handle:vertical {{ + height: 1px; +}} + +/* --- Misc --- */ +QToolTip {{ + background: {RAISED}; + color: {TEXT}; + border: 1px solid {BORDER}; + padding: 4px 6px; +}} +QStatusBar {{ + background: {SURFACE}; + color: {TEXT_DIM}; + border-top: 1px solid {BORDER}; +}} +QProgressBar {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + border-radius: 6px; + text-align: center; +}} +QProgressBar::chunk {{ + background: {ACCENT}; + border-radius: 5px; +}} +QCheckBox {{ + spacing: 8px; +}} +QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid {BORDER}; + border-radius: 4px; + background: {SURFACE}; +}} +QCheckBox::indicator:checked {{ + background: {ACCENT}; + border-color: {ACCENT}; +}} +QGroupBox {{ + border: 1px solid {BORDER}; + border-radius: 6px; + margin-top: 10px; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: {TEXT_DIM}; +}} +""" + + +def _dark_palette(): + p = QPalette() + p.setColor(QPalette.Window, QColor(BG)) + p.setColor(QPalette.WindowText, QColor(TEXT)) + p.setColor(QPalette.Base, QColor(SURFACE)) + p.setColor(QPalette.AlternateBase, QColor(RAISED)) + p.setColor(QPalette.ToolTipBase, QColor(RAISED)) + p.setColor(QPalette.ToolTipText, QColor(TEXT)) + p.setColor(QPalette.Text, QColor(TEXT)) + p.setColor(QPalette.Button, QColor(RAISED)) + p.setColor(QPalette.ButtonText, QColor(TEXT)) + p.setColor(QPalette.BrightText, QColor("#ff5c5c")) + p.setColor(QPalette.Link, QColor(ACCENT)) + p.setColor(QPalette.Highlight, QColor(ACCENT)) + p.setColor(QPalette.HighlightedText, QColor("#101116")) + p.setColor(QPalette.PlaceholderText, QColor(TEXT_FAINT)) + + faint = QColor(TEXT_FAINT) + for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText): + p.setColor(QPalette.Disabled, role, faint) + return p + + +def apply_theme(app): + """Apply the dark Fusion theme to ``app`` (call once before showing UI).""" + app.setStyle("Fusion") + app.setPalette(_dark_palette()) + app.setStyleSheet(_STYLESHEET) diff --git a/img/qt-gui.png b/img/qt-gui.png index 872625d..0cc6b86 100644 Binary files a/img/qt-gui.png and b/img/qt-gui.png differ