diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index e8bd1f9d4924aab3b2f316b4744b563df87718d1..bacf971887507dd2d0ae187599eb9e261b0edcdf 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 ( @@ -22,6 +23,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 ProxyLabelWithCustomQPrintText(QGraphicsProxyWidget): """QGraphicsItem that prints a custom text when printed onto QPrint. @@ -180,7 +186,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) @@ -213,7 +219,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() # NOTE: These variables are only included to support PDF output of the @@ -310,7 +320,7 @@ class InlineEditableSOIView(QScrollArea): self.draw_pages() # Render each page to own PDF page - for j in range(self.number_of_pages): + for j in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * j + self.soi.PADDING * j @@ -321,7 +331,7 @@ class InlineEditableSOIView(QScrollArea): ) # If there are more pages, newPage - if j + 1 < self.number_of_pages: + if j + 1 < self.number_of_pages_total: printer.newPage() # If there are more copies, newPage if i + 1 < self.copy_total: @@ -334,15 +344,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 @@ -354,7 +367,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 @@ -369,7 +395,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: @@ -379,6 +405,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) @@ -449,7 +484,7 @@ class InlineEditableSOIView(QScrollArea): # NOTE that this position is only correct for the default text. During # PDF printing the ProxyLabelWithCustomQPrintText class will paint # itself with a different text, and thus not be perfectly centered. - proxy.setPos(x + 18, copy_number_y_pos + label_width / 2) + proxy.setPos(x + 15, copy_number_y_pos + label_width / 2) proxy.setRotation(-90) self.scene.addItem(proxy) @@ -471,9 +506,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 @@ -481,7 +525,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/main_window.py b/soitool/main_window.py index c1aa8d6273560291987a2937efc81f22a1820c12..ddaf71c3771215eb7eba64bd9b41efa47d8ca2b4 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -225,9 +225,10 @@ class MainWindow(QMainWindow): self.tabs.setCurrentWidget(tab) def open_soi_workspace_tab(self): - """Open tab containing a SOIWorkspaceWidget.""" + """Open and select tab containing a SOIWorkspaceWidget.""" tab = SOIWorkspaceWidget() self.tabs.addTab(tab, tab.soi.title) + self.tabs.setCurrentWidget(tab) def close_tab(self, index): """Close tab at given index. diff --git a/soitool/media/authenticationboardmodule.PNG b/soitool/media/authenticationboardmodule.PNG index 9df0e912d0c92a22c40cbe72641897146749ab5f..d725e2615e1c1c37c0507da0da51d8cb6635cc1c 100644 Binary files a/soitool/media/authenticationboardmodule.PNG and b/soitool/media/authenticationboardmodule.PNG differ diff --git a/soitool/media/subtractorcodesmodule.PNG b/soitool/media/subtractorcodesmodule.PNG index a74e8c1a2b9bcec0b1a55e6205c137c53ca8aabc..7a3f581f62d58d2c88dbc909fdd06e27d5b8d404 100644 Binary files a/soitool/media/subtractorcodesmodule.PNG and b/soitool/media/subtractorcodesmodule.PNG differ 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/modules/code_table_base.py b/soitool/modules/code_table_base.py index 4516e2c2c673a7bc2f4bd1deb8e0030e4e97839a..47cb88e32ea4dd39f0527f7c0b2e78f49f00c1ab 100644 --- a/soitool/modules/code_table_base.py +++ b/soitool/modules/code_table_base.py @@ -114,8 +114,8 @@ class CodeTableBase(ModuleBase, QTableWidget, metaclass=Meta): for i in range(self.start_no_of_codes): # Insert non-editable code in third column item_third = QTableWidgetItem(codes[i]) - if self.type == SUBTRACTORCODES_MODULE: - item_third.setTextAlignment(Qt.AlignCenter) + item_third.setTextAlignment(Qt.AlignCenter) + item_third.setFont(self.code_font) item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) self.setItem(i, 2, item_third) diff --git a/soitool/modules/module_authentication_board.py b/soitool/modules/module_authentication_board.py index 6c6e76cb694b5d66df874443c751ff84789089c5..5399ddd044a96a382011def70db29adad7acf48a 100644 --- a/soitool/modules/module_authentication_board.py +++ b/soitool/modules/module_authentication_board.py @@ -12,14 +12,17 @@ START_NO_OF_CODES = 10 CODE_LENGTH = 25 # Has to be 'ascii', 'digits' or 'combo' -# Codes will consist of A-Z if 'ascii', 0-9 if 'digits' and A-Z+0-9 if 'combo'. +# Codes will consist of A-Z if 'ascii', 0-9 if 'digits' and A-Z+0-9 if 'combo' CODE_CHARACTER_TYPE = "ascii" +# Font for authentication codes, should be a monospaced font +CODE_FONT = QtGui.QFont("Consolas", 10, QtGui.QFont.SansSerif) + # Characters for first column, # it's length determines maximum number of codes (rows). ROW_IDENTIFIERS = string.ascii_uppercase -# Adds space between sets of characters, 0 => no spaces. +# Adds space between sets of characters, 0 => no spaces # If code is 123456 and interval is 2, code will be 12 34 56 SPACE_INTERVAL = 5 SPACE_AMOUNT = 2 @@ -38,6 +41,9 @@ class AuthenticationBoardModule(CodeTableBase): CODE_LENGTH, spaced out for readability if SPACE_INTERVAL and SPACE_AMOUNT is larger than 0. + The authentication codes have their own monospaced font for increased + readability, meaning each character has the same width. + If parameters are given, the widget initializes accordingly: 'size' is a dict: {"width": int, "height": int}, 'data' is a dict with keys "cells", "code_length", "space_interval", @@ -47,9 +53,6 @@ class AuthenticationBoardModule(CodeTableBase): The widget does not use more room than needed, and resizes dynamically. It has shortcuts for adding and removing rows. - - Codes are not horizontally centered for readability concerns because 'BGD' - is wider than 'III' (example) in certain fonts. """ def __init__(self, size=None, data=None): @@ -66,6 +69,8 @@ class AuthenticationBoardModule(CodeTableBase): "Invalid value for CONSTANT 'CODE_CHARACTER_TYPE': " "'{}'".format(CODE_CHARACTER_TYPE) ) + self.code_font = CODE_FONT + # Set default values for table to be generated if size is None and data is None: self.start_no_of_codes = START_NO_OF_CODES @@ -131,12 +136,10 @@ class AuthenticationBoardModule(CodeTableBase): # Insert authentication-code in third column item_third = QTableWidgetItem(code) + item_third.setFont(self.code_font) item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) self.setItem(selected_row_index + 1, 2, item_third) - # Resize code-column in case it got wider - # Example: 'BGD' is wider than 'III' (depending on font) - self.resizeColumnToContents(2) self.resizeRowToContents(selected_row_index + 1) resize_table(self, columns=False, rows=False, has_headline=True) diff --git a/soitool/modules/module_subtractorcodes.py b/soitool/modules/module_subtractorcodes.py index 5ef03ddbde0ef4e611a1356f0faeb497ae5ae8ac..8622f14b415973186a6e8a1e76eef606a0503b05 100644 --- a/soitool/modules/module_subtractorcodes.py +++ b/soitool/modules/module_subtractorcodes.py @@ -10,13 +10,16 @@ from soitool.modules.code_table_base import CodeTableBase START_NO_OF_CODES = 7 CODE_LENGTH = 8 +# Font for subtractorcodes +CODE_FONT = QtGui.QFont("Arial", 10, QtGui.QFont.SansSerif) + # Characters for first and second column ROW_IDENTIFIERS = string.ascii_uppercase # Adds space between sets of characters, 0 => no spaces. # If code is 12345678 and interval is 2, code will be 1234 5678 SPACE_INTERVAL = 4 -SPACE_AMOUNT = 5 +SPACE_AMOUNT = 3 HEADLINE_TEXT = "Subtraktorkoder" @@ -56,6 +59,7 @@ class SubtractorcodesModule(CodeTableBase): self.space_interval = SPACE_INTERVAL self.space_amount = SPACE_AMOUNT self.code_character_type = "digits" + self.code_font = CODE_FONT CodeTableBase.__init__(self, size, data) @@ -114,6 +118,7 @@ class SubtractorcodesModule(CodeTableBase): # Insert code item_code = QTableWidgetItem(code) item_code.setTextAlignment(Qt.AlignCenter) + item_code.setFont(self.code_font) item_code.setFlags(item_code.flags() ^ Qt.ItemIsEditable) self.setItem(selected_row_index + 1, 2, item_code) 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 + ]