kopia lustrzana https://github.com/corrscope/corrscope
Squash branch "qt-gui"
rodzic
f02883745d
commit
baad00a938
|
@ -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')
|
||||
|
|
|
@ -161,3 +161,4 @@ class OvgenWarning(UserWarning):
|
|||
(Should be) caught by GUI and displayed to user. """
|
||||
pass
|
||||
|
||||
ValidationError = OvgenError
|
||||
|
|
|
@ -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*
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from ovgenpy.gui import gui_main
|
||||
from ovgenpy.ovgenpy import default_config
|
||||
|
||||
gui_main(default_config(), '.')
|
|
@ -0,0 +1,129 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ChannelView</class>
|
||||
<widget class="QWidget" name="ChannelView">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="wavPathLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="wav_path_stem">
|
||||
<property name="text">
|
||||
<string>wav_path_stem</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="wavPathSub">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="cfg__wav_path"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="wav_path_browse">
|
||||
<property name="text">
|
||||
<string>Browse</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="wavPathSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="widthLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="trigger_widthL">
|
||||
<property name="text">
|
||||
<string>Trigger Width ×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="cfg__trigger_width">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="trigger_widthOut">
|
||||
<property name="text">
|
||||
<string>= twms</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="render_widthL">
|
||||
<property name="text">
|
||||
<string>Render Width ×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="cfg__render_width">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="render_widthOut">
|
||||
<property name="text">
|
||||
<string>= rwms</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<spacer name="widthSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
|
@ -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 <widget name="cfg__layout__nrows">
|
||||
- view.cfg__layout__nrows
|
||||
- model['layout__nrows']
|
||||
|
||||
Only <widget>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)
|
|
@ -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
|
|
@ -0,0 +1,421 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralWidget">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,1">
|
||||
<item>
|
||||
<widget class="QScrollArea" name="optionsScroll">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="verticalScrollBarPolicy">
|
||||
<enum>Qt::ScrollBarAlwaysOn</enum>
|
||||
</property>
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="optionsColumn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QLineEdit {
|
||||
width: 0px;
|
||||
}</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionGlobal">
|
||||
<property name="title">
|
||||
<string>Global</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="fpsL">
|
||||
<property name="text">
|
||||
<string>FPS</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="cfg__fps">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>999</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="amplificationL">
|
||||
<property name="text">
|
||||
<string>Amplification</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="cfg__amplification">
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionChannel">
|
||||
<property name="title">
|
||||
<string>Channel Width</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="trigger_msL">
|
||||
<property name="text">
|
||||
<string>Trigger Width (ms)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="cfg__trigger_ms">
|
||||
<property name="minimum">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="render_msL">
|
||||
<property name="text">
|
||||
<string>Render Width (ms)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="cfg__render_ms">
|
||||
<property name="minimum">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="trigger_subsamplingL">
|
||||
<property name="text">
|
||||
<string>Trigger Subsampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="cfg__trigger_subsampling">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="render_subsamplingL">
|
||||
<property name="text">
|
||||
<string>Render Subsampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="cfg__render_subsampling">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionAppear">
|
||||
<property name="title">
|
||||
<string>Appearance</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="render__bg_colorL">
|
||||
<property name="text">
|
||||
<string>Background</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="cfg__render__bg_color">
|
||||
<property name="text">
|
||||
<string>bg</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="render__bg_colorButton">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="render__init_line_colorL">
|
||||
<property name="text">
|
||||
<string>Foreground</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="cfg__render__init_line_color">
|
||||
<property name="text">
|
||||
<string>fg</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="render__init_line_colorButton">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render__line_widthL">
|
||||
<property name="text">
|
||||
<string>Line Width</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="cfg__render__line_width">
|
||||
<property name="minimum">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionLayout">
|
||||
<property name="title">
|
||||
<string>Layout</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="layout__orientationL">
|
||||
<property name="text">
|
||||
<string>Orientation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="cfg__layout__orientation"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="layout__ncolsL">
|
||||
<property name="text">
|
||||
<string>Columns</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<layout class="QHBoxLayout" name="layoutDims">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="cfg__layout__ncols">
|
||||
<property name="specialValueText">
|
||||
<string notr="true"> </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="layout__nrowsL">
|
||||
<property name="text">
|
||||
<string>Rows</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="cfg__layout__nrows">
|
||||
<property name="specialValueText">
|
||||
<string notr="true"> </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render_video_sizeL">
|
||||
<property name="text">
|
||||
<string>Video Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLineEdit" name="cfg__render_video_size">
|
||||
<property name="text">
|
||||
<string>vs</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="audioColumn">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="audioGroup">
|
||||
<property name="title">
|
||||
<string>Master Audio</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="cfg__master_audio">
|
||||
<property name="text">
|
||||
<string>/</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="master_audio_browse">
|
||||
<property name="text">
|
||||
<string>&Browse...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="channelsGroup">
|
||||
<property name="title">
|
||||
<string>Oscilloscope Channels</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="channelBar">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="channelDelete">
|
||||
<property name="text">
|
||||
<string>&Add...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="channelAdd">
|
||||
<property name="text">
|
||||
<string>&Delete</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableView" name="channel_widget"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menuBar">
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&File</string>
|
||||
</property>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionOpen"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionSaveAs"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionPlay"/>
|
||||
<addaction name="actionRender"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionExit"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
</widget>
|
||||
<action name="actionOpen">
|
||||
<property name="text">
|
||||
<string>&Open Project</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave">
|
||||
<property name="text">
|
||||
<string>&Save</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNew">
|
||||
<property name="text">
|
||||
<string>&New Project</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSaveAs">
|
||||
<property name="text">
|
||||
<string>Save &As</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Quit">
|
||||
<property name="text">
|
||||
<string>&Quit</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExit">
|
||||
<property name="text">
|
||||
<string>E&xit</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionPlay">
|
||||
<property name="text">
|
||||
<string>&Play</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRender">
|
||||
<property name="text">
|
||||
<string>&Render to File</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<layoutdefault spacing="6" margin="11"/>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
|
@ -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)
|
||||
|
|
1
setup.py
1
setup.py
|
@ -14,5 +14,6 @@ setup(
|
|||
'numpy', 'scipy', 'click', 'ruamel.yaml',
|
||||
'matplotlib',
|
||||
'attrs>=18.2.0',
|
||||
'PyQt5',
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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')
|
Ładowanie…
Reference in New Issue