643 lines
28 KiB
Python
643 lines
28 KiB
Python
"""Main window: filters + sound table (browser) on top, single-sound editor below."""
|
|
import os
|
|
|
|
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer
|
|
from PySide6.QtGui import QFont, QKeySequence, QShortcut
|
|
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
|
|
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
|
|
QPushButton, QScrollArea, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget)
|
|
|
|
from ackit import settings as settings_mod, workspace as W
|
|
from ackit.catalog import available_archives
|
|
from ackit.game import Game, GameError, default_game_dir
|
|
from ackit.modpack import export_mod, match_mod, prepare_import
|
|
from . import icons, theme
|
|
from .audioio import Player, find_device, output_devices
|
|
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
|
|
from .editor import EditorPanel
|
|
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
|
|
from .spinner import Spinner
|
|
from .userdata import UserData
|
|
from .workers import Runner
|
|
|
|
SIDEBAR_MIN, SIDEBAR_MAX = 220, 320
|
|
TABLE_MIN = 160 # the sound list never gets smaller than this when fitting the editor
|
|
|
|
ANY = "Any"
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self, settings=None, runner=None, game_dir=None):
|
|
super().__init__()
|
|
self.setWindowTitle("Unheard City")
|
|
self.settings = settings if settings is not None else settings_mod.load()
|
|
self.runner = runner or Runner()
|
|
self.player = Player(self)
|
|
self.player.error.connect(lambda m: self.statusBar().showMessage(m, 5000))
|
|
self.userdata = UserData()
|
|
self.ws = None
|
|
self.loading = False
|
|
self._build()
|
|
self._apply_output_device()
|
|
geo = self.settings.get("window_geometry")
|
|
if geo:
|
|
self.restoreGeometry(QByteArray.fromHex(geo.encode()))
|
|
else:
|
|
self.resize(1400, 900)
|
|
gd = game_dir or default_game_dir(self.settings)
|
|
if gd:
|
|
QTimer.singleShot(0, lambda: self.load_game(gd, self.settings.get("language")))
|
|
else:
|
|
self.show_no_game()
|
|
|
|
# --- UI ---------------------------------------------------------------------------------------
|
|
def _build(self):
|
|
self.setWindowIcon(icons.app_icon())
|
|
tb = QToolBar("Main")
|
|
tb.setMovable(False)
|
|
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
|
tb.setIconSize(QSize(18, 18))
|
|
self.addToolBar(tb)
|
|
logo = QLabel()
|
|
logo.setPixmap(icons.app_icon().pixmap(28, 28))
|
|
tb.addWidget(logo)
|
|
title = QLabel("UNHEARD CITY")
|
|
title.setObjectName("AppTitle")
|
|
f = title.font()
|
|
f.setBold(True)
|
|
f.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.6)
|
|
title.setFont(f)
|
|
tb.addWidget(title)
|
|
tb.addSeparator()
|
|
self.cmb_lang = QComboBox()
|
|
self.cmb_lang.setToolTip("Speech language archive to browse")
|
|
self.cmb_lang.activated.connect(lambda _: self.load_game(self.ws.game.root, self.cmb_lang.currentText())
|
|
if self.ws else None)
|
|
tb.addWidget(QLabel(" Language "))
|
|
tb.addWidget(self.cmb_lang)
|
|
tb.addSeparator()
|
|
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
|
|
self.act_revert_all.setToolTip("Put every original sound back")
|
|
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
|
|
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
|
|
tb.addSeparator()
|
|
tb.addAction(icons.icon("settings"), "Settings…", self.settings_dialog)
|
|
|
|
# banner for problems (changed files, missing game)
|
|
self.banner = QWidget()
|
|
self.banner.setObjectName("Banner")
|
|
self.banner.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
bl = QHBoxLayout(self.banner)
|
|
bl.setContentsMargins(8, 4, 8, 4)
|
|
warn = QLabel()
|
|
warn.setPixmap(icons.icon("warning", icons.ACCENT).pixmap(20, 20))
|
|
bl.addWidget(warn, 0, Qt.AlignmentFlag.AlignTop)
|
|
self.banner_text = QLabel()
|
|
self.banner_text.setWordWrap(True)
|
|
self.b_reapply = QPushButton("Re-apply my changes")
|
|
self.b_forget = QPushButton("Forget them")
|
|
self.b_choose = QPushButton("Choose game folder…")
|
|
self.b_reapply.clicked.connect(self.reapply_lost)
|
|
self.b_forget.clicked.connect(self.forget_problems)
|
|
self.b_choose.clicked.connect(self.choose_game)
|
|
bl.addWidget(self.banner_text, 1)
|
|
for b in (self.b_reapply, self.b_forget, self.b_choose):
|
|
bl.addWidget(b)
|
|
self.banner.hide()
|
|
|
|
# filters
|
|
side = theme.Sidebar()
|
|
side.configure(self.settings)
|
|
self.sidebar = side
|
|
side.setMinimumWidth(SIDEBAR_MIN)
|
|
side.setMaximumWidth(SIDEBAR_MAX) # opens at the maximum (wallpaper fully visible); can only shrink
|
|
fl = QFormLayout(side)
|
|
self.search = QLineEdit()
|
|
self.search.setPlaceholderText("Search names, characters, notes… (Ctrl+F)")
|
|
self.search.setClearButtonEnabled(True)
|
|
self.search.addAction(icons.icon("search", icons.MUTED), QLineEdit.ActionPosition.LeadingPosition)
|
|
self._search_timer = QTimer(self)
|
|
self._search_timer.setSingleShot(True)
|
|
self._search_timer.setInterval(120)
|
|
self._search_timer.timeout.connect(lambda: self._filter_text())
|
|
self.search.textChanged.connect(lambda _: self._search_timer.start())
|
|
self.cmb_group = QComboBox()
|
|
self.cmb_cat = QComboBox()
|
|
self.cmb_char = QComboBox()
|
|
self.cmb_char.setMaxVisibleItems(25)
|
|
self.cb_inferred = QCheckBox("Include characters mentioned in names")
|
|
self.cmb_arch = QComboBox()
|
|
self.cb_mod = QCheckBox("Modified only")
|
|
self.cb_fav = QCheckBox("Favorites only")
|
|
self.cmb_tag = QComboBox()
|
|
clear = QPushButton("Clear filters")
|
|
clear.setIcon(icons.icon("revert", icons.MUTED))
|
|
clear.clicked.connect(self.clear_filters)
|
|
fl.addRow(self.search)
|
|
fl.addRow("Group", self.cmb_group)
|
|
fl.addRow("Category", self.cmb_cat)
|
|
fl.addRow("Character", self.cmb_char)
|
|
fl.addRow(self.cb_inferred)
|
|
fl.addRow("Archive", self.cmb_arch)
|
|
fl.addRow("Tag", self.cmb_tag)
|
|
fl.addRow(self.cb_mod)
|
|
fl.addRow(self.cb_fav)
|
|
fl.addRow(clear)
|
|
self.lbl_count = QLabel()
|
|
fl.addRow(self.lbl_count)
|
|
self.cmb_group.currentTextChanged.connect(self._group_changed)
|
|
for w in (self.cmb_cat, self.cmb_arch, self.cmb_tag):
|
|
w.currentTextChanged.connect(lambda _: self._filters_changed())
|
|
self.cmb_char.currentTextChanged.connect(lambda _: self._filters_changed())
|
|
for w in (self.cb_inferred, self.cb_mod, self.cb_fav):
|
|
w.toggled.connect(lambda _: self._filters_changed())
|
|
|
|
# table
|
|
self.model = SoundModel(self)
|
|
self.proxy = SoundFilter(self)
|
|
self.proxy.setSourceModel(self.model)
|
|
self.table = QTableView()
|
|
self.table.setModel(self.proxy)
|
|
self.table.setSortingEnabled(True)
|
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.table.verticalHeader().hide()
|
|
self.table.verticalHeader().setDefaultSectionSize(22)
|
|
self.table.setAlternatingRowColors(True)
|
|
self.table.setWordWrap(False)
|
|
h = self.table.horizontalHeader()
|
|
h.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
h.setStretchLastSection(True)
|
|
for c, w in zip(range(len(COLS)), (28, 360, 140, 200, 70, 55, 110, 75, 70, 200)):
|
|
self.table.setColumnWidth(c, w)
|
|
self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
|
|
self.table.selectionModel().currentRowChanged.connect(self._current_changed)
|
|
self.table.clicked.connect(self._clicked)
|
|
self.table.doubleClicked.connect(lambda _: self.editor.play("orig"))
|
|
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.table.customContextMenuRequested.connect(self._context_menu)
|
|
|
|
# editor
|
|
self.editor = EditorPanel(self)
|
|
self.editor.applied.connect(self._after_change)
|
|
self.editor.reverted.connect(self._after_change)
|
|
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
|
|
scroll = QScrollArea()
|
|
scroll.setWidget(self.editor)
|
|
scroll.setWidgetResizable(True)
|
|
|
|
self.vsplit = QSplitter(Qt.Orientation.Vertical)
|
|
self.vsplit.addWidget(self.table)
|
|
self.vsplit.addWidget(scroll)
|
|
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list; the editor keeps its fitted height
|
|
self.vsplit.setStretchFactor(1, 0)
|
|
self.scroll = scroll
|
|
self._fitted = False
|
|
self._user_sized = False # once the divider is dragged, keep the user's choice
|
|
self.vsplit.splitterMoved.connect(lambda *_: setattr(self, "_user_sized", True))
|
|
self.editor.installEventFilter(self)
|
|
hs = QSplitter(Qt.Orientation.Horizontal)
|
|
hs.addWidget(side)
|
|
hs.addWidget(self.vsplit)
|
|
hs.setStretchFactor(1, 1)
|
|
hs.setSizes([SIDEBAR_MAX, 100000])
|
|
central = QWidget()
|
|
cl = QVBoxLayout(central)
|
|
cl.setContentsMargins(0, 0, 0, 0)
|
|
cl.setSpacing(0)
|
|
cl.addWidget(self.banner)
|
|
cl.addWidget(hs, 1)
|
|
self.setCentralWidget(central)
|
|
self.spinner = Spinner(14)
|
|
self.statusBar().addWidget(self.spinner)
|
|
self.lbl_game = QLabel()
|
|
self.statusBar().addPermanentWidget(self.lbl_game)
|
|
|
|
sc = Qt.ShortcutContext.WindowShortcut
|
|
QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc)
|
|
QShortcut(QKeySequence("Ctrl+S"), self, lambda: self.editor.apply(), context=sc)
|
|
QShortcut(QKeySequence("Ctrl+O"), self, lambda: self.editor.load_file_dialog(), context=sc)
|
|
QShortcut(QKeySequence("Escape"), self, self.editor.stop, context=sc)
|
|
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
|
|
context=Qt.ShortcutContext.WidgetShortcut)
|
|
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=Qt.ShortcutContext.WidgetShortcut)
|
|
|
|
# --- dialogs (overridable in tests) -----------------------------------------------------------
|
|
def confirm(self, title, text):
|
|
return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes
|
|
|
|
def inform(self, title, text):
|
|
QMessageBox.information(self, title, text)
|
|
|
|
# --- game loading -----------------------------------------------------------------------------
|
|
def show_no_game(self, msg=None):
|
|
self.banner_text.setText(msg or "Batman: Arkham City was not found automatically. Choose the game folder "
|
|
"(it contains BmGame and Binaries; on Linux usually ~/.steam/steam/steamapps/"
|
|
"common/Batman Arkham City GOTY).")
|
|
self.b_reapply.hide()
|
|
self.b_forget.hide()
|
|
self.b_choose.show()
|
|
self.banner.show()
|
|
|
|
def choose_game(self):
|
|
d = pick_game_dir(self, self.settings.get("game_dir") or "")
|
|
if d:
|
|
self.load_game(d)
|
|
|
|
def load_game(self, game_dir, language=None):
|
|
try:
|
|
game = Game(game_dir)
|
|
except GameError as e:
|
|
self.show_no_game(str(e))
|
|
return
|
|
lang = language if language in game.languages else game.language()
|
|
self.loading = True
|
|
self.statusBar().showMessage("Loading sound list…")
|
|
self.spinner.show()
|
|
store = self.settings.get("store_root")
|
|
full = self.settings.get("full_backup", True)
|
|
|
|
def work(job):
|
|
avail = available_archives(game)
|
|
return W.Workspace(game, store, [a for a in (lang, "SFX", "BANK") if a in avail], full_backup=full,
|
|
progress=lambda n, t: job.report("scanning game audio: %d / %d" % (n, t)))
|
|
|
|
def done(ws):
|
|
self.loading = False
|
|
self.spinner.hide()
|
|
self.ws = ws
|
|
self.settings["game_dir"] = game.root
|
|
self.settings["language"] = lang
|
|
self.save_settings()
|
|
self.cmb_lang.blockSignals(True)
|
|
self.cmb_lang.clear()
|
|
self.cmb_lang.addItems(game.languages)
|
|
self.cmb_lang.setCurrentText(lang)
|
|
self.cmb_lang.blockSignals(False)
|
|
self.populate()
|
|
self.banner.hide()
|
|
self.lbl_game.setText("%s • store: %s" % (game.root, ws.store.root))
|
|
self.statusBar().showMessage("Loaded %d sounds (%s)." % (len(ws.catalog.sounds), ", ".join(ws.catalog.archives)), 6000)
|
|
self.editor.refresh_devices()
|
|
self.check_problems()
|
|
|
|
def failed(e, tb):
|
|
self.loading = False
|
|
self.spinner.hide()
|
|
self.show_no_game("Could not load the game audio: %s" % e)
|
|
|
|
self.runner.start(work, done, failed, lambda m: self.statusBar().showMessage(m))
|
|
|
|
def populate(self):
|
|
ws = self.ws
|
|
self.model.reset(ws.catalog.sounds, ws.modified_keys(), self.userdata)
|
|
snd = ws.catalog.sounds
|
|
|
|
def fill(cmb, items):
|
|
cur = cmb.currentText()
|
|
cmb.blockSignals(True)
|
|
cmb.clear()
|
|
cmb.addItems([ANY] + items)
|
|
i = cmb.findText(cur)
|
|
cmb.setCurrentIndex(i if i >= 0 else 0)
|
|
cmb.blockSignals(False)
|
|
|
|
fill(self.cmb_group, [g for g in ("Speech", "Music", "Ambience", "SFX", "Unknown") if any(s.group == g for s in snd)])
|
|
self._fill_categories()
|
|
chars = sorted({c.strip() for s in snd for c in s.character.split(";") if c.strip()})
|
|
fill(self.cmb_char, chars)
|
|
fill(self.cmb_arch, list(ws.catalog.archives))
|
|
fill(self.cmb_tag, self.userdata.all_tags())
|
|
self._filters_changed()
|
|
|
|
def _fill_categories(self):
|
|
g = self.cmb_group.currentText()
|
|
cats = sorted({s.category for s in (self.ws.catalog.sounds if self.ws else []) if g == ANY or s.group == g})
|
|
cur = self.cmb_cat.currentText()
|
|
self.cmb_cat.blockSignals(True)
|
|
self.cmb_cat.clear()
|
|
self.cmb_cat.addItems([ANY] + cats)
|
|
i = self.cmb_cat.findText(cur)
|
|
self.cmb_cat.setCurrentIndex(i if i >= 0 else 0)
|
|
self.cmb_cat.blockSignals(False)
|
|
|
|
# --- filtering --------------------------------------------------------------------------------
|
|
def _val(self, cmb):
|
|
t = cmb.currentText().strip()
|
|
return None if not t or t == ANY else t
|
|
|
|
def _group_changed(self, _):
|
|
self._fill_categories()
|
|
self._filters_changed()
|
|
|
|
def _filters_changed(self):
|
|
self.proxy.set(group=self._val(self.cmb_group), category=self._val(self.cmb_cat),
|
|
character=self._val(self.cmb_char), include_inferred=self.cb_inferred.isChecked(),
|
|
archive=self._val(self.cmb_arch), modified_only=self.cb_mod.isChecked(),
|
|
favorites_only=self.cb_fav.isChecked(), tag=self._val(self.cmb_tag))
|
|
self._update_count()
|
|
|
|
def _filter_text(self):
|
|
self.proxy.set_text(self.search.text())
|
|
self._update_count()
|
|
|
|
def _update_count(self):
|
|
self.lbl_count.setText("%d of %d sounds" % (self.proxy.rowCount(), self.model.rowCount()))
|
|
|
|
def clear_filters(self):
|
|
self.search.clear()
|
|
for c in (self.cmb_group, self.cmb_cat, self.cmb_char, self.cmb_arch, self.cmb_tag):
|
|
c.setCurrentIndex(0)
|
|
for c in (self.cb_inferred, self.cb_mod, self.cb_fav):
|
|
c.setChecked(False)
|
|
self._filter_text()
|
|
|
|
# --- selection --------------------------------------------------------------------------------
|
|
def selected_sounds(self):
|
|
rows = self.table.selectionModel().selectedRows()
|
|
return [self.proxy.sound_at(i) for i in rows]
|
|
|
|
def _current_changed(self, cur, prev):
|
|
if cur.isValid():
|
|
self.editor.set_sound(self.proxy.sound_at(cur))
|
|
|
|
def _clicked(self, index):
|
|
if index.column() == C_FAV:
|
|
s = self.proxy.sound_at(index)
|
|
self.userdata.update(s.key, fav=not self.userdata.favorite(s.key))
|
|
self.model.refresh_keys()
|
|
|
|
def select_name(self, name):
|
|
"""Select a sound by name (used by tests and 'jump to')."""
|
|
for r in range(self.proxy.rowCount()):
|
|
idx = self.proxy.index(r, C_NAME)
|
|
if self.proxy.sound_at(idx).name == name:
|
|
self.table.selectionModel().setCurrentIndex(
|
|
idx, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
|
|
self.table.scrollTo(idx)
|
|
return True
|
|
return False
|
|
|
|
def _context_menu(self, pos):
|
|
sel = self.selected_sounds()
|
|
if not sel:
|
|
return
|
|
m = QMenu(self)
|
|
m.addAction("Play original", lambda: self.editor.play("orig"))
|
|
m.addAction("Notes && tags… (F2)", self.edit_notes)
|
|
m.addAction("Toggle favorite", lambda: self._toggle_fav(sel))
|
|
m.addSeparator()
|
|
m.addAction("Revert selected (%d)" % len(sel), lambda: self.revert_sounds(sel))
|
|
m.addAction("Copy name", lambda: self._copy("\n".join(s.name for s in sel)))
|
|
m.exec(self.table.viewport().mapToGlobal(pos))
|
|
|
|
def _copy(self, text):
|
|
from PySide6.QtWidgets import QApplication
|
|
QApplication.clipboard().setText(text)
|
|
|
|
def _toggle_fav(self, sounds):
|
|
on = not all(self.userdata.favorite(s.key) for s in sounds)
|
|
for s in sounds:
|
|
self.userdata.update(s.key, fav=on)
|
|
self.model.refresh_keys()
|
|
|
|
def edit_notes(self, sounds=None, note=None, tags=None):
|
|
sounds = sounds or self.selected_sounds()
|
|
if not sounds:
|
|
return
|
|
s0 = sounds[0]
|
|
if note is None:
|
|
dlg = QDialog(self)
|
|
dlg.setWindowTitle("Notes & tags - %s" % (s0.name if len(sounds) == 1 else "%d sounds" % len(sounds)))
|
|
f = QFormLayout(dlg)
|
|
en = QLineEdit(self.userdata.note(s0.key))
|
|
et = QLineEdit(", ".join(self.userdata.tags(s0.key)))
|
|
et.setPlaceholderText("comma separated, e.g. funny, todo")
|
|
f.addRow("Note", en)
|
|
f.addRow("Tags", et)
|
|
from PySide6.QtWidgets import QDialogButtonBox
|
|
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
bb.accepted.connect(dlg.accept)
|
|
bb.rejected.connect(dlg.reject)
|
|
f.addRow(bb)
|
|
dlg.resize(460, 0)
|
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
note, tags = en.text(), [t for t in et.text().split(",")]
|
|
for s in sounds:
|
|
self.userdata.update(s.key, note=note, tags=tags)
|
|
self.model.refresh_keys()
|
|
cur = self.cmb_tag.currentText()
|
|
self.cmb_tag.blockSignals(True)
|
|
self.cmb_tag.clear()
|
|
self.cmb_tag.addItems([ANY] + self.userdata.all_tags())
|
|
self.cmb_tag.setCurrentText(cur)
|
|
self.cmb_tag.blockSignals(False)
|
|
|
|
# --- changes ----------------------------------------------------------------------------------
|
|
def _after_change(self, key=None):
|
|
self.model.refresh_keys(modified=self.ws.modified_keys())
|
|
self.proxy.refilter()
|
|
self._update_count()
|
|
|
|
def revert_sounds(self, sounds):
|
|
sounds = [s for s in sounds if self.ws.store.get(s.key)]
|
|
if not sounds:
|
|
self.statusBar().showMessage("Nothing to revert: the selected sounds are original.", 5000)
|
|
return
|
|
self._run_change("Reverting %d sound(s)…" % len(sounds), lambda job: self.ws.revert(sounds, job.report),
|
|
"Reverted %d sound(s)." % len(sounds))
|
|
|
|
def revert_all(self):
|
|
if not self.ws:
|
|
return
|
|
n = len(self.ws.store.entries)
|
|
if not n:
|
|
self.inform("Revert all", "All sounds are already original.")
|
|
return
|
|
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s)?" % n):
|
|
return
|
|
self._run_change("Reverting everything…", lambda job: self.ws.revert_all(job.report),
|
|
"All sounds are original again.")
|
|
|
|
def _run_change(self, busy, fn, ok_msg):
|
|
self.statusBar().showMessage(busy)
|
|
self.spinner.show()
|
|
|
|
def done(_):
|
|
self.spinner.hide()
|
|
self.statusBar().showMessage(ok_msg, 8000)
|
|
self._after_change()
|
|
if self.editor.sound:
|
|
self.editor.set_sound(self.editor.sound)
|
|
self.check_problems()
|
|
|
|
def failed(e, tb):
|
|
self.spinner.hide()
|
|
self.inform("Error", str(e))
|
|
|
|
self.runner.start(fn, done, failed, lambda m: self.statusBar().showMessage(m))
|
|
|
|
# --- changed underneath -----------------------------------------------------------------------
|
|
def check_problems(self):
|
|
ws = self.ws
|
|
if not ws:
|
|
return
|
|
|
|
def work(job):
|
|
out = []
|
|
for key in list(ws.store.entries):
|
|
s = ws.catalog.by_key.get(key)
|
|
if s is None:
|
|
continue
|
|
st = ws.state(s)
|
|
if st in (W.LOST, W.CHANGED):
|
|
out.append((s, st))
|
|
return out
|
|
|
|
def done(problems):
|
|
self.problems = problems
|
|
if not problems:
|
|
if self.banner.isVisible() and not self.b_choose.isVisible():
|
|
self.banner.hide()
|
|
return
|
|
lost = [s.name for s, st in problems if st == W.LOST]
|
|
changed = [s.name for s, st in problems if st == W.CHANGED]
|
|
txt = []
|
|
if lost:
|
|
txt.append("%d of your changes were undone outside this editor (Steam 'verify files' or an update?): %s"
|
|
% (len(lost), ", ".join(lost[:4]) + (" …" if len(lost) > 4 else "")))
|
|
if changed:
|
|
txt.append("%d sound(s) were changed by something else since you modified them: %s. "
|
|
"Revert puts the original back." % (len(changed), ", ".join(changed[:4])))
|
|
self.banner_text.setText("\n".join(txt))
|
|
self.b_reapply.show()
|
|
self.b_forget.show()
|
|
self.b_choose.hide()
|
|
self.banner.show()
|
|
|
|
self.runner.start(work, done)
|
|
|
|
def reapply_lost(self):
|
|
ws = self.ws
|
|
items = [(s, ws.store.get_blob(ws.store.get(s.key)["applied"]), ws.store.get(s.key).get("meta", {}))
|
|
for s, st in getattr(self, "problems", []) if ws.store.get(s.key) and ws.store.get(s.key).get("applied")]
|
|
if items:
|
|
self._run_change("Re-applying…", lambda job: ws.apply(items, force=True, progress=job.report),
|
|
"Re-applied %d change(s)." % len(items))
|
|
|
|
def forget_problems(self):
|
|
if not self.confirm("Forget", "Stop tracking these sounds? Their current game files are kept as they are."):
|
|
return
|
|
for s, _ in getattr(self, "problems", []):
|
|
self.ws.store.forget(s.key)
|
|
self.ws.store.save()
|
|
self.ws.store.gc()
|
|
self.banner.hide()
|
|
self._after_change()
|
|
|
|
# --- mods -------------------------------------------------------------------------------------
|
|
def export_mod_dialog(self, path=None, keys=None, title="", author="", description=""):
|
|
if not self.ws:
|
|
return
|
|
mods = self.ws.modified_keys()
|
|
if not mods:
|
|
self.inform("Export mod", "You have not changed any sound yet.")
|
|
return
|
|
if keys is None:
|
|
names = [(k, self.ws.catalog.by_key[k].name if k in self.ws.catalog.by_key else k) for k in mods]
|
|
dlg = ExportModDialog(names, self)
|
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
keys, title, author, description = (dlg.selected_keys(), dlg.title.text(), dlg.author.text(),
|
|
dlg.desc.toPlainText())
|
|
path, _ = QFileDialog.getSaveFileName(self, "Save mod package", os.path.join(
|
|
self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)")
|
|
if not path:
|
|
return
|
|
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description),
|
|
"Saved %s" % path)
|
|
|
|
def import_mod_dialog(self, path=None, confirm=True):
|
|
if not self.ws:
|
|
return
|
|
if path is None:
|
|
path, _ = QFileDialog.getOpenFileName(self, "Install mod package", self.settings.get("last_dir") or "",
|
|
"Unheard City mod (*.acmod)")
|
|
if not path:
|
|
return
|
|
try:
|
|
man, items = match_mod(self.ws, path)
|
|
except Exception as e: # noqa: BLE001
|
|
self.inform("Import mod", "Cannot read this package: %s" % e)
|
|
return
|
|
if confirm and ImportModDialog(man, items, self).exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
ws = self.ws
|
|
|
|
def work(job):
|
|
prepare_import(ws, path, items, progress=job.report)
|
|
changes = [(im.sound, im.wem, {"source": "mod:" + os.path.basename(path)}) for im in items if im.wem]
|
|
try:
|
|
ws.apply(changes, progress=job.report)
|
|
except W.ConflictError:
|
|
ws.apply(changes, force=True, progress=job.report)
|
|
return len(changes)
|
|
|
|
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path))
|
|
|
|
# --- settings ---------------------------------------------------------------------------------
|
|
def settings_dialog(self):
|
|
dlg = SettingsDialog(self.settings, self)
|
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
old = dict(self.settings)
|
|
self.settings.update(dlg.values())
|
|
self.save_settings()
|
|
self._apply_output_device()
|
|
self.sidebar.configure(self.settings)
|
|
if (self.settings.get("game_dir") != old.get("game_dir") or self.settings.get("store_root") != old.get("store_root")
|
|
or self.settings.get("full_backup") != old.get("full_backup")):
|
|
if self.settings.get("game_dir"):
|
|
self.load_game(self.settings["game_dir"], self.settings.get("language"))
|
|
|
|
def _apply_output_device(self):
|
|
name = self.settings.get("output_device")
|
|
self.player.set_device(find_device(output_devices(), name) if name else None)
|
|
|
|
def save_settings(self):
|
|
self.settings["window_geometry"] = bytes(self.saveGeometry().toHex()).decode()
|
|
try:
|
|
settings_mod.save(self.settings)
|
|
except OSError:
|
|
pass
|
|
|
|
def fit_editor(self):
|
|
"""Give the editor panel the height of its contents (everything visible), the list gets the rest."""
|
|
total = sum(self.vsplit.sizes()) or self.vsplit.height()
|
|
lay = self.editor.layout()
|
|
need = self.editor.sizeHint().height()
|
|
if lay.hasHeightForWidth(): # wrapped labels: height at the actual width
|
|
need = max(need, lay.heightForWidth(self.scroll.viewport().width()))
|
|
need += 2 * self.scroll.frameWidth()
|
|
if self.editor.minimumSizeHint().width() > self.scroll.viewport().width(): # narrow window: room for the h-bar
|
|
need += self.scroll.horizontalScrollBar().sizeHint().height()
|
|
editor_h = min(need, max(0, total - TABLE_MIN))
|
|
self.vsplit.setSizes([total - editor_h, editor_h])
|
|
|
|
def eventFilter(self, obj, e):
|
|
if obj is self.editor and e.type() == QEvent.Type.LayoutRequest and self._fitted and not self._user_sized:
|
|
QTimer.singleShot(0, self.fit_editor)
|
|
return super().eventFilter(obj, e)
|
|
|
|
def showEvent(self, e):
|
|
super().showEvent(e)
|
|
if not self._fitted:
|
|
self._fitted = True
|
|
QTimer.singleShot(0, self.fit_editor)
|
|
|
|
def closeEvent(self, e):
|
|
self.player.stop()
|
|
self.save_settings()
|
|
super().closeEvent(e)
|