Companion plugin — minimal example

This notebook mirrors ``plugin_action_example.ipynb`` but uses AbstractCompanion instead of calling viewer.register_action() directly.

What changes compared to the raw API?

Raw API

Companion API

viewer.register_action('id', rdown_handler=fn)

self._register_action(self._action_id('x'), rdown=self._fn)

viewer.start_action('id', 'hint')

self._start_action(self._action_id('x'), 'hint')

viewer.end_action(...)

self._end_action()

viewer.unregister_action('id')

self.destroy() — unregisters all actions at once

wx.MessageBox(...)

self._show_info(...) / self._confirm(...)

The companion also:

  • auto-namespaces every action id ('{classname}.{local_id}') to avoid collisions

  • exposes all captured data as plain instance attributes (accessible from the notebook)

  • builds the menu declaratively — no ``import wx`` needed in your code

Workflow

  1. Start wx and create the viewer

  2. Define one companion class (state + menu + handlers, all in one place)

  3. Instantiate the companion and attach its menu with menu_build()

  4. Use the menu entries — or call companion.start() from the notebook

  5. Call companion.destroy() to clean up everything at once

1 — wx startup

Always use %gui wx in a notebook — never wx.App().

[1]:
import sys
%gui wx

2 — Create the MapViewer

[2]:
from wolfhece.PyDraw import WolfMapViewer

viewer = WolfMapViewer(None, title="Companion — minimal demo", w=1200, h=800)
viewer.Show()
INFO:root:Importing wolfhece modules
INFO:root:wolfhece modules imported
[2]:
False

3 — The companion class

Everything is in one place: the list of collected points, the menu, and all the event handlers.

Companion anatomy

class MyCaptureCompanion(AbstractCompanion):

    def __init__(self, viewer):         ← constructor: declare state
        ...

    def menu_build(self):               ← declare the menu and register actions
        ...                               (optional — omit for menu-less companions)

    def start(self) / def stop(self):   ← public API — called from the notebook
        ...                               (use self._start_action / _end_action internally)

    def _on_...(self, event):           ← menu-item callbacks (wx event, private)
        ...

    def _rdown_...(self, viewer, ctx):  ← mouse handler (viewer, MouseContext, private)
        ...

The AbstractCompanion base class provides protected building blocks for use inside the companion:

  • self._action_id('name')'capturcompanion.name' (namespaced, collision-free)

  • self._register_action(...) / self.destroy() — registration + cleanup

  • self._start_action(...) / self._end_action()

  • self._set_status(msg) — write to the viewer’s status bar

  • self._show_info(msg) / self._confirm(msg) — dialog boxes without wx

The companion’s public API (no underscore) is what notebook cells call.

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


class CaptureCompanion(AbstractCompanion):
    """Records right-click positions and exposes them as `self.points`."""

    # ---------------------------------------------------------------
    # 1 — State:  declare every piece of data your companion needs.
    # ---------------------------------------------------------------
    def __init__(self, viewer):
        super().__init__(viewer, namespace='capture')
        #: Collected (x, y) world-coordinate pairs — read from the notebook.
        self.points: list[tuple[float, float]] = []

    # ---------------------------------------------------------------
    # 2 — Menu:  declare entries with _build_menu (no wx import needed)
    #            then register the action handlers.
    # ---------------------------------------------------------------
    def menu_build(self) -> None:
        self._build_menu('Capture', [
            ActionItem('Start capture',  self._on_start,  'Right-click to record points'),
            ActionItem('Stop capture',   self._on_stop,   'End the capture action'),
            SEPARATOR,
            ActionItem('Show points',    self._on_show,   'Display collected points'),
            ActionItem('Clear points',   self._on_clear,  'Delete all collected points'),
        ])
        # Register the rdown handler — the id is auto-namespaced ('capture.record')
        self._register_action(
            self._action_id('record'),
            rdown=self._rdown,
        )

    # ---------------------------------------------------------------
    # 3 — Public API — callable directly from the notebook.
    #     These are the only methods notebook cells should call.
    # ---------------------------------------------------------------
    def start(self, live_coords: bool = False) -> None:
        """Begin recording right-click positions.

        Parameters
        ----------
        live_coords:
            When ``True`` the viewer's status bar continuously shows the
            current mouse coordinates while the cursor moves over the map.
        """
        if live_coords:
            # Re-register the action with the extra motion hook.
            self._unregister_action(self._action_id('record'))
            self._register_action(
                self._action_id('record'),
                rdown=self._rdown,
                motion=self._motion_coords,
            )
        self._start_action(
            self._action_id('record'),
            'Right-click on the map to capture positions…',
        )

    def stop(self) -> None:
        """Stop recording and print a summary."""
        self._end_action()
        print(f"{len(self.points)} point(s) recorded.")

    # ---------------------------------------------------------------
    # 4 — Menu-item callbacks  (signature: (self, event) → None)
    #     Private — called by wx, not by notebook cells.
    # ---------------------------------------------------------------
    def _on_start(self, _event):
        self.start()

    def _on_stop(self, _event):
        self.stop()

    def _on_show(self, _event):
        if not self.points:
            self._show_info("No points captured yet.", title="Points")
            return
        lines = "\n".join(
            f"  {i+1:3d}.  X={x:.3f}   Y={y:.3f}"
            for i, (x, y) in enumerate(self.points)
        )
        self._show_info(f"{len(self.points)} point(s):\n{lines}", title="Captured points")

    def _on_clear(self, _event):
        if not self.points:
            self._show_info("Nothing to clear.", title="Capture")
            return
        if self._confirm(f"Delete {len(self.points)} point(s)?", default='no'):
            self.points.clear()
            print("All points cleared.")

    # ---------------------------------------------------------------
    # 5 — Mouse handlers  (signature: (self, viewer, ctx) → None)
    #     Private — called by the viewer's dispatch, not by notebook cells.
    # ---------------------------------------------------------------
    def _rdown(self, viewer, ctx: MouseContext) -> None:
        """Called on every right-click while the action is active."""
        self.points.append((ctx.x_snap, ctx.y_snap))
        n = len(self.points)
        print(f"[Capture #{n}]  X={ctx.x_snap:.3f}   Y={ctx.y_snap:.3f}")
        if ctx.shift:
            print("  → Shift held: point flagged as reference")

    def _motion_coords(self, viewer, ctx: MouseContext) -> None:
        """Updates the status bar with the live cursor position."""
        self._set_status(f"X={ctx.x:.2f}  Y={ctx.y:.2f}")

4 — Instantiate and attach the menu

[4]:
companion = CaptureCompanion(viewer)
companion.menu_build()   # adds 'Capture' menu to the viewer menu bar

print(companion)         # shows namespace, menu status, registered actions
<CaptureCompanion ns='capture' menu=attached actions=[capture.record]>

5 — Activate from the notebook (alternative to using the menu)

You can also start/stop the action programmatically — useful for scripting.

[ ]:
companion.start()
print("Action active — right-click on the map.")
INFO:root:ACTION : Right-click on the map to capture positions…
Action active — right-click on the map.

6 — Inspect the collected points

[7]:
# Run this cell at any time to see what has been captured.
print(f"{len(companion.points)} point(s) captured:")
for i, (x, y) in enumerate(companion.points, 1):
    print(f"  {i:3d}.  X={x:.3f}   Y={y:.3f}")
3 point(s) captured:
    1.  X=13.236   Y=20.826
    2.  X=30.812   Y=27.483
    3.  X=32.037   Y=18.802

7 — Live coordinate display

Pass live_coords=True to start() to add a motion handler that continuously refreshes the viewer’s status bar with the cursor position.

The _motion_coords handler is defined inside the companion class and uses self._set_status(msg) — a helper provided by the base class that writes to the status bar without needing the viewer reference directly.

[ ]:
companion.start(live_coords=True)
print("Action with live preview active.")
INFO:root:ACTION :
INFO:root:ACTION : Right-click to capture; mouse movement updates the status bar
Action with live preview active.
[Capture #4]  X=17.390   Y=25.033
[Capture #5]  X=23.675   Y=20.027

8 — Cleanup

[ ]:
# stop() ends the current action.
# destroy() unregisters every action the companion registered — no need
# to track individual ids.
companion.stop()
companion.destroy()

print("Companion destroyed — all actions unregistered.")
print(companion)
INFO:root:ACTION :
Companion destroyed — all actions unregistered.
<CaptureCompanion ns='capture' menu=attached actions=[—]>

Summary — companion vs raw API

# ── RAW API ──────────────────────────────────────────────────────────────
def _rdown_capture_position(v, ctx):
    clicked_points.append((ctx.x, ctx.y))
    print(f"[#{len(clicked_points)}]  X={ctx.x:.3f}")

viewer.register_action('capture position', rdown_handler=_rdown_capture_position)
viewer.start_action('capture position', 'Right-click…')
# … later:
viewer.end_action('done')
viewer.unregister_action('capture position')

# ── COMPANION ─────────────────────────────────────────────────────────────
class CaptureCompanion(AbstractCompanion):
    def __init__(self, viewer):
        super().__init__(viewer, namespace='capture')
        self.points = []

    def menu_build(self):
        self._build_menu('Capture', [
            ActionItem('Start', self._on_start),
            ActionItem('Stop',  self._on_stop),
        ])
        self._register_action(self._action_id('record'), rdown=self._rdown)

    # Public API — what the notebook calls (no underscore):
    def start(self): self._start_action(self._action_id('record'), '…')
    def stop(self):  self._end_action()

    # Private — called internally by wx or the viewer dispatch:
    def _on_start(self, _e): self.start()
    def _on_stop(self, _e):  self.stop()
    def _rdown(self, v, ctx):
        self.points.append((ctx.x, ctx.y))
        print(f"[#{len(self.points)}]  X={ctx.x:.3f}")

companion = CaptureCompanion(viewer)
companion.menu_build()

companion.start()   # ← public, no underscore
# … later:
companion.stop()    # ← public, no underscore
companion.destroy() # ← unregisters everything

Key advantages

  • State (self.points) lives inside the companion — no global variables

  • Automatic namespace → no id collisions with other plugins

  • destroy() cleans up all actions at once

  • Dialog helpers (_show_info, _confirm, …) replace manual wx calls

  • _build_menu builds the menu without any import wx

  • Public API (start, stop, destroy) — no _ methods visible to notebook users