From c725bb909deec889946f521aad4bba3e85b97455 Mon Sep 17 00:00:00 2001 From: Julianne Swinoga Date: Sat, 24 Feb 2024 10:54:39 -0500 Subject: [PATCH] Change main widget from code to .ui file No more sketchy editing of ui code! Now the main widget is all done in the designer app, probably fixed some UI bugs along the way (but not the painting problem with the spinner/checkbox, #9) --- OATFWGUI/gui_logic.py | 85 +----------- OATFWGUI/main.py | 23 +++- OATFWGUI/main_widget.ui | 183 +++++++++++++++++++++++++ OATFWGUI/qbusyindicatorgoodbad.py | 41 +++++- OATFWGUI/qt_extensions.py | 30 ++++ OATFWGUI/register_custom_qt_widgets.py | 19 +++ README.md | 9 ++ 7 files changed, 299 insertions(+), 91 deletions(-) create mode 100644 OATFWGUI/main_widget.ui create mode 100644 OATFWGUI/register_custom_qt_widgets.py diff --git a/OATFWGUI/gui_logic.py b/OATFWGUI/gui_logic.py index 46e682b..141a5c2 100644 --- a/OATFWGUI/gui_logic.py +++ b/OATFWGUI/gui_logic.py @@ -7,16 +7,14 @@ from typing import List, Optional from pathlib import Path -from PySide6.QtCore import Slot, QThreadPool, QFile, QProcess, Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QLabel, QComboBox, QWidget, QFileDialog, QPushButton, QPlainTextEdit, QGridLayout, \ - QHBoxLayout, QCheckBox +from PySide6.QtCore import Slot, QThreadPool, QFile, QProcess +from PySide6.QtWidgets import QWidget, QFileDialog import requests -from log_utils import LogObject, LoggedExternalFile +from log_utils import LoggedExternalFile from qt_extensions import Worker -from qbusyindicatorgoodbad import QBusyIndicatorGoodBad, BusyIndicatorState +from qbusyindicatorgoodbad import BusyIndicatorState from external_processes import external_processes, get_install_dir from gui_state import LogicState, PioEnv, FWVersion from anon_usage_data import AnonStatsDialog, create_anon_stats, upload_anon_stats @@ -89,7 +87,7 @@ def extract_fw(zipfile_name: Path) -> Path: class BusinessLogic: - def __init__(self, main_app: 'MainWidget'): + def __init__(self, main_app: QWidget): self.logic_state = LogicState() self.main_app = main_app @@ -369,76 +367,3 @@ def pio_upload_finished(self): def modal_show_stats(self): dlg = AnonStatsDialog(self.logic_state, self.main_app) dlg.exec_() - - -class MainWidget(QWidget): - def __init__(self, log_object: LogObject): - QWidget.__init__(self) - - # widgets - self.wMsg_fw_version = QLabel('Select firmware version:') - self.wCombo_fw_version = QComboBox() - self.wCombo_fw_version.setPlaceholderText('Grabbing FW Versions...') - self.wBtn_download_fw = QPushButton('Download') - self.wBtn_download_fw.setEnabled(False) - self.wSpn_download = QBusyIndicatorGoodBad(fixed_size=(50, 50)) - - self.wMsg_pio_env = QLabel('Select board:') - self.wCombo_pio_env = QComboBox() - self.wCombo_pio_env.setPlaceholderText('No FW downloaded yet...') - self.wBtn_select_local_config = QPushButton('Select local config file') - self.wBtn_build_fw = QPushButton('Build FW') - self.wBtn_build_fw.setEnabled(False) - self.wMsg_config_path = QLabel('No config file selected') - self.wSpn_build = QBusyIndicatorGoodBad(fixed_size=(50, 50)) - - self.wBtn_refresh_ports = QPushButton('Refresh ports') - self.wCombo_serial_port = QComboBox() - self.wCombo_serial_port.setPlaceholderText('No port selected') - self.wBtn_upload_fw = QPushButton('Upload FW') - self.wBtn_upload_fw.setEnabled(False) - self.wSpn_upload = QBusyIndicatorGoodBad(fixed_size=(50, 50)) - - self.wChk_upload_stats = QCheckBox('Upload anonymous statistics?', - toolTip='After a successful firmware update, upload anonymous firmware details to the OAT devs') - self.wBtn_what_stats = QPushButton('What will be uploaded?') - - self.logText = QPlainTextEdit() - self.logText.setLineWrapMode(QPlainTextEdit.NoWrap) - self.logText.setReadOnly(True) - log_font = QFont('this-font-does-not-exist') - log_font.setStyleHint(QFont.Monospace) # Let Qt pick a monospace font - self.logText.setFont(log_font) - - # layout - self.g_layout = QGridLayout() - - layout_arr = [ - [self.wMsg_fw_version, self.wCombo_fw_version, self.wBtn_download_fw, self.wSpn_download], - [self.wMsg_pio_env, self.wCombo_pio_env, self.wBtn_select_local_config, self.wBtn_build_fw], - [self.wMsg_config_path, None, None, self.wSpn_build], - [self.wBtn_refresh_ports, self.wCombo_serial_port, self.wBtn_upload_fw, self.wSpn_upload], - [None, None, self.wChk_upload_stats, None], - [None, None, self.wBtn_what_stats, None], - ] - for y, row_arr in enumerate(layout_arr): - for x, widget in enumerate(row_arr): - rowSpan = 1 - colSpan = 1 - while x + colSpan < len(row_arr) and row_arr[x + colSpan] is None: - # next widget is None, expand column - colSpan += 1 - if widget is not None: - self.g_layout.addWidget(widget, y, x, rowSpan, colSpan) - self.g_layout.setAlignment(Qt.AlignTop) - - # log window will take up the entire right side - self.h_layout = QHBoxLayout(self) - self.h_layout.addLayout(self.g_layout) - self.h_layout.addWidget(self.logText) - - # signals - log_object.log_signal.connect(self.logText.appendHtml) - - # business logic will connect signals as well - self.logic = BusinessLogic(self) diff --git a/OATFWGUI/main.py b/OATFWGUI/main.py index b36adf1..a492a21 100755 --- a/OATFWGUI/main.py +++ b/OATFWGUI/main.py @@ -14,13 +14,14 @@ from typing import Dict, Tuple, Optional import semver -from PySide6.QtCore import Slot, Qt +from PySide6.QtCore import Slot, Qt, QFile from PySide6.QtWidgets import QApplication, QMainWindow, QStatusBar, QLabel from PySide6.QtGui import QAction, QActionGroup +from PySide6.QtUiTools import QUiLoader from _version import __version__ from log_utils import LogObject, setup_logging -from gui_logic import MainWidget +from gui_logic import BusinessLogic from platform_check import get_platform, PlatformEnum from external_processes import external_processes, add_external_process, get_install_dir from anon_usage_data import create_anon_stats @@ -191,10 +192,24 @@ def __init__(self): self.bug_hyperlink.setOpenExternalLinks(True) self.status_bar.addPermanentWidget(self.bug_hyperlink) # addPermanentWidget == right side - log.debug('Creating main widget') - self.main_widget = MainWidget(l_o) + # Load the main widget from the .ui file + # Need to tell the UI loader where our custom widgets are + os.environ['PYSIDE_DESIGNER_PLUGINS'] = str(Path(get_install_dir(), 'OATFWGUI')) + + main_widget_ui_path = Path(get_install_dir(), 'OATFWGUI', 'main_widget.ui') + log.debug(f'Loading main widget UI from {main_widget_ui_path}') + ui_file = QFile(main_widget_ui_path) + ui_file.open(QFile.ReadOnly) + loader = QUiLoader() + self.main_widget = loader.load(ui_file) + ui_file.close() self.setCentralWidget(self.main_widget) + # signals + l_o.log_signal.connect(self.main_widget.logText.appendHtml) + # business logic will connect signals as well + self.logic = BusinessLogic(self.main_widget) + def add_log_menu_helper(self, name: str, cb_fn, is_checked=False): action = QAction(name) action.setCheckable(True) diff --git a/OATFWGUI/main_widget.ui b/OATFWGUI/main_widget.ui new file mode 100644 index 0000000..8ee85c7 --- /dev/null +++ b/OATFWGUI/main_widget.ui @@ -0,0 +1,183 @@ + + + TopLevelWidget + + + true + + + + 0 + 0 + 766 + 513 + + + + Form + + + + + + QLayout::SetMaximumSize + + + + + + + Select local config file + + + + + + + What will be uploaded? + + + + + + + false + + + Upload FW + + + + + + + No port selected + + + + + + + No FW downloaded yet... + + + + + + + Select firmware version: + + + + + + + false + + + Download + + + + + + + After a successful firmware update, upload anonymous firmware details to the OAT devs + + + Upload anonymous statistics? + + + + + + + Select board: + + + + + + + Grabbing FW Versions... + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + false + + + Build FW + + + + + + + Refresh ports + + + + + + + + + + + + + No config file selected + + + + + + + + + + Monospace + + + + QPlainTextEdit::NoWrap + + + true + + + + + + + + + + QBusyIndicatorGoodBad + QWidget +
qbusyindicatorgoodbad
+ 1 +
+
+ + +
diff --git a/OATFWGUI/qbusyindicatorgoodbad.py b/OATFWGUI/qbusyindicatorgoodbad.py index fb4fef5..1a3931a 100644 --- a/OATFWGUI/qbusyindicatorgoodbad.py +++ b/OATFWGUI/qbusyindicatorgoodbad.py @@ -1,26 +1,51 @@ import math import enum import logging -from typing import Optional, Tuple from PySide6.QtCore import Qt, QSize from PySide6.QtWidgets import QWidget, QStackedWidget, QHBoxLayout, QSizePolicy from PySide6.QtGui import QPainter, QColor, QPen from waitingspinnerwidget import QtWaitingSpinner +from qt_extensions import RegisteredCustomWidget log = logging.getLogger('') class BusyIndicatorState(enum.Enum): + # designer_dom_xml = ''' + # + # + # + # + # 10 + # 10 + # + # + # + # + # ''' + # + # def get_fixed_size(self) -> QSize: + # return self._fixed_size + # + # def set_fixed_size(self, fixed_size: QSize): + # self._fixed_size = fixed_size + # self.setFixedSize(fixed_size) + # + # fixed_size = Property(QSize, get_fixed_size, set_fixed_size) + # + # ... + # + # self._fixed_size = QSize(200, 200) NONE = enum.auto() BUSY = enum.auto() GOOD = enum.auto() BAD = enum.auto() -class QBusyIndicatorGoodBad(QWidget): - def __init__(self, parent=None, fixed_size: Optional[Tuple[int, int]] = None): +class QBusyIndicatorGoodBad(RegisteredCustomWidget): + def __init__(self, parent=None): super().__init__(parent) self.wSpn = QtWaitingSpinner(self, centerOnParent=False) self.wGood = QIndicatorGood(self) @@ -40,13 +65,15 @@ def __init__(self, parent=None, fixed_size: Optional[Tuple[int, int]] = None): self.setWindowModality(Qt.NonModal) self.setAttribute(Qt.WA_TranslucentBackground) - if fixed_size is None: - fixed_size = (50, 50) - self.setFixedSize(QSize(*fixed_size)) + self.setFixedSize(QSize(50, 50)) + self.size_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) self.setSizePolicy(self.size_policy) + if self.running_in_designer(): + self.setState(BusyIndicatorState.BUSY) + else: + self.setState(BusyIndicatorState.NONE) - self.setState(BusyIndicatorState.NONE) self.setAttribute(Qt.WA_DontShowOnScreen) self.show() diff --git a/OATFWGUI/qt_extensions.py b/OATFWGUI/qt_extensions.py index 879f137..6c4a775 100644 --- a/OATFWGUI/qt_extensions.py +++ b/OATFWGUI/qt_extensions.py @@ -4,6 +4,7 @@ from typing import Optional from PySide6.QtCore import Slot, Signal, QObject, QRunnable, QMetaMethod +from PySide6.QtWidgets import QWidget log = logging.getLogger('') @@ -42,6 +43,35 @@ def run(self): self.signals.finished.emit() +class RegisteredCustomWidget(QWidget): + """ + See https://doc.qt.io/qt-6/designer-ui-file-format.html for the XML format (it's kind of hard to read tho :/) + Also see https://doc.qt.io/qt-6/designer-creating-custom-widgets.html + """ + designer_tooltip = '' + designer_dom_xml = '' + + @classmethod + def factory(cls: 'RegisteredCustomWidget'): + if not cls.designer_tooltip: + cls.designer_tooltip = f'{cls.__name__} tooltip' + if cls.designer_dom_xml: + cls.designer_dom_xml = f''' + + + + +''' + + cls.designer_module = cls.__name__.lower() + + return cls + + def running_in_designer(self): + # TODO: This is specific to the .ui file, is there a cleaner way to make it generic? + return self.window().objectName() != 'TopLevelWidget' + + # https://stackoverflow.com/a/68621792/1313872 # Idk why this is so hard for Qt def get_signal(o_object: QObject, str_signal_name: str) -> Optional[QMetaMethod]: diff --git a/OATFWGUI/register_custom_qt_widgets.py b/OATFWGUI/register_custom_qt_widgets.py new file mode 100644 index 0000000..8bdfbda --- /dev/null +++ b/OATFWGUI/register_custom_qt_widgets.py @@ -0,0 +1,19 @@ +from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + +from qbusyindicatorgoodbad import QBusyIndicatorGoodBad + +""" +Set the environment variable PYSIDE_DESIGNER_PLUGINS to this directory and load the plugin, +both in the app and with pyside6-designer + +""" + +if __name__ == '__main__': + # See RegisteredCustomWidget for this factory paradigm + qbusyindicatorgoodbad = QBusyIndicatorGoodBad.factory() + QPyDesignerCustomWidgetCollection.registerCustomWidget( + qbusyindicatorgoodbad, + module=qbusyindicatorgoodbad.designer_module, # idk what this actually affects + tool_tip=qbusyindicatorgoodbad.designer_tooltip, + xml=qbusyindicatorgoodbad.designer_dom_xml, + ) diff --git a/README.md b/README.md index 9a7b4d1..5278c41 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,12 @@ $ python3 -m venv .venv # Create a virtual environment in .venv $ ./.venv/bin/pip install -r requirements.txt # Install requirements $ source .venv/bin/activate && ./OATFWGUI/main.py # Run the app ``` + +To run the widget designer you need python development libraries: +- Fedora: `sudo dnf install python3-devel` +- Ubuntu (probably, not tested): `sudo apt install python3-dev` + +- then: +`source .venv/bin/activate && env PYSIDE_DESIGNER_PLUGINS=$(realpath ./OATFWGUI) pyside6-designer` + +Open `main_widget.ui` in the designer