diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a44111ecf75ba7e489250f88e1343f8794983603..cc4d80dde01a0addf65b1df34876b52c93cf913f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,8 @@ stages: job_lint_flake8: stage: lint + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - flake8 --version @@ -12,6 +14,8 @@ job_lint_flake8: job_lint_pylint: stage: lint + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - pylint --version @@ -19,6 +23,8 @@ job_lint_pylint: job_lint_bandit: stage: lint + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - bandit --version @@ -26,6 +32,8 @@ job_lint_bandit: job_lint_pydocstyle: stage: lint + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - pydocstyle --version @@ -33,6 +41,8 @@ job_lint_pydocstyle: job_test_gui_ubuntu_vnc: stage: test + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: # -platform because running with a screen is not supported @@ -57,6 +67,8 @@ job_test_gui_ubuntu: job_pages_smoke_test: stage: deploy + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - mkdir public @@ -70,6 +82,8 @@ job_pages_smoke_test: # name has to be pages pages: stage: deploy + tags: + - ci-ubuntu-executor-docker image: morkolai/soitool-ci script: - mkdir public diff --git a/Dockerfile b/Dockerfile index 0ba5c5605dc81669c396b2cc9f78f6538410168d..d4420c91c03b1d3436a69ceeb553db22250e6a8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,3 +41,11 @@ ENV LC_ALL en_US.UTF-8 # From https://it.i88.ca/2016/03/how-to-fix-importerror-libgssapikrb5so2.html # python3.7 -m unittest fails without this RUN apt-get -y install libgssapi-krb5-2 + +# Necessary for import of PySide2.QtWebEngineWidgets to work +RUN apt-get -y install libnss3 \ + libxcomposite-dev \ + libxcursor-dev \ + libxi-dev \ + libxtst-dev \ + libxrandr-dev diff --git a/README.md b/README.md index 5abde24ededc26891550f89aa9664f7ee9f19f30..1798acb96dcfa16bf291854cd340a0410431b769 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ deactivate Sjekk av kodekvalitet gjøres med scriptet "CodeQualityCheck" i script-mappa, og er skrevet i Powershell(.ps1) og Bash(.sh). Scriptet kjører Pylint og Flake8 med tilhørende konfigurasjonsfiler, Pydocstyle med numpy-konvensjon og Bandit på Python-filer. -Scriptet godtar kommandolinjeargumenter: .py-fil(er), mappe(r ) eller en blanding av disse. Uten argumenter vil scriptet sjekke alle .py-filer. +Scriptet godtar kommandolinjeargumenter: .py-fil(er), mappe(r) eller en blanding av disse. Uten argumenter vil scriptet sjekke alle .py-filer. Terminal kjøres fra root. Sjekk: @@ -39,7 +39,7 @@ Terminal kjøres fra root. Sjekk: * Spesifikk(e) .py: `.\scripts\CodeQualityCheck.ps1 filEn.py filTo.py` -* Alle .py i mappe(r ): `.\scripts\CodeQualityCheck.ps1 mappeEn mappeTo` +* Alle .py i mappe(r): `.\scripts\CodeQualityCheck.ps1 mappeEn mappeTo` * Blanding: `.\scripts\CodeQualityCheck.ps1 mappeEn mappeTo\fil.py` @@ -59,6 +59,8 @@ Gjøres med pdoc3: * Uten kildekode: `pdoc3 --html --config show_source_code=False --output-dir .\docs\ .\soitool\main.py` +Autogenerert docs: http://bachelor-paa-bittet.pages.stud.idi.ntnu.no/soitool/ + ## Om `Dockerfile` Docker image som brukes i `.gitlab-ci.yml` er bygget med filen `Dockerfile` og er lastet opp som `morkolai/soitool-ci`. Docker image inneholder alle avhengigheter til prosjektet. Følgende prosedyre brukes for å oppdatere image. Dette må gjøres når `requirements.txt` endrer seg. diff --git a/scripts/.flake8 b/scripts/.flake8 index de75a936c7acec77595ab58ce170803ae7205cd2..f6c974480c4192afdbffa9c58c1b72a51d49339e 100644 --- a/scripts/.flake8 +++ b/scripts/.flake8 @@ -2,4 +2,4 @@ ; ignorerer sjekk for lange linjer, ettersom dette gjøres i pylint ; vi vil tillate lange linjer som avsluttes med link, og dette er mulig i ; pylint, men såvidt vi vet ikke mulig i flake8 -ignore = E501 +ignore = E501,W503 diff --git a/scripts/.pylintrc b/scripts/.pylintrc index 01e79b9dda28b3eefb8acdaa22c29477f31ae2b2..6262e5ddd5bbbd19ecdd7bd226c74675c1477091 100644 --- a/scripts/.pylintrc +++ b/scripts/.pylintrc @@ -286,7 +286,7 @@ indent-after-paren=4 indent-string=' ' # Maximum number of characters on a single line. -max-line-length=100 +max-line-length=79 # Maximum number of lines in a module. max-module-lines=1000 @@ -404,6 +404,8 @@ function-naming-style=snake_case # Good variable names which should always be accepted, separated by a comma. good-names=app, + x, + y, ok, i, j, @@ -503,7 +505,7 @@ valid-metaclass-classmethod-first-arg=cls max-args=5 # Maximum number of attributes for a class (see R0902). -max-attributes=7 +max-attributes=15 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 diff --git a/soitool/__init__.py b/soitool/__init__.py index b5bf2de2219cec4d93e0b5e67629b24b5c8028b1..ab3061e1810d86c58eba45869272f7d921e541d4 100644 --- a/soitool/__init__.py +++ b/soitool/__init__.py @@ -1 +1 @@ -"""SOI - Tool: Verktøy for å produsere SOIer.""" +"""SOI - Tool: A tool to produce SOI.""" diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py new file mode 100644 index 0000000000000000000000000000000000000000..e49d3dc369e522aa096701a59e58185ebbd4733d --- /dev/null +++ b/soitool/inline_editable_soi_view.py @@ -0,0 +1,250 @@ +"""Includes functionality for inline editing of SOI.""" +from PySide2.QtCore import Qt, QRectF, QTimer, QPoint, QMarginsF +from PySide2.QtWidgets import QApplication, QScrollArea, QLabel, \ + QGraphicsScene, QGraphicsView, QGraphicsRectItem +from PySide2.QtGui import QFont, QPixmap, QBrush, QPalette, QPainter +from PySide2.QtPrintSupport import QPrinter + + +class InlineEditableSOIView(QScrollArea): + """Widget som kan byttes ut med view, edit etc.""" + + def is_widget_in_scene(self, widget): + """Indicate wether given widget already has a proxy in the scene.""" + for page in self.pages: + for proxy in page: + if proxy.widget() == widget: + return True + return False + + def setup_proxies(self): + """Set up proxies for the widgets of SOI modules.""" + for module in self.soi.modules: + if not self.is_widget_in_scene(module["widget"]): + proxy = self.scene.addWidget(module["widget"]) + + while len(self.pages) < module["meta"]["page"]: + self.pages.append(set()) + + self.pages[module["meta"]["page"] - 1].add(proxy) + + def mousePressEvent(self, _): + """Reorganize modules when pressed. + + This is a temporary way to activate reorganization of widgets. Note + that will not be triggered by clicks on modules in the scene + """ + self.soi.reorganize() + + def __init__(self, soi): + super().__init__() + + self.soi = soi + + self.pages = [] + + # necessary to make the scroll area fill the space it's given + self.setWidgetResizable(True) + + # scene and view widgets + self.scene = QGraphicsScene() + self.setup_scene() + self.view = QGraphicsView(self.scene) + + self.setWidget(self.view) + + self.setup_proxies() + + self.update_drawn_pages() + + # self.launch_auto_zoom() + + def produce_pdf(self, filename): + """Produce PDF using QGraphicsScene. + + Renders the QGraphicsScene-representation of the SOI as a PDF. This + PDF is in theory supposed to be searchable [1], but in practice it + seems that each QGraphicsItem in the scene is simply dumped as an + image. + + Sources: + [1]: https://doc.qt.io/qt-5/qprinter.html#OutputFormat-enum + + Parameters + ---------- + filename : str + Name of file to store PDF in. If file exists it will be + overwritten. Note that the filename should contain the extension + '.pdf' to be properly handled by operating systems. + """ + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(filename) + printer.setPageSize(QPrinter.A4) + printer.setOrientation(QPrinter.Landscape) + printer.setPageMargins(QMarginsF(0, 0, 0, 0)) + + painter = QPainter() + + try: + ok = painter.begin(printer) + + if not ok: + raise ValueError("Not able to begin QPainter using QPrinter " + "based on argument " + "filename '{}'".format(filename)) + + # render each page to own PDF page + for i, modules in enumerate(self.pages): + + x = 0 + y = self.soi.HEIGHT * i + self.soi.PADDING * i + + self.scene.render(painter, source=QRectF(x, y, + self.soi.WIDTH, + self.soi.HEIGHT)) + + # if there are more pages, newPage + if i + 1 < len(self.pages): + printer.newPage() + finally: + painter.end() + + def update_drawn_pages(self): + """Update drawn pages to reflect self.pages.""" + for i, modules in enumerate(self.pages): + + x = 0 + y = self.soi.HEIGHT * i + self.soi.PADDING * i + + # adjust page size + full_scene_height = y + self.soi.HEIGHT + self.scene.setSceneRect(QRectF(0, 0, self.soi.WIDTH, + full_scene_height)) + + self.draw_page(x, y) + self.draw_header(x + self.soi.PADDING, y + self.soi.PADDING, i + 1) + + for module in modules: + # redraw of pages requires modules to be moved to front again + module.setZValue(1) + + def draw_header(self, x, y, page_number): + """Draw header staring at given position. + + Parameters + ---------- + x : int + y : int + """ + # title + label_title = QLabel("SOI TITLE HERE") + label_title.move(x, y) + label_title.setStyleSheet("background-color: rgba(0,0,0,0%)") + label_title.setFont(QFont("Times New Roman", 50)) + self.scene.addWidget(label_title) + + # page numbering + page_number = QLabel("{} av {}".format(page_number, len(self.pages))) + page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") + page_number.setFont(QFont("Times New Roman", 50)) + # source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = \ + page_number.fontMetrics().boundingRect(page_number.text()).width() + page_number.move(x + (self.soi.CONTENT_WIDTH - label_width) / 2, y) + self.scene.addWidget(page_number) + + # grading + grading = QLabel("UGRADERT") + grading.setStyleSheet("background-color: rgba(0,0,0,0%); color: red") + grading.setFont(QFont("Times New Roman", 50)) + # source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = \ + grading.fontMetrics().boundingRect(grading.text()).width() + x_pos = x + self.soi.CONTENT_WIDTH - label_width - \ + self.soi.HEADER_HEIGHT + grading.move(x_pos, y) + self.scene.addWidget(grading) + + # patch + pixmap = QPixmap("soitool/media/HVlogo.png") + patch = QLabel() + patch.setPixmap(pixmap.scaled(self.soi.HEADER_HEIGHT, + self.soi.HEADER_HEIGHT)) + patch.move(x + self.soi.CONTENT_WIDTH - self.soi.HEADER_HEIGHT, y) + self.scene.addWidget(patch) + + def draw_page(self, x, y): + """Draw page starting at given position. + + Parameters + ---------- + x : int + y : int + """ + # color the page white + page_background = QGraphicsRectItem(x, y, self.soi.WIDTH, + self.soi.HEIGHT) + page_background.setBrush(QBrush(Qt.white)) + self.scene.addItem(page_background) + + # draw borders + self.scene.addRect(x, y, self.soi.WIDTH, self.soi.HEIGHT) + self.scene.addRect(x + self.soi.PADDING, y + self.soi.PADDING, + self.soi.CONTENT_WIDTH, self.soi.CONTENT_HEIGHT) + self.scene.addRect(x + self.soi.PADDING, y + self.soi.PADDING, + self.soi.CONTENT_WIDTH, self.soi.CONTENT_HEIGHT) + self.scene.addRect(x + self.soi.PADDING, y + self.soi.PADDING, + self.soi.CONTENT_WIDTH, self.soi.HEADER_HEIGHT) + + def setup_scene(self): + """Prepare scene for use. + + Draws borders, background, etc. + """ + # sets the background color of the scene to be the appropriate + # system-specific background color + # source: https://stackoverflow.com/a/23880531/3545896 + # source: https://stackoverflow.com/questions/15519749/how-to-get-widget-background-qcolor + app = QApplication.instance() + self.scene.setBackgroundBrush(app.palette().color(QPalette.Window)) + + self.update_drawn_pages() + + def launch_auto_zoom(self): + """Zoom in a regular interval. + + Used to demonstrate zooming only, should be removed once the project + matures. + """ + def do_on_timeout(): + self.zoom(1 / 1.00005) + + timer = QTimer(self) + timer.timeout.connect(do_on_timeout) + timer.start(10) + + def zoom(self, zoom_factor): + """Zoom GraphicsView by zoom_factor. + + source: https://stackoverflow.com/a/29026916/3545896 + + Parameters + ---------- + zoom_factor : float + > 1 to zoom in and < 1 to zoom out + """ + # Set Anchors + self.view.setTransformationAnchor(QGraphicsView.NoAnchor) + self.view.setResizeAnchor(QGraphicsView.NoAnchor) + + # Zoom + self.view.scale(zoom_factor, zoom_factor) + + pos_old = self.view.mapToScene(QPoint(self.soi.WIDTH / 2, + self.soi.HEIGHT / 2)) + pos_new = self.view.mapToScene(self.soi.WIDTH / 2, self.soi.HEIGHT / 2) + delta = pos_new - pos_old + self.view.translate(delta.x(), delta.y()) diff --git a/soitool/main_window.py b/soitool/main_window.py index 042f25a93784dabad3bd5a6a6bb8dd72f517211c..275b1e903d801526c709fda2e34788a977dd6fdb 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -1,16 +1,21 @@ -"""MainWindow. +"""Inclues the main window of our application. + +Built up by widgets implemented in other modules. -During initial development this module will include most of the code necessary -to get our project up-and-running. When our project matures we will move code -out to separate modules. """ import sys import os -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QTabWidget, QWidget, QMainWindow, \ - QApplication, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, \ - QAbstractItemView, QListWidget, QListWidgetItem, QAction +from enum import Enum +from PySide2.QtWidgets import QMainWindow, QApplication, QTabWidget, QAction from PySide2.QtGui import QIcon +from soitool.soi_workspace_widget import SOIWorkspaceWidget + + +class ModuleType(Enum): + """Enumerate with types of modules.""" + + MAIN_MODULE = 0 + ATTACHMENT_MODULE = 1 class MainWindow(QMainWindow): @@ -87,11 +92,11 @@ class MainWindow(QMainWindow): help_menu.addAction(easy_use) # Legger til MainWidget som en tab, kanskje flytt ut til egen funksjon - tabs = QTabWidget() + self.tabs = QTabWidget() tab = SOIWorkspaceWidget() - tabs.addTab(tab, "MainTab") - self.setCentralWidget(tabs) + self.tabs.addTab(tab, "MainTab") + self.setCentralWidget(self.tabs) # Add HV logo filename = 'media/HVlogo.PNG' @@ -100,87 +105,8 @@ class MainWindow(QMainWindow): self.setWindowIcon(QIcon(filepath)) -class SOIWorkspaceWidget(QWidget): - """Contains the working area for a single SOI. - - The widget is used inside tabs in our application, and contains a sidebar - with a module list along with a view of the SOI. - """ - - def __init__(self): - super().__init__() - - self.layout_wrapper = QHBoxLayout() - self.layout_sidebar = QVBoxLayout() - - # all widgets - self.button_new_module = QPushButton("Ny modul") - self.button_new_module.setShortcut("Ctrl+m") - self.button_new_module.setStatusTip("Legg til en ny modul") - self.button_setup = QPushButton("Oppsett") - self.list_modules = QListWidget() - self.view = ViewArea() - - # prepare module list - self.setup_list_modules() - self.fill_list_modules() - - # build layouts - self.layout_sidebar.addWidget(QLabel("Moduler:")) - self.layout_sidebar.addWidget(self.list_modules, 5) - self.layout_sidebar.addWidget(QLabel("Vedlegg:")) - self.layout_sidebar.addWidget(QListWidget(), 1) - self.layout_sidebar.addWidget(self.button_new_module) - self.layout_sidebar.addWidget(self.button_setup) - self.layout_wrapper.addLayout(self.layout_sidebar) - self.layout_wrapper.addWidget(self.view) - - self.setFixedWidth(200) - self.setLayout(self.layout_wrapper) - - def setup_list_modules(self): - """Prepare module list. - - The list contains modules that are drag-and-droppable. - """ - # enable drag-and-drop - self.list_modules.setDragEnabled(True) - self.list_modules.viewport().setAcceptDrops(True) - self.list_modules.viewport().setAcceptDrops(True) - self.list_modules.setDragDropMode(QAbstractItemView.InternalMove) - - # source: https://www.qtcentre.org/threads/32500-Horizontal-Scrolling-QListWidget - self.list_modules.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - def fill_list_modules(self): - """Fill module list with some items to manually test with. - - This function will be removed when we can fill the list properly. - """ - # hardcode some items - items = [ - QListWidgetItem("Frekvenstabell"), - QListWidgetItem("Sambandsdiagram"), - QListWidgetItem("Autentifiseringstavle"), - QListWidgetItem("Subtraktorkoder"), - ] - for i, item in enumerate(items): - self.list_modules.insertItem(i, item) - - -class ViewArea(QWidget): - """Widget som kan byttes ut med view, edit etc.""" - - def __init__(self): - super().__init__() - test = QLabel("Test") - layout = QHBoxLayout() - layout.addWidget(test) - self.setLayout(layout) - - if __name__ == "__main__": app = QApplication(sys.argv) WINDOW = MainWindow() - WINDOW.show() + WINDOW.showMaximized() app.exec_() diff --git a/soitool/module_list.py b/soitool/module_list.py new file mode 100644 index 0000000000000000000000000000000000000000..d8c81252b5a56cb53f6db3f5cf9337c5c2d27270 --- /dev/null +++ b/soitool/module_list.py @@ -0,0 +1,152 @@ +"""Includes functionality for displaying a prioritized list of modules.""" +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QListWidget, QListWidgetItem, QAbstractItemView +from soitool.soi import ModuleType +import soitool + + +class ModuleList(QListWidget): + """Contains module-names from soitool.SOI. + + List elements are names of modules or attachment-modules from soitool.SOI. + List elements are editable, drag-and-droppable and unique (no duplicates). + Makes changes in soitool.SOI lists through its parent-variable. + + Parameters + ---------- + module_type : int + Determines whether names are from modules or attachment-modules. + Is used with the Enum 'ModuleType' to know which list in parent + to update when changes are made. + parent : soitool.SOIWorkspaceWidget + Reference to parent. + """ + + def __init__(self, module_type, parent): + super().__init__() + + # full import path below to avoid circular dependency + if not isinstance(parent, + soitool.soi_workspace_widget.SOIWorkspaceWidget): + raise RuntimeError('Only soitool.SOIWorkspaceWidget is ' + 'acceptable type for parent-variable ' + 'in class Module_list.') + self.type = module_type + self.parent = parent + self.original_element_name = None + self.original_element_index = None + + self.setup_list() + self.fill_list() + + def setup_list(self): + """Prepare list. + + Make list drag-and-droppable and remove horizontal scrollbar. + """ + # Enable drag-and-drop + self.setDragEnabled(True) + self.viewport().setAcceptDrops(True) + self.setDragDropMode(QAbstractItemView.InternalMove) + + # Remove horizontal scrollbar + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def fill_list(self): + """Fill list with elements.""" + # Get names of modules/attachments: + if ModuleType(self.type) == ModuleType.MAIN_MODULE: + names = [module["meta"]["name"] for + module in self.parent.soi.modules] + elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: + names = [attachment["meta"]["name"] for + attachment in self.parent.soi.attachments] + + for i, name in enumerate(names): + item = QListWidgetItem(name) + item.setFlags(item.flags() | Qt.ItemIsEditable) + self.insertItem(i, item) + + def currentChanged(self, current, previous): + """Save name and index of an element when it is selected. + + Function is needed to remember original: + 1: index of an element in case it's' drag-and-dropped (notify parent). + 2: name of an element in case it's name changes (avoid duplicates). + + https://doc.qt.io/qt-5/qlistview.html#currentChanged. + + Parameters + ---------- + current : QModelIndex + Used to get index of element, is sent to super(). + previous : QModelIndex + Is sent to super(). + """ + super().currentChanged(current, previous) + self.original_element_name = self.item(current.row()).text() + self.original_element_index = current.row() + + def dataChanged(self, index1, index2, roles): + """Check for duplicate name and notify parent when an element changes. + + https://doc.qt.io/qt-5/qabstractitemview.html#dataChanged. + + Parameters + ---------- + index1 : QModelIndex + Used to get index of changed element, is sent to super(). + index2 : QModelIndex + Is sent to super(). + roles : QVector + Is sent to super(). + """ + index = index1.row() + # Get all names and new name + names = [self.item(i).text() for i in range(self.count())] + new_name = names[index] + + # Count occurrences of new name + occurrences = names.count(new_name) + + # If new name already exists, replace with original name + if occurrences > 1: + self.item(index).setText(self.original_element_name) + # Name does not already exist, update list in soi + else: + name = self.item(index).text() + + if ModuleType(self.type) == ModuleType.MAIN_MODULE: + self.parent.soi.modules[index]["meta"]["name"] = name + elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: + self.parent.soi.attachments[index]["meta"]["name"] = name + + super().dataChanged(index1, index2, roles) + + def dropEvent(self, event): + """Notify parent when an element is drag-and-dropped. + + https://doc.qt.io/qt-5/qabstractitemview.html#dropEvent. + + Parameters + ---------- + event : QDropEvent + Is sent to super(). + """ + super().dropEvent(event) + + # Get origin and destination index of module/attachment + origin = self.original_element_index + destination = self.currentRow() + + # Update module/attachment priority (order in list): + if ModuleType(self.type) == ModuleType.MAIN_MODULE: + moving_module = self.parent.soi.modules.pop(origin) + self.parent.soi.modules.insert(destination, moving_module) + elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: + moving_module = self.parent.soi.attachments.pop(origin) + self.parent.soi.attachments.insert(destination, moving_module) + + self.parent.soi.reorganize() + + self.original_element_index = self.currentRow() diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index 92e7e6bc6145a1a1921cb5580c7b8384d77cad51..630cdaeb4c5e7b982b52b49413cbde406abedb74 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -9,6 +9,10 @@ class ModuleBase(ABC): """Abstract method, should be implemented by derived class.""" raise NotImplementedError + def set_pos(self, pos): + """Abstract method, should be implemented by derived class.""" + raise NotImplementedError + def render_onto_pdf(self): """Abstract method, should be implemented by derived class.""" raise NotImplementedError diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py index 6ddf18b4645cd7e18294adb1bc9d518676b0e39b..29adbce0881c19645afa7c8e0435eefc6748a197 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -1,5 +1,5 @@ """Module containing subclassed SOIModule (QTableWidget, ModuleBase).""" -from PySide2.QtWidgets import QTableWidget, QTableWidgetItem, QShortcut +from PySide2.QtWidgets import QTableWidget, QTableWidgetItem from PySide2 import QtGui, QtCore from soitool.modules.module_base import ModuleBase @@ -27,6 +27,16 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): Has shortcuts for adding and removing rows and columns. """ + def set_pos(self, pos): + """Set position of widget. + + Parameters + ---------- + pos : QPoint + Position (x, y). + """ + self.move(pos) + def __init__(self): QTableWidget.__init__(self) super(QTableWidget) @@ -54,7 +64,31 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.cellChanged.connect(self.resize) - self.set_shortcuts() + def keyPressEvent(self, event): + """Launch actions when specific combinations of keys are pressed. + + If the keys pressed are not related to a shortcut on this custom widget + the event is sent on to be handled by the superclass (for navigation + with arrow-keys for.eg.) + + Parameters + ---------- + event : QKeyEvent + event sent by Qt for us to handle + """ + if event.key() == QtCore.Qt.Key_Question: + self.add_column() + elif (event.modifiers() == QtCore.Qt.ShiftModifier + and event.key() == QtCore.Qt.Key_Underscore): + self.remove_column() + elif (event.modifiers() == QtCore.Qt.ControlModifier + and event.key() == QtCore.Qt.Key_Plus): + self.add_row() + elif (event.modifiers() == QtCore.Qt.ControlModifier + and event.key() == QtCore.Qt.Key_Underscore): + self.remove_row() + else: + super(TableModule, self).keyPressEvent(event) def set_header_item(self, column, text): """Insert item with header-style. @@ -73,20 +107,6 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): item.setFont(HEADER_FONT) self.setItem(0, column, item) - def set_shortcuts(self): - """Set shortcuts for adding and removing rows and columns.""" - # Create shortcuts - shortcut_add_col = QShortcut(QtGui.QKeySequence("Shift++"), self) - shortcut_rem_col = QShortcut(QtGui.QKeySequence("Shift+-"), self) - shortcut_add_row = QShortcut(QtGui.QKeySequence("Ctrl++"), self) - shortcut_rem_row = QShortcut(QtGui.QKeySequence("Ctrl+-"), self) - - # Connect shortcuts to functions - shortcut_add_col.activated.connect(self.add_column) - shortcut_rem_col.activated.connect(self.remove_column) - shortcut_add_row.activated.connect(self.add_row) - shortcut_rem_row.activated.connect(self.remove_row) - def add_column(self): """Add column to the right of selected column.""" self.insertColumn(self.currentColumn() + 1) diff --git a/soitool/pdf_preview_widget.py b/soitool/pdf_preview_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..f241cb20cb087efdffcf52ae9057a1d99f47d143 --- /dev/null +++ b/soitool/pdf_preview_widget.py @@ -0,0 +1,33 @@ +"""Includes functionality for PDF preview.""" +from PySide2.QtCore import QUrl +from PySide2.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings + + +# pylint: disable=r0903 +class PDFPreviewWidget(QWebEngineView): + """Widget to preview PDFs using QWebEngineView with PDF plugin. + + Inspired by the following sources: + * https://doc.qt.io/qt-5/qtwebengine-features.html#pdf-file-viewing + * https://doc.qt.io/qt-5/qtwebengine-webenginewidgets-simplebrowser-example.html + + The load method interited from QWebEngineView's can be used to load a new + URL, and the inherited reload method can be used to reload the currently + opened URL. + + Parameters + ---------- + initial_url : QUrl + URL to load by default. Used for QWebEngineView's .load method + https://doc.qt.io/qtforpython/PySide2/QtWebEngineWidgets/QWebEngineView.html#detailed-description + """ + + def __init__(self, initial_url): + super().__init__() + self.page().settings().setAttribute(QWebEngineSettings.PluginsEnabled, + True) + # the following setting is the default, but explicitly enabling to be + # explicit + self.page().settings().setAttribute(QWebEngineSettings. + PdfViewerEnabled, True) + self.load(QUrl(initial_url)) diff --git a/soitool/setup_settings.py b/soitool/setup_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..3fef0c0c7d804eeff898b060f91a7e56ea68fa95 --- /dev/null +++ b/soitool/setup_settings.py @@ -0,0 +1,81 @@ +"""Includes the functionality to edit settings for SOI. + +This dialog is called when a button in soi_workspace_widget is pressed + +""" + +from PySide2.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, \ + QLabel, QLineEdit, QRadioButton, QPushButton + + +class Setup(QDialog): # pylint: disable = R0902 + """Contains the settings for the SOI.""" + + def __init__(self): # pylint: disable = R0915 + super().__init__() + self.layout_setup = QVBoxLayout() + self.layout_buttons = QHBoxLayout() + self.layout_header = QFormLayout() + self.layout_paper_orientation = QFormLayout() + self.layout_algorithm = QFormLayout() + self.layout_module_placement = QFormLayout() + + # Labels for headings + self.label_setup = QLabel("Oppsett") + # setup.setStyleSheet("font: 12pt") + self.label_header = QLabel("Headerdata") + self.label_paper_orientation = QLabel("Papirretning") + self.label_algorithm = QLabel("Plasseringsalgoritme") + self.label_module_placement = QLabel("Modulplassering") + + self.layout_setup.addWidget(self.label_setup) + + # Headerdata + self.layout_setup.addWidget(self.label_header) + self.head1 = QLabel("Header1") # Change variablename later + self.head2 = QLabel("Header2") # Change variablename later + self.hline1 = QLineEdit() # Change variablename later + self.hline2 = QLineEdit() # Change variablename later + self.layout_header.addRow(self.head1, self.hline1) + self.layout_header.addRow(self.head2, self.hline2) + self.layout_setup.addLayout(self.layout_header) + + # Paperorientation + self.layout_setup.addWidget(self.label_paper_orientation) + self.pob1 = QRadioButton() # Change variablename later + self.pob2 = QRadioButton() # Change variablename later + self.layout_paper_orientation.addRow(self.pob1, QLabel("Horisontal")) + self.layout_paper_orientation.addRow(self.pob2, QLabel("Vertikal")) + self.layout_setup.addLayout(self.layout_paper_orientation) + + # Placement algorithm + self.layout_setup.addWidget(self.label_algorithm) + self.pb1 = QRadioButton() # Change variablename later + self.pb2 = QRadioButton() # Change variablename later + self.pb3 = QRadioButton() # Change variablename later + self.pb4 = QRadioButton() # Change variablename later + self.layout_algorithm.addRow(self.pb1, QLabel("Alg1")) + self.layout_algorithm.addRow(self.pb2, QLabel("Alg2")) + self.layout_algorithm.addRow(self.pb3, QLabel("Alg3")) + self.layout_algorithm.addRow(self.pb4, QLabel("Alg4")) + self.layout_setup.addLayout(self.layout_algorithm) + + # Moduleplacement + self.layout_setup.addWidget(self.label_module_placement) + self.mb1 = QRadioButton() # Change variablename later + self.mb2 = QRadioButton() # Change variablename later + self.layout_module_placement.addRow(self.mb1, QLabel("Automatisk")) + self.layout_module_placement.addRow(self.mb2, QLabel("Manuelt")) + self.layout_setup.addLayout(self.layout_module_placement) + + # Exit-buttons + self.button_cancel = QPushButton("Avbryt") + self.button_save = QPushButton("Lagre") + self.layout_buttons.addWidget(self.button_cancel) + self.layout_buttons.addWidget(self.button_save) + self.layout_setup.addLayout(self.layout_buttons) + + self.setLayout(self.layout_setup) + + self.button_cancel.clicked.connect(self.reject) # esc-key (default) + self.button_save.clicked.connect(self.accept) # enter-key (default) diff --git a/soitool/soi.py b/soitool/soi.py new file mode 100644 index 0000000000000000000000000000000000000000..485185411fa1806a97262ded70972105e3459e9f --- /dev/null +++ b/soitool/soi.py @@ -0,0 +1,142 @@ +"""Includes datastructure used to represent a SOI.""" +from enum import Enum +from PySide2.QtCore import QPoint +from soitool.modules.module_table import TableModule + + +class ModuleType(Enum): + """Enumerate with types of modules.""" + + MAIN_MODULE = 0 + ATTACHMENT_MODULE = 1 + + +class SOI(): + """Temporary representation of SOI. + + Holds all info about an SOI necessary to view and edit it. + """ + + A4_RATIO = 1.414 + + # height must be adjusted to something that will look right when real + # wigets are placed inside the pages + HEIGHT = 1700 + WIDTH = HEIGHT * A4_RATIO + PADDING = 100 + CONTENT_WIDTH = WIDTH - PADDING * 2 + CONTENT_HEIGHT = HEIGHT - PADDING * 2 + HEADER_HEIGHT = 100 + MODULE_PADDING = 10 + + def __init__(self): + + # NOTE + # * test modules, just to have something show up on screen + # * not valid until reorganize has been run, as the positions must be + # updated + self.modules = [ + { + "widget": TableModule(), + "meta": { + "x": 0, + "y": 0, + "page": 1, + "name": 'Tabell1' + } + }, + { + "widget": TableModule(), + "meta": { + "x": 0, + "y": 0, + "page": 1, + "name": 'Tabell2' + } + }, + { + "widget": TableModule(), + "meta": { + "x": 0, + "y": 0, + "page": 2, + "name": 'Tabell3' + } + } + ] + + # NOTE + # test attachments, just to have something show up on screen + self.attachments = [ + { + "widget": TableModule(), + "meta": { + "x": 0, + "y": 0, + "page": 2, + "name": 'Tabell1' + } + } + ] + + self.reorganize() + + def update_module_widget_position(self, module): + """Update position of module widget based on meta position. + + This function is very much WIP.. + + Parameters + ---------- + module : see description + should be dict of fields "meta" and "widget", where "meta" is + itself a dict with fields "x", "y" and "page", and "widget" is a + widget based on "ModuleBase" + """ + distance_to_start_of_next_soi_content_y = self.CONTENT_HEIGHT + \ + self.PADDING * 2 + self.HEADER_HEIGHT + + scene_skip_distance_page_height = \ + distance_to_start_of_next_soi_content_y * \ + (module["meta"]["page"] - 1) + + new_x = module["meta"]["x"] + self.PADDING + new_y = module["meta"]["y"] + self.PADDING + self.HEADER_HEIGHT + \ + scene_skip_distance_page_height + + module["widget"].set_pos(QPoint(new_x, new_y)) + + def reorganize(self): + """Update x,y positions of modules. + + In the future this is where rectpack will do it's magic, for now it is + a WIP just to get some kind of positioning working. The positioning + scheme for now is as follows: for each page 1 and 2, position widgets + next to each other from left to right + """ + x = self.MODULE_PADDING + first_page_modules = [module for module in self.modules + if module["meta"]["page"] == 1] + + for module in first_page_modules: + module["meta"]["x"] = x + module["meta"]["y"] = self.MODULE_PADDING + + self.update_module_widget_position(module) + + widget_width, _ = module["widget"].get_size() + x = x + self.MODULE_PADDING + widget_width + + # NOTE the following is simply duplicated.. left like this to KISS + # will be replaced by rectpack anyways + x = self.MODULE_PADDING + second_page_modules = [module for module in self.modules + if module["meta"]["page"] == 2] + for module in second_page_modules: + module["meta"]["x"] = x + module["meta"]["y"] = self.MODULE_PADDING + + self.update_module_widget_position(module) + + widget_width, _ = module["widget"].get_size() + x = x + self.MODULE_PADDING + widget_width diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..76b5531b35690ce19984de8cdd938edd232b8bb6 --- /dev/null +++ b/soitool/soi_workspace_widget.py @@ -0,0 +1,61 @@ +"""Includes functionality to edit and view a single SOI. + +Meant for use inside of tabs in our program. + +""" +from PySide2.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QPushButton, \ + QLabel +from soitool.soi import SOI, ModuleType +from soitool.module_list import ModuleList +from soitool.inline_editable_soi_view import InlineEditableSOIView +from soitool.setup_settings import Setup + + +class SOIWorkspaceWidget(QWidget): + """Contains the working area for a single SOI. + + The widget is used inside tabs in our application, and contains a sidebar + with a module list along with a view of the SOI. + """ + + def __init__(self): + super().__init__() + + self.soi = SOI() + + self.layout_wrapper = QHBoxLayout() + self.layout_sidebar = QVBoxLayout() + + # all widgets + self.button_new_module = QPushButton("Ny modul") + self.button_new_module.setShortcut("Ctrl+m") + self.button_new_module.setStatusTip("Legg til en ny modul") + self.button_setup = QPushButton("Oppsett") + + self.list_modules = ModuleList(ModuleType.MAIN_MODULE, self) + self.list_attachments = ModuleList(ModuleType.ATTACHMENT_MODULE, self) + + self.view = InlineEditableSOIView(self.soi) + self.widget_sidebar = QWidget() + self.widget_sidebar.setFixedWidth(200) + self.popup = Setup() + + # build layouts + self.layout_sidebar.addWidget(QLabel("Moduler:")) + self.layout_sidebar.addWidget(self.list_modules, 5) + self.layout_sidebar.addWidget(QLabel("Vedlegg:")) + self.layout_sidebar.addWidget(self.list_attachments, 1) + self.layout_sidebar.addWidget(self.button_new_module) + self.layout_sidebar.addWidget(self.button_setup) + self.widget_sidebar.setLayout(self.layout_sidebar) + self.layout_wrapper.addWidget(self.widget_sidebar) + self.layout_wrapper.addWidget(self.view) + + self.setLayout(self.layout_wrapper) + + self.button_setup.clicked.connect(self.open_setup) + + def open_setup(self): + """Open setup_settings.""" + self.popup.setGeometry(150, 150, 200, 200) + self.popup.exec() # exec = modal dialog, show = modeless dialog diff --git a/test/test_main.py b/test/test_main.py index beadccfe5991936ceba67b155d69db315ddf1414..3255793a528443b4115c6ffccb8e029aeec7f9ab 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,9 +1,8 @@ """Test CoolWidget.""" import unittest -import sys from datetime import datetime as datetime_, timedelta -from PySide2 import QtWidgets, QtTest, QtCore +from PySide2 import QtWidgets, QtTest, QtCore, QtGui from soitool import main # references: @@ -18,8 +17,10 @@ from soitool import main # * keyclick: https://programtalk.com/python-examples/PyQt4.QtTest.QTest.keyClick/ # * isVisible: https://stackoverflow.com/questions/13850240/pyqt-how-to-check-is-qdialog-is-visible -# moved here from setUp to avoid annoying startup messages -app = QtWidgets.QApplication(sys.argv) +if isinstance(QtGui.qApp, type(None)): + app = QtWidgets.QApplication([]) +else: + app = QtGui.qApp def wait(msec): @@ -59,13 +60,19 @@ class TestMain(unittest.TestCase): """Test at endring av tekst funker.""" def change_text_and_ok(): - while self.widget.dlg_input is None: - app.processEvents() - child_line_edit = self.widget.dlg_input.findChild(QtWidgets.QLineEdit) + # in PySide2 we need to store a reference to this. If we don't the + # widget is garbage collected somehow before we get to use + # child_line_edit (a child of the active widget) + active_widget = app.activeModalWidget() + + child_line_edit = active_widget.findChild( + QtWidgets.QLineEdit + ) + QtTest.QTest.keyClicks(child_line_edit, self.test_text2) QtTest.QTest.keyClick(child_line_edit, QtCore.Qt.Key_Enter) - QtCore.QTimer.singleShot(100, change_text_and_ok) + QtCore.QTimer.singleShot(0, change_text_and_ok) QtTest.QTest.mouseClick(self.widget.button, QtCore.Qt.LeftButton) self.assertEqual( diff --git a/test/test_main_window.py b/test/test_main_window.py new file mode 100644 index 0000000000000000000000000000000000000000..5e6290c7ac066f54a4cf05b4192380b956f4393f --- /dev/null +++ b/test/test_main_window.py @@ -0,0 +1,35 @@ +"""Test main_window.py.""" + +import unittest +from PySide2 import QtWidgets, QtGui +from soitool import main_window + +if isinstance(QtGui.qApp, type(None)): + app = QtWidgets.QApplication([]) +else: + app = QtGui.qApp + + +class TestMainWindow(unittest.TestCase): + """Test class for main_window.py.""" + + def setUp(self): + """Set up main_window_widget for testing.""" + self.test_mw = main_window.MainWindow() + + def test_window_title(self): + """Test window title is correct.""" + expected = "SOI-tool" + actual = self.test_mw.windowTitle() + self.assertEqual(expected, actual) + + # To smoke test SOIWorkspaceWidget + def test_new_module_btn(self): + """Test correct label on new module button.""" + expected = "Ny modul" + actual = self.test_mw.tabs.currentWidget().button_new_module.text() + self.assertEqual(expected, actual) + + +if __name__ == '__main__': + unittest.main()