Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

640
app/mainwindow.py Normal file
View 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)