diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index 93941ece68f28062a99d8a57d0eb5d8795ceb46c..8a6c9a85e417cb5b00e799a00efb341e2390328f 100644 --- a/soitool/inline_editable_soi_view.py +++ b/soitool/inline_editable_soi_view.py @@ -1,4 +1,5 @@ """Includes functionality for inline editing of SOI.""" +import string from datetime import datetime from PySide2.QtCore import Qt, QRectF, QTimer, QPoint, QMarginsF from PySide2.QtWidgets import ( @@ -15,6 +16,11 @@ from soitool.soi import ModuleLargerThanBinError from soitool.dialog_wrappers import exec_warning_dialog from soitool.serialize_export_import_soi import generate_soi_filename +# How attachment pages should be numbered. The first page should be numbered +# by the value of ATTACHMENT_NUMBERING_SCHEME[0], the second page +# ATTACHMENT_NUMBERING_SCHEME[1], and so on. +ATTACHMENT_NUMBERING_SCHEME = list(string.ascii_uppercase) + class InlineEditableSOIView(QScrollArea): """Widget som kan byttes ut med view, edit etc.""" @@ -28,7 +34,7 @@ class InlineEditableSOIView(QScrollArea): def ensure_proxies(self): """Make sure all modules of the SOI have a proxy inside the scene.""" - for module in self.soi.modules: + for module in self.soi.modules + self.soi.attachments: if not self.is_widget_in_scene(module["widget"]): proxy = self.scene.addWidget(module["widget"]) self.proxies.add(proxy) @@ -61,7 +67,11 @@ class InlineEditableSOIView(QScrollArea): self.soi = soi - self.number_of_pages = 1 + # The following variables are updated by a call to `update_pages` later + # in this `__init__`. Therefore the values given here are in practice + # never used + self.number_of_pages_total = 1 + self.number_of_non_attachment_pages = 1 self.proxies = set() # Necessary to make the scroll area fill the space it's given @@ -132,7 +142,7 @@ class InlineEditableSOIView(QScrollArea): ) # Render each page to own PDF page - for i in range(self.number_of_pages): + for i in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * i + self.soi.PADDING * i @@ -143,7 +153,7 @@ class InlineEditableSOIView(QScrollArea): ) # If there are more pages, newPage - if i + 1 < self.number_of_pages: + if i + 1 < self.number_of_pages_total: printer.newPage() finally: painter.end() @@ -153,15 +163,18 @@ class InlineEditableSOIView(QScrollArea): The minimum page count is 1. """ - required_pages = 1 - for module in self.soi.modules: - if module["meta"]["page"] > required_pages: - required_pages += 1 - self.number_of_pages = required_pages + self.number_of_non_attachment_pages = ( + self.soi.get_number_of_non_attachment_pages() + ) + # Each attachment module requires it's own page + required_pages = self.number_of_non_attachment_pages + len( + self.soi.attachments + ) + self.number_of_pages_total = required_pages def draw_pages(self): - """Draw self.number_of_pages pages.""" - for i in range(self.number_of_pages): + """Draw self.number_of_pages_total pages.""" + for i in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * i + self.soi.PADDING * i @@ -173,7 +186,20 @@ class InlineEditableSOIView(QScrollArea): ) self.draw_page(x, y) - self.draw_header(x + self.soi.PADDING, y + self.soi.PADDING, i + 1) + page_number = i + 1 + header_x = x + self.soi.PADDING + header_y = y + self.soi.PADDING + # If not an attachment page: draw as normal + # If attachment page: draw with page number starting from 1 again + if page_number <= self.number_of_non_attachment_pages: + self.draw_header(header_x, header_y, page_number, False) + else: + self.draw_header( + header_x, + header_y, + page_number - self.number_of_non_attachment_pages, + True, + ) for proxy in self.proxies: # Redraw of pages requires modules to be moved to front again @@ -188,7 +214,7 @@ class InlineEditableSOIView(QScrollArea): # "Too many statements" for this function because it's doing GUI layout # work, which in nature is tedious and repetitive. # pylint: disable=R0914,R0915 - def draw_header(self, x, y, page_number): + def draw_header(self, x, y, page_number, is_attachment_page): """Draw header staring at given position. Source for rotation approach: @@ -198,6 +224,15 @@ class InlineEditableSOIView(QScrollArea): ---------- x : int y : int + page_number : int + Page number of page to draw. 'is_attachment_page' affects how the + page number is drawn. + is_attachment_page : bool + If True the page will indicate that it is an attachment, and the + page numbering will follow a convention for page numbering defined + in 'ATTACHMENT_NUMBERING_SCHEME'. If False the page will indicate + that the page is part of the main page, and the page numbering will + use 1,2,3,.... """ # Title label_title = QLabel(self.soi.title) @@ -254,7 +289,7 @@ class InlineEditableSOIView(QScrollArea): ) # Store for usage when placing copy number title and page number copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 - copy_number.move(x + 18, copy_number_y_pos + label_width / 2) + copy_number.move(x + 15, copy_number_y_pos + label_width / 2) proxy = self.scene.addWidget(copy_number) proxy.setRotation(-90) @@ -276,9 +311,18 @@ class InlineEditableSOIView(QScrollArea): proxy.setRotation(-90) # Page numbering - page_number = QLabel( - "Side {} av {}".format(page_number, self.number_of_pages) - ) + if is_attachment_page: + page_number = QLabel( + "Vedlegg {}".format( + ATTACHMENT_NUMBERING_SCHEME[page_number - 1] + ) + ) + else: + page_number = QLabel( + "Side {} av {}".format( + page_number, self.number_of_non_attachment_pages + ) + ) page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") page_number.setFont(QFont("Times New Roman", 15)) # Source: https://stackoverflow.com/a/8638114/3545896 @@ -286,7 +330,7 @@ class InlineEditableSOIView(QScrollArea): label_width = ( page_number.fontMetrics().boundingRect(page_number.text()).width() ) - page_number.move(x + 85, copy_number_y_pos + label_width / 2) + page_number.move(x + 80, copy_number_y_pos + label_width / 2) proxy = self.scene.addWidget(page_number) proxy.setRotation(-90) diff --git a/soitool/module_list.py b/soitool/module_list.py index 64c62e84200bf56fad6bb145357433b1bf463fff..c30fbcd636d733869a9c0b943ce7807c9c8fde4b 100644 --- a/soitool/module_list.py +++ b/soitool/module_list.py @@ -15,7 +15,7 @@ class ModuleList(QListWidget): 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. + Makes changes in soitool.SOI lists through its soi property. Parameters ---------- @@ -32,9 +32,9 @@ class ModuleList(QListWidget): if not isinstance(soi, SOI): raise RuntimeError( - "Only soitool.SOIWorkspaceWidget is " - "acceptable type for parent-variable " - "in class Module_list." + "Only soitool.SOI is " + "acceptable type for the soi property " + "in class ModuleList." ) self.type = module_type self.soi = soi @@ -142,7 +142,8 @@ class ModuleList(QListWidget): """Notify parent when an element is drag-and-dropped. Note that if the SOI is not prepared for manual priorization an error - message will be displayed, and nothing else will be done. + message will be displayed, and nothing else will be done. This only + applies to uses of this class with non-attachment modules. https://doc.qt.io/qt-5/qabstractitemview.html#dropEvent. @@ -151,7 +152,10 @@ class ModuleList(QListWidget): event : QDropEvent Is sent to super(). """ - if self.soi.algorithm_sort != "none": + if ( + self.soi.algorithm_sort != "none" + and ModuleType(self.type) == ModuleType.MAIN_MODULE + ): exec_warning_dialog( text="SOI er ikke innstilt for automatisk plassering av " "moduler!", diff --git a/soitool/soi.py b/soitool/soi.py index fcb67a2360e13a1ce094bcf7e864079ffd2494ed..bb776a51b5b81ea6c7c6fc1ead7a0fb573952935 100644 --- a/soitool/soi.py +++ b/soitool/soi.py @@ -469,10 +469,42 @@ class SOI: self.placement_strategy ) ) + self.reorganize_attachments() # Call listener functions for listener in self.reorganization_listeners: listener() + def reorganize_attachments(self): + """Reorganize attachments. Each on own page in correct order. + + Order taken from order in attachment list directly. Attachments appear + at the top-left corner always. + """ + for i, attachment in enumerate(self.attachments): + first_attachment_page = ( + self.get_number_of_non_attachment_pages() + 1 + ) + attachment["meta"]["x"] = 0 + attachment["meta"]["y"] = 0 + attachment["meta"]["page"] = first_attachment_page + i + self.update_module_widget_position(attachment) + + def get_number_of_non_attachment_pages(self): + """Calculate how many pages non-attachment modules require. + + The minimum page count is 1. + + Returns + ------- + int + Number of pages required for non-attachment modules. + """ + pages = 1 + for module in self.modules: + if module["meta"]["page"] > pages: + pages += 1 + return pages + def get_rectpack_packer(self): """Return rectpack packer set up for this SOI. diff --git a/test/test_serialize_export_import.py b/test/test_serialize_export_import.py index 3a9e20815cf18a62ae65564c8731f17ffc68e838..143c31635110ed0a69270a28e3124aba99960d80 100644 --- a/test/test_serialize_export_import.py +++ b/test/test_serialize_export_import.py @@ -14,6 +14,12 @@ from soitool.serialize_export_import_soi import ( SERIALIZED_SOI_SCHEMA, ) +# The error being ignored here is pylint telling us that 'test' is a standard +# module, so the import should be placed further up. In our case we have an +# actual custom module called 'test', so pylint is confused. +# pylint: disable=C0411 +from test.test_soi import deep_copy_of_modules_list + app = QApplication.instance() if app is None: app = QApplication([]) @@ -52,6 +58,27 @@ MODULES = [ ] +# We'd like to use the same list of modules for both main and attachment +# modules, so we need this to help make a copy of widgets. Otherwise main and +# attachment modules would be referencing the same underlying widgets, as Qt +# does not support cloning of QWidgets by default. +def tablemodule_cloner(module): + """Return new TableModule that is a clone of parameter module. + + Parameters + ---------- + module : TableModule + Module to clone. + + Returns + ------- + TableModule + New TableModule that is a clone of parameter module. + """ + width, height = module.get_size() + return TableModule({"width": width, "height": height}, module.get_data()) + + class SerializeTest(unittest.TestCase): """Testcase for functions in module 'serialize_export_import_soi.py'.""" @@ -71,8 +98,8 @@ class SerializeTest(unittest.TestCase): algorithm_bin=ALGORITHM_BIN, algorithm_pack=ALGORITHM_PACK, algorithm_sort=ALGORITHM_SORT, - modules=MODULES, - attachments=MODULES, + modules=deep_copy_of_modules_list(MODULES, tablemodule_cloner), + attachments=deep_copy_of_modules_list(MODULES, tablemodule_cloner), ) date = datetime.strptime(DATE, "%Y-%m-%d") date_formatted_for_file_path = date.strftime("%Y_%m_%d") diff --git a/test/test_soi.py b/test/test_soi.py index 4045214d1af11cf35a64c47c32d087716a5dc891..d122741addd8ee8a7337d8532aa832aa08b0c57d 100644 --- a/test/test_soi.py +++ b/test/test_soi.py @@ -36,11 +36,15 @@ class TestModule(ModuleBase, QWidget, metaclass=Meta): QWidget.__init__(self, *args, **kwargs) ModuleBase.__init__(self) + self.color = color + self.width = width + self.height = height + self.setAutoFillBackground(True) palette = self.palette() - palette.setColor(QPalette.Window, QColor(color)) + palette.setColor(QPalette.Window, QColor(self.color)) self.setPalette(palette) - self.setGeometry(0, 0, width, height) + self.setGeometry(0, 0, self.width, self.height) def get_size(self): """Override.""" @@ -60,6 +64,22 @@ class TestModule(ModuleBase, QWidget, metaclass=Meta): raise NotImplementedError() +def testmodule_cloner(module): + """Return new TestModule that is a clone of parameter module. + + Parameters + ---------- + module : TestModule + Module to clone. + + Returns + ------- + TestModule + New TestModule that is a clone of parameter module. + """ + return TestModule(module.color, module.width, module.height) + + # The modules below have sizes that make the ideal for testing. # Sorting them by width should yield # 1. wide_module @@ -105,8 +125,10 @@ class TestSOI(unittest.TestCase): simply sets the default. """ self.soi = SOI( - modules=list(TEST_MODULES), - attachments=list(TEST_MODULES), + modules=deep_copy_of_modules_list(TEST_MODULES, testmodule_cloner), + attachments=deep_copy_of_modules_list( + TEST_MODULES, testmodule_cloner + ), algorithm_sort="none", placement_strategy="auto", ) @@ -238,7 +260,9 @@ class TestSOI(unittest.TestCase): self.soi.modules.append(module_maximum_size) # Store modules so we can use the exact same input for both algorithms - input_modules = self.soi.modules + input_modules = deep_copy_of_modules_list( + self.soi.modules, testmodule_cloner + ) # Test packing with Guillotine algorithm expected_module_packing_metadata_guillotinebssfsas = [ @@ -255,7 +279,9 @@ class TestSOI(unittest.TestCase): ) # Restore modules and test packing with MaxRects - self.soi.modules = input_modules + self.soi.modules = deep_copy_of_modules_list( + input_modules, testmodule_cloner + ) expected_module_packing_metadata_maxrectsbl = [ {"x": 0, "y": 0, "page": 1, "name": "tall_module"}, {"x": 100, "y": 0, "page": 1, "name": "wide_module"}, @@ -387,3 +413,70 @@ class TestSOI(unittest.TestCase): ValueError, lambda: self.soi.update_properties(invalid_key="garbage"), ) + + def test_reorganize_attachments(self): + """Test that calling reorganize properly reorganized attachments. + + Attachments should appear on their own pages, starting after the last + non-attachment page. The attachments should be placed at the top-left + corner. + """ + self.soi.reorganize() + + # Modules of SOI from self.setUp will all be placed on first page, so + # expect the three attachment modules also from self.setUp on pages + # 2,3,4 + self.assertEqual( + self.soi.attachments[0]["meta"], + {"x": 0, "y": 0, "page": 2, "name": "tall_module"}, + ) + self.assertEqual( + self.soi.attachments[1]["meta"], + {"x": 0, "y": 0, "page": 3, "name": "wide_module"}, + ) + self.assertEqual( + self.soi.attachments[2]["meta"], + {"x": 0, "y": 0, "page": 4, "name": "big_module"}, + ) + + +def deep_copy_of_modules_list(modules, widget_cloner): + """Get deep copy of modules list. Works for both modules and attachments. + + This is necessary because copying lists in Python only works on one level + of nesting, such that: + + ```python + a = [[1,2,3] + b = a.copy() + b[0][0] = 9 + print( + "nested list in 'a' now updated " + "to '{}'!! Not what we want! ".format(a[0][0]) + ) + ``` + + There is a package copy that contains deepcopy for this, but we can't use + it because some of our objects "Can't be pickled", quoting the error. + + Parameters + ---------- + modules : list + List of modules. + widget_cloner : function + Function that given a Qt widget as a parameter can return a clone of + that widget. Example usage: `widget_cloner(mywidget)` should return a + widget that is a clone of mywidget. + + Returns + ------- + list + List of modules, deep copied from input list. + """ + return [ + { + "meta": module["meta"].copy(), + "widget": widget_cloner(module["widget"]), + } + for module in modules + ]