diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index e43fcfda6b341421e3265929ef5c1fdd01f70b7c..e56d498a095c42df4ccf139e0225b010747fdb97 100644 --- a/soitool/inline_editable_soi_view.py +++ b/soitool/inline_editable_soi_view.py @@ -59,7 +59,7 @@ class InlineEditableSOIView(QScrollArea): self.soi = soi - self.number_of_pages = 0 + self.number_of_pages = 1 self.proxies = set() # necessary to make the scroll area fill the space it's given @@ -75,8 +75,9 @@ class InlineEditableSOIView(QScrollArea): self.ensure_proxies() self.update_pages() - # update pages after SOI is reorganized + # add listeners to react properly to SOI changes self.soi.add_reorganization_listener(self.update_pages) + self.soi.add_new_module_listener(self.ensure_proxies) # self.launch_auto_zoom() @@ -135,8 +136,11 @@ class InlineEditableSOIView(QScrollArea): painter.end() def update_number_of_pages(self): - """Make sure number of pages excactly fits SOI modules.""" - required_pages = 0 + """Make sure number of pages excactly fits SOI modules. + + The minimum page count is 1. + """ + required_pages = 1 for module in self.soi.modules: if module["meta"]["page"] > required_pages: required_pages += 1 diff --git a/soitool/media/placeholder.png b/soitool/media/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6e7755d9ae4445afbca21af810d78ab139025a Binary files /dev/null and b/soitool/media/placeholder.png differ diff --git a/soitool/media/tablemodule.png b/soitool/media/tablemodule.png new file mode 100644 index 0000000000000000000000000000000000000000..bc9d143b46ea013cc6bc0c1a06873ec09952bfdc Binary files /dev/null and b/soitool/media/tablemodule.png differ diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index 630cdaeb4c5e7b982b52b49413cbde406abedb74..82b78aa26966556f35c77ebfb0d3fd49f82bf37c 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -16,3 +16,13 @@ class ModuleBase(ABC): def render_onto_pdf(self): """Abstract method, should be implemented by derived class.""" raise NotImplementedError + + @staticmethod + def get_user_friendly_name(): + """Abstract method, should be implemented by derived class.""" + raise NotImplementedError + + @staticmethod + def get_icon(): + """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 e5478ef547fb7f5cec916aab6afd365bc12ab8b4..0a0389fa4df3006c16ccb0b5fdb291226701b79f 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -1,6 +1,7 @@ """Module containing subclassed SOIModule (QTableWidget, ModuleBase).""" from PySide2.QtWidgets import QTableWidget, QTableWidgetItem from PySide2 import QtGui, QtCore +from PySide2.QtGui import QIcon from soitool.modules.module_base import ModuleBase HEADER_FONT = QtGui.QFont() @@ -181,3 +182,13 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): def render_onto_pdf(self): """Render onto pdf.""" + + @staticmethod + def get_user_friendly_name(): + """Get user-friendly name of module.""" + return "Generisk tabell" + + @staticmethod + def get_icon(): + """Get icon of module.""" + return QIcon("soitool/media/tablemodule.png") diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..2138c97961ac469ca49f4e20ff2aa1473815edb3 --- /dev/null +++ b/soitool/new_module_dialog.py @@ -0,0 +1,140 @@ +"""Provides a dialog where the user can select new module to be added.""" + +from PySide2.QtWidgets import ( + QHBoxLayout, + QVBoxLayout, + QPushButton, + QDialog, + QListWidget, + QLineEdit, + QListWidgetItem, + QLabel, + QCheckBox, +) +from PySide2.QtCore import QSize, Qt +from PySide2.QtGui import QIcon +from soitool.modules.module_table import TableModule +from soitool.modules.module_base import ModuleBase + + +class ModulePlaceholder(ModuleBase): + """Dummy module used only to fill dialog with content while developing.""" + + def set_pos(self, pos): + """Not used.""" + raise NotImplementedError + + def get_size(self): + """Not used.""" + raise NotImplementedError + + def render_onto_pdf(self): + """Not used.""" + raise NotImplementedError + + @staticmethod + def get_user_friendly_name(): + """Get placeholder name.""" + return "Modul" + + @staticmethod + def get_icon(): + """Get standard placeholder icon.""" + return QIcon("soitool/media/placeholder.png") + + +# Constant holding all modules the user can choose from. This is intended as a +# point of extensibility. Further modules that are developed can simply be +# placed here, and the rest of the program will respect them. +MODULE_CHOICES = [ + TableModule, + ModulePlaceholder, + ModulePlaceholder, + ModulePlaceholder, +] + + +class NewModuleDialogListItem(QListWidgetItem): + """Custom list item that includes a widget implementation of a module. + + Parameters + ---------- + widget : subclass of ModuleBase + The widget that implements the module. + """ + + def __init__(self, widget): + super().__init__(widget.get_icon(), widget.get_user_friendly_name()) + self.widget_implementation = widget + + +class NewModuleDialog(QDialog): + """Dialog to let user select module to be added to the SOI. + + When the dialog is closed the user's choice is fetched by directly + inspecting the child widgets, for example: `self.list_module_choices` and + `self.line_edit_name`. The dialog is otherwise used excactly as it's + superclass QDialog: https://doc.qt.io/qt-5/qdialog.html + + Parameters + ---------- + module_choices : list of subclasses of ModuleBase + list of modules the user can choose from + """ + + def __init__(self, module_choices=None): + super().__init__() + self.setWindowTitle("Ny modul") + + if module_choices is None: + module_choices = MODULE_CHOICES + + self.button_add = QPushButton("Legg til") + self.button_cancel = QPushButton("Avbryt") + + self.button_add.clicked.connect(self.accept) + self.button_cancel.clicked.connect(self.reject) + + self.layout_buttons = QHBoxLayout() + self.layout_buttons.addWidget(self.button_add) + self.layout_buttons.addWidget(self.button_cancel) + + self.list_module_choices = QListWidget() + self.list_module_choices.setViewMode(QListWidget.IconMode) + self.list_module_choices.setIconSize(QSize(100, 100)) + self.list_module_choices.setResizeMode(QListWidget.Adjust) + + for i, module_choice in enumerate(module_choices): + self.list_module_choices.insertItem( + i, NewModuleDialogListItem(module_choice), + ) + + # set the first element as the default choice, whatever it is + self.list_module_choices.item(0).setSelected(True) + + self.line_edit_name = QLineEdit() + self.line_edit_name.setPlaceholderText( + "La stå tom for automatisk navn" + ) + self.label_name = QLabel("Navn:") + + self.layout_name = QHBoxLayout() + self.layout_name.addWidget(self.label_name) + self.layout_name.addWidget(self.line_edit_name) + + self.label_attachment = QLabel("Vedlegg:") + + self.checkbox_attachment = QCheckBox() + + self.layout_attachment = QHBoxLayout() + self.layout_attachment.setAlignment(Qt.AlignLeft) + self.layout_attachment.addWidget(self.label_attachment) + self.layout_attachment.addWidget(self.checkbox_attachment) + + self.layout_wrapper = QVBoxLayout() + self.layout_wrapper.addWidget(self.list_module_choices) + self.layout_wrapper.addLayout(self.layout_name) + self.layout_wrapper.addLayout(self.layout_attachment) + self.layout_wrapper.addLayout(self.layout_buttons) + + self.setLayout(self.layout_wrapper) diff --git a/soitool/soi.py b/soitool/soi.py index bfcc1c27ac109859860e2cb8955afe0c4aaaa315..a8c340fc7a670b06a8074224d3eecb2a29459280 100644 --- a/soitool/soi.py +++ b/soitool/soi.py @@ -111,6 +111,10 @@ class ModuleLargerThanBinError(Exception): """At least one module is too large for the bin during rectpack packing.""" +class ModuleNameTaken(Exception): + """Module name is already taken by an existing module.""" + + class ModuleType(Enum): """Enumerate with types of modules.""" @@ -309,8 +313,10 @@ class SOI: self.algorithm_pack = algorithm_pack self.algorithm_sort = algorithm_sort - # functions to call after reorganization + # prepare listener lists: list of functions to call after certain + # events happen self.reorganization_listeners = [] + self.new_module_listeners = [] self.reorganize() @@ -322,6 +328,14 @@ class SOI: """ self.reorganization_listeners.append(function) + def add_new_module_listener(self, function): + """Add function to list of functions to be called after added module. + + This is useful for users of this class to handle changes to the SOI. + As an example a class displaying an SOI can be updated after changes. + """ + self.new_module_listeners.append(function) + def update_module_widget_position(self, module): """Update position of module widget based on meta position. @@ -466,3 +480,44 @@ class SOI: corresponding_module["meta"]["y"] = packed_rect_y corresponding_module["meta"]["page"] = packed_rect_bin + 1 self.update_module_widget_position(corresponding_module) + + def module_name_taken(self, name): + """Return True if module with name exists, False otherwise.""" + for module in self.modules + self.attachments: + if name == module["meta"]["name"]: + return True + return False + + def add_module(self, name, widget_implementation, is_attachment=False): + """Add module to SOI, reorganize it, and notify listeners. + + This function raises an exception if the given name is taken + + Parameters + ---------- + name : str + Name of new module + widget_implementation : subclass of ModuleBase + The widget implementation of the module. + is_attachment : bool + True if the module should be added as an attachment. False + otherwise + """ + if self.module_name_taken(name): + raise ModuleNameTaken + + # NOTE that "x", "y" and "page" under "meta" are not valid until + # self.reorganize() has run + to_add = { + "meta": {"x": 0, "y": 0, "page": 0, "name": name}, + "widget": widget_implementation, + } + if is_attachment: + self.attachments.append(to_add) + else: + self.modules.append(to_add) + + self.reorganize() + # call listener functions + for listener in self.new_module_listeners: + listener() diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py index 4c00e20aaaa6232bf75b7364505741b7bbdb8714..d2c929d84443a27ed08ff11a475de7d472d53ac1 100644 --- a/soitool/soi_workspace_widget.py +++ b/soitool/soi_workspace_widget.py @@ -9,12 +9,14 @@ from PySide2.QtWidgets import ( QVBoxLayout, QPushButton, QLabel, + QDialog, ) -from soitool.soi import SOI, ModuleType +from soitool.soi import SOI, ModuleType, ModuleNameTaken from soitool.module_list import ModuleList from soitool.inline_editable_soi_view import InlineEditableSOIView from soitool.setup_settings import Setup -from soitool.modules.module_table import TableModule +from soitool.new_module_dialog import NewModuleDialog, ModulePlaceholder +from soitool.dialog_wrappers import exec_warning_dialog class SOIWorkspaceWidget(QWidget): @@ -27,39 +29,7 @@ class SOIWorkspaceWidget(QWidget): def __init__(self): super().__init__() - # NOTE - # * test modules, just to have something show up on screen - # * not valid until reorganize has been run, as the positions must be - # updated - initial_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 - initial_attachments = [ - { - "widget": TableModule(), - "meta": {"x": 0, "y": 0, "page": 2, "name": "Tabell1"}, - } - ] - - self.soi = SOI( - modules=initial_modules, attachments=initial_attachments - ) - - self.soi.reorganize() + self.soi = SOI() self.layout_wrapper = QHBoxLayout() self.layout_sidebar = QVBoxLayout() @@ -92,8 +62,68 @@ class SOIWorkspaceWidget(QWidget): self.setLayout(self.layout_wrapper) self.button_setup.clicked.connect(self.open_setup) + self.button_new_module.clicked.connect(self.new_module) def open_setup(self): """Open setup_settings.""" self.popup.setGeometry(150, 150, 200, 200) self.popup.exec() # exec = modal dialog, show = modeless dialog + + def new_module(self): + """Add new module to SOI using new_module dialog.""" + new_module_dialog = NewModuleDialog() + new_module_dialog.resize(350, 350) + dialogcode = new_module_dialog.exec() + + if dialogcode == QDialog.DialogCode.Accepted: + + # fetch user's choices from the dialog + chosen_module = new_module_dialog.list_module_choices.currentItem() + module_choice = chosen_module.text() + module_name = new_module_dialog.line_edit_name.text() + module_widget_implementation = chosen_module.widget_implementation + is_attachment = new_module_dialog.checkbox_attachment.isChecked() + + if module_widget_implementation is ModulePlaceholder: + exec_warning_dialog( + text="Modulen ble ikke lagt til.", + informative_text="Den valgte modulen er ikke " + "implementert. Modulen er trolig bare valgbar for å fylle " + "ut valgene til flere moduler er implementert.", + ) + else: + # no module name means the user expects one to be generated + # autogenerated name is not meant to be pretty, it's just meant + # to be unique + if not module_name: + module_name = "{} {}".format( + module_choice, + str( + len(self.soi.modules) + + len(self.soi.attachments) + + 1 + ), + ) + + try: + self.soi.add_module( + module_name, + module_widget_implementation(), + is_attachment, + ) + except ModuleNameTaken: + exec_warning_dialog( + text="Modulen ble ikke lagt til.", + informative_text="Navnet du valgte er allerede i " + "bruk. Modulnavn må være unike. Velg et unikt " + "modulnavn, eller la programmet lage et navn " + "automatisk.", + ) + + elif dialogcode == QDialog.DialogCode.Rejected: + pass + else: + raise Exception( + f"Unknown QDialog.DialogCode returned from " + "NewModuleDialog: '{dialogcode}'" + ) diff --git a/test/test_dialog_wrappers.py b/test/test_dialog_wrappers.py index ba0cbca7f49f16deef915170a8c7be1d8ea3bd9d..d67e398e624b5e8fbec40c30a60e519322a541a5 100644 --- a/test/test_dialog_wrappers.py +++ b/test/test_dialog_wrappers.py @@ -1,4 +1,4 @@ -"""Test functions in test_dialog_wrappers.py. +"""Test functions in dialog_wrappers.py. Tests are performed by launching the dialogs and inspecting their icons. To test the dialogs we need to 'load' our testing function using @@ -32,7 +32,7 @@ class TestDialogWrappers(unittest.TestCase): """Assert icon of current dialog and close it by pressing enter.""" active_widget = app.activeModalWidget() self.assertEqual(icon, active_widget.icon()) - QTest.keyClick(app.activeModalWidget(), Qt.Key_Enter) + QTest.keyClick(active_widget, Qt.Key_Enter) def test_qmessagebox_wrapper(self): """Test the general QMessageBox wrapper.""" diff --git a/test/test_new_module_dialog.py b/test/test_new_module_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..e4934fceace445787100dbdbba72dfb4911171cb --- /dev/null +++ b/test/test_new_module_dialog.py @@ -0,0 +1,166 @@ +"""Test dialog in new_module_dialog.py.""" + +import unittest +from PySide2 import QtGui +from PySide2.QtGui import QIcon +from PySide2.QtWidgets import QApplication, QDialog +from PySide2.QtCore import QTimer, Qt +from PySide2.QtTest import QTest +from soitool.new_module_dialog import NewModuleDialog +from soitool.modules.module_base import ModuleBase + + +class SimpleTestModule(ModuleBase): + """Simple module used to test NewModuleDialog only.""" + + name = "" + + def set_pos(self, pos): + """Not used.""" + raise NotImplementedError + + def render_onto_pdf(self): + """Not used.""" + raise NotImplementedError + + def get_size(self): + """Not used.""" + raise NotImplementedError + + @classmethod + def get_user_friendly_name(cls): + """Get placeholder name.""" + return cls.name + + @staticmethod + def get_icon(): + """Get standard placeholder icon.""" + return QIcon("soitool/media/placeholder.png") + + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestNewModuleDialog(unittest.TestCase): + """TestCase for NewModuleDialog class.""" + + def test_starts_up(self): + """Test that the dialog can start. + + Also tests whether dialog can be closed with the enter key. + """ + dialog = NewModuleDialog() + + def assert_window_title_of_active_dialog(): + active_widget = app.activeModalWidget() + self.assertEqual("Ny modul", active_widget.windowTitle()) + QTest.keyClick(active_widget, Qt.Key_Enter) + + QTimer.singleShot(0, assert_window_title_of_active_dialog) + dialog.exec_() + + def test_dialog_code(self): + """Test that the dialog returns dialog codes as expected.""" + # prepare all nested functions + def press_enter_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Enter) + + def press_escape_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Escape) + + def press_add_button_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.mouseClick(active_widget.button_add, Qt.LeftButton) + + def press_cancel_button_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton) + + # perform tests + dialog = NewModuleDialog() + QTimer.singleShot(0, press_enter_on_active_dialog) + dialogcode = dialog.exec_() + self.assertEqual(dialogcode, QDialog.DialogCode.Accepted) + + dialog = NewModuleDialog() + QTimer.singleShot(0, press_escape_on_active_dialog) + dialogcode = dialog.exec_() + self.assertEqual(dialogcode, QDialog.DialogCode.Rejected) + + dialog = NewModuleDialog() + QTimer.singleShot(0, press_add_button_on_active_dialog) + dialogcode = dialog.exec_() + self.assertEqual(dialogcode, QDialog.DialogCode.Accepted) + + dialog = NewModuleDialog() + QTimer.singleShot(0, press_cancel_button_on_active_dialog) + dialogcode = dialog.exec_() + self.assertEqual(dialogcode, QDialog.DialogCode.Rejected) + + def test_full_selection_scenario(self): + """Test full usage of the module.""" + TestModule1 = SimpleTestModule + TestModule1.name = "test1" + TestModule2 = SimpleTestModule + TestModule2.name = "test2" + module_choices = [TestModule1, TestModule2] + + module_to_choose = 1 + + name_to_input = "myname" + + def perform_selection_and_add(): + """Perform selection and accept. + + Select second module, give name, check the attachment checkbox and + click add. + """ + # below code is based on code written when we were learning Qt: + # https://gitlab.stud.iie.ntnu.no/bachelor-paa-bittet/learning/blob/master/gui_nikko/test_widgets.py#L46 + active_widget = app.activeModalWidget() + + model_index = active_widget.list_module_choices.model().index( + module_to_choose, 0 + ) + item_point = active_widget.list_module_choices.visualRect( + model_index + ).center() + + QTest.mouseClick( + active_widget.list_module_choices.viewport(), + Qt.LeftButton, + Qt.NoModifier, + item_point, + ) + + QTest.keyClicks(active_widget.line_edit_name, name_to_input) + QTest.mouseClick(active_widget.checkbox_attachment, Qt.LeftButton) + QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton) + + dialog = NewModuleDialog(module_choices=module_choices) + QTimer.singleShot(0, perform_selection_and_add) + dialog.exec() + + # assert dialog was successfully filled + + self.assertEqual( + name_to_input, dialog.line_edit_name.text(), + ) + self.assertEqual( + True, dialog.checkbox_attachment.isChecked(), + ) + + module_choice = dialog.list_module_choices.currentItem() + self.assertEqual( + module_choices[module_to_choose], + module_choice.widget_implementation, + ) + self.assertEqual( + module_choices[module_to_choose].get_user_friendly_name(), + module_choice.text(), + ) diff --git a/test/test_soi.py b/test/test_soi.py index af49960134881835af154a5a5948aec1dd824196..b1223b4b60e10a16aa109abac9b7b4fb49c392a8 100644 --- a/test/test_soi.py +++ b/test/test_soi.py @@ -15,7 +15,7 @@ import unittest from PySide2.QtWidgets import QApplication, QWidget from PySide2 import QtGui from PySide2.QtGui import QPalette, QColor -from soitool.soi import SOI, ModuleLargerThanBinError +from soitool.soi import SOI, ModuleLargerThanBinError, ModuleNameTaken from soitool.modules.module_base import ModuleBase if isinstance(QtGui.qApp, type(None)): @@ -293,3 +293,64 @@ class TestSOI(unittest.TestCase): self.assertEqual( expected_y, self.soi.modules[0]["widget"].pos().y(), ) + + def test_add_module(self): + """Test add module and reorganize and add module listeners.""" + reorganize_listener_called = False + new_module_listener_called = False + + def listener_reorganize(): + # nonlocal is required to access variable in nesting function's + # scope + nonlocal reorganize_listener_called + reorganize_listener_called = True + + def listener_new_module(): + # nonlocal is required to access variable in nesting function's + # scope + nonlocal new_module_listener_called + new_module_listener_called = True + + self.soi.add_reorganization_listener(listener_reorganize) + self.soi.add_reorganization_listener(listener_new_module) + + module_name = "add module test name" + self.soi.add_module(module_name, TestModule("purple", 100, 100), True) + self.assertTrue(self.soi.module_name_taken(module_name)) + + self.assertTrue(reorganize_listener_called) + self.assertTrue(new_module_listener_called) + + def test_add_module_duplicate_name(self): + """Test adding module with duplicate name raises exception.""" + # Using already added module TEST_MODULES[0] to guarantee name exists + self.assertRaises( + ModuleNameTaken, + lambda: self.soi.add_module( + TEST_MODULES[0]["meta"]["name"], + TestModule("purple", 100, 100), + False, + ), + ) + self.assertRaises( + ModuleNameTaken, + lambda: self.soi.add_module( + TEST_MODULES[0]["meta"]["name"], + TestModule("purple", 100, 100), + True, + ), + ) + + # test that unique name doesn't raise exception + try: + self.soi.add_module( + "unique name", TestModule("purple", 100, 100), True + ) + except ModuleNameTaken: + self.fail("Exception should not be raised when name is unique") + try: + self.soi.add_module( + "another unique name", TestModule("purple", 100, 100), False + ) + except ModuleNameTaken: + self.fail("Exception should not be raised when name is unique")