Add cookbook examples with updated callback signatures for #229, #230

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:
John McCardle 2026-01-28 18:58:25 -05:00
commit 55f6ea9502
41 changed files with 8493 additions and 0 deletions

View 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',
]

View 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),
)

View 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

View 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
)

View 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

View 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
View 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()

View 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()

View 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

View 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
View 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))