Cookbook structure: - lib/: Reusable component library (Button, StatBar, AnimationChain, etc.) - primitives/: Demo apps for individual components - features/: Demo apps for complex features (animation chaining, shaders) - apps/: Complete mini-applications (calculator, dialogue system) - automation/: Screenshot capture utilities API signature updates applied: - on_enter/on_exit/on_move callbacks now only receive (pos) per #230 - on_cell_enter/on_cell_exit callbacks only receive (cell_pos) per #230 - Animation chain library uses Timer-based sequencing (unaffected by #229) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2daebc84b5
commit
55f6ea9502
41 changed files with 8493 additions and 0 deletions
81
tests/cookbook/lib/__init__.py
Normal file
81
tests/cookbook/lib/__init__.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# McRogueFace Cookbook - Standard Widget Library
|
||||
"""
|
||||
Reusable UI widget patterns for game development.
|
||||
|
||||
Widgets:
|
||||
Button - Clickable button with hover/press states
|
||||
StatBar - Horizontal bar showing current/max value (HP, mana, XP)
|
||||
ChoiceList - Vertical list of selectable text options
|
||||
ScrollableList - Scrolling list with arbitrary item rendering
|
||||
TextBox - Word-wrapped text with typewriter effect
|
||||
DialogueBox - TextBox with speaker name display
|
||||
Modal - Overlay popup that blocks background input
|
||||
ConfirmModal - Pre-configured yes/no modal
|
||||
AlertModal - Pre-configured OK modal
|
||||
ToastManager - Auto-dismissing notification popups
|
||||
GridContainer - NxM clickable cells for inventory/slot systems
|
||||
|
||||
Utilities:
|
||||
AnimationChain - Sequential animation execution
|
||||
AnimationGroup - Parallel animation execution
|
||||
delay - Create a delay step for animation chains
|
||||
callback - Create a callback step for animation chains
|
||||
fade_in, fade_out - Opacity animations
|
||||
slide_in_from_* - Slide-in animations
|
||||
shake - Shake effect animation
|
||||
"""
|
||||
|
||||
from .button import Button, create_button_row, create_button_column
|
||||
from .stat_bar import StatBar, create_stat_bar_group
|
||||
from .choice_list import ChoiceList, create_menu
|
||||
from .text_box import TextBox, DialogueBox
|
||||
from .scrollable_list import ScrollableList
|
||||
from .modal import Modal, ConfirmModal, AlertModal
|
||||
from .toast import Toast, ToastManager
|
||||
from .grid_container import GridContainer
|
||||
from .anim_utils import (
|
||||
AnimationChain, AnimationGroup, AnimationSequence,
|
||||
PropertyAnimation, DelayStep, CallbackStep,
|
||||
delay, callback,
|
||||
fade_in, fade_out,
|
||||
slide_in_from_left, slide_in_from_right,
|
||||
slide_in_from_top, slide_in_from_bottom,
|
||||
pulse, shake
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Widgets
|
||||
'Button',
|
||||
'create_button_row',
|
||||
'create_button_column',
|
||||
'StatBar',
|
||||
'create_stat_bar_group',
|
||||
'ChoiceList',
|
||||
'create_menu',
|
||||
'TextBox',
|
||||
'DialogueBox',
|
||||
'ScrollableList',
|
||||
'Modal',
|
||||
'ConfirmModal',
|
||||
'AlertModal',
|
||||
'Toast',
|
||||
'ToastManager',
|
||||
'GridContainer',
|
||||
# Animation utilities
|
||||
'AnimationChain',
|
||||
'AnimationGroup',
|
||||
'AnimationSequence',
|
||||
'PropertyAnimation',
|
||||
'DelayStep',
|
||||
'CallbackStep',
|
||||
'delay',
|
||||
'callback',
|
||||
'fade_in',
|
||||
'fade_out',
|
||||
'slide_in_from_left',
|
||||
'slide_in_from_right',
|
||||
'slide_in_from_top',
|
||||
'slide_in_from_bottom',
|
||||
'pulse',
|
||||
'shake',
|
||||
]
|
||||
387
tests/cookbook/lib/anim_utils.py
Normal file
387
tests/cookbook/lib/anim_utils.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# McRogueFace Cookbook - Animation Utilities
|
||||
"""
|
||||
Utilities for complex animation orchestration.
|
||||
|
||||
Example:
|
||||
from lib.anim_utils import AnimationChain, AnimationGroup, delay
|
||||
|
||||
# Sequential animations
|
||||
chain = AnimationChain(
|
||||
(frame, "x", 200, 0.5),
|
||||
delay(0.2),
|
||||
(frame, "y", 300, 0.5),
|
||||
callback=on_complete
|
||||
)
|
||||
chain.start()
|
||||
|
||||
# Parallel animations
|
||||
group = AnimationGroup(
|
||||
(frame, "x", 200, 0.5),
|
||||
(frame, "opacity", 0.5, 0.5),
|
||||
callback=on_complete
|
||||
)
|
||||
group.start()
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class AnimationStep:
|
||||
"""Base class for animation steps."""
|
||||
|
||||
def start(self, callback):
|
||||
"""Start the step, call callback when done."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PropertyAnimation(AnimationStep):
|
||||
"""Single property animation step.
|
||||
|
||||
Args:
|
||||
target: The UI element to animate
|
||||
property: Property name (e.g., "x", "y", "opacity")
|
||||
value: Target value
|
||||
duration: Duration in seconds
|
||||
easing: Easing function (default: EASE_OUT)
|
||||
"""
|
||||
|
||||
def __init__(self, target, property_name, value, duration, easing=None):
|
||||
self.target = target
|
||||
self.property_name = property_name
|
||||
self.value = value
|
||||
self.duration = duration
|
||||
self.easing = easing or mcrfpy.Easing.EASE_OUT
|
||||
self._timer_name = None
|
||||
|
||||
def start(self, callback=None):
|
||||
"""Start the animation."""
|
||||
# Start the animation on the target
|
||||
self.target.animate(self.property_name, self.value, self.duration, self.easing)
|
||||
|
||||
# Schedule callback after duration
|
||||
if callback:
|
||||
self._timer_name = f"anim_step_{id(self)}"
|
||||
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
|
||||
|
||||
|
||||
class DelayStep(AnimationStep):
|
||||
"""Delay step - just waits for a duration."""
|
||||
|
||||
def __init__(self, duration):
|
||||
self.duration = duration
|
||||
self._timer_name = None
|
||||
|
||||
def start(self, callback=None):
|
||||
"""Wait for duration then call callback."""
|
||||
if callback:
|
||||
self._timer_name = f"delay_step_{id(self)}"
|
||||
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
|
||||
# If no callback, this is a no-op (shouldn't happen in a chain)
|
||||
|
||||
|
||||
class CallbackStep(AnimationStep):
|
||||
"""Step that calls a function."""
|
||||
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
|
||||
def start(self, callback=None):
|
||||
"""Call the function immediately, then callback."""
|
||||
self.func()
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
|
||||
def delay(duration):
|
||||
"""Create a delay step.
|
||||
|
||||
Args:
|
||||
duration: Delay in seconds
|
||||
|
||||
Returns:
|
||||
DelayStep object
|
||||
"""
|
||||
return DelayStep(duration)
|
||||
|
||||
|
||||
def callback(func):
|
||||
"""Create a callback step.
|
||||
|
||||
Args:
|
||||
func: Function to call (no arguments)
|
||||
|
||||
Returns:
|
||||
CallbackStep object
|
||||
"""
|
||||
return CallbackStep(func)
|
||||
|
||||
|
||||
def _parse_animation_spec(spec):
|
||||
"""Parse an animation specification.
|
||||
|
||||
Args:
|
||||
spec: Either an AnimationStep, or a tuple of (target, property, value, duration[, easing])
|
||||
|
||||
Returns:
|
||||
AnimationStep object
|
||||
"""
|
||||
if isinstance(spec, AnimationStep):
|
||||
return spec
|
||||
|
||||
if isinstance(spec, tuple):
|
||||
if len(spec) == 4:
|
||||
target, prop, value, duration = spec
|
||||
return PropertyAnimation(target, prop, value, duration)
|
||||
elif len(spec) == 5:
|
||||
target, prop, value, duration, easing = spec
|
||||
return PropertyAnimation(target, prop, value, duration, easing)
|
||||
|
||||
raise ValueError(f"Invalid animation spec: {spec}")
|
||||
|
||||
|
||||
class AnimationChain:
|
||||
"""Sequential animation execution.
|
||||
|
||||
Runs animations one after another. Each step must complete before
|
||||
the next one starts.
|
||||
|
||||
Args:
|
||||
*steps: Animation steps - either AnimationStep objects or tuples of
|
||||
(target, property, value, duration[, easing])
|
||||
callback: Optional function to call when chain completes
|
||||
loop: If True, restart chain when it completes
|
||||
|
||||
Example:
|
||||
chain = AnimationChain(
|
||||
(frame, "x", 200, 0.5),
|
||||
delay(0.2),
|
||||
(frame, "y", 300, 0.5),
|
||||
callback=lambda: print("Done!")
|
||||
)
|
||||
chain.start()
|
||||
"""
|
||||
|
||||
def __init__(self, *steps, callback=None, loop=False):
|
||||
self.steps = [_parse_animation_spec(s) for s in steps]
|
||||
self.callback = callback
|
||||
self.loop = loop
|
||||
self._current_index = 0
|
||||
self._running = False
|
||||
|
||||
def start(self):
|
||||
"""Start the animation chain."""
|
||||
self._current_index = 0
|
||||
self._running = True
|
||||
self._run_next()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the animation chain."""
|
||||
self._running = False
|
||||
|
||||
def _run_next(self):
|
||||
"""Run the next step in the chain."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._current_index >= len(self.steps):
|
||||
# Chain complete
|
||||
if self.callback:
|
||||
self.callback()
|
||||
if self.loop:
|
||||
self._current_index = 0
|
||||
self._run_next()
|
||||
else:
|
||||
self._running = False
|
||||
return
|
||||
|
||||
# Run current step
|
||||
step = self.steps[self._current_index]
|
||||
self._current_index += 1
|
||||
step.start(callback=self._run_next)
|
||||
|
||||
|
||||
class AnimationGroup:
|
||||
"""Parallel animation execution.
|
||||
|
||||
Runs all animations simultaneously. The group completes when
|
||||
all animations have finished.
|
||||
|
||||
Args:
|
||||
*steps: Animation steps - either AnimationStep objects or tuples of
|
||||
(target, property, value, duration[, easing])
|
||||
callback: Optional function to call when all animations complete
|
||||
|
||||
Example:
|
||||
group = AnimationGroup(
|
||||
(frame, "x", 200, 0.5),
|
||||
(frame, "opacity", 0.5, 0.3),
|
||||
callback=lambda: print("All done!")
|
||||
)
|
||||
group.start()
|
||||
"""
|
||||
|
||||
def __init__(self, *steps, callback=None):
|
||||
self.steps = [_parse_animation_spec(s) for s in steps]
|
||||
self.callback = callback
|
||||
self._pending = 0
|
||||
self._running = False
|
||||
|
||||
def start(self):
|
||||
"""Start all animations in parallel."""
|
||||
if not self.steps:
|
||||
if self.callback:
|
||||
self.callback()
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pending = len(self.steps)
|
||||
|
||||
for step in self.steps:
|
||||
step.start(callback=self._on_step_complete)
|
||||
|
||||
def stop(self):
|
||||
"""Stop tracking the group (note: individual animations continue)."""
|
||||
self._running = False
|
||||
|
||||
def _on_step_complete(self):
|
||||
"""Called when each step completes."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._pending -= 1
|
||||
if self._pending <= 0:
|
||||
self._running = False
|
||||
if self.callback:
|
||||
self.callback()
|
||||
|
||||
|
||||
class AnimationSequence:
|
||||
"""More complex animation sequencing with named steps and branching.
|
||||
|
||||
Args:
|
||||
steps: Dict mapping step names to animation specs or step lists
|
||||
start_step: Name of the first step to run
|
||||
callback: Optional function to call when sequence ends
|
||||
"""
|
||||
|
||||
def __init__(self, steps=None, start_step="start", callback=None):
|
||||
self.steps = steps or {}
|
||||
self.start_step = start_step
|
||||
self.callback = callback
|
||||
self._current_step = None
|
||||
self._running = False
|
||||
|
||||
def add_step(self, name, *animations, next_step=None):
|
||||
"""Add a named step.
|
||||
|
||||
Args:
|
||||
name: Step name
|
||||
*animations: Animation specs for this step
|
||||
next_step: Name of next step (or None to end)
|
||||
"""
|
||||
self.steps[name] = {
|
||||
"animations": [_parse_animation_spec(a) for a in animations],
|
||||
"next": next_step
|
||||
}
|
||||
|
||||
def start(self, step_name=None):
|
||||
"""Start the sequence from a given step."""
|
||||
self._running = True
|
||||
self._run_step(step_name or self.start_step)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the sequence."""
|
||||
self._running = False
|
||||
|
||||
def _run_step(self, name):
|
||||
"""Run a named step."""
|
||||
if not self._running or name not in self.steps:
|
||||
if name is None and self.callback:
|
||||
self.callback()
|
||||
return
|
||||
|
||||
self._current_step = name
|
||||
step_data = self.steps[name]
|
||||
animations = step_data.get("animations", [])
|
||||
next_step = step_data.get("next")
|
||||
|
||||
if not animations:
|
||||
# No animations, go directly to next
|
||||
self._run_step(next_step)
|
||||
return
|
||||
|
||||
# Run animations in parallel, then proceed to next step
|
||||
group = AnimationGroup(
|
||||
*animations,
|
||||
callback=lambda: self._run_step(next_step)
|
||||
)
|
||||
group.start()
|
||||
|
||||
|
||||
# Convenience functions for common patterns
|
||||
|
||||
def fade_in(target, duration=0.3, easing=None):
|
||||
"""Create a fade-in animation (opacity 0 to 1)."""
|
||||
target.opacity = 0
|
||||
return PropertyAnimation(target, "opacity", 1.0, duration, easing)
|
||||
|
||||
|
||||
def fade_out(target, duration=0.3, easing=None):
|
||||
"""Create a fade-out animation (current opacity to 0)."""
|
||||
return PropertyAnimation(target, "opacity", 0.0, duration, easing)
|
||||
|
||||
|
||||
def slide_in_from_left(target, distance=100, duration=0.3, easing=None):
|
||||
"""Create a slide-in from left animation."""
|
||||
original_x = target.x
|
||||
target.x = original_x - distance
|
||||
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
|
||||
def slide_in_from_right(target, distance=100, duration=0.3, easing=None):
|
||||
"""Create a slide-in from right animation."""
|
||||
original_x = target.x
|
||||
target.x = original_x + distance
|
||||
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
|
||||
def slide_in_from_top(target, distance=100, duration=0.3, easing=None):
|
||||
"""Create a slide-in from top animation."""
|
||||
original_y = target.y
|
||||
target.y = original_y - distance
|
||||
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
|
||||
def slide_in_from_bottom(target, distance=100, duration=0.3, easing=None):
|
||||
"""Create a slide-in from bottom animation."""
|
||||
original_y = target.y
|
||||
target.y = original_y + distance
|
||||
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
|
||||
def pulse(target, scale_factor=1.1, duration=0.2):
|
||||
"""Create a pulse animation (scale up then back)."""
|
||||
# Note: This requires scale animation support
|
||||
# If not available, we approximate with position
|
||||
original_x = target.x
|
||||
original_y = target.y
|
||||
offset = (scale_factor - 1) * 10 # Approximate offset
|
||||
|
||||
return AnimationChain(
|
||||
(target, "x", original_x - offset, duration / 2),
|
||||
(target, "x", original_x, duration / 2),
|
||||
)
|
||||
|
||||
|
||||
def shake(target, intensity=5, duration=0.3):
|
||||
"""Create a shake animation."""
|
||||
original_x = target.x
|
||||
step_duration = duration / 6
|
||||
|
||||
return AnimationChain(
|
||||
(target, "x", original_x - intensity, step_duration),
|
||||
(target, "x", original_x + intensity, step_duration),
|
||||
(target, "x", original_x - intensity * 0.5, step_duration),
|
||||
(target, "x", original_x + intensity * 0.5, step_duration),
|
||||
(target, "x", original_x - intensity * 0.25, step_duration),
|
||||
(target, "x", original_x, step_duration),
|
||||
)
|
||||
241
tests/cookbook/lib/button.py
Normal file
241
tests/cookbook/lib/button.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# McRogueFace Cookbook - Button Widget
|
||||
"""
|
||||
Clickable button with hover/press states and visual feedback.
|
||||
|
||||
Example:
|
||||
from lib.button import Button
|
||||
|
||||
def on_click():
|
||||
print("Button clicked!")
|
||||
|
||||
btn = Button("Start Game", pos=(100, 100), callback=on_click)
|
||||
scene.children.append(btn.frame)
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class Button:
|
||||
"""Clickable button with hover/press states.
|
||||
|
||||
Args:
|
||||
text: Button label text
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple, default (120, 40)
|
||||
callback: Function to call on click (no arguments)
|
||||
fill_color: Background color (default: dark gray)
|
||||
hover_color: Color when mouse hovers (default: lighter gray)
|
||||
press_color: Color when pressed (default: even lighter)
|
||||
text_color: Label color (default: white)
|
||||
outline_color: Border color (default: white)
|
||||
outline: Border thickness (default: 2)
|
||||
font_size: Text size (default: 16)
|
||||
enabled: Whether button is clickable (default: True)
|
||||
|
||||
Attributes:
|
||||
frame: The underlying mcrfpy.Frame (add this to scene)
|
||||
label: The mcrfpy.Caption for the text
|
||||
is_hovered: True if mouse is over button
|
||||
is_pressed: True if button is being pressed
|
||||
enabled: Whether button responds to clicks
|
||||
"""
|
||||
|
||||
# Default colors
|
||||
DEFAULT_FILL = mcrfpy.Color(60, 60, 70)
|
||||
DEFAULT_HOVER = mcrfpy.Color(80, 80, 95)
|
||||
DEFAULT_PRESS = mcrfpy.Color(100, 100, 120)
|
||||
DEFAULT_DISABLED = mcrfpy.Color(40, 40, 45)
|
||||
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
|
||||
DEFAULT_TEXT_DISABLED = mcrfpy.Color(120, 120, 120)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(200, 200, 210)
|
||||
|
||||
def __init__(self, text, pos, size=(120, 40), callback=None,
|
||||
fill_color=None, hover_color=None, press_color=None,
|
||||
text_color=None, outline_color=None, outline=2,
|
||||
font_size=16, enabled=True):
|
||||
self.text = text
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self.callback = callback
|
||||
self.font_size = font_size
|
||||
self._enabled = enabled
|
||||
|
||||
# Store colors
|
||||
self.fill_color = fill_color or self.DEFAULT_FILL
|
||||
self.hover_color = hover_color or self.DEFAULT_HOVER
|
||||
self.press_color = press_color or self.DEFAULT_PRESS
|
||||
self.text_color = text_color or self.DEFAULT_TEXT
|
||||
self.outline_color = outline_color or self.DEFAULT_OUTLINE
|
||||
|
||||
# State tracking
|
||||
self.is_hovered = False
|
||||
self.is_pressed = False
|
||||
|
||||
# Create the frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.fill_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=outline
|
||||
)
|
||||
|
||||
# Create the label (centered in frame)
|
||||
self.label = mcrfpy.Caption(
|
||||
text=text,
|
||||
pos=(size[0] / 2, size[1] / 2 - font_size / 2),
|
||||
fill_color=self.text_color,
|
||||
font_size=font_size
|
||||
)
|
||||
self.frame.children.append(self.label)
|
||||
|
||||
# Set up event handlers
|
||||
self.frame.on_click = self._on_click
|
||||
self.frame.on_enter = self._on_enter
|
||||
self.frame.on_exit = self._on_exit
|
||||
|
||||
# Apply initial state
|
||||
if not enabled:
|
||||
self._apply_disabled_style()
|
||||
|
||||
def _on_click(self, pos, button, action):
|
||||
"""Handle click events."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if button == "left":
|
||||
if action == "start":
|
||||
self.is_pressed = True
|
||||
self.frame.fill_color = self.press_color
|
||||
# Animate a subtle press effect
|
||||
self._animate_press()
|
||||
elif action == "end":
|
||||
self.is_pressed = False
|
||||
# Restore hover or normal state
|
||||
if self.is_hovered:
|
||||
self.frame.fill_color = self.hover_color
|
||||
else:
|
||||
self.frame.fill_color = self.fill_color
|
||||
# Trigger callback on release if still over button
|
||||
if self.is_hovered and self.callback:
|
||||
self.callback()
|
||||
|
||||
def _on_enter(self, pos):
|
||||
"""Handle mouse enter.
|
||||
|
||||
Note: #230 - on_enter now only receives position, not button/action
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
self.is_hovered = True
|
||||
if not self.is_pressed:
|
||||
self.frame.fill_color = self.hover_color
|
||||
|
||||
def _on_exit(self, pos):
|
||||
"""Handle mouse exit.
|
||||
|
||||
Note: #230 - on_exit now only receives position, not button/action
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
self.is_hovered = False
|
||||
self.is_pressed = False
|
||||
self.frame.fill_color = self.fill_color
|
||||
|
||||
def _animate_press(self):
|
||||
"""Animate a subtle scale bounce on press."""
|
||||
# Small scale down then back up
|
||||
# Note: If scale animation is not available, this is a no-op
|
||||
try:
|
||||
# Animate origin for a press effect
|
||||
original_x = self.frame.x
|
||||
original_y = self.frame.y
|
||||
# Quick bounce using position
|
||||
self.frame.animate("x", original_x + 2, 0.05, mcrfpy.Easing.EASE_OUT)
|
||||
self.frame.animate("y", original_y + 2, 0.05, mcrfpy.Easing.EASE_OUT)
|
||||
except Exception:
|
||||
pass # Animation not critical
|
||||
|
||||
def _apply_disabled_style(self):
|
||||
"""Apply disabled visual style."""
|
||||
self.frame.fill_color = self.DEFAULT_DISABLED
|
||||
self.label.fill_color = self.DEFAULT_TEXT_DISABLED
|
||||
|
||||
def _apply_enabled_style(self):
|
||||
"""Restore enabled visual style."""
|
||||
self.frame.fill_color = self.fill_color
|
||||
self.label.fill_color = self.text_color
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
"""Whether the button is clickable."""
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value):
|
||||
"""Enable or disable the button."""
|
||||
self._enabled = value
|
||||
if value:
|
||||
self._apply_enabled_style()
|
||||
else:
|
||||
self._apply_disabled_style()
|
||||
self.is_hovered = False
|
||||
self.is_pressed = False
|
||||
|
||||
def set_text(self, text):
|
||||
"""Change the button label."""
|
||||
self.text = text
|
||||
self.label.text = text
|
||||
|
||||
def set_callback(self, callback):
|
||||
"""Change the click callback."""
|
||||
self.callback = callback
|
||||
|
||||
|
||||
def create_button_row(labels, start_pos, spacing=10, size=(120, 40), callbacks=None):
|
||||
"""Create a horizontal row of buttons.
|
||||
|
||||
Args:
|
||||
labels: List of button labels
|
||||
start_pos: (x, y) position of first button
|
||||
spacing: Pixels between buttons
|
||||
size: (width, height) for all buttons
|
||||
callbacks: List of callbacks (or None for no callbacks)
|
||||
|
||||
Returns:
|
||||
List of Button objects
|
||||
"""
|
||||
buttons = []
|
||||
x, y = start_pos
|
||||
callbacks = callbacks or [None] * len(labels)
|
||||
|
||||
for label, callback in zip(labels, callbacks):
|
||||
btn = Button(label, pos=(x, y), size=size, callback=callback)
|
||||
buttons.append(btn)
|
||||
x += size[0] + spacing
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
def create_button_column(labels, start_pos, spacing=10, size=(120, 40), callbacks=None):
|
||||
"""Create a vertical column of buttons.
|
||||
|
||||
Args:
|
||||
labels: List of button labels
|
||||
start_pos: (x, y) position of first button
|
||||
spacing: Pixels between buttons
|
||||
size: (width, height) for all buttons
|
||||
callbacks: List of callbacks (or None for no callbacks)
|
||||
|
||||
Returns:
|
||||
List of Button objects
|
||||
"""
|
||||
buttons = []
|
||||
x, y = start_pos
|
||||
callbacks = callbacks or [None] * len(labels)
|
||||
|
||||
for label, callback in zip(labels, callbacks):
|
||||
btn = Button(label, pos=(x, y), size=size, callback=callback)
|
||||
buttons.append(btn)
|
||||
y += size[1] + spacing
|
||||
|
||||
return buttons
|
||||
317
tests/cookbook/lib/choice_list.py
Normal file
317
tests/cookbook/lib/choice_list.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
# McRogueFace Cookbook - Choice List Widget
|
||||
"""
|
||||
Vertical list of selectable text options with keyboard/mouse navigation.
|
||||
|
||||
Example:
|
||||
from lib.choice_list import ChoiceList
|
||||
|
||||
def on_select(index, value):
|
||||
print(f"Selected {value} at index {index}")
|
||||
|
||||
choices = ChoiceList(
|
||||
pos=(100, 100),
|
||||
size=(200, 150),
|
||||
choices=["New Game", "Load Game", "Options", "Quit"],
|
||||
on_select=on_select
|
||||
)
|
||||
scene.children.append(choices.frame)
|
||||
|
||||
# Navigate with keyboard
|
||||
choices.navigate(1) # Move down
|
||||
choices.navigate(-1) # Move up
|
||||
choices.confirm() # Select current
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class ChoiceList:
|
||||
"""Vertical list of selectable text options.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple
|
||||
choices: List of choice strings
|
||||
on_select: Callback(index, value) when selection is confirmed
|
||||
item_height: Height of each item (default: 30)
|
||||
selected_color: Background color of selected item
|
||||
hover_color: Background color when hovered
|
||||
normal_color: Background color of unselected items
|
||||
text_color: Color of choice text
|
||||
selected_text_color: Text color when selected
|
||||
font_size: Size of choice text (default: 16)
|
||||
outline: Border thickness (default: 1)
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
selected_index: Currently selected index
|
||||
choices: List of choice strings
|
||||
"""
|
||||
|
||||
DEFAULT_NORMAL = mcrfpy.Color(40, 40, 45)
|
||||
DEFAULT_HOVER = mcrfpy.Color(60, 60, 70)
|
||||
DEFAULT_SELECTED = mcrfpy.Color(80, 100, 140)
|
||||
DEFAULT_TEXT = mcrfpy.Color(200, 200, 200)
|
||||
DEFAULT_SELECTED_TEXT = mcrfpy.Color(255, 255, 255)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(100, 100, 110)
|
||||
|
||||
def __init__(self, pos, size, choices, on_select=None,
|
||||
item_height=30, selected_color=None, hover_color=None,
|
||||
normal_color=None, text_color=None, selected_text_color=None,
|
||||
font_size=16, outline=1):
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self._choices = list(choices)
|
||||
self.on_select = on_select
|
||||
self.item_height = item_height
|
||||
self.font_size = font_size
|
||||
self._selected_index = 0
|
||||
|
||||
# Colors
|
||||
self.normal_color = normal_color or self.DEFAULT_NORMAL
|
||||
self.hover_color = hover_color or self.DEFAULT_HOVER
|
||||
self.selected_color = selected_color or self.DEFAULT_SELECTED
|
||||
self.text_color = text_color or self.DEFAULT_TEXT
|
||||
self.selected_text_color = selected_text_color or self.DEFAULT_SELECTED_TEXT
|
||||
|
||||
# Hover tracking
|
||||
self._hovered_index = -1
|
||||
|
||||
# Create outer frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.normal_color,
|
||||
outline_color=self.DEFAULT_OUTLINE,
|
||||
outline=outline
|
||||
)
|
||||
|
||||
# Item frames and labels
|
||||
self._item_frames = []
|
||||
self._item_labels = []
|
||||
|
||||
self._rebuild_items()
|
||||
|
||||
def _rebuild_items(self):
|
||||
"""Rebuild all item frames and labels."""
|
||||
# Clear existing - pop all items from the collection
|
||||
while len(self.frame.children) > 0:
|
||||
self.frame.children.pop()
|
||||
self._item_frames = []
|
||||
self._item_labels = []
|
||||
|
||||
# Create item frames
|
||||
for i, choice in enumerate(self._choices):
|
||||
# Create item frame
|
||||
item_frame = mcrfpy.Frame(
|
||||
pos=(0, i * self.item_height),
|
||||
size=(self.size[0], self.item_height),
|
||||
fill_color=self.selected_color if i == self._selected_index else self.normal_color,
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Create label
|
||||
label = mcrfpy.Caption(
|
||||
text=choice,
|
||||
pos=(10, (self.item_height - self.font_size) / 2),
|
||||
fill_color=self.selected_text_color if i == self._selected_index else self.text_color,
|
||||
font_size=self.font_size
|
||||
)
|
||||
|
||||
item_frame.children.append(label)
|
||||
|
||||
# Set up click handler
|
||||
idx = i # Capture index in closure
|
||||
def make_click_handler(index):
|
||||
def handler(pos, button, action):
|
||||
if button == "left" and action == "end":
|
||||
self.set_selected(index)
|
||||
if self.on_select:
|
||||
self.on_select(index, self._choices[index])
|
||||
return handler
|
||||
|
||||
def make_enter_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_enter(index)
|
||||
return handler
|
||||
|
||||
def make_exit_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_exit(index)
|
||||
return handler
|
||||
|
||||
item_frame.on_click = make_click_handler(idx)
|
||||
item_frame.on_enter = make_enter_handler(idx)
|
||||
item_frame.on_exit = make_exit_handler(idx)
|
||||
|
||||
self._item_frames.append(item_frame)
|
||||
self._item_labels.append(label)
|
||||
self.frame.children.append(item_frame)
|
||||
|
||||
def _on_item_enter(self, index):
|
||||
"""Handle mouse entering an item."""
|
||||
self._hovered_index = index
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.hover_color
|
||||
|
||||
def _on_item_exit(self, index):
|
||||
"""Handle mouse leaving an item."""
|
||||
self._hovered_index = -1
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.normal_color
|
||||
|
||||
def _update_display(self):
|
||||
"""Update visual state of all items."""
|
||||
for i, (frame, label) in enumerate(zip(self._item_frames, self._item_labels)):
|
||||
if i == self._selected_index:
|
||||
frame.fill_color = self.selected_color
|
||||
label.fill_color = self.selected_text_color
|
||||
elif i == self._hovered_index:
|
||||
frame.fill_color = self.hover_color
|
||||
label.fill_color = self.text_color
|
||||
else:
|
||||
frame.fill_color = self.normal_color
|
||||
label.fill_color = self.text_color
|
||||
|
||||
@property
|
||||
def selected_index(self):
|
||||
"""Currently selected index."""
|
||||
return self._selected_index
|
||||
|
||||
@property
|
||||
def selected_value(self):
|
||||
"""Currently selected value."""
|
||||
if 0 <= self._selected_index < len(self._choices):
|
||||
return self._choices[self._selected_index]
|
||||
return None
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
"""List of choices."""
|
||||
return list(self._choices)
|
||||
|
||||
@choices.setter
|
||||
def choices(self, value):
|
||||
"""Set new choices list."""
|
||||
self._choices = list(value)
|
||||
self._selected_index = min(self._selected_index, len(self._choices) - 1)
|
||||
if self._selected_index < 0:
|
||||
self._selected_index = 0
|
||||
self._rebuild_items()
|
||||
|
||||
def set_selected(self, index):
|
||||
"""Set the selected index.
|
||||
|
||||
Args:
|
||||
index: Index to select (clamped to valid range)
|
||||
"""
|
||||
if not self._choices:
|
||||
return
|
||||
|
||||
old_index = self._selected_index
|
||||
self._selected_index = max(0, min(index, len(self._choices) - 1))
|
||||
|
||||
if old_index != self._selected_index:
|
||||
self._update_display()
|
||||
|
||||
def navigate(self, direction):
|
||||
"""Navigate up or down in the list.
|
||||
|
||||
Args:
|
||||
direction: +1 for down, -1 for up
|
||||
"""
|
||||
if not self._choices:
|
||||
return
|
||||
|
||||
new_index = self._selected_index + direction
|
||||
# Wrap around
|
||||
if new_index < 0:
|
||||
new_index = len(self._choices) - 1
|
||||
elif new_index >= len(self._choices):
|
||||
new_index = 0
|
||||
|
||||
self.set_selected(new_index)
|
||||
|
||||
def confirm(self):
|
||||
"""Confirm the current selection (triggers callback)."""
|
||||
if self.on_select and 0 <= self._selected_index < len(self._choices):
|
||||
self.on_select(self._selected_index, self._choices[self._selected_index])
|
||||
|
||||
def add_choice(self, choice, index=None):
|
||||
"""Add a choice at the given index (or end if None)."""
|
||||
if index is None:
|
||||
self._choices.append(choice)
|
||||
else:
|
||||
self._choices.insert(index, choice)
|
||||
self._rebuild_items()
|
||||
|
||||
def remove_choice(self, index):
|
||||
"""Remove the choice at the given index."""
|
||||
if 0 <= index < len(self._choices):
|
||||
del self._choices[index]
|
||||
if self._selected_index >= len(self._choices):
|
||||
self._selected_index = max(0, len(self._choices) - 1)
|
||||
self._rebuild_items()
|
||||
|
||||
def set_choice(self, index, value):
|
||||
"""Change the text of a choice."""
|
||||
if 0 <= index < len(self._choices):
|
||||
self._choices[index] = value
|
||||
self._item_labels[index].text = value
|
||||
|
||||
|
||||
def create_menu(pos, choices, on_select=None, title=None, width=200):
|
||||
"""Create a simple menu with optional title.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position
|
||||
choices: List of choice strings
|
||||
on_select: Callback(index, value)
|
||||
title: Optional menu title
|
||||
width: Menu width
|
||||
|
||||
Returns:
|
||||
Tuple of (container_frame, choice_list) or just choice_list if no title
|
||||
"""
|
||||
if title:
|
||||
# Create container with title
|
||||
item_height = 30
|
||||
title_height = 40
|
||||
total_height = title_height + len(choices) * item_height
|
||||
|
||||
container = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=(width, total_height),
|
||||
fill_color=mcrfpy.Color(30, 30, 35),
|
||||
outline_color=mcrfpy.Color(100, 100, 110),
|
||||
outline=2
|
||||
)
|
||||
|
||||
# Title caption
|
||||
title_cap = mcrfpy.Caption(
|
||||
text=title,
|
||||
pos=(width / 2, 10),
|
||||
fill_color=mcrfpy.Color(255, 255, 255),
|
||||
font_size=18
|
||||
)
|
||||
container.children.append(title_cap)
|
||||
|
||||
# Choice list below title
|
||||
choice_list = ChoiceList(
|
||||
pos=(0, title_height),
|
||||
size=(width, len(choices) * item_height),
|
||||
choices=choices,
|
||||
on_select=on_select,
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Add choice list frame as child
|
||||
container.children.append(choice_list.frame)
|
||||
|
||||
return container, choice_list
|
||||
else:
|
||||
return ChoiceList(
|
||||
pos=pos,
|
||||
size=(width, len(choices) * 30),
|
||||
choices=choices,
|
||||
on_select=on_select
|
||||
)
|
||||
344
tests/cookbook/lib/grid_container.py
Normal file
344
tests/cookbook/lib/grid_container.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# McRogueFace Cookbook - Grid Container Widget
|
||||
"""
|
||||
NxM clickable cells for inventory/slot systems.
|
||||
|
||||
Example:
|
||||
from lib.grid_container import GridContainer
|
||||
|
||||
def on_cell_click(x, y, item):
|
||||
print(f"Clicked cell ({x}, {y}): {item}")
|
||||
|
||||
inventory = GridContainer(
|
||||
pos=(100, 100),
|
||||
cell_size=(48, 48),
|
||||
grid_dims=(5, 4), # 5 columns, 4 rows
|
||||
on_cell_click=on_cell_click
|
||||
)
|
||||
scene.children.append(inventory.frame)
|
||||
|
||||
# Set cell contents
|
||||
inventory.set_cell(0, 0, sprite_index=10, count=5)
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class GridContainer:
|
||||
"""NxM clickable cells for inventory/slot systems.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
cell_size: (width, height) of each cell
|
||||
grid_dims: (columns, rows) grid dimensions
|
||||
on_cell_click: Callback(x, y, item_data) when cell is clicked
|
||||
on_cell_hover: Callback(x, y, item_data) when cell is hovered
|
||||
texture: Optional texture for sprites
|
||||
empty_color: Background color for empty cells
|
||||
filled_color: Background color for cells with items
|
||||
selected_color: Background color for selected cell
|
||||
hover_color: Background color when hovered
|
||||
cell_outline: Cell border thickness
|
||||
cell_spacing: Space between cells
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
selected: (x, y) of selected cell or None
|
||||
"""
|
||||
|
||||
DEFAULT_EMPTY = mcrfpy.Color(40, 40, 45)
|
||||
DEFAULT_FILLED = mcrfpy.Color(50, 50, 60)
|
||||
DEFAULT_SELECTED = mcrfpy.Color(80, 100, 140)
|
||||
DEFAULT_HOVER = mcrfpy.Color(60, 60, 75)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(70, 70, 80)
|
||||
|
||||
def __init__(self, pos, cell_size, grid_dims, on_cell_click=None,
|
||||
on_cell_hover=None, texture=None,
|
||||
empty_color=None, filled_color=None,
|
||||
selected_color=None, hover_color=None,
|
||||
cell_outline=1, cell_spacing=2):
|
||||
self.pos = pos
|
||||
self.cell_size = cell_size
|
||||
self.grid_dims = grid_dims # (cols, rows)
|
||||
self.on_cell_click = on_cell_click
|
||||
self.on_cell_hover = on_cell_hover
|
||||
self.texture = texture
|
||||
self.cell_outline = cell_outline
|
||||
self.cell_spacing = cell_spacing
|
||||
|
||||
# Colors
|
||||
self.empty_color = empty_color or self.DEFAULT_EMPTY
|
||||
self.filled_color = filled_color or self.DEFAULT_FILLED
|
||||
self.selected_color = selected_color or self.DEFAULT_SELECTED
|
||||
self.hover_color = hover_color or self.DEFAULT_HOVER
|
||||
self.outline_color = self.DEFAULT_OUTLINE
|
||||
|
||||
# State
|
||||
self._selected = None
|
||||
self._hovered = None
|
||||
self._cells = {} # (x, y) -> cell data
|
||||
self._cell_frames = {} # (x, y) -> frame
|
||||
|
||||
# Calculate total size
|
||||
cols, rows = grid_dims
|
||||
total_width = cols * cell_size[0] + (cols - 1) * cell_spacing
|
||||
total_height = rows * cell_size[1] + (rows - 1) * cell_spacing
|
||||
|
||||
# Create outer frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=(total_width, total_height),
|
||||
fill_color=mcrfpy.Color(0, 0, 0, 0), # Transparent
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Create cell frames
|
||||
self._create_cells()
|
||||
|
||||
def _create_cells(self):
|
||||
"""Create all cell frames."""
|
||||
cols, rows = self.grid_dims
|
||||
cw, ch = self.cell_size
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
x = col * (cw + self.cell_spacing)
|
||||
y = row * (ch + self.cell_spacing)
|
||||
|
||||
cell_frame = mcrfpy.Frame(
|
||||
pos=(x, y),
|
||||
size=self.cell_size,
|
||||
fill_color=self.empty_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=self.cell_outline
|
||||
)
|
||||
|
||||
# Set up event handlers
|
||||
def make_click(cx, cy):
|
||||
def handler(pos, button, action):
|
||||
if button == "left" and action == "end":
|
||||
self._on_cell_clicked(cx, cy)
|
||||
return handler
|
||||
|
||||
def make_enter(cx, cy):
|
||||
def handler(pos, button, action):
|
||||
self._on_cell_enter(cx, cy)
|
||||
return handler
|
||||
|
||||
def make_exit(cx, cy):
|
||||
def handler(pos, button, action):
|
||||
self._on_cell_exit(cx, cy)
|
||||
return handler
|
||||
|
||||
cell_frame.on_click = make_click(col, row)
|
||||
cell_frame.on_enter = make_enter(col, row)
|
||||
cell_frame.on_exit = make_exit(col, row)
|
||||
|
||||
self._cell_frames[(col, row)] = cell_frame
|
||||
self.frame.children.append(cell_frame)
|
||||
|
||||
def _on_cell_clicked(self, x, y):
|
||||
"""Handle cell click."""
|
||||
old_selected = self._selected
|
||||
self._selected = (x, y)
|
||||
|
||||
# Update display
|
||||
if old_selected:
|
||||
self._update_cell_display(*old_selected)
|
||||
self._update_cell_display(x, y)
|
||||
|
||||
# Fire callback
|
||||
if self.on_cell_click:
|
||||
item = self._cells.get((x, y))
|
||||
self.on_cell_click(x, y, item)
|
||||
|
||||
def _on_cell_enter(self, x, y):
|
||||
"""Handle cell hover enter."""
|
||||
self._hovered = (x, y)
|
||||
self._update_cell_display(x, y)
|
||||
|
||||
if self.on_cell_hover:
|
||||
item = self._cells.get((x, y))
|
||||
self.on_cell_hover(x, y, item)
|
||||
|
||||
def _on_cell_exit(self, x, y):
|
||||
"""Handle cell hover exit."""
|
||||
if self._hovered == (x, y):
|
||||
self._hovered = None
|
||||
self._update_cell_display(x, y)
|
||||
|
||||
def _update_cell_display(self, x, y):
|
||||
"""Update visual state of a cell."""
|
||||
if (x, y) not in self._cell_frames:
|
||||
return
|
||||
|
||||
frame = self._cell_frames[(x, y)]
|
||||
has_item = (x, y) in self._cells
|
||||
|
||||
if (x, y) == self._selected:
|
||||
frame.fill_color = self.selected_color
|
||||
elif (x, y) == self._hovered:
|
||||
frame.fill_color = self.hover_color
|
||||
elif has_item:
|
||||
frame.fill_color = self.filled_color
|
||||
else:
|
||||
frame.fill_color = self.empty_color
|
||||
|
||||
@property
|
||||
def selected(self):
|
||||
"""Currently selected cell (x, y) or None."""
|
||||
return self._selected
|
||||
|
||||
@selected.setter
|
||||
def selected(self, value):
|
||||
"""Set selected cell."""
|
||||
old = self._selected
|
||||
self._selected = value
|
||||
if old:
|
||||
self._update_cell_display(*old)
|
||||
if value:
|
||||
self._update_cell_display(*value)
|
||||
|
||||
def get_selected_item(self):
|
||||
"""Get the item in the selected cell."""
|
||||
if self._selected:
|
||||
return self._cells.get(self._selected)
|
||||
return None
|
||||
|
||||
def set_cell(self, x, y, sprite_index=None, count=None, data=None):
|
||||
"""Set cell contents.
|
||||
|
||||
Args:
|
||||
x, y: Cell coordinates
|
||||
sprite_index: Index in texture for sprite display
|
||||
count: Stack count to display
|
||||
data: Arbitrary data to associate with cell
|
||||
"""
|
||||
if (x, y) not in self._cell_frames:
|
||||
return
|
||||
|
||||
cell_frame = self._cell_frames[(x, y)]
|
||||
|
||||
# Store cell data
|
||||
self._cells[(x, y)] = {
|
||||
'sprite_index': sprite_index,
|
||||
'count': count,
|
||||
'data': data
|
||||
}
|
||||
|
||||
# Clear existing children except the frame itself
|
||||
while len(cell_frame.children) > 0:
|
||||
cell_frame.children.pop()
|
||||
|
||||
# Add sprite if we have texture and sprite_index
|
||||
if self.texture and sprite_index is not None:
|
||||
sprite = mcrfpy.Sprite(
|
||||
pos=(2, 2),
|
||||
texture=self.texture,
|
||||
sprite_index=sprite_index
|
||||
)
|
||||
# Scale sprite to fit cell (with padding)
|
||||
# Note: sprite scaling depends on texture cell size
|
||||
cell_frame.children.append(sprite)
|
||||
|
||||
# Add count label if count > 1
|
||||
if count is not None and count > 1:
|
||||
count_label = mcrfpy.Caption(
|
||||
text=str(count),
|
||||
pos=(self.cell_size[0] - 8, self.cell_size[1] - 12),
|
||||
fill_color=mcrfpy.Color(255, 255, 255),
|
||||
font_size=10
|
||||
)
|
||||
count_label.outline = 1
|
||||
count_label.outline_color = mcrfpy.Color(0, 0, 0)
|
||||
cell_frame.children.append(count_label)
|
||||
|
||||
self._update_cell_display(x, y)
|
||||
|
||||
def get_cell(self, x, y):
|
||||
"""Get cell data.
|
||||
|
||||
Args:
|
||||
x, y: Cell coordinates
|
||||
|
||||
Returns:
|
||||
Cell data dict or None if empty
|
||||
"""
|
||||
return self._cells.get((x, y))
|
||||
|
||||
def clear_cell(self, x, y):
|
||||
"""Clear a cell's contents.
|
||||
|
||||
Args:
|
||||
x, y: Cell coordinates
|
||||
"""
|
||||
if (x, y) in self._cells:
|
||||
del self._cells[(x, y)]
|
||||
|
||||
if (x, y) in self._cell_frames:
|
||||
cell_frame = self._cell_frames[(x, y)]
|
||||
while len(cell_frame.children) > 0:
|
||||
cell_frame.children.pop()
|
||||
|
||||
self._update_cell_display(x, y)
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all cells."""
|
||||
for key in list(self._cells.keys()):
|
||||
self.clear_cell(*key)
|
||||
self._selected = None
|
||||
|
||||
def find_empty_cell(self):
|
||||
"""Find the first empty cell.
|
||||
|
||||
Returns:
|
||||
(x, y) of empty cell or None if all full
|
||||
"""
|
||||
cols, rows = self.grid_dims
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
if (col, row) not in self._cells:
|
||||
return (col, row)
|
||||
return None
|
||||
|
||||
def is_full(self):
|
||||
"""Check if all cells are filled."""
|
||||
return len(self._cells) >= self.grid_dims[0] * self.grid_dims[1]
|
||||
|
||||
def swap_cells(self, x1, y1, x2, y2):
|
||||
"""Swap contents of two cells.
|
||||
|
||||
Args:
|
||||
x1, y1: First cell
|
||||
x2, y2: Second cell
|
||||
"""
|
||||
cell1 = self._cells.get((x1, y1))
|
||||
cell2 = self._cells.get((x2, y2))
|
||||
|
||||
if cell1:
|
||||
self.set_cell(x2, y2, **cell1)
|
||||
else:
|
||||
self.clear_cell(x2, y2)
|
||||
|
||||
if cell2:
|
||||
self.set_cell(x1, y1, **cell2)
|
||||
else:
|
||||
self.clear_cell(x1, y1)
|
||||
|
||||
def move_cell(self, from_x, from_y, to_x, to_y):
|
||||
"""Move cell contents to another cell.
|
||||
|
||||
Args:
|
||||
from_x, from_y: Source cell
|
||||
to_x, to_y: Destination cell
|
||||
|
||||
Returns:
|
||||
True if successful, False if destination not empty
|
||||
"""
|
||||
if (to_x, to_y) in self._cells:
|
||||
return False
|
||||
|
||||
cell = self._cells.get((from_x, from_y))
|
||||
if cell:
|
||||
self.set_cell(to_x, to_y, **cell)
|
||||
self.clear_cell(from_x, from_y)
|
||||
return True
|
||||
return False
|
||||
563
tests/cookbook/lib/item_manager.py
Normal file
563
tests/cookbook/lib/item_manager.py
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Item Manager - Coordinates item pickup/drop across multiple widgets
|
||||
|
||||
This module provides:
|
||||
- ItemManager: Central coordinator for item transfers
|
||||
- ItemSlot: Frame-based equipment slot widget
|
||||
- Item: Data class for item properties
|
||||
|
||||
The manager allows entities to move between:
|
||||
- Grid widgets (inventory, shop displays)
|
||||
- ItemSlot widgets (equipment slots)
|
||||
- The cursor (when held)
|
||||
|
||||
Usage:
|
||||
manager = ItemManager()
|
||||
manager.register_grid("inventory", inventory_grid)
|
||||
manager.register_slot("weapon", weapon_slot)
|
||||
|
||||
# Widgets call manager methods on click:
|
||||
manager.pickup_from_grid("inventory", cell_pos)
|
||||
manager.drop_to_slot("weapon")
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class ItemEntity(mcrfpy.Entity):
|
||||
"""Entity subclass that can hold item data."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.item = None # Will be set after adding to grid
|
||||
|
||||
|
||||
class Item:
|
||||
"""Data class representing an item's properties."""
|
||||
|
||||
def __init__(self, sprite_index, name, **stats):
|
||||
"""
|
||||
Args:
|
||||
sprite_index: Texture sprite index
|
||||
name: Display name
|
||||
**stats: Arbitrary stats (atk, def, int, price, etc.)
|
||||
"""
|
||||
self.sprite_index = sprite_index
|
||||
self.name = name
|
||||
self.stats = stats
|
||||
|
||||
def __repr__(self):
|
||||
return f"Item({self.name}, sprite={self.sprite_index})"
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
return self.stats.get('price', 0)
|
||||
|
||||
@property
|
||||
def atk(self):
|
||||
return self.stats.get('atk', 0)
|
||||
|
||||
@property
|
||||
def def_(self):
|
||||
return self.stats.get('def', 0)
|
||||
|
||||
@property
|
||||
def slot_type(self):
|
||||
"""Equipment slot this item fits in (weapon, shield, etc.)"""
|
||||
return self.stats.get('slot_type', None)
|
||||
|
||||
|
||||
class ItemSlot(mcrfpy.Frame):
|
||||
"""A frame that can hold a single item (for equipment slots)."""
|
||||
|
||||
def __init__(self, pos, size=(64, 64), slot_type=None,
|
||||
empty_color=(60, 60, 70), valid_color=(60, 100, 60),
|
||||
invalid_color=(100, 60, 60), filled_color=(80, 80, 90)):
|
||||
"""
|
||||
Args:
|
||||
pos: Position tuple (x, y)
|
||||
size: Size tuple (w, h)
|
||||
slot_type: Type of items this slot accepts (e.g., 'weapon', 'shield')
|
||||
empty_color: Color when empty and not hovered
|
||||
valid_color: Color when valid item is hovering
|
||||
invalid_color: Color when invalid item is hovering
|
||||
filled_color: Color when containing an item
|
||||
"""
|
||||
super().__init__(pos, size, fill_color=empty_color, outline=2, outline_color=(100, 100, 120))
|
||||
|
||||
self.slot_type = slot_type
|
||||
self.empty_color = empty_color
|
||||
self.valid_color = valid_color
|
||||
self.invalid_color = invalid_color
|
||||
self.filled_color = filled_color
|
||||
|
||||
# Item stored in this slot
|
||||
self.item = None
|
||||
self.item_sprite = None
|
||||
|
||||
# Manager reference (set by manager.register_slot)
|
||||
self.manager = None
|
||||
self.slot_name = None
|
||||
|
||||
# Setup sprite for displaying item
|
||||
sprite_scale = min(size[0], size[1]) / 16.0 * 0.8 # 80% of slot size
|
||||
self.item_sprite = mcrfpy.Sprite(
|
||||
pos=(size[0] * 0.1, size[1] * 0.1),
|
||||
texture=mcrfpy.default_texture,
|
||||
sprite_index=0
|
||||
)
|
||||
self.item_sprite.scale = sprite_scale
|
||||
self.item_sprite.visible = False
|
||||
self.children.append(self.item_sprite)
|
||||
|
||||
# Setup events
|
||||
self.on_click = self._on_click
|
||||
self.on_enter = self._on_enter
|
||||
self.on_exit = self._on_exit
|
||||
|
||||
def set_item(self, item):
|
||||
"""Set the item in this slot."""
|
||||
self.item = item
|
||||
if item:
|
||||
self.item_sprite.sprite_index = item.sprite_index
|
||||
self.item_sprite.visible = True
|
||||
self.fill_color = self.filled_color
|
||||
else:
|
||||
self.item_sprite.visible = False
|
||||
self.fill_color = self.empty_color
|
||||
|
||||
def clear(self):
|
||||
"""Remove item from slot."""
|
||||
self.set_item(None)
|
||||
|
||||
def can_accept(self, item):
|
||||
"""Check if this slot can accept the given item."""
|
||||
if self.slot_type is None:
|
||||
return True # Accepts anything
|
||||
if item is None:
|
||||
return True
|
||||
return item.slot_type == self.slot_type
|
||||
|
||||
def _on_click(self, pos, button, action):
|
||||
if action != "start" or button != "left":
|
||||
return
|
||||
if self.manager:
|
||||
self.manager.handle_slot_click(self.slot_name)
|
||||
|
||||
def _on_enter(self, pos, *args):
|
||||
if self.manager and self.manager.held_item:
|
||||
if self.can_accept(self.manager.held_item):
|
||||
self.fill_color = self.valid_color
|
||||
else:
|
||||
self.fill_color = self.invalid_color
|
||||
|
||||
def _on_exit(self, pos, *args):
|
||||
if self.item:
|
||||
self.fill_color = self.filled_color
|
||||
else:
|
||||
self.fill_color = self.empty_color
|
||||
|
||||
|
||||
class ItemManager:
|
||||
"""Coordinates item pickup and placement across multiple widgets."""
|
||||
|
||||
def __init__(self, scene):
|
||||
"""
|
||||
Args:
|
||||
scene: The mcrfpy.Scene to add cursor elements to
|
||||
"""
|
||||
self.scene = scene
|
||||
self.grids = {} # name -> (grid, {(x,y): Item})
|
||||
self.slots = {} # name -> ItemSlot
|
||||
|
||||
# Currently held item state
|
||||
self.held_item = None
|
||||
self.held_source = None # ('grid', name, pos) or ('slot', name)
|
||||
self.held_entity = None # For grid sources, the original entity
|
||||
|
||||
# Cursor sprite setup
|
||||
self.cursor_frame = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(64, 64),
|
||||
fill_color=(0, 0, 0, 0),
|
||||
outline=0
|
||||
)
|
||||
self.cursor_sprite = mcrfpy.Sprite(
|
||||
pos=(0, 0),
|
||||
texture=mcrfpy.default_texture,
|
||||
sprite_index=0
|
||||
)
|
||||
self.cursor_sprite.scale = 4.0
|
||||
self.cursor_sprite.visible = False
|
||||
self.cursor_frame.children.append(self.cursor_sprite)
|
||||
scene.children.append(self.cursor_frame)
|
||||
|
||||
# Callbacks for UI updates
|
||||
self.on_pickup = None # Called when item picked up
|
||||
self.on_drop = None # Called when item dropped
|
||||
self.on_cancel = None # Called when pickup cancelled
|
||||
|
||||
def register_grid(self, name, grid, items=None):
|
||||
"""Register a grid for item management.
|
||||
|
||||
Args:
|
||||
name: Unique name for this grid
|
||||
grid: mcrfpy.Grid instance
|
||||
items: Optional dict of {(x, y): Item} for initial items
|
||||
"""
|
||||
item_map = items or {}
|
||||
|
||||
# Add a color layer for highlighting if not present
|
||||
color_layer = grid.add_layer('color', z_index=-1)
|
||||
|
||||
self.grids[name] = (grid, item_map, color_layer)
|
||||
|
||||
# Setup grid event handlers
|
||||
grid.on_click = lambda pos, btn, act: self._on_grid_click(name, pos, btn, act)
|
||||
grid.on_cell_enter = lambda cell_pos: self._on_grid_cell_enter(name, cell_pos)
|
||||
grid.on_move = self._on_move
|
||||
|
||||
def register_slot(self, name, slot):
|
||||
"""Register an ItemSlot for item management.
|
||||
|
||||
Args:
|
||||
name: Unique name for this slot
|
||||
slot: ItemSlot instance
|
||||
"""
|
||||
self.slots[name] = slot
|
||||
slot.manager = self
|
||||
slot.slot_name = name
|
||||
|
||||
def add_item_to_grid(self, grid_name, pos, item):
|
||||
"""Add an item to a grid at the specified position.
|
||||
|
||||
Args:
|
||||
grid_name: Name of registered grid
|
||||
pos: (x, y) cell position
|
||||
item: Item instance
|
||||
|
||||
Returns:
|
||||
True if successful, False if cell occupied
|
||||
"""
|
||||
if grid_name not in self.grids:
|
||||
return False
|
||||
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
|
||||
if pos in item_map:
|
||||
return False # Cell occupied
|
||||
|
||||
# Create entity
|
||||
entity = ItemEntity()
|
||||
grid.entities.append(entity)
|
||||
entity.grid_pos = pos # Use grid_pos for tile coordinates
|
||||
entity.sprite_index = item.sprite_index
|
||||
entity.item = item # Store item reference on our subclass
|
||||
item_map[pos] = item
|
||||
|
||||
return True
|
||||
|
||||
def get_item_at(self, grid_name, pos):
|
||||
"""Get item at grid position, or None."""
|
||||
if grid_name not in self.grids:
|
||||
return None
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
return item_map.get(pos)
|
||||
|
||||
def _get_entity_at(self, grid_name, pos):
|
||||
"""Get entity at grid position."""
|
||||
grid, _, _ = self.grids[grid_name]
|
||||
for entity in grid.entities:
|
||||
gp = entity.grid_pos
|
||||
if (int(gp[0]), int(gp[1])) == pos:
|
||||
return entity
|
||||
return None
|
||||
|
||||
def _on_grid_click(self, grid_name, pos, button, action):
|
||||
"""Handle click on a registered grid."""
|
||||
if action != "start":
|
||||
return
|
||||
|
||||
if button == "right":
|
||||
self.cancel_pickup()
|
||||
return
|
||||
|
||||
if button != "left":
|
||||
return
|
||||
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
|
||||
# Convert screen pos to cell
|
||||
cell_size = 16 * grid.zoom
|
||||
x = int((pos[0] - grid.x) / cell_size)
|
||||
y = int((pos[1] - grid.y) / cell_size)
|
||||
grid_w, grid_h = grid.grid_size
|
||||
|
||||
if not (0 <= x < grid_w and 0 <= y < grid_h):
|
||||
return
|
||||
|
||||
cell_pos = (x, y)
|
||||
|
||||
if self.held_item is None:
|
||||
# Try to pick up
|
||||
if cell_pos in item_map:
|
||||
self._pickup_from_grid(grid_name, cell_pos)
|
||||
else:
|
||||
# Try to drop
|
||||
if cell_pos not in item_map:
|
||||
self._drop_to_grid(grid_name, cell_pos)
|
||||
|
||||
def _pickup_from_grid(self, grid_name, pos):
|
||||
"""Pick up item from grid cell."""
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
item = item_map[pos]
|
||||
entity = self._get_entity_at(grid_name, pos)
|
||||
|
||||
if entity:
|
||||
entity.visible = False
|
||||
|
||||
self.held_item = item
|
||||
self.held_source = ('grid', grid_name, pos)
|
||||
self.held_entity = entity
|
||||
|
||||
# Setup cursor
|
||||
self.cursor_sprite.sprite_index = item.sprite_index
|
||||
self.cursor_sprite.visible = True
|
||||
|
||||
# Highlight source cell
|
||||
color_layer.set(pos, (255, 255, 100, 200))
|
||||
|
||||
if self.on_pickup:
|
||||
self.on_pickup(item, grid_name, pos)
|
||||
|
||||
def _drop_to_grid(self, grid_name, pos):
|
||||
"""Drop held item to grid cell."""
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
|
||||
# Check if same grid and moving item
|
||||
if self.held_source[0] == 'grid':
|
||||
source_grid_name = self.held_source[1]
|
||||
source_pos = self.held_source[2]
|
||||
|
||||
# Remove from source
|
||||
source_grid, source_map, source_color_layer = self.grids[source_grid_name]
|
||||
if source_pos in source_map:
|
||||
del source_map[source_pos]
|
||||
|
||||
# Clear source highlight
|
||||
source_color_layer.set(source_pos, (0, 0, 0, 0))
|
||||
|
||||
# Move or recreate entity
|
||||
if grid_name == source_grid_name and self.held_entity:
|
||||
# Same grid - just move entity
|
||||
self.held_entity.grid_pos = pos
|
||||
self.held_entity.visible = True
|
||||
else:
|
||||
# Different grid - remove old, create new
|
||||
if self.held_entity:
|
||||
# Remove from source grid
|
||||
for i, e in enumerate(source_grid.entities):
|
||||
if e is self.held_entity:
|
||||
source_grid.entities.pop(i)
|
||||
break
|
||||
|
||||
# Create in target grid
|
||||
entity = ItemEntity()
|
||||
grid.entities.append(entity)
|
||||
entity.grid_pos = pos # Use grid_pos for tile coordinates
|
||||
entity.sprite_index = self.held_item.sprite_index
|
||||
entity.item = self.held_item
|
||||
self.held_entity = None
|
||||
|
||||
elif self.held_source[0] == 'slot':
|
||||
# Moving from slot to grid
|
||||
slot_name = self.held_source[1]
|
||||
slot = self.slots[slot_name]
|
||||
slot.clear()
|
||||
|
||||
# Create entity in grid
|
||||
entity = ItemEntity()
|
||||
grid.entities.append(entity)
|
||||
entity.grid_pos = pos # Use grid_pos for tile coordinates
|
||||
entity.sprite_index = self.held_item.sprite_index
|
||||
entity.item = self.held_item
|
||||
|
||||
# Add to target grid's item map
|
||||
item_map[pos] = self.held_item
|
||||
|
||||
if self.on_drop:
|
||||
self.on_drop(self.held_item, grid_name, pos)
|
||||
|
||||
# Clear held state
|
||||
self.cursor_sprite.visible = False
|
||||
self.held_item = None
|
||||
self.held_source = None
|
||||
self.held_entity = None
|
||||
|
||||
def handle_slot_click(self, slot_name):
|
||||
"""Handle click on a registered slot."""
|
||||
slot = self.slots[slot_name]
|
||||
|
||||
if self.held_item is None:
|
||||
# Try to pick up from slot
|
||||
if slot.item:
|
||||
self._pickup_from_slot(slot_name)
|
||||
else:
|
||||
# Try to drop to slot
|
||||
if slot.can_accept(self.held_item):
|
||||
self._drop_to_slot(slot_name)
|
||||
|
||||
def _pickup_from_slot(self, slot_name):
|
||||
"""Pick up item from slot."""
|
||||
slot = self.slots[slot_name]
|
||||
item = slot.item
|
||||
|
||||
self.held_item = item
|
||||
self.held_source = ('slot', slot_name)
|
||||
self.held_entity = None
|
||||
|
||||
slot.clear()
|
||||
|
||||
# Setup cursor
|
||||
self.cursor_sprite.sprite_index = item.sprite_index
|
||||
self.cursor_sprite.visible = True
|
||||
|
||||
if self.on_pickup:
|
||||
self.on_pickup(item, slot_name, None)
|
||||
|
||||
def _drop_to_slot(self, slot_name):
|
||||
"""Drop held item to slot."""
|
||||
slot = self.slots[slot_name]
|
||||
|
||||
# If slot has item, swap
|
||||
old_item = slot.item
|
||||
slot.set_item(self.held_item)
|
||||
|
||||
# Clean up source
|
||||
if self.held_source[0] == 'grid':
|
||||
source_grid_name = self.held_source[1]
|
||||
source_pos = self.held_source[2]
|
||||
source_grid, source_map, source_color_layer = self.grids[source_grid_name]
|
||||
|
||||
# Remove from source grid
|
||||
if source_pos in source_map:
|
||||
del source_map[source_pos]
|
||||
|
||||
# Clear highlight
|
||||
source_color_layer.set(source_pos, (0, 0, 0, 0))
|
||||
|
||||
# Remove entity
|
||||
if self.held_entity:
|
||||
for i, e in enumerate(source_grid.entities):
|
||||
if e is self.held_entity:
|
||||
source_grid.entities.pop(i)
|
||||
break
|
||||
|
||||
# If swapping, put old item in source position
|
||||
if old_item:
|
||||
self.add_item_to_grid(source_grid_name, source_pos, old_item)
|
||||
|
||||
elif self.held_source[0] == 'slot':
|
||||
# Slot to slot swap
|
||||
source_slot_name = self.held_source[1]
|
||||
source_slot = self.slots[source_slot_name]
|
||||
if old_item:
|
||||
source_slot.set_item(old_item)
|
||||
|
||||
if self.on_drop:
|
||||
self.on_drop(self.held_item, slot_name, None)
|
||||
|
||||
# Clear held state
|
||||
self.cursor_sprite.visible = False
|
||||
self.held_item = None
|
||||
self.held_source = None
|
||||
self.held_entity = None
|
||||
|
||||
def cancel_pickup(self):
|
||||
"""Cancel current pickup and return item to source."""
|
||||
if not self.held_item:
|
||||
return
|
||||
|
||||
if self.held_source[0] == 'grid':
|
||||
grid_name = self.held_source[1]
|
||||
pos = self.held_source[2]
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
|
||||
# Restore entity visibility
|
||||
if self.held_entity:
|
||||
self.held_entity.visible = True
|
||||
|
||||
# Restore item map
|
||||
item_map[pos] = self.held_item
|
||||
|
||||
# Clear highlight
|
||||
color_layer.set(pos, (0, 0, 0, 0))
|
||||
|
||||
elif self.held_source[0] == 'slot':
|
||||
slot_name = self.held_source[1]
|
||||
slot = self.slots[slot_name]
|
||||
slot.set_item(self.held_item)
|
||||
|
||||
if self.on_cancel:
|
||||
self.on_cancel(self.held_item)
|
||||
|
||||
# Clear held state
|
||||
self.cursor_sprite.visible = False
|
||||
self.held_item = None
|
||||
self.held_source = None
|
||||
self.held_entity = None
|
||||
|
||||
def _on_grid_cell_enter(self, grid_name, cell_pos):
|
||||
"""Handle cell hover on registered grid."""
|
||||
if not self.held_item:
|
||||
return
|
||||
|
||||
grid, item_map, color_layer = self.grids[grid_name]
|
||||
x, y = int(cell_pos[0]), int(cell_pos[1])
|
||||
|
||||
# Don't highlight source cell
|
||||
if (self.held_source[0] == 'grid' and
|
||||
self.held_source[1] == grid_name and
|
||||
self.held_source[2] == (x, y)):
|
||||
return
|
||||
|
||||
if (x, y) in item_map:
|
||||
color_layer.set((x, y), (255, 100, 100, 200)) # Red - occupied
|
||||
else:
|
||||
color_layer.set((x, y), (100, 255, 100, 200)) # Green - available
|
||||
|
||||
def _on_move(self, pos, *args):
|
||||
"""Update cursor position."""
|
||||
if self.cursor_sprite.visible:
|
||||
self.cursor_frame.x = pos[0] - 32
|
||||
self.cursor_frame.y = pos[1] - 32
|
||||
|
||||
|
||||
# Predefined items using the texture sprites
|
||||
ITEM_DATABASE = {
|
||||
'buckler': Item(101, "Buckler", def_=1, slot_type='shield', price=15),
|
||||
'shield': Item(102, "Shield", def_=2, slot_type='shield', price=30),
|
||||
'shortsword': Item(103, "Shortsword", atk=1, slot_type='weapon', price=20),
|
||||
'longsword': Item(104, "Longsword", atk=2, slot_type='weapon', price=40),
|
||||
'cleaver': Item(105, "Cleaver", atk=3, slot_type='weapon', two_handed=True, price=60),
|
||||
'buster': Item(106, "Buster Sword", atk=4, slot_type='weapon', two_handed=True, price=100),
|
||||
'training_sword': Item(107, "Training Sword", atk=2, slot_type='weapon', two_handed=True, price=25),
|
||||
'hammer': Item(117, "Hammer", atk=2, slot_type='weapon', price=35),
|
||||
'double_axe': Item(118, "Double Axe", atk=5, slot_type='weapon', two_handed=True, price=120),
|
||||
'axe': Item(119, "Axe", atk=3, slot_type='weapon', price=50),
|
||||
'wand': Item(129, "Wand", atk=1, int_=4, slot_type='weapon', price=45),
|
||||
'staff': Item(130, "Staff", atk=1, int_=7, slot_type='weapon', two_handed=True, price=80),
|
||||
'spear': Item(131, "Spear", atk=4, range_=1, slot_type='weapon', two_handed=True, price=55),
|
||||
'cloudy_potion': Item(113, "Cloudy Potion", slot_type='consumable', price=5),
|
||||
'str_potion': Item(114, "Strength Potion", slot_type='consumable', price=25),
|
||||
'health_potion': Item(115, "Health Potion", slot_type='consumable', price=15),
|
||||
'mana_potion': Item(116, "Mana Potion", slot_type='consumable', price=15),
|
||||
'lesser_cloudy': Item(125, "Lesser Cloudy Potion", slot_type='consumable', price=3),
|
||||
'lesser_str': Item(126, "Lesser Strength Potion", slot_type='consumable', price=12),
|
||||
'lesser_health': Item(127, "Lesser Health Potion", slot_type='consumable', price=8),
|
||||
'lesser_mana': Item(128, "Lesser Mana Potion", slot_type='consumable', price=8),
|
||||
}
|
||||
|
||||
|
||||
def get_item(name):
|
||||
"""Get an item from the database by name."""
|
||||
return ITEM_DATABASE.get(name)
|
||||
332
tests/cookbook/lib/modal.py
Normal file
332
tests/cookbook/lib/modal.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
# McRogueFace Cookbook - Modal Widget
|
||||
"""
|
||||
Overlay popup that blocks background input.
|
||||
|
||||
Example:
|
||||
from lib.modal import Modal
|
||||
|
||||
def on_confirm():
|
||||
print("Confirmed!")
|
||||
modal.hide()
|
||||
|
||||
modal = Modal(
|
||||
title="Confirm Action",
|
||||
message="Are you sure you want to proceed?",
|
||||
buttons=[
|
||||
("Cancel", modal.hide),
|
||||
("Confirm", on_confirm)
|
||||
]
|
||||
)
|
||||
modal.show(scene)
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class Modal:
|
||||
"""Overlay popup that blocks background input.
|
||||
|
||||
Args:
|
||||
title: Modal title text
|
||||
message: Modal body message (optional)
|
||||
content_frame: Custom content frame (overrides message)
|
||||
buttons: List of (label, callback) tuples
|
||||
width: Modal width (default: 400)
|
||||
height: Modal height (default: auto-calculated)
|
||||
overlay_color: Semi-transparent overlay color
|
||||
bg_color: Modal background color
|
||||
title_color: Title text color
|
||||
message_color: Message text color
|
||||
button_spacing: Space between buttons
|
||||
|
||||
Attributes:
|
||||
overlay: The overlay frame (add to scene to show)
|
||||
modal_frame: The modal content frame
|
||||
visible: Whether modal is currently visible
|
||||
"""
|
||||
|
||||
DEFAULT_OVERLAY = mcrfpy.Color(0, 0, 0, 180)
|
||||
DEFAULT_BG = mcrfpy.Color(40, 40, 50)
|
||||
DEFAULT_TITLE_COLOR = mcrfpy.Color(255, 255, 255)
|
||||
DEFAULT_MESSAGE_COLOR = mcrfpy.Color(200, 200, 200)
|
||||
DEFAULT_BUTTON_BG = mcrfpy.Color(60, 60, 75)
|
||||
DEFAULT_BUTTON_HOVER = mcrfpy.Color(80, 80, 100)
|
||||
|
||||
def __init__(self, title, message=None, content_frame=None,
|
||||
buttons=None, width=400, height=None,
|
||||
overlay_color=None, bg_color=None,
|
||||
title_color=None, message_color=None,
|
||||
button_spacing=10):
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.width = width
|
||||
self.button_spacing = button_spacing
|
||||
self._on_close = None
|
||||
|
||||
# Colors
|
||||
self.overlay_color = overlay_color or self.DEFAULT_OVERLAY
|
||||
self.bg_color = bg_color or self.DEFAULT_BG
|
||||
self.title_color = title_color or self.DEFAULT_TITLE_COLOR
|
||||
self.message_color = message_color or self.DEFAULT_MESSAGE_COLOR
|
||||
|
||||
# State
|
||||
self.visible = False
|
||||
self._scene = None
|
||||
self._buttons = buttons or []
|
||||
|
||||
# Calculate height if not specified
|
||||
if height is None:
|
||||
height = 60 # Title
|
||||
if message:
|
||||
# Rough estimate of message height
|
||||
lines = len(message) // 40 + message.count('\n') + 1
|
||||
height += lines * 20 + 20
|
||||
if content_frame:
|
||||
height += 150 # Default content height
|
||||
if buttons:
|
||||
height += 50 # Button row
|
||||
height = max(150, height)
|
||||
|
||||
self.height = height
|
||||
|
||||
# Create overlay (fullscreen semi-transparent)
|
||||
self.overlay = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(1024, 768), # Will be adjusted on show
|
||||
fill_color=self.overlay_color,
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Block clicks on overlay from reaching elements behind
|
||||
self.overlay.on_click = self._on_overlay_click
|
||||
|
||||
# Create modal frame (centered)
|
||||
modal_x = (1024 - width) // 2
|
||||
modal_y = (768 - height) // 2
|
||||
|
||||
self.modal_frame = mcrfpy.Frame(
|
||||
pos=(modal_x, modal_y),
|
||||
size=(width, height),
|
||||
fill_color=self.bg_color,
|
||||
outline_color=mcrfpy.Color(100, 100, 120),
|
||||
outline=2
|
||||
)
|
||||
self.overlay.children.append(self.modal_frame)
|
||||
|
||||
# Add title
|
||||
self._title_caption = mcrfpy.Caption(
|
||||
text=title,
|
||||
pos=(width // 2, 15),
|
||||
fill_color=self.title_color,
|
||||
font_size=18
|
||||
)
|
||||
self._title_caption.outline = 1
|
||||
self._title_caption.outline_color = mcrfpy.Color(0, 0, 0)
|
||||
self.modal_frame.children.append(self._title_caption)
|
||||
|
||||
# Add separator
|
||||
sep = mcrfpy.Frame(
|
||||
pos=(20, 45),
|
||||
size=(width - 40, 2),
|
||||
fill_color=mcrfpy.Color(80, 80, 100),
|
||||
outline=0
|
||||
)
|
||||
self.modal_frame.children.append(sep)
|
||||
|
||||
# Content area starts at y=55
|
||||
content_y = 55
|
||||
|
||||
# Add message if provided
|
||||
if message and not content_frame:
|
||||
self._message_caption = mcrfpy.Caption(
|
||||
text=message,
|
||||
pos=(20, content_y),
|
||||
fill_color=self.message_color,
|
||||
font_size=14
|
||||
)
|
||||
self.modal_frame.children.append(self._message_caption)
|
||||
elif content_frame:
|
||||
content_frame.x = 20
|
||||
content_frame.y = content_y
|
||||
self.modal_frame.children.append(content_frame)
|
||||
|
||||
# Add buttons at bottom
|
||||
if buttons:
|
||||
self._create_buttons(buttons)
|
||||
|
||||
def _create_buttons(self, buttons):
|
||||
"""Create button row at bottom of modal."""
|
||||
button_width = 100
|
||||
button_height = 35
|
||||
total_width = len(buttons) * button_width + (len(buttons) - 1) * self.button_spacing
|
||||
start_x = (self.width - total_width) // 2
|
||||
button_y = self.height - button_height - 15
|
||||
|
||||
self._button_frames = []
|
||||
for i, (label, callback) in enumerate(buttons):
|
||||
x = start_x + i * (button_width + self.button_spacing)
|
||||
|
||||
btn_frame = mcrfpy.Frame(
|
||||
pos=(x, button_y),
|
||||
size=(button_width, button_height),
|
||||
fill_color=self.DEFAULT_BUTTON_BG,
|
||||
outline_color=mcrfpy.Color(120, 120, 140),
|
||||
outline=1
|
||||
)
|
||||
|
||||
btn_label = mcrfpy.Caption(
|
||||
text=label,
|
||||
pos=(button_width // 2, (button_height - 14) // 2),
|
||||
fill_color=mcrfpy.Color(220, 220, 220),
|
||||
font_size=14
|
||||
)
|
||||
btn_frame.children.append(btn_label)
|
||||
|
||||
# Hover effect
|
||||
def make_enter(frame):
|
||||
def handler(pos, button, action):
|
||||
frame.fill_color = self.DEFAULT_BUTTON_HOVER
|
||||
return handler
|
||||
|
||||
def make_exit(frame):
|
||||
def handler(pos, button, action):
|
||||
frame.fill_color = self.DEFAULT_BUTTON_BG
|
||||
return handler
|
||||
|
||||
def make_click(cb):
|
||||
def handler(pos, button, action):
|
||||
if button == "left" and action == "end" and cb:
|
||||
cb()
|
||||
return handler
|
||||
|
||||
btn_frame.on_enter = make_enter(btn_frame)
|
||||
btn_frame.on_exit = make_exit(btn_frame)
|
||||
btn_frame.on_click = make_click(callback)
|
||||
|
||||
self._button_frames.append(btn_frame)
|
||||
self.modal_frame.children.append(btn_frame)
|
||||
|
||||
def _on_overlay_click(self, pos, button, action):
|
||||
"""Handle clicks on overlay (outside modal)."""
|
||||
# Check if click is outside modal
|
||||
if button == "left" and action == "end":
|
||||
mx, my = self.modal_frame.x, self.modal_frame.y
|
||||
mw, mh = self.modal_frame.w, self.modal_frame.h
|
||||
px, py = pos.x, pos.y
|
||||
|
||||
if not (mx <= px <= mx + mw and my <= py <= my + mh):
|
||||
# Click outside modal - close if allowed
|
||||
if self._on_close:
|
||||
self._on_close()
|
||||
|
||||
@property
|
||||
def on_close(self):
|
||||
"""Callback when modal is closed by clicking outside."""
|
||||
return self._on_close
|
||||
|
||||
@on_close.setter
|
||||
def on_close(self, callback):
|
||||
"""Set close callback."""
|
||||
self._on_close = callback
|
||||
|
||||
def show(self, scene=None):
|
||||
"""Show the modal.
|
||||
|
||||
Args:
|
||||
scene: Scene to add modal to (uses stored scene if not provided)
|
||||
"""
|
||||
if scene:
|
||||
self._scene = scene
|
||||
|
||||
if self._scene and not self.visible:
|
||||
# Adjust overlay size to match scene
|
||||
# Note: Assumes 1024x768 for now
|
||||
self._scene.children.append(self.overlay)
|
||||
self.visible = True
|
||||
|
||||
def hide(self):
|
||||
"""Hide the modal."""
|
||||
if self._scene and self.visible:
|
||||
# Remove overlay from scene
|
||||
try:
|
||||
# Find and remove overlay
|
||||
for i in range(len(self._scene.children)):
|
||||
if self._scene.children[i] is self.overlay:
|
||||
self._scene.children.pop()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self.visible = False
|
||||
|
||||
def set_message(self, message):
|
||||
"""Update the modal message."""
|
||||
self.message = message
|
||||
if hasattr(self, '_message_caption'):
|
||||
self._message_caption.text = message
|
||||
|
||||
def set_title(self, title):
|
||||
"""Update the modal title."""
|
||||
self.title = title
|
||||
self._title_caption.text = title
|
||||
|
||||
|
||||
class ConfirmModal(Modal):
|
||||
"""Pre-configured confirmation modal with Yes/No buttons.
|
||||
|
||||
Args:
|
||||
title: Modal title
|
||||
message: Confirmation message
|
||||
on_confirm: Callback when confirmed
|
||||
on_cancel: Callback when cancelled (optional)
|
||||
confirm_text: Text for confirm button (default: "Confirm")
|
||||
cancel_text: Text for cancel button (default: "Cancel")
|
||||
"""
|
||||
|
||||
def __init__(self, title, message, on_confirm, on_cancel=None,
|
||||
confirm_text="Confirm", cancel_text="Cancel", **kwargs):
|
||||
self._confirm_callback = on_confirm
|
||||
self._cancel_callback = on_cancel
|
||||
|
||||
buttons = [
|
||||
(cancel_text, self._on_cancel),
|
||||
(confirm_text, self._on_confirm)
|
||||
]
|
||||
|
||||
super().__init__(title, message=message, buttons=buttons, **kwargs)
|
||||
|
||||
def _on_confirm(self):
|
||||
"""Handle confirm button."""
|
||||
self.hide()
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _on_cancel(self):
|
||||
"""Handle cancel button."""
|
||||
self.hide()
|
||||
if self._cancel_callback:
|
||||
self._cancel_callback()
|
||||
|
||||
|
||||
class AlertModal(Modal):
|
||||
"""Pre-configured alert modal with single OK button.
|
||||
|
||||
Args:
|
||||
title: Modal title
|
||||
message: Alert message
|
||||
on_dismiss: Callback when dismissed (optional)
|
||||
button_text: Text for button (default: "OK")
|
||||
"""
|
||||
|
||||
def __init__(self, title, message, on_dismiss=None,
|
||||
button_text="OK", **kwargs):
|
||||
self._dismiss_callback = on_dismiss
|
||||
|
||||
buttons = [(button_text, self._on_dismiss)]
|
||||
|
||||
super().__init__(title, message=message, buttons=buttons,
|
||||
width=kwargs.pop('width', 350), **kwargs)
|
||||
|
||||
def _on_dismiss(self):
|
||||
"""Handle dismiss button."""
|
||||
self.hide()
|
||||
if self._dismiss_callback:
|
||||
self._dismiss_callback()
|
||||
351
tests/cookbook/lib/scrollable_list.py
Normal file
351
tests/cookbook/lib/scrollable_list.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
# McRogueFace Cookbook - Scrollable List Widget
|
||||
"""
|
||||
Scrolling list with arbitrary item rendering.
|
||||
|
||||
Example:
|
||||
from lib.scrollable_list import ScrollableList
|
||||
|
||||
def render_item(item, frame, index, selected):
|
||||
# Add item content to frame
|
||||
label = mcrfpy.Caption(text=item['name'], pos=(10, 5))
|
||||
frame.children.append(label)
|
||||
|
||||
items = [{'name': f'Item {i}'} for i in range(50)]
|
||||
|
||||
scroll_list = ScrollableList(
|
||||
pos=(100, 100),
|
||||
size=(300, 400),
|
||||
items=items,
|
||||
render_item=render_item,
|
||||
item_height=40
|
||||
)
|
||||
scene.children.append(scroll_list.frame)
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class ScrollableList:
|
||||
"""Scrolling list with arbitrary item rendering.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple
|
||||
items: List of items to display
|
||||
item_height: Height of each item row (default: 30)
|
||||
render_item: Callback(item, frame, index, selected) to render item content
|
||||
on_select: Callback(index, item) when item is selected
|
||||
bg_color: Background color (default: dark)
|
||||
item_bg_color: Normal item background
|
||||
selected_bg_color: Selected item background
|
||||
hover_bg_color: Hovered item background
|
||||
outline_color: Border color
|
||||
outline: Border thickness
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
items: List of items
|
||||
selected_index: Currently selected index
|
||||
scroll_offset: Current scroll position
|
||||
"""
|
||||
|
||||
DEFAULT_BG = mcrfpy.Color(30, 30, 35)
|
||||
DEFAULT_ITEM_BG = mcrfpy.Color(35, 35, 40)
|
||||
DEFAULT_SELECTED_BG = mcrfpy.Color(60, 80, 120)
|
||||
DEFAULT_HOVER_BG = mcrfpy.Color(50, 50, 60)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(80, 80, 90)
|
||||
|
||||
def __init__(self, pos, size, items=None, item_height=30,
|
||||
render_item=None, on_select=None,
|
||||
bg_color=None, item_bg_color=None,
|
||||
selected_bg_color=None, hover_bg_color=None,
|
||||
outline_color=None, outline=1):
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self._items = list(items) if items else []
|
||||
self.item_height = item_height
|
||||
self.render_item = render_item or self._default_render
|
||||
self.on_select = on_select
|
||||
|
||||
# Colors
|
||||
self.bg_color = bg_color or self.DEFAULT_BG
|
||||
self.item_bg_color = item_bg_color or self.DEFAULT_ITEM_BG
|
||||
self.selected_bg_color = selected_bg_color or self.DEFAULT_SELECTED_BG
|
||||
self.hover_bg_color = hover_bg_color or self.DEFAULT_HOVER_BG
|
||||
self.outline_color = outline_color or self.DEFAULT_OUTLINE
|
||||
|
||||
# State
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._hovered_index = -1
|
||||
self._visible_count = int(size[1] / item_height)
|
||||
|
||||
# Create outer frame with clipping
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.bg_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=outline
|
||||
)
|
||||
self.frame.clip_children = True
|
||||
|
||||
# Content container (scrolls within frame)
|
||||
self._content = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(size[0], max(size[1], len(self._items) * item_height)),
|
||||
fill_color=mcrfpy.Color(0, 0, 0, 0), # Transparent
|
||||
outline=0
|
||||
)
|
||||
self.frame.children.append(self._content)
|
||||
|
||||
# Set up scroll handler
|
||||
self.frame.on_move = self._on_mouse_move
|
||||
|
||||
# Build initial items
|
||||
self._rebuild_items()
|
||||
|
||||
def _default_render(self, item, frame, index, selected):
|
||||
"""Default item renderer - just shows string representation."""
|
||||
text = str(item) if not isinstance(item, dict) else item.get('name', str(item))
|
||||
label = mcrfpy.Caption(
|
||||
text=text,
|
||||
pos=(10, (self.item_height - 14) / 2),
|
||||
fill_color=mcrfpy.Color(200, 200, 200),
|
||||
font_size=14
|
||||
)
|
||||
frame.children.append(label)
|
||||
|
||||
def _rebuild_items(self):
|
||||
"""Rebuild all item frames."""
|
||||
# Clear content
|
||||
while len(self._content.children) > 0:
|
||||
self._content.children.pop()
|
||||
|
||||
# Update content size
|
||||
total_height = len(self._items) * self.item_height
|
||||
self._content.h = max(self.size[1], total_height)
|
||||
|
||||
# Create item frames
|
||||
self._item_frames = []
|
||||
for i, item in enumerate(self._items):
|
||||
item_frame = mcrfpy.Frame(
|
||||
pos=(0, i * self.item_height),
|
||||
size=(self.size[0], self.item_height),
|
||||
fill_color=self.item_bg_color,
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Set up click handler
|
||||
def make_click_handler(index):
|
||||
def handler(pos, button, action):
|
||||
if button == "left" and action == "end":
|
||||
self.select(index)
|
||||
return handler
|
||||
|
||||
def make_enter_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_enter(index)
|
||||
return handler
|
||||
|
||||
def make_exit_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_exit(index)
|
||||
return handler
|
||||
|
||||
item_frame.on_click = make_click_handler(i)
|
||||
item_frame.on_enter = make_enter_handler(i)
|
||||
item_frame.on_exit = make_exit_handler(i)
|
||||
|
||||
# Render item content
|
||||
is_selected = i == self._selected_index
|
||||
self.render_item(item, item_frame, i, is_selected)
|
||||
|
||||
self._item_frames.append(item_frame)
|
||||
self._content.children.append(item_frame)
|
||||
|
||||
def _on_item_enter(self, index):
|
||||
"""Handle mouse entering an item."""
|
||||
self._hovered_index = index
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.hover_bg_color
|
||||
|
||||
def _on_item_exit(self, index):
|
||||
"""Handle mouse leaving an item."""
|
||||
if self._hovered_index == index:
|
||||
self._hovered_index = -1
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.item_bg_color
|
||||
|
||||
def _on_mouse_move(self, pos, button, action):
|
||||
"""Handle mouse movement for scroll wheel detection."""
|
||||
# Note: This is a placeholder - actual scroll wheel handling
|
||||
# depends on the engine's input system
|
||||
pass
|
||||
|
||||
def _update_item_display(self):
|
||||
"""Update visual state of items."""
|
||||
for i, frame in enumerate(self._item_frames):
|
||||
if i == self._selected_index:
|
||||
frame.fill_color = self.selected_bg_color
|
||||
elif i == self._hovered_index:
|
||||
frame.fill_color = self.hover_bg_color
|
||||
else:
|
||||
frame.fill_color = self.item_bg_color
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
"""List of items."""
|
||||
return list(self._items)
|
||||
|
||||
@items.setter
|
||||
def items(self, value):
|
||||
"""Set new items list."""
|
||||
self._items = list(value)
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._rebuild_items()
|
||||
|
||||
@property
|
||||
def selected_index(self):
|
||||
"""Currently selected index (-1 if none)."""
|
||||
return self._selected_index
|
||||
|
||||
@property
|
||||
def selected_item(self):
|
||||
"""Currently selected item (None if none)."""
|
||||
if 0 <= self._selected_index < len(self._items):
|
||||
return self._items[self._selected_index]
|
||||
return None
|
||||
|
||||
@property
|
||||
def scroll_offset(self):
|
||||
"""Current scroll position in pixels."""
|
||||
return self._scroll_offset
|
||||
|
||||
def select(self, index):
|
||||
"""Select an item by index.
|
||||
|
||||
Args:
|
||||
index: Item index to select
|
||||
"""
|
||||
if not self._items:
|
||||
return
|
||||
|
||||
old_index = self._selected_index
|
||||
self._selected_index = max(-1, min(index, len(self._items) - 1))
|
||||
|
||||
if old_index != self._selected_index:
|
||||
self._update_item_display()
|
||||
if self.on_select and self._selected_index >= 0:
|
||||
self.on_select(self._selected_index, self._items[self._selected_index])
|
||||
|
||||
def scroll(self, delta):
|
||||
"""Scroll the list by a number of items.
|
||||
|
||||
Args:
|
||||
delta: Number of items to scroll (positive = down)
|
||||
"""
|
||||
max_scroll = max(0, len(self._items) * self.item_height - self.size[1])
|
||||
new_offset = self._scroll_offset + delta * self.item_height
|
||||
new_offset = max(0, min(new_offset, max_scroll))
|
||||
|
||||
if new_offset != self._scroll_offset:
|
||||
self._scroll_offset = new_offset
|
||||
self._content.y = -self._scroll_offset
|
||||
|
||||
def scroll_to(self, index):
|
||||
"""Scroll to make an item visible.
|
||||
|
||||
Args:
|
||||
index: Item index to scroll to
|
||||
"""
|
||||
if not 0 <= index < len(self._items):
|
||||
return
|
||||
|
||||
item_top = index * self.item_height
|
||||
item_bottom = item_top + self.item_height
|
||||
|
||||
if item_top < self._scroll_offset:
|
||||
self._scroll_offset = item_top
|
||||
elif item_bottom > self._scroll_offset + self.size[1]:
|
||||
self._scroll_offset = item_bottom - self.size[1]
|
||||
|
||||
self._content.y = -self._scroll_offset
|
||||
|
||||
def navigate(self, direction):
|
||||
"""Navigate selection up or down.
|
||||
|
||||
Args:
|
||||
direction: +1 for down, -1 for up
|
||||
"""
|
||||
if not self._items:
|
||||
return
|
||||
|
||||
if self._selected_index < 0:
|
||||
new_index = 0 if direction > 0 else len(self._items) - 1
|
||||
else:
|
||||
new_index = self._selected_index + direction
|
||||
# Wrap around
|
||||
if new_index < 0:
|
||||
new_index = len(self._items) - 1
|
||||
elif new_index >= len(self._items):
|
||||
new_index = 0
|
||||
|
||||
self.select(new_index)
|
||||
self.scroll_to(new_index)
|
||||
|
||||
def add_item(self, item, index=None):
|
||||
"""Add an item to the list.
|
||||
|
||||
Args:
|
||||
item: Item to add
|
||||
index: Position to insert (None = end)
|
||||
"""
|
||||
if index is None:
|
||||
self._items.append(item)
|
||||
else:
|
||||
self._items.insert(index, item)
|
||||
self._rebuild_items()
|
||||
|
||||
def remove_item(self, index):
|
||||
"""Remove an item by index.
|
||||
|
||||
Args:
|
||||
index: Index of item to remove
|
||||
"""
|
||||
if 0 <= index < len(self._items):
|
||||
del self._items[index]
|
||||
if self._selected_index >= len(self._items):
|
||||
self._selected_index = len(self._items) - 1
|
||||
self._rebuild_items()
|
||||
|
||||
def clear(self):
|
||||
"""Remove all items."""
|
||||
self._items.clear()
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._rebuild_items()
|
||||
|
||||
def filter(self, predicate):
|
||||
"""Filter displayed items.
|
||||
|
||||
Args:
|
||||
predicate: Function(item) -> bool to filter items
|
||||
|
||||
Note: This creates a new filtered view, not modifying original items.
|
||||
"""
|
||||
# Store original items if not already stored
|
||||
if not hasattr(self, '_original_items'):
|
||||
self._original_items = list(self._items)
|
||||
|
||||
self._items = [item for item in self._original_items if predicate(item)]
|
||||
self._selected_index = -1
|
||||
self._rebuild_items()
|
||||
|
||||
def reset_filter(self):
|
||||
"""Reset to show all items (undo filter)."""
|
||||
if hasattr(self, '_original_items'):
|
||||
self._items = list(self._original_items)
|
||||
del self._original_items
|
||||
self._selected_index = -1
|
||||
self._rebuild_items()
|
||||
272
tests/cookbook/lib/stat_bar.py
Normal file
272
tests/cookbook/lib/stat_bar.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# McRogueFace Cookbook - Stat Bar Widget
|
||||
"""
|
||||
Horizontal bar showing current/max value with animation support.
|
||||
|
||||
Example:
|
||||
from lib.stat_bar import StatBar
|
||||
|
||||
hp_bar = StatBar(
|
||||
pos=(100, 50),
|
||||
size=(200, 20),
|
||||
current=75,
|
||||
maximum=100,
|
||||
fill_color=mcrfpy.Color(200, 50, 50), # Red for health
|
||||
label="HP"
|
||||
)
|
||||
scene.children.append(hp_bar.frame)
|
||||
|
||||
# Take damage
|
||||
hp_bar.set_value(50, animate=True)
|
||||
|
||||
# Flash on hit
|
||||
hp_bar.flash(mcrfpy.Color(255, 255, 255))
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class StatBar:
|
||||
"""Horizontal bar showing current/max value.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple, default (200, 20)
|
||||
current: Current value (default: 100)
|
||||
maximum: Maximum value (default: 100)
|
||||
fill_color: Bar fill color (default: green)
|
||||
bg_color: Background color (default: dark gray)
|
||||
outline_color: Border color (default: white)
|
||||
outline: Border thickness (default: 1)
|
||||
label: Optional label prefix (e.g., "HP")
|
||||
show_text: Whether to show value text (default: True)
|
||||
text_color: Color of value text (default: white)
|
||||
font_size: Size of value text (default: 12)
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
bar: The inner fill bar
|
||||
current: Current value
|
||||
maximum: Maximum value
|
||||
"""
|
||||
|
||||
# Preset colors for common stat types
|
||||
HEALTH_COLOR = mcrfpy.Color(200, 50, 50) # Red
|
||||
MANA_COLOR = mcrfpy.Color(50, 100, 200) # Blue
|
||||
STAMINA_COLOR = mcrfpy.Color(50, 180, 80) # Green
|
||||
XP_COLOR = mcrfpy.Color(200, 180, 50) # Gold
|
||||
SHIELD_COLOR = mcrfpy.Color(100, 150, 200) # Light blue
|
||||
|
||||
DEFAULT_BG = mcrfpy.Color(30, 30, 35)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(150, 150, 160)
|
||||
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
def __init__(self, pos, size=(200, 20), current=100, maximum=100,
|
||||
fill_color=None, bg_color=None, outline_color=None,
|
||||
outline=1, label=None, show_text=True,
|
||||
text_color=None, font_size=12):
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self._current = current
|
||||
self._maximum = maximum
|
||||
self.label = label
|
||||
self.show_text = show_text
|
||||
self.font_size = font_size
|
||||
|
||||
# Colors
|
||||
self.fill_color = fill_color or self.STAMINA_COLOR
|
||||
self.bg_color = bg_color or self.DEFAULT_BG
|
||||
self.text_color = text_color or self.DEFAULT_TEXT
|
||||
self.outline_color = outline_color or self.DEFAULT_OUTLINE
|
||||
|
||||
# Create outer frame (background)
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.bg_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=outline
|
||||
)
|
||||
|
||||
# Create inner bar (the fill)
|
||||
bar_width = self._calculate_bar_width()
|
||||
self.bar = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(bar_width, size[1]),
|
||||
fill_color=self.fill_color,
|
||||
outline=0
|
||||
)
|
||||
self.frame.children.append(self.bar)
|
||||
|
||||
# Create text label if needed
|
||||
if show_text:
|
||||
text = self._format_text()
|
||||
# Center text in the bar
|
||||
self.text = mcrfpy.Caption(
|
||||
text=text,
|
||||
pos=(size[0] / 2, (size[1] - font_size) / 2),
|
||||
fill_color=self.text_color,
|
||||
font_size=font_size
|
||||
)
|
||||
# Add outline for readability over bar
|
||||
self.text.outline = 1
|
||||
self.text.outline_color = mcrfpy.Color(0, 0, 0)
|
||||
self.frame.children.append(self.text)
|
||||
else:
|
||||
self.text = None
|
||||
|
||||
def _calculate_bar_width(self):
|
||||
"""Calculate the fill bar width based on current/max ratio."""
|
||||
if self._maximum <= 0:
|
||||
return 0
|
||||
ratio = max(0, min(1, self._current / self._maximum))
|
||||
return ratio * self.size[0]
|
||||
|
||||
def _format_text(self):
|
||||
"""Format the display text."""
|
||||
if self.label:
|
||||
return f"{self.label}: {int(self._current)}/{int(self._maximum)}"
|
||||
return f"{int(self._current)}/{int(self._maximum)}"
|
||||
|
||||
def _update_display(self, animate=True):
|
||||
"""Update the bar width and text."""
|
||||
target_width = self._calculate_bar_width()
|
||||
|
||||
if animate:
|
||||
# Animate bar width change
|
||||
self.bar.animate("w", target_width, 0.3, mcrfpy.Easing.EASE_OUT)
|
||||
else:
|
||||
self.bar.w = target_width
|
||||
|
||||
# Update text
|
||||
if self.text:
|
||||
self.text.text = self._format_text()
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
"""Current value."""
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value):
|
||||
"""Set current value (no animation)."""
|
||||
self._current = max(0, min(value, self._maximum))
|
||||
self._update_display(animate=False)
|
||||
|
||||
@property
|
||||
def maximum(self):
|
||||
"""Maximum value."""
|
||||
return self._maximum
|
||||
|
||||
@maximum.setter
|
||||
def maximum(self, value):
|
||||
"""Set maximum value."""
|
||||
self._maximum = max(1, value)
|
||||
self._current = min(self._current, self._maximum)
|
||||
self._update_display(animate=False)
|
||||
|
||||
def set_value(self, current, maximum=None, animate=True):
|
||||
"""Set the bar value with optional animation.
|
||||
|
||||
Args:
|
||||
current: New current value
|
||||
maximum: New maximum value (optional)
|
||||
animate: Whether to animate the change
|
||||
"""
|
||||
if maximum is not None:
|
||||
self._maximum = max(1, maximum)
|
||||
self._current = max(0, min(current, self._maximum))
|
||||
self._update_display(animate=animate)
|
||||
|
||||
def flash(self, color=None, duration=0.2):
|
||||
"""Flash the bar a color (e.g., white on damage).
|
||||
|
||||
Args:
|
||||
color: Flash color (default: white)
|
||||
duration: Flash duration in seconds
|
||||
"""
|
||||
color = color or mcrfpy.Color(255, 255, 255)
|
||||
original_color = self.fill_color
|
||||
|
||||
# Flash to color
|
||||
self.bar.fill_color = color
|
||||
|
||||
# Create a timer to restore color
|
||||
# Using a closure to capture state
|
||||
bar_ref = self.bar
|
||||
restore_color = original_color
|
||||
|
||||
def restore(runtime):
|
||||
bar_ref.fill_color = restore_color
|
||||
|
||||
# Schedule restoration
|
||||
timer_name = f"flash_{id(self)}"
|
||||
mcrfpy.Timer(timer_name, restore, int(duration * 1000))
|
||||
|
||||
def set_colors(self, fill_color=None, bg_color=None, text_color=None):
|
||||
"""Update bar colors.
|
||||
|
||||
Args:
|
||||
fill_color: New fill color
|
||||
bg_color: New background color
|
||||
text_color: New text color
|
||||
"""
|
||||
if fill_color:
|
||||
self.fill_color = fill_color
|
||||
self.bar.fill_color = fill_color
|
||||
if bg_color:
|
||||
self.bg_color = bg_color
|
||||
self.frame.fill_color = bg_color
|
||||
if text_color and self.text:
|
||||
self.text_color = text_color
|
||||
self.text.fill_color = text_color
|
||||
|
||||
@property
|
||||
def ratio(self):
|
||||
"""Get current fill ratio (0.0 to 1.0)."""
|
||||
if self._maximum <= 0:
|
||||
return 0
|
||||
return self._current / self._maximum
|
||||
|
||||
def is_empty(self):
|
||||
"""Check if bar is empty (current <= 0)."""
|
||||
return self._current <= 0
|
||||
|
||||
def is_full(self):
|
||||
"""Check if bar is full (current >= maximum)."""
|
||||
return self._current >= self._maximum
|
||||
|
||||
|
||||
def create_stat_bar_group(stats, start_pos, spacing=30, size=(200, 20)):
|
||||
"""Create a vertical group of stat bars.
|
||||
|
||||
Args:
|
||||
stats: List of dicts with keys: name, current, max, color
|
||||
start_pos: (x, y) position of first bar
|
||||
spacing: Vertical pixels between bars
|
||||
size: (width, height) for all bars
|
||||
|
||||
Returns:
|
||||
Dict mapping stat names to StatBar objects
|
||||
|
||||
Example:
|
||||
bars = create_stat_bar_group([
|
||||
{"name": "HP", "current": 80, "max": 100, "color": StatBar.HEALTH_COLOR},
|
||||
{"name": "MP", "current": 50, "max": 80, "color": StatBar.MANA_COLOR},
|
||||
{"name": "XP", "current": 250, "max": 1000, "color": StatBar.XP_COLOR},
|
||||
], start_pos=(50, 50))
|
||||
"""
|
||||
bar_dict = {}
|
||||
x, y = start_pos
|
||||
|
||||
for stat in stats:
|
||||
bar = StatBar(
|
||||
pos=(x, y),
|
||||
size=size,
|
||||
current=stat.get("current", 100),
|
||||
maximum=stat.get("max", 100),
|
||||
fill_color=stat.get("color"),
|
||||
label=stat.get("name")
|
||||
)
|
||||
bar_dict[stat.get("name", f"stat_{len(bar_dict)}")] = bar
|
||||
y += size[1] + spacing
|
||||
|
||||
return bar_dict
|
||||
286
tests/cookbook/lib/text_box.py
Normal file
286
tests/cookbook/lib/text_box.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# McRogueFace Cookbook - Text Box Widget
|
||||
"""
|
||||
Word-wrapped text display with typewriter effect.
|
||||
|
||||
Example:
|
||||
from lib.text_box import TextBox
|
||||
|
||||
text_box = TextBox(
|
||||
pos=(100, 100),
|
||||
size=(400, 200),
|
||||
text="This is a long text that will be word-wrapped...",
|
||||
chars_per_second=30
|
||||
)
|
||||
scene.children.append(text_box.frame)
|
||||
|
||||
# Skip animation
|
||||
text_box.skip_animation()
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class TextBox:
|
||||
"""Word-wrapped text with optional typewriter effect.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple
|
||||
text: Initial text to display
|
||||
chars_per_second: Typewriter speed (0 = instant)
|
||||
font_size: Text size (default: 14)
|
||||
text_color: Color of text (default: white)
|
||||
bg_color: Background color (default: dark)
|
||||
outline_color: Border color (default: gray)
|
||||
outline: Border thickness (default: 1)
|
||||
padding: Internal padding (default: 10)
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
text: Current text content
|
||||
is_complete: Whether typewriter animation finished
|
||||
"""
|
||||
|
||||
DEFAULT_TEXT_COLOR = mcrfpy.Color(220, 220, 220)
|
||||
DEFAULT_BG_COLOR = mcrfpy.Color(25, 25, 30)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(80, 80, 90)
|
||||
|
||||
def __init__(self, pos, size, text="", chars_per_second=30,
|
||||
font_size=14, text_color=None, bg_color=None,
|
||||
outline_color=None, outline=1, padding=10):
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self._full_text = text
|
||||
self.chars_per_second = chars_per_second
|
||||
self.font_size = font_size
|
||||
self.padding = padding
|
||||
|
||||
# Colors
|
||||
self.text_color = text_color or self.DEFAULT_TEXT_COLOR
|
||||
self.bg_color = bg_color or self.DEFAULT_BG_COLOR
|
||||
self.outline_color = outline_color or self.DEFAULT_OUTLINE
|
||||
|
||||
# State
|
||||
self._displayed_chars = 0
|
||||
self._is_complete = chars_per_second == 0
|
||||
self._on_complete = None
|
||||
self._timer_name = None
|
||||
|
||||
# Create outer frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.bg_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=outline
|
||||
)
|
||||
|
||||
# Calculate text area
|
||||
self._text_width = size[0] - padding * 2
|
||||
self._text_height = size[1] - padding * 2
|
||||
|
||||
# Create text caption
|
||||
self._caption = mcrfpy.Caption(
|
||||
text="",
|
||||
pos=(padding, padding),
|
||||
fill_color=self.text_color,
|
||||
font_size=font_size
|
||||
)
|
||||
self.frame.children.append(self._caption)
|
||||
|
||||
# Start typewriter if there's text
|
||||
if text and chars_per_second > 0:
|
||||
self._start_typewriter()
|
||||
elif text:
|
||||
self._caption.text = self._word_wrap(text)
|
||||
self._displayed_chars = len(text)
|
||||
self._is_complete = True
|
||||
|
||||
def _word_wrap(self, text):
|
||||
"""Word-wrap text to fit within the text box width.
|
||||
|
||||
Simple implementation that breaks on spaces.
|
||||
"""
|
||||
# Estimate chars per line based on font size
|
||||
# This is approximate - real implementation would measure text
|
||||
avg_char_width = self.font_size * 0.6
|
||||
chars_per_line = int(self._text_width / avg_char_width)
|
||||
|
||||
if chars_per_line <= 0:
|
||||
return text
|
||||
|
||||
words = text.split(' ')
|
||||
lines = []
|
||||
current_line = []
|
||||
current_length = 0
|
||||
|
||||
for word in words:
|
||||
# Check if word fits on current line
|
||||
word_len = len(word)
|
||||
if current_length + word_len + (1 if current_line else 0) <= chars_per_line:
|
||||
current_line.append(word)
|
||||
current_length += word_len + (1 if len(current_line) > 1 else 0)
|
||||
else:
|
||||
# Start new line
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
current_length = word_len
|
||||
|
||||
# Add last line
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _start_typewriter(self):
|
||||
"""Start the typewriter animation."""
|
||||
if self.chars_per_second <= 0:
|
||||
return
|
||||
|
||||
self._displayed_chars = 0
|
||||
self._is_complete = False
|
||||
|
||||
# Calculate interval in milliseconds
|
||||
interval_ms = max(1, int(1000 / self.chars_per_second))
|
||||
|
||||
self._timer_name = f"textbox_{id(self)}"
|
||||
mcrfpy.Timer(self._timer_name, self._typewriter_tick, interval_ms)
|
||||
|
||||
def _typewriter_tick(self, runtime):
|
||||
"""Add one character to the display."""
|
||||
if self._displayed_chars >= len(self._full_text):
|
||||
# Animation complete
|
||||
self._is_complete = True
|
||||
# Stop timer by deleting it - there's no direct stop method
|
||||
# The timer will continue but we'll just not update
|
||||
if self._on_complete:
|
||||
self._on_complete()
|
||||
return
|
||||
|
||||
self._displayed_chars += 1
|
||||
visible_text = self._full_text[:self._displayed_chars]
|
||||
self._caption.text = self._word_wrap(visible_text)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""Get the full text content."""
|
||||
return self._full_text
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
"""Whether the typewriter animation has finished."""
|
||||
return self._is_complete
|
||||
|
||||
@property
|
||||
def on_complete(self):
|
||||
"""Callback when animation completes."""
|
||||
return self._on_complete
|
||||
|
||||
@on_complete.setter
|
||||
def on_complete(self, callback):
|
||||
"""Set callback for animation completion."""
|
||||
self._on_complete = callback
|
||||
|
||||
def set_text(self, text, animate=True):
|
||||
"""Change the text content.
|
||||
|
||||
Args:
|
||||
text: New text to display
|
||||
animate: Whether to use typewriter effect
|
||||
"""
|
||||
self._full_text = text
|
||||
|
||||
if animate and self.chars_per_second > 0:
|
||||
self._start_typewriter()
|
||||
else:
|
||||
self._caption.text = self._word_wrap(text)
|
||||
self._displayed_chars = len(text)
|
||||
self._is_complete = True
|
||||
|
||||
def skip_animation(self):
|
||||
"""Skip to the end of the typewriter animation."""
|
||||
self._displayed_chars = len(self._full_text)
|
||||
self._caption.text = self._word_wrap(self._full_text)
|
||||
self._is_complete = True
|
||||
if self._on_complete:
|
||||
self._on_complete()
|
||||
|
||||
def clear(self):
|
||||
"""Clear the text box."""
|
||||
self._full_text = ""
|
||||
self._displayed_chars = 0
|
||||
self._caption.text = ""
|
||||
self._is_complete = True
|
||||
|
||||
def append_text(self, text, animate=True):
|
||||
"""Append text to the current content.
|
||||
|
||||
Args:
|
||||
text: Text to append
|
||||
animate: Whether to animate the new text
|
||||
"""
|
||||
new_text = self._full_text + text
|
||||
self.set_text(new_text, animate=animate)
|
||||
|
||||
|
||||
class DialogueBox(TextBox):
|
||||
"""Specialized text box for dialogue with speaker name.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple
|
||||
speaker: Name of the speaker
|
||||
text: Dialogue text
|
||||
speaker_color: Color for speaker name (default: yellow)
|
||||
**kwargs: Additional TextBox arguments
|
||||
"""
|
||||
|
||||
DEFAULT_SPEAKER_COLOR = mcrfpy.Color(255, 220, 100)
|
||||
|
||||
def __init__(self, pos, size, speaker="", text="",
|
||||
speaker_color=None, **kwargs):
|
||||
# Initialize parent with empty text
|
||||
super().__init__(pos, size, text="", **kwargs)
|
||||
|
||||
self._speaker = speaker
|
||||
self.speaker_color = speaker_color or self.DEFAULT_SPEAKER_COLOR
|
||||
|
||||
# Create speaker name caption
|
||||
self._speaker_caption = mcrfpy.Caption(
|
||||
text=speaker,
|
||||
pos=(self.padding, self.padding - 5),
|
||||
fill_color=self.speaker_color,
|
||||
font_size=self.font_size + 2
|
||||
)
|
||||
self._speaker_caption.outline = 1
|
||||
self._speaker_caption.outline_color = mcrfpy.Color(0, 0, 0)
|
||||
self.frame.children.append(self._speaker_caption)
|
||||
|
||||
# Adjust main text position
|
||||
self._caption.y = self.padding + self.font_size + 10
|
||||
|
||||
# Now set the actual text
|
||||
if text:
|
||||
self.set_text(text, animate=kwargs.get('chars_per_second', 30) > 0)
|
||||
|
||||
@property
|
||||
def speaker(self):
|
||||
"""Get the speaker name."""
|
||||
return self._speaker
|
||||
|
||||
@speaker.setter
|
||||
def speaker(self, value):
|
||||
"""Set the speaker name."""
|
||||
self._speaker = value
|
||||
self._speaker_caption.text = value
|
||||
|
||||
def set_dialogue(self, speaker, text, animate=True):
|
||||
"""Set both speaker and text.
|
||||
|
||||
Args:
|
||||
speaker: Speaker name
|
||||
text: Dialogue text
|
||||
animate: Whether to animate the text
|
||||
"""
|
||||
self.speaker = speaker
|
||||
self.set_text(text, animate=animate)
|
||||
221
tests/cookbook/lib/toast.py
Normal file
221
tests/cookbook/lib/toast.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# McRogueFace Cookbook - Toast Notification Widget
|
||||
"""
|
||||
Auto-dismissing notification popups.
|
||||
|
||||
Example:
|
||||
from lib.toast import ToastManager
|
||||
|
||||
# Create manager (once per scene)
|
||||
toasts = ToastManager(scene)
|
||||
|
||||
# Show notifications
|
||||
toasts.show("Game saved!")
|
||||
toasts.show("Level up!", duration=5000, color=mcrfpy.Color(100, 200, 100))
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class Toast:
|
||||
"""Single toast notification.
|
||||
|
||||
Internal class - use ToastManager to create toasts.
|
||||
"""
|
||||
|
||||
DEFAULT_BG = mcrfpy.Color(50, 50, 60, 240)
|
||||
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
|
||||
|
||||
def __init__(self, message, y_position, width=300,
|
||||
bg_color=None, text_color=None, duration=3000):
|
||||
self.message = message
|
||||
self.duration = duration
|
||||
self.y_position = y_position
|
||||
self.width = width
|
||||
self._dismissed = False
|
||||
|
||||
# Colors
|
||||
self.bg_color = bg_color or self.DEFAULT_BG
|
||||
self.text_color = text_color or self.DEFAULT_TEXT
|
||||
|
||||
# Create toast frame (starts off-screen to the right)
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=(1024 + 10, y_position), # Start off-screen
|
||||
size=(width, 40),
|
||||
fill_color=self.bg_color,
|
||||
outline_color=mcrfpy.Color(100, 100, 120),
|
||||
outline=1
|
||||
)
|
||||
|
||||
# Create message caption
|
||||
self.caption = mcrfpy.Caption(
|
||||
text=message,
|
||||
pos=(15, 10),
|
||||
fill_color=self.text_color,
|
||||
font_size=14
|
||||
)
|
||||
self.frame.children.append(self.caption)
|
||||
|
||||
def slide_in(self, target_x):
|
||||
"""Animate sliding in from the right."""
|
||||
self.frame.animate("x", target_x, 0.3, mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
def slide_out(self, callback=None):
|
||||
"""Animate sliding out to the right."""
|
||||
self._dismissed = True
|
||||
self.frame.animate("x", 1024 + 10, 0.3, mcrfpy.Easing.EASE_IN)
|
||||
if callback:
|
||||
mcrfpy.Timer(f"toast_dismiss_{id(self)}", lambda rt: callback(), 350)
|
||||
|
||||
def move_up(self, new_y):
|
||||
"""Animate moving to a new Y position."""
|
||||
self.y_position = new_y
|
||||
self.frame.animate("y", new_y, 0.2, mcrfpy.Easing.EASE_OUT)
|
||||
|
||||
@property
|
||||
def is_dismissed(self):
|
||||
"""Whether this toast has been dismissed."""
|
||||
return self._dismissed
|
||||
|
||||
|
||||
class ToastManager:
|
||||
"""Manages auto-dismissing notification popups.
|
||||
|
||||
Args:
|
||||
scene: Scene to add toasts to
|
||||
position: Anchor position ("top-right", "bottom-right", "top-left", "bottom-left")
|
||||
max_toasts: Maximum visible toasts (default: 5)
|
||||
toast_width: Width of toast notifications
|
||||
toast_spacing: Vertical spacing between toasts
|
||||
margin: Margin from screen edge
|
||||
|
||||
Attributes:
|
||||
scene: The scene toasts are added to
|
||||
toasts: List of active toast objects
|
||||
"""
|
||||
|
||||
def __init__(self, scene, position="top-right", max_toasts=5,
|
||||
toast_width=300, toast_spacing=10, margin=20):
|
||||
self.scene = scene
|
||||
self.position = position
|
||||
self.max_toasts = max_toasts
|
||||
self.toast_width = toast_width
|
||||
self.toast_spacing = toast_spacing
|
||||
self.margin = margin
|
||||
self.toasts = []
|
||||
|
||||
# Calculate anchor position
|
||||
self._calculate_anchor()
|
||||
|
||||
def _calculate_anchor(self):
|
||||
"""Calculate the anchor point based on position setting."""
|
||||
# Assuming 1024x768 screen
|
||||
if "right" in self.position:
|
||||
self._anchor_x = 1024 - self.toast_width - self.margin
|
||||
else:
|
||||
self._anchor_x = self.margin
|
||||
|
||||
if "top" in self.position:
|
||||
self._anchor_y = self.margin
|
||||
self._direction = 1 # Stack downward
|
||||
else:
|
||||
self._anchor_y = 768 - 40 - self.margin # 40 = toast height
|
||||
self._direction = -1 # Stack upward
|
||||
|
||||
def _get_toast_y(self, index):
|
||||
"""Get Y position for toast at given index."""
|
||||
return self._anchor_y + index * (40 + self.toast_spacing) * self._direction
|
||||
|
||||
def show(self, message, duration=3000, color=None):
|
||||
"""Show a toast notification.
|
||||
|
||||
Args:
|
||||
message: Text to display
|
||||
duration: Time in ms before auto-dismiss (0 = never)
|
||||
color: Optional background color
|
||||
|
||||
Returns:
|
||||
Toast object
|
||||
"""
|
||||
# Create new toast
|
||||
y_pos = self._get_toast_y(len(self.toasts))
|
||||
toast = Toast(
|
||||
message=message,
|
||||
y_position=y_pos,
|
||||
width=self.toast_width,
|
||||
bg_color=color,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
# Add to scene
|
||||
self.scene.children.append(toast.frame)
|
||||
self.toasts.append(toast)
|
||||
|
||||
# Animate in
|
||||
toast.slide_in(self._anchor_x)
|
||||
|
||||
# Schedule auto-dismiss
|
||||
if duration > 0:
|
||||
timer_name = f"toast_auto_{id(toast)}"
|
||||
mcrfpy.Timer(timer_name, lambda rt: self.dismiss(toast), duration)
|
||||
|
||||
# Remove oldest if over limit
|
||||
while len(self.toasts) > self.max_toasts:
|
||||
self.dismiss(self.toasts[0])
|
||||
|
||||
return toast
|
||||
|
||||
def dismiss(self, toast):
|
||||
"""Dismiss a specific toast.
|
||||
|
||||
Args:
|
||||
toast: Toast object to dismiss
|
||||
"""
|
||||
if toast not in self.toasts or toast.is_dismissed:
|
||||
return
|
||||
|
||||
index = self.toasts.index(toast)
|
||||
|
||||
def on_dismissed():
|
||||
# Remove from scene and list
|
||||
if toast in self.toasts:
|
||||
self.toasts.remove(toast)
|
||||
# Try to remove from scene
|
||||
try:
|
||||
for i in range(len(self.scene.children)):
|
||||
if self.scene.children[i] is toast.frame:
|
||||
self.scene.children.pop()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Move remaining toasts up
|
||||
self._reposition_toasts()
|
||||
|
||||
toast.slide_out(callback=on_dismissed)
|
||||
|
||||
def _reposition_toasts(self):
|
||||
"""Reposition all remaining toasts after one is removed."""
|
||||
for i, toast in enumerate(self.toasts):
|
||||
if not toast.is_dismissed:
|
||||
new_y = self._get_toast_y(i)
|
||||
toast.move_up(new_y)
|
||||
|
||||
def dismiss_all(self):
|
||||
"""Dismiss all active toasts."""
|
||||
for toast in list(self.toasts):
|
||||
self.dismiss(toast)
|
||||
|
||||
def show_success(self, message, duration=3000):
|
||||
"""Show a success toast (green)."""
|
||||
return self.show(message, duration, mcrfpy.Color(40, 120, 60, 240))
|
||||
|
||||
def show_error(self, message, duration=5000):
|
||||
"""Show an error toast (red)."""
|
||||
return self.show(message, duration, mcrfpy.Color(150, 50, 50, 240))
|
||||
|
||||
def show_warning(self, message, duration=4000):
|
||||
"""Show a warning toast (yellow)."""
|
||||
return self.show(message, duration, mcrfpy.Color(180, 150, 40, 240))
|
||||
|
||||
def show_info(self, message, duration=3000):
|
||||
"""Show an info toast (blue)."""
|
||||
return self.show(message, duration, mcrfpy.Color(50, 100, 150, 240))
|
||||
Loading…
Add table
Add a link
Reference in a new issue