Companion plugin — minimal example

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

Using the abstract class allows you to build plugins that can live outside a jupyter notebook. This is useful if you plan to extend Wolf with your own plugins.

The plugin that uses the class interacts with the viewer via a proxy

What changes compared to the raw API?

Raw API

Companion API

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

self.proxy.register_action('x', rdown=self._fn)

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

self.proxy.start_action('x', 'hint')

viewer.end_action(...)

self.proxy.end_action()

viewer.unregister_action('id')

self.destroy() — unregisters all actions at once

wx.MessageBox(...)

self.proxy.show_info(...) / self.proxy.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 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
INFO:root:Plugin manager init: skipping auto-discovery because no global configuration is available yet.
[2]:
False

3 — The companion class

Everything is in one place: collected points, menu, action specs, and handlers.

Companion anatomy

class MyCaptureCompanion(AbstractUICompanion):

    def get_namespace(self) -> str:   # optional custom namespace
        return 'capture'

    def __init__(self):                # constructor: declare state
        ...

    def menu_spec(self):               # optional: menu declaration
        ...

    def actions_spec(self):            # declarative action declaration
        return [ActionSpec('record', rdown=self._rdown)]

    def start(self) / def stop(self):  # public API called from notebook
        ...

    def on_...(self, event):           # menu callback (private)
        ...

    def rdown_...(self, ctx):          # mouse handler (private)
        ...

Inside the class, use the proxy helpers:

  • self.proxy.action_id('name') for explicit namespaced ids

  • self.proxy.register_action(...) / self.proxy.unregister_action(...) for dynamic registration

  • self.proxy.start_action(...) / self.proxy.end_action()

  • self.proxy.set_status(msg) for status bar updates

  • self.proxy.show_info(...) / self.proxy.confirm(...) for dialogs

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


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

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

    def __init__(self):
        super().__init__()
        self.points: list[tuple[float, float]] = []

    def menu_spec(self):
        return ('Capture', [
            MenuItem('Start capture', self.on_start, 'Right-click to record points'),
            MenuItem('Stop capture', self.on_stop, 'End the capture action'),
            SEPARATOR,
            MenuItem('Show points', self.on_show, 'Display collected points'),
            MenuItem('Clear points', self.on_clear, 'Delete all collected points'),
        ])

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

    def start(self, live_coords: bool = False) -> None:
        """Begin recording right-click positions."""
        if live_coords:
            self.proxy.unregister_action('record')
            self.proxy.register_action('record', rdown=self.rdown, motion=self.motion_coords)
        self.proxy.start_action('record', 'Right-click on the map to capture positions...')

    def stop(self) -> None:
        self.proxy.end_action()
        print(f'{len(self.points)} point(s) recorded.')

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

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

    def on_show(self, _ctx):
        if not self.points:
            self.proxy.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.proxy.show_info(f'{len(self.points)} point(s):\n{lines}', title='Captured points')

    def on_clear(self, _ctx):
        if not self.points:
            self.proxy.show_info('Nothing to clear.', title='Capture')
            return
        if self.proxy.confirm(f'Delete {len(self.points)} point(s)?', default='no'):
            self.points.clear()
            print('All points cleared.')

    def rdown(self, ctx: MouseContext) -> None:
        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, ctx: MouseContext) -> None:
        self.proxy.set_status(f'X={ctx.x:.2f}  Y={ctx.y:.2f}')

4 — Instantiate and attach the menu

[4]:
companion = CaptureCompanion()
viewer.attach_companion(companion)  # configures and attaches the companion to the viewer

print(companion)
<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.

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

[6]:
# 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=4.714   Y=25.300
    2.  X=25.007   Y=29.188
    3.  X=17.071   Y=14.594

7 — Live coordinate display

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

The _motion_coords handler is defined inside the companion class and uses self.proxy.set_status(msg).

[7]:
companion.start(live_coords=True)
print("Action with live preview active.")
INFO:root:ACTION : Right-click on the map to capture positions...
Action with live preview active.

8 — Cleanup

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

#or call viewer.detach_companion(companion) to destroy and detach in one step.
# viewer.detach_companion(companion)

print("Companion destroyed — all actions unregistered.")
print(companion)
INFO:root:ACTION : End of action
3 point(s) recorded.
Companion destroyed — all actions unregistered.
<CaptureCompanion ns='capture' menu=not built actions=[—]>

Summary — companion vs raw API

# RAW API
def _rdown_capture(ctx):
    clicked_points.append((ctx.x, ctx.y))

viewer.register_action('capture.position', rdown=_rdown_capture)
viewer.start_action('capture.position', 'Right-click...')
viewer.end_action()
viewer.unregister_action('capture.position')

# COMPANION API
class CaptureCompanion(AbstractUICompanion):
    def get_namespace(self) -> str: return 'capture'

    def __init__(self):
        super().__init__()
        self.points = []

    def menu_spec(self):
        return ('Capture', [
            MenuItem('Start', self._on_start),
            MenuItem('Stop', self._on_stop),
        ])

    def actions_spec(self):
        return [ActionSpec('record', rdown=self._rdown)]

    def start(self): self.proxy.start_action('record', '...')
    def stop(self):  self.proxy.end_action()

companion = CaptureCompanion()
companion.proxy.attach(viewer)
companion.build()
companion.start()
companion.stop()
companion.destroy()

Why this style is preferred

  • Keeps menu + handlers + lifecycle in one object.

  • Uses declarative actions_spec for predictable registration.

  • Preserves direct proxy control for advanced dynamic behaviors.

  • destroy() centralizes cleanup to avoid leaked handlers.

9 — Example: declarative multi-step action (MultiStepSpec)

This pattern is useful when the interaction has explicit steps (for example pick start/end points).

Declare a MultiStepSpec inside actions_spec(). Each StepSpec carries its own hint and handlers; the proxy drives step progression automatically based on the StepTransition value returned by each handler.

Quick ownership map:

  • MultiStepSpec / proxy: step index, step hints, state machine.

  • Companion: picked points/geometry, business decisions, _ldown_* / _key_* handlers.

  • Proxy: viewer bridge (start_action, end_action, id resolution).

[11]:
from wolfhece.plugins.abc import (
    AbstractUICompanion,
    MultiStepSpec,
    StepSpec,
    StepTransition,
    MenuItem,
)
from wolfhece._viewer_plugin_handlers import KeyboardSnapshot, MouseContext


class SegmentCompanion(AbstractUICompanion):
    """Pick two points and print the segment length."""

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

    def __init__(self):
        super().__init__()
        self._pts: list[tuple[float, float]] = []
        self.length: float = 0.0

    def menu_spec(self):
        return ('Segment', [
            MenuItem('Place segment...', self._on_place, 'Pick start and end points - Right clicks'),
        ])

    def actions_spec(self):
        return [
            MultiStepSpec(
                'place',
                steps=[
                    StepSpec(
                        hint='Right-click: pick start point',
                        rdown=self._rdown_step1,
                    ),
                    StepSpec(
                        hint='Right-click: pick end point  (Esc to cancel)',
                        rdown=self._rdown_step2,
                        key=self._key_cancel,
                    ),
                ],
            ),
        ]

    def _on_place(self, _ctx) -> None:
        self._pts.clear()
        self.start()

    def _rdown_step1(self, ctx: MouseContext) -> StepTransition:
        self._pts.append((ctx.x_snap, ctx.y_snap))
        return StepTransition.NEXT

    def _rdown_step2(self, ctx: MouseContext) -> StepTransition:
        self._pts.append((ctx.x_snap, ctx.y_snap))

        (x1, y1), (x2, y2) = self._pts
        self.length = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
        return StepTransition.FINISH

    def _key_cancel(self, kb: KeyboardSnapshot) -> bool:
        if kb.key_code == 27:  # Esc
            self.stop()
            return True
        return False

segment = SegmentCompanion()
viewer.attach_companion(segment)
print('Use the Segment menu to start the 2-step action.')

Use the Segment menu to start the 2-step action.
[ ]:
print(segment.length)
34.371861910330416
The Kernel crashed while executing code in the current cell or a previous cell.

Please review the code in the cell(s) to identify a possible cause of the failure.

Click <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info.

View Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details.