754 lines
34 KiB
Python
754 lines
34 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 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()
|
|
voices = lang if lang in game.languages else ("INT" if "INT" in game.languages else game.languages[0])
|
|
return lang, voices
|
|
|
|
|
|
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 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)
|
|
|
|
# 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()
|
|
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.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.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())
|
|
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()
|
|
self._texts_changed()
|
|
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))
|
|
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
|
|
for s, _ in getattr(self, "problems", []):
|
|
self.ws.store.forget(s.key)
|
|
for k, _ in getattr(self, "text_problems", []):
|
|
self.ws.texts.records().pop(k, None)
|
|
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()
|
|
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
|
|
|
|
def work(job):
|
|
prepare_import(ws, path, items, progress=job.report)
|
|
source = {"source": "mod:" + os.path.basename(path)}
|
|
changes = [(im.sound, im.wem, source) for im in items if im.wem]
|
|
if changes:
|
|
try:
|
|
ws.apply(changes, progress=job.report)
|
|
except W.ConflictError:
|
|
ws.apply(changes, force=True, progress=job.report)
|
|
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
|
|
if texts:
|
|
try:
|
|
ws.texts.apply(texts, progress=job.report, meta=source)
|
|
except TextConflictError:
|
|
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
|
|
return len(changes) + len(texts)
|
|
|
|
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.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)
|