Source code for simvx.editor.panels.inspector_sections._sprite_section

"""SpriteAnimationSection -- playback controls for AnimatedSprite2D / 3D.

Registered with the section registry via @register_inspector_section at
import time.
"""

from simvx.core import (
    AnimatedSprite2D,
    AnimatedSprite3D,
    Button,
    Control,
    DropDown,
    HBoxContainer,
    Label,
    SpinBox,
    Vec2,
)

from ._base import (
    InspectorContext,
    InspectorSection,
    _font_size,
    _make_property_row,
    _row_h,
    register_inspector_section,
)

_ANIM_HANDLED_PROPS = {"frame", "playing", "current_animation", "animations", "speed_scale"}

def _get_anim(node) -> object | None:
    """Return the active SpriteAnimation/SpriteAnimation3D or None."""
    name = getattr(node, "current_animation", None)
    if name and name in node.animations:
        return node.animations[name]
    return None

def _total_frames(node) -> int:
    """Total frame count for the current animation (or the full sheet)."""
    anim = _get_anim(node)
    if anim:
        return len(anim.frames)
    return max(getattr(node, "frames_h", 1) * getattr(node, "frames_v", 1), 1)

def _current_fps(node) -> float:
    anim = _get_anim(node)
    return anim.fps if anim else 10.0

[docs] @register_inspector_section class SpriteAnimationSection(InspectorSection): """Playback controls and frame info for AnimatedSprite2D / AnimatedSprite3D.""" section_title = "Sprite Animation" priority = 4
[docs] def can_handle(self, node): return isinstance(node, AnimatedSprite2D | AnimatedSprite3D)
[docs] def handled_properties(self, node): return _ANIM_HANDLED_PROPS
[docs] def build_rows(self, node, ctx): rows: list[Control] = [] total = _total_frames(node) # -- Playback controls bar -- bar = HBoxContainer() bar.separation = 2 first_btn = Button("|<") first_btn.size = Vec2(30, _row_h()) first_btn.font_size = _font_size() first_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, 0, c)) bar.add_child(first_btn) ctx.register_widget("anim_first", first_btn) prev_btn = Button("<") prev_btn.size = Vec2(30, _row_h()) prev_btn.font_size = _font_size() prev_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, max(n.frame - 1, 0), c)) bar.add_child(prev_btn) ctx.register_widget("anim_prev", prev_btn) play_label = "Pause" if node.playing else "Play" play_btn = Button(play_label) play_btn.size = Vec2(50, _row_h()) play_btn.font_size = _font_size() play_btn.pressed.connect(lambda n=node, c=ctx, b=play_btn: _toggle_playing(n, c, b)) bar.add_child(play_btn) ctx.register_widget("anim_play", play_btn) next_btn = Button(">") next_btn.size = Vec2(30, _row_h()) next_btn.font_size = _font_size() next_btn.pressed.connect( lambda n=node, c=ctx: _set_frame(n, min(n.frame + 1, _total_frames(n) - 1), c) ) bar.add_child(next_btn) ctx.register_widget("anim_next", next_btn) last_btn = Button(">|") last_btn.size = Vec2(30, _row_h()) last_btn.font_size = _font_size() last_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, _total_frames(n) - 1, c)) bar.add_child(last_btn) ctx.register_widget("anim_last", last_btn) rows.append(bar) # -- Frame info label -- frame_label = Label(f"Frame: {node.frame} / {total}") frame_label.font_size = _font_size() frame_label.size = Vec2(200, _row_h()) rows.append(_make_property_row("Frame", frame_label)) ctx.register_widget("anim_frame_label", frame_label) # -- FPS control -- fps = _current_fps(node) fps_spin = SpinBox(min_val=0.1, max_val=120.0, value=fps, step=0.5) fps_spin.font_size = _font_size() fps_spin.value_changed.connect(lambda val, n=node, c=ctx: _set_fps(n, val, c)) rows.append(_make_property_row("FPS", fps_spin)) ctx.register_widget("anim_fps", fps_spin) # -- Frames info -- _get_anim(node) anim_name = node.current_animation or "(none)" grid = f"{getattr(node, 'frames_h', 1)}x{getattr(node, 'frames_v', 1)}" info_text = f"{anim_name} | {total} frames | Grid: {grid}" info_label = Label(info_text) info_label.font_size = _font_size() info_label.size = Vec2(280, _row_h()) rows.append(_make_property_row("Frames", info_label)) ctx.register_widget("anim_info", info_label) # -- Animation list (if multiple) -- if len(node.animations) > 1: anim_names = list(node.animations) current_idx = anim_names.index(node.current_animation) if node.current_animation in anim_names else 0 dd = DropDown(items=anim_names, selected=current_idx) dd.font_size = _font_size() dd.item_selected.connect( lambda idx, n=node, c=ctx, names=anim_names: _set_animation(n, names[idx], c) ) rows.append(_make_property_row("Animation", dd)) ctx.register_widget("anim_dropdown", dd) return rows
def _set_frame(node, new_frame: int, ctx: InspectorContext): """Set current frame with undo support.""" old_frame = node.frame if old_frame == new_frame: return def do_fn(): node.frame = new_frame def undo_fn(): node.frame = old_frame ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} frame to {new_frame}") def _toggle_playing(node, ctx: InspectorContext, btn: Button): """Toggle playing state with undo support.""" old_val = node.playing new_val = not old_val def do_fn(): node.playing = new_val def undo_fn(): node.playing = old_val ctx.on_callable_command(do_fn, undo_fn, description=f"{'Pause' if old_val else 'Play'} {node.name}") btn.text = "Pause" if new_val else "Play" def _set_fps(node, new_fps: float, ctx: InspectorContext): """Set animation FPS with undo support.""" anim = _get_anim(node) if anim is None: return old_fps = anim.fps if old_fps == new_fps: return def do_fn(): anim.fps = new_fps def undo_fn(): anim.fps = old_fps ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} FPS to {new_fps}") def _set_animation(node, anim_name: str, ctx: InspectorContext): """Switch active animation with undo support.""" old_name = node.current_animation old_frame = node.frame if old_name == anim_name: return def do_fn(): node.current_animation = anim_name node.frame = 0 def undo_fn(): node.current_animation = old_name node.frame = old_frame ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} animation to {anim_name}")