Files
UnheardCity/app/mainwindow.py
Cristian Gora 1ccaa8b398 Full correctness check against the docs: safer writes, reverts and texts
Safety
- Writes are serialized (Workspace.lock): two background jobs rebuilding one
  package (a sound and its subtitle applied side by side) lost an edit.
- A store copies a bank file or package as "original" only if it matches the
  shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of
  voice lines, subtitles); the editor refuses to switch stores while changes
  are applied. A second store could otherwise resurrect a reverted sound.
- Mod import in the editor asks before overwriting files changed outside the
  tool (it forced silently).
- Revert all in the editor reverts texts too; the uninstaller counts text
  edits before offering to delete the store.
- ackit restore-backup: the full-backup restore decisions.md described; it
  keeps the sounds of WADs without a full copy recorded.
- build_catalog also stops when a subtitle differs from the catalog.

Languages and texts
- Game.language() returns languages without voices (JPN, RUS, ...) and
  Game.voice_archive() their INT voices; ackit text --lang JPN works.
- Menu lookups parse one cached file (find & replace: minutes -> ms).
- Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line
  Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte;
  SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed).
- Mods exported while another language is loaded keep name and line.

Other fixes
- Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped).
- e2e_editor no longer overwrites the user's settings.json; kill_game waits
  for the game to exit before reverts.
- Recorder handles Int32/UInt8 input and split frames; package block cache
  locked; conflict messages, CLI help examples, stale docstrings and doc
  references fixed; tests no longer read this PC's game language.

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
2026-10-13 21:34:13 +02:00

775 lines
36 KiB
Python

"""Main window. Tabs: Sounds (filters + sound table on top, single-sound editor below, with the sound's
subtitle), Subtitles and Menu text (app/textpanel.py)."""
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, QTabWidget, 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, match_texts, prepare_import
from ackit.texts import TextConflictError
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 BusyOverlay, Spinner
from .textpanel import TextPanel
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"
def all_languages(ws):
"""Every language the editor offers: speech WADs + subtitle and menu text languages."""
return sorted(set(ws.game.languages) | set(ws.texts.menu_languages()) | set(ws.texts.subtitle_languages()))
def editor_languages(game, language=None):
"""(language, voices): the selected language (default: the game's) and the speech archive used for it
(the language itself, or INT for languages the game has no voices for)."""
lang = (language or game.language() or "INT").upper()
return lang, game.voice_archive(lang)
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.language = None # the editor's language (voices, subtitles, menu text)
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)) # always starts in the game's 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("The one language setting of the editor: the voice lines in the Sounds tab, the "
"subtitles and the menu text.\nLanguages without their own voices (e.g. JPN, RUS) "
"use the English (INT) voice lines, like the game.")
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)
self.lbl_voices = QLabel()
self.lbl_voices.setObjectName("Muted")
tb.addWidget(self.lbl_voices)
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 and text back (all languages)")
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_busy = BusyOverlay(self.table) # while the sound list loads (start, language change)
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)
# sidebar | sound list on top, the editor below across the whole width
hs = QSplitter(Qt.Orientation.Horizontal)
hs.addWidget(side)
hs.addWidget(self.table)
hs.setStretchFactor(1, 1)
hs.setSizes([SIDEBAR_MAX, 100000])
self.vsplit = QSplitter(Qt.Orientation.Vertical)
self.vsplit.addWidget(hs)
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)
# sections: sounds, subtitles, menu text
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
self.tabs.addTab(self.vsplit, icons.icon("wave"), "Sounds")
self.subs = TextPanel(self, "subs")
self.menu = TextPanel(self, "menu")
self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles")
self.tabs.addTab(self.menu, icons.icon("settings"), "Menu text")
self._text_loaded = set()
for panel in (self.subs, self.menu):
panel.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
panel.changed.connect(self._texts_changed)
self.subs.go_to_sound.connect(self.show_sound)
self.editor.subtitle.changed.connect(self._texts_changed)
self.editor.subtitle.open_in_list.connect(self.show_subtitle)
self.tabs.currentChanged.connect(self._tab_changed)
central = QWidget()
cl = QVBoxLayout(central)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
cl.addWidget(self.banner)
cl.addWidget(self.tabs, 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, voices = editor_languages(game, language)
self.loading = True
self.statusBar().showMessage("Loading sound list…")
self.spinner.show()
self.table_busy.start("Loading sounds…")
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 (voices, "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.table_busy.stop()
self.ws = ws
self.settings["game_dir"] = game.root
self.save_settings()
self.language = lang
self.cmb_lang.blockSignals(True)
self.cmb_lang.clear()
self.cmb_lang.addItems(all_languages(ws))
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
self.lbl_voices.setText("" if voices == lang else " voices: %s (no %s voices in the game)" % (voices, lang))
self.populate()
self._text_loaded.clear()
self._tab_changed(self.tabs.currentIndex())
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.table_busy.stop()
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)
# --- text sections --------------------------------------------------------------------------
def _tab_changed(self, i):
panel = self.tabs.widget(i)
if self.ws and panel in (self.subs, self.menu) and panel.kind not in self._text_loaded:
self._text_loaded.add(panel.kind)
panel.load()
def _texts_changed(self):
for panel in (self.subs, self.menu):
if panel.kind in self._text_loaded:
panel.refresh()
self.editor.subtitle.set_sound(self.editor.sound)
def show_sound(self, key):
"""Sounds tab with `key` selected (from a subtitle)."""
s = self.ws.catalog.by_key.get(key) if self.ws else None
if not s:
self.inform("Sound", "%s is not in the sounds loaded now (choose its language at the top)." % key)
return
self.tabs.setCurrentIndex(0)
self.clear_filters()
self.select_name(s.name)
def show_subtitle(self, key):
"""Subtitles tab with `key` selected (from the sound editor)."""
self.tabs.setCurrentWidget(self.subs)
self.subs.search.clear()
self.subs.cb_mod.setChecked(False)
for cb in self.subs.filters.values():
cb.setCurrentIndex(0)
self.subs._filter()
self.subs.select_key(key)
# --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys()) # the view refilters/re-sorts itself
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):
"""Every change of this store: sounds, subtitles and menu texts (every language)."""
if not self.ws:
return
n, t = len(self.ws.store.entries), len(self.ws.texts.records())
if not n and not t:
self.inform("Revert all", "All sounds and texts are already original.")
return
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s) and %d changed "
"text(s)?" % (n, t)):
return
def work(job):
self.ws.revert_all(job.report)
self.ws.texts.revert_all(job.report)
self._run_change("Reverting everything…", work, "All sounds and texts are original again.")
def _run_change(self, busy, fn, ok_msg, ask_force=False):
"""Run a change as a background job. ask_force: fn(job, force); when files were changed outside this
tool, ask the user and run it again with force=True."""
self.statusBar().showMessage(busy)
self.spinner.show()
def done(_):
self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000)
self._refresh_after_change()
self.check_problems()
def failed(e, tb):
self.spinner.hide()
if self.ws:
self._refresh_after_change() # part of the change may have been written
if ask_force and isinstance(e, (W.ConflictError, TextConflictError)):
if self.confirm("Changed outside this tool", "%s\n\nOverwrite them?" % e):
self._run_change(busy, lambda job: fn(job, True), ok_msg)
return
self.inform("Error", str(e))
self.runner.start((lambda job: fn(job, False)) if ask_force else fn, done, failed,
lambda m: self.statusBar().showMessage(m))
def _refresh_after_change(self):
self._after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
# --- 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))
texts = []
for key in list(ws.texts.records()):
try:
st = ws.texts.state(key)
except (GameError, OSError, ValueError):
continue
if st in (W.LOST, W.CHANGED):
texts.append((key, st))
return out, texts
def done(res):
problems, texts = res
self.problems, self.text_problems = problems, texts
if not problems and not texts:
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])))
if texts:
txt.append("%d of your text changes were undone or changed outside this editor: %s"
% (len(texts), ", ".join(k for k, _ in texts[:3]) + (" …" if len(texts) > 3 else "")))
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")]
texts = {k: ws.texts.records()[k]["applied"] for k, st in getattr(self, "text_problems", [])
if k in ws.texts.records() and ws.texts.records()[k].get("applied") is not None}
def work(job):
if items:
ws.apply(items, force=True, progress=job.report)
if texts:
ws.texts.apply(texts, force=True, progress=job.report)
if items or texts:
self._run_change("Re-applying…", work, "Re-applied %d change(s)." % (len(items) + len(texts)))
def forget_problems(self):
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
return
self.ws.forget([s.key for s, _ in getattr(self, "problems", [])],
[k for k, _ in getattr(self, "text_problems", [])])
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()
texts = self.ws.texts.modified()
if not mods and not texts:
self.inform("Export mod", "You have not changed any sound or text 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]
names += [(k, "Text " + k) for k in texts]
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
text_keys = [k for k in keys if k.startswith(("SUB:", "MENU:"))]
keys = [k for k in keys if k not in text_keys]
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description,
texts=text_keys), "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
dlg = ImportModDialog(man, items, self, match_texts(self.ws, man)) if confirm else None
if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
return
ws = self.ws
prepared = []
def work(job, force):
if not prepared: # encode once (a confirmed overwrite runs this again)
prepare_import(ws, path, items, progress=job.report)
prepared.append(True)
source = {"source": "mod:" + os.path.basename(path)}
changes = [(im.sound, im.wem, source) for im in items if im.wem]
if changes:
ws.apply(changes, force=force, progress=job.report)
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
if texts:
ws.texts.apply(texts, force=force, progress=job.report, meta=source)
return len(changes) + len(texts)
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path), ask_force=True)
# --- settings ---------------------------------------------------------------------------------
def settings_dialog(self):
dlg = SettingsDialog(self.settings, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
old = dict(self.settings)
new = dlg.values()
if self.ws and (new.get("store_root") or None) != (old.get("store_root") or None) and (
self.ws.store.entries or self.ws.texts.records()):
# the changes are recorded in the current store only; another store would take the modified files
# as originals (and could not revert these changes)
self.inform("Settings", "The originals store keeps the originals of the changes you have applied. "
"Use Revert all before choosing another store folder; the store was not "
"changed.")
new["store_root"] = old.get("store_root")
self.settings.update(new)
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.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)