"""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}")