Squash branch "qt-gui"

pull/357/head
nyanpasu64 2018-09-03 04:52:38 -07:00
rodzic f02883745d
commit baad00a938
12 zmienionych plików z 1189 dodań i 2 usunięć

Wyświetl plik

@ -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')

Wyświetl plik

@ -161,3 +161,4 @@ class OvgenWarning(UserWarning):
(Should be) caught by GUI and displayed to user. """
pass
ValidationError = OvgenError

43
ovgenpy/gui/.gitignore vendored 100644
Wyświetl plik

@ -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*

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,4 @@
from ovgenpy.gui import gui_main
from ovgenpy.ovgenpy import default_config
gui_main(default_config(), '.')

Wyświetl plik

@ -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>

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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>&amp;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>&amp;Add...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="channelAdd">
<property name="text">
<string>&amp;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>&amp;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>&amp;Open Project</string>
</property>
</action>
<action name="actionSave">
<property name="text">
<string>&amp;Save</string>
</property>
</action>
<action name="actionNew">
<property name="text">
<string>&amp;New Project</string>
</property>
</action>
<action name="actionSaveAs">
<property name="text">
<string>Save &amp;As</string>
</property>
</action>
<action name="action_Quit">
<property name="text">
<string>&amp;Quit</string>
</property>
</action>
<action name="actionExit">
<property name="text">
<string>E&amp;xit</string>
</property>
</action>
<action name="actionPlay">
<property name="text">
<string>&amp;Play</string>
</property>
</action>
<action name="actionRender">
<property name="text">
<string>&amp;Render to File</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<resources/>
<connections/>
</ui>

Wyświetl plik

@ -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)

Wyświetl plik

@ -14,5 +14,6 @@ setup(
'numpy', 'scipy', 'click', 'ruamel.yaml',
'matplotlib',
'attrs>=18.2.0',
'PyQt5',
]
)

Wyświetl plik

@ -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')