Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
main_window.py 17.34 KiB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B-ASIC Scheduler-gui Module.

Contains the scheduler-gui class for scheduling operations in an SFG.

Start main-window with start_gui().
"""





import os
import sys
from pathlib        import Path
from types          import ModuleType
from typing         import Any, Iterable, List
from pprint         import pprint
#from matplotlib.pyplot import bar
#from diagram import *
from importlib.machinery import SourceFileLoader
import inspect


# Qt/qtpy
import qtpy
from qtpy           import uic, QtCore, QtGui, QtWidgets
from qtpy.QtCore    import QCoreApplication, Qt, Slot, Signal, QSettings, QStandardPaths
from qtpy.QtGui     import QCloseEvent
from qtpy.QtWidgets import (
    QApplication, QMainWindow, QMessageBox, QFileDialog, QInputDialog, QCheckBox, QAbstractButton,
    QTableWidgetItem, QSizePolicy)

# QGraphics and QPainter imports
from qtpy.QtCore    import (
    QRect, QRectF, QPoint, QSize, QByteArray, QMarginsF, QObject)
from qtpy.QtGui     import (
    QPaintEvent, QPainter, QPainterPath, QColor, QBrush, QPen, QFont, QPolygon, QIcon, QPixmap,
    QLinearGradient)
from qtpy.QtWidgets import (
    QGraphicsView, QGraphicsScene, QGraphicsWidget, QGraphicsScale,
    QGraphicsLayout, QGraphicsLinearLayout, QGraphicsGridLayout, QGraphicsLayoutItem, QGraphicsAnchorLayout,
    QGraphicsItem, QGraphicsItemGroup, QGraphicsRectItem)

# B-ASIC
import logger
from b_asic.schedule            import Schedule
from graphics_graph_item        import GraphicsGraphItem
from graphics_axis_item         import GraphicsAxisItem
from graphics_component_item    import GraphicsComponentItem

# if sys.version_info >= (3, 9):
#     List = list
#     #Dict = dict

log = logger.getLogger()
sys.excepthook = logger.handle_exceptions


# Debug struff
if __debug__:
    log.setLevel('DEBUG')


if __debug__:
    # Print some system version information
    QT_API = os.environ.get('QT_API')
    log.debug('Qt version (runtime):     {}'.format(QtCore.qVersion()))
    log.debug('Qt version (compiletime): {}'.format(QtCore.__version__))
    log.debug('QT_API:                   {}'.format(QT_API))
    if QT_API.lower().startswith('pyside'):
        import PySide2
        log.debug('PySide version:           {}'.format(PySide2.__version__))
    if QT_API.lower().startswith('pyqt'):
        from qtpy.QtCore import PYQT_VERSION_STR
        log.debug('PyQt version:             {}'.format(PYQT_VERSION_STR))
    log.debug('QtPy version:             {}'.format(qtpy.__version__))
    
    # Autocompile the .ui form to a python file.
    try:                                        # PyQt5, try autocompile
        from qtpy.uic import compileUiDir
        uic.compileUiDir('.', map=(lambda dir,file: (dir, 'ui_' + file)))
    except:
        try:                                    # PySide2, try manual compile
            import subprocess
            os_ = sys.platform
            if os_.startswith('linux'):
                cmds = ['pyside2-uic -o ui_main_window.py main_window.ui']
                for cmd in cmds:
                    subprocess.call(cmd.split())
            else:
                #TODO: Implement (startswith) 'win32', 'darwin' (MacOs)
                raise SystemExit
        except:                                 # Compile failed, look for pre-compiled file
            try:
                from ui_main_window import Ui_MainWindow
            except:                             # Everything failed, exit
                log.exception("Could not import 'Ui_MainWindow'.")
                log.exception("Can't autocompile under", QT_API, "eviroment. Try to manual compile 'main_window.ui' to 'ui/main_window_ui.py'")
                os._exit(1)


sys.path.insert(0, 'icons/')                # Needed for the compiled '*_rc.py' files in 'ui_*.py' files
from ui_main_window import Ui_MainWindow    # Only availible when the form (.ui) is compiled


# The folowing QCoreApplication values is used for QSettings among others
QCoreApplication.setOrganizationName('Linöping University')
QCoreApplication.setOrganizationDomain('liu.se')
QCoreApplication.setApplicationName('B-ASIC Scheduler')
#QCoreApplication.setApplicationVersion(__version__)     # TODO: read from packet __version__




class MainWindow(QMainWindow, Ui_MainWindow):
    """Schedule of an SFG with scheduled Operations."""
    _scene: QGraphicsScene
    _graph: GraphicsGraphItem
    _scale: float
    _debug_rects: QGraphicsItemGroup

    
    def __init__(self):
        """Initialize Schedule-gui."""
        super().__init__()
        self._graph = None
        self._open_file_dialog_opened = False
        self._scale = 75
        self._debug_rects = None
        
        QIcon.setThemeName('breeze')
        log.debug('themeName: \'{}\''.format(QIcon.themeName()))
        log.debug('themeSearchPaths: {}'.format(QIcon.themeSearchPaths()))
        self._init_ui()
        self._init_graphics()
        self._read_settings()

        

    def _init_ui(self) -> None:
        """Initialize the ui"""
        self.setupUi(self)
        
        # Connect signals to slots
        self.menu_load_from_file.triggered      .connect(self._load_schedule_from_pyfile)
        self.menu_save          .triggered      .connect(self.save)
        self.menu_save_as       .triggered      .connect(self.save_as)
        self.menu_quit          .triggered      .connect(self.close)
        self.menu_node_info     .triggered      .connect(self.toggle_component_info)
        self.menu_exit_dialog   .triggered      .connect(self.toggle_exit_dialog)
        self.actionT            .triggered      .connect(self.actionTbtn)
        self.splitter           .splitterMoved  .connect(self._splitter_moved)

        # Setup event member functions
        self.closeEvent = self._close_event
        
        # Setup info table
        self.info_table.setHorizontalHeaderLabels(['Property','Value'])
        # test = '#b085b2'
        # self.info_table.setStyleSheet('alternate-background-color: lightGray;background-color: white;')
        self.info_table.setStyleSheet('alternate-background-color: #fadefb;background-color: #ebebeb;')
        for i in range(10):
            self.info_table.insertRow(i)
            item = QTableWidgetItem('this is a very very very very long string that says abolutly nothing')
            self.info_table.setItem(i,0, QTableWidgetItem('property {}: '.format(i)))
            self.info_table.setItem(i,1,item)

        # Init central-widget splitter
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)
        self.splitter.setCollapsible(0, False)
        self.splitter.setCollapsible(1, True)

    def _init_graphics(self) -> None:
        """Initialize the QGraphics framework"""
        self._scene = QGraphicsScene()
        self.view.setScene(self._scene)
        self.view.scale(self._scale, self._scale)
        GraphicsComponentItem._scale = self._scale
        GraphicsAxisItem._scale = self._scale
        self._scene.changed.connect(self.shrink_scene_to_min_size)
        
    

    ###############
    #### Slots ####
    ###############
    @Slot()
    def actionTbtn(self) -> None:
        self._graph.schedule.plot_schedule()
        print(f'filtersChildEvents(): {self._graph.filtersChildEvents()}')
        # self.printButtonPressed('callback_pushButton()')
    
    @Slot()
    def _load_schedule_from_pyfile(self) -> None:
        settings = QSettings()
        # open_dir = QStandardPaths.standardLocations(QStandardPaths.HomeLocation)[0] if not self._open_file_dialog_opened else ''
        last_file = settings.value('mainwindow/last_opened_file', QStandardPaths.standardLocations(QStandardPaths.HomeLocation)[0], str)
        if not os.path.exists(last_file):               # if filename does not exist
            last_file = os.path.dirname(last_file) + '/'
            if not os.path.exists(last_file):           # if path does not exist
                last_file = QStandardPaths.standardLocations(QStandardPaths.HomeLocation)[0]

        abs_path_filename, _ = QFileDialog.getOpenFileName(self,
                                                        self.tr("Open python file"),
                                                        last_file,
                                                        self.tr("Python Files (*.py *.py3)"))
        
        if not abs_path_filename:       # return if empty filename (QFileDialog was canceled)
            return
        log.debug('abs_path_filename = {}.'.format(abs_path_filename))
        self._open_file_dialog_opened = True

        module_name = inspect.getmodulename(abs_path_filename)
        if not module_name:             # return if empty module name
            log.error('Could not load module from file \'{}\'.'.format(abs_path_filename))
            return 
        
        try:
            module = SourceFileLoader(module_name, abs_path_filename).load_module()
        except:
            log.exception('Exception occurred. Could not load module from file \'{}\'.'.format(abs_path_filename))
            return

        schedule_obj_list = dict(inspect.getmembers(module, (lambda x: isinstance(x, Schedule))))
        
        if not schedule_obj_list:       # return if no Schedule objects in script
            QMessageBox.warning(self,
                                self.tr('File not found'),
                                self.tr('Could not find any Schedule object in file \'{}\'.')
                                .format(os.path.basename(abs_path_filename)))
            log.info('Could not find any Schedule object in file \'{}\'.'
                     .format(os.path.basename(abs_path_filename)))
            del module
            return
        
        ret_tuple = QInputDialog.getItem(self,
                                         self.tr('Load object'),
                                         self.tr('Found the following Schedule object(s) in file.)\n\n'
                                                 'Select an object to proceed:'),
                                         schedule_obj_list.keys(),0,False)

        if not ret_tuple[1]:                  # User canceled the operation
            log.debug('Load schedule operation: user canceled')
            del module
            return
        
        self.open(schedule_obj_list[ret_tuple[0]])
        del module
        settings.setValue("mainwindow/last_opened_file", abs_path_filename)

        
    
    #@Slot()
    def open(self, schedule: Schedule) -> None:
        """Takes in an Schedule and creates a GraphicsGraphItem object."""

        self._graph = GraphicsGraphItem(schedule)
        self._scene.addItem(self._graph)
        self._graph.installSceneEventFilters()
        
        # graph.prepareGeometryChange()
        # graph.setPos(200, 20)

        # self._scene.setSceneRect(self._scene.itemsBoundingRect())   # Forces the scene to it's minimum size
        
        # # Debug rectangles
        # if __debug__:
        #     # self._scene.setSceneRect(self._scene.itemsBoundingRect())   # Forces the scene to it's minimum size
        #     m = QMarginsF(1/self._scale, 1/self._scale, 0, 0)
        #     m2 = QMarginsF(1, 1, 1, 1)
        #     pen = QPen(Qt.red)
        #     pen.setStyle(Qt.DotLine)
        #     pen.setCosmetic(True)
        #     for component in graph.items:
        #         self._scene.addRect(component.mapRectToScene(component.boundingRect() - m), pen)
        #     pen.setColor(Qt.red)
        #     for axis in graph.axis.childItems():
        #         self._scene.addRect(axis.mapRectToScene(axis.boundingRect() - m), pen)
        #     pen.setColor(Qt.green)
        #     # self._scene.addRect(self._scene.itemsBoundingRect() - m, pen)
            
        # self._scene.setSceneRect(self._scene.itemsBoundingRect())

        self.update_statusbar(self.tr('Schedule loaded successfully'))
    
    @Slot()
    def save(self) -> None:
        """This method save an schedule."""
        #TODO: all
        self.printButtonPressed('save_schedule()')
        self.update_statusbar(self.tr('Schedule saved successfully'))

    @Slot()
    def save_as(self) -> None:
        """This method save as an schedule."""
        #TODO: all
        self.printButtonPressed('save_schedule()')
        self.update_statusbar(self.tr('Schedule saved successfully'))
    
    @Slot(bool)
    def toggle_component_info(self, checked: bool) -> None:
        """This method toggles the right hand side info window."""
        # Note: splitter handler index 0 is a hidden splitter handle far most left, use index 1
        settings = QSettings()
        range = self.splitter.getRange(1)    # tuple(min, max)
        
        if checked:
            self.splitter.restoreState(settings.value("mainwindow/splitter/last_state"))
            # self.splitter.restoreState(settings.value("splitterSizes"))
        else:
            settings.setValue("mainwindow/splitter/last_state", self.splitter.saveState())
            self.splitter.moveSplitter(range[1], 1)
            
    @Slot(bool)
    def toggle_exit_dialog(self, checked: bool) -> None:
        s = QSettings()
        s.setValue("mainwindow/hide_exit_dialog", checked)

    @Slot(int, int)
    def _splitter_moved(self, pos: int, index: int) -> None:
        """Callback method used to check if the right widget (info window) 
        has collapsed. Update the checkbutton accordingly."""
        # TODO: Custom move handler, save state on click-release?
        widths: list[int, int] = list(self.splitter.sizes())
        
        if widths[1] == 0:
            self.menu_node_info.setChecked(False)
        else:
            self.menu_node_info.setChecked(True)
        
    @Slot('QList<QRectF>')
    def shrink_scene_to_min_size(self, region: List[QRectF]) -> None:
        self._scene.setSceneRect(self._scene.itemsBoundingRect())
    


    ################
    #### Events ####
    ################
    def _close_event(self, event: QCloseEvent) -> None:
        """Replaces QMainWindow default closeEvent(QCloseEvent) event"""
        s = QSettings()
        hide_dialog = s.value('mainwindow/hide_exit_dialog', False, bool)
        ret = QMessageBox.StandardButton.Yes
        
        if not hide_dialog:
            box = QMessageBox(self)
            box.setWindowTitle(self.tr('Confirm Exit'))
            box.setText('<h3>' + self.tr('Confirm Exit') + '</h3><p><br>' +
                        self.tr('Are you sure you want to exit?') +
                        '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<br></p>')
            box.setIcon(QMessageBox.Question)
            box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            buttons: list[QAbstractButton] = box.buttons()
            buttons[0].setText(self.tr('&Exit'))
            buttons[1].setText(self.tr('&Cancel'))
            checkbox = QCheckBox(self.tr('Don\'t ask again'))
            box.setCheckBox(checkbox)
            ret = box.exec_()
        
        if ret == QMessageBox.StandardButton.Yes:
            if not hide_dialog:
                s.setValue('mainwindow/hide_exit_dialog', checkbox.isChecked())
            self._write_settings()
            log.info('Exit: {}'.format(os.path.basename(__file__)))
            event.accept()
        else:
            event.ignore()
        


    #################################
    #### Helper member functions ####
    #################################
    def printButtonPressed(self, func_name: str) -> None:
        #TODO: remove        

        alert = QMessageBox(self)
        alert.setText("Called from " + func_name + '!')
        alert.exec_()

    def update_statusbar(self, msg: str) -> None:
        """Write the given str to the statusbar with temporarily policy."""
        self.statusbar.showMessage(msg)
        
    def _write_settings(self) -> None:
        """Write settings from MainWindow to Settings."""
        s = QSettings()
        s.setValue('mainwindow/maximized',      self.isMaximized())     # window: maximized, in X11 - alwas False
        s.setValue('mainwindow/pos',            self.pos())             # window: pos
        s.setValue('mainwindow/size',           self.size())            # window: size
        s.setValue('mainwindow/state',          self.saveState())       # toolbars, dockwidgets: pos, size
        s.setValue('mainwindow/menu/node_info', self.menu_node_info.isChecked())
        s.setValue('mainwindow/splitter/state', self.splitter.saveState())

        if s.isWritable():
            log.debug('Settings written to \'{}\'.'.format(s.fileName()))
        else:
            log.warning('Settings cant be saved to file, read-only.')
    
    def _read_settings(self) -> None:
        """Read settings from Settings to MainWindow."""
        s = QSettings()
        if s.value('mainwindow/maximized', defaultValue=False, type=bool):
            self.showMaximized()
        else:
            self.move(                      s.value('mainwindow/pos', self.pos()))
            self.resize(                    s.value('mainwindow/size', self.size()))
        self.restoreState(                  s.value('mainwindow/state', QByteArray()))
        self.menu_node_info.setChecked(     s.value('mainwindow/menu/node_info', True, bool))
        self.splitter.restoreState(         s.value('mainwindow/splitter/state', QByteArray()))
        self.menu_exit_dialog.setChecked(   s.value('mainwindow/hide_exit_dialog', False, bool))

        log.debug('Settings read from \'{}\'.'.format(s.fileName()))



def start_gui():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    start_gui()