diff --git a/const.py b/const.py deleted file mode 100644 index 94fa93cc..00000000 --- a/const.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Constants used througout the plugin.""" - -from enum import IntEnum - - -class Column(IntEnum): - """Column positions for main parts table.""" - - REFERENCE = 0 - VALUE = 1 - FOOTPRINT = 2 - LCSC = 3 - TYPE = 4 - STOCK = 5 - BOM = 6 - POS = 7 - ROTATION = 8 - SIDE = 9 diff --git a/datamodel.py b/datamodel.py new file mode 100644 index 00000000..f68a2eea --- /dev/null +++ b/datamodel.py @@ -0,0 +1,257 @@ +"""Implementation of the Datamodel for the parts list with natural sort.""" + +import logging +import re + +import wx.dataview as dv + +from .helpers import loadIconScaled + + +class PartListDataModel(dv.PyDataViewModel): + """Datamodel for use with the DataViewCtrl of the mainwindow.""" + + def __init__(self, scale_factor): + super().__init__() + self.data = [] + self.columns = { + "REF_COL": 0, + "VALUE_COL": 1, + "FP_COL": 2, + "LCSC_COL": 3, + "TYPE_COL": 4, + "STOCK_COL": 5, + "BOM_COL": 6, + "POS_COL": 7, + "ROT_COL": 8, + "SIDE_COL": 9, + } + + self.bom_pos_icons = [ + loadIconScaled( + "mdi-check-color.png", + scale_factor, + ), + loadIconScaled( + "mdi-close-color.png", + scale_factor, + ), + ] + self.side_icons = [ + loadIconScaled( + "TOP.png", + scale_factor, + ), + loadIconScaled( + "BOT.png", + scale_factor, + ), + ] + self.logger = logging.getLogger(__name__) + + @staticmethod + def natural_sort_key(s): + """Return a tuple that can be used for natural sorting.""" + return [ + int(text) if text.isdigit() else text.lower() + for text in re.split("([0-9]+)", s) + ] + + def GetColumnCount(self): + """Get number of columns.""" + return len(self.columns) + + def GetColumnType(self, col): + """Get type of each column.""" + columntypes = ( + "string", + "string", + "string", + "string", + "string", + "string", + "wxDataViewIconText", + "wxDataViewIconText", + "string", + "wxDataViewIconText", + ) + return columntypes[col] + + def GetChildren(self, parent, children): + """Get child items of a parent.""" + if not parent: + for row in self.data: + children.append(self.ObjectToItem(row)) + return len(self.data) + return 0 + + def IsContainer(self, item): + """Check if tem is a container.""" + return not item + + def GetParent(self, item): + """Get parent item.""" + return dv.NullDataViewItem + + def GetValue(self, item, col): + """Get value of an item.""" + row = self.ItemToObject(item) + if col in [ + self.columns["BOM_COL"], + self.columns["POS_COL"], + self.columns["SIDE_COL"], + ]: + icon = row[col] + return dv.DataViewIconText("", icon) + return row[col] + + def SetValue(self, value, item, col): + """Set value of an item.""" + row = self.ItemToObject(item) + if col in [ + self.columns["BOM_COL"], + self.columns["POS_COL"], + self.columns["SIDE_COL"], + ]: + return False + row[col] = value + return True + + def Compare(self, item1, item2, column, ascending): + """Override to implement natural sorting.""" + val1 = self.GetValue(item1, column) + val2 = self.GetValue(item2, column) + + key1 = self.natural_sort_key(val1) + key2 = self.natural_sort_key(val2) + + if ascending: + return (key1 > key2) - (key1 < key2) + else: + return (key2 > key1) - (key2 < key1) + + def find_index(self, ref): + """Get the index of a part within the data list by its reference.""" + try: + return self.data.index([x for x in self.data if x[0] == ref].pop()) + except (ValueError, IndexError): + return None + + def get_bom_pos_icon(self, state: str): + """Get an icon for a state.""" + return self.bom_pos_icons[int(state)] + + def get_side_icon(self, side: str): + """Get The side for a layer number.""" + return self.side_icons[0] if side == "0" else self.side_icons[1] + + def AddEntry(self, data: list): + """Add a new entry to the data model.""" + data[self.columns["BOM_COL"]] = self.get_bom_pos_icon( + data[self.columns["BOM_COL"]] + ) + data[self.columns["POS_COL"]] = self.get_bom_pos_icon( + data[self.columns["POS_COL"]] + ) + data[self.columns["SIDE_COL"]] = self.get_side_icon( + data[self.columns["SIDE_COL"]] + ) + self.data.append(data) + self.ItemAdded(dv.NullDataViewItem, self.ObjectToItem(data)) + + def UpdateEntry(self, data: list): + """Update an entry in the data model.""" + if (index := self.find_index(data[0])) is None: + return + item = self.data[index] + for i in range(0, len(data)): + if i in [self.columns["BOM_COL"], self.columns["POS_COL"]]: + item[i] = self.get_bom_pos_icon(data[i]) + elif i == self.columns["SIDE_COL"]: + item[i] = self.get_side_icon(data[i]) + else: + item[i] = data[i] + self.ItemChanged(self.ObjectToItem(item)) + + def RemoveEntry(self, ref: str): + """Remove an entry from the data model.""" + if (index := self.find_index(ref)) is None: + return + item = self.ObjectToItem(self.data[index]) + self.data.remove(self.data[index]) + self.ItemDeleted(dv.NullDataViewItem, item) + + def RemoveAll(self): + """Remove all entries from the data model.""" + self.data.clear() + self.Cleared() + + def get_all(self): + """Get tall items.""" + return self.data + + def get_reference(self, item): + """Get the reference of an item.""" + return self.ItemToObject(item)[self.columns["REF_COL"]] + + def get_value(self, item): + """Get the value of an item.""" + return self.ItemToObject(item)[self.columns["VALUE_COL"]] + + def get_lcsc(self, item): + """Get the lcsc of an item.""" + return self.ItemToObject(item)[self.columns["LCSC_COL"]] + + def get_footprint(self, item): + """Get the footprint of an item.""" + return self.ItemToObject(item)[self.columns["FP_COL"]] + + def select_alike(self, item): + """Select all items that have the same value and footprint.""" + obj = self.ItemToObject(item) + alike = [] + for data in self.data: + if data[1:3] == obj[1:3]: + alike.append(self.ObjectToItem(data)) + return alike + + def set_lcsc(self, ref, lcsc, type, stock): + """Set an lcsc number, type and stock for given reference.""" + if (index := self.find_index(ref)) is None: + return + item = self.data[index] + item[self.columns["LCSC_COL"]] = lcsc + item[self.columns["TYPE_COL"]] = type + item[self.columns["STOCK_COL"]] = stock + self.ItemChanged(self.ObjectToItem(item)) + + def remove_lcsc_number(self, item): + """Remove the LCSC number of an item.""" + obj = self.ItemToObject(item) + obj[self.columns["LCSC_COL"]] = "" + obj[self.columns["TYPE_COL"]] = "" + obj[self.columns["STOCK_COL"]] = "" + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_bom(self, item): + """Toggle BOM for a given item.""" + obj = self.ItemToObject(item) + if obj[self.columns["BOM_COL"]] == self.bom_pos_icons[0]: + obj[self.columns["BOM_COL"]] = self.bom_pos_icons[1] + else: + obj[self.columns["BOM_COL"]] = self.bom_pos_icons[0] + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_pos(self, item): + """Toggle POS for a given item.""" + obj = self.ItemToObject(item) + if obj[self.columns["POS_COL"]] == self.bom_pos_icons[0]: + obj[self.columns["POS_COL"]] = self.bom_pos_icons[1] + else: + obj[self.columns["POS_COL"]] = self.bom_pos_icons[0] + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_bom_pos(self, item): + """Toggle BOM and POS for a given item.""" + self.toggle_bom(item) + self.toggle_pos(item) diff --git a/fabrication.py b/fabrication.py index 9d4f3af4..68092a5d 100644 --- a/fabrication.py +++ b/fabrication.py @@ -323,12 +323,7 @@ def generate_bom(self): if not add_without_lcsc and not part["lcsc"]: continue writer.writerow( - [ - part["value"], - part["refs"], - part["footprint"], - part["lcsc"] - ] + [part["value"], part["refs"], part["footprint"], part["lcsc"]] ) self.logger.info( "Finished generating BOM file %s", os.path.join(self.outputdir, bomname) diff --git a/icons/BOT.png b/icons/BOT.png new file mode 100644 index 00000000..bc089ee9 Binary files /dev/null and b/icons/BOT.png differ diff --git a/icons/TOP.png b/icons/TOP.png new file mode 100644 index 00000000..d9c6c698 Binary files /dev/null and b/icons/TOP.png differ diff --git a/icons/svg/BOT.svg b/icons/svg/BOT.svg new file mode 100644 index 00000000..51d341b6 --- /dev/null +++ b/icons/svg/BOT.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + BOT + + diff --git a/icons/svg/TOP.svg b/icons/svg/TOP.svg new file mode 100644 index 00000000..1768c0b9 --- /dev/null +++ b/icons/svg/TOP.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + TOP + + diff --git a/lcsc_api.py b/lcsc_api.py index 0cf0135f..d2723b19 100644 --- a/lcsc_api.py +++ b/lcsc_api.py @@ -1,4 +1,5 @@ """Unofficial LCSC API.""" + import io from pathlib import Path diff --git a/mainwindow.py b/mainwindow.py index b216c9d5..42c68471 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -10,9 +10,9 @@ import pcbnew as kicad_pcbnew import wx # pylint: disable=import-error from wx import adv # pylint: disable=import-error -import wx.dataview # pylint: disable=import-error +import wx.dataview as dv # pylint: disable=import-error -from .const import Column +from .datamodel import PartListDataModel from .events import ( EVT_ASSIGN_PARTS_EVENT, EVT_LOGBOX_APPEND_EVENT, @@ -26,12 +26,10 @@ from .fabrication import Fabrication from .helpers import ( PLUGIN_PATH, - GetListIcon, GetScaleFactor, HighResWxSize, getVersion, loadBitmapScaled, - loadIconScaled, toggle_exclude_from_bom, toggle_exclude_from_pos, ) @@ -54,7 +52,7 @@ ID_DOWNLOAD = 4 ID_SETTINGS = 5 ID_SELECT_PART = 6 -ID_REMOVE_PART = 7 +ID_REMOVE_LCSC_NUMBER = 7 ID_SELECT_ALIKE = 8 ID_TOGGLE_BOM_POS = 9 ID_TOGGLE_BOM = 10 @@ -64,8 +62,12 @@ ID_HIDE_POS = 14 ID_SAVE_MAPPINGS = 15 ID_EXPORT_TO_SCHEMATIC = 16 +ID_CONTEXT_MENU_COPY_LCSC = wx.NewIdRef() +ID_CONTEXT_MENU_PASTE_LCSC = wx.NewIdRef() ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE = wx.NewIdRef() ID_CONTEXT_MENU_ADD_ROT_BY_NAME = wx.NewIdRef() +ID_CONTEXT_MENU_FIND_MAPPING = wx.NewIdRef() +ID_CONTEXT_MENU_ADD_MAPPING = wx.NewIdRef() class KicadProvider: @@ -236,8 +238,8 @@ def __init__(self, parent, kicad_provider=KicadProvider()): "Assign a LCSC number to a footprint", ) - self.remove_part_button = self.right_toolbar.AddTool( - ID_REMOVE_PART, + self.remove_lcsc_number_button = self.right_toolbar.AddTool( + ID_REMOVE_LCSC_NUMBER, "Remove LCSC number", loadBitmapScaled( "mdi-close-box-outline.png", @@ -339,7 +341,7 @@ def __init__(self, parent, kicad_provider=KicadProvider()): ) self.Bind(wx.EVT_TOOL, self.select_part, self.select_part_button) - self.Bind(wx.EVT_TOOL, self.remove_part, self.remove_part_button) + self.Bind(wx.EVT_TOOL, self.remove_lcsc_number, self.remove_lcsc_number_button) self.Bind(wx.EVT_TOOL, self.select_alike, self.select_alike_button) self.Bind(wx.EVT_TOOL, self.toggle_bom_pos, self.toggle_bom_pos_button) self.Bind(wx.EVT_TOOL, self.toggle_bom, self.toggle_bom_button) @@ -355,107 +357,69 @@ def __init__(self, parent, kicad_provider=KicadProvider()): # --------------------------------------------------------------------- # ----------------------- Footprint List ------------------------------ # --------------------------------------------------------------------- + table_sizer = wx.BoxSizer(wx.HORIZONTAL) table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600))) - self.footprint_list = wx.dataview.DataViewListCtrl( + + self.footprint_list = dv.DataViewCtrl( self, - wx.ID_ANY, - wx.DefaultPosition, - wx.DefaultSize, - style=wx.dataview.DV_MULTIPLE, + style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE, ) - self.footprint_list.SetMinSize(HighResWxSize(self.window, wx.Size(750, 400))) - self.reference = self.footprint_list.AppendTextColumn( - "Reference", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 100), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + + reference = self.footprint_list.AppendTextColumn( + "Reference", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.value = self.footprint_list.AppendTextColumn( - "Value", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 200), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + value = self.footprint_list.AppendTextColumn( + "Value", 1, width=250, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.footprint = self.footprint_list.AppendTextColumn( + footprint = self.footprint_list.AppendTextColumn( "Footprint", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 300), + 2, + width=250, + mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, ) - self.lcsc = self.footprint_list.AppendTextColumn( - "LCSC", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 100), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + lcsc = self.footprint_list.AppendTextColumn( + "LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.type_column = self.footprint_list.AppendTextColumn( - "Type", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 100), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, - ) - self.stock = self.footprint_list.AppendTextColumn( - "Stock", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 100), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + type = self.footprint_list.AppendTextColumn( + "Type", 4, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.bom = self.footprint_list.AppendIconTextColumn( - "BOM", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 40), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + stock = self.footprint_list.AppendTextColumn( + "Stock", 5, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.pos = self.footprint_list.AppendIconTextColumn( - "POS", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 40), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + bom = self.footprint_list.AppendIconTextColumn( + "BOM", 6, width=50, mode=dv.DATAVIEW_CELL_INERT ) - self.rot = self.footprint_list.AppendTextColumn( - "Rotation", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 60), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + pos = self.footprint_list.AppendIconTextColumn( + "POS", 7, width=50, mode=dv.DATAVIEW_CELL_INERT ) - self.side = self.footprint_list.AppendTextColumn( - "Side", - mode=wx.dataview.DATAVIEW_CELL_INERT, - width=int(self.scale_factor * 40), - align=wx.ALIGN_CENTER, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + rotation = self.footprint_list.AppendTextColumn( + "Rotation", 8, width=70, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) - self.footprint_list.AppendTextColumn( - "", - mode=wx.dataview.DATAVIEW_CELL_INERT, - align=wx.ALIGN_CENTER, - width=1, - flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + side = self.footprint_list.AppendIconTextColumn( + "Side", 9, width=50, mode=dv.DATAVIEW_CELL_INERT ) - table_sizer.Add(self.footprint_list, 20, wx.ALL | wx.EXPAND, 5) - self.footprint_list.Bind( - wx.dataview.EVT_DATAVIEW_COLUMN_HEADER_CLICK, self.OnSortFootprintList - ) + reference.SetSortable(True) + value.SetSortable(True) + footprint.SetSortable(True) + lcsc.SetSortable(True) + type.SetSortable(True) + stock.SetSortable(True) + bom.SetSortable(True) + pos.SetSortable(False) + rotation.SetSortable(True) + side.SetSortable(True) - self.footprint_list.Bind( - wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected - ) + table_sizer.Add(self.footprint_list, 20, wx.ALL | wx.EXPAND, 5) self.footprint_list.Bind( - wx.dataview.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown + dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected ) + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown) + table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5) # --------------------------------------------------------------------- # --------------------- Bottom Logbox and Gauge ----------------------- @@ -510,6 +474,8 @@ def __init__(self, parent, kicad_provider=KicadProvider()): self.enable_part_specific_toolbar_buttons(False) self.init_logger() + self.partlist_data_model = PartListDataModel(self.scale_factor) + self.footprint_list.AssociateModel(self.partlist_data_model) self.init_library() self.init_fabrication() if self.library.state == LibraryState.UPDATE_NEEDED: @@ -555,7 +521,7 @@ def assign_parts(self, e): for reference in e.references: self.store.set_lcsc(reference, e.lcsc) self.store.set_stock(reference, int(e.stock)) - self.populate_footprint_list() + self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock) def display_message(self, e): """Dispaly a message with the data from the event.""" @@ -576,29 +542,13 @@ def get_correction(self, part: dict, corrections: list) -> str: for regex, correction in corrections: if re.search(regex, str(part["footprint"])): return str(correction) - return "" + return "0" def populate_footprint_list(self, *_): - """Populate/Refresh list of footprints.""" + """Populate list of footprints.""" if not self.store: self.init_store() - self.footprint_list.DeleteAllItems() - icons = { - 0: wx.dataview.DataViewIconText( - "", - loadIconScaled( - "mdi-check-color.png", - self.scale_factor, - ), - ), - 1: wx.dataview.DataViewIconText( - "", - loadIconScaled( - "mdi-close-color.png", - self.scale_factor, - ), - ), - } + self.partlist_data_model.RemoveAll() details = {} corrections = self.library.get_all_correction_data() for part in self.store.read_all(): @@ -612,7 +562,7 @@ def populate_footprint_list(self, *_): # don't show the part if hide POS is set if self.hide_pos_parts and part["exclude_from_pos"]: continue - self.footprint_list.AppendItem( + self.partlist_data_model.AddEntry( [ part["reference"], part["value"], @@ -620,23 +570,13 @@ def populate_footprint_list(self, *_): part["lcsc"], details.get(part["lcsc"], {}).get("type", ""), # type details.get(part["lcsc"], {}).get("stock", ""), # stock - icons.get( - part["exclude_from_bom"], icons.get(0) - ), # exclude_from_bom icon - icons.get( - part["exclude_from_pos"], icons.get(0) - ), # exclude_from_pos icon - self.get_correction(part, corrections), # rotation - "Top" if fp.GetLayer() == 0 else "Bot", # Side - "", + part["exclude_from_bom"], + part["exclude_from_pos"], + str(self.get_correction(part, corrections)), + str(fp.GetLayer()), ] ) - def OnSortFootprintList(self, e): - """Set order_by to the clicked column and trigger list refresh.""" - self.store.set_order_by(e.GetColumn()) - self.populate_footprint_list() - def OnBomHide(self, *_): """Hide all parts from the list that have 'in BOM' set to No.""" self.hide_bom_parts = not self.hide_bom_parts @@ -717,12 +657,9 @@ def OnFootprintSelected(self, *_): # select all of the selected items in the footprint_list if self.footprint_list.GetSelectedItemsCount() > 0: for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - ref = self.footprint_list.GetTextValue(row, 0) + ref = self.partlist_data_model.get_reference(item) fp = self.pcbnew.GetBoard().FindFootprintByReference(ref) - fp.SetSelected() - # cause pcbnew to refresh the board with the changes to the selected footprint(s) self.pcbnew.Refresh() @@ -740,7 +677,7 @@ def enable_part_specific_toolbar_buttons(self, state): """Control the state of all the buttons that relate to parts in toolbar on the right side.""" for button in ( ID_SELECT_PART, - ID_REMOVE_PART, + ID_REMOVE_LCSC_NUMBER, ID_SELECT_ALIKE, ID_TOGGLE_BOM_POS, ID_TOGGLE_BOM, @@ -753,89 +690,58 @@ def enable_part_specific_toolbar_buttons(self, state): def toggle_bom_pos(self, *_): """Toggle the exclude from BOM/POS attribute of a footprint.""" - selected_rows = [] for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - selected_rows.append(row) - ref = self.footprint_list.GetTextValue(row, 0) + ref = self.partlist_data_model.get_reference(item) board = self.pcbnew.GetBoard() fp = board.FindFootprintByReference(ref) bom = toggle_exclude_from_bom(fp) pos = toggle_exclude_from_pos(fp) self.store.set_bom(ref, int(bom)) self.store.set_pos(ref, int(pos)) - self.footprint_list.SetValue( - GetListIcon(bom, self.scale_factor), row, Column.BOM - ) - self.footprint_list.SetValue( - GetListIcon(pos, self.scale_factor), row, Column.POS - ) + self.partlist_data_model.toggle_bom_pos(item) def toggle_bom(self, *_): """Toggle the exclude from BOM attribute of a footprint.""" - selected_rows = [] for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - selected_rows.append(row) - ref = self.footprint_list.GetTextValue(row, 0) + ref = self.partlist_data_model.get_reference(item) board = self.pcbnew.GetBoard() fp = board.FindFootprintByReference(ref) bom = toggle_exclude_from_bom(fp) self.store.set_bom(ref, int(bom)) - self.footprint_list.SetValue( - GetListIcon(bom, self.scale_factor), row, Column.BOM - ) + self.partlist_data_model.toggle_bom(item) def toggle_pos(self, *_): """Toggle the exclude from POS attribute of a footprint.""" - selected_rows = [] for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - selected_rows.append(row) - ref = self.footprint_list.GetTextValue(row, 0) + ref = self.partlist_data_model.get_reference(item) board = self.pcbnew.GetBoard() fp = board.FindFootprintByReference(ref) pos = toggle_exclude_from_pos(fp) self.store.set_pos(ref, int(pos)) - self.footprint_list.SetValue( - GetListIcon(pos, self.scale_factor), row, Column.POS - ) + self.partlist_data_model.toggle_pos(item) - def remove_part(self, *_): + def remove_lcsc_number(self, *_): """Remove an assigned a LCSC Part number to a footprint.""" for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - ref = self.footprint_list.GetTextValue(row, 0) + ref = self.partlist_data_model.get_reference(item) self.store.set_lcsc(ref, "") - self.populate_footprint_list() + self.store.set_stock(ref, None) + self.partlist_data_model.remove_lcsc_number(item) def select_alike(self, *_): """Select all parts that have the same value and footprint.""" - num_sel = ( - self.footprint_list.GetSelectedItemsCount() - ) # could have selected more than 1 item (by mistake?) - if num_sel == 1: - item = self.footprint_list.GetSelection() - else: + if self.footprint_list.GetSelectedItemsCount() > 1: self.logger.warning("Select only one component, please.") return - row = self.footprint_list.ItemToRow(item) - ref = self.footprint_list.GetValue(row, 0) - part = self.store.get_part(ref) - for r in range(self.footprint_list.GetItemCount()): - value = self.footprint_list.GetValue(r, 1) - fp = self.footprint_list.GetValue(r, 2) - if part["value"] == value and part["footprint"] == fp: - self.footprint_list.SelectRow(r) + item = self.footprint_list.GetSelection() + for item in self.partlist_data_model.select_alike(item): + self.footprint_list.Select(item) def get_part_details(self, *_): """Fetch part details from LCSC and show them one after another each in a modal.""" - parts = self.get_selected_part_id_from_gui() - if not parts: - return - - for part in parts: - self.show_part_details_dialog(part) + for item in self.footprint_list.GetSelections(): + if lcsc := self.partlist_data_model.get_lcsc(item): + self.show_part_details_dialog(lcsc) def get_column_by_name(self, column_title_to_find): """Lookup a column in our main footprint table by matching its title.""" @@ -851,25 +757,6 @@ def get_column_position_by_name(self, column_title_to_find): return -1 return self.footprint_list.GetColumnPosition(col) - def get_selected_part_id_from_gui(self): - """Get a list of LCSC part#s currently selected.""" - lcsc_ids_selected = [] - for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - if row == -1: - continue - - lcsc_id = self.get_row_item_in_column(row, "LCSC") - lcsc_ids_selected.append(lcsc_id) - - return lcsc_ids_selected - - def get_row_item_in_column(self, row, column_title): - """Get an item from a row based on the column title.""" - return self.footprint_list.GetTextValue( - row, self.get_column_position_by_name(column_title) - ) - def show_part_details_dialog(self, part): """Show the part details modal dialog.""" wx.BeginBusyCursor() @@ -925,14 +812,13 @@ def select_part(self, *_): """Select a part from the library and assign it to the selected footprint(s).""" selection = {} for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - reference = self.footprint_list.GetTextValue(row, 0) - value = self.footprint_list.GetTextValue(row, 1) - lcsc = self.footprint_list.GetTextValue(row, 3) + ref = self.partlist_data_model.get_reference(item) + lcsc = self.partlist_data_model.get_lcsc(item) + value = self.partlist_data_model.get_value(item) if lcsc != "": - selection[reference] = lcsc + selection[ref] = lcsc else: - selection[reference] = value + selection[ref] = value PartSelectorDialog(self, selection).ShowModal() def generate_fabrication_data(self, *_): @@ -953,13 +839,9 @@ def generate_fabrication_data(self, *_): def copy_part_lcsc(self, *_): """Fetch part details from LCSC and show them in a modal.""" for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - if row == -1: - return - part = self.footprint_list.GetTextValue(row, 3) - if part != "": + if lcsc := self.partlist_data_model.get_lcsc(item): if wx.TheClipboard.Open(): - wx.TheClipboard.SetData(wx.TextDataObject(part)) + wx.TheClipboard.SetData(wx.TextDataObject(lcsc)) wx.TheClipboard.Close() def paste_part_lcsc(self, *_): @@ -969,41 +851,36 @@ def paste_part_lcsc(self, *_): success = wx.TheClipboard.GetData(text_data) wx.TheClipboard.Close() if success: - lcsc = self.sanitize_lcsc(text_data.GetText()) - if lcsc == "": - return - for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - reference = self.footprint_list.GetTextValue(row, 0) - self.store.set_lcsc(reference, lcsc) - self.populate_footprint_list() + if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "": + for item in self.footprint_list.GetSelections(): + details = self.library.get_part_details(lcsc) + reference = self.partlist_data_model.get_reference(item) + self.partlist_data_model.set_lcsc( + reference, lcsc, details["type"], details["stock"] + ) + self.store.set_lcsc(reference, lcsc) - def add_part_rot(self, e): + def add_rotation(self, e): """Add part rotation for the current part.""" for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - if row == -1: - return if e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE: - package = self.footprint_list.GetTextValue(row, 2) - if package != "": - RotationManagerDialog(self, "^" + re.escape(package)).ShowModal() + if footprint := self.partlist_data_model.get_footprint(item): + RotationManagerDialog(self, "^" + re.escape(footprint)).ShowModal() elif e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_NAME: - name = self.footprint_list.GetTextValue(row, 1) - if name != "": - RotationManagerDialog(self, re.escape(name)).ShowModal() + if value := self.partlist_data_model.get_value(item): + RotationManagerDialog(self, re.escape(value)).ShowModal() def save_all_mappings(self, *_): """Save all mappings.""" - for r in range(self.footprint_list.GetItemCount()): - footp = self.footprint_list.GetTextValue(r, 2) - partval = self.footprint_list.GetTextValue(r, 1) - lcscpart = self.footprint_list.GetTextValue(r, 3) - if footp != "" and partval != "" and lcscpart != "": - if self.library.get_mapping_data(footp, partval): - self.library.update_mapping_data(footp, partval, lcscpart) + for item in self.partlist_data_model.get_all(): + value = item[1] + footprint = item[2] + lcsc = item[3] + if footprint != "" and value != "" and lcsc != "": + if self.library.get_mapping_data(footprint, value): + self.library.update_mapping_data(footprint, value, lcsc) else: - self.library.insert_mapping_data(footp, partval, lcscpart) + self.library.insert_mapping_data(footprint, value, lcsc) self.logger.info("All mappings saved") def export_to_schematic(self, *_): @@ -1024,33 +901,30 @@ def export_to_schematic(self, *_): def add_foot_mapping(self, *_): """Add a footprint mapping.""" for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - if row == -1: - return - footp = self.footprint_list.GetTextValue(row, 2) - partval = self.footprint_list.GetTextValue(row, 1) - lcscpart = self.footprint_list.GetTextValue(row, 3) - if footp != "" and partval != "" and lcscpart != "": - if self.library.get_mapping_data(footp, partval): - self.library.update_mapping_data(footp, partval, lcscpart) + footprint = self.partlist_data_model.get_footprint(item) + value = self.partlist_data_model.get_value(item) + lcsc = self.partlist_data_model.get_lcsc(item) + if footprint != "" and value != "" and lcsc != "": + if self.library.get_mapping_data(footprint, value): + self.library.update_mapping_data(footprint, value, lcsc) else: - self.library.insert_mapping_data(footp, partval, lcscpart) + self.library.insert_mapping_data(footprint, value, lcsc) def search_foot_mapping(self, *_): """Search for a footprint mapping.""" for item in self.footprint_list.GetSelections(): - row = self.footprint_list.ItemToRow(item) - if row == -1: - return - footp = self.footprint_list.GetTextValue(row, 2) - partval = self.footprint_list.GetTextValue(row, 1) - if footp != "" and partval != "": - if self.library.get_mapping_data(footp, partval): - lcsc = self.library.get_mapping_data(footp, partval)[2] - reference = self.footprint_list.GetTextValue(row, 0) + reference = self.partlist_data_model.get_reference(item) + footprint = self.partlist_data_model.get_footprint(item) + value = self.partlist_data_model.get_value(item) + if footprint != "" and value != "": + if self.library.get_mapping_data(footprint, value): + lcsc = self.library.get_mapping_data(footprint, value)[2] self.store.set_lcsc(reference, lcsc) self.logger.info("Found %s", lcsc) - self.populate_footprint_list() + details = self.library.get_part_details(lcsc) + self.partlist_data_model.set_lcsc( + reference, lcsc, details["type"], details["stock"] + ) def sanitize_lcsc(self, lcsc_PN): """Sanitize a given LCSC number using a regex.""" @@ -1061,37 +935,48 @@ def sanitize_lcsc(self, lcsc_PN): def OnRightDown(self, *_): """Right click context menu for action on parts table.""" - conMenu = wx.Menu() - copy_lcsc = wx.MenuItem(conMenu, wx.NewIdRef(), "Copy LCSC") - conMenu.Append(copy_lcsc) - conMenu.Bind(wx.EVT_MENU, self.copy_part_lcsc, copy_lcsc) + right_click_menu = wx.Menu() + + copy_lcsc = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_COPY_LCSC, "Copy LCSC" + ) + right_click_menu.Append(copy_lcsc) + right_click_menu.Bind(wx.EVT_MENU, self.copy_part_lcsc, copy_lcsc) - paste_lcsc = wx.MenuItem(conMenu, wx.NewIdRef(), "Paste LCSC") - conMenu.Append(paste_lcsc) - conMenu.Bind(wx.EVT_MENU, self.paste_part_lcsc, paste_lcsc) + paste_lcsc = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_PASTE_LCSC, "Paste LCSC" + ) + right_click_menu.Append(paste_lcsc) + right_click_menu.Bind(wx.EVT_MENU, self.paste_part_lcsc, paste_lcsc) rotation_by_package = wx.MenuItem( - conMenu, ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE, "Add Rotation by package" + right_click_menu, + ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE, + "Add Rotation by package", ) - conMenu.Append(rotation_by_package) - conMenu.Bind(wx.EVT_MENU, self.add_part_rot, rotation_by_package) + right_click_menu.Append(rotation_by_package) + right_click_menu.Bind(wx.EVT_MENU, self.add_rotation, rotation_by_package) rotation_by_name = wx.MenuItem( - conMenu, ID_CONTEXT_MENU_ADD_ROT_BY_NAME, "Add Rotation by name" + right_click_menu, ID_CONTEXT_MENU_ADD_ROT_BY_NAME, "Add Rotation by name" ) - conMenu.Append(rotation_by_name) - conMenu.Bind(wx.EVT_MENU, self.add_part_rot, rotation_by_name) + right_click_menu.Append(rotation_by_name) + right_click_menu.Bind(wx.EVT_MENU, self.add_rotation, rotation_by_name) - find_mapping = wx.MenuItem(conMenu, wx.NewIdRef(), "Find LCSC from Mappings") - conMenu.Append(find_mapping) - conMenu.Bind(wx.EVT_MENU, self.search_foot_mapping, find_mapping) + find_mapping = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_FIND_MAPPING, "Find LCSC from Mappings" + ) + right_click_menu.Append(find_mapping) + right_click_menu.Bind(wx.EVT_MENU, self.search_foot_mapping, find_mapping) - add_mapping = wx.MenuItem(conMenu, wx.NewIdRef(), "Add Footprint Mapping") - conMenu.Append(add_mapping) - conMenu.Bind(wx.EVT_MENU, self.add_foot_mapping, add_mapping) + add_mapping = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_ADD_MAPPING, "Add Footprint Mapping" + ) + right_click_menu.Append(add_mapping) + right_click_menu.Bind(wx.EVT_MENU, self.add_foot_mapping, add_mapping) - self.footprint_list.PopupMenu(conMenu) - conMenu.Destroy() # destroy to avoid memory leak + self.footprint_list.PopupMenu(right_click_menu) + right_click_menu.Destroy() # destroy to avoid memory leak def init_logger(self): """Initialize logger to log into textbox.""" diff --git a/partdetails.py b/partdetails.py index 6be55f27..f30f2e81 100644 --- a/partdetails.py +++ b/partdetails.py @@ -147,7 +147,9 @@ def savepdf(self, *_): filename = self.pdfurl.rsplit("/", maxsplit=1)[1] self.logger.info("Save datasheet %s to %s", filename, self.datasheet_path) self.datasheet_path.mkdir(parents=True, exist_ok=True) - result = self.lcsc_api.download_datasheet(self.pdfurl, self.datasheet_path / filename) + result = self.lcsc_api.download_datasheet( + self.pdfurl, self.datasheet_path / filename + ) title = "Success" if result["success"] else "Error" style = "info" if result["success"] else "error" wx.PostEvent( diff --git a/partselector.py b/partselector.py index 1ea714a4..67a1d1ac 100644 --- a/partselector.py +++ b/partselector.py @@ -651,8 +651,8 @@ def get_price(self, quantity, prices) -> float: return float(price) for p in price_ranges: range, price = p.split(":") - lower,upper = range.split("-") - if not upper: # upper bound of price ranges + lower, upper = range.split("-") + if not upper: # upper bound of price ranges return float(price) lower = int(lower) upper = int(upper) @@ -678,8 +678,8 @@ def populate_part_list(self, parts, search_duration): self.result_count.SetLabel(f"{count} Results in {search_duration_text}") for p in parts: item = [str(c) for c in p] - pricecol = 8 # Must match order in library.py search function - price = round(self.get_price(len(self.parts), item[pricecol]) , 3) + pricecol = 8 # Must match order in library.py search function + price = round(self.get_price(len(self.parts), item[pricecol]), 3) sum = round(price * len(self.parts), 3) item[pricecol] = f"{len(self.parts)} parts: ${price} each / ${sum} total" self.part_list.AppendItem(item) @@ -691,11 +691,13 @@ def select_part(self, *_): if row == -1: return selection = self.part_list.GetTextValue(row, 0) + type = self.part_list.GetTextValue(row, 4) stock = self.part_list.GetTextValue(row, 5) wx.PostEvent( self.parent, AssignPartsEvent( lcsc=selection, + type=type, stock=stock, references=self.parts.keys(), ), diff --git a/schematicexport.py b/schematicexport.py index 506843a6..40343d0e 100644 --- a/schematicexport.py +++ b/schematicexport.py @@ -228,7 +228,9 @@ def _update_schematic8(self, path): value = m.group(2) lastLcsc = value if newLcsc not in (lastLcsc, ""): - self.logger.info("Updating %s on %s in %s", newLcsc, lastRef, path) + self.logger.info( + "Updating %s on %s in %s", newLcsc, lastRef, path + ) outLine = outLine.replace( '"' + lastLcsc + '"', '"' + newLcsc + '"' ) diff --git a/store.py b/store.py index cad8d887..8f338317 100644 --- a/store.py +++ b/store.py @@ -107,7 +107,10 @@ def read_bom_parts(self) -> dict: def create_part(self, part: dict): """Create a part in the database.""" with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: - cur.execute("INSERT INTO part_info VALUES (:reference, :value, :footprint, :lcsc, '', :exclude_from_bom, :exclude_from_pos)", part) + cur.execute( + "INSERT INTO part_info VALUES (:reference, :value, :footprint, :lcsc, '', :exclude_from_bom, :exclude_from_pos)", + part, + ) cur.commit() def update_part(self, part: dict): @@ -124,15 +127,16 @@ def get_part(self, ref: str) -> dict: with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: con.row_factory = dict_factory return cur.execute( - "SELECT * FROM part_info WHERE reference = :reference", {"reference": ref} + "SELECT * FROM part_info WHERE reference = :reference", + {"reference": ref}, ).fetchone() - - def set_stock(self, ref: str, stock: int): + def set_stock(self, ref: str, stock: int | None): """Set the stock value for a part in the database.""" with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: cur.execute( - "UPDATE part_info SET stock = :stock WHERE reference = :reference", {"reference": ref, "stock": stock} + "UPDATE part_info SET stock = :stock WHERE reference = :reference", + {"reference": ref, "stock": stock}, ) cur.commit() @@ -140,23 +144,26 @@ def set_bom(self, ref: str, state: int): """Change the BOM attribute for a part in the database.""" with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: cur.execute( - "UPDATE part_info SET exclude_from_bom = :state WHERE reference = :reference", {"reference": ref, "state": state} + "UPDATE part_info SET exclude_from_bom = :state WHERE reference = :reference", + {"reference": ref, "state": state}, ) cur.commit() def set_pos(self, ref: str, state: int): - """Change the BOM attribute for a part in the database.""" + """Change the POS attribute for a part in the database.""" with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: cur.execute( - "UPDATE part_info SET exclude_from_pos = :state WHERE reference = :reference", {"reference": ref, "state": state} + "UPDATE part_info SET exclude_from_pos = :state WHERE reference = :reference", + {"reference": ref, "state": state}, ) cur.commit() def set_lcsc(self, ref: str, lcsc: str): - """Change the BOM attribute for a part in the database.""" + """Change the LCSC attribute for a part in the database.""" with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: cur.execute( - "UPDATE part_info SET lcsc = :lcsc WHERE reference = :reference", {"reference": ref, "lcsc": lcsc} + "UPDATE part_info SET lcsc = :lcsc WHERE reference = :reference", + {"reference": ref, "lcsc": lcsc}, ) cur.commit()