diff --git a/.github/workflows/build-and-release.yaml b/.github/workflows/build-and-release.yaml index 1597dce..781d12f 100644 --- a/.github/workflows/build-and-release.yaml +++ b/.github/workflows/build-and-release.yaml @@ -97,8 +97,8 @@ jobs: rm -v ./dist/.python_local/Lib/site-packages/PySide6/designer.exe rm -v ./dist/.python_local/Lib/site-packages/PySide6/qmllint.exe rm -rv ./dist/.python_local/Lib/site-packages/PySide6/translations - rm -rv ./dist/.python_local/Lib/site-packages/PySide6/examples rm -rv ./dist/.python_local/Lib/site-packages/PySide6/qml + rm -rv ./dist/.python_local/Lib/site-packages/PySide6/include - name: Smoke test run: ./dist/OATFWGUI_Windows.bat --no-gui diff --git a/OATFWGUI/gui_logic.py b/OATFWGUI/gui_logic.py index 6921711..141a5c2 100644 --- a/OATFWGUI/gui_logic.py +++ b/OATFWGUI/gui_logic.py @@ -7,15 +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 qt_extensions import Worker, QBusyIndicatorGoodBad, BusyIndicatorState +from log_utils import LoggedExternalFile +from qt_extensions import Worker +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 @@ -88,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 @@ -368,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..790585f 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) @@ -266,7 +281,7 @@ def main(): else: log.debug('NOT executing app') log.debug('Testing anonymous statistics creation') - anon_stats = create_anon_stats(widget.main_widget.logic.logic_state) + anon_stats = create_anon_stats(widget.logic.logic_state) log.debug(f'Statistics: {json.dumps(anon_stats)}') # Wait a bit before exiting, prevents Qt complaining about deleted objects time.sleep(1.0) 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 new file mode 100644 index 0000000..1a3931a --- /dev/null +++ b/OATFWGUI/qbusyindicatorgoodbad.py @@ -0,0 +1,146 @@ +import math +import enum +import logging + +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(RegisteredCustomWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.wSpn = QtWaitingSpinner(self, centerOnParent=False) + self.wGood = QIndicatorGood(self) + self.wBad = QIndicatorBad(self) + + self.wStacked = QStackedWidget() + self.wStacked.addWidget(self.wSpn) + self.wStacked.addWidget(self.wGood) + self.wStacked.addWidget(self.wBad) + self.wSpn.start() + + self.hbox = QHBoxLayout(self) + self.hbox.addWidget(self.wStacked) + self.hbox.setAlignment(Qt.AlignCenter) + self.setLayout(self.hbox) + + self.setWindowModality(Qt.NonModal) + self.setAttribute(Qt.WA_TranslucentBackground) + + 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.setAttribute(Qt.WA_DontShowOnScreen) + self.show() + + def setState(self, state: BusyIndicatorState): + if state == BusyIndicatorState.NONE: + self.wStacked.hide() + elif state == BusyIndicatorState.BUSY: + self.wStacked.setCurrentWidget(self.wSpn) + self.wStacked.show() + # self.wSpn.update() + elif state == BusyIndicatorState.GOOD: + self.wStacked.setCurrentWidget(self.wGood) + self.wStacked.show() + elif state == BusyIndicatorState.BAD: + self.wStacked.setCurrentWidget(self.wBad) + self.wStacked.show() + else: + log.error(f'Invalid busy indicator state {state}') + + +class QIndicatorGood(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.setWindowModality(Qt.NonModal) + self.setAttribute(Qt.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + max_width = painter.device().width() + max_height = painter.device().height() + bounding_size = min(max_width, max_height) + pen = QPen() + pen.setWidth(0.05 * bounding_size) + pen.setColor(QColor('green')) + painter.setPen(pen) + + painter.drawEllipse(0, 0, + bounding_size, bounding_size) + bottom_point = (0.5 * bounding_size, 0.9 * bounding_size) + painter.drawLine(0.2 * bounding_size, 0.6 * bounding_size, + *bottom_point) + painter.drawLine(*bottom_point, + 0.8 * bounding_size, 0.2 * bounding_size) + + +class QIndicatorBad(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.setWindowModality(Qt.NonModal) + self.setAttribute(Qt.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + max_width = painter.device().width() + max_height = painter.device().height() + bounding_size = min(max_width, max_height) + pen = QPen() + pen.setWidth(0.05 * bounding_size) + pen.setColor(QColor('red')) + painter.setPen(pen) + + painter.drawEllipse(0, 0, + bounding_size, bounding_size) + circle_width_fudge = 0.00001 * bounding_size # move the lines into the circle's width just a little + on_circle_top_half = (0.5 * math.cos(math.pi * 3 / 4) + 0.5) + circle_width_fudge + on_circle_bot_half = (0.5 * math.cos(math.pi * 1 / 4) + 0.5) - circle_width_fudge + painter.drawLine(on_circle_top_half * bounding_size, on_circle_top_half * bounding_size, + on_circle_bot_half * bounding_size, on_circle_bot_half * bounding_size) + painter.drawLine(on_circle_top_half * bounding_size, on_circle_bot_half * bounding_size, + on_circle_bot_half * bounding_size, on_circle_top_half * bounding_size) diff --git a/OATFWGUI/qt_extensions.py b/OATFWGUI/qt_extensions.py index cd86ce8..6c4a775 100644 --- a/OATFWGUI/qt_extensions.py +++ b/OATFWGUI/qt_extensions.py @@ -1,15 +1,10 @@ import sys import traceback import logging -import math -import enum -from typing import Optional, Tuple +from typing import Optional -from PySide6.QtCore import Slot, Signal, QObject, QRunnable, Qt, QSize, QMetaMethod -from PySide6.QtWidgets import QWidget, QStackedWidget, QHBoxLayout, QSizePolicy -from PySide6.QtGui import QPainter, QColor, QPen - -from waitingspinnerwidget import QtWaitingSpinner +from PySide6.QtCore import Slot, Signal, QObject, QRunnable, QMetaMethod +from PySide6.QtWidgets import QWidget log = logging.getLogger('') @@ -48,110 +43,33 @@ def run(self): self.signals.finished.emit() -class BusyIndicatorState(enum.Enum): - NONE = enum.auto() - BUSY = enum.auto() - GOOD = enum.auto() - BAD = enum.auto() - - -class QBusyIndicatorGoodBad(QWidget): - def __init__(self, fixed_size: Optional[Tuple[int, int]] = None): - super().__init__() - self.wSpn = QtWaitingSpinner(self, centerOnParent=False) - self.wGood = QIndicatorGood(self) - self.wBad = QIndicatorBad(self) - - self.wStacked = QStackedWidget() - self.wStacked.addWidget(self.wSpn) - self.wStacked.addWidget(self.wGood) - self.wStacked.addWidget(self.wBad) - self.wSpn.start() - - self.hbox = QHBoxLayout(self) - self.hbox.addWidget(self.wStacked) - self.hbox.setAlignment(Qt.AlignCenter) - self.setLayout(self.hbox) - - self.setWindowModality(Qt.NonModal) - self.setAttribute(Qt.WA_TranslucentBackground) - - if fixed_size is not None: - self.setFixedSize(QSize(*fixed_size)) - self.size_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - self.setSizePolicy(self.size_policy) - - self.setState(BusyIndicatorState.NONE) - self.setAttribute(Qt.WA_DontShowOnScreen) - self.show() - - def setState(self, state: BusyIndicatorState): - if state == BusyIndicatorState.NONE: - self.wStacked.hide() - elif state == BusyIndicatorState.BUSY: - self.wStacked.setCurrentWidget(self.wSpn) - self.wStacked.show() - self.wSpn.update() - elif state == BusyIndicatorState.GOOD: - self.wStacked.setCurrentWidget(self.wGood) - self.wStacked.show() - elif state == BusyIndicatorState.BAD: - self.wStacked.setCurrentWidget(self.wBad) - self.wStacked.show() - else: - log.error(f'Invalid busy indicator state {state}') - - -class QIndicatorGood(QWidget): - def __init__(self, parent): - super().__init__(parent) - self.setWindowModality(Qt.NonModal) - self.setAttribute(Qt.WA_TranslucentBackground) - - def paintEvent(self, QPaintEvent): - painter = QPainter(self) - max_width = painter.device().width() - max_height = painter.device().height() - bounding_size = min(max_width, max_height) - pen = QPen() - pen.setWidth(0.05 * bounding_size) - pen.setColor(QColor('green')) - painter.setPen(pen) - - painter.drawEllipse(0, 0, - bounding_size, bounding_size) - bottom_point = (0.5 * bounding_size, 0.9 * bounding_size) - painter.drawLine(0.2 * bounding_size, 0.6 * bounding_size, - *bottom_point) - painter.drawLine(*bottom_point, - 0.8 * bounding_size, 0.2 * bounding_size) - - -class QIndicatorBad(QWidget): - def __init__(self, parent): - super().__init__(parent) - self.setWindowModality(Qt.NonModal) - self.setAttribute(Qt.WA_TranslucentBackground) - - def paintEvent(self, QPaintEvent): - painter = QPainter(self) - max_width = painter.device().width() - max_height = painter.device().height() - bounding_size = min(max_width, max_height) - pen = QPen() - pen.setWidth(0.05 * bounding_size) - pen.setColor(QColor('red')) - painter.setPen(pen) - - painter.drawEllipse(0, 0, - bounding_size, bounding_size) - circle_width_fudge = 0.00001 * bounding_size # move the lines into the circle's width just a little - on_circle_top_half = (0.5 * math.cos(math.pi * 3 / 4) + 0.5) + circle_width_fudge - on_circle_bot_half = (0.5 * math.cos(math.pi * 1 / 4) + 0.5) - circle_width_fudge - painter.drawLine(on_circle_top_half * bounding_size, on_circle_top_half * bounding_size, - on_circle_bot_half * bounding_size, on_circle_bot_half * bounding_size) - painter.drawLine(on_circle_top_half * bounding_size, on_circle_bot_half * bounding_size, - on_circle_bot_half * bounding_size, on_circle_top_half * bounding_size) +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 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 73cd35d..5278c41 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OpenAstroTech FirmWare Graphical User Interface -- A graphical way to build and ## Supported platforms - Windows 64 bit - Linux 64 bit - - Requires Python 3.7+, git, libc >= 2.28 (check with `ldd --version`) + - Requires Python 3.7..3.11, git, libc >= 2.28 (check with `ldd --version`) MacOS _might_ work, don't have a mac to test on. Drop a line if you're willing to test it! @@ -31,3 +31,20 @@ Windows: Linux: ![](assets/screenshot_Linux.jpg) + +## Development +To run the app: +```shell +$ 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 diff --git a/requirements.txt b/requirements.txt index 254cbad..088f84c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ platformio==6.1.4 -PySide6-Essentials==6.4.2 +PySide6-Essentials==6.5.3 # Last version that supports python 3.7 requests~=2.28.1 semver~=2.13.0 pygments~=2.13.0 \ No newline at end of file