Companion plugin — overload and restore

Advanced user only

This notebook mirrors ``plugin_action_overload.ipynb`` using AbstractUICompanion.

The overload scenario

Sometimes a second plugin needs to temporarily replace a handler that a first plugin already owns — for example, adding a diagnostic layer on top of a production handler. The viewer supports this through the overload=True flag.

With companions, the pattern maps cleanly:

Step

What happens

base.build()

Registers 'demo.click'_rdown_base

diag.build()

Registers same id with overload=True_rdown_diag installed, _rdown_base saved

diag.destroy()

Unregisters 'demo.click'``_rdown_base`` automatically restored

The saving and restoration are handled entirely by the viewer internals — neither companion needs to know about the other.

Overload rules (same as raw API)

Scenario

overload=

Result

New action

False

Silent registration

Occupied slot

False

WARNING — previous handler lost

Occupied slot

True

WARNING + previous handler saved — restored on unregister

1 — wx startup

[1]:
import logging, sys
logging.basicConfig(level=logging.DEBUG, format='%(levelname)-8s %(message)s')
%gui wx

2 — Viewer

[2]:
from wolfhece.PyDraw import WolfMapViewer

viewer = WolfMapViewer(None, title="Companion — overload demo", w=1200, h=800)
viewer.Show()
DEBUG    Searching MKL in c:\Users\pierre\Documents\Gitlab\python311\Scripts\Library\bin
DEBUG    Searching MKL in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Found MKL in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Searching FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Scripts\Library\bin
DEBUG    Searching FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Found FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
INFO     Executable paths loaded from cache
DEBUG    Searching MKL in c:\Users\pierre\Documents\Gitlab\python311\Scripts\Library\bin
DEBUG    Searching MKL in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Found MKL in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Searching FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Scripts\Library\bin
DEBUG    Searching FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Found FORTRAN in c:\Users\pierre\Documents\Gitlab\python311\Library\bin
DEBUG    Detected MKL DLL version: 2
DEBUG    OpenGL_accelerate module loaded
DEBUG    Using accelerated ArrayDatatype
INFO     Using NT-specific GLUT calls with exit callbacks
INFO     Importing wolfhece modules
INFO     wolfhece modules imported
INFO     Plugin manager init: skipping auto-discovery because no global configuration is available yet.
[2]:
False

3 — Base companion (permanent handler)

The DemoCompanion registers a simple right-click handler under the id 'demo.click'. This is the production handler that should survive overloads.

[3]:
from wolfhece.plugins.abc import AbstractUICompanion, ActionSpec, MenuItem
from wolfhece._viewer_plugin_handlers import MouseContext


class DemoCompanion(AbstractUICompanion):
    """Base companion — registers the production rdown handler."""

    def get_namespace(self) -> str:
        return 'demo'

    def menu_spec(self):
        return ('Demo', [
            MenuItem('Activate', self.on_activate),
            MenuItem('Stop', self.on_stop),
        ])

    def actions_spec(self):
        return [ActionSpec('click', rdown=self.rdown_v1)]

    def start(self) -> None:
        self.proxy.start_action('click', 'Right-click -> version 1')

    def stop(self) -> None:
        self.proxy.end_action()

    def on_activate(self, _ctx):
        self.start()

    def on_stop(self, _ctx):
        self.stop()

    def rdown_v1(self, ctx: MouseContext) -> None:
        print(f'[v1]  X={ctx.x_snap:.3f}  Y={ctx.y_snap:.3f}')


base = DemoCompanion()
viewer.attach_companion(base)
base.start()

print(base)

INFO     ACTION : Right-click -> version 1
<DemoCompanion ns='demo' menu=attached actions=[demo.click]>

4 — Diagnostic companion (temporary overload)

DiagCompanion registers on the same action id with overload=True.

  • The viewer emits a WARNING and saves the v1 handler.

  • v2 becomes active.

  • When diag.destroy() is called, v1 is automatically restored.

[ ]:
class DiagCompanion(AbstractUICompanion):
    """Temporary diagnostic companion that overloads the base handler."""

    def get_namespace(self) -> str:
        return 'diag'

    def menu_spec(self):
        return ('Diagnostics', [
            MenuItem('Install diagnostic handler', self.on_install),
            MenuItem('Remove diagnostic handler', self.on_remove),
        ])

    def actions_spec(self):
        return None   # installed on demand

    def start(self) -> None:
        self.install()

    def stop(self) -> None:
        self.remove()

    def install(self) -> None:
        self.proxy.register_action(
            'demo.click', rdown=self.rdown_v2, overload=True,
        )
        print('Diagnostic handler installed.')

    def remove(self) -> None:
        self.proxy.unregister_action('demo.click')
        print('Diagnostic handler removed — base handler restored.')

    def on_install(self, _ctx):
        self.install()

    def on_remove(self, _ctx):
        self.remove()

    def rdown_v2(self, ctx: MouseContext) -> None:
        print(
            f'[v2 DIAG]  pixel=({ctx.x_pixel},{ctx.y_pixel})  '
            f'world=({ctx.x:.3f},{ctx.y:.3f})'
        )


diag = DiagCompanion()
viewer.attach_companion(diag)

print(diag)

<DiagCompanion ns='diag' menu=attached actions=[—]>

5 — Install the diagnostic handler

[5]:
diag.install()

print("\nActive handler:",
      viewer._custom_rdown_handlers.get('demo.click').__name__)
saved = viewer._saved_handlers.get('demo.click', {})
print("Saved rdown:",
      saved.get('rdown').__name__ if saved.get('rdown') else '<none>')
WARNING  register_action: 'demo.click' — rdown handler overloaded (previous '_wrapped' saved and will be restored on unregister).
Diagnostic handler installed.

Active handler: _wrapped
Saved rdown: _wrapped

6 — Live check

Do a few right-clicks in the viewer window.
You should see [v2 DIAG] messages — the diagnostic version is active.
[6]:
print("Current action:", viewer.action)
print("→ Right-click in the viewer window to see [v2 DIAG]")
Current action: demo.click
→ Right-click in the viewer window to see [v2 DIAG]

7 — Remove the diagnostic handler (restores the base)

diag.destroy() calls unregister_action('demo.click') internally, which triggers the viewer to restore the saved _rdown_v1.

[7]:
# Option A — remove only the overloaded action (keeps the diag companion alive):
diag.remove()

current = viewer._custom_rdown_handlers.get('demo.click')
print("Active handler after removal:",
      current.__name__ if current else '<removed>')
print("_saved_handlers:", viewer._saved_handlers)   # must be empty again

# v1 is active again — right-click to verify
base.start()
INFO     unregister_action: 'demo.click' — rdown handler restored to '_wrapped'.
INFO     ACTION : Right-click -> version 1
Diagnostic handler removed — base handler restored.
Active handler after removal: _wrapped
_saved_handlers: {}

8 — Cascaded overloads (same rules as raw API)

The save happens only once per slot. Stacking two overloads on top of each other means only the original handler is saved — destroy() always unwinds back to that original, skipping any intermediate handlers.

[8]:
# Build two extra companions that both overload 'demo.click'.
class ExtraA(AbstractUICompanion):
    def actions_spec(self):
        return [
            ActionSpec(
                'demo.click',
                rdown=lambda ctx: print(f'[vA] X={ctx.x:.1f}'),
                overload=True,
            ),
        ]

    def start(self):
        self.proxy.start_action('demo.click', '')


class ExtraB(AbstractUICompanion):
    def actions_spec(self):
        return [
            ActionSpec(
                'demo.click',
                rdown=lambda ctx: print(f'[vB] X={ctx.x:.1f}'),
                overload=True,
            ),
        ]

    def start(self):
        self.proxy.start_action('demo.click', '')


ea = ExtraA()
viewer.attach_companion(ea)
eb = ExtraB()
viewer.attach_companion(eb)

print('Active:', viewer._custom_rdown_handlers.get('demo.click'))
saved = viewer._saved_handlers.get('demo.click', {}).get('rdown')
print('Saved (rdown):', saved.__name__ if saved else '<none>')

WARNING  register_action: 'demo.click' — rdown handler overloaded (previous '_wrapped' saved and will be restored on unregister).
WARNING  register_action: 'demo.click' — rdown handler overloaded (previous '_wrapped' saved and will be restored on unregister).
Active: <function ViewerProxy._adapt_mouse_handler.<locals>._wrapped at 0x0000026C85112020>
Saved (rdown): _wrapped

9 — Final cleanup

[9]:
base.stop()
base.destroy()   # removes 'demo.click'
diag.destroy()   # idempotent — already removed by step 7

print("All companions destroyed.")
print("_custom_rdown_handlers:", viewer._custom_rdown_handlers)
print("_saved_handlers:",        viewer._saved_handlers)
INFO     ACTION : End of action
INFO     unregister_action: 'demo.click' — rdown handler restored to '_wrapped'.
All companions destroyed.
_custom_rdown_handlers: {'demo.click': <function ViewerProxy._adapt_mouse_handler.<locals>._wrapped at 0x0000026C85112160>}
_saved_handlers: {}

Summary — overload with companions

# -- BASE companion ----------------------------------------------------------
class BaseCompanion(AbstractUICompanion):
    def actions_spec(self):
        return [ActionSpec('shared.action', rdown=self._handler_v1)]

    def start(self): self.proxy.start_action('shared.action', '...')
    def stop(self):  self.proxy.end_action()   # override if you need post-processing

    def on_activate(self, _ctx): self.start()
    def on_stop(self, _ctx):     self.stop()

# -- DIAGNOSTIC companion ----------------------------------------------------
class DiagCompanion(AbstractUICompanion):
    def install(self):
        # overload=True -> saves _handler_v1, installs _handler_v2
        self.proxy.register_action('shared.action', rdown=self._handler_v2, overload=True)

    def remove(self):
        # unregister -> _handler_v1 automatically restored
        self.proxy.unregister_action('shared.action')

    # start()/stop() can alias domain-specific names.
    def start(self): self.install()
    def stop(self):  self.remove()

    def on_install(self, _ctx): self.install()
    def on_remove(self, _ctx):  self.remove()

# Usage:
base = BaseCompanion(); base.proxy.attach(viewer); base.build()
base.start()                                     # v1 active
diag = DiagCompanion(); diag.proxy.attach(viewer); diag.install()   # v2 active (or diag.start())
diag.remove()                                    # v1 restored (or diag.stop())
base.stop()
base.destroy()                                   # removes 'shared.action'

Rules recap

  • proxy.register_action(id, overload=True) saves the previous handler once.

  • Subsequent overloads on the same slot emit a warning but do not replace the saved base handler.

  • proxy.unregister_action(id) restores the saved handler if one exists.

  • destroy() unregisters companion actions, so restoration is automatic.