Initial commit
This commit is contained in:
640
app/mainwindow.py
Normal file
640
app/mainwindow.py
Normal file
@@ -0,0 +1,640 @@
|
||||
"""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.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):
|
||||
return W.Workspace(game, store, [lang, "SFX"] if "SFX" in game.archives else [lang], full_backup=full,
|
||||
progress=lambda n, t: job.report("scanning %s: %d / %d" % (lang, 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)
|
||||
Reference in New Issue
Block a user