logical_layers/interface.py

561 lines
20 KiB
Python

# 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()