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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
The companion also:
auto-namespaces every action id (
'{classname}.{local_id}') to avoid collisionsexposes all captured data as plain instance attributes (accessible from the notebook)
builds the menu declaratively — no ``import wx`` needed in your code
Workflow
Start wx and create the viewer
Define one companion class (state + menu + handlers, all in one place)
Instantiate the companion and attach its menu with
build()Use the menu entries — or call
companion.start()from the notebookCall
companion.destroy()to clean up everything at once
1 — wx startup
Always use
%gui wxin a notebook — neverwx.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 idsself.proxy.register_action(...)/self.proxy.unregister_action(...)for dynamic registrationself.proxy.start_action(...)/self.proxy.end_action()self.proxy.set_status(msg)for status bar updatesself.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}')
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_specfor 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.