import functools import signal import sys import traceback from contextlib import contextmanager from enum import Enum from pathlib import Path from types import MethodType from typing import Optional, List, Any, Tuple, Callable, Union, Dict, Sequence, NewType import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw import attr from PyQt5.QtCore import QModelIndex, Qt, QVariant from PyQt5.QtGui import QFont, QCloseEvent, QDesktopServices import corrscope import corrscope.settings.global_prefs as gp from corrscope import cli from corrscope.channel import ChannelConfig, DefaultLabel from corrscope.config import CorrError, copy_config, yaml from corrscope.corrscope import CorrScope, Config, Arguments, template_config from corrscope.gui.history_file_dlg import ( get_open_file_name, get_open_file_list, get_save_file_path, ) from corrscope.gui.model_bind import ( PresentationModel, map_gui, behead, rgetattr, rsetattr, Symbol, SymbolText, BoundComboBox, ) from corrscope.gui.util import color2hex, Locked, find_ranges, TracebackDialog from corrscope.gui.view_mainwindow import MainWindow as Ui_MainWindow from corrscope.gui.widgets import ChannelTableView, ShortcutButton from corrscope.layout import Orientation, StereoOrientation from corrscope.outputs import IOutputConfig, FFplayOutputConfig from corrscope.renderer import LabelPosition from corrscope.settings import paths from corrscope.triggers import ( CorrelationTriggerConfig, MainTriggerConfig, SpectrumConfig, ZeroCrossingTriggerConfig, ) from corrscope.util import obj_name, iround, coalesce from corrscope.wave import Flatten FILTER_WAV_FILES = ["WAV files (*.wav)"] APP_NAME = f"{corrscope.app_name} {corrscope.__version__}" APP_DIR = Path(__file__).parent PATH_uri = qc.QUrl.fromLocalFile(paths.PATH_dir) def res(file: str) -> str: return str(APP_DIR / file) @contextmanager def exception_as_dialog(window: qw.QWidget): def excepthook(exc_type, exc_val, exc_tb): TracebackDialog(window).showMessage(format_stack_trace(exc_val)) orig = sys.excepthook try: sys.excepthook = excepthook yield finally: sys.excepthook = orig def gui_main(cfg_or_path: Union[Config, Path]): # Allow Ctrl-C to exit signal.signal(signal.SIGINT, signal.SIG_DFL) # qw.QApplication.setStyle('fusion') QApp = qw.QApplication QApp.setAttribute(qc.Qt.AA_EnableHighDpiScaling) app = qw.QApplication(sys.argv) # On Windows, Qt 5's default system font (MS Shell Dlg 2) is outdated. # Interestingly, the QMenu font is correct and comes from lfMenuFont. # So use it for the entire application. # Qt on Windows will finally switch default font to lfMessageFont=Segoe UI # (Vista, 2006)... in 2020 (Qt 6.0). if qc.QSysInfo.kernelType() == "winnt": QApp.setFont(QApp.font("QMenu")) window = MainWindow(cfg_or_path) # Any exceptions raised within MainWindow() will be caught within exec_. # exception_as_dialog() turns it into a Qt dialog. with exception_as_dialog(window): ret = app.exec_() # Any exceptions raised after exec_ terminates will call # exception_as_dialog().__exit__ before being caught. # This produces a Python traceback. # On Linux, if signal.signal(signal.SIGINT, signal.SIG_DFL) and Ctrl+C pressed, # corrscope closes immediately. # ffmpeg receives SIGPIPE and terminates by itself (according to strace). corr_thread = window.corr_thread if corr_thread is not None: corr_thread.abort_terminate() corr_thread.wait() sys.exit(ret) SafeProperty = NewType("SafeProperty", property) def safe_property(unsafe_getter: Callable, *args, **kwargs) -> SafeProperty: """Prevents (AttributeError from leaking outside a property, which causes hasattr() to return False).""" @functools.wraps(unsafe_getter) def safe_getter(self): try: return unsafe_getter(self) except AttributeError as e: raise RuntimeError(e) from e # NewType("", cls)(x) == x. return SafeProperty(property(safe_getter, *args, **kwargs)) class MainWindow(qw.QMainWindow, Ui_MainWindow): """ Main window. Control flow: __init__: either - load_cfg - load_cfg_from_path Opening a document: - load_cfg_from_path ## Dialog Directory/Filename Generation Save-dialog dir is persistent state, saved across program runs. Most recent of: - Any open/save dialog (unless separate_render_dir is True). - self.pref.file_dir_ref, .set() - Load YAML from CLI. - load_cfg_from_path(cfg_path) sets `self.pref.file_dir`. - Load .wav files from CLI. - if isinstance(cfg_or_path, Config): - save_dir = self.compute_save_dir(self.cfg) - self.pref.file_dir = save_dir (if not empty) Render-dialog dir is persistent state, = most recent render-save dialog. - self.pref.render_dir, .set() Save/render-dialog filename (no dir) is computed on demand, NOT persistent state. - (Currently loaded config path, or master audio, or channel 0) + ext. - Otherwise empty string. - self.get_save_filename() calls cli.get_file_stem(). CLI filename is the same, but defaults to "corrscope.{yaml, mp4}" instead of empty string. - cli._get_file_name() calls cli.get_file_stem(). """ def __init__(self, cfg_or_path: Union[Config, Path]): super().__init__() # Load settings. self.pref = gp.load_prefs() # Load UI. self.setupUi(self) # sets windowTitle # Bind UI buttons, etc. Functions block main thread, avoiding race conditions. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.channelUp.add_shortcut(self.channelsGroup, "ctrl+shift+up") self.channelDown.add_shortcut(self.channelsGroup, "ctrl+shift+down") self.channelUp.clicked.connect(self.channel_view.on_channel_up) self.channelDown.clicked.connect(self.channel_view.on_channel_down) self.channelAdd.clicked.connect(self.on_channel_add) self.channelDelete.clicked.connect(self.on_channel_delete) # Bind actions. self.action_separate_render_dir.setChecked(self.pref.separate_render_dir) self.action_separate_render_dir.toggled.connect( self.on_separate_render_dir_toggled ) self.action_open_config_dir.triggered.connect(self.on_open_config_dir) self.actionNew.triggered.connect(self.on_action_new) self.actionOpen.triggered.connect(self.on_action_open) self.actionSave.triggered.connect(self.on_action_save) self.actionSaveAs.triggered.connect(self.on_action_save_as) self.actionPreview.triggered.connect(self.on_action_preview) self.actionRender.triggered.connect(self.on_action_render) self.actionWebsite.triggered.connect(self.on_action_website) self.actionHelp.triggered.connect(self.on_action_help) self.actionExit.triggered.connect(qw.QApplication.closeAllWindows) # Initialize CorrScope-thread attribute. self.corr_thread: Optional[CorrThread] = None # Setup UI. self.model = ConfigModel(template_config()) self.model.edited.connect(self.on_model_edited) # Calls self.on_gui_edited() whenever GUI widgets change. map_gui(self, self.model) self.model.update_widget["render_stereo"].append(self.on_render_stereo_changed) # Bind config to UI. if isinstance(cfg_or_path, Config): self.load_cfg(cfg_or_path, None) save_dir = self.compute_save_dir(self.cfg) if save_dir: self.pref.file_dir = save_dir elif isinstance(cfg_or_path, Path): self.load_cfg_from_path(cfg_or_path) else: raise TypeError( f"argument cfg={cfg_or_path} has invalid type {obj_name(cfg_or_path)}" ) self.show() _cfg_path: Optional[Path] # Whether document is dirty, changed, has unsaved changes _any_unsaved: bool @property def any_unsaved(self) -> bool: return self._any_unsaved @any_unsaved.setter def any_unsaved(self, value: bool): self._any_unsaved = value self._update_unsaved_title() # Config models model: Optional["ConfigModel"] = None channel_model: "ChannelModel" channel_view: "ChannelTableView" channelsGroup: qw.QGroupBox def on_render_stereo_changed(self): self.layout__stereo_orientation.setEnabled( self.model.cfg.render_stereo is Flatten.Stereo ) # Closing active document def _cancel_render_if_active(self, title: str) -> bool: """ :return: False if user cancels close-document action. """ if self.corr_thread is None: return True Msg = qw.QMessageBox message = self.tr("Cancel current {} and close project?").format( self.preview_or_render ) response = Msg.question(self, title, message, Msg.Yes | Msg.No, Msg.No) if response == Msg.Yes: # Closing ffplay preview (can't cancel render, the dialog is untouchable) # will set self.corr_thread to None while the dialog is active. # https://www.vikingsoftware.com/how-to-use-qthread-properly/ # QObject thread affinity # But since the dialog is modal, # self.corr_thread cannot have been replaced by a different thread. if self.corr_thread is not None: self.corr_thread.abort_terminate() return True return False def _prompt_if_unsaved(self, title: str) -> bool: """ :return: False if user cancels close-document action. """ if not self.any_unsaved: return True Msg = qw.QMessageBox message = f"Save changes to {self.title_cache}?" should_close = Msg.question( self, title, message, Msg.Save | Msg.Discard | Msg.Cancel ) if should_close == Msg.Cancel: return False elif should_close == Msg.Discard: return True else: return self.on_action_save() def should_close_document(self, title: str) -> bool: """ Called when user is closing document (when opening a new document or closing the app). :return: False if user cancels close-document action. """ if not self._prompt_if_unsaved(title): return False if not self._cancel_render_if_active(title): # Saying Yes quits render immediately, so place this dialog last. return False return True def closeEvent(self, event: QCloseEvent) -> None: """Called on closing window.""" if self.should_close_document(self.tr("Quit")): gp.dump_prefs(self.pref) event.accept() else: event.ignore() def on_action_new(self): if not self.should_close_document(self.tr("New Project")): return cfg = template_config() self.load_cfg(cfg, None) def on_action_open(self): if not self.should_close_document(self.tr("Open Project")): return name = get_open_file_name( self, "Open config", self.pref.file_dir_ref, ["YAML files (*.yaml)"] ) if name: cfg_path = Path(name) self.load_cfg_from_path(cfg_path) def load_cfg_from_path(self, cfg_path: Path): # Bind GUI to dummy config, in case loading cfg_path raises Exception. if self.model is None: self.load_cfg(template_config(), None) assert cfg_path.is_file() self.pref.file_dir = str(cfg_path.parent.resolve()) # Raises YAML structural exceptions cfg = yaml.load(cfg_path) try: # Raises color getter exceptions self.load_cfg(cfg, cfg_path) except Exception as e: # FIXME if error halfway, clear "file path" and load empty model. TracebackDialog(self).showMessage(format_stack_trace(e)) return def load_cfg(self, cfg: Config, cfg_path: Optional[Path]) -> None: self._cfg_path = cfg_path self._any_unsaved = False self.load_title() self.left_tabs.setCurrentIndex(0) self.model.set_cfg(cfg) self.channel_model = ChannelModel(cfg.channels) # Calling setModel again disconnects previous model. self.channel_view.setModel(self.channel_model) self.channel_model.dataChanged.connect(self.on_model_edited) self.channel_model.rowsInserted.connect(self.on_model_edited) self.channel_model.rowsMoved.connect(self.on_model_edited) self.channel_model.rowsRemoved.connect(self.on_model_edited) def on_model_edited(self): self.any_unsaved = True title_cache: str def load_title(self) -> None: self.title_cache = self.title self._update_unsaved_title() def _update_unsaved_title(self) -> None: if self.any_unsaved: undo_str = "*" else: undo_str = "" self.setWindowTitle(f"{self.title_cache}{undo_str} - {APP_NAME}") # GUI actions, etc. master_audio_browse: qw.QPushButton channelAdd: "ShortcutButton" channelDelete: "ShortcutButton" channelUp: "ShortcutButton" channelDown: "ShortcutButton" action_separate_render_dir: qw.QAction action_open_config_dir: qw.QAction # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar actionNew: qw.QAction actionOpen: qw.QAction actionSave: qw.QAction actionSaveAs: qw.QAction actionPreview: qw.QAction actionRender: qw.QAction actionExit: qw.QAction def on_master_audio_browse(self): name = get_open_file_name( self, "Open master audio file", self.pref.file_dir_ref, FILTER_WAV_FILES ) if name: master_audio = "master_audio" self.model[master_audio] = name self.model.update_all_bound(master_audio) def on_separate_render_dir_toggled(self, checked: bool): self.pref.separate_render_dir = checked if checked: self.pref.render_dir = self.pref.file_dir else: self.pref.render_dir = "" def on_open_config_dir(self): appdata_uri = qc.QUrl.fromLocalFile(str(paths.appdata_dir)) QDesktopServices.openUrl(appdata_uri) def on_channel_add(self): wavs = get_open_file_list( self, "Add audio channels", self.pref.file_dir_ref, FILTER_WAV_FILES ) if wavs: self.channel_view.append_channels(wavs) def on_channel_delete(self): self.channel_view.delete_selected() def on_action_save(self) -> bool: """ :return: False if user cancels save action. """ if self._cfg_path is None: return self.on_action_save_as() yaml.dump(self.cfg, self._cfg_path) self.any_unsaved = False self._update_unsaved_title() return True def on_action_save_as(self) -> bool: """ :return: False if user cancels save action. """ # Name and extension (no folder). cfg_filename = self.get_save_filename(cli.YAML_NAME) # Folder is obtained from self.pref.file_dir_ref. filters = ["YAML files (*.yaml)", "All files (*)"] path = get_save_file_path( self, "Save As", self.pref.file_dir_ref, cfg_filename, filters, cli.YAML_NAME, ) if path: self._cfg_path = path self.load_title() self.on_action_save() return True else: return False def on_action_preview(self): """ Launch CorrScope and ffplay. """ if self.corr_thread is not None: error_msg = self.tr("Cannot preview, another {} is active").format( self.preview_or_render ) qw.QMessageBox.critical(self, "Error", error_msg) return outputs = [FFplayOutputConfig()] self.play_thread(outputs, PreviewOrRender.preview, dlg=None) def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ if self.corr_thread is not None: error_msg = self.tr("Cannot render to file, another {} is active").format( self.preview_or_render ) qw.QMessageBox.critical(self, "Error", error_msg) return # Name and extension (no folder). video_filename = self.get_save_filename(cli.VIDEO_NAME) filters = ["MP4 files (*.mp4)", "All files (*)"] # Points to either `file_dir` or `render_dir`. # Folder is obtained from `dir_ref`. dir_ref = self.pref.render_dir_ref path = get_save_file_path( self, "Render to Video", dir_ref, video_filename, filters, cli.VIDEO_NAME ) if path: name = str(path) dlg = CorrProgressDialog(self, "Rendering video") # FFmpegOutputConfig contains only hashable/immutable strs, # so get_ffmpeg_cfg() can be shared across threads without copying. # Optionally copy_config() first. outputs = [self.cfg.get_ffmpeg_cfg(name)] self.play_thread(outputs, PreviewOrRender.render, dlg) def play_thread( self, outputs: List[IOutputConfig], mode: "PreviewOrRender", dlg: Optional["CorrProgressDialog"], ): assert self.model arg = self._get_args(outputs) cfg = copy_config(self.model.cfg) t = self.corr_thread = CorrThread(cfg, arg, mode) if dlg: # t.abort -> Locked.set() is thread-safe (hopefully). # It can be called from main thread (not just within CorrThread). dlg.canceled.connect(t.abort, Qt.DirectConnection) t.arg = attr.evolve( arg, on_begin=run_on_ui_thread(dlg.on_begin, (float, float)), progress=run_on_ui_thread(dlg.setValue, (int,)), on_end=run_on_ui_thread(dlg.reset, ()), # TODO dlg.close ) t.finished.connect(self.on_play_thread_finished) t.error.connect(self.on_play_thread_error) t.ffmpeg_missing.connect(self.on_play_thread_ffmpeg_missing) t.start() @safe_property def preview_or_render(self) -> str: if self.corr_thread is not None: return self.tr(self.corr_thread.mode.value) return "neither preview nor render" def _get_args(self, outputs: List[IOutputConfig]): def raise_exception(): raise RuntimeError( "Arguments.is_aborted should be overwritten by CorrThread" ) arg = Arguments( cfg_dir=self.cfg_dir, outputs=outputs, is_aborted=raise_exception ) return arg def on_play_thread_finished(self): self.corr_thread = None def on_play_thread_error(self, stack_trace: str): TracebackDialog(self).showMessage(stack_trace) def on_play_thread_ffmpeg_missing(self): DownloadFFmpegActivity(self) # File paths @safe_property def cfg_dir(self) -> str: """Only used when generating Arguments when playing corrscope. Not used to determine default path of file dialogs.""" maybe_path = self._cfg_path or self.cfg.master_audio if maybe_path: # Windows likes to raise OSError when path contains * try: return str(Path(maybe_path).resolve().parent) except OSError: return "." return "." UNTITLED = "Untitled" @safe_property def title(self) -> str: if self._cfg_path: return self._cfg_path.name return self.UNTITLED def get_save_filename(self, suffix: str) -> str: """ If file name can be guessed, return "filename.suffix" (no dir). Otherwise return "". Used for saving file or video. """ stem = cli.get_file_stem(self._cfg_path, self.cfg, default="") if stem: return stem + suffix else: return "" @staticmethod def compute_save_dir(cfg: Config) -> Optional[str]: """Computes a "save directory" when constructing a config from CLI wav files.""" if cfg.master_audio: file_path = cfg.master_audio elif len(cfg.channels) > 0: file_path = cfg.channels[0].wav_path else: return None # If file_path is "file.wav", we want to return "." . # os.path.dirname("file.wav") == "" # Path("file.wav").parent..str == "." dir = Path(file_path).parent return str(dir) @safe_property def cfg(self): return self.model.cfg # Misc. @qc.pyqtSlot() def on_action_website(self): website_url = r"https://github.com/corrscope/corrscope/" QDesktopServices.openUrl(qc.QUrl(website_url)) @qc.pyqtSlot() def on_action_help(self): help_url = r"https://corrscope.github.io/corrscope/" QDesktopServices.openUrl(qc.QUrl(help_url)) def _format_exc_value(e: BaseException, limit=None, chain=True): """Like traceback.format_exc() but takes an exception object.""" list = traceback.format_exception( type(e), e, e.__traceback__, limit=limit, chain=chain ) str = "".join(list) return str def format_stack_trace(e: BaseException): if isinstance(e, CorrError): stack_trace = _format_exc_value(e, limit=0) else: stack_trace = _format_exc_value(e) return stack_trace class PreviewOrRender(Enum): # PreviewOrRender.value is translated at time of use, not time of definition. preview = qc.QT_TRANSLATE_NOOP("MainWindow", "preview") render = qc.QT_TRANSLATE_NOOP("MainWindow", "render") class CorrThread(qc.QThread): is_aborted: Locked[bool] @qc.pyqtSlot() def abort(self): self.is_aborted.set(True) def abort_terminate(self): """Sends abort signal to main loop, and terminates all outputs.""" self.abort() if self.corr is not None: for output in self.corr.outputs: output.terminate(from_same_thread=False) error = qc.pyqtSignal(str) ffmpeg_missing = qc.pyqtSignal() def __init__(self, cfg: Config, arg: Arguments, mode: PreviewOrRender): qc.QThread.__init__(self) self.is_aborted = Locked(False) self.cfg = cfg self.arg = arg self.arg.is_aborted = self.is_aborted.get self.mode = mode self.corr = None # type: Optional[CorrScope] def run(self) -> None: """Called in separate thread.""" cfg = self.cfg arg = self.arg try: self.corr = CorrScope(cfg, arg) self.corr.play() except paths.MissingFFmpegError: arg.on_end() self.ffmpeg_missing.emit() except Exception as e: arg.on_end() stack_trace = format_stack_trace(e) self.error.emit(stack_trace) else: arg.on_end() class CorrProgressDialog(qw.QProgressDialog): def __init__(self, parent: Optional[qw.QWidget], title: str): super().__init__(parent) self.setMinimumWidth(300) self.setWindowTitle(title) self.setLabelText("Progress:") # If set to 0, the dialog is always shown as soon as any progress is set. self.setMinimumDuration(0) # Don't reset when rendering is approximately finished. self.setAutoReset(False) # Close after CorrScope finishes. self.setAutoClose(True) @qc.pyqtSlot(float, float) def on_begin(self, begin_time, end_time): self.setRange(iround(begin_time), iround(end_time)) # self.setValue is called by CorrScope, on the first frame. def run_on_ui_thread( bound_slot: MethodType, types: Tuple[type, ...] ) -> Callable[..., None]: """ Runs an object's slot on the object's own thread. It's terrible code but it works (as long as the slot has no return value). """ qmo = qc.QMetaObject # QObject *obj, obj = bound_slot.__self__ # const char *member, member = bound_slot.__name__ # Qt::ConnectionType type, # QGenericReturnArgument ret, # https://riverbankcomputing.com/pipermail/pyqt/2014-December/035223.html conn = Qt.QueuedConnection @functools.wraps(bound_slot) def inner(*args): if len(types) != len(args): raise TypeError(f"len(types)={len(types)} != len(args)={len(args)}") # https://www.qtcentre.org/threads/29156-Calling-a-slot-from-another-thread?p=137140#post137140 # QMetaObject.invokeMethod(skypeThread, "startSkypeCall", Qt.QueuedConnection, QtCore.Q_ARG("QString", "someguy")) _args = [qc.Q_ARG(typ, typ(arg)) for typ, arg in zip(types, args)] return qmo.invokeMethod(obj, member, conn, *_args) return inner # Begin ConfigModel properties def nrow_ncol_property(altered: str, unaltered: str) -> SafeProperty: def get(self: "ConfigModel"): val = getattr(self.cfg.layout, altered) if val is None: return 0 else: return val def set(self: "ConfigModel", val: int): if val > 0: setattr(self.cfg.layout, altered, val) setattr(self.cfg.layout, unaltered, None) self.update_all_bound("layout__" + unaltered) elif val == 0: setattr(self.cfg.layout, altered, None) else: raise CorrError(f"invalid input: {altered} < 0, should never happen") return safe_property(get, set) # Unused def default_property(path: str, default: Any) -> SafeProperty: def getter(self: "ConfigModel"): val = rgetattr(self.cfg, path) if val is None: return default else: return val def setter(self: "ConfigModel", val): rsetattr(self.cfg, path, val) return safe_property(getter, setter) def path_strip_quotes(path: str) -> str: if len(path) and path[0] == path[-1] == '"': return path[1:-1] return path def path_fix_property(path: str) -> SafeProperty: """Removes quotes from paths, when setting from GUI.""" def getter(self: "ConfigModel") -> str: return rgetattr(self.cfg, path) def setter(self: "ConfigModel", val: str): val = path_strip_quotes(val) rsetattr(self.cfg, path, val) return safe_property(getter, setter) flatten_no_stereo = { Flatten.SumAvg: "Average: (L+R)/2", Flatten.DiffAvg: "DiffAvg: (L-R)/2", } flatten_modes = {**flatten_no_stereo, Flatten.Stereo: "Stereo"} assert set(flatten_modes.keys()) == set(Flatten.modes) # type: ignore class ConfigModel(PresentationModel): cfg: Config combo_symbol_text: Dict[str, Sequence[SymbolText]] = {} master_audio = path_fix_property("master_audio") # Stereo flattening combo_symbol_text["trigger_stereo"] = list(flatten_no_stereo.items()) + [ (BoundComboBox.Custom, "Custom") ] combo_symbol_text["render_stereo"] = list(flatten_modes.items()) + [ (BoundComboBox.Custom, "Custom") ] # Trigger @safe_property def trigger__pitch_tracking(self) -> bool: scfg = self.cfg.trigger.pitch_tracking gui = scfg is not None return gui @trigger__pitch_tracking.setter def trigger__pitch_tracking(self, gui: bool): scfg = SpectrumConfig() if gui else None self.cfg.trigger.pitch_tracking = scfg combo_symbol_text["trigger__edge_direction"] = [ (1, "Rising (+1)"), (-1, "Falling (-1)"), ] combo_symbol_text["trigger__post_trigger"] = [ (type(None), "Disabled"), (ZeroCrossingTriggerConfig, "Zero Crossing"), ] # Render @safe_property def render_resolution(self) -> str: render = self.cfg.render w, h = render.width, render.height return f"{w}x{h}" @render_resolution.setter def render_resolution(self, value: str): error = CorrError(f"invalid resolution {value}, must be WxH") for sep in "x*,": width_height = value.split(sep) if len(width_height) == 2: break else: raise error render = self.cfg.render width, height = width_height try: render.width = int(width) render.height = int(height) except ValueError: raise error combo_symbol_text["default_label"] = [ (DefaultLabel.NoLabel, MainWindow.tr("None", "Default Label")), (DefaultLabel.FileName, MainWindow.tr("File Name", "Default Label")), (DefaultLabel.Number, MainWindow.tr("Number", "Default Label")), ] combo_symbol_text["render.label_position"] = [ (LabelPosition.LeftTop, "Top Left"), (LabelPosition.LeftBottom, "Bottom Left"), (LabelPosition.RightTop, "Top Right"), (LabelPosition.RightBottom, "Bottom Right"), ] @safe_property def render__label_qfont(self) -> QFont: qfont = QFont() qfont.setStyleHint(QFont.SansSerif) # no-op on X11 font = self.cfg.render.label_font if font.toString: qfont.fromString(font.toString) return qfont # Passing None or "" to QFont(family) results in qfont.family() = "", and # wrong font being selected (Abyssinica SIL, which appears early in the list). family = coalesce(font.family, qfont.defaultFamily()) # Font file selection qfont.setFamily(family) qfont.setBold(font.bold) qfont.setItalic(font.italic) # Font size qfont.setPointSizeF(font.size) return qfont @render__label_qfont.setter def render__label_qfont(self, qfont: QFont): self.cfg.render.label_font = attr.evolve( self.cfg.render.label_font, # Font file selection family=qfont.family(), bold=qfont.bold(), italic=qfont.italic(), # Font size size=qfont.pointSizeF(), # QFont implementation details toString=qfont.toString(), ) # Layout layout__nrows = nrow_ncol_property("nrows", unaltered="ncols") layout__ncols = nrow_ncol_property("ncols", unaltered="nrows") _orientations = [["h", "Horizontal"], ["v", "Vertical"]] _stereo_orientations = _orientations + [["overlay", "Overlay"]] combo_symbol_text["layout__orientation"] = [ (Orientation(key), name) for key, name in _orientations ] combo_symbol_text["layout__stereo_orientation"] = [ (StereoOrientation(key), name) for key, name in _stereo_orientations ] # End ConfigModel @attr.dataclass class Column: key: str cls: Union[type, Callable[[str], Any]] # `default` is written into config, # when users type "blank or whitespace" into table cell. default: Any def _display_name(self) -> str: return self.key.replace("__", "\n").replace("_", " ").title() display_name: str = attr.Factory(_display_name, takes_self=True) def plus_minus_one(value: str) -> int: if int(value) >= 0: # Raises ValueError return 1 else: return -1 nope = qc.QVariant() class ChannelModel(qc.QAbstractTableModel): """ Design based off https://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model and https://doc.qt.io/qt-5/model-view-programming.html#model-subclassing-reference """ def __init__(self, channels: List[ChannelConfig]): """ Mutates `channels` and `line_color` for convenience. """ super().__init__() self.channels = channels line_color = "line_color" for cfg in self.channels: t = cfg.trigger if isinstance(t, MainTriggerConfig): if not isinstance(t, CorrelationTriggerConfig): raise CorrError(f"Loading per-channel {obj_name(t)} not supported") trigger_dict = attr.asdict(t) else: trigger_dict = dict(t or {}) if line_color in trigger_dict: trigger_dict[line_color] = color2hex(trigger_dict[line_color]) cfg.trigger = trigger_dict def triggers(self, row: int) -> Dict[str, Any]: trigger = self.channels[row].trigger assert isinstance(trigger, dict) return trigger # columns col_data = [ Column("wav_path", path_strip_quotes, "", "WAV Path"), Column("label", str, "", "Label"), Column("amplification", float, None, "Amplification\n(override)"), Column("line_color", str, None, "Line Color"), Column("render_stereo", str, None, "Render Stereo\nDownmix"), Column("trigger_width", int, None, "Trigger Width ×"), Column("render_width", int, None, "Render Width ×"), Column("trigger__sign_strength", float, None), Column("trigger__buffer_strength", float, None), Column("trigger__responsiveness", float, None, "Buffer\nResponsiveness"), Column("trigger__edge_direction", plus_minus_one, None), Column("trigger__edge_strength", float, None), Column("trigger__slope_strength", float, None), Column("trigger__slope_width", float, None), ] idx_of_key = {} for idx, col in enumerate(col_data): idx_of_key[col.key] = idx del idx, col def columnCount(self, parent: QModelIndex = ...) -> int: return len(self.col_data) def headerData( self, section: int, orientation: Qt.Orientation, role: int = Qt.DisplayRole ) -> Union[str, QVariant]: if role == Qt.DisplayRole: if orientation == Qt.Horizontal: col = section try: return self.col_data[col].display_name except IndexError: return nope else: return str(section + 1) return nope # rows def rowCount(self, parent: QModelIndex = ...) -> int: return len(self.channels) # data TRIGGER = "trigger__" def data(self, index: QModelIndex, role=Qt.DisplayRole) -> Any: col = index.column() row = index.row() if ( role in [Qt.DisplayRole, Qt.EditRole] and index.isValid() and row < self.rowCount() ): data = self.col_data[col] key = data.key if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) value = self.triggers(row).get(key, "") else: value = getattr(self.channels[row], key) if value == data.default: return "" if key == "wav_path" and role == Qt.DisplayRole: if Path(value).parent != Path(): return "..." + Path(value).name return str(value) return nope def setData(self, index: QModelIndex, value: str, role=Qt.EditRole) -> bool: col = index.column() row = index.row() if index.isValid() and role == Qt.EditRole: # type(value) == str data = self.col_data[col] key = data.key if value and not value.isspace(): try: value = data.cls(value) except ValueError: return False else: value = data.default if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) trigger = self.triggers(row) if value == data.default: # Delete key if (key: value) present trigger.pop(key, None) else: trigger[key] = value else: setattr(self.channels[row], key, value) self.dataChanged.emit(index, index, [role]) return True return False """So if I understood it correctly you want to reorder the columns by moving the headers and then want to know how the view looks like. I believe ( 90% certain ) when you reorder the headers it does not trigger any change in the model! and then if you just start printing the data of the model you will only see the data in the order how it was initially before you swapper/reordered some column with the header. """ def insertRows(self, row: int, count: int, parent=QModelIndex()) -> bool: if not (count >= 1 and 0 <= row <= len(self.channels)): return False self.beginInsertRows(parent, row, row + count - 1) self.channels[row:row] = [ChannelConfig("") for _ in range(count)] self.endInsertRows() return True def removeRows(self, row: int, count: int, parent=QModelIndex()) -> bool: nchan = len(self.channels) # row <= nchan for consistency. if not (count >= 1 and 0 <= row <= nchan and row + count <= nchan): return False self.beginRemoveRows(parent, row, row + count - 1) del self.channels[row : row + count] self.endRemoveRows() return True def moveRows( self, _sourceParent: QModelIndex, src_row: int, count: int, _destinationParent: QModelIndex, dest_row: int, ): nchan = len(self.channels) if not ( count >= 1 and 0 <= src_row <= nchan and src_row + count <= nchan and 0 <= dest_row <= nchan ): return False # If source and destination overlap, beginMoveRows returns False. if not self.beginMoveRows( _sourceParent, src_row, src_row + count - 1, _destinationParent, dest_row ): return False # We know source and destination do not overlap. src = slice(src_row, src_row + count) dest = slice(dest_row, dest_row) if dest_row > src_row: # Move down: Insert dest, then remove src self.channels[dest] = self.channels[src] del self.channels[src] else: # Move up: Remove src, then insert dest. rows = self.channels[src] del self.channels[src] self.channels[dest] = rows self.endMoveRows() return True def flags(self, index: QModelIndex): if not index.isValid(): return Qt.ItemIsEnabled return ( qc.QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable | Qt.ItemNeverHasChildren ) class DownloadFFmpegActivity: title = "Missing FFmpeg" ffmpeg_url = paths.get_ffmpeg_url() can_download = bool(ffmpeg_url) required = ( f"FFmpeg+FFplay must be in PATH or " f'<a href="{PATH_uri.toString()}">corrscope folder</a> in order to use corrscope.<br>' ) ffmpeg_template = required + ( f'Download ffmpeg from <a href="{ffmpeg_url}">{ffmpeg_url}</a>.' ) fail_template = required + "Cannot download FFmpeg for your platform." def __init__(self, window: qw.QWidget): """Prompt the user to download and install ffmpeg.""" Msg = qw.QMessageBox if not self.can_download: Msg.information(window, self.title, self.fail_template, Msg.Ok) return Msg.information(window, self.title, self.ffmpeg_template, Msg.Ok)