Hotkey-driven plugin with Undo support

I have created a plugin triggered by hotkeys, using instructions here:

However, when I try to undo the action, I get
Incomplete undo/redo operation: some items not found.

This isn’t an issue when I use a button to trigger the action, but the button doesn’t appear under the Hotkeys Preferences. Nor does there appear to be a way to produce two buttons from the same plugin.

Is there really no access to the UNDO_REDO list in Python? Here’s an example of what I’m trying to accomplish:

See also:

You can have two plugins in the same script though.

No, not directly.

The way plugins and undo/redo interact is like a transaction. When kicad invokes a plugin it saves current state in undo system before starting plugin code. Then after the plugin Run() function completes it compares the board state to previous one and puts all changes into the undo action.

So in theory you can use this to have 2 plugins, one as an orchestrator and another as doing bits of work that you want to have undo/redo control over. It’s important that the orchestrator then returns control from Run() immediately so that kicad can run another plugin. Orchestrator can have a window floating around with a button or listening to keyboard and invoking the other plugin.

It’s hacky but should work.

Putting two plugins in the same file is easy enough, but I’m confused about how to invoke the plugin properly. Simply executing the Run() method doesn’t seem to be sufficient. Is it in the actionPlugin superclass?

I hope that hotkey support for plugins is coming in KiCAD 8.

You need to have kicad invoke it, not call plugin’s method directly. For that you need to send wx event to the pcb editor window. First post you linked has some code that finds the window, now you either need to send it a keyboard event (if you have bound your plugin to a shortcut programmatically) or find the top toolbar and send it appropriate command event with id of the plugin button.

Any tips on how to find the toolbar button?
I tried with the WxPython inspector but it failed to find the top window.

Once you get the pcbnew window you can FindWindowById(ID_H_TOOLBAR)

For example this code used to add the plugin button in kicad 5 before it was natively supported.

Note that nowadays top toolbar is regenerated so you should not store the id like I did in the link above, that works only for old kicad. Instead find the toolbar and button every time you need to invoke it.

Alternative option is to find the window menu and iterate through it’s items to find “tools” then “external plugins” then your plugin. This method will not rely on user keeping your plugin button on the toolbar (they can hide it in preferences).
To iterate through child widgets of an element use native wx.Window methods
https://docs.wxpython.org/wx.Window.html#wx.Window.GetChildren

and use their properties to find the right one (title, label, whatever fits).

Ok. I found the toolbar but the only child seems to be the wx.BitmapComboBox for selecting the active layer.
Where are all the buttons??
I did verify that I have the right object, by way of the Center() and Disable() methods.

I played around a bit, you should be able to piece things together from this:

w = [w for w in wx.GetTopLevelWindows() if "pcb editor" in w.GetTitle().lower()][0]
tb = [t for t in w.Children if t.GetId() == pcbnew.ID_H_TOOLBAR][0]
tb.ToolCount
# prints 49
t = tb.FindToolByIndex(48)
t.ShortHelp
# prints 'Update test plugin'
w.QueueEvent(wx.CommandEvent(wx.wxEVT_TOOL, id=t.Id))
# runs plugin

I see what you’re doing, but I can’t replicate the results.

w = [w for w in wx.GetTopLevelWindows() if "pcb editor" in w.GetTitle().lower()][0]
tb = [t for t in w.Children if t.GetId() == pcbnew.ID_H_TOOLBAR][0]
tb.ToolCount
# AttributeError: 'Control' object has no attribute 'ToolCount'
tb
# prints <wx._core.Control object at 0x7fd21916db40>
tb.ClassName
# prints 'wxAuiToolBar'
wx.aui.AuiToolBar(tb)
# Creates a child of the toolbar, rather than typecasting as I hoped
typing.cast(wx.aui.AuiToolBar,tb)
# Returns the same tb object without changing the type

So even though the object claims to be an AuiToolBar, I don’t know how to access it as one.
I’m sorry I’m not very good with Python type casting. It seems to be one of those things Python is supposed to take care of without intervention.

Post your full kicad version (click copy version info in about dialog)

Here’s one:

Application: KiCad PCB Editor x64 on x64

Version: 7.0.8, release build

Libraries:
wxWidgets 3.2.2
FreeType 2.12.1
HarfBuzz 6.0.0
FontConfig 2.14.1
libcurl/7.88.1-DEV Schannel zlib/1.2.13

Platform: Windows 10 (build 19045), 64-bit edition, 64 bit, Little endian, wxMSW

Build Info:
Date: Sep 29 2023 18:44:47
wxWidgets: 3.2.2 (wchar_t,wx containers)
Boost: 1.81.0
OCC: 7.7.1
Curl: 7.88.1-DEV
ngspice: 41
Compiler: Visual C++ 1936 without C++ ABI

Build settings:
KICAD_SPICE=ON

I also run on Linux a lot of the time, but I couldn’t get the “copy version info” to work there. Maybe a reboot will help :laughing:

EDIT: Here it is

Application: KiCad PCB Editor x86_64 on x86_64

Version: 7.0.9, release build

Libraries:
wxWidgets 3.2.4
FreeType 2.13.2
HarfBuzz 8.3.0
FontConfig 2.14.2
libcurl/8.4.0 OpenSSL/3.1.4 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.4 libpsl/0.21.2 (+libidn2/2.3.4) libssh2/1.11.0 nghttp2/1.58.0

Platform: Arch Linux, 64 bit, Little endian, wxGTK, , x11

Build Info:
Date: Nov 14 2023 23:40:40
wxWidgets: 3.2.4 (wchar_t,wx containers) GTK+ 3.24
Boost: 1.83.0
OCC: 7.7.2
Curl: 8.4.0
ngspice: 41
Compiler: GCC 13.2.1 with C++ ABI 1018

Build settings:
KICAD_USE_EGL=ON
KICAD_SPICE=ON

I have same version on windows and my snippet works perfectly in kicad’s scripting console. Did you try it there?
Also maybe you installed some weird plugins or pip packages that mess with wxpython, try it on a clean kicad installation.

Yes, I did try it on Windows with the same result.
Maybe it has to do with the imports? I only used two:

import wx
import pcbnew

EDIT:
Yes, that’s the issue. I need to also import wx.aui in order to see the correct classes.
Something with the REPL optimization was preventing this from working after I had already tried. I had to restart KiCAD and do the correct imports the first time.

1 Like

I recognize the photo. He posts here under the same user name.

Strange, I didn’t have to do that, probably some of the plugins I have do it for me. Good to know.

Isn’t it redundant to make a button to queue a button press?
It seems like I should be able to update the acceleratortable using the tool ID of the plugin directly.

However, I can’t seem to trigger any of the toolbar buttons, though I can make the hotkey button trigger itself. The tool IDs are global, right?

Here’s my test code

import pcbnew
import wx.aui
import wx
import os

class MyTool(pcbnew.ActionPlugin):
    def defaults(self):
        self.name = "MyTool"
        self.category = "testing"
        self.description = "testing hotkeys"
        self.show_toolbar_button = True# Optional, defaults to False
    def Run(self):
        print("Hurray! Button was pressed")

MyTool().register() # Instantiate and register to Pcbnew

def findPcbnewWindow():
    """Find the window for the PCBNEW application."""
    windows = wx.GetTopLevelWindows()
    pcbnew = [w for w in windows if "PCB Editor" in w.GetTitle()]
    if len(pcbnew) != 1:
        raise Exception("Cannot find pcbnew window from title matching!")
    return pcbnew[0]

def FindToolBar(barid = pcbnew.ID_H_TOOLBAR):
    bar = [a for a in findPcbnewWindow().GetChildren() if a.GetId() == barid]
    if len(bar) != 1:
        raise Exception("Cannot find toolbar panel from ID matching")
    return bar[0]

def FindToolId(tool):
    bar = FindToolBar()
    tools = [bar.FindToolByIndex(i) for i in range(bar.ToolCount)]
    if isinstance(tool,str):
        name = tool
    else:
        name = tool.name
    tid = [t.GetId() for t in tools if t.ShortHelp == name]
    if len(tid) != 1:
        raise Exception("Cannot find desired tool")
    return tid[0]

mainFrame = findPcbnewWindow()
hotkey1= wx.NewId()
hotkey2= wx.NewId()
toolbtn = FindToolId(MyTool())
def btn_press(id):
    mainFrame.QueueEvent(wx.CommandEvent(wx.wxEVT_TOOL, id=id))
def cb1(context):
    print("hotkey1")
    btn_press(hotkey2)
def cb2(context):
    print("hotkey2")
    btn_press(toolbtn)

accel_tbl = wx.AcceleratorTable([(wx.ACCEL_SHIFT,  ord('J'), hotkey1)
                                 ,(wx.ACCEL_SHIFT,  ord('K'), hotkey2)
                                 ])
mainFrame.Bind(wx.EVT_TOOL, cb1, id=hotkey1)
mainFrame.Bind(wx.EVT_TOOL, cb2, id=hotkey2)
mainFrame.SetAcceleratorTable(accel_tbl)
print("Starting Hotkey test plugin")

The toolbar with plugin buttons is recreated dynamically on various triggers so the tool IDs are not stable. Also you can’t access it in the plugin init code because toolbar is likely not yet populated when init runs.

That makes sense. I changed to run FindToolId() every time and it all works now.
Thank you for your help.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.