McRogueFace/tests/cookbook/lib/text_box.py
John McCardle 55f6ea9502 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>
2026-01-28 18:58:25 -05:00

286 lines
8.7 KiB
Python

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