Companion plugin — overload and restore

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

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.menu_build()

Registers 'demo.click'_rdown_base

diag.menu_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

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

2 — Viewer

[ ]:
from wolfhece.PyDraw import WolfMapViewer

viewer = WolfMapViewer(None, title="Companion — overload demo", w=1200, h=800)
viewer.Show()

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.

[ ]:
from wolfhece._menu_companion_abc import (
    AbstractCompanion,
    ActionItem,
    SEPARATOR,
)
from wolfhece._viewer_plugin_handlers import MouseContext


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

    def __init__(self, viewer):
        super().__init__(viewer, namespace='demo')

    def menu_build(self) -> None:
        self._build_menu('Demo', [
            ActionItem('Activate', self._on_activate),
            ActionItem('Stop',     self._on_stop),
        ])
        # Standard registration — no overload.
        self._register_action(self._action_id('click'), rdown=self._rdown_v1)

    # Public API
    def start(self) -> None:
        """Activate the v1 handler."""
        self._start_action(self._action_id('click'), 'Right-click → version 1')

    def stop(self) -> None:
        """Deactivate the action."""
        self._end_action()

    # Menu callbacks (private — delegate to public methods)
    def _on_activate(self, _event) -> None:
        self.start()

    def _on_stop(self, _event) -> None:
        self.stop()

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


base = DemoCompanion(viewer)
base.menu_build()
base.start()   # activate immediately

print(base)
print("Active handler:", viewer._custom_rdown_handlers.get('demo.click').__name__)
print("_saved_handlers:", viewer._saved_handlers)

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(AbstractCompanion):
    """Temporary diagnostic companion that overloads the base handler."""

    def __init__(self, viewer):
        super().__init__(viewer, namespace='diag')

    def menu_build(self) -> None:
        self._build_menu('Diagnostics', [
            ActionItem('Install diagnostic handler', self._on_install),
            ActionItem('Remove diagnostic handler',  self._on_remove),
        ])

    # Public API
    def start(self) -> None:
        """Install the diagnostic handler (alias for install())."""
        self.install()

    def stop(self) -> None:
        """Remove the diagnostic handler (alias for remove())."""
        self.remove()

    def install(self) -> None:
        """Register the diagnostic handler on top of any existing one."""
        # overload=True saves the displaced handler and restores it on destroy().
        self._register_action(
            'demo.click',          # same id as the base companion
            rdown=self._rdown_v2,
            overload=True,
        )
        print("Diagnostic handler installed.")

    def remove(self) -> None:
        """Unregister the diagnostic handler and restore the base handler."""
        self._unregister_action('demo.click')
        print("Diagnostic handler removed — base handler restored.")

    # Menu callbacks (private — delegate to public methods)
    def _on_install(self, _event) -> None:
        self.install()

    def _on_remove(self, _event) -> None:
        self.remove()

    def _rdown_v2(self, viewer, 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)
diag.menu_build()

print(diag)

5 — Install the diagnostic handler

[ ]:
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>')

6 — Live check

Do a few right-clicks in the viewer window.
You should see [v2 DIAG] messages — the diagnostic version is active.
[ ]:
print("Current action:", viewer.action)
print("→ 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.

[ ]:
# 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()

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.

[ ]:
# Build two extra companions that both overload 'demo.click'.
class ExtraA(AbstractCompanion):
    def menu_build(self):
        self._register_action('demo.click',
                              rdown=lambda v, c: print(f"[vA] X={c.x:.1f}"),
                              overload=True)

class ExtraB(AbstractCompanion):
    def menu_build(self):
        self._register_action('demo.click',
                              rdown=lambda v, c: print(f"[vB] X={c.x:.1f}"),
                              overload=True)

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

ea.menu_build()   # saves v1, installs vA
eb.menu_build()   # slot 'rdown' already saved → WARNING, vA lost, vB installed

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

# Restore v1 (skips vA)
eb.destroy()
ea.destroy()   # already unregistered by eb, silently ignored

current = viewer._custom_rdown_handlers.get('demo.click')
print("After both destroys:",
      current.__name__ if current else '<removed>')

9 — Final cleanup

[ ]:
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)

Summary — overload with companions

# ── BASE companion ─────────────────────────────────────────────────────────
class BaseCompanion(AbstractCompanion):
    def menu_build(self):
        self._register_action('shared.action', rdown=self._handler_v1)

    # start() is abstract in the base class — every companion must implement it:
    def start(self): self._start_action('shared.action', '…')
    def stop(self):  self._end_action()   # or override to add post-processing

    def _on_activate(self, _e): self.start()
    def _on_stop(self, _e):     self.stop()

# ── DIAGNOSTIC companion ───────────────────────────────────────────────────
class DiagCompanion(AbstractCompanion):
    def install(self):
        # overload=True → saves _handler_v1, installs _handler_v2
        self._register_action('shared.action', rdown=self._handler_v2, overload=True)

    def remove(self):
        # Unregister → _handler_v1 automatically restored
        self._unregister_action('shared.action')

    # start()/stop() are aliases for the domain-specific names:
    def start(self): self.install()
    def stop(self):  self.remove()

    def _on_install(self, _e): self.install()
    def _on_remove(self, _e):  self.remove()

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

Rules recap

  • _register_action(id, overload=True) → saves the previous handler once; subsequent overload=True calls on the same slot emit a WARNING but do not replace the save.

  • _unregister_action(id) → restores the saved handler if one exists.

  • destroy() → calls _unregister_action for every registered id, so restoration is automatic.

  • All of this is identical to the raw API behaviour; the companion just wraps it cleanly.