Dark theme, scan auto-ingest, packaging prep, and README refresh
This commit is contained in:
@@ -3,15 +3,16 @@ the name is terrible, i know.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as
|
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
|
||||||
`.iso` files. It scans your discs, verifies them against the full Redump catalog
|
UMD **games** and **videos** as `.iso` files. It scans your discs, verifies them
|
||||||
(Redump DATs), reports what's missing, and retags/renames discs to a canonical
|
against the Redump/No-Intro catalogs, reports what's missing, and retags/renames
|
||||||
scheme. It also fetches cover art and metadata, syncs discs and save-data with a
|
discs to a canonical scheme. It fetches cover art and metadata, syncs discs and
|
||||||
real PSP, can pull missing discs from your own Redump 7z archive collection, and
|
save-data with a real PSP, can pull missing discs from a Redump 7z archive
|
||||||
converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
collection, downloads PSN content from NoPayStation, and converts PlayStation 1
|
||||||
|
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||||
|
|
||||||

|

|
||||||
* Use default app theme for Qt.
|
* Dark theme with a blue accent — the Library tab in cover view. *
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -35,9 +36,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux + Python 3
|
- Python 3.10+ on Linux or Windows
|
||||||
- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`)
|
- `PySide6`, `curl_cffi`, `pycryptodome` (`pip install -r requirements.txt`)
|
||||||
- `7z` (p7zip) — used to peek/extract disc images from store archives
|
- `7z` (p7zip / 7-Zip) — used to peek/extract disc images from store archives
|
||||||
- PPSSPP (optional) — to launch discs from the details panel
|
- PPSSPP (optional) — to launch discs from the details panel
|
||||||
|
|
||||||
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
|
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).
|
Redump lacks — keeps serial-based matching working).
|
||||||
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
|
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
|
||||||
no UMD Video DAT).
|
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
|
```bash
|
||||||
python3 -m app.ingest
|
python3 -m app.ingest
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open the GUI and point **Settings → General → Games/Videos directory** at
|
3. Point **Settings → General → Games/Videos directory** at your disc folders
|
||||||
your disc folders (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
|
(or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
|
||||||
4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to
|
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
|
`data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1
|
||||||
directory**, **PS1 output directory** and **PS1 base.pbp** in
|
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
|
- `data/ps1_templates/` — optional PS1 Classics art templates
|
||||||
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
||||||
to drawn art when they're absent.
|
to drawn art when they're absent.
|
||||||
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop
|
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
|
||||||
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline).
|
a packaged build stores them under the state dir instead).
|
||||||
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
|
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
|
||||||
only as an optional fallback decryptor; the app normally uses its own
|
only as an optional fallback decryptor; the app normally uses its own
|
||||||
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
|
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
|
||||||
See `data/bin/ATTRIBUTION.md`.
|
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.
|
- `library.sqlite` — the catalog + library database.
|
||||||
- `covers/` — downloaded cover cache.
|
- `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
|
SQLite is kept on a local disk because SQLite locking is unreliable over
|
||||||
network filesystems; the DATs, manual covers and credentials stay in `data/`
|
network filesystems. Read-only resources (DATs, the POPS loader, icons) are
|
||||||
(which may be a network share).
|
bundled with the app; user-writable data lives in the state dir.
|
||||||
|
|
||||||
## Configuration
|
## 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_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL |
|
||||||
| `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) |
|
| `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) |
|
||||||
| `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
|
| `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
|
||||||
| `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file |
|
| `PSP_USER_DATA_DIR` | `<state>/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_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) |
|
| `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
|
`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`).
|
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
|
The **Store** tab downloads missing discs from a Redump 7z archive collection
|
||||||
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
|
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
|
||||||
|
|||||||
+28
-3
@@ -1,8 +1,20 @@
|
|||||||
"""Central configuration, driven by environment variables."""
|
"""Central configuration, driven by environment variables."""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
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")))
|
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")))
|
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).
|
"""Local state directory (fast, reliable local disk).
|
||||||
|
|
||||||
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
|
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
|
||||||
library DB and the cover cache live on local storage while DATs, manual
|
library DB and the cover cache live on local storage while read-only DATs
|
||||||
covers and credentials stay in ``DATA_DIR`` (which may be a network share).
|
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")
|
xdg = os.environ.get("XDG_STATE_HOME")
|
||||||
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
|
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
|
||||||
return base / "psp-preserve"
|
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())))
|
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")))
|
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")))
|
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_DB_PATH = DATA_DIR / "library.sqlite"
|
||||||
LEGACY_COVERS_DIR = DATA_DIR / "covers"
|
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.
|
# 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"))
|
STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/media/downloads/Sony - PlayStation Portable"))
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ _IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
|
|||||||
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
||||||
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
||||||
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
|
_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"
|
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
|
||||||
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-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)
|
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):
|
def _ps1_template_bytes(name):
|
||||||
|
|||||||
+25
-18
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
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
|
from .db import get_connection, init_db
|
||||||
|
|
||||||
log = logging.getLogger("gui")
|
log = logging.getLogger("gui")
|
||||||
@@ -622,7 +622,7 @@ class GameDetailsPanel(QWidget):
|
|||||||
|
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.layout = QVBoxLayout(content)
|
self.layout = QVBoxLayout(content)
|
||||||
self.layout.setContentsMargins(6, 6, 6, 6)
|
self.layout.setContentsMargins(6, 6, 16, 6)
|
||||||
self.layout.setSpacing(6)
|
self.layout.setSpacing(6)
|
||||||
scroll.setWidget(content)
|
scroll.setWidget(content)
|
||||||
|
|
||||||
@@ -681,32 +681,28 @@ class GameDetailsPanel(QWidget):
|
|||||||
self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP")
|
self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP")
|
||||||
self.layout.addWidget(self.convert_btn)
|
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 = QPushButton("Scrape…")
|
||||||
self.link_btn.clicked.connect(self.link_requested.emit)
|
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 = QPushButton("Edit details…")
|
||||||
self.edit_btn.clicked.connect(self.edit_requested.emit)
|
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 = QPushButton("Copy to PSP")
|
||||||
self.copy_btn.clicked.connect(self.copy_requested.emit)
|
self.copy_btn.clicked.connect(self.copy_requested.emit)
|
||||||
self.copy_btn.setEnabled(False)
|
self.copy_btn.setEnabled(False)
|
||||||
self.copy_btn.setToolTip("Copy this disc to the connected PSP")
|
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 = QPushButton("Delete")
|
||||||
self.delete_btn.clicked.connect(self.delete_requested.emit)
|
self.delete_btn.clicked.connect(self.delete_requested.emit)
|
||||||
self.delete_btn.setStyleSheet("color: #e06c6c;")
|
self.delete_btn.setStyleSheet("color: #e06c6c;")
|
||||||
self.delete_btn.setToolTip("Permanently delete this disc from disk")
|
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 = QPushButton("Delete from PSP")
|
||||||
self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit)
|
self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit)
|
||||||
self.del_psp_btn.setEnabled(False)
|
self.del_psp_btn.setEnabled(False)
|
||||||
self.del_psp_btn.setStyleSheet("color: #e06c6c;")
|
self.del_psp_btn.setStyleSheet("color: #e06c6c;")
|
||||||
self.del_psp_btn.setToolTip("Delete this disc from the connected PSP")
|
self.del_psp_btn.setToolTip("Delete this disc from the connected PSP")
|
||||||
btn_grid.addWidget(self.del_psp_btn, 2, 0)
|
self.layout.addWidget(self.del_psp_btn)
|
||||||
self.layout.addLayout(btn_grid)
|
|
||||||
|
|
||||||
self.layout.addStretch(1)
|
self.layout.addStretch(1)
|
||||||
|
|
||||||
@@ -1207,7 +1203,7 @@ class SettingsPage(QWidget):
|
|||||||
|
|
||||||
hint = QLabel(
|
hint = QLabel(
|
||||||
"Manual UMD covers: drop images into "
|
"Manual UMD covers: drop images into "
|
||||||
f"<tt>{config.DATA_DIR / 'manual_covers'}</tt> "
|
f"<tt>{config.USER_DATA_DIR / 'manual_covers'}</tt> "
|
||||||
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
|
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
|
||||||
)
|
)
|
||||||
hint.setWordWrap(True)
|
hint.setWordWrap(True)
|
||||||
@@ -1450,7 +1446,7 @@ class StoreDetailsPanel(QWidget):
|
|||||||
outer.addWidget(scroll)
|
outer.addWidget(scroll)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.layout = QVBoxLayout(content)
|
self.layout = QVBoxLayout(content)
|
||||||
self.layout.setContentsMargins(6, 6, 6, 6)
|
self.layout.setContentsMargins(6, 6, 16, 6)
|
||||||
self.layout.setSpacing(6)
|
self.layout.setSpacing(6)
|
||||||
scroll.setWidget(content)
|
scroll.setWidget(content)
|
||||||
|
|
||||||
@@ -2503,8 +2499,10 @@ class QueuePage(QWidget):
|
|||||||
self.table = QTableWidget(0, 7)
|
self.table = QTableWidget(0, 7)
|
||||||
self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""])
|
self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""])
|
||||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.Fixed)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeToContents)
|
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.Fixed)
|
||||||
|
self.table.setColumnWidth(5, 80)
|
||||||
|
self.table.setColumnWidth(6, 34)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
layout.addWidget(self.table)
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
@@ -2531,11 +2529,14 @@ class QueuePage(QWidget):
|
|||||||
self.table.setCellWidget(row, 3, bar)
|
self.table.setCellWidget(row, 3, bar)
|
||||||
self.table.setItem(row, 4, QTableWidgetItem(job["dest"]))
|
self.table.setItem(row, 4, QTableWidgetItem(job["dest"]))
|
||||||
cancel = QPushButton("Cancel")
|
cancel = QPushButton("Cancel")
|
||||||
|
cancel.setFixedHeight(22)
|
||||||
|
cancel.setStyleSheet("padding: 0px 8px;")
|
||||||
cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid))
|
cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid))
|
||||||
self.table.setCellWidget(row, 5, cancel)
|
self.table.setCellWidget(row, 5, cancel)
|
||||||
delete = QPushButton("✕")
|
delete = QPushButton("✕")
|
||||||
delete.setToolTip("Remove this entry from the queue history")
|
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))
|
delete.clicked.connect(lambda _=False, jid=job["id"]: self.queue.remove(jid))
|
||||||
self.table.setCellWidget(row, 6, delete)
|
self.table.setCellWidget(row, 6, delete)
|
||||||
self._rows[job["id"]] = row
|
self._rows[job["id"]] = row
|
||||||
@@ -4092,7 +4093,7 @@ class MainWindow(QMainWindow):
|
|||||||
layout.setContentsMargins(8, 8, 0, 8)
|
layout.setContentsMargins(8, 8, 0, 8)
|
||||||
|
|
||||||
self.tabs = QTabWidget()
|
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)
|
layout.addWidget(self.tabs)
|
||||||
self.queue = queue.TransferQueue(self)
|
self.queue = queue.TransferQueue(self)
|
||||||
self.queue.job_finished.connect(self._on_queue_job_done)
|
self.queue.job_finished.connect(self._on_queue_job_done)
|
||||||
@@ -4175,7 +4176,7 @@ class MainWindow(QMainWindow):
|
|||||||
splitter = QSplitter(Qt.Horizontal)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
self.library_subtabs = QTabWidget()
|
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_library_view(), "Library")
|
||||||
self.library_subtabs.addTab(self._build_scan_view(), "Scan")
|
self.library_subtabs.addTab(self._build_scan_view(), "Scan")
|
||||||
splitter.addWidget(self.library_subtabs)
|
splitter.addWidget(self.library_subtabs)
|
||||||
@@ -5310,8 +5311,14 @@ def _start_bundle_check(window):
|
|||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
|
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
|
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)
|
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
style.apply_theme(app)
|
||||||
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
||||||
if icon_path.is_file():
|
if icon_path.is_file():
|
||||||
app.setWindowIcon(QIcon(str(icon_path)))
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
|||||||
@@ -88,6 +88,42 @@ def ingest_dat(conn, path):
|
|||||||
return dat_name, system, release_count, rom_count
|
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):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
|
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
|
||||||
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
|
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
|
||||||
|
|||||||
+4
-2
@@ -48,8 +48,10 @@ def launch_ppsspp(iso, exe=""):
|
|||||||
else:
|
else:
|
||||||
return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
|
return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(cmd, start_new_session=True, env=_env(),
|
kwargs = {"env": _env(), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
||||||
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, ""
|
return True, ""
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return False, str(exc)
|
return False, str(exc)
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ def tsv_url(content_type: str, base: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _tsv_dir() -> Path:
|
def _tsv_dir() -> Path:
|
||||||
d = config.DATA_DIR / "nps"
|
d = config.USER_DATA_DIR / "nps"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -40,6 +40,7 @@ import io
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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
|
# (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
|
# the base PBP's encrypted ~PSP loader which frequently fails to link on
|
||||||
# 6.60/6.61 (error 80020148 "game could not be started").
|
# 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_DATA_PSP = _LOADER_DIR / "DATA.PSP"
|
||||||
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
|
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
CREDENTIALS_PATH = Path(
|
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 = [
|
FIELDS = [
|
||||||
|
|||||||
+313
@@ -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)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 799 KiB After Width: | Height: | Size: 740 KiB |
Reference in New Issue
Block a user