"""Abstract companion base class.
This module intentionally focuses on :class:`AbstractUICompanion`.
Related helpers now live in dedicated modules:
- :mod:`wolfhece.plugins.types` for declarative menu/action dataclasses,
- :mod:`wolfhece.plugins.state` for resettable state containers,
- :mod:`wolfhece.plugins.actions` for interactive action helpers.
For backward compatibility, their main symbols are re-exported here.
"""
from __future__ import annotations
import logging
from abc import ABC
from typing import Iterable
from .actions import MultiStepAction
from .state import CompanionState
from .types import (
ActionSpec,
Keys,
MenuEntry,
MenuItem,
MultiStepSpec,
SEPARATOR,
Separator,
StepTransition,
StepSpec,
SubMenuSpec,
)
__all__ = [
'AbstractCompanionModel',
'AbstractUICompanion',
'ActionSpec',
'Keys',
'StepTransition',
'StepSpec',
'MultiStepSpec',
'MenuItem',
'ViewerProxy',
'CompanionState',
'MenuEntry',
'MultiStepAction',
'SEPARATOR',
'Separator',
'SubMenuSpec',
]
# Compatibility re-exports:
# Historically, CompanionState, MultiStepAction and declarative menu/action
# types lived in this module. They now live in dedicated modules but remain
# available here to preserve import stability.
from .viewer_proxy import ViewerProxy
# ---------------------------------------------------------------------------
# Optional domain-model contract
# ---------------------------------------------------------------------------
[docs]
class AbstractCompanionModel(ABC):
"""Pure business model contract for companion plugins.
Keep all business state and domain transitions in a model class that does
not depend on wx/viewer/OpenGL APIs. The UI companion can then compose this
model and stay focused on event wiring and rendering.
This default contract is intentionally lightweight to support plugins that
only need simple dataclasses. Override :meth:`reset` when your model must
clear transient state.
"""
[docs]
def reset(self) -> None:
"""Reset transient domain state (no-op by default)."""
# ---------------------------------------------------------------------------
# AbstractUICompanion — the ABC
# ---------------------------------------------------------------------------
[docs]
class AbstractUICompanion(ABC):
"""Abstract base class for companion objects that integrate with WolfMapViewer.
Viewer access policy
--------------------
Companion code should use :attr:`proxy` as the primary entry point for
viewer integration (actions, menus, dialogs, redraw, OpenGL helpers).
During the migration period, direct viewer access is still possible via the
private bridge attribute ``self.proxy._viewer`` for advanced cases where
no dedicated helper exists yet.
A *companion* is an object that:
1. Owns the transient state needed by its interactive actions.
2. Registers (and later cleans up) custom mouse/keyboard handlers in
the viewer's action dispatch tables.
3. Optionally appends its own ``wx.Menu`` to the viewer's menu bar
(declare it through :meth:`menu_spec`).
Subclasses **must** implement :meth:`start`. All other methods are
either concrete helpers or optional overrides. In particular,
:meth:`menu_spec` has a no-op default — companions that interact
purely via mouse/keyboard events do not need a menu.
"""
[docs]
model : AbstractCompanionModel | None
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
def __init__(self) -> None:
"""Initialize the companion instance.
The viewer reference is not available at this stage. Do not attempt
to access the viewer or proxy here. Use :meth:`configure` instead.
"""
pass
[docs]
def create_model(self) -> 'AbstractCompanionModel | None':
"""Create the companion's optional pure business model.
Override this to keep domain logic independent from UI concerns while
staying in the same plugin module (companion + model in one file).
Default implementation returns ``None``.
"""
return None
[docs]
def get_namespace(self) -> str:
"""Return the namespace string used to prefix action ids.
The default is the companion's class name lowercased. Override in a
subclass to choose a different prefix::
class MyCompanion(AbstractUICompanion):
def get_namespace(self) -> str:
return 'myfeature'
"""
return type(self).__name__.lower()
# ------------------------------------------------------------------
# Override hooks — declare your menu and actions
# ------------------------------------------------------------------
[docs]
def actions_spec(self) -> 'Iterable[ActionSpec | MultiStepSpec] | None':
"""Declare interactive handlers to register with the viewer.
Override this method declaratively by returning :class:`ActionSpec`
and/or :class:`MultiStepSpec` rows. The proxy performs registration
and handler adaptation::
def actions_spec(self):
return [
ActionSpec(self._pick.action_id, ldown=self._ldown, key=self._key),
MultiStepSpec(
'wall',
steps=[
StepSpec(hint='Pick first point', ldown=self._step1),
StepSpec(hint='Pick second point', ldown=self._step2),
],
),
]
For :class:`MultiStepSpec`, handlers can return :class:`StepTransition`
values (or equivalent strings) to drive the runtime state machine.
Return ``None`` (or an empty iterable) when there is no action to
register.
Called automatically by :meth:`build`. Do not call directly.
"""
# ------------------------------------------------------------------
# Override hooks — lifecycle callbacks
# ------------------------------------------------------------------
[docs]
def start(self) -> None:
"""Activate the companion's primary interactive action.
Default behaviour is declarative and driven by :meth:`actions_spec`:
- select the :class:`ActionSpec` marked ``primary=True``,
- otherwise fall back to the first declared :class:`ActionSpec`,
- call :meth:`ViewerProxy.start_action` with its ``action_id`` and
optional ``start_message``.
Override this method when startup needs additional business logic.
:raises RuntimeError: If no actions are declared or more than one
:class:`ActionSpec` is marked primary.
"""
primary = self._primary_action_spec()
if primary is None:
raise RuntimeError(
f"{type(self).__name__}: no ActionSpec declared. "
"Override start() or return at least one ActionSpec from actions_spec()."
)
self.proxy.start_action(primary.action_id, primary.start_message)
[docs]
def stop(self) -> None:
"""Deactivate the companion's primary interactive action.
The default implementation calls :meth:`proxy.end_action`. Override to
add post-processing (e.g. printing a summary)::
def stop(self) -> None:
self.proxy.end_action()
print(f"{len(self.points)} item(s) recorded.")
"""
self.proxy.end_action()
# ------------------------------------------------------------------
# Framework-managed lifecycle (called by the viewer owner)
# ------------------------------------------------------------------
[docs]
def build(self) -> None:
"""Build and attach the companion's menu to the viewer menubar.
The default implementation calls :meth:`menu_spec` to build any
declared menu, then :meth:`actions_spec` to wire up interactive
handlers. Override this method directly only for advanced cases
that the two hooks cannot express.
**Preferred approach** — override :meth:`menu_spec` and/or
:meth:`actions_spec`::
def menu_spec(self) -> tuple[str, list[MenuEntry]] | None:
return (_('My Feature'), [
MenuItem(_('Do something'), self._on_do),
SEPARATOR,
SubMenuSpec(_('Tools'), [
MenuItem(_('Export'), self._on_export),
]),
])
def actions_spec(self):
return [
ActionSpec(self._pick.action_id, ldown=self._ldown),
]
**Advanced — full wx control** (bypass both hooks)::
def build(self) -> None:
if self.proxy._menu is not None:
return
import wx
self.proxy._menu = wx.Menu()
...
self.proxy.register_action(...)
Implementations **must** be idempotent.
"""
spec = self.menu_spec()
if spec is not None:
title, items = spec
self.proxy.build_menu(title, items, host=self.menu_host())
action_specs = list(self.actions_spec() or [])
if action_specs:
self.proxy.register_actions(action_specs)
[docs]
def destroy(self) -> None:
"""Unregister all custom actions, remove the companion's menu, and
release viewer resources.
Call this when the companion is no longer needed. The method is
safe to call multiple times.
Override this method to add companion-specific teardown::
def destroy(self) -> None:
# custom pre-teardown ...
super().destroy()
:raises RuntimeError: Never — all wx errors are caught and logged.
"""
self.proxy.destroy()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
[docs]
def _iter_action_specs(self) -> 'Iterable[ActionSpec]':
"""Yield action specs normalized to :class:`ActionSpec`.
``MultiStepSpec`` entries are normalized to a synthetic
:class:`ActionSpec` for primary-action selection logic.
"""
for spec in self.actions_spec() or []:
if isinstance(spec, ActionSpec):
yield spec
continue
if isinstance(spec, MultiStepSpec):
step = spec.first_step
yield ActionSpec(
action_id=spec.action_id,
rdown=step.rdown,
motion=step.motion,
ldown=step.ldown,
key=step.key,
paint=step.paint,
overload=spec.overload,
primary=spec.primary,
start_message=spec.effective_start_message,
)
continue
raise TypeError(
f"{type(self).__name__}.actions_spec(): unsupported spec type {type(spec)!r}. "
"Expected ActionSpec or MultiStepSpec."
)
[docs]
def _primary_action_spec(self) -> 'ActionSpec | None':
"""Return the action spec used by the default :meth:`start` method.
Selection rules:
1. If one spec has ``primary=True``, use it.
2. If none are marked primary, use the first declared spec.
3. If multiple specs are marked primary, raise ``RuntimeError``.
"""
specs = list(self._iter_action_specs())
if not specs:
return None
primary_specs = [spec for spec in specs if spec.primary]
if len(primary_specs) > 1:
ids = ', '.join(str(spec.action_id) for spec in primary_specs)
raise RuntimeError(
f"{type(self).__name__}: multiple primary ActionSpec entries ({ids}). "
"Mark at most one ActionSpec with primary=True."
)
if primary_specs:
return primary_specs[0]
return specs[0]
# ------------------------------------------------------------------
# Dunder helpers
# ------------------------------------------------------------------
def __repr__(self) -> str:
registered = ', '.join(self.proxy._registered_action_ids) or '—'
return (
f"<{type(self).__name__} "
f"ns='{self.proxy._namespace}' "
f"menu={'attached' if self.proxy._menu else 'not built'} "
f"actions=[{registered}]>"
)