wolfhece._menu_companion_abc ============================ .. py:module:: wolfhece._menu_companion_abc .. autoapi-nested-parse:: Abstract base class for menu-based companion objects in WolfMapViewer. Purpose ------- This module provides the building blocks to author new companion classes that integrate with the viewer's menu bar and interactive-action system. Design goals ------------ * **Encapsulation of state** — each companion instance is the natural home for all transient state needed by its actions (picked objects, step counters, intermediate geometry, …). The ABC guarantees a minimal, consistent interface while leaving full freedom to subclasses. * **Reduced boilerplate** — common operations (menu idempotency, guards, action registration/cleanup, standard dialogs) are provided once and reused by every subclass. * **Multi-step actions** — :class:`MultiStepAction` encapsulates the step-counter, per-step hint messages and state reset for interactions that span several mouse events. Typical usage ------------- :: from wolfhece._menu_companion_abc import AbstractCompanion, CompanionState, MultiStepAction class MyCompanion(AbstractCompanion): def __init__(self, viewer): super().__init__(viewer) # _make_action() creates a namespaced + viewer-bound MultiStepAction. # The action_id becomes e.g. 'mycompanion.place' automatically. self._place = self._make_action( 'place', ['Left-click to pick start point', 'Left-click to pick end point — Esc to cancel'], ) # Typed local state. self._pts: list = [] def menu_build(self) -> None: # No wx import needed — _build_menu handles it. self._build_menu(_('My Feature'), [ ActionItem(_('Place…'), self._on_place, _('Pick two points')), ]) # self._place.action_id is already namespaced ('mycompanion.place'). self._register_action( self._place.action_id, ldown=self._ldown_place, key=self._key_place, ) # -- event handlers ----------------------------------------------------- def _on_place(self, event): self._pts.clear() self._place.start() # viewer already bound def _ldown_place(self, viewer, ctx): self._pts.append((ctx.x, ctx.y)) if not self._place.advance(): # viewer already bound # All steps done — finalise. logging.info('Two points: %s', self._pts) self._place.cancel() # viewer already bound def _key_place(self, viewer, kb): if kb.key_code == 27: # Esc self._place.cancel() # viewer already bound return True return False Integration in PyDraw.py ------------------------ :: from ._my_feature_manager import MyCompanion ... self._my_feature = MyCompanion(self) ... self._my_feature.menu_build() Module Contents --------------- .. py:class:: CompanionState Minimal resettable key/value store for companion state. Subclass this to get typed attributes:: class _MyState(CompanionState): start_xy: tuple | None = None end_xy: tuple | None = None class MyCompanion(AbstractCompanion): def __init__(self, viewer): super().__init__(viewer) self._state = _MyState() .. py:attribute:: step :type: int .. py:method:: reset() -> None Reset ``step`` and all public mutable attributes to their class defaults. Attributes whose class-level default is a type (not a value) are skipped so that accidental re-initialisation of class-level type annotations does not cause errors. .. py:method:: advance() -> int Increment *step* and return the new value. .. py:class:: MultiStepAction(action_id: str | wolfhece._action_kind.ActionKind, step_hints: list[str], state: Optional[CompanionState] = None, viewer: Optional[wolfhece.PyDraw.WolfMapViewer] = None) Wraps a viewer interactive action that spans multiple input steps. :param action_id: The lowercase string (or :class:`ActionKind`) used to identify the action in the viewer's ``start_action`` / ``register_action`` system. :param step_hints: Ordered list of status-bar / log messages, one per step. The message at index *n* is shown while the user is performing step *n*. :param state: Optional :class:`CompanionState` to reset automatically when :meth:`start` and :meth:`cancel` are called. If not provided a plain :class:`CompanionState` is created. :param Typical pattern inside a companion: :param -----------------------------------: :param ::: self._pick_action = MultiStepAction( 'mycompanion pick', ['Click first point', 'Click second point'], state=self._state, ) :param In the ldown handler::: def _ldown(self, viewer, ctx): # store data … done = not self._pick_action.advance() # viewer already bound if done: self._finalise() self._pick_action.cancel() # resets and ends action .. py:attribute:: action_id :type: str .. py:attribute:: step_hints :type: list[str] .. py:attribute:: state :type: CompanionState :value: None .. py:attribute:: _viewer :type: Optional[wolfhece.PyDraw.WolfMapViewer] :value: None .. py:attribute:: _active :type: bool :value: False .. py:method:: _resolve_viewer(viewer: Optional[wolfhece.PyDraw.WolfMapViewer]) -> wolfhece.PyDraw.WolfMapViewer Return *viewer* if provided, else fall back to the bound viewer. Raises ``RuntimeError`` if neither is available. .. py:property:: is_active :type: bool ``True`` while the action has been started and not yet finished. .. py:property:: current_step :type: int Current zero-based step index. .. py:property:: current_hint :type: str Status message for the current step, or empty string if out of range. .. py:property:: total_steps :type: int .. py:method:: start(viewer: Optional[wolfhece.PyDraw.WolfMapViewer] = None, message: str = '') -> None Begin the action at step 0. :param viewer: The WolfMapViewer instance. May be omitted when a viewer was supplied to the constructor. :param message: Override for the initial hint (defaults to ``step_hints[0]``). .. py:method:: advance(viewer: Optional[wolfhece.PyDraw.WolfMapViewer] = None) -> bool Increment the step counter and refresh the viewer's action message. Returns ``True`` if there are more steps to perform, ``False`` when all steps have been completed (the caller should then finalise its logic and call :meth:`cancel` to end the action cleanly). :param viewer: May be omitted when a viewer was supplied to the constructor. .. py:method:: cancel(viewer: Optional[wolfhece.PyDraw.WolfMapViewer] = None, message: str = '') -> None Abort or finish the action and reset all state. Safe to call even when the action is not active. :param viewer: May be omitted when a viewer was supplied to the constructor. :param message: Optional status-bar text forwarded to :meth:`WolfMapViewer.end_action`. .. py:class:: ActionItem A single clickable (or checkable) menu entry. :param label: Text shown in the menu. :param handler: Callable invoked when the item is selected. Signature: ``(event) -> None`` — ``event`` can be ignored if not needed. :param help: Optional help string shown in the status bar. :param checkable: When ``True`` the item renders as a toggle checkbox. :param enabled: When ``False`` the item is greyed out on creation. .. py:attribute:: label :type: str .. py:attribute:: handler :type: Callable[Ellipsis, None] .. py:attribute:: help :type: str :value: '' .. py:attribute:: checkable :type: bool :value: False .. py:attribute:: enabled :type: bool :value: True .. py:class:: Separator Singleton sentinel that inserts a separator line in the menu. Use the pre-built :data:`SEPARATOR` instance rather than instantiating this class directly. .. py:attribute:: _instance :type: Separator | None :value: None .. py:data:: SEPARATOR :type: Separator .. py:class:: SubMenuSpec A labelled submenu containing its own list of entries. :param label: Text shown in the parent menu. :param items: Ordered list of :class:`ActionItem`, :data:`SEPARATOR`, or nested :class:`SubMenuSpec` objects. :param help: Optional help string shown in the status bar. .. py:attribute:: label :type: str .. py:attribute:: items :type: list[MenuEntry] :value: [] .. py:attribute:: help :type: str :value: '' .. py:data:: MenuEntry .. py:class:: AbstractCompanion(viewer: wolfhece.PyDraw.WolfMapViewer, dialogs: wolfhece.dialog_provider.DialogProvider | None = None, namespace: str | None = None) Bases: :py:obj:`abc.ABC` .. autoapi-inheritance-diagram:: wolfhece._menu_companion_abc.AbstractCompanion :parts: 1 :private-bases: Abstract base class for companion objects that integrate with WolfMapViewer. 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 (override :meth:`menu_build` to add one). Subclasses **must** implement :meth:`start`. All other methods are either concrete helpers or optional overrides. In particular, :meth:`menu_build` has a no-op default — companions that interact purely via mouse/keyboard events do not need a menu. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance that owns this companion. .. py:attribute:: _viewer :type: wolfhece.PyDraw.WolfMapViewer .. py:attribute:: _dialogs :type: wolfhece.dialog_provider.DialogProvider :value: None .. py:attribute:: _namespace :type: str :value: None .. py:attribute:: _menu :type: wx.Menu | None :value: None .. py:attribute:: _registered_action_ids :type: list[str] :value: [] .. py:method:: menu_build() -> None Build and attach the companion's menu to the viewer menubar. The default implementation does nothing — companions that work purely through mouse/keyboard interactions do not need a menu. Override this method when a menu is desired. **Preferred — wx-free approach** using :meth:`_build_menu`:: def menu_build(self) -> None: self._build_menu(_('My Feature'), [ ActionItem(_('Do something'), self._on_do), SEPARATOR, SubMenuSpec(_('Tools'), [ ActionItem(_('Export'), self._on_export), ]), ]) **Alternative — full wx control** (for advanced cases):: def menu_build(self) -> None: if self._menu is not None: return import wx self._menu = wx.Menu() ... Implementations **must** be idempotent. .. py:method:: start() -> None :abstractmethod: Activate the companion's primary interactive action. Implement this method by calling :meth:`_start_action` with the appropriate action id and status-bar hint:: def start(self) -> None: self._start_action(self._action_id('record'), 'Right-click to capture…') This method is intended to be called from notebook cells or scripts. It should not contain wx logic — delegate that to the menu-item callback (``_on_start``) which itself calls ``self.start()``:: def _on_start(self, _event): self.start() .. py:method:: stop() -> None Deactivate the companion's primary interactive action. The default implementation calls :meth:`_end_action`. Override to add post-processing (e.g. printing a summary):: def stop(self) -> None: self._end_action() print(f"{len(self.points)} item(s) recorded.") .. py:method:: _action_id(local_id: str) -> str Return a fully-qualified action id: ``'{namespace}.{local_id}'``. Using this method (instead of bare strings) guarantees that two companions that independently choose the same *local_id* will never collide in the viewer's action dispatch tables. :param local_id: Short, human-readable name for the action (e.g. ``'pick'``, ``'place wall'``). :returns: ``'{namespace}.{local_id}'`` — e.g. ``'mycompanion.pick'``. .. py:method:: _make_action(local_id: str, step_hints: list[str], state: Optional[CompanionState] = None) -> MultiStepAction Create a namespaced, viewer-bound :class:`MultiStepAction`. Shorthand for:: MultiStepAction( self._action_id(local_id), step_hints, state=state, viewer=self._viewer, ) The resulting ``action.action_id`` is already namespaced, so it can be passed directly to :meth:`_register_action`:: self._pick = self._make_action('pick', ['Click a point']) # In menu_build: self._register_action(self._pick.action_id, ldown=self._ldown) :param local_id: Short name scoped to this companion. :param step_hints: One hint message per interaction step. :param state: Optional shared :class:`CompanionState` instance. .. py:method:: _build_menu(title: str, items: list[MenuEntry]) -> None Build and attach the companion's menu from a declarative spec. No wx knowledge is required. Call this from :meth:`menu_build` instead of writing raw ``wx.Menu`` code. Idempotent — subsequent calls are no-ops once the menu is built. :param title: Label shown in the viewer's menu bar. :param items: Ordered list of :class:`ActionItem`, :data:`SEPARATOR`, or :class:`SubMenuSpec` objects. Example:: def menu_build(self) -> None: self._build_menu(_('My Tool'), [ ActionItem(_('Run'), self._on_run, _('Execute the tool')), SEPARATOR, SubMenuSpec(_('Settings'), [ ActionItem(_('Configure…'), self._on_configure), ]), ]) .. py:method:: _fill_wx_menu(menu: wx.Menu, items: list[MenuEntry]) -> None Recursively populate *menu* from *items*. Internal use only. .. py:method:: destroy() -> None Unregister all custom actions and release viewer resources. Call this when the companion is no longer needed. The method is safe to call multiple times. .. py:method:: _register_action(action_id: str | wolfhece._action_kind.ActionKind, *, rdown: Optional[Callable] = None, motion: Optional[Callable] = None, ldown: Optional[Callable] = None, key: Optional[Callable] = None, paint: Optional[Callable] = None, overload: bool = False) -> None Register a custom action in the viewer and track it for cleanup. All parameters are forwarded to :meth:`~wolfhece.PyDraw.WolfMapViewer.register_action`. The *action_id* is remembered so that :meth:`destroy` can call :meth:`~wolfhece.PyDraw.WolfMapViewer.unregister_action` automatically. :param action_id: Lowercase action identifier. :param rdown: ``(viewer, MouseContext) → None`` — right click. :param motion: ``(viewer, MouseContext) → None`` — mouse move. :param ldown: ``(viewer, MouseContext) → None`` — left click. :param key: ``(viewer, KeyboardSnapshot) → bool`` — key press. :param paint: ``(viewer) → None`` — OpenGL paint hook. :param overload: Save and restore displaced handlers on cleanup. .. py:method:: _unregister_action(action_id: str | wolfhece._action_kind.ActionKind) -> None Unregister a single custom action. :param action_id: The id passed to :meth:`_register_action`. .. py:method:: _start_action(action_id: str | wolfhece._action_kind.ActionKind, message: str = '') -> None Shortcut for ``self._viewer.start_action(action_id, message)``. .. py:method:: _end_action(message: str = '') -> None End the current viewer action via :meth:`WolfMapViewer.end_action`. Prefer this over calling ``viewer.start_action('', '')`` directly: ``end_action`` additionally resets ``active_vertex``, ``active_cloud_vertex_id``, fires the sculpt / assets end-action callbacks, and logs an "End of action" message. :param message: Optional status-bar text to display after the action ends (forwarded to :meth:`WolfMapViewer.end_action`). .. py:method:: _require_active_array() -> bool Return ``True`` if the viewer has an active array, else warn and return ``False``. .. py:method:: _require(condition: bool, warning: str) -> bool Generic guard: log *warning* and return ``False`` when *condition* is falsy. :param condition: Pre-evaluated boolean condition. :param warning: Message logged at WARNING level when the guard fails. .. py:method:: _show_error(message: str, title: str = '') -> None Display a modal error dialog. .. py:method:: _show_info(message: str, title: str = '') -> None Display a modal information dialog. .. py:method:: _confirm(message: str, title: str = '', default: str = 'yes') -> bool Display a Yes/No confirmation dialog. :param message: Question text. :param title: Dialog title (defaults to *"Confirm"*). :param default: Which button is the default — ``'yes'`` or ``'no'``. :returns: ``True`` if the user clicked *Yes*. .. py:method:: _ask_text(message: str, title: str = '', default: str = '') -> str | None Prompt the user for a text value. :returns: The entered string, or ``None`` if cancelled. .. py:method:: _ask_float(message: str, title: str = '', default: float | str = '') -> float | None Prompt the user for a floating-point value. :returns: The entered float, or ``None`` if cancelled or invalid. .. py:method:: _ask_integer(message: str, title: str = '', prompt: str = '', default: int = 0, min_value: int = 0, max_value: int = 0) -> int | None Prompt the user for an integer value via a spin-entry dialog. :returns: The entered integer, or ``None`` if cancelled. .. py:method:: _ask_single_choice(message: str, title: str, choices: list[str], preselected: int | None = None) -> str | None Show a single-choice list dialog. :returns: The selected string, or ``None`` if cancelled. .. py:method:: _ask_file_open(message: str, wildcard: str = 'all (*.*)|*.*', default_path: str = '') -> str | None Open a file-open dialog. :returns: Selected file path, or ``None`` if cancelled. .. py:method:: _ask_file_save(message: str, wildcard: str = 'all (*.*)|*.*', default_path: str = '', default_file: str = '') -> str | None Open a file-save dialog. :returns: Selected file path, or ``None`` if cancelled. .. py:method:: _ask_directory(message: str, default_path: str = '') -> str | None Open a directory-chooser dialog. :returns: Selected directory path, or ``None`` if cancelled. .. py:method:: _run_with_progress(title: str, steps: list[tuple[int, str, Callable]]) -> None Run a series of steps under a progress dialog. Each element of *steps* is ``(percent, label, callable)`` where the callable is invoked with no arguments. The progress dialog is always closed even if an exception occurs. Example:: self._run_with_progress( _("Processing"), [ (25, _("Loading…"), self._load), (75, _("Computing…"), self._compute), (100, _("Saving…"), self._save), ], ) :param title: Dialog title. :param steps: Ordered list of ``(percent, label, fn)`` tuples. .. py:method:: _set_status(message: str) -> None Write *message* to the viewer's status bar. Shorthand for ``self._viewer.set_statusbar_text(message)``. Useful in motion handlers to display live coordinates without needing to know the internal viewer API. :param message: Text to display in the status bar. .. py:method:: _force_redraw() -> None Ask the viewer to repaint immediately. Shorthand for ``self._viewer.Paint()``. Call this after modifying data that is drawn by a paint hook so that the screen reflects the change without waiting for the next natural repaint event. .. py:method:: _viewport_fraction(fraction: float = 0.008) -> float Return *fraction* × viewport width in world units. Useful for computing marker sizes that scale naturally with the current zoom level:: def _paint_markers(self, viewer) -> None: half = self._viewport_fraction(0.008) self._draw_crosses(self.points, half) :param fraction: Fraction of the visible viewport width (default 0.008). :return: World-space distance proportional to the current zoom. .. py:method:: _draw_crosses(points: Iterable[tuple[float, float]], half_size: float, color: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 1.0), *, selected_idx: int = -1, selected_color: tuple[float, float, float, float] = (1.0, 0.8, 0.0, 1.0), line_width: float = 2.0) -> None Draw a cross marker at each ``(x, y)`` point using OpenGL. No ``import OpenGL`` is needed in the companion — this helper handles it internally. Typical use inside a paint handler:: def _paint_markers(self, viewer) -> None: half = self._viewport_fraction(0.008) self._draw_crosses( self.markers, half, selected_idx=self._selected, ) :param points: Sequence of ``(x, y)`` world-coordinate pairs. :param half_size: Half arm-length of each cross, in world units. :param color: RGBA color for unselected crosses (each channel 0–1). :param selected_idx: Index of the cross to highlight (``-1`` = none). :param selected_color: RGBA color for the highlighted cross. :param line_width: OpenGL line width in pixels. .. py:method:: _draw_polyline(points: Iterable[tuple[float, float]], color: tuple[float, float, float, float] = (0.0, 0.5, 1.0, 1.0), *, closed: bool = False, line_width: float = 1.5) -> None Draw a polyline (connected line string) through *points* using OpenGL. :: def _paint_path(self, viewer) -> None: self._draw_polyline(self.path, (0.0, 0.7, 1.0, 1.0)) :param points: Sequence of ``(x, y)`` world-coordinate pairs (≥ 2). :param color: RGBA line color (each channel 0–1). :param closed: If ``True`` a closing segment is added back to the first point. :param line_width: OpenGL line width in pixels. .. py:method:: _draw_segments(segments: Iterable[tuple[float, float, float, float]], color: tuple[float, float, float, float] = (1.0, 1.0, 0.0, 1.0), *, line_width: float = 1.5) -> None Draw individual line segments using OpenGL. Each segment is a ``(x1, y1, x2, y2)`` tuple:: def _paint_edges(self, viewer) -> None: self._draw_segments( [(x0, y0, x1, y1) for x0, y0, x1, y1 in self.edges], ) :param segments: Sequence of ``(x1, y1, x2, y2)`` tuples. :param color: RGBA line color (each channel 0–1). :param line_width: OpenGL line width in pixels. .. py:method:: _draw_points(points: Iterable[tuple[float, float]], color: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 1.0), *, point_size: float = 4.0) -> None Draw filled dots at each ``(x, y)`` point using OpenGL. :: def _paint_nodes(self, viewer) -> None: self._draw_points(self.nodes, (0.2, 0.8, 0.2, 1.0), point_size=6.0) :param points: Sequence of ``(x, y)`` world-coordinate pairs. :param color: RGBA color (each channel 0–1). :param point_size: OpenGL point size in pixels.