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