# interface.py import wx import wx.dataview as dv import pcbnew from .backend import manager from .backend.thumbnails import ThumbnailGenerator class LogDialog(wx.Dialog): def __init__(self, parent, title, log_text): super().__init__(parent, title=title, size=(600, 400), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) sizer = wx.BoxSizer(wx.VERTICAL) text = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL) text.SetValue(log_text) sizer.Add(text, 1, wx.EXPAND | wx.ALL, 10) btn = wx.Button(self, wx.ID_OK, "Close") sizer.Add(btn, 0, wx.ALIGN_CENTER | wx.BOTTOM, 10) self.SetSizer(sizer) btn.Bind(wx.EVT_BUTTON, lambda e: self.EndModal(wx.ID_OK)) class DeleteDialog(wx.Dialog): def __init__(self, parent): super().__init__(parent, title="Delete Layer", size=(400, 200)) sizer = wx.BoxSizer(wx.VERTICAL) lbl = wx.StaticText(self, label="How should items associated with this layer be handled?") sizer.Add(lbl, 0, wx.ALL, 15) self.cb_remember = wx.CheckBox(self, label="Remember my choice (Don't ask again)") sizer.Add(self.cb_remember, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 15) btn_sizer = wx.BoxSizer(wx.HORIZONTAL) btn_del_all = wx.Button(self, id=wx.ID_YES, label="Delete Layer & Items") btn_del_layer = wx.Button(self, id=wx.ID_NO, label="Delete Layer Only") btn_cancel = wx.Button(self, id=wx.ID_CANCEL, label="Cancel") btn_sizer.Add(btn_del_all, 0, wx.ALL, 5) btn_sizer.Add(btn_del_layer, 0, wx.ALL, 5) btn_sizer.Add(btn_cancel, 0, wx.ALL, 5) sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) self.SetSizer(sizer) self.Fit() btn_del_all.Bind(wx.EVT_BUTTON, self.OnButton) btn_del_layer.Bind(wx.EVT_BUTTON, self.OnButton) btn_cancel.Bind(wx.EVT_BUTTON, self.OnButton) def OnButton(self, event): self.EndModal(event.GetId()) class LayerDropTarget(wx.TextDropTarget): def __init__(self, panel): super().__init__() self.panel = panel def OnDragOver(self, x, y, defResult): # Provide visual feedback by selecting the row under the cursor item, col = self.panel.list.HitTest(wx.Point(x, y)) if item.IsOk(): row = self.panel.list.ItemToRow(item) self.panel.list.SelectRow(row) return defResult def OnDropText(self, x, y, data): try: source_idx = int(data) item, col = self.panel.list.HitTest(wx.Point(x, y)) if not item.IsOk(): target_idx = self.panel.list.GetItemCount() else: target_idx = self.panel.list.ItemToRow(item) if source_idx != target_idx: # Capture active layer object to restore selection index later active_layer = None if manager.active_layer_index is not None and 0 <= manager.active_layer_index < len(manager.layers): active_layer = manager.layers[manager.active_layer_index] # Perform the move if 0 <= source_idx < len(manager.layers): layer_obj = manager.layers.pop(source_idx) # Adjust target if we removed an item before it if source_idx < target_idx: target_idx -= 1 # Clamp if target_idx < 0: target_idx = 0 if target_idx > len(manager.layers): target_idx = len(manager.layers) manager.layers.insert(target_idx, layer_obj) # Restore active layer index if active_layer: try: manager.active_layer_index = manager.layers.index(active_layer) except ValueError: manager.active_layer_index = None manager.save() self.panel.RefreshList() self.panel.list.SelectRow(target_idx) return True except Exception as e: print(f"Drop Error: {e}") return False class LayerPanel(wx.Panel): def __init__(self, parent): super().__init__(parent) sizer = wx.BoxSizer(wx.VERTICAL) # Toolbar btn_sizer = wx.BoxSizer(wx.HORIZONTAL) self.btn_new = wx.Button(self, label="New") self.btn_add = wx.Button(self, label="Add") self.btn_paste = wx.Button(self, label="Paste") self.btn_del = wx.Button(self, label="Del") self.btn_settings = wx.Button(self, label="⚙", size=(30, 24)) btn_sizer.Add(self.btn_new, 0, wx.ALL, 2) btn_sizer.Add(self.btn_add, 0, wx.ALL, 2) btn_sizer.Add(self.btn_paste, 0, wx.ALL, 2) btn_sizer.Add(self.btn_del, 0, wx.ALL, 2) btn_sizer.Add(self.btn_settings, 0, wx.ALL, 2) # Sort Toolbar sort_sizer = wx.BoxSizer(wx.HORIZONTAL) self.btn_up = wx.Button(self, label="▲", size=(30, 24)) self.btn_down = wx.Button(self, label="▼", size=(30, 24)) sort_sizer.Add(self.btn_up, 0, wx.ALL, 2) sort_sizer.Add(self.btn_down, 0, wx.ALL, 2) # DataViewListCtrl # Use DV_ROW_LINES for row separators self.list = dv.DataViewListCtrl(self, style=wx.BORDER_THEME | dv.DV_ROW_LINES) # Columns: Active (Toggle), Visible (Toggle), Preview (IconText), Name (Text), Items (Text) self.col_active = self.list.AppendToggleColumn("Act", width=35) self.col_vis = self.list.AppendToggleColumn("Vis", width=35) # Use IconTextColumn for preview to avoid AppendBitmapColumn issues self.col_thumb = self.list.AppendIconTextColumn("Preview", width=85, mode=dv.DATAVIEW_CELL_INERT) self.col_name = self.list.AppendTextColumn("Name", width=150, mode=dv.DATAVIEW_CELL_EDITABLE) self.col_items = self.list.AppendTextColumn("Items", width=50) # Drag and Drop self.drop_target = LayerDropTarget(self) self.list.SetDropTarget(self.drop_target) sizer.Add(btn_sizer, 0, wx.EXPAND) sizer.Add(sort_sizer, 0, wx.EXPAND) sizer.Add(self.list, 1, wx.EXPAND) self.SetSizer(sizer) # Bindings self.Bind(wx.EVT_BUTTON, self.OnNew, self.btn_new) self.Bind(wx.EVT_BUTTON, self.OnAddSel, self.btn_add) self.Bind(wx.EVT_BUTTON, self.OnPasteSel, self.btn_paste) self.Bind(wx.EVT_BUTTON, self.OnDelete, self.btn_del) self.Bind(wx.EVT_BUTTON, self.OnSettings, self.btn_settings) self.Bind(wx.EVT_BUTTON, self.OnUp, self.btn_up) self.Bind(wx.EVT_BUTTON, self.OnDown, self.btn_down) self.list.Bind(dv.EVT_DATAVIEW_ITEM_VALUE_CHANGED, self.OnValueChanged) self.list.Bind(dv.EVT_DATAVIEW_ITEM_EDITING_DONE, self.OnRenameDone) self.list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnContextMenu) self.list.Bind(dv.EVT_DATAVIEW_ITEM_BEGIN_DRAG, self.OnBeginDrag) def RefreshList(self): self.list.DeleteAllItems() board = pcbnew.GetBoard() for i, l in enumerate(manager.layers): is_active = (manager.active_layer_index == i) is_visible = l.visible # Generate thumbnail items_to_draw = l.on_board_items if l.visible else [] bmp = ThumbnailGenerator.generate_thumbnail(items_to_draw, board, 80, 40) # Convert Bitmap to Icon for DataViewIconText icon = wx.Icon() icon.CopyFromBitmap(bmp) preview_data = dv.DataViewIconText("", icon) count_str = str(len(l.on_board_items) if l.visible else len(l.stored_items)) # Append [Active, Visible, IconText, Name, Count] self.list.AppendItem([is_active, is_visible, preview_data, l.name, count_str]) # Force UI update self.list.Refresh() self.list.Update() def OnValueChanged(self, event): col = event.GetColumn() item = event.GetItem() if not item.IsOk(): return row = self.list.ItemToRow(item) if row < 0 or row >= len(manager.layers): return layer = manager.layers[row] # Column 0: Active if col == 0: # This is a radio-like behavior. # The event has already toggled the value in the model, but we need to enforce single selection. is_checked = self.list.GetToggleValue(row, 0) if is_checked: manager.set_active_layer(row) # Uncheck others for r in range(self.list.GetItemCount()): if r != row: self.list.SetToggleValue(False, r, 0) else: # If user unchecks the active layer, we have no active layer if manager.active_layer_index == row: manager.set_active_layer(None) # Refresh list to update thumbnails if needed (active layer might be drawn differently) wx.CallAfter(self.RefreshList) # Column 1: Visible elif col == 1: is_visible = self.list.GetToggleValue(row, 1) # Use CallAfter to decouple heavy logic from event handler wx.CallAfter(self.DoToggleVis, row, is_visible) def DoToggleVis(self, row, is_visible): layer = manager.layers[row] manager.clear_log() if is_visible and not layer.visible: layer.show() elif not is_visible and layer.visible: layer.hide() pcbnew.Refresh() self.RefreshList() # Check if state matches intent if layer.visible != is_visible: # State mismatch! Show log. dlg = LogDialog(self, "Layer Toggle Error", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() def OnRenameDone(self, event): item = event.GetItem() if not item.IsOk(): return row = self.list.ItemToRow(item) # Use CallAfter to ensure the model has updated the text value wx.CallAfter(self._SaveRename, row) def _SaveRename(self, row): if 0 <= row < len(manager.layers): new_name = self.list.GetTextValue(row, 3) manager.layers[row].name = new_name manager.save() def OnBeginDrag(self, event): item = event.GetItem() if not item.IsOk(): return row = self.list.ItemToRow(item) # Create drag source source = wx.DropSource(self.list) data = wx.TextDataObject(str(row)) source.SetData(data) source.DoDragDrop(wx.Drag_DefaultMove) def OnNew(self, event): dlg = wx.TextEntryDialog(self, "Layer Name:", "New Layer") if dlg.ShowModal() == wx.ID_OK: manager.create_layer(dlg.GetValue()) self.RefreshList() dlg.Destroy() def OnAddSel(self, event): row = self.list.GetSelectedRow() if row == -1: return layer = manager.layers[row] if not layer.visible: wx.MessageBox("Unhide layer first.") return manager.clear_log() count, msg = layer.add_selection() if count == 0: dlg = LogDialog(self, "Add Selection Log", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() else: wx.MessageBox(msg) self.RefreshList() def GetClipboardText(self): text = "" if wx.TheClipboard.Open(): try: if wx.TheClipboard.IsSupported(wx.DataFormat(wx.DF_TEXT)): data = wx.TextDataObject() if wx.TheClipboard.GetData(data): text = data.GetText() except: pass finally: wx.TheClipboard.Close() return text def OnPasteSel(self, event): row = self.list.GetSelectedRow() if row == -1: return layer = manager.layers[row] if not layer.visible: wx.MessageBox("Unhide layer first.") return manager.clear_log() text = self.GetClipboardText() if not text: wx.MessageBox("Clipboard empty or invalid.") else: count, msg = layer.add_from_text(text) if count == 0: dlg = LogDialog(self, "Paste Log", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() else: wx.MessageBox(msg) self.RefreshList() def OnDelete(self, event): row = self.list.GetSelectedRow() if row == -1: return mode = manager.settings.get("delete_mode", "ask") delete_items = False if mode == "ask": dlg = DeleteDialog(self) res = dlg.ShowModal() remember = dlg.cb_remember.GetValue() dlg.Destroy() if res == wx.ID_CANCEL: return delete_items = (res == wx.ID_YES) if remember: manager.settings["delete_mode"] = "delete" if delete_items else "keep" manager.save() elif mode == "delete": delete_items = True elif mode == "keep": delete_items = False manager.delete_layer(row, delete_items) self.RefreshList() def OnUp(self, event): row = self.list.GetSelectedRow() if row == -1: return if manager.move_layer_up(row): self.RefreshList() self.list.SelectRow(row - 1) def OnDown(self, event): row = self.list.GetSelectedRow() if row == -1: return if manager.move_layer_down(row): self.RefreshList() self.list.SelectRow(row + 1) def OnSettings(self, event): menu = wx.Menu() menu.Append(wx.ID_ANY, "Deletion Behavior:", kind=wx.ITEM_NORMAL).Enable(False) cur_mode = manager.settings.get("delete_mode", "ask") id_ask = wx.NewIdRef() id_del = wx.NewIdRef() id_keep = wx.NewIdRef() mi_ask = menu.AppendRadioItem(id_ask, "Ask every time") mi_del = menu.AppendRadioItem(id_del, "Always Delete Items") mi_keep = menu.AppendRadioItem(id_keep, "Always Keep Items") if cur_mode == "delete": mi_del.Check() elif cur_mode == "keep": mi_keep.Check() else: mi_ask.Check() menu.AppendSeparator() id_analyze = wx.NewIdRef() menu.Append(id_analyze, "Analyze Clipboard") id_debug = wx.NewIdRef() menu.Append(id_debug, "Show Debug Info") menu.AppendSeparator() id_recover = wx.NewIdRef() menu.Append(id_recover, "Recover Lost Items") self.Bind(wx.EVT_MENU, lambda e: self.SetMode("ask"), id=id_ask) self.Bind(wx.EVT_MENU, lambda e: self.SetMode("delete"), id=id_del) self.Bind(wx.EVT_MENU, lambda e: self.SetMode("keep"), id=id_keep) self.Bind(wx.EVT_MENU, self.OnAnalyze, id=id_analyze) self.Bind(wx.EVT_MENU, self.OnDebug, id=id_debug) self.Bind(wx.EVT_MENU, self.OnRecover, id=id_recover) self.PopupMenu(menu) menu.Destroy() def SetMode(self, mode): manager.settings["delete_mode"] = mode manager.save() def OnAnalyze(self, event): manager.clear_log() text = self.GetClipboardText() if text: manager.analyze_clipboard_text(text) dlg = LogDialog(self, "Analysis Log", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() def OnDebug(self, event): # Show full log in LogDialog dlg = LogDialog(self, "Debug Info", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() def OnRecover(self, event): count, msg = manager.recover_orphans() wx.MessageBox(msg, "Recovery Result") self.RefreshList() def OnContextMenu(self, event): item = event.GetItem() if not item.IsOk(): return row = self.list.ItemToRow(item) self.list.SelectRow(row) layer = manager.layers[row] menu = wx.Menu() is_active = (manager.active_layer_index == row) lbl_active = "Deactivate" if is_active else "Set Active" item_act = menu.Append(wx.ID_ANY, lbl_active) lbl_vis = "Hide Layer" if layer.visible else "Show Layer" item_vis = menu.Append(wx.ID_ANY, lbl_vis) menu.AppendSeparator() item_add = menu.Append(wx.ID_ANY, "Add Selection") item_paste = menu.Append(wx.ID_ANY, "Add from Clipboard") item_rem = menu.Append(wx.ID_ANY, "Remove Selection") item_insp = menu.Append(wx.ID_ANY, "Inspect Content") menu.AppendSeparator() item_del_all = menu.Append(wx.ID_ANY, "Delete Layer & Items") item_del_keep = menu.Append(wx.ID_ANY, "Delete Layer (Keep Items)") item_clr = menu.Append(wx.ID_ANY, "Clear Items (Keep Layer)") self.Bind(wx.EVT_MENU, lambda e: self.ToggleActive(row), item_act) self.Bind(wx.EVT_MENU, lambda e: self.ToggleLayerVis(row), item_vis) self.Bind(wx.EVT_MENU, lambda e: self.OnAddSel(None), item_add) self.Bind(wx.EVT_MENU, lambda e: self.OnPasteSel(None), item_paste) self.Bind(wx.EVT_MENU, lambda e: self.OnRemSel(row), item_rem) self.Bind(wx.EVT_MENU, lambda e: self.OnInspect(row), item_insp) self.Bind(wx.EVT_MENU, lambda e: self.DoDelete(row, True), item_del_all) self.Bind(wx.EVT_MENU, lambda e: self.DoDelete(row, False), item_del_keep) self.Bind(wx.EVT_MENU, lambda e: self.DoClear(row), item_clr) self.PopupMenu(menu) menu.Destroy() def ToggleActive(self, row): if manager.active_layer_index == row: manager.set_active_layer(None) self.list.SetToggleValue(False, row, 0) else: manager.set_active_layer(row) for r in range(self.list.GetItemCount()): self.list.SetToggleValue(r == row, r, 0) self.RefreshList() def ToggleLayerVis(self, row): # Use CallAfter to decouple heavy logic from event handler wx.CallAfter(self._ToggleLayerVisImpl, row) def _ToggleLayerVisImpl(self, row): layer = manager.layers[row] if layer.visible: layer.hide() else: layer.show() pcbnew.Refresh() self.RefreshList() def OnRemSel(self, row): layer = manager.layers[row] manager.clear_log() count, msg = layer.remove_selection() if count == 0: dlg = LogDialog(self, "Remove Selection Log", manager.get_log_text()) dlg.ShowModal() dlg.Destroy() else: wx.MessageBox(msg) self.RefreshList() def OnInspect(self, row): layer = manager.layers[row] wx.MessageBox(layer.inspect(), "Layer Inspection") def DoDelete(self, row, delete_items): manager.delete_layer(row, delete_items) self.RefreshList() def DoClear(self, row): manager.layers[row].clear_items() self.RefreshList() class LayerWindow(wx.Frame): def __init__(self): parent = None try: for w in wx.GetTopLevelWindows(): if "PCB Editor" in w.GetTitle(): parent = w break except: pass super().__init__(parent, title="Logical Layers", size=(500, 600), style=wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT) self.panel = LayerPanel(self) self.Bind(wx.EVT_CLOSE, self.OnClose) self.Bind(wx.EVT_SHOW, self.OnShow) self.Bind(wx.EVT_ACTIVATE, self.OnActivate) def OnShow(self, event): if event.IsShown(): manager.load() self.panel.RefreshList() event.Skip() def OnClose(self, event): self.Hide() def OnActivate(self, event): if event.GetActive(): self.SetTransparent(255) # Opaque else: self.SetTransparent(180) # ~70% Opaque (30% Transparent) event.Skip() _win = None def ShowWindow(): global _win if not _win: _win = LayerWindow() _win.Show() _win.Raise()