From baad00a938bb35896580ad60e637699e90eff3d7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 3 Sep 2018 04:52:38 -0700 Subject: [PATCH 001/102] Squash branch "qt-gui" --- ovgenpy/cli.py | 6 +- ovgenpy/config.py | 1 + ovgenpy/gui/.gitignore | 43 ++++ ovgenpy/gui/__init__.py | 274 ++++++++++++++++++++++ ovgenpy/gui/__main__.py | 4 + ovgenpy/gui/channel_widget.ui | 129 +++++++++++ ovgenpy/gui/data_bind.py | 222 ++++++++++++++++++ ovgenpy/gui/gui.pro | 31 +++ ovgenpy/gui/mainwindow.ui | 421 ++++++++++++++++++++++++++++++++++ ovgenpy/util.py | 4 + setup.py | 1 + tests/test_gui_binding.py | 55 +++++ 12 files changed, 1189 insertions(+), 2 deletions(-) create mode 100644 ovgenpy/gui/.gitignore create mode 100644 ovgenpy/gui/__init__.py create mode 100644 ovgenpy/gui/__main__.py create mode 100644 ovgenpy/gui/channel_widget.ui create mode 100644 ovgenpy/gui/data_bind.py create mode 100644 ovgenpy/gui/gui.pro create mode 100644 ovgenpy/gui/mainwindow.ui create mode 100644 tests/test_gui_binding.py diff --git a/ovgenpy/cli.py b/ovgenpy/cli.py index e5b1257..dac465f 100644 --- a/ovgenpy/cli.py +++ b/ovgenpy/cli.py @@ -103,7 +103,7 @@ def main( # Create cfg: Config object. cfg: Optional[Config] = None cfg_path: Optional[Path] = None - cfg_dir: Optional[str] = None # Changing to Path will take a lot of refactoring. + cfg_dir: Optional[str] = None wav_list: List[Path] = [] for name in files: @@ -145,6 +145,7 @@ def main( wav_list += matches if not cfg: + # cfg and cfg_dir are always initialized together. channels = [ChannelConfig(str(wav_path)) for wav_path in wav_list] cfg = default_config( @@ -157,7 +158,8 @@ def main( cfg_dir = '.' if show_gui: - raise click.UsageError('GUI not implemented') + from ovgenpy import gui + gui.gui_main(cfg, cfg_dir) else: if not files: raise click.UsageError('Must specify files or folders to play') diff --git a/ovgenpy/config.py b/ovgenpy/config.py index 8354813..a73cb6b 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -161,3 +161,4 @@ class OvgenWarning(UserWarning): (Should be) caught by GUI and displayed to user. """ pass +ValidationError = OvgenError diff --git a/ovgenpy/gui/.gitignore b/ovgenpy/gui/.gitignore new file mode 100644 index 0000000..5291a38 --- /dev/null +++ b/ovgenpy/gui/.gitignore @@ -0,0 +1,43 @@ +# C++ objects and libs +*.slo +*.lo +*.o +*.a +*.la +*.lai +*.so +*.dll +*.dylib + +# Qt-es +object_script.*.Release +object_script.*.Debug +*_plugin_import.cpp +/.qmake.cache +/.qmake.stash +*.pro.user +*.pro.user.* +*.qbs.user +*.qbs.user.* +*.moc +moc_*.cpp +moc_*.h +qrc_*.cpp +ui_*.h +*.qmlc +*.jsc +Makefile* +*build-* + +# Qt unit tests +target_wrapper.* + +# QtCreator +*.autosave + +# QtCreator Qml +*.qmlproject.user +*.qmlproject.user.* + +# QtCreator CMake +CMakeLists.txt.user* diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py new file mode 100644 index 0000000..234cd33 --- /dev/null +++ b/ovgenpy/gui/__init__.py @@ -0,0 +1,274 @@ +import sys +from typing import * +from pathlib import Path + +import attr +from PyQt5 import uic +import PyQt5.QtCore as qc +import PyQt5.QtWidgets as qw +from PyQt5.QtCore import QModelIndex, Qt + +from ovgenpy.channel import ChannelConfig +from ovgenpy.config import OvgenError +from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead +from ovgenpy.ovgenpy import Config, default_config +from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig +from ovgenpy.util import perr, obj_name + +APP_NAME = 'ovgenpy' +APP_DIR = Path(__file__).parent + +def res(file: str) -> str: + return str(APP_DIR / file) + + +def gui_main(cfg: Config, cfg_dir: str): + app = qw.QApplication(sys.argv) + app.setAttribute(qc.Qt.AA_EnableHighDpiScaling) + + window = MainWindow(cfg, cfg_dir) + sys.exit(app.exec_()) + + +class MainWindow(qw.QMainWindow): + """ + Main window. + + Control flow: + __init__ + load_cfg + + # Opening a document + load_cfg + """ + + def __init__(self, cfg: Config, cfg_dir: str): + super().__init__() + uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle + self.setWindowTitle(APP_NAME) + + self.cfg_dir = cfg_dir + self.load_cfg(cfg) + + self.master_audio_browse: qw.QPushButton + self.master_audio_browse.clicked.connect(self.on_master_audio_browse) + self.show() + # Complex changes are done in the presentation model's setters. + + # Abstract Item Model.data[idx] == QVariant (impl in subclass, wraps data) + # Standard Item Model.item[r,c] == QStandardItem (it IS the data) + # Explanation: https://doc.qt.io/qt-5/modelview.html#3-3-predefined-models + # items.setData(items.index(0,0), item) + + model: 'ConfigModel' + channel_model: 'ChannelModel' + + def load_cfg(self, cfg: Config): + # TODO unbind current model's slots if exists + # or maybe disconnect ALL connections?? + self.model = ConfigModel(cfg) + map_gui(self, self.model) + + self.channel_model = ChannelModel(cfg.channels) + self.channel_widget: qw.QTableView + self.channel_widget.setModel(self.channel_model) + + def on_master_audio_browse(self): + # TODO add default file-open dir, initialized to yaml path and remembers prev + # useless if people don't reopen old projects + name, file_type = qw.QFileDialog.getOpenFileName( + self, "Open master audio file", filter="WAV files (*.wav)" + ) + if name != '': + master_audio = 'master_audio' + self.model[master_audio] = name + self.model.update_widget[master_audio]() + + +def nrow_ncol_property(altered: str, unaltered: str) -> property: + 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): + perr(altered) + if val > 0: + setattr(self._cfg.layout, altered, val) + setattr(self._cfg.layout, unaltered, None) + self.update_widget['layout__' + unaltered]() + elif val == 0: + setattr(self._cfg.layout, altered, None) + else: + raise OvgenError(f"invalid input: {altered} < 0, should never happen") + + return property(get, set) + + +class ConfigModel(PresentationModel): + _cfg: Config + combo_symbols = {} + combo_text = {} + + @property + def render_video_size(self) -> str: + render = self._cfg.render + w, h = render.width, render.height + return f'{w}x{h}' + + @render_video_size.setter + def render_video_size(self, value: str): + error = OvgenError(f"invalid video size {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 + + layout__nrows = nrow_ncol_property('nrows', unaltered='ncols') + layout__ncols = nrow_ncol_property('ncols', unaltered='nrows') + combo_symbols['layout__orientation'] = ['h', 'v'] + combo_text['layout__orientation'] = ['Horizontal', 'Vertical'] + + # TODO mutate _cfg and convert all colors to #rrggbb on access + + +class ChannelWidget(qw.QWidget): + """ Widget bound to a single ChannelModel. """ + + def __init__(self, cfg: ChannelConfig): + super().__init__() + uic.loadUi(res('channel_widget.ui'), self) + + self.model = ChannelModel(cfg) + map_gui(self, self.model) + + # FIXME uncomment? + # self.show() + + +T = TypeVar('T') + + +# def coalesce_property(key: str, default: T) -> property: +# def get(self: 'ChannelModel') -> T: +# val: Optional[T] = getattr(self._cfg, key) +# if val is None: +# return default +# return val +# +# def set(self: 'ChannelModel', val: T): +# if val == default: +# val = None +# setattr(self._cfg, key, val) +# +# return property(get, set) + + +class ChannelModel(qc.QAbstractTableModel): + + def __init__(self, channels: List[ChannelConfig]): + """ Mutates `channels` for convenience. """ + super().__init__() + self.channels = channels + self.triggers: List[dict] = [] + + for cfg in self.channels: + t = cfg.trigger + if isinstance(t, ITriggerConfig): + if not isinstance(t, CorrelationTriggerConfig): + raise OvgenError( + f'Loading per-channel {obj_name(t)} not supported') + trigger_dict = attr.asdict(t) + else: + trigger_dict = dict(t or {}) + + cfg.trigger = trigger_dict + self.triggers.append(trigger_dict) + + # columns + col_keys = ['wav_path', 'trigger_width', 'render_width', 'line_color', + 'trigger__'] + + def columnCount(self, parent: QModelIndex = ...) -> int: + return len(self.col_keys) + + def headerData(self, section: int, orientation: Qt.Orientation, + role=Qt.DisplayRole): + nope = qc.QVariant() + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + col = section + try: + return self.col_keys[col] + except IndexError: + return nope + return nope + + # rows + def rowCount(self, parent: QModelIndex = ...) -> int: + return len(self.channels) + + # data + TRIGGER = 'trigger__' + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + col = index.column() + row = index.row() + + key = self.col_keys[col] + if key.startswith(self.TRIGGER): + key = behead(key, self.TRIGGER) + return self.triggers[row].get(key, '') + + else: + return getattr(self.channels[row], key) + + # try: + # return getattr(self, 'cfg__' + key) + # except AttributeError: + # pass + # if key.startswith('trigger__') and cfg.trigger is None: + # return rgetattr(cfg, key, '') + # else: + # return rgetattr(cfg, key) + + # DEFAULT = object() + # if val is DEFAULT: + # # Trigger attributes can be missing, all others must be present. + # assert key.startswith('trigger__') + # return '' + # else: + # return val + return super().data(index, role) + + def setData(self, index: QModelIndex, value, role=Qt.EditRole) -> bool: + if role == Qt.EditRole: + # FIXME what type is value? str or not? + perr(repr(role)) + return True + return super().setData(index, value, role) + + def flags(self, index: QModelIndex) -> Qt.ItemFlags: + return super().flags(index) + + # _cfg: ChannelConfig + # combo_symbols = {} + # combo_text = {} + # + # trigger_width = coalesce_property('trigger_width', 1) + # render_width = coalesce_property('render_width', 1) + # line_color = coalesce_property('line_color', '') # TODO + diff --git a/ovgenpy/gui/__main__.py b/ovgenpy/gui/__main__.py new file mode 100644 index 0000000..28bc012 --- /dev/null +++ b/ovgenpy/gui/__main__.py @@ -0,0 +1,4 @@ +from ovgenpy.gui import gui_main +from ovgenpy.ovgenpy import default_config + +gui_main(default_config(), '.') diff --git a/ovgenpy/gui/channel_widget.ui b/ovgenpy/gui/channel_widget.ui new file mode 100644 index 0000000..2374d62 --- /dev/null +++ b/ovgenpy/gui/channel_widget.ui @@ -0,0 +1,129 @@ + + + ChannelView + + + + 0 + 0 + + + + Form + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + wav_path_stem + + + + + + + + + + + + Browse + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + + Trigger Width × + + + + + + + 1 + + + + + + + = twms + + + + + + + Render Width × + + + + + + + 1 + + + + + + + = rwms + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py new file mode 100644 index 0000000..5465740 --- /dev/null +++ b/ovgenpy/gui/data_bind.py @@ -0,0 +1,222 @@ +import functools +from typing import Optional, List, Callable, Dict, Any + +import attr +from PyQt5 import QtWidgets as qw, QtCore as qc +from PyQt5.QtCore import pyqtSlot +from PyQt5.QtWidgets import QWidget + +from ovgenpy.util import obj_name, perr + +__all__ = ['PresentationModel', 'map_gui'] + + +WidgetUpdater = Callable[[], None] +Attrs = Any + + +class PresentationModel: + """ Key-value MVP presentation-model. + + Qt's built-in model-view framework expects all models to + take the form of a database-style numbered [row, column] structure, + whereas my model takes the form of a key-value struct exposed as a form. + """ + + # These fields are specific to each subclass, and assigned there. + # Although less explicit, these can be assigned using __init_subclass__. + combo_symbols: Dict[str, List[str]] + combo_text: Dict[str, List[str]] + + def __init__(self, cfg: Attrs): + self._cfg = cfg + self.update_widget: Dict[str, WidgetUpdater] = {} + + def __getitem__(self, item): + try: + # Custom properties + return getattr(self, item) + except AttributeError: + return rgetattr(self._cfg, item) + + def __setitem__(self, key, value): + perr(f'{key} = {value}') + # Custom properties + if hasattr(self, key): + setattr(self, key, value) + elif rhasattr(self._cfg, key): + rsetattr(self._cfg, key, value) + else: + raise AttributeError(f'cannot set attribute {key} on {obj_name(self)}()') + + +BIND_PREFIX = 'cfg__' + +# TODO add tests for recursive operations +def map_gui(view: QWidget, model: PresentationModel): + """ + Binding: + - .ui + - view.cfg__layout__nrows + - model['layout__nrows'] + + Only s starting with 'cfg__' will be bound. + """ + + widgets: List[QWidget] = view.findChildren(QWidget) # dear pyqt, add generic mypy return types + for widget in widgets: + widget_name = widget.objectName() + path = try_behead(widget_name, BIND_PREFIX) + if path is not None: + bind_widget(widget, model, path) + + +@functools.singledispatch +def bind_widget(widget: QWidget, model: PresentationModel, path: str): + perr(widget, path) + return + + +@bind_widget.register(qw.QLineEdit) +def _(widget, model: PresentationModel, path: str): + direct_bind(widget, model, path, DirectBinding( + set_widget=widget.setText, + widget_changed=widget.textChanged, + value_type=str, + )) + + +@bind_widget.register(qw.QSpinBox) +def _(widget, model: PresentationModel, path: str): + direct_bind(widget, model, path, DirectBinding( + set_widget=widget.setValue, + widget_changed=widget.valueChanged, + value_type=int, + )) + + +@bind_widget.register(qw.QDoubleSpinBox) +def _(widget, model: PresentationModel, path: str): + direct_bind(widget, model, path, DirectBinding( + set_widget=widget.setValue, + widget_changed=widget.valueChanged, + value_type=float, + )) + + +@bind_widget.register(qw.QComboBox) +def _(widget, model: PresentationModel, path: str): + combo_symbols = model.combo_symbols[path] + combo_text = model.combo_text[path] + symbol2idx = {} + for i, symbol in enumerate(combo_symbols): + symbol2idx[symbol] = i + widget.addItem(combo_text[i]) + + # combobox.index = model.attr + def set_widget(symbol: str): + combo_index = symbol2idx[symbol] + widget.setCurrentIndex(combo_index) + + # model.attr = combobox.index + def set_model(combo_index: int): + assert isinstance(combo_index, int) + model[path] = combo_symbols[combo_index] + widget.currentIndexChanged.connect(set_model) + + direct_bind(widget, model, path, DirectBinding( + set_widget=set_widget, + widget_changed=None, + value_type=None, + )) + + +Signal = Any + +@attr.dataclass +class DirectBinding: + set_widget: Callable + widget_changed: Optional[Signal] + value_type: Optional[type] + + +def direct_bind(widget: QWidget, model: PresentationModel, path: str, bind: DirectBinding): + def update_widget(): + """ Update the widget without triggering signals. + + When the presentation model updates dependent widget 1, + the model (not widget 1) is responsible for updating other + dependent widgets. + """ + # FIXME add option to send signals + with qc.QSignalBlocker(widget): + bind.set_widget(model[path]) + + update_widget() + + # Allow widget to be updated by other events. + model.update_widget[path] = update_widget + + # Allow model to be changed by widget. + if bind.widget_changed is not None: + @pyqtSlot(bind.value_type) + def set_model(value): + assert isinstance(value, bind.value_type) + model[path] = value + + bind.widget_changed.connect(set_model) + + # QSpinBox.valueChanged may or may not be called with (str). + # http://pyqt.sourceforge.net/Docs/PyQt5/signals_slots.html#connecting-slots-by-name + # mentions connectSlotsByName(), + # but we're using QSpinBox().valueChanged.connect() and my assert never fails. + # Either way, @pyqtSlot(value_type) will ward off incorrect calls. + + +def try_behead(string: str, header: str) -> Optional[str]: + if not string.startswith(header): + return None + return string[len(header):] + + +def behead(string: str, header: str) -> str: + if not string.startswith(header): + raise ValueError(f'{string} does not start with {header}') + return string[len(header):] + + +DUNDER = '__' + +# https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288 +def rgetattr(obj, dunder_delim_path: str, *default): + """ + :param obj: Object + :param dunder_delim_path: 'attr1__attr2__etc' + :param default: Optional default value, at any point in the path + :return: obj.attr1.attr2.etc + """ + def _getattr(obj, attr): + return getattr(obj, attr, *default) + + attrs = dunder_delim_path.split(DUNDER) + return functools.reduce(_getattr, [obj] + attrs) + + +def rhasattr(obj, dunder_delim_path: str): + try: + rgetattr(obj, dunder_delim_path) + return True + except AttributeError: + return False + +# https://stackoverflow.com/a/31174427/2683842 +def rsetattr(obj, dunder_delim_path: str, val): + """ + :param obj: Object + :param dunder_delim_path: 'attr1__attr2__etc' + :param val: obj.attr1.attr2.etc = val + """ + parent, _, name = dunder_delim_path.rpartition(DUNDER) + parent_obj = rgetattr(obj, parent) if parent else obj + + return setattr(parent_obj, name, val) diff --git a/ovgenpy/gui/gui.pro b/ovgenpy/gui/gui.pro new file mode 100644 index 0000000..dd98bc4 --- /dev/null +++ b/ovgenpy/gui/gui.pro @@ -0,0 +1,31 @@ +#------------------------------------------------- +# +# Project created by QtCreator 2018-09-03T02:05:33 +# +#------------------------------------------------- + +QT += core gui + +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +TARGET = gui +TEMPLATE = app + +# The following define makes your compiler emit warnings if you use +# any feature of Qt which has been marked as deprecated (the exact warnings +# depend on your compiler). Please consult the documentation of the +# deprecated API in order to know how to port your code away from it. +DEFINES += QT_DEPRECATED_WARNINGS + +# You can also make your code fail to compile if you use deprecated APIs. +# In order to do so, uncomment the following line. +# You can also select to disable deprecated APIs only up to a certain version of Qt. +#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 + + +SOURCES += + +HEADERS += + +FORMS += \ + mainwindow.ui diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui new file mode 100644 index 0000000..eb6c99e --- /dev/null +++ b/ovgenpy/gui/mainwindow.ui @@ -0,0 +1,421 @@ + + + MainWindow + + + MainWindow + + + + + + + + 0 + 0 + + + + Qt::ScrollBarAlwaysOn + + + true + + + + + 0 + 0 + + + + QLineEdit { +width: 0px; +} + + + + + + Global + + + + + + FPS + + + + + + + 1 + + + 999 + + + 10 + + + + + + + Amplification + + + + + + + 0.100000000000000 + + + + + + + + + + Channel Width + + + + + + Trigger Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Render Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Trigger Subsampling + + + + + + + 1 + + + + + + + Render Subsampling + + + + + + + 1 + + + + + + + + + + Appearance + + + + + + Background + + + + + + + bg + + + + + + + + + + + + + + Foreground + + + + + + + fg + + + + + + + + + + + + + + Line Width + + + + + + + 0.500000000000000 + + + 0.500000000000000 + + + + + + + + + + Layout + + + + + + Orientation + + + + + + + + + + Columns + + + + + + + + +   + + + + + + + Rows + + + + + + +   + + + + + + + + + Video Size + + + + + + + vs + + + + + + + + + + + + + + + + Master Audio + + + + + + / + + + + + + + &Browse... + + + + + + + + + + Oscilloscope Channels + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + &Add... + + + + + + + &Delete + + + + + + + + + + + + + + + + + + + &File + + + + + + + + + + + + + + + + &Open Project + + + + + &Save + + + + + &New Project + + + + + Save &As + + + + + &Quit + + + + + E&xit + + + + + &Play + + + + + &Render to File + + + + + + + diff --git a/ovgenpy/util.py b/ovgenpy/util.py index 4eb17a5..3a0e445 100644 --- a/ovgenpy/util.py +++ b/ovgenpy/util.py @@ -1,4 +1,5 @@ import os +import sys from contextlib import contextmanager from itertools import chain from pathlib import Path @@ -96,3 +97,6 @@ def pushd(new_dir: Union[Path, str]): finally: os.chdir(previous_dir) + +def perr(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) diff --git a/setup.py b/setup.py index 183a516..6c9657e 100644 --- a/setup.py +++ b/setup.py @@ -14,5 +14,6 @@ setup( 'numpy', 'scipy', 'click', 'ruamel.yaml', 'matplotlib', 'attrs>=18.2.0', + 'PyQt5', ] ) diff --git a/tests/test_gui_binding.py b/tests/test_gui_binding.py new file mode 100644 index 0000000..762746a --- /dev/null +++ b/tests/test_gui_binding.py @@ -0,0 +1,55 @@ +import pytest + +from ovgenpy.gui.data_bind import rgetattr, rsetattr, rhasattr + + +def test_rgetattr(): + """ Test to ensure recursive model access works. + GUI elements are named "prefix__" "recursive__attr" and bind to recursive.attr. + + https://stackoverflow__com/a/31174427/ + """ + + class Person(object): + def __init__(self): + self.pet = Pet() + self.residence = Residence() + + class Pet(object): + def __init__(self, name='Fido', species='Dog'): + self.name = name + self.species = species + + class Residence(object): + def __init__(self, type='House', sqft=None): + self.type = type + self.sqft = sqft + + p = Person() + + # Test rgetattr(present) + assert rgetattr(p, 'pet__species') == 'Dog' + assert rgetattr(p, 'pet__species', object()) == 'Dog' + + # Test rgetattr(missing) + assert rgetattr(p, 'pet__ghost__species', 'calico') == 'calico' + with pytest.raises(AttributeError): + # Without a default argument, `rgetattr`, like `getattr`, raises + # AttributeError when the dotted attribute is missing + print(rgetattr(p, 'pet__ghost__species')) + + # Test rsetattr() + rsetattr(p, 'pet__name', 'Sparky') + rsetattr(p, 'residence__type', 'Apartment') + assert p.pet.name == 'Sparky' + assert p.residence.type == 'Apartment' + + # Test rhasattr() + assert rhasattr(p, 'pet') + assert rhasattr(p, 'pet__name') + + # Test rhasattr(levels of missing) + assert not rhasattr(p, 'pet__ghost') + assert not rhasattr(p, 'pet__ghost__species') + assert not rhasattr(p, 'ghost') + assert not rhasattr(p, 'ghost__species') From be3d537500e3233e32636b3e288b4e4de015735a Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 20:04:07 -0800 Subject: [PATCH 002/102] Fix ChannelModel data() recursion error, implement setData --- ovgenpy/gui/__init__.py | 144 +++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 76 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 234cd33..18811b2 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -11,7 +11,7 @@ from PyQt5.QtCore import QModelIndex, Qt from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead -from ovgenpy.ovgenpy import Config, default_config +from ovgenpy.ovgenpy import Config from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import perr, obj_name @@ -58,7 +58,6 @@ class MainWindow(qw.QMainWindow): # Abstract Item Model.data[idx] == QVariant (impl in subclass, wraps data) # Standard Item Model.item[r,c] == QStandardItem (it IS the data) # Explanation: https://doc.qt.io/qt-5/modelview.html#3-3-predefined-models - # items.setData(items.index(0,0), item) model: 'ConfigModel' channel_model: 'ChannelModel' @@ -145,39 +144,27 @@ class ConfigModel(PresentationModel): # TODO mutate _cfg and convert all colors to #rrggbb on access -class ChannelWidget(qw.QWidget): - """ Widget bound to a single ChannelModel. """ - - def __init__(self, cfg: ChannelConfig): - super().__init__() - uic.loadUi(res('channel_widget.ui'), self) - - self.model = ChannelModel(cfg) - map_gui(self, self.model) - - # FIXME uncomment? - # self.show() - - T = TypeVar('T') -# def coalesce_property(key: str, default: T) -> property: -# def get(self: 'ChannelModel') -> T: -# val: Optional[T] = getattr(self._cfg, key) -# if val is None: -# return default -# return val -# -# def set(self: 'ChannelModel', val: T): -# if val == default: -# val = None -# setattr(self._cfg, key, val) -# -# return property(get, set) +nope = qc.QVariant() + + +@attr.dataclass +class Column: + key: str + default: Any # FIXME unused + cls: Type = None + + def __attrs_post_init__(self): + if self.cls is None: + self.cls = type(self.default) + + # Idea: Add translatable display_name class ChannelModel(qc.QAbstractTableModel): + """ Design based off http://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model """ def __init__(self, channels: List[ChannelConfig]): """ Mutates `channels` for convenience. """ @@ -199,21 +186,28 @@ class ChannelModel(qc.QAbstractTableModel): self.triggers.append(trigger_dict) # columns - col_keys = ['wav_path', 'trigger_width', 'render_width', 'line_color', - 'trigger__'] + col_data = [ + Column('wav_path', ''), + Column('trigger_width', None, int), + Column('render_width', None, int), + Column('line_color', None, str), + # Column('trigger__)' + ] def columnCount(self, parent: QModelIndex = ...) -> int: - return len(self.col_keys) + return len(self.col_data) def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole): - nope = qc.QVariant() - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - col = section - try: - return self.col_keys[col] - except IndexError: - return nope + if role == Qt.DisplayRole: + if orientation == Qt.Horizontal: + col = section + try: + return self.col_data[col].key + except IndexError: + return nope + else: + return str(section) return nope # rows @@ -223,12 +217,12 @@ class ChannelModel(qc.QAbstractTableModel): # data TRIGGER = 'trigger__' - def data(self, index: QModelIndex, role=Qt.DisplayRole): - if role == Qt.DisplayRole: - col = index.column() - row = index.row() + def data(self, index: QModelIndex, role=Qt.DisplayRole) -> qc.QVariant: + col = index.column() + row = index.row() - key = self.col_keys[col] + if role == Qt.DisplayRole and index.isValid() and row < self.rowCount(): + key = self.col_data[col].key if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) return self.triggers[row].get(key, '') @@ -236,39 +230,37 @@ class ChannelModel(qc.QAbstractTableModel): else: return getattr(self.channels[row], key) - # try: - # return getattr(self, 'cfg__' + key) - # except AttributeError: - # pass - # if key.startswith('trigger__') and cfg.trigger is None: - # return rgetattr(cfg, key, '') - # else: - # return rgetattr(cfg, key) + return nope - # DEFAULT = object() - # if val is DEFAULT: - # # Trigger attributes can be missing, all others must be present. - # assert key.startswith('trigger__') - # return '' - # else: - # return val - return super().data(index, role) + 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 != '': + try: + value = data.cls(value) + except ValueError as e: + # raise OvgenError(e) + return False + else: + value = data.default + + if key.startswith(self.TRIGGER): + key = behead(key, self.TRIGGER) + self.triggers[row][key] = value + + else: + setattr(self.channels[row], key, value) - def setData(self, index: QModelIndex, value, role=Qt.EditRole) -> bool: - if role == Qt.EditRole: - # FIXME what type is value? str or not? - perr(repr(role)) return True - return super().setData(index, value, role) - - def flags(self, index: QModelIndex) -> Qt.ItemFlags: - return super().flags(index) - - # _cfg: ChannelConfig - # combo_symbols = {} - # combo_text = {} - # - # trigger_width = coalesce_property('trigger_width', 1) - # render_width = coalesce_property('render_width', 1) - # line_color = coalesce_property('line_color', '') # TODO + return False + def flags(self, index: QModelIndex): + if not index.isValid(): + return Qt.ItemIsEnabled + return qc.QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable From be1e9f6563ea6a868547a15b6027e1dfedb53d55 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 21:11:04 -0800 Subject: [PATCH 003/102] [channels] Replace __attrs_post_init__ with self-factory --- ovgenpy/gui/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 18811b2..7ab6571 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -154,11 +154,7 @@ nope = qc.QVariant() class Column: key: str default: Any # FIXME unused - cls: Type = None - - def __attrs_post_init__(self): - if self.cls is None: - self.cls = type(self.default) + cls: Type = attr.Factory(lambda self: type(self.default), takes_self=True) # Idea: Add translatable display_name From 4b69fb2d06ba71ca52cba4fced25a013ad035dce Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 21:19:14 -0800 Subject: [PATCH 004/102] [channels] Add column display_name --- ovgenpy/gui/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 7ab6571..3a2a77e 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -154,9 +154,14 @@ nope = qc.QVariant() class Column: key: str default: Any # FIXME unused - cls: Type = attr.Factory(lambda self: type(self.default), takes_self=True) - # Idea: Add translatable display_name + def _cls(self) -> Type: + return type(self.default) + cls: Type = attr.Factory(_cls, takes_self=True) + + def _display_name(self) -> str: + return self.key.replace('_', ' ').title() + display_name: str = attr.Factory(_display_name, takes_self=True) class ChannelModel(qc.QAbstractTableModel): @@ -183,10 +188,10 @@ class ChannelModel(qc.QAbstractTableModel): # columns col_data = [ - Column('wav_path', ''), - Column('trigger_width', None, int), - Column('render_width', None, int), - Column('line_color', None, str), + Column('wav_path', '', str, 'WAV Path'), + Column('trigger_width', None, int, 'Trigger Width ×'), + Column('render_width', None, int, 'Render Width ×'), + Column('line_color', None, str, 'Line Color'), # Column('trigger__)' ] @@ -199,7 +204,7 @@ class ChannelModel(qc.QAbstractTableModel): if orientation == Qt.Horizontal: col = section try: - return self.col_data[col].key + return self.col_data[col].display_name except IndexError: return nope else: From 926b7456522b266ee06b16166d6dac63dd2103dd Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 21:19:42 -0800 Subject: [PATCH 005/102] Index table rows from 1 --- ovgenpy/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3a2a77e..4a10dc5 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -208,7 +208,7 @@ class ChannelModel(qc.QAbstractTableModel): except IndexError: return nope else: - return str(section) + return str(section + 1) return nope # rows From 36c4f82071ca3e990ec30de56d7cbb637ab56356 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 21:30:03 -0800 Subject: [PATCH 006/102] Expand window by default --- ovgenpy/gui/mainwindow.ui | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index eb6c99e..0fe0af7 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -2,6 +2,14 @@ MainWindow + + + 0 + 0 + 960 + 640 + + MainWindow From 663015ad2ca7c0b2095daa1bbdc3e00fb0acf04e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 21:38:35 -0800 Subject: [PATCH 007/102] Switch UI to use "Line Color" consistently --- ovgenpy/gui/mainwindow.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 0fe0af7..4c32e82 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -186,7 +186,7 @@ width: 0px; - Foreground + Line Color From b49618cfbceab0bc567ca400251b56ebdfe49101 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 22:24:20 -0800 Subject: [PATCH 008/102] Add ability to edit existing data (as str) Editing as int/float is bad, since users cannot easily blank out a numeric field (and setting =0 is unintuitive). --- ovgenpy/gui/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 4a10dc5..7047a29 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -222,14 +222,20 @@ class ChannelModel(qc.QAbstractTableModel): col = index.column() row = index.row() - if role == Qt.DisplayRole and index.isValid() and row < self.rowCount(): - key = self.col_data[col].key + 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) - return self.triggers[row].get(key, '') + value = self.triggers[row].get(key, '') else: - return getattr(self.channels[row], key) + value = getattr(self.channels[row], key) + + if value == data.default: + return '' + else: + return str(value) return nope @@ -242,7 +248,7 @@ class ChannelModel(qc.QAbstractTableModel): data = self.col_data[col] key = data.key - if value != '': + if value: try: value = data.cls(value) except ValueError as e: From 04da8a3f274a952eb932f8e808d78e1bfaf0ffe5 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 22:51:21 -0800 Subject: [PATCH 009/102] Treat whitespace-only GUI inputs as default value --- ovgenpy/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 7047a29..d046b57 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -248,7 +248,7 @@ class ChannelModel(qc.QAbstractTableModel): data = self.col_data[col] key = data.key - if value: + if value and not value.isspace(): try: value = data.cls(value) except ValueError as e: From 93d65ee6370d9474ff2e9696b215d7b4efe2aeb4 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 22:56:23 -0800 Subject: [PATCH 010/102] Change order of Column dataclass --- ovgenpy/gui/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index d046b57..2ac817f 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -153,11 +153,8 @@ nope = qc.QVariant() @attr.dataclass class Column: key: str - default: Any # FIXME unused - - def _cls(self) -> Type: - return type(self.default) - cls: Type = attr.Factory(_cls, takes_self=True) + cls: Type + default: Any def _display_name(self) -> str: return self.key.replace('_', ' ').title() @@ -188,11 +185,10 @@ class ChannelModel(qc.QAbstractTableModel): # columns col_data = [ - Column('wav_path', '', str, 'WAV Path'), - Column('trigger_width', None, int, 'Trigger Width ×'), - Column('render_width', None, int, 'Render Width ×'), - Column('line_color', None, str, 'Line Color'), - # Column('trigger__)' + Column('wav_path', str, '', 'WAV Path'), + Column('trigger_width', int, None, 'Trigger Width ×'), + Column('render_width', int, None, 'Render Width ×'), + Column('line_color', str, None, 'Line Color'), ] def columnCount(self, parent: QModelIndex = ...) -> int: From 532aaef187a602f25e7270ed1662d6a71300ce01 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 22:56:34 -0800 Subject: [PATCH 011/102] Add trigger table columns --- ovgenpy/gui/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 2ac817f..f12d9fa 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -189,6 +189,10 @@ class ChannelModel(qc.QAbstractTableModel): Column('trigger_width', int, None, 'Trigger Width ×'), Column('render_width', int, None, 'Render Width ×'), Column('line_color', str, None, 'Line Color'), + # TODO move from table view to sidebar QDataWidgetMapper? + Column('trigger__edge_strength', float, None), + Column('trigger__responsiveness', float, None), + Column('trigger__buffer_falloff', float, None), ] def columnCount(self, parent: QModelIndex = ...) -> int: From 201e6e3e78e983a4aed16e7a172c9b9edf330a45 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 23:04:16 -0800 Subject: [PATCH 012/102] Replace dunder with newline (table headers) --- ovgenpy/gui/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index f12d9fa..70308f6 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -157,7 +157,10 @@ class Column: default: Any def _display_name(self) -> str: - return self.key.replace('_', ' ').title() + return (self.key + .replace('__', '\n') + .replace('_', ' ') + .title()) display_name: str = attr.Factory(_display_name, takes_self=True) From 73147bac391fd477436c2b5e1a35c61267924f8d Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 23:17:38 -0800 Subject: [PATCH 013/102] Convert colors to hex, when loading config in GUI --- ovgenpy/gui/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 70308f6..2d0c3c5 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -3,6 +3,7 @@ from typing import * from pathlib import Path import attr +import matplotlib.colors from PyQt5 import uic import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw @@ -111,6 +112,14 @@ class ConfigModel(PresentationModel): combo_symbols = {} combo_text = {} + def __init__(self, cfg: Config): + """ Mutates colors for convenience. """ + super().__init__(cfg) + + for key in ['bg_color', 'init_line_color']: + color = getattr(cfg.render, key) + setattr(cfg.render, key, color2hex(color)) + @property def render_video_size(self) -> str: render = self._cfg.render @@ -168,11 +177,13 @@ class ChannelModel(qc.QAbstractTableModel): """ Design based off http://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model """ def __init__(self, channels: List[ChannelConfig]): - """ Mutates `channels` for convenience. """ + """ Mutates `channels` and `line_color` for convenience. """ super().__init__() self.channels = channels self.triggers: List[dict] = [] + line_color = 'line_color' + for cfg in self.channels: t = cfg.trigger if isinstance(t, ITriggerConfig): @@ -185,6 +196,8 @@ class ChannelModel(qc.QAbstractTableModel): cfg.trigger = trigger_dict self.triggers.append(trigger_dict) + if line_color in trigger_dict: + trigger_dict[line_color] = color2hex(trigger_dict[line_color]) # columns col_data = [ @@ -274,3 +287,7 @@ class ChannelModel(qc.QAbstractTableModel): if not index.isValid(): return Qt.ItemIsEnabled return qc.QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable + + +def color2hex(color): + return matplotlib.colors.to_hex(color, keep_alpha=False) From 80b9a3197f90a283595a9f803f9eea3b06f5e8ee Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 9 Dec 2018 23:23:52 -0800 Subject: [PATCH 014/102] Switch default per-channel trigger to empty dict Don't write empty GUI dicts --- ovgenpy/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/channel.py b/ovgenpy/channel.py index 3490ac1..885a4b6 100644 --- a/ovgenpy/channel.py +++ b/ovgenpy/channel.py @@ -18,7 +18,7 @@ class ChannelConfig: wav_path: str # Supplying a dict inherits attributes from global trigger. - trigger: Union[ITriggerConfig, dict, None] = None # TODO test channel-specific triggers + trigger: Union[ITriggerConfig, dict, None] = attr.Factory(dict) # TODO test channel-specific triggers # Multiplies how wide the window is, in milliseconds. trigger_width: Optional[int] = None render_width: Optional[int] = None From 1fc8b49ff99503c551822052cc887df526f68a44 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 08:58:41 -0800 Subject: [PATCH 015/102] [ui] Widen window, remove unfinished color swatches, add toolbar and stuff --- ovgenpy/gui/mainwindow.ui | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 4c32e82..7512090 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -6,7 +6,7 @@ 0 0 - 960 + 1080 640 @@ -161,7 +161,7 @@ width: 0px; Appearance - + @@ -176,13 +176,6 @@ width: 0px; - - - - - - - @@ -197,13 +190,6 @@ width: 0px; - - - - - - - @@ -382,29 +368,42 @@ width: 0px; + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + - &Open Project + &Open? - &Save + &Save? - &New Project + &New? - Save &As - - - - - &Quit + Save &As? From 1d5cdf8db0fb8399aa1f84a04c8b6a96c6c77428 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 09:02:48 -0800 Subject: [PATCH 016/102] [ui] Delete unused channel widget --- ovgenpy/gui/channel_widget.ui | 129 ---------------------------------- 1 file changed, 129 deletions(-) delete mode 100644 ovgenpy/gui/channel_widget.ui diff --git a/ovgenpy/gui/channel_widget.ui b/ovgenpy/gui/channel_widget.ui deleted file mode 100644 index 2374d62..0000000 --- a/ovgenpy/gui/channel_widget.ui +++ /dev/null @@ -1,129 +0,0 @@ - - - ChannelView - - - - 0 - 0 - - - - Form - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - - - wav_path_stem - - - - - - - - - - - - Browse - - - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - - - - - Trigger Width × - - - - - - - 1 - - - - - - - = twms - - - - - - - Render Width × - - - - - - - 1 - - - - - - - = rwms - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - - - - From 999546f094667e52d8ffe6b17ad32b1b7721cd9c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 14:58:14 -0800 Subject: [PATCH 017/102] Reorganize UI/config MainWindow.__init__() --- ovgenpy/gui/__init__.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 2d0c3c5..8d9a3b0 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -45,14 +45,18 @@ class MainWindow(qw.QMainWindow): def __init__(self, cfg: Config, cfg_dir: str): super().__init__() + + # Load UI. uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle self.setWindowTitle(APP_NAME) + # Bind UI buttons, etc. + self.master_audio_browse.clicked.connect(self.on_master_audio_browse) + + # Bind config to UI. self.cfg_dir = cfg_dir self.load_cfg(cfg) - self.master_audio_browse: qw.QPushButton - self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.show() # Complex changes are done in the presentation model's setters. @@ -60,18 +64,7 @@ class MainWindow(qw.QMainWindow): # Standard Item Model.item[r,c] == QStandardItem (it IS the data) # Explanation: https://doc.qt.io/qt-5/modelview.html#3-3-predefined-models - model: 'ConfigModel' - channel_model: 'ChannelModel' - - def load_cfg(self, cfg: Config): - # TODO unbind current model's slots if exists - # or maybe disconnect ALL connections?? - self.model = ConfigModel(cfg) - map_gui(self, self.model) - - self.channel_model = ChannelModel(cfg.channels) - self.channel_widget: qw.QTableView - self.channel_widget.setModel(self.channel_model) + master_audio_browse: qw.QPushButton def on_master_audio_browse(self): # TODO add default file-open dir, initialized to yaml path and remembers prev @@ -84,6 +77,19 @@ class MainWindow(qw.QMainWindow): self.model[master_audio] = name self.model.update_widget[master_audio]() + # Config models + model: 'ConfigModel' + channel_model: 'ChannelModel' + + def load_cfg(self, cfg: Config): + # TODO unbind current model's slots if exists + # or maybe disconnect ALL connections?? + self.model = ConfigModel(cfg) + map_gui(self, self.model) + + self.channel_model = ChannelModel(cfg.channels) + self.channel_widget: qw.QTableView + self.channel_widget.setModel(self.channel_model) def nrow_ncol_property(altered: str, unaltered: str) -> property: def get(self: 'ConfigModel'): From 6222ecf8120bcff7d35657c85f690f6a71356106 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 15:03:02 -0800 Subject: [PATCH 018/102] Expose PresentationModel.cfg to public --- ovgenpy/gui/__init__.py | 14 +++++++------- ovgenpy/gui/data_bind.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 8d9a3b0..de09f74 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -93,7 +93,7 @@ class MainWindow(qw.QMainWindow): def nrow_ncol_property(altered: str, unaltered: str) -> property: def get(self: 'ConfigModel'): - val = getattr(self._cfg.layout, altered) + val = getattr(self.cfg.layout, altered) if val is None: return 0 else: @@ -102,11 +102,11 @@ def nrow_ncol_property(altered: str, unaltered: str) -> property: def set(self: 'ConfigModel', val: int): perr(altered) if val > 0: - setattr(self._cfg.layout, altered, val) - setattr(self._cfg.layout, unaltered, None) + setattr(self.cfg.layout, altered, val) + setattr(self.cfg.layout, unaltered, None) self.update_widget['layout__' + unaltered]() elif val == 0: - setattr(self._cfg.layout, altered, None) + setattr(self.cfg.layout, altered, None) else: raise OvgenError(f"invalid input: {altered} < 0, should never happen") @@ -114,7 +114,7 @@ def nrow_ncol_property(altered: str, unaltered: str) -> property: class ConfigModel(PresentationModel): - _cfg: Config + cfg: Config combo_symbols = {} combo_text = {} @@ -128,7 +128,7 @@ class ConfigModel(PresentationModel): @property def render_video_size(self) -> str: - render = self._cfg.render + render = self.cfg.render w, h = render.width, render.height return f'{w}x{h}' @@ -143,7 +143,7 @@ class ConfigModel(PresentationModel): else: raise error - render = self._cfg.render + render = self.cfg.render width, height = width_height try: render.width = int(width) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 5465740..b13e3e0 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -29,7 +29,7 @@ class PresentationModel: combo_text: Dict[str, List[str]] def __init__(self, cfg: Attrs): - self._cfg = cfg + self.cfg = cfg self.update_widget: Dict[str, WidgetUpdater] = {} def __getitem__(self, item): @@ -37,15 +37,15 @@ class PresentationModel: # Custom properties return getattr(self, item) except AttributeError: - return rgetattr(self._cfg, item) + return rgetattr(self.cfg, item) def __setitem__(self, key, value): perr(f'{key} = {value}') # Custom properties if hasattr(self, key): setattr(self, key, value) - elif rhasattr(self._cfg, key): - rsetattr(self._cfg, key, value) + elif rhasattr(self.cfg, key): + rsetattr(self.cfg, key, value) else: raise AttributeError(f'cannot set attribute {key} on {obj_name(self)}()') From 25147e66bc6be2a3d05e731311142269dc671ef5 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 15:10:40 -0800 Subject: [PATCH 019/102] Bind Exit action --- ovgenpy/gui/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index de09f74..94135d0 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -52,6 +52,7 @@ class MainWindow(qw.QMainWindow): # Bind UI buttons, etc. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) + self.actionExit.triggered.connect(qw.QApplication.quit) # Bind config to UI. self.cfg_dir = cfg_dir @@ -65,6 +66,9 @@ class MainWindow(qw.QMainWindow): # Explanation: https://doc.qt.io/qt-5/modelview.html#3-3-predefined-models master_audio_browse: qw.QPushButton + # Loading mainwindow.ui changes menuBar from a getter to an attribute. + menuBar: qw.QMenuBar + actionExit: qw.QAction def on_master_audio_browse(self): # TODO add default file-open dir, initialized to yaml path and remembers prev From 47597f7663ab689c8cce414c5e8edddb6ab9c542 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 16:23:06 -0800 Subject: [PATCH 020/102] Add function to copy config objects --- ovgenpy/config.py | 49 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/ovgenpy/config.py b/ovgenpy/config.py index a73cb6b..2999359 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -1,5 +1,6 @@ -from io import StringIO -from typing import ClassVar, TYPE_CHECKING, Type +import pickle +from io import StringIO, BytesIO +from typing import ClassVar, TYPE_CHECKING, Type, TypeVar import attr from ruamel.yaml import yaml_object, YAML, Representer @@ -8,7 +9,7 @@ if TYPE_CHECKING: from enum import Enum -__all__ = ['yaml', +__all__ = ['yaml', 'copy_config', 'register_config', 'kw_config', 'Alias', 'Ignored', 'register_enum', 'OvgenError', 'OvgenWarning'] @@ -32,6 +33,35 @@ yaml = MyYAML() _yaml_loadable = yaml_object(yaml) +""" +Speed of copying objects: + +number = 100 +print(timeit.timeit(lambda: f(cfg), number=number)) + +- pickle_copy 0.0566s +- deepcopy 0.0967s +- yaml_copy 0.4875s + +pickle_copy is fastest. +""" + +T = TypeVar('T') + +# Unused +# def yaml_copy(obj: T) -> T: +# with StringIO() as stream: +# yaml.dump(obj, stream) +# return yaml.load(stream) + +# AKA pickle_copy +def copy_config(obj: T) -> T: + with BytesIO() as stream: + pickle.dump(obj, stream) + stream.seek(0) + return pickle.load(stream) + + # Setup configuration load/dump infrastructure. def register_config(cls=None, *, kw_only=False, always_dump: str = ''): @@ -77,10 +107,15 @@ class _ConfigMixin: cls = type(self) for field in attr.fields(cls): - name = field.name - value = getattr(self, name) + # Remove leading underscore from attribute name, + # since attrs __init__ removes leading underscore. - if dump_all or name in always_dump: + key = field.name + value = getattr(self, key) + + name = key[1:] if key[0] == '_' else key + + if dump_all or key in always_dump: state[name] = value continue @@ -160,5 +195,3 @@ class OvgenWarning(UserWarning): """ Warning about deprecated end-user config (YAML/GUI). (Should be) caught by GUI and displayed to user. """ pass - -ValidationError = OvgenError From 8551a46b246f4db3351ba8d60045d1e6bc6d9efa Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 16:25:46 -0800 Subject: [PATCH 021/102] [wip] Bind Play action (runs on main thread) TODO run on new thread to not block GUI --- ovgenpy/gui/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 94135d0..2d0141e 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -10,9 +10,10 @@ import PyQt5.QtWidgets as qw from PyQt5.QtCore import QModelIndex, Qt from ovgenpy.channel import ChannelConfig -from ovgenpy.config import OvgenError +from ovgenpy.config import OvgenError, copy_config from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead -from ovgenpy.ovgenpy import Config +from ovgenpy.outputs import FFplayOutputConfig +from ovgenpy.ovgenpy import Config, Ovgen from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import perr, obj_name @@ -53,6 +54,7 @@ class MainWindow(qw.QMainWindow): # Bind UI buttons, etc. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.actionExit.triggered.connect(qw.QApplication.quit) + self.actionPlay.triggered.connect(self.on_action_play) # Bind config to UI. self.cfg_dir = cfg_dir @@ -69,6 +71,7 @@ class MainWindow(qw.QMainWindow): # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar actionExit: qw.QAction + actionPlay: qw.QAction def on_master_audio_browse(self): # TODO add default file-open dir, initialized to yaml path and remembers prev @@ -81,6 +84,12 @@ class MainWindow(qw.QMainWindow): self.model[master_audio] = name self.model.update_widget[master_audio]() + def on_action_play(self): + cfg = copy_config(self.model.cfg) + cfg_dir = self.cfg_dir + outputs = [FFplayOutputConfig()] + Ovgen(cfg, cfg_dir, outputs).play() + # Config models model: 'ConfigModel' channel_model: 'ChannelModel' From 83ff8bcfae524f6b6ae04d6d08f6ebc8d4a1c5e2 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 14:57:33 -0800 Subject: [PATCH 022/102] cleanup comments --- ovgenpy/gui/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 2d0141e..ba519e9 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -61,11 +61,6 @@ class MainWindow(qw.QMainWindow): self.load_cfg(cfg) self.show() - # Complex changes are done in the presentation model's setters. - - # Abstract Item Model.data[idx] == QVariant (impl in subclass, wraps data) - # Standard Item Model.item[r,c] == QStandardItem (it IS the data) - # Explanation: https://doc.qt.io/qt-5/modelview.html#3-3-predefined-models master_audio_browse: qw.QPushButton # Loading mainwindow.ui changes menuBar from a getter to an attribute. @@ -104,6 +99,7 @@ class MainWindow(qw.QMainWindow): self.channel_widget: qw.QTableView self.channel_widget.setModel(self.channel_model) + def nrow_ncol_property(altered: str, unaltered: str) -> property: def get(self: 'ConfigModel'): val = getattr(self.cfg.layout, altered) From be3355b0c843ed10720302554e3793f54d244df4 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 18:26:49 -0800 Subject: [PATCH 023/102] Eliminate BrokenPipeError when closing ffplay --- ovgenpy/outputs.py | 29 +++++++++++++++++++++++------ ovgenpy/ovgenpy.py | 11 +++++++++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/ovgenpy/outputs.py b/ovgenpy/outputs.py index 4330af9..35c0ace 100644 --- a/ovgenpy/outputs.py +++ b/ovgenpy/outputs.py @@ -24,6 +24,13 @@ class IOutputConfig: return self.cls(ovgen_cfg, cfg=self) +class _Stop: + pass + + +Stop = _Stop() + + class Output(ABC): def __init__(self, ovgen_cfg: 'Config', cfg: IOutputConfig): self.ovgen_cfg = ovgen_cfg @@ -38,7 +45,7 @@ class Output(ABC): return self @abstractmethod - def write_frame(self, frame: bytes) -> None: + def write_frame(self, frame: bytes) -> Optional[_Stop]: """ Output a Numpy ndarray. """ def __exit__(self, exc_type, exc_val, exc_tb): @@ -117,11 +124,21 @@ class PipeOutput(Output): def __enter__(self): return self - def write_frame(self, frame: bytes) -> None: - self._stream.write(frame) + def write_frame(self, frame: bytes) -> Optional[_Stop]: + try: + self._stream.write(frame) + return None + except BrokenPipeError: + return Stop - def close(self) -> int: - self._stream.close() + def close(self, wait=True) -> int: + try: + self._stream.close() + except BrokenPipeError: + pass + + if not wait: + return 0 retval = 0 for popen in self._pipeline: @@ -135,7 +152,7 @@ class PipeOutput(Output): # Calling self.close() is bad. # If exception occurred but ffplay continues running. # popen.wait() will prevent stack trace from showing up. - self._stream.close() + self.close(wait=False) exc = None for popen in self._pipeline: diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index 5d2ccab..4e8991e 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -296,12 +296,19 @@ class Ovgen: if not_benchmarking or benchmark_mode >= BenchmarkMode.RENDER: # Render frame renderer.render_frame(render_datas) - frame = renderer.get_frame() + frame_data = renderer.get_frame() if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT: # Output frame + aborted = False for output in self.outputs: - output.write_frame(frame) + if output.write_frame(frame_data) is outputs_.Stop: + aborted = True + break + if aborted: + # Outputting frame happens after most computation finished. + end_frame = frame + 1 + break if self.raise_on_teardown: raise self.raise_on_teardown From 0c67b6333448edef6ab03f877b564d891669c987 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 18:53:29 -0800 Subject: [PATCH 024/102] Move gui.color2hex to gui/util.py --- ovgenpy/gui/__init__.py | 6 +----- ovgenpy/gui/util.py | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 ovgenpy/gui/util.py diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index ba519e9..8fe1911 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -3,7 +3,6 @@ from typing import * from pathlib import Path import attr -import matplotlib.colors from PyQt5 import uic import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw @@ -12,6 +11,7 @@ from PyQt5.QtCore import QModelIndex, Qt from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead +from ovgenpy.gui.util import color2hex from ovgenpy.outputs import FFplayOutputConfig from ovgenpy.ovgenpy import Config, Ovgen from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig @@ -302,7 +302,3 @@ class ChannelModel(qc.QAbstractTableModel): if not index.isValid(): return Qt.ItemIsEnabled return qc.QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable - - -def color2hex(color): - return matplotlib.colors.to_hex(color, keep_alpha=False) diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py new file mode 100644 index 0000000..9aefe18 --- /dev/null +++ b/ovgenpy/gui/util.py @@ -0,0 +1,5 @@ +import matplotlib.colors + + +def color2hex(color): + return matplotlib.colors.to_hex(color, keep_alpha=False) From 9836100b42ecacc83c0eeee8de8ca883ac82c48f Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 18:55:16 -0800 Subject: [PATCH 025/102] [gui] Move Ovgen.play() to OvgenThread --- ovgenpy/gui/__init__.py | 43 +++++++++++++++++++++++++++++++++++------ ovgenpy/gui/util.py | 25 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 8fe1911..dd37842 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -11,8 +11,8 @@ from PyQt5.QtCore import QModelIndex, Qt from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead -from ovgenpy.gui.util import color2hex -from ovgenpy.outputs import FFplayOutputConfig +from ovgenpy.gui.util import color2hex, Locked +from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig from ovgenpy.ovgenpy import Config, Ovgen from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import perr, obj_name @@ -56,6 +56,9 @@ class MainWindow(qw.QMainWindow): self.actionExit.triggered.connect(qw.QApplication.quit) self.actionPlay.triggered.connect(self.on_action_play) + # Initialize ovgen-thread attribute. + self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None) + # Bind config to UI. self.cfg_dir = cfg_dir self.load_cfg(cfg) @@ -80,10 +83,23 @@ class MainWindow(qw.QMainWindow): self.model.update_widget[master_audio]() def on_action_play(self): - cfg = copy_config(self.model.cfg) - cfg_dir = self.cfg_dir - outputs = [FFplayOutputConfig()] - Ovgen(cfg, cfg_dir, outputs).play() + with self.ovgen_thread as t: + if t is not None: + qw.QMessageBox.critical( + self, + 'Error', + 'Cannot play, another play/render is active', + ) + return + + cfg = copy_config(self.model.cfg) + cfg_dir = self.cfg_dir + outputs = [FFplayOutputConfig()] + + t = self.ovgen_thread.set( + OvgenThread(self, cfg, cfg_dir, outputs)) + # Assigns self.ovgen_thread.set(None) when finished. + t.start() # Config models model: 'ConfigModel' @@ -100,6 +116,21 @@ class MainWindow(qw.QMainWindow): self.channel_widget.setModel(self.channel_model) +class OvgenThread(qc.QThread): + + def __init__(self, parent: MainWindow, + cfg: Config, cfg_dir: str, outputs: List[IOutputConfig]): + qc.QThread.__init__(self) + + def run() -> None: + Ovgen(cfg, cfg_dir, outputs).play() + self.run = run + + def finished(): + parent.ovgen_thread.set(None) + self.finished.connect(finished) + + def nrow_ncol_property(altered: str, unaltered: str) -> property: def get(self: 'ConfigModel'): val = getattr(self.cfg.layout, altered) diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 9aefe18..d5b39cc 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -1,5 +1,30 @@ +from typing import * +from PyQt5.QtCore import QMutex import matplotlib.colors def color2hex(color): return matplotlib.colors.to_hex(color, keep_alpha=False) + + +T = TypeVar('T') + + +class Locked(Generic[T]): + """ Based off https://stackoverflow.com/a/37606669 """ + def __init__(self, obj: T): + super().__init__() + self.__obj = obj + self.lock = QMutex(QMutex.Recursive) + + def __enter__(self) -> T: + self.lock.lock() + return self.__obj + + def __exit__(self, *args, **kwargs): + self.lock.unlock() + + def set(self, value: T) -> T: + with self: + self.__obj = value + return value From 82e56d24be211f3742a681c72778caba3f7ef070 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 19:33:11 -0800 Subject: [PATCH 026/102] [!] Unlock thread when showing "render in progress" dialog --- ovgenpy/gui/__init__.py | 2 ++ ovgenpy/gui/util.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index dd37842..78f837f 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -85,6 +85,8 @@ class MainWindow(qw.QMainWindow): def on_action_play(self): with self.ovgen_thread as t: if t is not None: + # FIXME does it work? i was not thinking clearly when i wrote this + self.ovgen_thread.unlock() qw.QMessageBox.critical( self, 'Error', diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index d5b39cc..0ab744a 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -12,17 +12,27 @@ T = TypeVar('T') class Locked(Generic[T]): """ Based off https://stackoverflow.com/a/37606669 """ + def __init__(self, obj: T): super().__init__() self.__obj = obj self.lock = QMutex(QMutex.Recursive) + self.skip_exit = False def __enter__(self) -> T: self.lock.lock() return self.__obj + def unlock(self): + if not self.skip_exit: + self.skip_exit = True + self.lock.unlock() + def __exit__(self, *args, **kwargs): - self.lock.unlock() + if self.skip_exit: + self.skip_exit = False + else: + self.lock.unlock() def set(self, value: T) -> T: with self: From 565ea2f24c6184f35f8a1917ef7aaed8ccc9ef5e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 20:19:00 -0800 Subject: [PATCH 027/102] Add keyboard shortcuts --- ovgenpy/gui/mainwindow.ui | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 7512090..5b6308a 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -390,35 +390,56 @@ width: 0px; &Open? + + Ctrl+O + &Save? + + Ctrl+S + &New? + + Ctrl+N + Save &As? + + Ctrl+Shift+S + E&xit + + Ctrl+Q + &Play + + Ctrl+P + - &Render to File + &Render to Video + + + Ctrl+R From c5ae077ea502f2a62f4134ccf4adab6432464244 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Mon, 10 Dec 2018 20:25:01 -0800 Subject: [PATCH 028/102] [wip] Bind render action (but no status/cancel) --- ovgenpy/gui/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 78f837f..2aa0f86 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -12,7 +12,7 @@ from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead from ovgenpy.gui.util import color2hex, Locked -from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig +from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import Config, Ovgen from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import perr, obj_name @@ -55,6 +55,7 @@ class MainWindow(qw.QMainWindow): self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.actionExit.triggered.connect(qw.QApplication.quit) self.actionPlay.triggered.connect(self.on_action_play) + self.actionRender.triggered.connect(self.on_action_render) # Initialize ovgen-thread attribute. self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None) @@ -70,6 +71,7 @@ class MainWindow(qw.QMainWindow): menuBar: qw.QMenuBar actionExit: qw.QAction actionPlay: qw.QAction + actionRender: qw.QAction def on_master_audio_browse(self): # TODO add default file-open dir, initialized to yaml path and remembers prev @@ -83,6 +85,21 @@ class MainWindow(qw.QMainWindow): self.model.update_widget[master_audio]() def on_action_play(self): + outputs = [FFplayOutputConfig()] + error_msg = 'Cannot play, another play/render is active' + self.play_thread(outputs, error_msg) + + def on_action_render(self): + name, file_type = qw.QFileDialog.getSaveFileName( + self, "Render to Video", filter="MP4 files (*.mp4);;All files (*)" + ) + if name != '': + outputs = [FFmpegOutputConfig(name)] + self.play_thread( + outputs, 'Cannot render to file, another play/render is active' + ) + + def play_thread(self, outputs: List[IOutputConfig], error_msg: str): with self.ovgen_thread as t: if t is not None: # FIXME does it work? i was not thinking clearly when i wrote this @@ -90,13 +107,12 @@ class MainWindow(qw.QMainWindow): qw.QMessageBox.critical( self, 'Error', - 'Cannot play, another play/render is active', + error_msg, ) return cfg = copy_config(self.model.cfg) cfg_dir = self.cfg_dir - outputs = [FFplayOutputConfig()] t = self.ovgen_thread.set( OvgenThread(self, cfg, cfg_dir, outputs)) From 207b8815c53b49997451a43f361e34348a2ceef3 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 04:38:20 -0800 Subject: [PATCH 029/102] Print widget at fault, when GUI binding error occurs --- ovgenpy/gui/data_bind.py | 54 ++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index b13e3e0..626a87b 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -141,36 +141,42 @@ class DirectBinding: def direct_bind(widget: QWidget, model: PresentationModel, path: str, bind: DirectBinding): - def update_widget(): - """ Update the widget without triggering signals. + try: + def update_widget(): + """ Update the widget without triggering signals. - When the presentation model updates dependent widget 1, - the model (not widget 1) is responsible for updating other - dependent widgets. - """ - # FIXME add option to send signals - with qc.QSignalBlocker(widget): - bind.set_widget(model[path]) + When the presentation model updates dependent widget 1, + the model (not widget 1) is responsible for updating other + dependent widgets. + """ + # FIXME add option to send signals + with qc.QSignalBlocker(widget): + bind.set_widget(model[path]) - update_widget() + update_widget() - # Allow widget to be updated by other events. - model.update_widget[path] = update_widget + # Allow widget to be updated by other events. + model.update_widget[path] = update_widget - # Allow model to be changed by widget. - if bind.widget_changed is not None: - @pyqtSlot(bind.value_type) - def set_model(value): - assert isinstance(value, bind.value_type) - model[path] = value + # Allow model to be changed by widget. + if bind.widget_changed is not None: + @pyqtSlot(bind.value_type) + def set_model(value): + assert isinstance(value, bind.value_type) + model[path] = value - bind.widget_changed.connect(set_model) + bind.widget_changed.connect(set_model) - # QSpinBox.valueChanged may or may not be called with (str). - # http://pyqt.sourceforge.net/Docs/PyQt5/signals_slots.html#connecting-slots-by-name - # mentions connectSlotsByName(), - # but we're using QSpinBox().valueChanged.connect() and my assert never fails. - # Either way, @pyqtSlot(value_type) will ward off incorrect calls. + # QSpinBox.valueChanged may or may not be called with (str). + # http://pyqt.sourceforge.net/Docs/PyQt5/signals_slots.html#connecting-slots-by-name + # mentions connectSlotsByName(), + # but we're using QSpinBox().valueChanged.connect() and my assert never fails. + # Either way, @pyqtSlot(value_type) will ward off incorrect calls. + + except Exception: + perr(widget) + perr(path) + raise def try_behead(string: str, header: str) -> Optional[str]: From 779792933821c7e1ec2896a32cb46fea6ef2bd9e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 04:53:48 -0800 Subject: [PATCH 030/102] Cleanup ffplay logs, disable ffmpeg printouts --- ovgenpy/outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/outputs.py b/ovgenpy/outputs.py index 35c0ace..026a3cb 100644 --- a/ovgenpy/outputs.py +++ b/ovgenpy/outputs.py @@ -215,7 +215,7 @@ class FFplayOutput(PipeOutput): def __init__(self, ovgen_cfg: 'Config', cfg: FFplayOutputConfig): super().__init__(ovgen_cfg, cfg) - ffmpeg = _FFmpegProcess([FFMPEG], ovgen_cfg) + ffmpeg = _FFmpegProcess([FFMPEG, '-nostats'], ovgen_cfg) ffmpeg.add_output(cfg) ffmpeg.templates.append('-f nut') From 85caa921ecd6dea0a93ac98a6732238583882a5e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 06:12:16 -0800 Subject: [PATCH 031/102] Fix GUI when cfg.render.line_width missing --- ovgenpy/gui/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 2aa0f86..3a08361 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -171,6 +171,19 @@ def nrow_ncol_property(altered: str, unaltered: str) -> property: return property(get, set) +def default_property(path: str, default): + def getter(self: 'ConfigModel'): + val = rgetattr(self.cfg, path) + if val is None: + return default + else: + return val + + def setter(self: 'ConfigModel', val: int): + rsetattr(self.cfg, path, val) + + return property(getter, setter) + class ConfigModel(PresentationModel): cfg: Config combo_symbols = {} @@ -214,6 +227,8 @@ class ConfigModel(PresentationModel): combo_symbols['layout__orientation'] = ['h', 'v'] combo_text['layout__orientation'] = ['Horizontal', 'Vertical'] + render__line_width = default_property('render__line_width', 1.5) + # TODO mutate _cfg and convert all colors to #rrggbb on access From 1b63f66d477fc2e898e4a5e01140f9d4ec36f2a5 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 06:12:54 -0800 Subject: [PATCH 032/102] Add Ovgen progress callbacks, switch to Arguments parameter --- ovgenpy/cli.py | 5 ++-- ovgenpy/gui/__init__.py | 62 ++++++++++++++++++++++++++++++----------- ovgenpy/ovgenpy.py | 45 +++++++++++++++++++++++------- tests/test_channel.py | 4 +-- tests/test_cli.py | 4 +-- tests/test_output.py | 12 ++++---- 6 files changed, 94 insertions(+), 38 deletions(-) diff --git a/ovgenpy/cli.py b/ovgenpy/cli.py index dac465f..59826b4 100644 --- a/ovgenpy/cli.py +++ b/ovgenpy/cli.py @@ -7,7 +7,7 @@ import click from ovgenpy.channel import ChannelConfig from ovgenpy.config import yaml from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig -from ovgenpy.ovgenpy import default_config, Config, Ovgen +from ovgenpy.ovgenpy import default_config, Ovgen, Config, Arguments Folder = click.Path(exists=True, file_okay=False) @@ -178,7 +178,8 @@ def main( if outputs: assert Ovgen # to prevent PyCharm from deleting the import - command = 'Ovgen(cfg, cfg_dir, outputs).play()' + arg = Arguments(cfg_dir=cfg_dir, outputs=outputs) + command = 'Ovgen(cfg, arg).play()' if profile: import cProfile diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3a08361..1801df4 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -10,12 +10,12 @@ from PyQt5.QtCore import QModelIndex, Qt from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config -from ovgenpy.gui.data_bind import PresentationModel, map_gui, rgetattr, behead +from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr from ovgenpy.gui.util import color2hex, Locked from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig -from ovgenpy.ovgenpy import Config, Ovgen +from ovgenpy.ovgenpy import Ovgen, Config, Arguments from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig -from ovgenpy.util import perr, obj_name +from ovgenpy.util import perr, obj_name, coalesce APP_NAME = 'ovgenpy' APP_DIR = Path(__file__).parent @@ -85,21 +85,42 @@ class MainWindow(qw.QMainWindow): self.model.update_widget[master_audio]() def on_action_play(self): - outputs = [FFplayOutputConfig()] + """ Launch ovgen and ffplay. """ + + # FIXME remove dialog from play + dlg = OvgenProgressDialog(self) + arg = self._get_args([FFplayOutputConfig()], dlg) error_msg = 'Cannot play, another play/render is active' - self.play_thread(outputs, error_msg) + self.play_thread(arg, error_msg) def on_action_render(self): + """ Get file name. Then show a progress dialog while rendering to file. """ name, file_type = qw.QFileDialog.getSaveFileName( self, "Render to Video", filter="MP4 files (*.mp4);;All files (*)" ) if name != '': - outputs = [FFmpegOutputConfig(name)] - self.play_thread( - outputs, 'Cannot render to file, another play/render is active' + dlg = OvgenProgressDialog(self) + arg = self._get_args([FFmpegOutputConfig(name)], dlg) + error_msg = 'Cannot render to file, another play/render is active' + self.play_thread(arg, error_msg) + + def _get_args(self, outputs: List[IOutputConfig], + dlg: Optional['OvgenProgressDialog'] = None): + arg = Arguments( + cfg_dir=self.cfg_dir, + outputs=outputs, + ) + if dlg: + arg = attr.evolve(arg, + on_begin=dlg.on_begin, + progress=dlg.setValue, + is_aborted=dlg.wasCanceled, + on_end=dlg.reset, ) - def play_thread(self, outputs: List[IOutputConfig], error_msg: str): + return arg + + def play_thread(self, arg: Arguments, error_msg: str): with self.ovgen_thread as t: if t is not None: # FIXME does it work? i was not thinking clearly when i wrote this @@ -112,10 +133,8 @@ class MainWindow(qw.QMainWindow): return cfg = copy_config(self.model.cfg) - cfg_dir = self.cfg_dir - t = self.ovgen_thread.set( - OvgenThread(self, cfg, cfg_dir, outputs)) + t = self.ovgen_thread.set(OvgenThread(self, cfg, arg)) # Assigns self.ovgen_thread.set(None) when finished. t.start() @@ -135,13 +154,11 @@ class MainWindow(qw.QMainWindow): class OvgenThread(qc.QThread): - - def __init__(self, parent: MainWindow, - cfg: Config, cfg_dir: str, outputs: List[IOutputConfig]): + def __init__(self, parent: MainWindow, cfg: Config, arg: Arguments): qc.QThread.__init__(self) def run() -> None: - Ovgen(cfg, cfg_dir, outputs).play() + Ovgen(cfg, arg).play() self.run = run def finished(): @@ -149,6 +166,19 @@ class OvgenThread(qc.QThread): self.finished.connect(finished) +class OvgenProgressDialog(qw.QProgressDialog): + def __init__(self, parent: Optional[qw.QWidget]): + # flags = + super().__init__(parent) + + # If set to 0, the dialog is always shown as soon as any progress is set. + self.setMinimumDuration(0) + self.setAutoClose(False) + + def on_begin(self, begin_time, end_time): + self.setRange(int(round(begin_time)), int(round(end_time))) + + def nrow_ncol_property(altered: str, unaltered: str) -> property: def get(self: 'ConfigModel'): val = getattr(self.cfg.layout, altered) diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index 4e8991e..a99a37a 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -5,7 +5,7 @@ from contextlib import ExitStack, contextmanager from enum import unique, IntEnum from fractions import Fraction from types import SimpleNamespace -from typing import Optional, List, Union, TYPE_CHECKING +from typing import Optional, List, Union, TYPE_CHECKING, Callable import attr @@ -97,7 +97,7 @@ class Config: subsampling = self._subsampling self.trigger_subsampling = coalesce(self.trigger_subsampling, subsampling) self.render_subsampling = coalesce(self.render_subsampling, subsampling) - + # Compute trigger_ms and render_ms. width_ms = self._width_ms try: @@ -147,19 +147,33 @@ def default_config(**kwargs) -> Config: return attr.evolve(cfg, **kwargs) +BeginFunc = Callable[[float, float], None] +ProgressFunc = Callable[[int], None] +IsAborted = Callable[[], bool] + +@attr.dataclass +class Arguments: + cfg_dir: str + outputs: List[outputs_.IOutputConfig] + + on_begin: BeginFunc = lambda begin_time, end_time: None + progress: ProgressFunc = print + is_aborted: IsAborted = lambda: False + on_end: Callable[[], None] = lambda: None + class Ovgen: - def __init__(self, cfg: Config, cfg_dir: str, - outputs: List[outputs_.IOutputConfig]): + def __init__(self, cfg: Config, arg: Arguments): self.cfg = cfg - self.cfg_dir = cfg_dir + self.arg = arg self.has_played = False + # TODO test progress and is_aborted # TODO benchmark_mode/not_benchmarking == code duplication. benchmark_mode = self.cfg.benchmark_mode not_benchmarking = not benchmark_mode if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT: - self.output_cfgs = outputs + self.output_cfgs = arg.outputs else: self.output_cfgs = [] @@ -172,7 +186,7 @@ class Ovgen: nchan: int def _load_channels(self): - with pushd(self.cfg_dir): + with pushd(self.arg.cfg_dir): self.channels = [Channel(ccfg, self.cfg) for ccfg in self.cfg.channels] self.waves = [channel.wave for channel in self.channels] self.triggers = [channel.trigger for channel in self.channels] @@ -180,7 +194,7 @@ class Ovgen: @contextmanager def _load_outputs(self): - with pushd(self.cfg_dir): + with pushd(self.arg.cfg_dir): with ExitStack() as stack: self.outputs = [ stack.enter_context(output_cfg(self.cfg)) @@ -204,9 +218,12 @@ class Ovgen: begin_frame = round(fps * self.cfg.begin_time) - end_frame = fps * coalesce(self.cfg.end_time, self.waves[0].get_s()) + end_time = coalesce(self.cfg.end_time, self.waves[0].get_s()) + end_frame = fps * end_time end_frame = int(end_frame) + 1 + self.arg.on_begin(self.cfg.begin_time, end_time) + renderer = self._load_renderer() # region show_internals @@ -256,12 +273,19 @@ class Ovgen: # For each frame, render each wave for frame in range(begin_frame, end_frame): + if self.arg.is_aborted(): + # Used for FPS calculation + end_frame = frame + + # FIXME: does not kill ffmpeg/ffplay output + break + time_seconds = frame / fps should_render = (frame - begin_frame) % render_subfps == ahead rounded = int(time_seconds) if PRINT_TIMESTAMP and rounded != prev: - print(rounded) + self.arg.progress(rounded) prev = rounded render_datas = [] @@ -313,6 +337,7 @@ class Ovgen: if self.raise_on_teardown: raise self.raise_on_teardown + self.arg.on_end() if PRINT_TIMESTAMP: # noinspection PyUnboundLocalVariable dtime = time.perf_counter() - begin diff --git a/tests/test_channel.py b/tests/test_channel.py index 2745eab..e64fee4 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -10,7 +10,7 @@ import ovgenpy.channel import ovgenpy.ovgenpy from ovgenpy.channel import ChannelConfig, Channel from ovgenpy.config import OvgenError -from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode +from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode, Arguments from ovgenpy.triggers import NullTriggerConfig from ovgenpy.util import coalesce @@ -124,7 +124,7 @@ def test_config_channel_width_stride( assert trigger._stride == channel.trigger_stride ## Ensure ovgenpy calls render using channel.render_samp and render_stride. - ovgen = Ovgen(cfg, '.', outputs=[]) + ovgen = Ovgen(cfg, Arguments(cfg_dir='.', outputs=[])) renderer = mocker.patch.object(Ovgen, '_load_renderer').return_value ovgen.play() diff --git a/tests/test_cli.py b/tests/test_cli.py index f377e4d..18276ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,7 @@ from ovgenpy import cli from ovgenpy.cli import YAML_NAME from ovgenpy.config import yaml from ovgenpy.outputs import FFmpegOutputConfig -from ovgenpy.ovgenpy import Config, Ovgen +from ovgenpy.ovgenpy import Config, Ovgen, Arguments from ovgenpy.util import pushd if TYPE_CHECKING: @@ -132,7 +132,7 @@ def test_load_yaml_another_dir(yaml_sink, mocker, Popen): # Issue: this test does not use cli.main() to compute output path. # Possible solution: Call cli.main() via Click runner. output = FFmpegOutputConfig(cli.get_path(cfg.master_audio, cli.VIDEO_NAME)) - ovgen = Ovgen(cfg, subdir, [output]) + ovgen = Ovgen(cfg, Arguments(subdir, [output])) ovgen.play() # Compute absolute paths diff --git a/tests/test_output.py b/tests/test_output.py index 17bc337..3b1ae8c 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -7,7 +7,7 @@ import pytest from ovgenpy.channel import ChannelConfig from ovgenpy.outputs import RGB_DEPTH, \ FFmpegOutput, FFmpegOutputConfig, FFplayOutput, FFplayOutputConfig -from ovgenpy.ovgenpy import default_config, Ovgen +from ovgenpy.ovgenpy import default_config, Ovgen, Arguments from ovgenpy.renderer import RendererConfig, MatplotlibRenderer from tests.test_renderer import WIDTH, HEIGHT, ALL_ZEROS @@ -90,7 +90,7 @@ def test_ovgen_terminate_ffplay(Popen, mocker: 'pytest_mock.MockFixture'): Python exceptions occur. """ cfg = sine440_config() - ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()]) + ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()])) render_frame = mocker.patch.object(MatplotlibRenderer, 'render_frame') render_frame.side_effect = DummyException() @@ -110,7 +110,7 @@ def test_ovgen_terminate_works(): `popen.terminate()` is called. """ cfg = sine440_config() - ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()]) + ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()])) ovgen.raise_on_teardown = DummyException with pytest.raises(DummyException): @@ -127,7 +127,7 @@ def test_ovgen_output_without_audio(): cfg = sine440_config() cfg.master_audio = None - ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT]) + ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT])) # Should not raise exception. ovgen.play() @@ -157,7 +157,7 @@ def test_render_subfps_one(): cfg = sine440_config() cfg.render_subfps = 1 - ovgen = Ovgen(cfg, '.', outputs=[DummyOutputConfig()]) + ovgen = Ovgen(cfg, Arguments('.', [DummyOutputConfig()])) ovgen.play() assert DummyOutput.frames_written >= 2 @@ -177,7 +177,7 @@ def test_render_subfps_non_integer(mocker: 'pytest_mock.MockFixture'): assert cfg.render_fps != int(cfg.render_fps) assert Fraction(1) == int(1) - ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT]) + ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT])) ovgen.play() # But it seems FFmpeg actually allows decimal -framerate (although a bad idea). From 4d115eb02a43a2426bbe1ef8ccbaa076f83294b4 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 06:11:20 -0800 Subject: [PATCH 033/102] reformat --- ovgenpy/gui/__init__.py | 8 +------- ovgenpy/gui/data_bind.py | 2 +- ovgenpy/gui/util.py | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 1801df4..fc6891a 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -123,13 +123,8 @@ class MainWindow(qw.QMainWindow): def play_thread(self, arg: Arguments, error_msg: str): with self.ovgen_thread as t: if t is not None: - # FIXME does it work? i was not thinking clearly when i wrote this self.ovgen_thread.unlock() - qw.QMessageBox.critical( - self, - 'Error', - error_msg, - ) + qw.QMessageBox.critical(self, 'Error', error_msg) return cfg = copy_config(self.model.cfg) @@ -168,7 +163,6 @@ class OvgenThread(qc.QThread): class OvgenProgressDialog(qw.QProgressDialog): def __init__(self, parent: Optional[qw.QWidget]): - # flags = super().__init__(parent) # If set to 0, the dialog is always shown as soon as any progress is set. diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 626a87b..76f619c 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -149,7 +149,7 @@ def direct_bind(widget: QWidget, model: PresentationModel, path: str, bind: Dire the model (not widget 1) is responsible for updating other dependent widgets. """ - # FIXME add option to send signals + # TODO add option to send signals with qc.QSignalBlocker(widget): bind.set_widget(model[path]) diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 0ab744a..4fc151b 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -24,6 +24,7 @@ class Locked(Generic[T]): return self.__obj def unlock(self): + # FIXME does it work? i was not thinking clearly when i wrote this if not self.skip_exit: self.skip_exit = True self.lock.unlock() From bbdf79ffe55e6f3a00a8153bbd082215516d5b7b Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 07:53:35 -0800 Subject: [PATCH 034/102] Terminate ffmpeg/ffplay when cancelling GUI ovgen --- ovgenpy/outputs.py | 50 ++++++++++++++++++++-------------------------- ovgenpy/ovgenpy.py | 3 ++- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/ovgenpy/outputs.py b/ovgenpy/outputs.py index 026a3cb..ce6a942 100644 --- a/ovgenpy/outputs.py +++ b/ovgenpy/outputs.py @@ -51,6 +51,9 @@ class Output(ABC): def __exit__(self, exc_type, exc_val, exc_tb): pass + def terminate(self): + pass + # Glue logic def register_output(config_t: Type[IOutputConfig]): @@ -149,24 +152,27 @@ class PipeOutput(Output): if exc_type is None: self.close() else: - # Calling self.close() is bad. - # If exception occurred but ffplay continues running. - # popen.wait() will prevent stack trace from showing up. - self.close(wait=False) + self.terminate() - exc = None - for popen in self._pipeline: - popen.terminate() - # https://stackoverflow.com/a/49038779/2683842 - try: - popen.wait(1) # timeout=seconds - except subprocess.TimeoutExpired as e: - # gee thanks Python, https://stackoverflow.com/questions/45292479/ - exc = e - popen.kill() + def terminate(self): + # Calling self.close() is bad. + # If exception occurred but ffplay continues running, + # popen.wait() will prevent stack trace from showing up. + self.close(wait=False) - if exc: - raise exc + exc = None + for popen in self._pipeline: + popen.terminate() + # https://stackoverflow.com/a/49038779/2683842 + try: + popen.wait(1) # timeout=seconds + except subprocess.TimeoutExpired as e: + # gee thanks Python, https://stackoverflow.com/questions/45292479/ + exc = e + popen.kill() + + if exc: + raise exc # FFmpegOutput @@ -228,15 +234,3 @@ class FFplayOutput(PipeOutput): # assert p2.stdin is None # True unless Popen is being mocked (test_output). self.open(p1, p2) - - -# ImageOutput - -@register_config -class ImageOutputConfig(IOutputConfig): - path_prefix: str - - -@register_output(ImageOutputConfig) -class ImageOutput(Output): - pass diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index a99a37a..f83b7d5 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -277,7 +277,8 @@ class Ovgen: # Used for FPS calculation end_frame = frame - # FIXME: does not kill ffmpeg/ffplay output + for output in self.outputs: + output.terminate() break time_seconds = frame / fps From 305c49043e63763f5f570dec93c466a1ed0ee3ab Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 07:57:01 -0800 Subject: [PATCH 035/102] Close dialog after ovgen finishes, not approximately --- ovgenpy/gui/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index fc6891a..3c637ae 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -167,10 +167,16 @@ class OvgenProgressDialog(qw.QProgressDialog): # If set to 0, the dialog is always shown as soon as any progress is set. self.setMinimumDuration(0) - self.setAutoClose(False) + + # Don't reset when rendering is approximately finished. + self.setAutoReset(False) + + # Close after ovgen finishes. + self.setAutoClose(True) def on_begin(self, begin_time, end_time): self.setRange(int(round(begin_time)), int(round(end_time))) + # self.setValue is called by Ovgen, on the first frame. def nrow_ncol_property(altered: str, unaltered: str) -> property: From c479961dd80ba2982a30859c1f096c045a3d4715 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 09:02:49 -0800 Subject: [PATCH 036/102] Remove dialog from GUI play (only when rendering) --- ovgenpy/gui/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3c637ae..47e0dd3 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -86,10 +86,7 @@ class MainWindow(qw.QMainWindow): def on_action_play(self): """ Launch ovgen and ffplay. """ - - # FIXME remove dialog from play - dlg = OvgenProgressDialog(self) - arg = self._get_args([FFplayOutputConfig()], dlg) + arg = self._get_args([FFplayOutputConfig()]) error_msg = 'Cannot play, another play/render is active' self.play_thread(arg, error_msg) From 975a9b4d562d406483d1d8d5596824079c87909d Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 17:00:48 -0800 Subject: [PATCH 037/102] Set open-file default folder --- ovgenpy/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 47e0dd3..3dc8257 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -77,7 +77,7 @@ class MainWindow(qw.QMainWindow): # TODO add default file-open dir, initialized to yaml path and remembers prev # useless if people don't reopen old projects name, file_type = qw.QFileDialog.getOpenFileName( - self, "Open master audio file", filter="WAV files (*.wav)" + self, "Open master audio file", self.cfg_dir, "WAV files (*.wav)" ) if name != '': master_audio = 'master_audio' From 714c2de153e19f2aa0dc28d5e69e2b3fec3cb796 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 17:03:05 -0800 Subject: [PATCH 038/102] Delete ovgenpy.gui.__main__.py (you should launch via cli) --- ovgenpy/gui/__main__.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 ovgenpy/gui/__main__.py diff --git a/ovgenpy/gui/__main__.py b/ovgenpy/gui/__main__.py deleted file mode 100644 index 28bc012..0000000 --- a/ovgenpy/gui/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ovgenpy.gui import gui_main -from ovgenpy.ovgenpy import default_config - -gui_main(default_config(), '.') From 38e8b3ef21722c02dcc51231ef98003e7eb10b6d Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 17:19:46 -0800 Subject: [PATCH 039/102] Switch from cfg_dir to cfg_path, add default save dir cfg_dir defaults to master_audio or '.'. --- ovgenpy/cli.py | 15 +++++++++++---- ovgenpy/gui/__init__.py | 38 +++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ovgenpy/cli.py b/ovgenpy/cli.py index 59826b4..5c85925 100644 --- a/ovgenpy/cli.py +++ b/ovgenpy/cli.py @@ -36,12 +36,18 @@ YAML_NAME = YAML_EXTS[0] VIDEO_NAME = '.mp4' -def get_path(audio_file: Union[None, str, Path], ext: str) -> Path: +DEFAULT_NAME = 'ovgenpy' +def get_name(audio_file: Union[None, str, Path]) -> str: # Write file to current working dir, not audio dir. if audio_file: - name = Path(audio_file).name + name = Path(audio_file).stem else: - name = 'ovgenpy' + name = DEFAULT_NAME + return name + + +def get_path(audio_file: Union[None, str, Path], ext: str) -> Path: + name = get_name(audio_file) # Add extension return Path(name).with_suffix(ext) @@ -159,7 +165,8 @@ def main( if show_gui: from ovgenpy import gui - gui.gui_main(cfg, cfg_dir) + gui.gui_main(cfg, cfg_path) + else: if not files: raise click.UsageError('Must specify files or folders to play') diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3dc8257..2ad4307 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -1,13 +1,15 @@ +import os import sys -from typing import * from pathlib import Path +from typing import * -import attr -from PyQt5 import uic import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw +import attr +from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt +from ovgenpy import cli from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr @@ -15,7 +17,7 @@ from ovgenpy.gui.util import color2hex, Locked from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import Ovgen, Config, Arguments from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig -from ovgenpy.util import perr, obj_name, coalesce +from ovgenpy.util import perr, obj_name APP_NAME = 'ovgenpy' APP_DIR = Path(__file__).parent @@ -24,11 +26,11 @@ def res(file: str) -> str: return str(APP_DIR / file) -def gui_main(cfg: Config, cfg_dir: str): +def gui_main(cfg: Config, cfg_path: Optional[Path]): app = qw.QApplication(sys.argv) app.setAttribute(qc.Qt.AA_EnableHighDpiScaling) - window = MainWindow(cfg, cfg_dir) + window = MainWindow(cfg, cfg_path) sys.exit(app.exec_()) @@ -44,7 +46,7 @@ class MainWindow(qw.QMainWindow): load_cfg """ - def __init__(self, cfg: Config, cfg_dir: str): + def __init__(self, cfg: Config, cfg_path: Optional[Path]): super().__init__() # Load UI. @@ -61,11 +63,27 @@ class MainWindow(qw.QMainWindow): self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None) # Bind config to UI. - self.cfg_dir = cfg_dir + self._cfg_path = cfg_path self.load_cfg(cfg) self.show() + @property + def cfg_dir(self) -> str: + maybe_path = self._cfg_path or self.cfg.master_audio + if maybe_path: + return str(Path(maybe_path).resolve().parent) + + return '.' + + @property + def title(self) -> str: + return cli.get_name(self._cfg_path or self.cfg.master_audio) + + @property + def cfg(self): + return self.model.cfg + master_audio_browse: qw.QPushButton # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar @@ -92,8 +110,10 @@ class MainWindow(qw.QMainWindow): def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ + video_path = os.path.join(self.cfg_dir, self.title) + cli.VIDEO_NAME + name, file_type = qw.QFileDialog.getSaveFileName( - self, "Render to Video", filter="MP4 files (*.mp4);;All files (*)" + self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" ) if name != '': dlg = OvgenProgressDialog(self) From 1d8e2ab1f4331239cb7da6c51a3fa33b0aefc54c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 18:08:03 -0800 Subject: [PATCH 040/102] Add title, separate from file_stem --- ovgenpy/gui/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 2ad4307..e55e078 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -51,7 +51,6 @@ class MainWindow(qw.QMainWindow): # Load UI. uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle - self.setWindowTitle(APP_NAME) # Bind UI buttons, etc. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) @@ -66,8 +65,12 @@ class MainWindow(qw.QMainWindow): self._cfg_path = cfg_path self.load_cfg(cfg) + self.load_title() self.show() + def load_title(self): + self.setWindowTitle(f'{self.title} - {APP_NAME}') + @property def cfg_dir(self) -> str: maybe_path = self._cfg_path or self.cfg.master_audio @@ -76,8 +79,16 @@ class MainWindow(qw.QMainWindow): return '.' + UNTITLED = 'Untitled' + @property def title(self) -> str: + if self._cfg_path: + return self._cfg_path.name + return self.UNTITLED + + @property + def file_stem(self) -> str: return cli.get_name(self._cfg_path or self.cfg.master_audio) @property @@ -110,7 +121,7 @@ class MainWindow(qw.QMainWindow): def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ - video_path = os.path.join(self.cfg_dir, self.title) + cli.VIDEO_NAME + video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME name, file_type = qw.QFileDialog.getSaveFileName( self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" From 9a35c2c768753b88d77995c0542535ebc720c894 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 20:15:49 -0800 Subject: [PATCH 041/102] Remove double-locking mutex when launching play_thread() --- ovgenpy/gui/__init__.py | 2 +- ovgenpy/gui/util.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index e55e078..e22f1e9 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -157,7 +157,7 @@ class MainWindow(qw.QMainWindow): cfg = copy_config(self.model.cfg) - t = self.ovgen_thread.set(OvgenThread(self, cfg, arg)) + t = self.ovgen_thread.obj = OvgenThread(self, cfg, arg) # Assigns self.ovgen_thread.set(None) when finished. t.start() diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 4fc151b..4f2ec9e 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -15,13 +15,13 @@ class Locked(Generic[T]): def __init__(self, obj: T): super().__init__() - self.__obj = obj + self.obj = obj self.lock = QMutex(QMutex.Recursive) self.skip_exit = False def __enter__(self) -> T: self.lock.lock() - return self.__obj + return self.obj def unlock(self): # FIXME does it work? i was not thinking clearly when i wrote this @@ -37,5 +37,5 @@ class Locked(Generic[T]): def set(self, value: T) -> T: with self: - self.__obj = value + self.obj = value return value From 5e609f0ac56b38ee5025d95d2c1028a08fae0d88 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 20:31:48 -0800 Subject: [PATCH 042/102] [wip] Add saving to GUI (dialog not supported) --- ovgenpy/gui/__init__.py | 66 +++++++++++++++++++++++---------------- ovgenpy/gui/mainwindow.ui | 2 +- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index e22f1e9..63889a0 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -11,7 +11,7 @@ from PyQt5.QtCore import QModelIndex, Qt from ovgenpy import cli from ovgenpy.channel import ChannelConfig -from ovgenpy.config import OvgenError, copy_config +from ovgenpy.config import OvgenError, copy_config, yaml from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr from ovgenpy.gui.util import color2hex, Locked from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig @@ -52,9 +52,10 @@ class MainWindow(qw.QMainWindow): # Load UI. uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle - # Bind UI buttons, etc. + # Bind UI buttons, etc. Functions block main thread, avoiding race conditions. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.actionExit.triggered.connect(qw.QApplication.quit) + self.actionSave.triggered.connect(self.on_action_save) self.actionPlay.triggered.connect(self.on_action_play) self.actionRender.triggered.connect(self.on_action_render) @@ -68,37 +69,15 @@ class MainWindow(qw.QMainWindow): self.load_title() self.show() - def load_title(self): - self.setWindowTitle(f'{self.title} - {APP_NAME}') - @property - def cfg_dir(self) -> str: - maybe_path = self._cfg_path or self.cfg.master_audio - if maybe_path: - return str(Path(maybe_path).resolve().parent) - - return '.' - - UNTITLED = 'Untitled' - - @property - def title(self) -> str: - if self._cfg_path: - return self._cfg_path.name - return self.UNTITLED - - @property - def file_stem(self) -> str: - return cli.get_name(self._cfg_path or self.cfg.master_audio) - - @property - def cfg(self): - return self.model.cfg + def ever_saved(self): + return self._cfg_path is not None master_audio_browse: qw.QPushButton # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar actionExit: qw.QAction + actionSave: qw.QAction actionPlay: qw.QAction actionRender: qw.QAction @@ -113,6 +92,11 @@ class MainWindow(qw.QMainWindow): self.model[master_audio] = name self.model.update_widget[master_audio]() + def on_action_save(self): + if self._cfg_path is None: + raise NotImplementedError + yaml.dump(self.cfg, self._cfg_path) + def on_action_play(self): """ Launch ovgen and ffplay. """ arg = self._get_args([FFplayOutputConfig()]) @@ -175,6 +159,34 @@ class MainWindow(qw.QMainWindow): self.channel_widget: qw.QTableView self.channel_widget.setModel(self.channel_model) + # File paths + def load_title(self): + self.setWindowTitle(f'{self.title} - {APP_NAME}') + + @property + def cfg_dir(self) -> str: + maybe_path = self._cfg_path or self.cfg.master_audio + if maybe_path: + return str(Path(maybe_path).resolve().parent) + + return '.' + + UNTITLED = 'Untitled' + + @property + def title(self) -> str: + if self._cfg_path: + return self._cfg_path.name + return self.UNTITLED + + @property + def file_stem(self) -> str: + return cli.get_name(self._cfg_path or self.cfg.master_audio) + + @property + def cfg(self): + return self.model.cfg + class OvgenThread(qc.QThread): def __init__(self, parent: MainWindow, cfg: Config, arg: Arguments): diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 5b6308a..c4d7972 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -396,7 +396,7 @@ width: 0px; - &Save? + &Save Ctrl+S From a9d62868e35b6ee9d6237cd78e62c77505caa9ab Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 11 Dec 2018 20:50:26 -0800 Subject: [PATCH 043/102] Add save-as to GUI --- ovgenpy/gui/__init__.py | 22 +++++++++++++++++++++- ovgenpy/gui/mainwindow.ui | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 63889a0..738f952 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -56,6 +56,7 @@ class MainWindow(qw.QMainWindow): self.master_audio_browse.clicked.connect(self.on_master_audio_browse) self.actionExit.triggered.connect(qw.QApplication.quit) self.actionSave.triggered.connect(self.on_action_save) + self.actionSaveAs.triggered.connect(self.on_action_save_as) self.actionPlay.triggered.connect(self.on_action_play) self.actionRender.triggered.connect(self.on_action_render) @@ -78,6 +79,7 @@ class MainWindow(qw.QMainWindow): menuBar: qw.QMenuBar actionExit: qw.QAction actionSave: qw.QAction + actionSaveAs: qw.QAction actionPlay: qw.QAction actionRender: qw.QAction @@ -94,9 +96,26 @@ class MainWindow(qw.QMainWindow): def on_action_save(self): if self._cfg_path is None: - raise NotImplementedError + return self.on_action_save_as() yaml.dump(self.cfg, self._cfg_path) + def on_action_save_as(self): + cfg_path_default = os.path.join(self.cfg_dir, self.file_stem) + cli.YAML_NAME + + filters = ["YAML files (*.yaml)", "All files (*)"] + name, file_type = qw.QFileDialog.getSaveFileName( + self, "Save As", cfg_path_default, ';;'.join(filters) + ) + if name != '': + path = Path(name) + # FIXME automatic extension bad? Only if "YAML files (*.yaml)" + if file_type == filters[0] and path.suffix == '': + path = path.with_suffix(cli.YAML_NAME) + + self._cfg_path = path + self.load_title() + self.on_action_save() + def on_action_play(self): """ Launch ovgen and ffplay. """ arg = self._get_args([FFplayOutputConfig()]) @@ -111,6 +130,7 @@ class MainWindow(qw.QMainWindow): self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" ) if name != '': + # FIXME what if missing mp4? dlg = OvgenProgressDialog(self) arg = self._get_args([FFmpegOutputConfig(name)], dlg) error_msg = 'Cannot render to file, another play/render is active' diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index c4d7972..7439eb8 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -412,7 +412,7 @@ width: 0px; - Save &As? + Save &As Ctrl+Shift+S From 01255ece6d4ee60d2942db1bf54f4637a4c8a014 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 09:27:28 -0800 Subject: [PATCH 044/102] Add title to rendering progress dialog --- ovgenpy/gui/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 738f952..d88f666 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -131,7 +131,7 @@ class MainWindow(qw.QMainWindow): ) if name != '': # FIXME what if missing mp4? - dlg = OvgenProgressDialog(self) + dlg = OvgenProgressDialog(self, 'Rendering video') arg = self._get_args([FFmpegOutputConfig(name)], dlg) error_msg = 'Cannot render to file, another play/render is active' self.play_thread(arg, error_msg) @@ -222,8 +222,11 @@ class OvgenThread(qc.QThread): class OvgenProgressDialog(qw.QProgressDialog): - def __init__(self, parent: Optional[qw.QWidget]): + 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) From 10ea2105fa05830f5a757ca453232efb248baabf Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 12:13:28 -0800 Subject: [PATCH 045/102] Remove progress dialog from prohibited second-renders --- ovgenpy/gui/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index d88f666..96257af 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -120,7 +120,7 @@ class MainWindow(qw.QMainWindow): """ Launch ovgen and ffplay. """ arg = self._get_args([FFplayOutputConfig()]) error_msg = 'Cannot play, another play/render is active' - self.play_thread(arg, error_msg) + self.play_thread(arg, None, error_msg) def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ @@ -132,16 +132,18 @@ class MainWindow(qw.QMainWindow): if name != '': # FIXME what if missing mp4? dlg = OvgenProgressDialog(self, 'Rendering video') - arg = self._get_args([FFmpegOutputConfig(name)], dlg) + arg = self._get_args([FFmpegOutputConfig(name)]) error_msg = 'Cannot render to file, another play/render is active' - self.play_thread(arg, error_msg) + self.play_thread(arg, dlg, error_msg) - def _get_args(self, outputs: List[IOutputConfig], - dlg: Optional['OvgenProgressDialog'] = None): + def _get_args(self, outputs: List[IOutputConfig]): arg = Arguments( cfg_dir=self.cfg_dir, outputs=outputs, ) + return arg + + def play_thread(self, arg: Arguments, dlg: Optional['OvgenProgressDialog'], error_msg: str): if dlg: arg = attr.evolve(arg, on_begin=dlg.on_begin, @@ -149,13 +151,11 @@ class MainWindow(qw.QMainWindow): is_aborted=dlg.wasCanceled, on_end=dlg.reset, ) - - return arg - - def play_thread(self, arg: Arguments, error_msg: str): with self.ovgen_thread as t: if t is not None: self.ovgen_thread.unlock() + if dlg: + dlg.close() qw.QMessageBox.critical(self, 'Error', error_msg) return From c5ee95d8a4a13a29d33f9d3865ba6a867516b8b7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 12:19:37 -0800 Subject: [PATCH 046/102] Construct Arguments within play_thread(), not each caller --- ovgenpy/gui/__init__.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 96257af..682a8a6 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -118,9 +118,9 @@ class MainWindow(qw.QMainWindow): def on_action_play(self): """ Launch ovgen and ffplay. """ - arg = self._get_args([FFplayOutputConfig()]) + outputs = [FFplayOutputConfig()] error_msg = 'Cannot play, another play/render is active' - self.play_thread(arg, None, error_msg) + self.play_thread(outputs, None, error_msg) def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ @@ -132,24 +132,19 @@ class MainWindow(qw.QMainWindow): if name != '': # FIXME what if missing mp4? dlg = OvgenProgressDialog(self, 'Rendering video') - arg = self._get_args([FFmpegOutputConfig(name)]) + outputs = [FFmpegOutputConfig(name)] error_msg = 'Cannot render to file, another play/render is active' - self.play_thread(arg, dlg, error_msg) + self.play_thread(outputs, dlg, error_msg) - def _get_args(self, outputs: List[IOutputConfig]): - arg = Arguments( - cfg_dir=self.cfg_dir, - outputs=outputs, - ) - return arg - - def play_thread(self, arg: Arguments, dlg: Optional['OvgenProgressDialog'], error_msg: str): + def play_thread(self, outputs: List[IOutputConfig], + dlg: Optional['OvgenProgressDialog'], error_msg: str): + arg = self._get_args(outputs) if dlg: arg = attr.evolve(arg, on_begin=dlg.on_begin, progress=dlg.setValue, is_aborted=dlg.wasCanceled, - on_end=dlg.reset, + on_end=dlg.reset, # TODO dlg.close ) with self.ovgen_thread as t: if t is not None: @@ -165,6 +160,13 @@ class MainWindow(qw.QMainWindow): # Assigns self.ovgen_thread.set(None) when finished. t.start() + def _get_args(self, outputs: List[IOutputConfig]): + arg = Arguments( + cfg_dir=self.cfg_dir, + outputs=outputs, + ) + return arg + # Config models model: 'ConfigModel' channel_model: 'ChannelModel' From 0913a1afbcd412a4e5fe8c4785cff6ded14349a1 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 13:15:56 -0800 Subject: [PATCH 047/102] Play/Render: ensure no running ovgen before asking for output path --- ovgenpy/gui/__init__.py | 55 ++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 682a8a6..1ec4b1d 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -118,26 +118,39 @@ class MainWindow(qw.QMainWindow): def on_action_play(self): """ Launch ovgen and ffplay. """ - outputs = [FFplayOutputConfig()] error_msg = 'Cannot play, another play/render is active' - self.play_thread(outputs, None, error_msg) + with self.ovgen_thread as t: + if t is not None: + self.ovgen_thread.unlock() + qw.QMessageBox.critical(self, 'Error', error_msg) + return + + outputs = [FFplayOutputConfig()] + self.play_thread(outputs, dlg=None) def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ - video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME + error_msg = 'Cannot render to file, another play/render is active' + with self.ovgen_thread as t: + if t is not None: + self.ovgen_thread.unlock() + qw.QMessageBox.critical(self, 'Error', error_msg) + return - name, file_type = qw.QFileDialog.getSaveFileName( - self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" - ) - if name != '': - # FIXME what if missing mp4? - dlg = OvgenProgressDialog(self, 'Rendering video') - outputs = [FFmpegOutputConfig(name)] - error_msg = 'Cannot render to file, another play/render is active' - self.play_thread(outputs, dlg, error_msg) + video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME + name, file_type = qw.QFileDialog.getSaveFileName( + self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" + ) + if name != '': + # FIXME what if missing mp4? + dlg = OvgenProgressDialog(self, 'Rendering video') + + outputs = [FFmpegOutputConfig(name)] + self.play_thread(outputs, dlg) def play_thread(self, outputs: List[IOutputConfig], - dlg: Optional['OvgenProgressDialog'], error_msg: str): + dlg: Optional['OvgenProgressDialog']): + """ self.ovgen_thread MUST be locked. """ arg = self._get_args(outputs) if dlg: arg = attr.evolve(arg, @@ -146,19 +159,11 @@ class MainWindow(qw.QMainWindow): is_aborted=dlg.wasCanceled, on_end=dlg.reset, # TODO dlg.close ) - with self.ovgen_thread as t: - if t is not None: - self.ovgen_thread.unlock() - if dlg: - dlg.close() - qw.QMessageBox.critical(self, 'Error', error_msg) - return - cfg = copy_config(self.model.cfg) - - t = self.ovgen_thread.obj = OvgenThread(self, cfg, arg) - # Assigns self.ovgen_thread.set(None) when finished. - t.start() + cfg = copy_config(self.model.cfg) + t = self.ovgen_thread.obj = OvgenThread(self, cfg, arg) + # Assigns self.ovgen_thread.set(None) when finished. + t.start() def _get_args(self, outputs: List[IOutputConfig]): arg = Arguments( From a60561e182211dfc6be6ae0efebe1bdac29c6497 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 14:26:31 -0800 Subject: [PATCH 048/102] Add get_save_with_ext(), ensure video export has MP4 extension --- ovgenpy/gui/__init__.py | 22 +++++++++------------- ovgenpy/gui/util.py | 24 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 1ec4b1d..c630103 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -13,7 +13,7 @@ from ovgenpy import cli from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config, yaml from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr -from ovgenpy.gui.util import color2hex, Locked +from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import Ovgen, Config, Arguments from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig @@ -103,15 +103,10 @@ class MainWindow(qw.QMainWindow): cfg_path_default = os.path.join(self.cfg_dir, self.file_stem) + cli.YAML_NAME filters = ["YAML files (*.yaml)", "All files (*)"] - name, file_type = qw.QFileDialog.getSaveFileName( - self, "Save As", cfg_path_default, ';;'.join(filters) + path = get_save_with_ext( + self, "Save As", cfg_path_default, filters, cli.YAML_NAME ) - if name != '': - path = Path(name) - # FIXME automatic extension bad? Only if "YAML files (*.yaml)" - if file_type == filters[0] and path.suffix == '': - path = path.with_suffix(cli.YAML_NAME) - + if path: self._cfg_path = path self.load_title() self.on_action_save() @@ -138,10 +133,11 @@ class MainWindow(qw.QMainWindow): return video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME - name, file_type = qw.QFileDialog.getSaveFileName( - self, "Render to Video", video_path, "MP4 files (*.mp4);;All files (*)" - ) - if name != '': + filters = ["MP4 files (*.mp4)", "All files (*)"] + path = get_save_with_ext(self, "Render to Video", video_path, filters, + cli.VIDEO_NAME) + if path: + name = str(path) # FIXME what if missing mp4? dlg = OvgenProgressDialog(self, 'Rendering video') diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 4f2ec9e..54b4982 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -1,6 +1,9 @@ +from pathlib import Path from typing import * -from PyQt5.QtCore import QMutex + import matplotlib.colors +from PyQt5.QtCore import QMutex +from PyQt5.QtWidgets import QWidget, QFileDialog def color2hex(color): @@ -39,3 +42,22 @@ class Locked(Generic[T]): with self: self.obj = value return value + + +def get_save_with_ext( + parent: QWidget, caption: str, dir_or_file: str, + filters: List[str], default_suffix: str +) -> Optional[Path]: + """ On KDE, getSaveFileName does not append extension. This is a workaround. """ + name, sel_filter = QFileDialog.getSaveFileName( + parent, caption, dir_or_file, ';;'.join(filters) + ) + + if name == '': + return None + + path = Path(name) + if sel_filter == filters[0] and path.suffix == '': + path = path.with_suffix(default_suffix) + return path + From e6abb32e3457a52a7ececf31520663ef2cbf27d1 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 14:26:42 -0800 Subject: [PATCH 049/102] Expose more functions in data_bind.py --- ovgenpy/gui/data_bind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 76f619c..d5e12e3 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import QWidget from ovgenpy.util import obj_name, perr -__all__ = ['PresentationModel', 'map_gui'] +__all__ = ['PresentationModel', 'map_gui', 'behead', 'rgetattr', 'rsetattr'] WidgetUpdater = Callable[[], None] From 23e640a6a6c657497c0a33947d1a334a690ead3c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 17:52:51 -0800 Subject: [PATCH 050/102] Implement New action (keeps model, replaces cfg) --- ovgenpy/gui/__init__.py | 64 +++++++++++++++++++++++---------------- ovgenpy/gui/data_bind.py | 5 +++ ovgenpy/gui/mainwindow.ui | 2 +- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index c630103..41403b3 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -15,7 +15,7 @@ from ovgenpy.config import OvgenError, copy_config, yaml from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig -from ovgenpy.ovgenpy import Ovgen, Config, Arguments +from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import perr, obj_name @@ -54,34 +54,63 @@ class MainWindow(qw.QMainWindow): # Bind UI buttons, etc. Functions block main thread, avoiding race conditions. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) - self.actionExit.triggered.connect(qw.QApplication.quit) + self.actionNew.triggered.connect(self.on_action_new) self.actionSave.triggered.connect(self.on_action_save) self.actionSaveAs.triggered.connect(self.on_action_save_as) self.actionPlay.triggered.connect(self.on_action_play) self.actionRender.triggered.connect(self.on_action_render) + self.actionExit.triggered.connect(qw.QApplication.quit) # Initialize ovgen-thread attribute. self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None) # Bind config to UI. - self._cfg_path = cfg_path - self.load_cfg(cfg) + self.load_cfg(cfg, cfg_path) - self.load_title() self.show() - @property - def ever_saved(self): - return self._cfg_path is not None + # Config models + _cfg_path: Optional[Path] + model: Optional['ConfigModel'] = None + channel_model: 'ChannelModel' + channel_widget: qw.QTableView + def on_action_new(self): + cfg = default_config() + self.load_cfg(cfg, None) + + def load_cfg(self, cfg: Config, cfg_path: Optional[Path]): + self._cfg_path = cfg_path + if self.model is None: + self.model = ConfigModel(cfg) + map_gui(self, self.model) + else: + self.model.set_cfg(cfg) + + self.channel_model = ChannelModel(cfg.channels) + # Calling setModel again disconnects previous model. + self.channel_widget.setModel(self.channel_model) + + self.load_title() + + def load_title(self): + self.setWindowTitle(f'{self.title} - {APP_NAME}') + + # Unused + # @property + # def ever_saved(self): + # return self._cfg_path is not None + + # GUI actions, etc. master_audio_browse: qw.QPushButton # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar - actionExit: qw.QAction + actionNew: qw.QAction actionSave: qw.QAction actionSaveAs: qw.QAction actionPlay: qw.QAction actionRender: qw.QAction + actionExit: qw.QAction def on_master_audio_browse(self): # TODO add default file-open dir, initialized to yaml path and remembers prev @@ -168,24 +197,7 @@ class MainWindow(qw.QMainWindow): ) return arg - # Config models - model: 'ConfigModel' - channel_model: 'ChannelModel' - - def load_cfg(self, cfg: Config): - # TODO unbind current model's slots if exists - # or maybe disconnect ALL connections?? - self.model = ConfigModel(cfg) - map_gui(self, self.model) - - self.channel_model = ChannelModel(cfg.channels) - self.channel_widget: qw.QTableView - self.channel_widget.setModel(self.channel_model) - # File paths - def load_title(self): - self.setWindowTitle(f'{self.title} - {APP_NAME}') - @property def cfg_dir(self) -> str: maybe_path = self._cfg_path or self.cfg.master_audio diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index d5e12e3..29fe2e1 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -49,6 +49,11 @@ class PresentationModel: else: raise AttributeError(f'cannot set attribute {key} on {obj_name(self)}()') + def set_cfg(self, cfg: Attrs): + self.cfg = cfg + for updater in self.update_widget.values(): + updater() + BIND_PREFIX = 'cfg__' diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 7439eb8..7c650b9 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -404,7 +404,7 @@ width: 0px; - &New? + &New Ctrl+N From 8490ae276561f34c80cf5eda4403cff06ba8dec5 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 17:55:24 -0800 Subject: [PATCH 051/102] Remove debug code when mutating model --- ovgenpy/gui/data_bind.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 29fe2e1..1bf716d 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -40,7 +40,6 @@ class PresentationModel: return rgetattr(self.cfg, item) def __setitem__(self, key, value): - perr(f'{key} = {value}') # Custom properties if hasattr(self, key): setattr(self, key, value) From c7741ae038b2a6457d2fb5dae4c9c8a02adf81ed Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 18:15:12 -0800 Subject: [PATCH 052/102] Bind Open action --- ovgenpy/gui/__init__.py | 11 +++++++++++ ovgenpy/gui/mainwindow.ui | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 41403b3..01720e8 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -55,6 +55,7 @@ class MainWindow(qw.QMainWindow): # Bind UI buttons, etc. Functions block main thread, avoiding race conditions. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) 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.actionPlay.triggered.connect(self.on_action_play) @@ -79,6 +80,15 @@ class MainWindow(qw.QMainWindow): cfg = default_config() self.load_cfg(cfg, None) + def on_action_open(self): + name, file_type = qw.QFileDialog.getOpenFileName( + self, "Open config", self.cfg_dir, "YAML files (*.yaml)" + ) + if name != '': + cfg_path = Path(name) + cfg = yaml.load(cfg_path) + self.load_cfg(cfg, cfg_path) + def load_cfg(self, cfg: Config, cfg_path: Optional[Path]): self._cfg_path = cfg_path if self.model is None: @@ -106,6 +116,7 @@ class MainWindow(qw.QMainWindow): # 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 actionPlay: qw.QAction diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 7c650b9..a1f384a 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -388,7 +388,7 @@ width: 0px; - &Open? + &Open Ctrl+O From e9b0704ca11785d50d72d3b31fe93701fb991d9e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 18:20:01 -0800 Subject: [PATCH 053/102] Fix crash when reopening config where render.color = array --- ovgenpy/gui/__init__.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 01720e8..bdc691b 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -303,18 +303,24 @@ def default_property(path: str, default): return property(getter, setter) + +def adapter_property(path: str, adapter: Callable[[Any], Any]): + def getter(self: 'ConfigModel'): + return adapter(rgetattr(self.cfg, path)) + + def setter(self: 'ConfigModel', val: int): + rsetattr(self.cfg, path, val) + + return property(getter, setter) + + class ConfigModel(PresentationModel): cfg: Config combo_symbols = {} combo_text = {} - def __init__(self, cfg: Config): - """ Mutates colors for convenience. """ - super().__init__(cfg) - - for key in ['bg_color', 'init_line_color']: - color = getattr(cfg.render, key) - setattr(cfg.render, key, color2hex(color)) + render__bg_color = adapter_property('render__bg_color', color2hex) + render__init_line_color = adapter_property('render__init_line_color', color2hex) @property def render_video_size(self) -> str: From 6f284951fd5c8a2a0cf825666211a7cb9fc77569 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 17:53:23 -0800 Subject: [PATCH 054/102] Switch user-facing code to OvgenError --- ovgenpy/config.py | 2 +- ovgenpy/layout.py | 11 ++++++----- ovgenpy/ovgenpy.py | 2 +- ovgenpy/triggers.py | 4 ++-- ovgenpy/wave.py | 5 ++++- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ovgenpy/config.py b/ovgenpy/config.py index 2999359..8f92e28 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -144,7 +144,7 @@ class _ConfigMixin: if isinstance(class_var, Alias): target = class_var.key if target in state: - raise TypeError( + raise OvgenError( f'{type(self).__name__} received both Alias {key} and ' f'equivalent {target}' ) diff --git a/ovgenpy/layout.py b/ovgenpy/layout.py index 10f3c50..e2e681e 100644 --- a/ovgenpy/layout.py +++ b/ovgenpy/layout.py @@ -2,7 +2,7 @@ from typing import Optional, TypeVar, Callable, List import numpy as np -from ovgenpy.config import register_config +from ovgenpy.config import register_config, OvgenError from ovgenpy.util import ceildiv @@ -19,7 +19,7 @@ class LayoutConfig: self.ncols = None if self.nrows and self.ncols: - raise ValueError('cannot manually assign both nrows and ncols') + raise OvgenError('cannot manually assign both nrows and ncols') if not self.nrows and not self.ncols: self.ncols = 1 @@ -41,7 +41,7 @@ class RendererLayout: self.orientation = cfg.orientation if self.orientation not in self.VALID_ORIENTATIONS: - raise ValueError(f'Invalid orientation {self.orientation} not in ' + raise OvgenError(f'Invalid orientation {self.orientation} not in ' f'{self.VALID_ORIENTATIONS}') def _calc_layout(self): @@ -54,12 +54,13 @@ class RendererLayout: if cfg.nrows: nrows = cfg.nrows if nrows is None: - raise ValueError('invalid cfg: rows_first is True and nrows is None') + raise ValueError('impossible cfg: nrows is None and true') ncols = ceildiv(self.nplots, nrows) else: ncols = cfg.ncols if ncols is None: - raise ValueError('invalid cfg: rows_first is False and ncols is None') + raise ValueError('invalid LayoutConfig: nrows,ncols is None ' + '(__attrs_post_init__ not called?)') nrows = ceildiv(self.nplots, ncols) return nrows, ncols diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index f83b7d5..985bd1a 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -178,7 +178,7 @@ class Ovgen: self.output_cfgs = [] if len(self.cfg.channels) == 0: - raise ValueError('Config.channels is empty') + raise OvgenError('Config.channels is empty') waves: List[Wave] channels: List[Channel] diff --git a/ovgenpy/triggers.py b/ovgenpy/triggers.py index 58efdd6..7700716 100644 --- a/ovgenpy/triggers.py +++ b/ovgenpy/triggers.py @@ -141,7 +141,7 @@ class CorrelationTriggerConfig(ITriggerConfig): def _validate_param(self, key: str, begin, end): value = getattr(self, key) if not begin <= value <= end: - raise ValueError( + raise OvgenError( f'Invalid {key}={value} (should be within [{begin}, {end}])') @@ -457,7 +457,7 @@ class LocalPostTrigger(PostTrigger): # Window data if cache.period is None: - raise ValueError( + raise OvgenError( "Missing 'cache.period', try stacking CorrelationTrigger " "before LocalPostTrigger") diff --git a/ovgenpy/wave.py b/ovgenpy/wave.py index 814dab3..7cb510f 100644 --- a/ovgenpy/wave.py +++ b/ovgenpy/wave.py @@ -6,6 +6,9 @@ from scipy.io import wavfile # Internal class, not exposed via YAML +from ovgenpy.config import OvgenError + + @attr.dataclass class _WaveConfig: amplification: float = 1 @@ -51,7 +54,7 @@ class Wave: self.max_val = 1 else: - raise ValueError(f'unexpected wavfile dtype {dtype}') + raise OvgenError(f'unexpected wavfile dtype {dtype}') def __getitem__(self, index: Union[int, slice]) -> 'np.ndarray[FLOAT]': """ Copies self.data[item], converted to a FLOAT within range [-1, 1). """ From c47d6d6369e26394fd9dd36fa25055f7ebe7ee5a Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 20:48:43 -0800 Subject: [PATCH 055/102] Prevent GUI getter access when setting items --- ovgenpy/gui/data_bind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 1bf716d..df8c3b7 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -41,7 +41,7 @@ class PresentationModel: def __setitem__(self, key, value): # Custom properties - if hasattr(self, key): + if hasattr(type(self), key): setattr(self, key, value) elif rhasattr(self.cfg, key): rsetattr(self.cfg, key, value) From b352d2bf8e52c0cde14d4b7274072188b7012cf4 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Wed, 12 Dec 2018 20:49:06 -0800 Subject: [PATCH 056/102] [gui] Remove debug prints when changing nrow/ncol --- ovgenpy/gui/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index bdc691b..3cdde12 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -17,7 +17,7 @@ from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig -from ovgenpy.util import perr, obj_name +from ovgenpy.util import obj_name APP_NAME = 'ovgenpy' APP_DIR = Path(__file__).parent @@ -277,7 +277,6 @@ def nrow_ncol_property(altered: str, unaltered: str) -> property: return val def set(self: 'ConfigModel', val: int): - perr(altered) if val > 0: setattr(self.cfg.layout, altered, val) setattr(self.cfg.layout, unaltered, None) From 027ba504bdb4956ad1fb5a26058826b090090da7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 06:58:45 -0800 Subject: [PATCH 057/102] When loading invalid config, wrap exceptions with OvgenError Fix unit test (switching to OvgenError) --- ovgenpy/gui/__init__.py | 15 +++++++++++---- tests/test_config.py | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3cdde12..f5b8788 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -303,9 +303,16 @@ def default_property(path: str, default): return property(getter, setter) -def adapter_property(path: str, adapter: Callable[[Any], Any]): +def color2hex_property(path: str): def getter(self: 'ConfigModel'): - return adapter(rgetattr(self.cfg, path)) + color_attr = rgetattr(self.cfg, path) + try: + return color2hex(color_attr) + except ValueError: + raise OvgenError(f'invalid config color {color_attr}') + except Exception as e: + raise OvgenError( + f'doubly invalid config color {color_attr}, raises {e} (report bug!)') def setter(self: 'ConfigModel', val: int): rsetattr(self.cfg, path, val) @@ -318,8 +325,8 @@ class ConfigModel(PresentationModel): combo_symbols = {} combo_text = {} - render__bg_color = adapter_property('render__bg_color', color2hex) - render__init_line_color = adapter_property('render__init_line_color', color2hex) + render__bg_color = color2hex_property('render__bg_color') + render__init_line_color = color2hex_property('render__init_line_color') @property def render_video_size(self) -> str: diff --git a/tests/test_config.py b/tests/test_config.py index 2365355..6149aed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ import pytest from ruamel.yaml import yaml_object -from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config +from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config, OvgenError # YAML Idiosyncrasies: https://docs.saltstack.com/en/develop/topics/troubleshooting/yaml_idiosyncrasies.html @@ -163,7 +163,7 @@ xx: 1 x: 1 xx: 1 ''' - with pytest.raises(TypeError): + with pytest.raises(OvgenError): yaml.load(s) From 93deda6c80e4701f4d06933074db244b7234bf70 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 07:54:25 -0800 Subject: [PATCH 058/102] remove comment --- ovgenpy/gui/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index f5b8788..d741bb7 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -477,8 +477,7 @@ class ChannelModel(qc.QAbstractTableModel): if value and not value.isspace(): try: value = data.cls(value) - except ValueError as e: - # raise OvgenError(e) + except ValueError: return False else: value = data.default From 38fb6f00debb3cfda54f0273ac4492072aba77a2 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 07:55:28 -0800 Subject: [PATCH 059/102] Subclass Bound(Widget) from Q(Widget) --- ovgenpy/gui/data_bind.py | 20 ++++++++++++--- ovgenpy/gui/mainwindow.ui | 52 ++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index df8c3b7..d8e12ea 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -81,7 +81,10 @@ def bind_widget(widget: QWidget, model: PresentationModel, path: str): return -@bind_widget.register(qw.QLineEdit) +class BoundLineEdit(qw.QLineEdit): + pass + +@bind_widget.register(BoundLineEdit) def _(widget, model: PresentationModel, path: str): direct_bind(widget, model, path, DirectBinding( set_widget=widget.setText, @@ -90,7 +93,10 @@ def _(widget, model: PresentationModel, path: str): )) -@bind_widget.register(qw.QSpinBox) +class BoundSpinBox(qw.QSpinBox): + pass + +@bind_widget.register(BoundSpinBox) def _(widget, model: PresentationModel, path: str): direct_bind(widget, model, path, DirectBinding( set_widget=widget.setValue, @@ -99,7 +105,10 @@ def _(widget, model: PresentationModel, path: str): )) -@bind_widget.register(qw.QDoubleSpinBox) +class BoundDoubleSpinBox(qw.QDoubleSpinBox): + pass + +@bind_widget.register(BoundDoubleSpinBox) def _(widget, model: PresentationModel, path: str): direct_bind(widget, model, path, DirectBinding( set_widget=widget.setValue, @@ -108,7 +117,10 @@ def _(widget, model: PresentationModel, path: str): )) -@bind_widget.register(qw.QComboBox) +class BoundComboBox(qw.QComboBox): + pass + +@bind_widget.register(BoundComboBox) def _(widget, model: PresentationModel, path: str): combo_symbols = model.combo_symbols[path] combo_text = model.combo_text[path] diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index a1f384a..bd49d85 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -37,7 +37,7 @@ - QLineEdit { + BoundLineEdit { width: 0px; } @@ -56,7 +56,7 @@ width: 0px; - + 1 @@ -76,7 +76,7 @@ width: 0px; - + 0.100000000000000 @@ -99,7 +99,7 @@ width: 0px; - + 5 @@ -116,7 +116,7 @@ width: 0px; - + 5 @@ -133,7 +133,7 @@ width: 0px; - + 1 @@ -147,7 +147,7 @@ width: 0px; - + 1 @@ -170,7 +170,7 @@ width: 0px; - + bg @@ -184,7 +184,7 @@ width: 0px; - + fg @@ -198,7 +198,7 @@ width: 0px; - + 0.500000000000000 @@ -224,7 +224,7 @@ width: 0px; - + @@ -236,7 +236,7 @@ width: 0px; - +   @@ -250,7 +250,7 @@ width: 0px; - +   @@ -266,7 +266,7 @@ width: 0px; - + vs @@ -288,7 +288,7 @@ width: 0px; - + / @@ -444,6 +444,28 @@ width: 0px; + + + BoundLineEdit + QLineEdit +
ovgenpy/gui/data_bind.h
+
+ + BoundSpinBox + QSpinBox +
ovgenpy/gui/data_bind.h
+
+ + BoundDoubleSpinBox + QDoubleSpinBox +
ovgenpy/gui/data_bind.h
+
+ + BoundComboBox + QComboBox +
ovgenpy/gui/data_bind.h
+
+
From fbd89faa9dd0293e3dc9c4f2bb89d4fabc6c0e56 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 13:20:46 -0800 Subject: [PATCH 060/102] Switch to class-based widget binding (BoundWidget) --- ovgenpy/gui/data_bind.py | 211 +++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 111 deletions(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index d8e12ea..99ffd26 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -1,5 +1,6 @@ import functools -from typing import Optional, List, Callable, Dict, Any +import operator +from typing import Optional, List, Callable, Dict, Any, ClassVar import attr from PyQt5 import QtWidgets as qw, QtCore as qc @@ -62,137 +63,125 @@ def map_gui(view: QWidget, model: PresentationModel): Binding: - .ui - view.cfg__layout__nrows - - model['layout__nrows'] + - pmodel['layout__nrows'] Only s starting with 'cfg__' will be bound. """ - widgets: List[QWidget] = view.findChildren(QWidget) # dear pyqt, add generic mypy return types + widgets = view.findChildren(QWidget) # dear pyqt, add generic mypy return types for widget in widgets: widget_name = widget.objectName() path = try_behead(widget_name, BIND_PREFIX) if path is not None: - bind_widget(widget, model, path) - - -@functools.singledispatch -def bind_widget(widget: QWidget, model: PresentationModel, path: str): - perr(widget, path) - return - - -class BoundLineEdit(qw.QLineEdit): - pass - -@bind_widget.register(BoundLineEdit) -def _(widget, model: PresentationModel, path: str): - direct_bind(widget, model, path, DirectBinding( - set_widget=widget.setText, - widget_changed=widget.textChanged, - value_type=str, - )) - - -class BoundSpinBox(qw.QSpinBox): - pass - -@bind_widget.register(BoundSpinBox) -def _(widget, model: PresentationModel, path: str): - direct_bind(widget, model, path, DirectBinding( - set_widget=widget.setValue, - widget_changed=widget.valueChanged, - value_type=int, - )) - - -class BoundDoubleSpinBox(qw.QDoubleSpinBox): - pass - -@bind_widget.register(BoundDoubleSpinBox) -def _(widget, model: PresentationModel, path: str): - direct_bind(widget, model, path, DirectBinding( - set_widget=widget.setValue, - widget_changed=widget.valueChanged, - value_type=float, - )) - - -class BoundComboBox(qw.QComboBox): - pass - -@bind_widget.register(BoundComboBox) -def _(widget, model: PresentationModel, path: str): - combo_symbols = model.combo_symbols[path] - combo_text = model.combo_text[path] - symbol2idx = {} - for i, symbol in enumerate(combo_symbols): - symbol2idx[symbol] = i - widget.addItem(combo_text[i]) - - # combobox.index = model.attr - def set_widget(symbol: str): - combo_index = symbol2idx[symbol] - widget.setCurrentIndex(combo_index) - - # model.attr = combobox.index - def set_model(combo_index: int): - assert isinstance(combo_index, int) - model[path] = combo_symbols[combo_index] - widget.currentIndexChanged.connect(set_model) - - direct_bind(widget, model, path, DirectBinding( - set_widget=set_widget, - widget_changed=None, - value_type=None, - )) + assert isinstance(widget, BoundWidget) + widget.bind_widget(model, path) Signal = Any -@attr.dataclass -class DirectBinding: - set_widget: Callable - widget_changed: Optional[Signal] - value_type: Optional[type] +class BoundWidget: + pmodel: PresentationModel + path: str + + def bind_widget(self, model: PresentationModel, path: str) -> None: + try: + self.pmodel = model + self.path = path + self.cfg2gui() + + # Allow widget to be updated by other events. + model.update_widget[path] = self.cfg2gui + + # Allow pmodel to be changed by widget. + self.gui_changed.connect(self.set_model) + + except Exception: + perr(self) + perr(path) + raise + + def cfg2gui(self): + """ Update the widget without triggering signals. + + When the presentation pmodel updates dependent widget 1, + the pmodel (not widget 1) is responsible for updating other + dependent widgets. + TODO add option to send signals + """ + with qc.QSignalBlocker(self): + self.set_gui(self.pmodel[self.path]) + + def set_gui(self, value): pass + + gui_changed: ClassVar[Signal] + + def set_model(self, value): pass -def direct_bind(widget: QWidget, model: PresentationModel, path: str, bind: DirectBinding): - try: - def update_widget(): - """ Update the widget without triggering signals. +def model_setter(value_type: type) -> Callable: + @pyqtSlot(value_type) + def set_model(self: BoundWidget, value): + assert isinstance(value, value_type) + self.pmodel[self.path] = value + return set_model - When the presentation model updates dependent widget 1, - the model (not widget 1) is responsible for updating other - dependent widgets. - """ - # TODO add option to send signals - with qc.QSignalBlocker(widget): - bind.set_widget(model[path]) - update_widget() +def alias(name: str): + return property(operator.attrgetter(name)) - # Allow widget to be updated by other events. - model.update_widget[path] = update_widget - # Allow model to be changed by widget. - if bind.widget_changed is not None: - @pyqtSlot(bind.value_type) - def set_model(value): - assert isinstance(value, bind.value_type) - model[path] = value +class BoundLineEdit(qw.QLineEdit, BoundWidget): + # PyQt complains when we assign unbound methods (`set_gui = qw.QLineEdit.setText`), + # but not if we call them indirectly. + set_gui = alias('setText') + gui_changed = alias('textChanged') + set_model = model_setter(str) - bind.widget_changed.connect(set_model) - # QSpinBox.valueChanged may or may not be called with (str). - # http://pyqt.sourceforge.net/Docs/PyQt5/signals_slots.html#connecting-slots-by-name - # mentions connectSlotsByName(), - # but we're using QSpinBox().valueChanged.connect() and my assert never fails. - # Either way, @pyqtSlot(value_type) will ward off incorrect calls. +class BoundSpinBox(qw.QSpinBox, BoundWidget): + set_gui = alias('setValue') + gui_changed = alias('valueChanged') + set_model = model_setter(int) - except Exception: - perr(widget) - perr(path) - raise + +class BoundDoubleSpinBox(qw.QDoubleSpinBox, BoundWidget): + set_gui = alias('setValue') + gui_changed = alias('valueChanged') + set_model = model_setter(float) + + +class BoundComboBox(qw.QComboBox, BoundWidget): + combo_symbols: List[str] + symbol2idx: Dict[str, int] + + # noinspection PyAttributeOutsideInit + def bind_widget(self, model: PresentationModel, path: str) -> None: + # Effectively enum values. + self.combo_symbols = model.combo_symbols[path] + + # symbol2idx[str] = int + self.symbol2idx = {} + + # Pretty-printed text + combo_text = model.combo_text[path] + for i, symbol in enumerate(self.combo_symbols): + self.symbol2idx[symbol] = i + self.addItem(combo_text[i]) + + BoundWidget.bind_widget(self, model, path) + + # combobox.index = pmodel.attr + def set_gui(self, symbol: str): + combo_index = self.symbol2idx[symbol] + self.setCurrentIndex(combo_index) + + gui_changed = alias('currentIndexChanged') + + # pmodel.attr = combobox.index + @pyqtSlot(int) + def set_model(self, combo_index: int): + assert isinstance(combo_index, int) + self.pmodel[self.path] = self.combo_symbols[combo_index] def try_behead(string: str, header: str) -> Optional[str]: From 8f8f4c8b888238ea3cff48b0df9306018172c8d8 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 21:22:43 -0800 Subject: [PATCH 061/102] Turn BoundWidget red on invalid input (OvgenError) --- ovgenpy/gui/data_bind.py | 46 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 99ffd26..7b77ffc 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -2,11 +2,13 @@ import functools import operator from typing import Optional, List, Callable, Dict, Any, ClassVar -import attr from PyQt5 import QtWidgets as qw, QtCore as qc from PyQt5.QtCore import pyqtSlot +from PyQt5.QtGui import QPalette, QColor from PyQt5.QtWidgets import QWidget +from ovgenpy.config import OvgenError +from ovgenpy.triggers import lerp from ovgenpy.util import obj_name, perr __all__ = ['PresentationModel', 'map_gui', 'behead', 'rgetattr', 'rsetattr'] @@ -79,12 +81,18 @@ def map_gui(view: QWidget, model: PresentationModel): Signal = Any -class BoundWidget: +class BoundWidget(QWidget): + default_palette: QPalette + error_palette: QPalette + pmodel: PresentationModel path: str def bind_widget(self, model: PresentationModel, path: str) -> None: try: + self.default_palette = self.palette() + self.error_palette = self.calc_error_palette() + self.pmodel = model self.path = path self.cfg2gui() @@ -99,6 +107,17 @@ class BoundWidget: perr(self) perr(path) raise + + def calc_error_palette(self) -> QPalette: + """ Palette with red background, used for widgets with invalid input. """ + error_palette = QPalette(self.palette()) + + bg = error_palette.color(QPalette.Base) + red = QColor(qc.Qt.red) + + red_bg = blend_colors(bg, red, 0.5) + error_palette.setColor(QPalette.Base, red_bg) + return error_palette def cfg2gui(self): """ Update the widget without triggering signals. @@ -118,11 +137,32 @@ class BoundWidget: def set_model(self, value): pass +def blend_colors(color1: QColor, color2: QColor, ratio: float, gamma=2): + """ Blends two colors in linear color space. + Produces better results on both light and dark themes, + than integer blending (which is too dark). + """ + rgb1 = color1.getRgbF()[:3] # r,g,b, remove alpha + rgb2 = color2.getRgbF()[:3] + rgb_blend = [] + + for ch1, ch2 in zip(rgb1, rgb2): + blend = lerp(ch1 ** gamma, ch2 ** gamma, ratio) ** (1/gamma) + rgb_blend.append(blend) + + return QColor.fromRgbF(*rgb_blend, 1.0) + + def model_setter(value_type: type) -> Callable: @pyqtSlot(value_type) def set_model(self: BoundWidget, value): assert isinstance(value, value_type) - self.pmodel[self.path] = value + try: + self.pmodel[self.path] = value + except OvgenError: + self.setPalette(self.error_palette) + else: + self.setPalette(self.default_palette) return set_model From 684dff1f620449431b2997c4448f0529af9478f0 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Thu, 13 Dec 2018 21:27:25 -0800 Subject: [PATCH 062/102] [gui] Mark colors as red if color invalid --- ovgenpy/gui/__init__.py | 15 +++++---------- ovgenpy/gui/util.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index d741bb7..bc6e754 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -297,7 +297,7 @@ def default_property(path: str, default): else: return val - def setter(self: 'ConfigModel', val: int): + def setter(self: 'ConfigModel', val): rsetattr(self.cfg, path, val) return property(getter, setter) @@ -306,16 +306,11 @@ def default_property(path: str, default): def color2hex_property(path: str): def getter(self: 'ConfigModel'): color_attr = rgetattr(self.cfg, path) - try: - return color2hex(color_attr) - except ValueError: - raise OvgenError(f'invalid config color {color_attr}') - except Exception as e: - raise OvgenError( - f'doubly invalid config color {color_attr}, raises {e} (report bug!)') + return color2hex(color_attr) - def setter(self: 'ConfigModel', val: int): - rsetattr(self.cfg, path, val) + def setter(self: 'ConfigModel', val: str): + color = color2hex(val) + rsetattr(self.cfg, path, color) return property(getter, setter) diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 54b4982..108ff20 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -5,9 +5,18 @@ import matplotlib.colors from PyQt5.QtCore import QMutex from PyQt5.QtWidgets import QWidget, QFileDialog +from ovgenpy.config import OvgenError + def color2hex(color): - return matplotlib.colors.to_hex(color, keep_alpha=False) + try: + return matplotlib.colors.to_hex(color, keep_alpha=False) + except ValueError: + raise OvgenError(f'invalid color {color}') + except Exception as e: + raise OvgenError( + f'doubly invalid color {color}, raises {e} (report bug!)') + T = TypeVar('T') From 9f318b187fb73ae9bb4eaf794e96cd04447118b8 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 15:06:57 -0800 Subject: [PATCH 063/102] [wip] Add up/down buttons and keyboard shortcuts (BUG they activate regardless of focus) --- ovgenpy/gui/__init__.py | 52 +++++++++++++++++++++++++++++++++++++-- ovgenpy/gui/mainwindow.ui | 23 +++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index bc6e754..920024d 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -8,6 +8,7 @@ import PyQt5.QtWidgets as qw import attr from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt +from PyQt5.QtGui import QKeySequence from ovgenpy import cli from ovgenpy.channel import ChannelConfig @@ -54,6 +55,11 @@ class MainWindow(qw.QMainWindow): # 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('ctrl+shift+up') + self.channelDown.add_shortcut('ctrl+shift+down') + + # Bind actions. self.actionNew.triggered.connect(self.on_action_new) self.actionOpen.triggered.connect(self.on_action_open) self.actionSave.triggered.connect(self.on_action_save) @@ -113,6 +119,10 @@ class MainWindow(qw.QMainWindow): # GUI actions, etc. master_audio_browse: qw.QPushButton + channelAdd: 'ShortcutButton' + channelDelete: 'ShortcutButton' + channelUp: 'ShortcutButton' + channelDown: 'ShortcutButton' # Loading mainwindow.ui changes menuBar from a getter to an attribute. menuBar: qw.QMenuBar actionNew: qw.QAction @@ -234,6 +244,14 @@ class MainWindow(qw.QMainWindow): return self.model.cfg +class ShortcutButton(qw.QPushButton): + def add_shortcut(self, shortcut: str) -> None: + """ Adds shortcut and tooltip. """ + keys = QKeySequence(shortcut, QKeySequence.PortableText) + self.setShortcut(keys) + self.setToolTip(keys.toString(QKeySequence.NativeText)) + + class OvgenThread(qc.QThread): def __init__(self, parent: MainWindow, cfg: Config, arg: Arguments): qc.QThread.__init__(self) @@ -379,7 +397,10 @@ class Column: class ChannelModel(qc.QAbstractTableModel): - """ Design based off http://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model """ + """ 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. """ @@ -487,7 +508,34 @@ class ChannelModel(qc.QAbstractTableModel): 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.insert(row, [ChannelConfig('') for _ in range(count)]) + self.endInsertRows() + return True + + def removeRows(self, row: int, count: int, parent: QModelIndex = ...) -> bool: + nchan = len(self.channels) + 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.endInsertRows() + return True + def flags(self, index: QModelIndex): if not index.isValid(): return Qt.ItemIsEnabled - return qc.QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable + return (qc.QAbstractItemModel.flags(self, index) + | Qt.ItemIsEditable | Qt.ItemNeverHasChildren) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index bd49d85..ca1aa09 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -326,19 +326,33 @@ width: 0px; - + &Add... - + &Delete + + + + Up + + + + + + + Down + + + @@ -465,6 +479,11 @@ width: 0px; QComboBox
ovgenpy/gui/data_bind.h
+ + ShortcutButton + QPushButton +
ovgenpy/gui/__init__.h
+
From e5bb4f23e370a26022ecea89f256b663007cf51e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 15:20:08 -0800 Subject: [PATCH 064/102] Limit "move channel" shortcuts to channelsGroup animateClick is pretty, but prevents key repeat from working. --- ovgenpy/gui/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 920024d..1292836 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -9,6 +9,7 @@ import attr from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt from PyQt5.QtGui import QKeySequence +from PyQt5.QtWidgets import QShortcut from ovgenpy import cli from ovgenpy.channel import ChannelConfig @@ -56,8 +57,8 @@ class MainWindow(qw.QMainWindow): # 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('ctrl+shift+up') - self.channelDown.add_shortcut('ctrl+shift+down') + self.channelUp.add_shortcut(self.channelsGroup, 'ctrl+shift+up') + self.channelDown.add_shortcut(self.channelsGroup, 'ctrl+shift+down') # Bind actions. self.actionNew.triggered.connect(self.on_action_new) @@ -81,6 +82,7 @@ class MainWindow(qw.QMainWindow): model: Optional['ConfigModel'] = None channel_model: 'ChannelModel' channel_widget: qw.QTableView + channelsGroup: qw.QGroupBox def on_action_new(self): cfg = default_config() @@ -245,10 +247,16 @@ class MainWindow(qw.QMainWindow): class ShortcutButton(qw.QPushButton): - def add_shortcut(self, shortcut: str) -> None: + scoped_shortcut: QShortcut + + def add_shortcut(self, scope: qw.QWidget, shortcut: str) -> None: """ Adds shortcut and tooltip. """ keys = QKeySequence(shortcut, QKeySequence.PortableText) - self.setShortcut(keys) + + self.scoped_shortcut = qw.QShortcut(keys, scope) + self.scoped_shortcut.setContext(Qt.WidgetWithChildrenShortcut) + self.scoped_shortcut.activated.connect(self.click) + self.setToolTip(keys.toString(QKeySequence.NativeText)) From 53263b3a924718f28e4a28823ebe89f3509e47ed Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 16:59:28 -0800 Subject: [PATCH 065/102] Fix ChannelModel.insert/removeRows --- ovgenpy/gui/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 1292836..5919b58 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -528,13 +528,14 @@ class ChannelModel(qc.QAbstractTableModel): return False self.beginInsertRows(parent, row, row + count - 1) - self.channels.insert(row, [ChannelConfig('') for _ in range(count)]) + 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) - if not (count >= 1 and 0 <= row < nchan and row + count <= nchan): + # 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) From b559ba8bf8603cbe438d58e6a5e7d1cdf88cecbf Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:00:15 -0800 Subject: [PATCH 066/102] [wip] Add channel reordering BUG: trigger__ attributes are not moved. --- ovgenpy/gui/__init__.py | 86 ++++++++++++++++++++++++++++++++++++++- ovgenpy/gui/mainwindow.ui | 7 +++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 5919b58..3622833 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -2,10 +2,12 @@ import os import sys from pathlib import Path from typing import * +from typing import List, Any import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw import attr +import more_itertools from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt from PyQt5.QtGui import QKeySequence @@ -60,6 +62,9 @@ class MainWindow(qw.QMainWindow): self.channelUp.add_shortcut(self.channelsGroup, 'ctrl+shift+up') self.channelDown.add_shortcut(self.channelsGroup, 'ctrl+shift+down') + self.channelUp.clicked.connect(self.channel_widget.on_channel_up) + self.channelDown.clicked.connect(self.channel_widget.on_channel_down) + # Bind actions. self.actionNew.triggered.connect(self.on_action_new) self.actionOpen.triggered.connect(self.on_action_open) @@ -81,7 +86,7 @@ class MainWindow(qw.QMainWindow): _cfg_path: Optional[Path] model: Optional['ConfigModel'] = None channel_model: 'ChannelModel' - channel_widget: qw.QTableView + channel_widget: 'ChannelTableView' channelsGroup: qw.QGroupBox def on_action_new(self): @@ -384,10 +389,53 @@ class ConfigModel(PresentationModel): # TODO mutate _cfg and convert all colors to #rrggbb on access +class ChannelTableView(qw.QTableView): + def on_channel_up(self): + self.move_selection(-1) + + def on_channel_down(self): + self.move_selection(1) + + def move_selection(self, delta: int): + model: 'ChannelModel' = self.model() + rows = self.selected_rows() + row_ranges = find_ranges(rows) + + # If we hit the end, cancel all other moves. + # If moving up, move top first. + if delta > 0: + # If moving down, move bottom first. + row_ranges = reversed(list(row_ranges)) + + parent = qc.QModelIndex() + for first_row, nrow in row_ranges: + if delta > 0: + dest_row = first_row + nrow + delta + else: + dest_row = first_row + delta + + if not model.moveRows(parent, first_row, nrow, parent, dest_row): + break + + def selected_rows(self) -> List[int]: + sel: qc.QItemSelectionModel = self.selectionModel() + inds: List[qc.QModelIndex] = sel.selectedIndexes() + rows: List[int] = sorted({ind.row() for ind in inds}) + return rows + + T = TypeVar('T') -nope = qc.QVariant() +def find_ranges(iterable: Iterable[T]) -> Iterable[Tuple[T, int]]: + """Extracts consecutive runs from a list of items. + + :param iterable: List of items. + :return: Iterable of (first elem, length). + """ + for group in more_itertools.consecutive_groups(iterable): + group = list(group) + yield group[0], len(group) @attr.dataclass @@ -543,8 +591,42 @@ class ChannelModel(qc.QAbstractTableModel): self.endInsertRows() 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) + + +nope = qc.QVariant() diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index ca1aa09..a4b19d9 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -356,7 +356,7 @@ width: 0px;
- +
@@ -484,6 +484,11 @@ width: 0px; QPushButton
ovgenpy/gui/__init__.h
+ + ChannelTableView + QTableView +
ovgenpy/gui/__init__.h
+
From 6b1f84a23d7b60c6a2a723bb1f3da405ed36814f Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:17:08 -0800 Subject: [PATCH 067/102] Fix triggers not being reordered when reordering channels --- ovgenpy/gui/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 3622833..4be9170 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -462,7 +462,6 @@ class ChannelModel(qc.QAbstractTableModel): """ Mutates `channels` and `line_color` for convenience. """ super().__init__() self.channels = channels - self.triggers: List[dict] = [] line_color = 'line_color' @@ -476,11 +475,16 @@ class ChannelModel(qc.QAbstractTableModel): else: trigger_dict = dict(t or {}) - cfg.trigger = trigger_dict - self.triggers.append(trigger_dict) 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: + trigger = self.channels[row].trigger + assert isinstance(trigger, dict) + return trigger + # columns col_data = [ Column('wav_path', str, '', 'WAV Path'), @@ -525,7 +529,7 @@ class ChannelModel(qc.QAbstractTableModel): key = data.key if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) - value = self.triggers[row].get(key, '') + value = self.triggers(row).get(key, '') else: value = getattr(self.channels[row], key) @@ -556,7 +560,7 @@ class ChannelModel(qc.QAbstractTableModel): if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) - self.triggers[row][key] = value + self.triggers(row)[key] = value else: setattr(self.channels[row], key, value) From 5d54f0ef3a543a9d46a0d584aeecaeb37bcecf93 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:25:43 -0800 Subject: [PATCH 068/102] setup.py more_itertools squash --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6c9657e..edfc703 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( description='', tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'], install_requires=[ - 'numpy', 'scipy', 'click', 'ruamel.yaml', + 'numpy', 'scipy', 'click', 'ruamel.yaml', 'more_itertools', 'matplotlib', 'attrs>=18.2.0', 'PyQt5', From cf151f7172c2db28004c49b722a5441ad1ac8ea8 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:26:15 -0800 Subject: [PATCH 069/102] Reorganize channel selection range code --- ovgenpy/gui/__init__.py | 17 +---------------- ovgenpy/gui/util.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 4be9170..52c072c 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -7,7 +7,6 @@ from typing import List, Any import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw import attr -import more_itertools from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt from PyQt5.QtGui import QKeySequence @@ -17,7 +16,7 @@ from ovgenpy import cli from ovgenpy.channel import ChannelConfig from ovgenpy.config import OvgenError, copy_config, yaml from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr -from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext +from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext, find_ranges from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig @@ -424,20 +423,6 @@ class ChannelTableView(qw.QTableView): return rows -T = TypeVar('T') - - -def find_ranges(iterable: Iterable[T]) -> Iterable[Tuple[T, int]]: - """Extracts consecutive runs from a list of items. - - :param iterable: List of items. - :return: Iterable of (first elem, length). - """ - for group in more_itertools.consecutive_groups(iterable): - group = list(group) - yield group[0], len(group) - - @attr.dataclass class Column: key: str diff --git a/ovgenpy/gui/util.py b/ovgenpy/gui/util.py index 108ff20..da03562 100644 --- a/ovgenpy/gui/util.py +++ b/ovgenpy/gui/util.py @@ -1,7 +1,9 @@ from pathlib import Path from typing import * +from typing import Iterable, Tuple import matplotlib.colors +import more_itertools from PyQt5.QtCore import QMutex from PyQt5.QtWidgets import QWidget, QFileDialog @@ -18,7 +20,6 @@ def color2hex(color): f'doubly invalid color {color}, raises {e} (report bug!)') - T = TypeVar('T') @@ -70,3 +71,13 @@ def get_save_with_ext( path = path.with_suffix(default_suffix) return path + +def find_ranges(iterable: Iterable[T]) -> Iterable[Tuple[T, int]]: + """Extracts consecutive runs from a list of items. + + :param iterable: List of items. + :return: Iterable of (first elem, length). + """ + for group in more_itertools.consecutive_groups(iterable): + group = list(group) + yield group[0], len(group) From 710621438df537b6f7a948454993d5936a4fdf86 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:48:06 -0800 Subject: [PATCH 070/102] Fix setData, emit dataChanged signal --- ovgenpy/gui/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 52c072c..19a915d 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -550,6 +550,7 @@ class ChannelModel(qc.QAbstractTableModel): else: setattr(self.channels[row], key, value) + self.dataChanged(index, index, [role]) return True return False From 40c645fb61af5d67d94b054359de2320330efd0c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 17:59:28 -0800 Subject: [PATCH 071/102] [ui] Fix id of channelAdd and channelDelete buttons --- ovgenpy/gui/mainwindow.ui | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index a4b19d9..d9ffb66 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -326,14 +326,14 @@ width: 0px; - + &Add... - + &Delete From 75e4f372f9ee7b6bb9b3f11b0ea6d3bd5b1fd77f Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 18:03:55 -0800 Subject: [PATCH 072/102] double-fix signal emit --- ovgenpy/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 19a915d..501cf08 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -550,7 +550,7 @@ class ChannelModel(qc.QAbstractTableModel): else: setattr(self.channels[row], key, value) - self.dataChanged(index, index, [role]) + self.dataChanged.emit(index, index, [role]) return True return False From 047e10f1672c860f5dc5766ae6c3051d3db813e2 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 18:20:43 -0800 Subject: [PATCH 073/102] Implement channel add button --- ovgenpy/gui/__init__.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 501cf08..468cd91 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -22,6 +22,8 @@ from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig from ovgenpy.util import obj_name +FILTER_WAV_FILES = "WAV files (*.wav)" + APP_NAME = 'ovgenpy' APP_DIR = Path(__file__).parent @@ -63,6 +65,8 @@ class MainWindow(qw.QMainWindow): self.channelUp.clicked.connect(self.channel_widget.on_channel_up) self.channelDown.clicked.connect(self.channel_widget.on_channel_down) + self.channelAdd.clicked.connect(self.on_channel_add) + # self.channelDelete.clicked.connect(self.on_channel_delete) # Bind actions. self.actionNew.triggered.connect(self.on_action_new) @@ -143,13 +147,20 @@ class MainWindow(qw.QMainWindow): # TODO add default file-open dir, initialized to yaml path and remembers prev # useless if people don't reopen old projects name, file_type = qw.QFileDialog.getOpenFileName( - self, "Open master audio file", self.cfg_dir, "WAV files (*.wav)" + self, "Open master audio file", self.cfg_dir, FILTER_WAV_FILES ) if name != '': master_audio = 'master_audio' self.model[master_audio] = name self.model.update_widget[master_audio]() + def on_channel_add(self): + wavs, file_type = qw.QFileDialog.getOpenFileNames( + self, "Add audio channels", self.cfg_dir, FILTER_WAV_FILES + ) + if wavs: + self.channel_widget.append_channels(wavs) + def on_action_save(self): if self._cfg_path is None: return self.on_action_save_as() @@ -389,6 +400,19 @@ class ConfigModel(PresentationModel): class ChannelTableView(qw.QTableView): + def append_channels(self, wavs: List[str]): + model: ChannelModel = self.model() + + begin_row = model.rowCount() + count_rows = len(wavs) + + col = model.idx_of_key['wav_path'] + + model.insertRows(begin_row, count_rows) + for row, wav_path in enumerate(wavs, begin_row): + index = model.index(row, col) + model.setData(index, wav_path) + def on_channel_up(self): self.move_selection(-1) @@ -482,6 +506,14 @@ class ChannelModel(qc.QAbstractTableModel): Column('trigger__buffer_falloff', float, None), ] + @staticmethod + def _idx_of_key(col_data=col_data): + return { + col.key: idx + for idx, col in enumerate(col_data) + } + idx_of_key = _idx_of_key.__func__() + def columnCount(self, parent: QModelIndex = ...) -> int: return len(self.col_data) @@ -561,7 +593,7 @@ class ChannelModel(qc.QAbstractTableModel): 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: + def insertRows(self, row: int, count: int, parent=QModelIndex()) -> bool: if not (count >= 1 and 0 <= row <= len(self.channels)): return False @@ -570,7 +602,7 @@ class ChannelModel(qc.QAbstractTableModel): self.endInsertRows() return True - def removeRows(self, row: int, count: int, parent: QModelIndex = ...) -> bool: + 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): From 3739ec03fbb0d3c1a8675c90a48c9cab8f3643c0 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 18:23:48 -0800 Subject: [PATCH 074/102] [gui] Delete blank trigger keys, instead of setting to None --- ovgenpy/gui/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 468cd91..1e2f2e5 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -577,7 +577,11 @@ class ChannelModel(qc.QAbstractTableModel): if key.startswith(self.TRIGGER): key = behead(key, self.TRIGGER) - self.triggers(row)[key] = value + trigger = self.triggers(row) + if value == data.default: + del trigger[key] + else: + trigger[key] = value else: setattr(self.channels[row], key, value) From 4ecd27cf5b03e0f393e85d6ac7df91c04f8d09f7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 18:36:50 -0800 Subject: [PATCH 075/102] [gui] Collapse per-channel absolute paths --- ovgenpy/gui/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 1e2f2e5..f511c6b 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -553,8 +553,10 @@ class ChannelModel(qc.QAbstractTableModel): if value == data.default: return '' - else: - return str(value) + if key == 'wav_path' and role == Qt.DisplayRole: + if Path(value).parent != Path(): + return '...' + Path(value).name + return str(value) return nope From 0c4125a5b70e8ad1cf5a13809f33f7383a5ac268 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 18:37:01 -0800 Subject: [PATCH 076/102] Implement delete button --- ovgenpy/gui/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index f511c6b..1338998 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -66,7 +66,7 @@ class MainWindow(qw.QMainWindow): self.channelUp.clicked.connect(self.channel_widget.on_channel_up) self.channelDown.clicked.connect(self.channel_widget.on_channel_down) self.channelAdd.clicked.connect(self.on_channel_add) - # self.channelDelete.clicked.connect(self.on_channel_delete) + self.channelDelete.clicked.connect(self.on_channel_delete) # Bind actions. self.actionNew.triggered.connect(self.on_action_new) @@ -161,6 +161,9 @@ class MainWindow(qw.QMainWindow): if wavs: self.channel_widget.append_channels(wavs) + def on_channel_delete(self): + self.channel_widget.delete_selected() + def on_action_save(self): if self._cfg_path is None: return self.on_action_save_as() @@ -413,6 +416,14 @@ class ChannelTableView(qw.QTableView): index = model.index(row, col) model.setData(index, wav_path) + def delete_selected(self): + model: 'ChannelModel' = self.model() + rows = self.selected_rows() + row_ranges = find_ranges(rows) + + for first_row, nrow in reversed(list(row_ranges)): + model.removeRows(first_row, nrow) + def on_channel_up(self): self.move_selection(-1) @@ -616,7 +627,7 @@ class ChannelModel(qc.QAbstractTableModel): self.beginRemoveRows(parent, row, row + count - 1) del self.channels[row: row + count] - self.endInsertRows() + self.endRemoveRows() return True def moveRows(self, From 4c4b84a23a1e2086b7bb1c9582585b00a58807d4 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 19:13:37 -0800 Subject: [PATCH 077/102] [gui] Report errors when launching Ovgen --- ovgenpy/gui/__init__.py | 30 ++++++++++++++++++++++-------- ovgenpy/ovgenpy.py | 1 - 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 1338998..7ba1eae 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -227,10 +227,17 @@ class MainWindow(qw.QMainWindow): ) cfg = copy_config(self.model.cfg) - t = self.ovgen_thread.obj = OvgenThread(self, cfg, arg) - # Assigns self.ovgen_thread.set(None) when finished. + t = self.ovgen_thread.obj = OvgenThread(cfg, arg) + t.error.connect(self.on_play_thread_error) + t.finished.connect(self.on_play_thread_finished) t.start() + def on_play_thread_error(self, exc: BaseException): + qw.QMessageBox.critical(self, 'Error rendering oscilloscope', str(exc)) + + def on_play_thread_finished(self): + self.ovgen_thread.set(None) + def _get_args(self, outputs: List[IOutputConfig]): arg = Arguments( cfg_dir=self.cfg_dir, @@ -279,16 +286,23 @@ class ShortcutButton(qw.QPushButton): class OvgenThread(qc.QThread): - def __init__(self, parent: MainWindow, cfg: Config, arg: Arguments): + def __init__(self, cfg: Config, arg: Arguments): qc.QThread.__init__(self) + self.cfg = cfg + self.arg = arg - def run() -> None: + def run(self) -> None: + cfg = self.cfg + arg = self.arg + try: Ovgen(cfg, arg).play() - self.run = run + except Exception as e: + arg.on_end() + self.error.emit(e) + else: + arg.on_end() - def finished(): - parent.ovgen_thread.set(None) - self.finished.connect(finished) + error = qc.pyqtSignal(Exception) class OvgenProgressDialog(qw.QProgressDialog): diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index 985bd1a..ecdfc7c 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -338,7 +338,6 @@ class Ovgen: if self.raise_on_teardown: raise self.raise_on_teardown - self.arg.on_end() if PRINT_TIMESTAMP: # noinspection PyUnboundLocalVariable dtime = time.perf_counter() - begin From 6d60803d85a07000f652210f9901258a1fab4610 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 20:58:00 -0800 Subject: [PATCH 078/102] Update ruamel.yaml to fix GUI errors (reordering channels) --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index edfc703..637fbee 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,8 @@ setup( description='', tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'], install_requires=[ - 'numpy', 'scipy', 'click', 'ruamel.yaml', 'more_itertools', + # ruamel.yaml 0.15.55 implements slice assignment in loaded list (CommentedSeq). + 'numpy', 'scipy', 'click', 'ruamel.yaml>=0.15.55', 'more_itertools', 'matplotlib', 'attrs>=18.2.0', 'PyQt5', From dcb5bd3baa85a3a36240d81461f8a293cabe5b46 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 21:26:44 -0800 Subject: [PATCH 079/102] Show popup if file fails to load --- ovgenpy/gui/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 7ba1eae..5e25bee 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -32,6 +32,7 @@ def res(file: str) -> str: def gui_main(cfg: Config, cfg_path: Optional[Path]): + # TODO read config within MainWindow, and show popup if loading fails. app = qw.QApplication(sys.argv) app.setAttribute(qc.Qt.AA_EnableHighDpiScaling) @@ -102,8 +103,15 @@ class MainWindow(qw.QMainWindow): ) if name != '': cfg_path = Path(name) - cfg = yaml.load(cfg_path) - self.load_cfg(cfg, cfg_path) + try: + # Raises YAML structural exceptions + cfg = yaml.load(cfg_path) + # Raises color getter exceptions + # ISSUE: catching an exception will leave UI in undefined state? + self.load_cfg(cfg, cfg_path) + except Exception as e: + qw.QMessageBox.critical(self, 'Error loading file', str(e)) + return def load_cfg(self, cfg: Config, cfg_path: Optional[Path]): self._cfg_path = cfg_path From 021e1578942d071433dfd22e4cb9bf2c7da12d30 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 23:14:32 -0800 Subject: [PATCH 080/102] Update ruamel.yaml to 0.15.70, add behavior tests --- setup.py | 4 +-- tests/test_config.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 637fbee..d6109c3 100644 --- a/setup.py +++ b/setup.py @@ -11,8 +11,8 @@ setup( description='', tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'], install_requires=[ - # ruamel.yaml 0.15.55 implements slice assignment in loaded list (CommentedSeq). - 'numpy', 'scipy', 'click', 'ruamel.yaml>=0.15.55', 'more_itertools', + 'ruamel.yaml>=0.15.70', # See test_config.py to pick a suitable minimum version + 'numpy', 'scipy', 'click', 'more_itertools', 'matplotlib', 'attrs>=18.2.0', 'PyQt5', diff --git a/tests/test_config.py b/tests/test_config.py index 6149aed..3cea8b4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -229,3 +229,68 @@ def test_load_post_init(): foo: 0 ''' assert yaml.load(s) == Foo(99) + + +# ruamel.yaml has a unstable and shape-shifting API. +# Test which version numbers have properties we want. + +def test_dump_dataclass_order(): + @register_config(always_dump='*') + class Config: + a: int = 1 + b: int = 1 + c: int = 1 + d: int = 1 + e: int = 1 + z: int = 1 + y: int = 1 + x: int = 1 + w: int = 1 + v: int = 1 + + assert yaml.dump(Config()) == '''\ +!Config +a: 1 +b: 1 +c: 1 +d: 1 +e: 1 +z: 1 +y: 1 +x: 1 +w: 1 +v: 1 +''' + + +def test_load_dump_dict_order(): + s = '''\ +a: 1 +b: 1 +c: 1 +d: 1 +e: 1 +z: 1 +y: 1 +x: 1 +w: 1 +v: 1 +''' + dic = yaml.load(s) + assert yaml.dump(dic) == s, yaml.dump(dic) + + +def test_load_list_dict_type(): + """Fails on ruamel.yaml<0.15.70 (CommentedMap/CommentedSeq).""" + dic = yaml.load('{}') + assert isinstance(dic, dict) + + lis = yaml.load('[]') + assert isinstance(lis, list) + + +def test_list_slice_assign(): + """Crashes on ruamel.yaml<0.15.55 (CommentedSeq).""" + lis = yaml.load('[]') + lis[0:0] = list(range(5)) + lis[2:5] = [] From 395a8ea0c937be2a3f27543261e1136b6c9c8846 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 23:32:03 -0800 Subject: [PATCH 081/102] Switch Windows font to Segoe UI 9 --- ovgenpy/gui/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 5e25bee..c49a76d 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -9,7 +9,7 @@ import PyQt5.QtWidgets as qw import attr from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt -from PyQt5.QtGui import QKeySequence +from PyQt5.QtGui import QKeySequence, QFont from PyQt5.QtWidgets import QShortcut from ovgenpy import cli @@ -33,9 +33,19 @@ def res(file: str) -> str: def gui_main(cfg: Config, cfg_path: Optional[Path]): # TODO read config within MainWindow, and show popup if loading fails. - app = qw.QApplication(sys.argv) - app.setAttribute(qc.Qt.AA_EnableHighDpiScaling) + # qw.QApplication.setStyle('fusion') + QApp = qw.QApplication + QApp.setAttribute(qc.Qt.AA_EnableHighDpiScaling) + # Qt on Windows will finally switch default font to lfMessageFont=Segoe UI + # (Vista, 2006)... in 2020 (Qt 6.0). + if qc.QSysInfo.kernelType() == 'winnt': + # This will be wrong for non-English languages, but it's better than default? + font = QFont("Segoe UI", 9) + font.setStyleHint(QFont.SansSerif) + QApp.setFont(font) + + app = qw.QApplication(sys.argv) window = MainWindow(cfg, cfg_path) sys.exit(app.exec_()) From 48e666e992217dd6a5811741fd753d3ba758802b Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Fri, 14 Dec 2018 23:46:43 -0800 Subject: [PATCH 082/102] Fix exception when closing ffplay on Windows --- ovgenpy/outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovgenpy/outputs.py b/ovgenpy/outputs.py index ce6a942..18fd66d 100644 --- a/ovgenpy/outputs.py +++ b/ovgenpy/outputs.py @@ -137,7 +137,7 @@ class PipeOutput(Output): def close(self, wait=True) -> int: try: self._stream.close() - except BrokenPipeError: + except (BrokenPipeError, OSError): # BrokenPipeError is a OSError pass if not wait: From e068bdf3d41e21f72ff76e4d315b168573088e89 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 12:16:02 -0800 Subject: [PATCH 083/102] Prevent tab key from focusing scrollbar/widget --- ovgenpy/gui/mainwindow.ui | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index d9ffb66..d5873b9 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -23,6 +23,9 @@ 0 + + Qt::NoFocus + Qt::ScrollBarAlwaysOn From bece7c81c47d436fbe1a5b0dc25df47ab7916700 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 12:24:15 -0800 Subject: [PATCH 084/102] Remove stylesheet, fix Windows spinbox height (Qt bug) https://bugreports.qt.io/browse/QTBUG-38537 --- ovgenpy/gui/mainwindow.ui | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index d5873b9..ebbb6e1 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -39,11 +39,6 @@ 0 - - BoundLineEdit { -width: 0px; -} - From 6749a19a4c9c4f1977fccf423276542eb250cb60 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 15:42:08 -0800 Subject: [PATCH 085/102] [ui] Move trigger ms to Global, rename subsampling to Performance --- ovgenpy/gui/mainwindow.ui | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index ebbb6e1..49146d4 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -80,23 +80,14 @@ - - - - - - - Channel Width - - - + Trigger Width (ms) - + 5 @@ -106,14 +97,14 @@ - + Render Width (ms) - + 5 @@ -123,28 +114,37 @@ - + + + + + + + Performance + + + Trigger Subsampling - + 1 - + Render Subsampling - + 1 From c900b09e65501a8530956a10aae16b612a8c385d Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 15:45:12 -0800 Subject: [PATCH 086/102] [ui,gui] Rename channel_widget to channel_view --- ovgenpy/gui/__init__.py | 12 ++++++------ ovgenpy/gui/mainwindow.ui | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index c49a76d..cacf150 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -74,8 +74,8 @@ class MainWindow(qw.QMainWindow): self.channelUp.add_shortcut(self.channelsGroup, 'ctrl+shift+up') self.channelDown.add_shortcut(self.channelsGroup, 'ctrl+shift+down') - self.channelUp.clicked.connect(self.channel_widget.on_channel_up) - self.channelDown.clicked.connect(self.channel_widget.on_channel_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) @@ -100,7 +100,7 @@ class MainWindow(qw.QMainWindow): _cfg_path: Optional[Path] model: Optional['ConfigModel'] = None channel_model: 'ChannelModel' - channel_widget: 'ChannelTableView' + channel_view: 'ChannelTableView' channelsGroup: qw.QGroupBox def on_action_new(self): @@ -133,7 +133,7 @@ class MainWindow(qw.QMainWindow): self.channel_model = ChannelModel(cfg.channels) # Calling setModel again disconnects previous model. - self.channel_widget.setModel(self.channel_model) + self.channel_view.setModel(self.channel_model) self.load_title() @@ -177,10 +177,10 @@ class MainWindow(qw.QMainWindow): self, "Add audio channels", self.cfg_dir, FILTER_WAV_FILES ) if wavs: - self.channel_widget.append_channels(wavs) + self.channel_view.append_channels(wavs) def on_channel_delete(self): - self.channel_widget.delete_selected() + self.channel_view.delete_selected() def on_action_save(self): if self._cfg_path is None: diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 49146d4..ad4c140 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -354,7 +354,7 @@ - +
From 003eebb0f22c98b0ba991082d30e14b07ac5488c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 16:00:11 -0800 Subject: [PATCH 087/102] [ui] Remove leading 'cfg__' prefix, bind all BoundWidgets Makes tree easier to read in Qt Designer --- ovgenpy/gui/data_bind.py | 16 ++++++---------- ovgenpy/gui/mainwindow.ui | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 7b77ffc..12c683b 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -57,8 +57,6 @@ class PresentationModel: updater() -BIND_PREFIX = 'cfg__' - # TODO add tests for recursive operations def map_gui(view: QWidget, model: PresentationModel): """ @@ -70,13 +68,10 @@ def map_gui(view: QWidget, model: PresentationModel): Only s starting with 'cfg__' will be bound. """ - widgets = view.findChildren(QWidget) # dear pyqt, add generic mypy return types + widgets: List[BoundWidget] = view.findChildren(BoundWidget) # dear pyqt, add generic mypy return types for widget in widgets: - widget_name = widget.objectName() - path = try_behead(widget_name, BIND_PREFIX) - if path is not None: - assert isinstance(widget, BoundWidget) - widget.bind_widget(model, path) + path = widget.objectName() + widget.bind_widget(model, path) Signal = Any @@ -92,7 +87,7 @@ class BoundWidget(QWidget): try: self.default_palette = self.palette() self.error_palette = self.calc_error_palette() - + self.pmodel = model self.path = path self.cfg2gui() @@ -107,7 +102,7 @@ class BoundWidget(QWidget): perr(self) perr(path) raise - + def calc_error_palette(self) -> QPalette: """ Palette with red background, used for widgets with invalid input. """ error_palette = QPalette(self.palette()) @@ -224,6 +219,7 @@ class BoundComboBox(qw.QComboBox, BoundWidget): self.pmodel[self.path] = self.combo_symbols[combo_index] +# Unused def try_behead(string: str, header: str) -> Optional[str]: if not string.startswith(header): return None diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index ad4c140..6261b4d 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -54,7 +54,7 @@ - + 1 @@ -74,7 +74,7 @@ - + 0.100000000000000 @@ -88,7 +88,7 @@ - + 5 @@ -105,7 +105,7 @@ - + 5 @@ -131,7 +131,7 @@ - + 1 @@ -145,7 +145,7 @@ - + 1 @@ -168,7 +168,7 @@ - + bg @@ -182,7 +182,7 @@ - + fg @@ -196,7 +196,7 @@ - + 0.500000000000000 @@ -222,7 +222,7 @@ - + @@ -234,7 +234,7 @@ - +   @@ -248,7 +248,7 @@ - +   @@ -264,7 +264,7 @@ - + vs @@ -286,7 +286,7 @@ - + / From 47e9b87181d871c1680ecf08175cab7cf84d10d9 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 16:05:45 -0800 Subject: [PATCH 088/102] [gui] Add Render FPS Divisor (render_subfps) field --- ovgenpy/gui/mainwindow.ui | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 6261b4d..774c5b1 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -124,27 +124,41 @@ - + - Trigger Subsampling + Render FPS Divisor - + 1 + + + Trigger Subsampling + + + + + + + 1 + + + + Render Subsampling - + 1 From 17f194f140e0f701ec0cc65023f02f2d3d46c0b7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 16:10:28 -0800 Subject: [PATCH 089/102] [py] When dumping config, skip fields with leading underscores They have already been baked into other config fields. --- ovgenpy/config.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ovgenpy/config.py b/ovgenpy/config.py index 8f92e28..28229a9 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -107,15 +107,16 @@ class _ConfigMixin: cls = type(self) for field in attr.fields(cls): - # Remove leading underscore from attribute name, - # since attrs __init__ removes leading underscore. + # Skip deprecated fields with leading underscores. + # They have already been baked into other config fields. - key = field.name - value = getattr(self, key) + name = field.name + if name[0] == '_': + continue - name = key[1:] if key[0] == '_' else key + value = getattr(self, name) - if dump_all or key in always_dump: + if dump_all or name in always_dump: state[name] = value continue From 0d207dc3a125be80eb6ef1850dc32256450f29a1 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 19:25:05 -0800 Subject: [PATCH 090/102] [ui] Reorganize sidebar (like sidwizplus), add global trigger config - Move performance group to bottom. --- ovgenpy/gui/mainwindow.ui | 282 +++++++++++++++++++++++--------------- 1 file changed, 168 insertions(+), 114 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 774c5b1..3f71333 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -67,27 +67,13 @@ - - - Amplification - - - - - - - 0.100000000000000 - - - - Trigger Width (ms) - + 5 @@ -97,14 +83,14 @@ - + Render Width (ms) - + 5 @@ -114,108 +100,17 @@ - - - - - - - Performance - - - - + + - Render FPS Divisor + Amplification - - - - 1 - - - - - - - Trigger Subsampling - - - - - - - 1 - - - - - - - Render Subsampling - - - - - - - 1 - - - - - - - - - - Appearance - - - - - - Background - - - - - - - bg - - - - - - - Line Color - - - - - - - fg - - - - - - - Line Width - - - - - - - 0.500000000000000 - + + - 0.500000000000000 + 0.100000000000000 @@ -287,6 +182,165 @@ + + + + Appearance + + + + + + Background + + + + + + + bg + + + + + + + Line Color + + + + + + + fg + + + + + + + Line Width + + + + + + + 0.500000000000000 + + + 0.500000000000000 + + + + + + + + + + Trigger + + + + + + Edge Strength + + + + + + + -99.000000000000000 + + + + + + + Responsiveness + + + + + + + 1.000000000000000 + + + 0.100000000000000 + + + + + + + Buffer Falloff + + + + + + + 0.500000000000000 + + + + + + + + + + Performance + + + + + + Render FPS Divisor + + + + + + + 1 + + + + + + + Trigger Subsampling + + + + + + + 1 + + + + + + + Render Subsampling + + + + + + + 1 + + + + + + From 57ff731f9d4e4a6e16f82ee2cb9d04312bc617f7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 19:36:41 -0800 Subject: [PATCH 091/102] [ui] Move trigger to new tab, eliminate scrollbars (shrinkwrap height) --- ovgenpy/gui/mainwindow.ui | 190 ++++++++++++++++++++------------------ 1 file changed, 98 insertions(+), 92 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 3f71333..b015911 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -7,7 +7,7 @@ 0 0 1080 - 640 + 0 @@ -16,30 +16,15 @@ - - - - 0 - 0 - + + + 0 - - Qt::NoFocus - - - Qt::ScrollBarAlwaysOn - - - true - - - - - 0 - 0 - - - + + + &General + + @@ -117,6 +102,60 @@ + + + + Appearance + + + + + + Background + + + + + + + bg + + + + + + + Line Color + + + + + + + fg + + + + + + + Line Width + + + + + + + 0.500000000000000 + + + 0.500000000000000 + + + + + + @@ -183,59 +222,70 @@ - + - Appearance + Performance - + - + - Background + Render FPS Divisor - - - bg + + + 1 - + - Line Color + Trigger Subsampling - - - fg + + + 1 - + - Line Width + Render Subsampling - + - 0.500000000000000 - - - 0.500000000000000 + 1 + + + + Qt::Vertical + + + + + + + + &Trigger + + @@ -291,55 +341,11 @@ - - - Performance + + + Qt::Vertical - - - - - Render FPS Divisor - - - - - - - 1 - - - - - - - Trigger Subsampling - - - - - - - 1 - - - - - - - Render Subsampling - - - - - - - 1 - - - - - + From d0ca70bbf0c32d9f71e4babd1ec2f27b557f1ada Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 19:41:39 -0800 Subject: [PATCH 092/102] [ui, remove scroll] Re-add vertical spacer size hints --- ovgenpy/gui/mainwindow.ui | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index b015911..a0cf67a 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -277,6 +277,12 @@ Qt::Vertical + + + 20 + 40 + + @@ -345,6 +351,12 @@ Qt::Vertical + + + 20 + 40 + + From 1b8460833a197102dc584dedb00c3f2aec597abc Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 22:50:09 -0800 Subject: [PATCH 093/102] [ui] Move trigger config to right panel, with per-channel trigger --- ovgenpy/gui/mainwindow.ui | 607 ++++++++++++++++++-------------------- 1 file changed, 291 insertions(+), 316 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index a0cf67a..cace566 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -16,288 +16,303 @@ - - - 0 + + + + 0 + 0 + - - - &General - - + + + + + Global + + + + + + FPS + + + + + + + 1 + + + 999 + + + 10 + + + + + + + Trigger Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Render Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Amplification + + + + + + + 0.100000000000000 + + + + + + + + + + Appearance + + + + + + Background + + + + + + + bg + + + + + + + Line Color + + + + + + + fg + + + + + + + Line Width + + + + + + + 0.500000000000000 + + + 0.500000000000000 + + + + + + + + + + Layout + + + + + + Orientation + + + + + + + + + + Columns + + + + + + + + +   + + + + + + + Rows + + + + + + +   + + + + + + + + + Video Size + + + + + + + vs + + + + + + + + + + Performance + + + + + + Render FPS Divisor + + + + + + + 1 + + + + + + + Trigger Subsampling + + + + + + + 1 + + + + + + + Render Subsampling + + + + + + + 1 + + + + + + + + + + + + + - + - Global + Master Audio - - - + + + - FPS + / - - - - 1 - - - 999 - - - 10 - - - - - + + - Trigger Width (ms) - - - - - - - 5 - - - 5 - - - - - - - Render Width (ms) - - - - - - - 5 - - - 5 - - - - - - - Amplification - - - - - - - 0.100000000000000 + &Browse... - - - - Appearance - - - - - - Background - - - - - - - bg - - - - - - - Line Color - - - - - - - fg - - - - - - - Line Width - - - - - - - 0.500000000000000 - - - 0.500000000000000 - - - - - - - - - - Layout - - - - - - Orientation - - - - - - - - - - Columns - - - - - - - - -   - - - - - - - Rows - - - - - - -   - - - - - - - - - Video Size - - - - - - - vs - - - - - - - - - - Performance - - - - - - Render FPS Divisor - - - - - - - 1 - - - - - - - Trigger Subsampling - - - - - - - 1 - - - - - - - Render Subsampling - - - - - - - 1 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - &Trigger - - + + + 0 + 0 + + Trigger - + @@ -306,16 +321,23 @@ - - - -99.000000000000000 + + + Responsiveness + + + + + + + Buffer Falloff - - - Responsiveness + + + -99.000000000000000 @@ -329,14 +351,7 @@ - - - - Buffer Falloff - - - - + 0.500000000000000 @@ -346,47 +361,7 @@ - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - Master Audio - - - - - - / - - - - - - - &Browse... - - - - - From 6ba4c87a3e9bb995ec3feab76c028009b9503fb7 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 23:07:38 -0800 Subject: [PATCH 094/102] [ui] Restore options tabs (General only) --- ovgenpy/gui/mainwindow.ui | 525 ++++++++++++++++++++------------------ 1 file changed, 270 insertions(+), 255 deletions(-) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index cace566..2cf6a23 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -16,262 +16,277 @@ - - - - 0 - 0 - + + + 0 - - - - - Global - - - - - - FPS - - - - - - - 1 - - - 999 - - - 10 - - - - - - - Trigger Width (ms) - - - - - - - 5 - - - 5 - - - - - - - Render Width (ms) - - - - - - - 5 - - - 5 - - - - - - - Amplification - - - - - - - 0.100000000000000 - - - - - - - - - - Appearance - - - - - - Background - - - - - - - bg - - - - - - - Line Color - - - - - - - fg - - - - - - - Line Width - - - - - - - 0.500000000000000 - - - 0.500000000000000 - - - - - - - - - - Layout - - - - - - Orientation - - - - - - - - - - Columns - - - - - - - - -   - - - - - - - Rows - - - - - - -   - - - - - - - - - Video Size - - - - - - - vs - - - - - - - - - - Performance - - - - - - Render FPS Divisor - - - - - - - 1 - - - - - - - Trigger Subsampling - - - - - - - 1 - - - - - - - Render Subsampling - - - - - - - 1 - - - - - - - + + + &General + + + + + + Global + + + + + + FPS + + + + + + + 1 + + + 999 + + + 10 + + + + + + + Trigger Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Render Width (ms) + + + + + + + 5 + + + 5 + + + + + + + Amplification + + + + + + + 0.100000000000000 + + + + + + + + + + Appearance + + + + + + Background + + + + + + + bg + + + + + + + Line Color + + + + + + + fg + + + + + + + Line Width + + + + + + + 0.500000000000000 + + + 0.500000000000000 + + + + + + + + + + Layout + + + + + + Orientation + + + + + + + + + + Columns + + + + + + + + +   + + + + + + + Rows + + + + + + +   + + + + + + + + + Video Size + + + + + + + vs + + + + + + + + + + Performance + + + + + + Render FPS Divisor + + + + + + + 1 + + + + + + + Trigger Subsampling + + + + + + + 1 + + + + + + + Render Subsampling + + + + + + + 1 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + From 80354335176d73bfb307d434665187dd644f8c4b Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 23:31:29 -0800 Subject: [PATCH 095/102] [gui] Fix crash when erasing nonexistent per-channel trigger item --- ovgenpy/gui/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index cacf150..ed1dd13 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -624,7 +624,8 @@ class ChannelModel(qc.QAbstractTableModel): key = behead(key, self.TRIGGER) trigger = self.triggers(row) if value == data.default: - del trigger[key] + # Delete key if (key: value) present + trigger.pop(key, None) else: trigger[key] = value From 7932bee52905100407e562e41400715a0bd0937e Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sat, 15 Dec 2018 23:42:16 -0800 Subject: [PATCH 096/102] [gui] Add begin time field --- ovgenpy/gui/mainwindow.ui | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ovgenpy/gui/mainwindow.ui b/ovgenpy/gui/mainwindow.ui index 2cf6a23..1333825 100644 --- a/ovgenpy/gui/mainwindow.ui +++ b/ovgenpy/gui/mainwindow.ui @@ -99,6 +99,20 @@ + + + + Begin Time (s) + + + + + + + 9999.000000000000000 + + + From c74593b84c786e001773da525446c8b6e315dabe Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Sun, 16 Dec 2018 17:06:51 -0800 Subject: [PATCH 097/102] Switch render buffer type to Union[bytes, np.ndarray] --- ovgenpy/outputs.py | 6 ++++-- ovgenpy/renderer.py | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ovgenpy/outputs.py b/ovgenpy/outputs.py index 18fd66d..4203f69 100644 --- a/ovgenpy/outputs.py +++ b/ovgenpy/outputs.py @@ -1,4 +1,5 @@ # https://ffmpeg.org/ffplay.html +import numpy as np import shlex import subprocess from abc import ABC, abstractmethod @@ -11,6 +12,7 @@ if TYPE_CHECKING: from ovgenpy.ovgenpy import Config +ByteBuffer = Union[bytes, np.ndarray] RGB_DEPTH = 3 PIXEL_FORMAT = 'rgb24' @@ -45,7 +47,7 @@ class Output(ABC): return self @abstractmethod - def write_frame(self, frame: bytes) -> Optional[_Stop]: + def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]: """ Output a Numpy ndarray. """ def __exit__(self, exc_type, exc_val, exc_tb): @@ -127,7 +129,7 @@ class PipeOutput(Output): def __enter__(self): return self - def write_frame(self, frame: bytes) -> Optional[_Stop]: + def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]: try: self._stream.write(frame) return None diff --git a/ovgenpy/renderer.py b/ovgenpy/renderer.py index e254486..cf5affd 100644 --- a/ovgenpy/renderer.py +++ b/ovgenpy/renderer.py @@ -7,7 +7,7 @@ import attr from ovgenpy.config import register_config from ovgenpy.layout import RendererLayout, LayoutConfig -from ovgenpy.outputs import RGB_DEPTH +from ovgenpy.outputs import RGB_DEPTH, ByteBuffer from ovgenpy.util import coalesce matplotlib.use('agg') @@ -48,6 +48,7 @@ class LineParam: color: str +# TODO rename to Plotter class Renderer(ABC): def __init__(self, cfg: RendererConfig, lcfg: 'LayoutConfig', nplots: int, channel_cfgs: Optional[List['ChannelConfig']]): @@ -73,7 +74,7 @@ class Renderer(ABC): def render_frame(self, datas: List[np.ndarray]) -> None: ... @abstractmethod - def get_frame(self) -> bytes: ... + def get_frame(self) -> ByteBuffer: ... class MatplotlibRenderer(Renderer): @@ -183,7 +184,7 @@ class MatplotlibRenderer(Renderer): self._fig.canvas.draw() self._fig.canvas.flush_events() - def get_frame(self) -> bytes: + def get_frame(self) -> ByteBuffer: """ Returns ndarray of shape w,h,3. """ canvas = self._fig.canvas From 635e280d6fb14d2e95636d4336435a5305619e04 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 18 Dec 2018 00:43:35 -0800 Subject: [PATCH 098/102] Warn on unsaved changes --- ovgenpy/gui/__init__.py | 89 +++++++++++++++++++++++++++++++++++----- ovgenpy/gui/data_bind.py | 8 +++- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index ed1dd13..6a0b6c7 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -9,7 +9,7 @@ import PyQt5.QtWidgets as qw import attr from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt -from PyQt5.QtGui import QKeySequence, QFont +from PyQt5.QtGui import QKeySequence, QFont, QCloseEvent from PyQt5.QtWidgets import QShortcut from ovgenpy import cli @@ -86,7 +86,7 @@ class MainWindow(qw.QMainWindow): self.actionSaveAs.triggered.connect(self.on_action_save_as) self.actionPlay.triggered.connect(self.on_action_play) self.actionRender.triggered.connect(self.on_action_render) - self.actionExit.triggered.connect(qw.QApplication.quit) + self.actionExit.triggered.connect(qw.QApplication.closeAllWindows) # Initialize ovgen-thread attribute. self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None) @@ -98,16 +98,31 @@ class MainWindow(qw.QMainWindow): # Config models _cfg_path: Optional[Path] + + # Whether document is dirty, changed, has unsaved changes + any_unsaved: bool + model: Optional['ConfigModel'] = None channel_model: 'ChannelModel' channel_view: 'ChannelTableView' channelsGroup: qw.QGroupBox + def closeEvent(self, event: QCloseEvent) -> None: + """Called on closing window.""" + if self.prompt_save(): + event.accept() + else: + event.ignore() + def on_action_new(self): + if not self.prompt_save(): + return cfg = default_config() self.load_cfg(cfg, None) def on_action_open(self): + if not self.prompt_save(): + return name, file_type = qw.QFileDialog.getOpenFileName( self, "Open config", self.cfg_dir, "YAML files (*.yaml)" ) @@ -123,10 +138,41 @@ class MainWindow(qw.QMainWindow): qw.QMessageBox.critical(self, 'Error loading file', str(e)) return + def prompt_save(self) -> 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.any_unsaved: + return True + + # should_close = qw.QMessageBox.question(self, "Close Confirmation", "Exit?", + # QMessageBox::Yes | QMessageBox::No) + Msg = qw.QMessageBox + + save_message = f"Save changes to {self.title_cache}?" + should_close = Msg.question( + self, "Save Changes?", save_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 load_cfg(self, cfg: Config, cfg_path: Optional[Path]): self._cfg_path = cfg_path + self.any_unsaved = False + self.load_title() + if self.model is None: self.model = ConfigModel(cfg) + # Calls self.on_gui_edited() whenever GUI widgets change. map_gui(self, self.model) else: self.model.set_cfg(cfg) @@ -134,16 +180,24 @@ class MainWindow(qw.QMainWindow): 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_gui_edited) - self.load_title() + def on_gui_edited(self): + self.any_unsaved = True + self.update_unsaved_title() + + title_cache: str def load_title(self): - self.setWindowTitle(f'{self.title} - {APP_NAME}') + self.title_cache = self.title + self.update_unsaved_title() - # Unused - # @property - # def ever_saved(self): - # return self._cfg_path is not None + def update_unsaved_title(self): + 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 @@ -182,12 +236,22 @@ class MainWindow(qw.QMainWindow): def on_channel_delete(self): self.channel_view.delete_selected() - def on_action_save(self): + 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) - def on_action_save_as(self): + 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. + """ cfg_path_default = os.path.join(self.cfg_dir, self.file_stem) + cli.YAML_NAME filters = ["YAML files (*.yaml)", "All files (*)"] @@ -198,6 +262,9 @@ class MainWindow(qw.QMainWindow): self._cfg_path = path self.load_title() self.on_action_save() + return True + else: + return False def on_action_play(self): """ Launch ovgen and ffplay. """ diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 12c683b..0bdc5ee 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -1,6 +1,6 @@ import functools import operator -from typing import Optional, List, Callable, Dict, Any, ClassVar +from typing import Optional, List, Callable, Dict, Any, ClassVar, TYPE_CHECKING from PyQt5 import QtWidgets as qw, QtCore as qc from PyQt5.QtCore import pyqtSlot @@ -11,6 +11,9 @@ from ovgenpy.config import OvgenError from ovgenpy.triggers import lerp from ovgenpy.util import obj_name, perr +if TYPE_CHECKING: + from ovgenpy.gui import MainWindow + __all__ = ['PresentationModel', 'map_gui', 'behead', 'rgetattr', 'rsetattr'] @@ -58,7 +61,7 @@ class PresentationModel: # TODO add tests for recursive operations -def map_gui(view: QWidget, model: PresentationModel): +def map_gui(view: 'MainWindow', model: PresentationModel): """ Binding: - .ui @@ -72,6 +75,7 @@ def map_gui(view: QWidget, model: PresentationModel): for widget in widgets: path = widget.objectName() widget.bind_widget(model, path) + widget.gui_changed.connect(view.on_gui_edited) Signal = Any From 98958903079d655358974bee50e7c6125498262c Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 18 Dec 2018 00:49:59 -0800 Subject: [PATCH 099/102] Use property to update title when any_unsaved edited --- ovgenpy/gui/__init__.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 6a0b6c7..8fbe9bc 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -100,7 +100,16 @@ class MainWindow(qw.QMainWindow): _cfg_path: Optional[Path] # Whether document is dirty, changed, has unsaved changes - any_unsaved: bool + _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() model: Optional['ConfigModel'] = None channel_model: 'ChannelModel' @@ -167,7 +176,7 @@ class MainWindow(qw.QMainWindow): def load_cfg(self, cfg: Config, cfg_path: Optional[Path]): self._cfg_path = cfg_path - self.any_unsaved = False + self._any_unsaved = False self.load_title() if self.model is None: @@ -184,15 +193,14 @@ class MainWindow(qw.QMainWindow): def on_gui_edited(self): self.any_unsaved = True - self.update_unsaved_title() title_cache: str def load_title(self): self.title_cache = self.title - self.update_unsaved_title() + self._update_unsaved_title() - def update_unsaved_title(self): + def _update_unsaved_title(self): if self.any_unsaved: undo_str = '*' else: @@ -245,7 +253,7 @@ class MainWindow(qw.QMainWindow): yaml.dump(self.cfg, self._cfg_path) self.any_unsaved = False - self.update_unsaved_title() + self._update_unsaved_title() return True def on_action_save_as(self) -> bool: @@ -305,11 +313,11 @@ class MainWindow(qw.QMainWindow): arg = self._get_args(outputs) if dlg: arg = attr.evolve(arg, - on_begin=dlg.on_begin, - progress=dlg.setValue, - is_aborted=dlg.wasCanceled, - on_end=dlg.reset, # TODO dlg.close - ) + on_begin=dlg.on_begin, + progress=dlg.setValue, + is_aborted=dlg.wasCanceled, + on_end=dlg.reset, # TODO dlg.close + ) cfg = copy_config(self.model.cfg) t = self.ovgen_thread.obj = OvgenThread(cfg, arg) From 5a70ec3070388a3ff0de32c7f4733ad0e1d5db93 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 18 Dec 2018 12:21:57 -0800 Subject: [PATCH 100/102] Delete gui.pro (unneeded for pyqt and Qt Designer) If you can't open .ui in Qt Creator, use Qt Designer instead. --- ovgenpy/gui/gui.pro | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 ovgenpy/gui/gui.pro diff --git a/ovgenpy/gui/gui.pro b/ovgenpy/gui/gui.pro deleted file mode 100644 index dd98bc4..0000000 --- a/ovgenpy/gui/gui.pro +++ /dev/null @@ -1,31 +0,0 @@ -#------------------------------------------------- -# -# Project created by QtCreator 2018-09-03T02:05:33 -# -#------------------------------------------------- - -QT += core gui - -greaterThan(QT_MAJOR_VERSION, 4): QT += widgets - -TARGET = gui -TEMPLATE = app - -# The following define makes your compiler emit warnings if you use -# any feature of Qt which has been marked as deprecated (the exact warnings -# depend on your compiler). Please consult the documentation of the -# deprecated API in order to know how to port your code away from it. -DEFINES += QT_DEPRECATED_WARNINGS - -# You can also make your code fail to compile if you use deprecated APIs. -# In order to do so, uncomment the following line. -# You can also select to disable deprecated APIs only up to a certain version of Qt. -#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 - - -SOURCES += - -HEADERS += - -FORMS += \ - mainwindow.ui From faafeafabec78fa70e221437009ac0f12d7c0369 Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 18 Dec 2018 19:50:52 -0800 Subject: [PATCH 101/102] Update cli.py --- ovgenpy/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ovgenpy/cli.py b/ovgenpy/cli.py index 5c85925..96be7fc 100644 --- a/ovgenpy/cli.py +++ b/ovgenpy/cli.py @@ -186,6 +186,7 @@ def main( if outputs: assert Ovgen # to prevent PyCharm from deleting the import arg = Arguments(cfg_dir=cfg_dir, outputs=outputs) + # TODO make it a lambda command = 'Ovgen(cfg, arg).play()' if profile: import cProfile From bddfd5dc6a4a1538514fece50bd1d681341a196d Mon Sep 17 00:00:00 2001 From: nyanpasu64 Date: Tue, 18 Dec 2018 19:55:27 -0800 Subject: [PATCH 102/102] Update comments via Github web UI --- ovgenpy/config.py | 3 +++ ovgenpy/gui/__init__.py | 4 ---- ovgenpy/gui/data_bind.py | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ovgenpy/config.py b/ovgenpy/config.py index 28229a9..c9f76c3 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -44,6 +44,9 @@ print(timeit.timeit(lambda: f(cfg), number=number)) - yaml_copy 0.4875s pickle_copy is fastest. + +According to https://stackoverflow.com/questions/1410615/ , +pickle is faster, but less general (works fine for @register_config objects). """ T = TypeVar('T') diff --git a/ovgenpy/gui/__init__.py b/ovgenpy/gui/__init__.py index 8fbe9bc..7bada0a 100644 --- a/ovgenpy/gui/__init__.py +++ b/ovgenpy/gui/__init__.py @@ -157,8 +157,6 @@ class MainWindow(qw.QMainWindow): if not self.any_unsaved: return True - # should_close = qw.QMessageBox.question(self, "Close Confirmation", "Exit?", - # QMessageBox::Yes | QMessageBox::No) Msg = qw.QMessageBox save_message = f"Save changes to {self.title_cache}?" @@ -506,8 +504,6 @@ class ConfigModel(PresentationModel): render__line_width = default_property('render__line_width', 1.5) - # TODO mutate _cfg and convert all colors to #rrggbb on access - class ChannelTableView(qw.QTableView): def append_channels(self, wavs: List[str]): diff --git a/ovgenpy/gui/data_bind.py b/ovgenpy/gui/data_bind.py index 0bdc5ee..9f44ae5 100644 --- a/ovgenpy/gui/data_bind.py +++ b/ovgenpy/gui/data_bind.py @@ -64,11 +64,11 @@ class PresentationModel: def map_gui(view: 'MainWindow', model: PresentationModel): """ Binding: - - .ui - - view.cfg__layout__nrows + - .ui + - view.layout__nrows - pmodel['layout__nrows'] - Only s starting with 'cfg__' will be bound. + Only s subclassing BoundWidget will be bound. """ widgets: List[BoundWidget] = view.findChildren(BoundWidget) # dear pyqt, add generic mypy return types