diff --git a/corrscope.spec b/corrscope.spec index bbcffd6..68e386a 100644 --- a/corrscope.spec +++ b/corrscope.spec @@ -3,6 +3,7 @@ import os import shutil import subprocess from pathlib import Path +from typing import List, Tuple from PyInstaller.building.api import PYZ, EXE, COLLECT from PyInstaller.building.build_main import Analysis @@ -19,10 +20,12 @@ def keep(dir, wildcard): return [(include, dir) for include in includes] -datas = keep("corrscope/gui", "*.ui") +InFile = str +OutFolder = str +datas: List[Tuple[InFile, OutFolder]] = [] version = v.pyinstaller_write_version() -datas.append((v.version_txt, ".")) +datas += [(str(v.version_txt), ".")] app_name = "corrscope" app_name_version = f"{app_name}-{version}" diff --git a/corrscope/gui/__init__.py b/corrscope/gui/__init__.py index a12d5b8..52e147b 100644 --- a/corrscope/gui/__init__.py +++ b/corrscope/gui/__init__.py @@ -9,7 +9,6 @@ from typing import Optional, List, Any, Tuple, Callable, Union, Dict, Sequence import PyQt5.QtCore as qc import PyQt5.QtWidgets as qw import attr -from PyQt5 import uic from PyQt5.QtCore import QModelIndex, Qt from PyQt5.QtCore import QVariant from PyQt5.QtGui import QKeySequence, QFont, QCloseEvent @@ -21,7 +20,7 @@ from corrscope import cli from corrscope.channel import ChannelConfig from corrscope.config import CorrError, copy_config, yaml from corrscope.corrscope import CorrScope, Config, Arguments, default_config -from corrscope.gui.data_bind import ( +from corrscope.gui.model_bind import ( PresentationModel, map_gui, behead, @@ -34,6 +33,7 @@ from corrscope.gui.history_file_dlg import ( get_open_file_list, get_save_file_path, ) +from corrscope.gui.view_mainwindow import MainWindow as Ui_MainWindow from corrscope.gui.util import color2hex, Locked, find_ranges, TracebackDialog from corrscope.layout import Orientation, StereoOrientation from corrscope.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig @@ -74,7 +74,7 @@ def gui_main(cfg_or_path: Union[Config, Path]): sys.exit(app.exec_()) -class MainWindow(qw.QMainWindow): +class MainWindow(qw.QMainWindow, Ui_MainWindow): """ Main window. @@ -94,7 +94,7 @@ class MainWindow(qw.QMainWindow): self.pref = gp.load_prefs() # Load UI. - uic.loadUi(res("mainwindow.ui"), self) # sets windowTitle + self.setupUi(self) # sets windowTitle # Bind UI buttons, etc. Functions block main thread, avoiding race conditions. self.master_audio_browse.clicked.connect(self.on_master_audio_browse) @@ -151,7 +151,7 @@ class MainWindow(qw.QMainWindow): self._update_unsaved_title() # GUI layout widgets - tabWidget: qw.QTabWidget + left_tabs: qw.QTabWidget # Config models model: Optional["ConfigModel"] = None @@ -232,7 +232,7 @@ class MainWindow(qw.QMainWindow): self._cfg_path = cfg_path self._any_unsaved = False self.load_title() - self.tabWidget.setCurrentIndex(0) + self.left_tabs.setCurrentIndex(0) if self.model is None: self.model = ConfigModel(cfg) diff --git a/corrscope/gui/mainwindow.ui b/corrscope/gui/mainwindow.ui deleted file mode 100644 index 0c9ce48..0000000 --- a/corrscope/gui/mainwindow.ui +++ /dev/null @@ -1,795 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1160 - 0 - - - - MainWindow - - - - - - - - 0 - 0 - - - - 0 - - - - &General - - - - - - Global - - - - - - FPS - - - - - - - 1 - - - 999 - - - 10 - - - - - - - Trigger Width - - - - - - - 5 - - - 5 - - - - - - - Render Width - - - - - - - 5 - - - 5 - - - - - - - Amplification - - - - - - - 0.100000000000000 - - - - - - - Begin Time - - - - - - - 9999.000000000000000 - - - - - - - - - - Appearance - - - - - - Resolution - - - - - - - vs - - - - - - - Background - - - - - - - - - - Line Color - - - - - - - - - - Line Width - - - - - - - 0.500000000000000 - - - 0.500000000000000 - - - - - - - Grid Color - - - - - - - - - - Midline Color - - - - - - - - - - Vertical - - - - - - - Horizontal Midline - - - - - - - - - - Layout - - - - - - Orientation - - - - - - - - - - Columns - - - - - - - - -   - - - - - - - Rows - - - - - - -   - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - &Stereo - - - - - - Stereo Enable - - - - - - Trigger Stereo - - - - - - - - - - Render Stereo - - - - - - - - - - - - - Stereo Appearance - - - - - - Stereo Orientation - - - - - - - - - - Grid Opacity - - - - - - - 1.000000000000000 - - - 0.250000000000000 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - &Performance - - - - - - Preview and Render - - - - - - Trigger Subsampling - - - - - - - 1 - - - - - - - Render Subsampling - - - - - - - 1 - - - - - - - - - - Preview Only - - - - - - Render FPS Divisor - - - - - - - 1 - - - - - - - Resolution Divisor - - - - - - - 1.000000000000000 - - - 0.500000000000000 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - - - - Master Audio - - - - - - / - - - - - - - &Browse... - - - - - - - - - - - 0 - 0 - - - - Trigger - - - - - - Edge Strength - - - - - - - Responsiveness - - - - - - - Buffer Falloff - - - - - - - 0.000000000000000 - - - - - - - 1.000000000000000 - - - 0.100000000000000 - - - - - - - 0.500000000000000 - - - - - - - Pitch Tracking - - - - - - - Edge Direction - - - - - - - - - - - - - - - Oscilloscope Channels - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - &Add... - - - - - - - &Delete - - - - - - - Up - - - - - - - Down - - - - - - - - - - - - - - - - - - - &File - - - - - - - - - - - - - - &Tools - - - - - - - - - toolBar - - - TopToolBarArea - - - false - - - - - - - - - - - - &Open - - - Ctrl+O - - - - - &Save - - - Ctrl+S - - - - - &New - - - Ctrl+N - - - - - Save &As - - - Ctrl+Shift+S - - - - - E&xit - - - Ctrl+Q - - - - - &Preview - - - Ctrl+P - - - - - &Render to Video - - - Ctrl+R - - - - - true - - - &Separate Render Folder - - - - - - - BoundLineEdit - QLineEdit -
corrscope/gui/data_bind.h
-
- - BoundSpinBox - QSpinBox -
corrscope/gui/data_bind.h
-
- - BoundDoubleSpinBox - QDoubleSpinBox -
corrscope/gui/data_bind.h
-
- - BoundComboBox - QComboBox -
corrscope/gui/data_bind.h
-
- - BoundCheckBox - QCheckBox -
corrscope/gui/data_bind.h
-
- - ShortcutButton - QPushButton -
corrscope/gui/__init__.h
-
- - ChannelTableView - QTableView -
corrscope/gui/__init__.h
-
- - BoundColorWidget - QWidget -
corrscope/gui/data_bind.h
- 1 -
- - OptionalColorWidget - QWidget -
corrscope/gui/data_bind.h
- 1 -
-
- - -
diff --git a/corrscope/gui/data_bind.py b/corrscope/gui/model_bind.py similarity index 99% rename from corrscope/gui/data_bind.py rename to corrscope/gui/model_bind.py index b2f9e68..5a609bb 100644 --- a/corrscope/gui/data_bind.py +++ b/corrscope/gui/model_bind.py @@ -71,6 +71,9 @@ class PresentationModel(qc.QObject): updater() +SKIP_BINDING = "skip" + + def map_gui(view: "MainWindow", model: PresentationModel) -> None: """ Binding: @@ -90,7 +93,8 @@ def map_gui(view: "MainWindow", model: PresentationModel) -> None: # Exclude nameless ColorText inside BoundColorWidget wrapper, # since bind_widget(path="") will crash. # BoundColorWidget.bind_widget() handles binding children. - if path: + if path != SKIP_BINDING: + assert path != "" widget.bind_widget(model, path) @@ -366,6 +370,7 @@ class _ColorText(BoundLineEdit): def __init__(self, parent: QWidget, optional: bool): super().__init__(parent) + self.setObjectName(SKIP_BINDING) self.optional = optional hex_color = qc.pyqtSignal(str) diff --git a/corrscope/gui/translate.pro b/corrscope/gui/translate.pro new file mode 100644 index 0000000..4bae3de --- /dev/null +++ b/corrscope/gui/translate.pro @@ -0,0 +1,2 @@ +SOURCES += view_mainwindow.py +TRANSLATIONS += corrscope_xa.ts diff --git a/corrscope/gui/view_mainwindow.py b/corrscope/gui/view_mainwindow.py new file mode 100644 index 0000000..1dc28fa --- /dev/null +++ b/corrscope/gui/view_mainwindow.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- + +from PyQt5.QtCore import * +from PyQt5.QtWidgets import * + +from corrscope.gui.view_stack import ( + LayoutStack, + set_layout, + central_widget, + append_widget, + add_row, + add_tab, + set_attr_objectName, + append_stretch, + add_grid_col, + Both, + set_menu_bar, + append_menu, + add_toolbar, +) + +NBSP = "\xa0" + + +def fixed_size_policy(): + return QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + +# noinspection PyAttributeOutsideInit +class MainWindow(QWidget): + @staticmethod + def tr(*args, **kwargs) -> str: + """Only at runtime, not at pylupdate5 time.""" + # noinspection PyCallByClass,PyTypeChecker + return QCoreApplication.translate("MainWindow", *args, **kwargs) + + def setupUi(self, MainWindow: QMainWindow): + MainWindow.resize(1160, 0) + + s = LayoutStack(MainWindow) + + # Window contents + with central_widget(s, QWidget) as self.centralWidget: + horizontalLayout = set_layout(s, QHBoxLayout) + + # Left-hand config tabs + with append_widget(s, QTabWidget) as self.left_tabs: + self.tabGeneral = self.add_general_tab(s) + self.tabStereo = self.add_stereo_tab(s) + self.tabPerf = self.add_performance_tab(s) + + # Right-hand channel list + with append_widget(s, QVBoxLayout) as self.audioColumn: + + # Top bar (master audio, trigger) + self.add_top_bar(s) + + # Channel list (group box) + self.channelsGroup = self.add_channels_list(s) + + # Right-hand channel list expands to fill space. + horizontalLayout.setStretch(1, 1) + + self.add_actions(s, MainWindow) + + # Creates references to labels + set_attr_objectName(self, s) + + # Initializes labels by reference + self.retranslateUi(MainWindow) + self.left_tabs.setCurrentIndex(0) + + # Depends on objectName + QMetaObject.connectSlotsByName(MainWindow) + + def add_general_tab(self, s: LayoutStack) -> QWidget: + tr = self.tr + with self._add_tab(s, tr("&General")) as tab: + set_layout(s, QVBoxLayout) + + # Global group + with append_widget(s, QGroupBox) as self.optionGlobal: + set_layout(s, QFormLayout) + + with add_row(s, BoundSpinBox) as self.fps: + self.fps.setMinimum(1) + self.fps.setMaximum(999) + self.fps.setSingleStep(10) + + with add_row(s, BoundSpinBox) as self.trigger_ms: + self.trigger_ms.setMinimum(5) + self.trigger_ms.setSingleStep(5) + + with add_row(s, BoundSpinBox) as self.render_ms: + self.render_ms.setMinimum(5) + self.render_ms.setSingleStep(5) + + with add_row(s, BoundDoubleSpinBox) as self.amplification: + self.amplification.setSingleStep(0.1) + + with add_row(s, BoundDoubleSpinBox) as self.begin_time: + self.begin_time.setMaximum(9999.0) + + with append_widget(s, QGroupBox) as self.optionAppearance: + set_layout(s, QFormLayout) + + with add_row(s, BoundLineEdit) as self.render_resolution: + pass + + with add_row(s, BoundColorWidget) as self.render__bg_color: + pass + + with add_row(s, BoundColorWidget) as self.render__init_line_color: + pass + + with add_row(s, BoundDoubleSpinBox) as self.render__line_width: + self.render__line_width.setMinimum(0.5) + self.render__line_width.setSingleStep(0.5) + + with add_row(s, OptionalColorWidget) as self.render__grid_color: + pass + + with add_row(s, OptionalColorWidget) as self.render__midline_color: + pass + + with add_row(s, BoundCheckBox, BoundCheckBox) as ( + self.render__v_midline, + self.render__h_midline, + ): + pass + + with append_widget(s, QGroupBox) as self.optionLayout: + set_layout(s, QFormLayout) + + with add_row(s, BoundComboBox) as self.layout__orientation: + pass + + with add_row(s, QLabel, QHBoxLayout) as ( + self.layout__ncolsL, + self.layoutDims, + ): + with append_widget(s, BoundSpinBox) as self.layout__ncols: + self.layout__ncols.setSpecialValueText(NBSP) + + with append_widget(s, QLabel) as self.layout__nrowsL: + pass + + with append_widget(s, BoundSpinBox) as self.layout__nrows: + self.layout__nrows.setSpecialValueText(NBSP) + + append_stretch(s) + + return tab + + def add_stereo_tab(self, s: LayoutStack) -> QWidget: + tr = self.tr + with self._add_tab(s, tr("&Stereo")) as tab: + set_layout(s, QVBoxLayout) + + with append_widget(s, QGroupBox) as self.optionStereo: + set_layout(s, QFormLayout) + with add_row(s, BoundComboBox) as self.trigger_stereo: + pass + + with add_row(s, BoundComboBox) as self.render_stereo: + pass + + with append_widget(s, QGroupBox) as self.dockStereo_2: + set_layout(s, QFormLayout) + + with add_row(s, BoundComboBox) as self.layout__stereo_orientation: + pass + + with add_row(s, BoundDoubleSpinBox) as ( + self.render__stereo_grid_opacity + ): + self.render__stereo_grid_opacity.setMaximum(1.0) + self.render__stereo_grid_opacity.setSingleStep(0.25) + + append_stretch(s) + + return tab + + def add_performance_tab(self, s: LayoutStack) -> QWidget: + tr = self.tr + with self._add_tab(s, tr("&Performance")) as tab: + set_layout(s, QVBoxLayout) + + with append_widget(s, QGroupBox) as self.perfAll: + set_layout(s, QFormLayout) + + with add_row(s, BoundSpinBox) as self.trigger_subsampling: + self.trigger_subsampling.setMinimum(1) + + with add_row(s, BoundSpinBox) as self.render_subsampling: + self.render_subsampling.setMinimum(1) + + with append_widget(s, QGroupBox) as self.perfPreview: + set_layout(s, QFormLayout) + + with add_row(s, BoundSpinBox) as self.render_subfps: + self.render_subfps.setMinimum(1) + + with add_row(s, BoundDoubleSpinBox) as self.render__res_divisor: + self.render__res_divisor.setMinimum(1.0) + self.render__res_divisor.setSingleStep(0.5) + + append_stretch(s) + + return tab + + @staticmethod + def _add_tab(s: LayoutStack, label: str = ""): + return add_tab(s, QWidget, label) + + def add_top_bar(self, s): + with append_widget(s, QHBoxLayout): + # Master audio + with append_widget(s, QGroupBox) as self.masterAudioGroup: + set_layout(s, QHBoxLayout) + with append_widget(s, BoundLineEdit) as self.master_audio: + pass + with append_widget(s, QPushButton) as self.master_audio_browse: + pass + + # Trigger config + with append_widget(s, QGroupBox) as self.optionAudio: + # Prevent expansion (does nothing even if removed :| ) + self.optionAudio.setSizePolicy(fixed_size_policy()) + + set_layout(s, QGridLayout) + + with add_grid_col(s, BoundComboBox) as (self.trigger__edge_direction): + pass + + with add_grid_col(s, BoundDoubleSpinBox) as ( + self.trigger__edge_strength + ): + self.trigger__edge_strength.setMinimum(0.0) + + with add_grid_col(s, BoundDoubleSpinBox) as ( + self.trigger__responsiveness + ): + self.trigger__responsiveness.setMaximum(1.0) + self.trigger__responsiveness.setSingleStep(0.1) + + with add_grid_col(s, BoundDoubleSpinBox) as ( + self.trigger__buffer_falloff + ): + self.trigger__buffer_falloff.setSingleStep(0.5) + + with add_grid_col(s, BoundCheckBox, Both) as ( + self.trigger__pitch_tracking + ): + assert isinstance(self.trigger__pitch_tracking, QWidget) + + def add_channels_list(self, s): + with append_widget(s, QGroupBox) as group: + set_layout(s, QVBoxLayout) + + # Button toolbar + with append_widget(s, QHBoxLayout) as self.channelBar: + append_stretch(s) + + with append_widget(s, ShortcutButton) as self.channelAdd: + pass + with append_widget(s, ShortcutButton) as self.channelDelete: + pass + with append_widget(s, ShortcutButton) as self.channelUp: + pass + with append_widget(s, ShortcutButton) as self.channelDown: + pass + + # Spreadsheet grid + with append_widget(s, ChannelTableView) as self.channel_view: + pass + + return group + + def add_actions(self, s: LayoutStack, MainWindow): + # Setup actions + self.actionOpen = QAction(MainWindow) + self.actionSave = QAction(MainWindow) + self.actionNew = QAction(MainWindow) + self.actionSaveAs = QAction(MainWindow) + self.actionExit = QAction(MainWindow) + self.actionPreview = QAction(MainWindow) + self.actionRender = QAction(MainWindow) + self.action_separate_render_dir = QAction(MainWindow) + self.action_separate_render_dir.setCheckable(True) + + # Setup menu_bar + assert s.widget is MainWindow + with set_menu_bar(s) as self.menuBar: + with append_menu(s) as self.menuFile: + self.menuFile.addAction(self.actionNew) + self.menuFile.addAction(self.actionOpen) + self.menuFile.addAction(self.actionSave) + self.menuFile.addAction(self.actionSaveAs) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionPreview) + self.menuFile.addAction(self.actionRender) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionExit) + + with append_menu(s) as self.menuTools: + self.menuTools.addAction(self.action_separate_render_dir) + + # Setup toolbar + with add_toolbar(s, Qt.TopToolBarArea) as self.toolBar: + self.toolBar.addAction(self.actionNew) + self.toolBar.addAction(self.actionOpen) + self.toolBar.addAction(self.actionSave) + self.toolBar.addAction(self.actionSaveAs) + self.toolBar.addSeparator() + self.toolBar.addAction(self.actionPreview) + self.toolBar.addAction(self.actionRender) + + # noinspection PyUnresolvedReferences + def retranslateUi(self, MainWindow): + tr = self.tr + + MainWindow.setWindowTitle(tr("MainWindow")) + + self.optionGlobal.setTitle(tr("Global")) + self.fpsL.setText(tr("FPS")) + self.trigger_msL.setText(tr("Trigger Width")) + self.render_msL.setText(tr("Render Width")) + self.amplificationL.setText(tr("Amplification")) + self.begin_timeL.setText(tr("Begin Time")) + self.optionAppearance.setTitle(tr("Appearance")) + self.render_resolutionL.setText(tr("Resolution")) + self.render_resolution.setText(tr("vs")) + self.render__bg_colorL.setText(tr("Background")) + self.render__init_line_colorL.setText(tr("Line Color")) + self.render__line_widthL.setText(tr("Line Width")) + self.render__grid_colorL.setText(tr("Grid Color")) + self.render__midline_colorL.setText(tr("Midline Color")) + self.render__v_midline.setText(tr("Vertical")) + self.render__h_midline.setText(tr("Horizontal Midline")) + self.optionLayout.setTitle(tr("Layout")) + self.layout__orientationL.setText(tr("Orientation")) + self.layout__ncolsL.setText(tr("Columns")) + self.layout__nrowsL.setText(tr("Rows")) + self.optionStereo.setTitle(tr("Stereo Enable")) + self.trigger_stereoL.setText(tr("Trigger Stereo")) + self.render_stereoL.setText(tr("Render Stereo")) + self.dockStereo_2.setTitle(tr("Stereo Appearance")) + self.layout__stereo_orientationL.setText(tr("Stereo Orientation")) + self.render__stereo_grid_opacityL.setText(tr("Grid Opacity")) + + self.perfAll.setTitle(tr("Preview and Render")) + self.trigger_subsamplingL.setText(tr("Trigger Subsampling")) + self.render_subsamplingL.setText(tr("Render Subsampling")) + self.perfPreview.setTitle(tr("Preview Only")) + self.render_subfpsL.setText(tr("Render FPS Divisor")) + self.render__res_divisorL.setText(tr("Resolution Divisor")) + self.masterAudioGroup.setTitle(tr("Master Audio")) + self.master_audio.setText(tr("/")) + self.master_audio_browse.setText(tr("&Browse...")) + self.optionAudio.setTitle(tr("Trigger")) + self.trigger__edge_strengthL.setText(tr("Edge Strength")) + self.trigger__responsivenessL.setText(tr("Responsiveness")) + self.trigger__buffer_falloffL.setText(tr("Buffer Falloff")) + self.trigger__pitch_tracking.setText(tr("Pitch Tracking")) + self.trigger__edge_directionL.setText(tr("Edge Direction")) + + self.channelsGroup.setTitle(tr("Oscilloscope Channels")) + self.channelAdd.setText(tr("&Add...")) + self.channelDelete.setText(tr("&Delete")) + self.channelUp.setText(tr("Up")) + self.channelDown.setText(tr("Down")) + self.menuFile.setTitle(tr("&File")) + self.menuTools.setTitle(tr("&Tools")) + self.toolBar.setWindowTitle(tr("toolBar")) + self.actionOpen.setText(tr("&Open")) + self.actionOpen.setShortcut(tr("Ctrl+O")) + self.actionSave.setText(tr("&Save")) + self.actionSave.setShortcut(tr("Ctrl+S")) + self.actionNew.setText(tr("&New")) + self.actionNew.setShortcut(tr("Ctrl+N")) + self.actionSaveAs.setText(tr("Save &As")) + self.actionSaveAs.setShortcut(tr("Ctrl+Shift+S")) + self.actionExit.setText(tr("E&xit")) + self.actionExit.setShortcut(tr("Ctrl+Q")) + self.actionPreview.setText(tr("&Preview")) + self.actionPreview.setShortcut(tr("Ctrl+P")) + self.actionRender.setText(tr("&Render to Video")) + self.actionRender.setShortcut(tr("Ctrl+R")) + self.action_separate_render_dir.setText(tr("&Separate Render Folder")) + + +from corrscope.gui.__init__ import ChannelTableView, ShortcutButton +from corrscope.gui.model_bind import ( + BoundCheckBox, + BoundColorWidget, + BoundComboBox, + BoundDoubleSpinBox, + BoundLineEdit, + BoundSpinBox, + OptionalColorWidget, +) diff --git a/corrscope/gui/view_stack.py b/corrscope/gui/view_stack.py new file mode 100644 index 0000000..d47ccbb --- /dev/null +++ b/corrscope/gui/view_stack.py @@ -0,0 +1,280 @@ +from contextlib import contextmanager +from typing import * + +import attr +from PyQt5.QtCore import QObject, Qt +from PyQt5.QtWidgets import * + +from corrscope.util import obj_name + +T = TypeVar("T") +ctx = Iterator +SomeQW = TypeVar("SomeQW", bound=QWidget) +WidgetOrLayout = TypeVar("WidgetOrLayout", bound=Union[QWidget, QLayout]) + + +def new_widget_or_layout( + item_type: Type[WidgetOrLayout], parent: QWidget +) -> WidgetOrLayout: + """Creates a widget or layout, for insertion into an existing layout. + Do NOT use for filling a widget with a layout!""" + if issubclass(item_type, QWidget): + right = item_type(parent) + else: + right = item_type(None) + return right + + +@attr.dataclass +class StackFrame: + widget: Optional[QWidget] + layout: Optional[QLayout] = None + + def with_layout(self, layout: Optional[QLayout]): + return attr.evolve(self, layout=layout) + + +class LayoutStack: + def __init__(self, root: Optional[QWidget]): + self._items = [StackFrame(root)] + self.widget_to_label: Dict[QWidget, QLabel] = {} + + @contextmanager + def push(self, item: T) -> ctx[T]: + if isinstance(item, StackFrame): + frame = item + elif isinstance(item, QWidget): + frame = StackFrame(item) + elif isinstance(item, QLayout): + frame = self.peek().with_layout(item) + else: + raise TypeError(obj_name(item)) + + self._items.append(frame) + + try: + yield item + finally: + self._items.pop() + + def peek(self) -> StackFrame: + return self._items[-1] + + @property + def widget(self): + return self.peek().widget + + @property + def layout(self): + return self.peek().layout + + +def set_layout(stack: LayoutStack, layout_type: Type[QLayout]) -> QLayout: + layout = layout_type(stack.peek().widget) + stack.peek().layout = layout + return layout + + +def assert_peek(stack: LayoutStack, cls): + assert isinstance(stack.widget, cls) + + +def central_widget(stack: LayoutStack, widget_type: Type[SomeQW] = QWidget): + assert_peek(stack, QMainWindow) + # do NOT orphan=True + return _add_widget(stack, widget_type, exit_action="setCentralWidget") + + +def orphan_widget(stack: LayoutStack, widget_type: Type[SomeQW] = QWidget): + return _add_widget(stack, widget_type, orphan=True) + + +@contextmanager +def append_widget( + stack: LayoutStack, item_type: Type[WidgetOrLayout] +) -> ctx[WidgetOrLayout]: + with _add_widget(stack, item_type) as item: + yield item + add_widget_or_layout(stack.layout, item) + + +# noinspection PyArgumentList +def add_widget_or_layout(layout: QLayout, item: WidgetOrLayout, *args, **kwargs): + if isinstance(item, QWidget): + layout.addWidget(item, *args, **kwargs) + elif isinstance(item, QLayout): + # QLayout and some subclasses (like QFormLayout) omit this method, + # and will crash at runtime. + layout.addLayout(item, *args, **kwargs) + else: + raise TypeError(item) + + +# Main window toolbars/menus + + +def set_menu_bar(stack: LayoutStack): + assert_peek(stack, QMainWindow) + return _add_widget(stack, QMenuBar, exit_action="setMenuBar") + + +# won't bother adding type hints that pycharm is too dumb to understand +def append_menu(stack: LayoutStack): + assert_peek(stack, QMenuBar) + return _add_widget(stack, QMenu, exit_action="addMenu") + + +def add_toolbar(stack: LayoutStack, area=Qt.TopToolBarArea): + assert_peek(stack, QMainWindow) + + def _add_toolbar(parent: QMainWindow, toolbar): + parent.addToolBar(area, toolbar) + + return _add_widget(stack, QToolBar, exit_action=_add_toolbar) + + +# Implementation + + +@contextmanager +def _add_widget( + stack: LayoutStack, + item_type: Type[WidgetOrLayout], + orphan=False, + exit_action: Union[Callable[[Any, Any], Any], str] = "", +) -> ctx[WidgetOrLayout]: + """ + - Constructs item_type using parent. + - Yields item_type. + """ + + if not orphan: + parent = stack.widget + else: + parent = None + + with stack.push(new_widget_or_layout(item_type, parent)) as item: + yield item + + real_parent = stack.widget + if callable(exit_action): + exit_action(real_parent, item) + elif exit_action: + getattr(real_parent, exit_action)(item) + + +def append_stretch(stack: LayoutStack): + cast(QBoxLayout, stack.layout).addStretch() + + +Left = TypeVar("Left", bound=QWidget) +Right = TypeVar("Right", bound=Union[QWidget, QLayout]) # same as WidgetOrLayout + + +Both = object() + + +def widget_pair_inserter(append_widgets: Callable): + @contextmanager + def add_row_col(stack: LayoutStack, arg1, arg2=None): + left_type: Type[Left] + right_type: Type[Right] + if arg2 is None: + left_type, right_type = QLabel, arg1 + auto_left_label = True + else: + left_type, right_type = arg1, arg2 + auto_left_label = False + + parent = stack.widget + left = new_widget_or_layout(left_type, parent) # TODO support str + + if right_type is Both: + right = Both + push = left + else: + right = new_widget_or_layout(right_type, parent) + push = right + + with stack.push(push): + if right is Both: + yield left + elif auto_left_label: + yield right + else: + yield left, right + + append_widgets(stack.layout, left, right) + if auto_left_label: + assert isinstance(left, QLabel) + stack.widget_to_label[right] = left + + return add_row_col + + +def _add_row(layout: QFormLayout, left, right): + assert isinstance(layout, QFormLayout), layout + if right is Both: + raise TypeError("Cannot add_row(QFormLayout, span=Both)") + return layout.addRow(left, right) + + +add_row = widget_pair_inserter(_add_row) + + +def _add_grid_col(layout: QGridLayout, up, down): + assert isinstance(layout, QGridLayout), layout + col = layout.columnCount() + + """ + void QGridLayout::addWidget( + QWidget *widget, + int fromRow, int fromColumn, [int rowSpan, int columnSpan], + Qt::Alignment alignment = Qt::Alignment() + ) + """ + if down is Both: + shape = lambda: [0, col, -1, 1] + add_widget_or_layout(layout, up, *shape()) + else: + shape = lambda row: [row, col] + add_widget_or_layout(layout, up, *shape(0)) + add_widget_or_layout(layout, down, *shape(1)) + + +add_grid_col = widget_pair_inserter(_add_grid_col) + + +@contextmanager +def add_tab(stack, widget_type: Type[SomeQW] = QWidget, label: str = "") -> ctx[SomeQW]: + """ + - Constructs widget using parent. + - Yields widget. + """ + tabs: QTabWidget = stack.widget + assert isinstance(tabs, QTabWidget), tabs + + with orphan_widget(stack, widget_type) as w: + yield w + tabs.addTab(w, label) + + +# After building a tree... +def set_attr_objectName(ui, stack: LayoutStack): + """ + - Set objectName of all objects referenced by ui. + - For all object $name added by add_row() or add_grid_col(), + if $label was generated but not yielded + setattr(ui.$name + "L" = $label) + """ + widget_to_label = stack.widget_to_label + + for name, obj in dict(ui.__dict__).items(): + if not isinstance(obj, QObject): + continue + obj.setObjectName(name) + if obj in widget_to_label: + label = widget_to_label[obj] + label_name = name + "L" + label.setObjectName(label_name) + ui.__dict__[label_name] = label diff --git a/tests/test_data_bind.py b/tests/test_model_bind.py similarity index 96% rename from tests/test_data_bind.py rename to tests/test_model_bind.py index 7d26fc5..d1d88c5 100644 --- a/tests/test_data_bind.py +++ b/tests/test_model_bind.py @@ -1,6 +1,6 @@ import pytest -from corrscope.gui.data_bind import rgetattr, rsetattr, rhasattr, flatten_attr +from corrscope.gui.model_bind import rgetattr, rsetattr, rhasattr, flatten_attr class Person(object):