From 55f6ea9502c85de66a9135856035dd5fd9829a05 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 28 Jan 2026 18:58:25 -0500 Subject: [PATCH] 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 --- tests/cookbook/__init__.py | 15 + tests/cookbook/apps/__init__.py | 15 + tests/cookbook/apps/calculator.py | 320 ++++++++++ tests/cookbook/apps/dialogue_system.py | 497 ++++++++++++++++ tests/cookbook/automation/__init__.py | 10 + .../cookbook/automation/capture_primitives.py | 117 ++++ tests/cookbook/compound/__init__.py | 11 + tests/cookbook/compound/shop_demo.py | 445 ++++++++++++++ tests/cookbook/cookbook_main.py | 250 ++++++++ tests/cookbook/features/__init__.py | 9 + .../cookbook/features/demo_animation_chain.py | 424 +++++++++++++ tests/cookbook/features/demo_rotation.py | 454 ++++++++++++++ tests/cookbook/features/demo_shaders.py | 340 +++++++++++ tests/cookbook/lib/__init__.py | 81 +++ tests/cookbook/lib/anim_utils.py | 387 ++++++++++++ tests/cookbook/lib/button.py | 241 ++++++++ tests/cookbook/lib/choice_list.py | 317 ++++++++++ tests/cookbook/lib/grid_container.py | 344 +++++++++++ tests/cookbook/lib/item_manager.py | 563 ++++++++++++++++++ tests/cookbook/lib/modal.py | 332 +++++++++++ tests/cookbook/lib/scrollable_list.py | 351 +++++++++++ tests/cookbook/lib/stat_bar.py | 272 +++++++++ tests/cookbook/lib/text_box.py | 286 +++++++++ tests/cookbook/lib/toast.py | 221 +++++++ tests/cookbook/primitives/__init__.py | 6 + tests/cookbook/primitives/demo_button.py | 296 +++++++++ tests/cookbook/primitives/demo_choice_list.py | 287 +++++++++ .../cookbook/primitives/demo_click_pickup.py | 380 ++++++++++++ .../primitives/demo_drag_drop_grid.py | 270 +++++++++ tests/cookbook/primitives/demo_stat_bar.py | 332 +++++++++++ tests/cookbook/primitives/demo_text_box.py | 248 ++++++++ tests/cookbook/primitives/demo_toast.py | 237 ++++++++ tests/cookbook/run_screenshots.py | 135 +++++ .../screenshots/features/animation_chain.png | Bin 0 -> 39260 bytes .../screenshots/features/rotation.png | Bin 0 -> 39260 bytes .../cookbook/screenshots/features/shaders.png | Bin 0 -> 39260 bytes .../screenshots/primitives/button.png | Bin 0 -> 39260 bytes .../screenshots/primitives/choice_list.png | Bin 0 -> 39260 bytes .../screenshots/primitives/stat_bar.png | Bin 0 -> 39260 bytes .../screenshots/primitives/text_box.png | Bin 0 -> 39260 bytes .../cookbook/screenshots/primitives/toast.png | Bin 0 -> 39260 bytes 41 files changed, 8493 insertions(+) create mode 100644 tests/cookbook/__init__.py create mode 100644 tests/cookbook/apps/__init__.py create mode 100644 tests/cookbook/apps/calculator.py create mode 100644 tests/cookbook/apps/dialogue_system.py create mode 100644 tests/cookbook/automation/__init__.py create mode 100644 tests/cookbook/automation/capture_primitives.py create mode 100644 tests/cookbook/compound/__init__.py create mode 100644 tests/cookbook/compound/shop_demo.py create mode 100644 tests/cookbook/cookbook_main.py create mode 100644 tests/cookbook/features/__init__.py create mode 100644 tests/cookbook/features/demo_animation_chain.py create mode 100644 tests/cookbook/features/demo_rotation.py create mode 100644 tests/cookbook/features/demo_shaders.py create mode 100644 tests/cookbook/lib/__init__.py create mode 100644 tests/cookbook/lib/anim_utils.py create mode 100644 tests/cookbook/lib/button.py create mode 100644 tests/cookbook/lib/choice_list.py create mode 100644 tests/cookbook/lib/grid_container.py create mode 100644 tests/cookbook/lib/item_manager.py create mode 100644 tests/cookbook/lib/modal.py create mode 100644 tests/cookbook/lib/scrollable_list.py create mode 100644 tests/cookbook/lib/stat_bar.py create mode 100644 tests/cookbook/lib/text_box.py create mode 100644 tests/cookbook/lib/toast.py create mode 100644 tests/cookbook/primitives/__init__.py create mode 100644 tests/cookbook/primitives/demo_button.py create mode 100644 tests/cookbook/primitives/demo_choice_list.py create mode 100644 tests/cookbook/primitives/demo_click_pickup.py create mode 100644 tests/cookbook/primitives/demo_drag_drop_grid.py create mode 100644 tests/cookbook/primitives/demo_stat_bar.py create mode 100644 tests/cookbook/primitives/demo_text_box.py create mode 100644 tests/cookbook/primitives/demo_toast.py create mode 100644 tests/cookbook/run_screenshots.py create mode 100644 tests/cookbook/screenshots/features/animation_chain.png create mode 100644 tests/cookbook/screenshots/features/rotation.png create mode 100644 tests/cookbook/screenshots/features/shaders.png create mode 100644 tests/cookbook/screenshots/primitives/button.png create mode 100644 tests/cookbook/screenshots/primitives/choice_list.png create mode 100644 tests/cookbook/screenshots/primitives/stat_bar.png create mode 100644 tests/cookbook/screenshots/primitives/text_box.png create mode 100644 tests/cookbook/screenshots/primitives/toast.png diff --git a/tests/cookbook/__init__.py b/tests/cookbook/__init__.py new file mode 100644 index 0000000..7d2e02a --- /dev/null +++ b/tests/cookbook/__init__.py @@ -0,0 +1,15 @@ +# McRogueFace Cookbook - Widget Standard Library and Demos +""" +A comprehensive collection of reusable UI widgets and interactive demos +showcasing McRogueFace capabilities. + +Usage: + # Interactive menu + cd build && ./mcrogueface ../tests/cookbook/cookbook_main.py + + # Run specific demo + cd build && ./mcrogueface ../tests/cookbook/primitives/demo_button.py + + # Generate screenshots (headless) + cd build && ./mcrogueface --headless --exec ../tests/cookbook/run_screenshots.py +""" diff --git a/tests/cookbook/apps/__init__.py b/tests/cookbook/apps/__init__.py new file mode 100644 index 0000000..975f33c --- /dev/null +++ b/tests/cookbook/apps/__init__.py @@ -0,0 +1,15 @@ +# McRogueFace Cookbook - Mini-Application Demos +""" +Complete mini-applications demonstrating real-world patterns. + +Applications: + calculator.py - Boss-key calculator with scene switching + dialogue_system.py - NPC dialogue with choices and mood + day_night_shadows.py - HeightMap shadow projection demo + remote_control.py - FOV + entity perspective switching + health_bar_hover.py - Hover-based health display + toast_demo.py - Notification system demo + banter_chat.py - Timed chat messages + loot_detector.py - Directional arrow compass + grid_puzzle.py - Interactive Sokoban-style puzzle +""" diff --git a/tests/cookbook/apps/calculator.py b/tests/cookbook/apps/calculator.py new file mode 100644 index 0000000..b6d6b73 --- /dev/null +++ b/tests/cookbook/apps/calculator.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Calculator App - Boss-key calculator with scene switching + +Interactive controls: + 0-9: Number input + +, -, *, /: Operations + Enter/=: Calculate result + C: Clear + ESC: Toggle between game and calculator scenes + Backspace: Delete last digit + +This demonstrates: + - Scene switching (boss key pattern) + - Button grid layout + - State management + - Click handlers +""" +import mcrfpy +import sys +import os + +# Add parent to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from lib.button import Button + + +class Calculator: + """A simple calculator with GUI.""" + + def __init__(self): + self.scene = mcrfpy.Scene("calculator") + self.ui = self.scene.children + self.expression = "" + self.result = "" + self.setup() + + def setup(self): + """Build the calculator UI.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(30, 30, 35) + ) + self.ui.append(bg) + + # Calculator frame + calc_frame = mcrfpy.Frame( + pos=(312, 100), + size=(400, 550), + fill_color=mcrfpy.Color(40, 40, 50), + outline_color=mcrfpy.Color(80, 80, 100), + outline=2 + ) + self.ui.append(calc_frame) + + # Title + title = mcrfpy.Caption( + text="Calculator", + pos=(512, 130), + font_size=24, + fill_color=mcrfpy.Color(200, 200, 200) + ) + self.ui.append(title) + + # Display frame + display_bg = mcrfpy.Frame( + pos=(332, 170), + size=(360, 80), + fill_color=mcrfpy.Color(20, 25, 30), + outline_color=mcrfpy.Color(60, 60, 80), + outline=1 + ) + self.ui.append(display_bg) + + # Expression display + self.expr_display = mcrfpy.Caption( + text="", + pos=(682, 180), # Right-aligned + font_size=18, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(self.expr_display) + + # Result display + self.result_display = mcrfpy.Caption( + text="0", + pos=(682, 210), # Right-aligned + font_size=32, + fill_color=mcrfpy.Color(255, 255, 255) + ) + self.ui.append(self.result_display) + + # Button layout + button_layout = [ + ["C", "(", ")", "/"], + ["7", "8", "9", "*"], + ["4", "5", "6", "-"], + ["1", "2", "3", "+"], + ["0", ".", "DEL", "="], + ] + + start_x = 342 + start_y = 270 + btn_width = 80 + btn_height = 50 + spacing = 8 + + for row_idx, row in enumerate(button_layout): + for col_idx, label in enumerate(row): + x = start_x + col_idx * (btn_width + spacing) + y = start_y + row_idx * (btn_height + spacing) + + # Different colors for different button types + if label in "0123456789.": + fill = mcrfpy.Color(60, 60, 70) + hover = mcrfpy.Color(80, 80, 95) + elif label == "=": + fill = mcrfpy.Color(80, 120, 80) + hover = mcrfpy.Color(100, 150, 100) + elif label == "C": + fill = mcrfpy.Color(120, 60, 60) + hover = mcrfpy.Color(150, 80, 80) + else: + fill = mcrfpy.Color(70, 70, 90) + hover = mcrfpy.Color(90, 90, 115) + + btn = Button( + label, + pos=(x, y), + size=(btn_width, btn_height), + fill_color=fill, + hover_color=hover, + callback=lambda l=label: self.on_button(l), + font_size=20 + ) + self.ui.append(btn.frame) + + # Instructions + instr = mcrfpy.Caption( + text="Press ESC to switch to game | Click buttons or use keyboard", + pos=(512, 580), + font_size=14, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(instr) + + def on_button(self, label): + """Handle button press.""" + if label == "C": + self.expression = "" + self.result = "0" + elif label == "DEL": + self.expression = self.expression[:-1] + elif label == "=": + self.calculate() + else: + self.expression += label + + self._update_display() + + def calculate(self): + """Evaluate the expression.""" + if not self.expression: + return + + try: + # Safe evaluation (only math operations) + allowed = set("0123456789+-*/.()") + if all(c in allowed for c in self.expression): + self.result = str(eval(self.expression)) + if self.result.endswith('.0'): + self.result = self.result[:-2] + else: + self.result = "Error" + except Exception: + self.result = "Error" + + def _update_display(self): + """Update the display captions.""" + self.expr_display.text = self.expression or "" + self.result_display.text = self.result or "0" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + # Switch to game scene + mcrfpy.current_scene = game_scene + return + + # Map keys to buttons + key_map = { + "Num0": "0", "Num1": "1", "Num2": "2", "Num3": "3", + "Num4": "4", "Num5": "5", "Num6": "6", "Num7": "7", + "Num8": "8", "Num9": "9", + "Period": ".", "Add": "+", "Subtract": "-", + "Multiply": "*", "Divide": "/", + "Enter": "=", "Return": "=", + "C": "C", "Backspace": "DEL", + "LParen": "(", "RParen": ")", + } + + if key in key_map: + self.on_button(key_map[key]) + + def activate(self): + """Activate the calculator scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +class GamePlaceholder: + """Placeholder game scene to switch to/from.""" + + def __init__(self): + self.scene = mcrfpy.Scene("game_placeholder") + self.ui = self.scene.children + self.setup() + + def setup(self): + """Build the game placeholder UI.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 40, 20) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="GAME SCENE", + pos=(512, 350), + font_size=48, + fill_color=mcrfpy.Color(100, 200, 100) + ) + title.outline = 3 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Subtitle + subtitle = mcrfpy.Caption( + text="Press ESC for Calculator (Boss Key!)", + pos=(512, 420), + font_size=20, + fill_color=mcrfpy.Color(150, 200, 150) + ) + self.ui.append(subtitle) + + # Fake game elements + for i in range(5): + fake_element = mcrfpy.Frame( + pos=(100 + i * 180, 550), + size=(150, 100), + fill_color=mcrfpy.Color(40 + i * 10, 60 + i * 5, 40), + outline_color=mcrfpy.Color(80, 120, 80), + outline=1 + ) + self.ui.append(fake_element) + + label = mcrfpy.Caption( + text=f"Game Element {i+1}", + pos=(175 + i * 180, 590), + font_size=12, + fill_color=mcrfpy.Color(200, 200, 200) + ) + self.ui.append(label) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + # Switch to calculator + calculator.activate() + + def activate(self): + """Activate the game scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +# Global instances +calculator = None +game_scene = None + + +def main(): + """Run the calculator app.""" + global calculator, game_scene + + calculator = Calculator() + game_scene = GamePlaceholder() + + # Start with the game scene + game_scene.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Show calculator for screenshot + calculator.activate() + calculator.expression = "7+8" + calculator._update_display() + + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/apps/calculator.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/apps/dialogue_system.py b/tests/cookbook/apps/dialogue_system.py new file mode 100644 index 0000000..9f8ee93 --- /dev/null +++ b/tests/cookbook/apps/dialogue_system.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""Dialogue System - NPC dialogue with choices and mood + +Interactive controls: + 1-4: Select dialogue choice + Enter: Confirm selection + Space: Advance dialogue (skip typewriter) + R: Restart conversation + ESC: Exit demo + +This demonstrates: + - Portrait + text + choices pattern + - State machine for NPC mood + - Choice consequences + - Dynamic UI updates +""" +import mcrfpy +import sys +import os + +# Add parent to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from lib.text_box import DialogueBox +from lib.choice_list import ChoiceList + + +class NPC: + """NPC with mood and dialogue state.""" + + MOODS = { + "neutral": mcrfpy.Color(150, 150, 150), + "happy": mcrfpy.Color(100, 200, 100), + "angry": mcrfpy.Color(200, 80, 80), + "sad": mcrfpy.Color(80, 80, 200), + "suspicious": mcrfpy.Color(200, 180, 80), + } + + def __init__(self, name, initial_mood="neutral"): + self.name = name + self.mood = initial_mood + self.trust = 50 # 0-100 scale + self.dialogue_state = "greeting" + + @property + def mood_color(self): + return self.MOODS.get(self.mood, self.MOODS["neutral"]) + + +class DialogueSystem: + """Complete dialogue system with NPC interaction.""" + + def __init__(self): + self.scene = mcrfpy.Scene("dialogue_system") + self.ui = self.scene.children + + # Create NPC + self.npc = NPC("Elder Sage") + + # Dialogue tree + self.dialogue_tree = self._create_dialogue_tree() + + self.setup() + + def _create_dialogue_tree(self): + """Create the dialogue tree structure.""" + return { + "greeting": { + "text": "Greetings, traveler. I am the Elder Sage of this village. What brings you to these remote lands?", + "choices": [ + ("I seek wisdom and knowledge.", "wise_response"), + ("I'm looking for treasure!", "treasure_response"), + ("None of your business.", "rude_response"), + ("I'm lost. Can you help me?", "help_response"), + ] + }, + "wise_response": { + "text": "Ah, a seeker of truth! That is admirable. Knowledge is the greatest treasure one can possess. Tell me, what specific wisdom do you seek?", + "mood": "happy", + "trust_change": 10, + "choices": [ + ("I want to learn about the ancient prophecy.", "prophecy"), + ("Teach me about magic.", "magic"), + ("I wish to know the history of this land.", "history"), + ] + }, + "treasure_response": { + "text": "Treasure? Bah! Material wealth corrupts the soul. But... perhaps you could prove yourself worthy of learning where such things might be found.", + "mood": "suspicious", + "trust_change": -5, + "choices": [ + ("I apologize. I spoke hastily.", "apologize"), + ("I don't need your approval!", "defiant"), + ("What must I do to prove myself?", "prove_worthy"), + ] + }, + "rude_response": { + "text": "How dare you speak to me with such disrespect! Leave my presence at once!", + "mood": "angry", + "trust_change": -30, + "choices": [ + ("I'm sorry, I didn't mean that.", "apologize_angry"), + ("Make me!", "defiant"), + ("*Leave quietly*", "leave"), + ] + }, + "help_response": { + "text": "Lost? Poor soul. These mountains can be treacherous. I will help you find your way, but first, tell me where you wish to go.", + "mood": "neutral", + "trust_change": 5, + "choices": [ + ("To the nearest town.", "directions"), + ("Anywhere but here.", "sad_path"), + ("Actually, maybe I'll stay a while.", "stay"), + ] + }, + "prophecy": { + "text": "The ancient prophecy speaks of a chosen one who will restore balance when darkness falls. Many believe that time is now approaching...", + "mood": "neutral", + "choices": [ + ("Am I the chosen one?", "chosen"), + ("How can I help prevent this darkness?", "help_prevent"), + ("That sounds like nonsense.", "skeptic"), + ] + }, + "magic": { + "text": "Magic is not learned from books alone. It flows from within, from understanding the natural world. Your journey has only begun.", + "mood": "happy", + "choices": [ + ("Will you teach me?", "teach"), + ("I understand. Thank you.", "thanks"), + ] + }, + "history": { + "text": "This land was once a great kingdom, until the Shadow Wars tore it asunder. Now only ruins remain of its former glory.", + "mood": "sad", + "choices": [ + ("What caused the Shadow Wars?", "shadow_wars"), + ("Can the kingdom be restored?", "restore"), + ("Thank you for sharing.", "thanks"), + ] + }, + "apologize": { + "text": "Hmm... perhaps I misjudged you. True wisdom includes recognizing one's mistakes. Let us start again.", + "mood": "neutral", + "trust_change": 5, + "choices": [ + ("Thank you for understanding.", "wise_response"), + ] + }, + "apologize_angry": { + "text": "*sighs* Very well. I accept your apology. But mind your tongue in the future.", + "mood": "neutral", + "trust_change": -10, + "choices": [ + ("I will. Now, I seek wisdom.", "wise_response"), + ("Thank you, Elder.", "thanks"), + ] + }, + "defiant": { + "text": "GUARDS! Remove this insolent fool from my sight!", + "mood": "angry", + "trust_change": -50, + "choices": [ + ("*Run away!*", "escape"), + ("*Fight the guards*", "fight"), + ] + }, + "prove_worthy": { + "text": "There is a sacred trial in the mountains. Complete it, and I may reconsider my opinion of you.", + "mood": "neutral", + "trust_change": 5, + "choices": [ + ("I accept the challenge!", "accept_trial"), + ("What kind of trial?", "trial_info"), + ("Perhaps another time.", "decline"), + ] + }, + "thanks": { + "text": "You are welcome, traveler. May your journey be blessed with wisdom and good fortune. Return if you need guidance.", + "mood": "happy", + "choices": [ + ("Farewell, Elder.", "end_good"), + ("I have more questions.", "greeting"), + ] + }, + "end_good": { + "text": "Farewell. May the ancient spirits watch over you.", + "mood": "happy", + "choices": [ + ("*Restart conversation*", "greeting"), + ] + }, + "leave": { + "text": "Perhaps it is for the best. Safe travels... if you can find your way.", + "mood": "neutral", + "choices": [ + ("*Restart conversation*", "greeting"), + ] + }, + "escape": { + "text": "*You flee into the wilderness, the guards' shouts fading behind you...*", + "mood": "neutral", + "choices": [ + ("*Restart conversation*", "greeting"), + ] + }, + "fight": { + "text": "*The guards overwhelm you. You wake up in a cell...*", + "mood": "angry", + "choices": [ + ("*Restart conversation*", "greeting"), + ] + }, + # Default responses for missing states + "chosen": {"text": "That remains to be seen. Only time will tell.", "mood": "neutral", "choices": [("I understand.", "thanks")]}, + "help_prevent": {"text": "Prepare yourself. Train hard. The darkness comes for us all.", "mood": "neutral", "choices": [("I will.", "thanks")]}, + "skeptic": {"text": "Believe what you will. But when darkness comes, remember my words.", "mood": "sad", "choices": [("Perhaps you're right.", "apologize")]}, + "teach": {"text": "In time, perhaps. First, prove your dedication.", "mood": "neutral", "choices": [("How?", "prove_worthy")]}, + "shadow_wars": {"text": "Ancient evils that should have remained buried. Let us speak no more of it.", "mood": "sad", "choices": [("I understand.", "thanks")]}, + "restore": {"text": "Perhaps... if the chosen one rises. Perhaps.", "mood": "neutral", "choices": [("I hope so.", "thanks")]}, + "directions": {"text": "Head east through the mountain pass. The town of Millbrook lies two days' journey.", "mood": "neutral", "choices": [("Thank you!", "thanks")]}, + "sad_path": {"text": "I sense great pain in you. Perhaps you should stay and heal.", "mood": "sad", "choices": [("You're right.", "stay")]}, + "stay": {"text": "You are welcome here. Rest, and we shall talk more.", "mood": "happy", "choices": [("Thank you, Elder.", "thanks")]}, + "accept_trial": {"text": "Brave soul! Seek the Cave of Trials to the north. Return victorious!", "mood": "happy", "choices": [("I will return!", "thanks")]}, + "trial_info": {"text": "It tests courage, wisdom, and heart. Few have succeeded.", "mood": "neutral", "choices": [("I'll try anyway!", "accept_trial"), ("Maybe not...", "decline")]}, + "decline": {"text": "Perhaps another time then. The offer stands.", "mood": "neutral", "choices": [("Thank you.", "thanks")]}, + } + + def setup(self): + """Build the dialogue UI.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(25, 25, 30) + ) + self.ui.append(bg) + + # Scene background (simple village scene) + scene_bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 400), + fill_color=mcrfpy.Color(40, 60, 40) + ) + self.ui.append(scene_bg) + + # Ground + ground = mcrfpy.Frame( + pos=(0, 350), + size=(1024, 50), + fill_color=mcrfpy.Color(60, 45, 30) + ) + self.ui.append(ground) + + # Simple building shapes + for i in range(3): + building = mcrfpy.Frame( + pos=(100 + i * 300, 200 - i * 20), + size=(200, 150 + i * 20), + fill_color=mcrfpy.Color(70 + i * 10, 60 + i * 5, 50), + outline_color=mcrfpy.Color(40, 35, 30), + outline=2 + ) + self.ui.append(building) + + # Portrait frame + self.portrait_frame = mcrfpy.Frame( + pos=(50, 420), + size=(150, 180), + fill_color=mcrfpy.Color(50, 50, 60), + outline_color=mcrfpy.Color(100, 100, 120), + outline=3 + ) + self.ui.append(self.portrait_frame) + + # NPC "face" (simple representation) + face_bg = mcrfpy.Frame( + pos=(15, 15), + size=(120, 120), + fill_color=mcrfpy.Color(200, 180, 160), + outline=0 + ) + self.portrait_frame.children.append(face_bg) + + # Mood indicator (eyes/expression) + self.mood_indicator = mcrfpy.Frame( + pos=(35, 50), + size=(80, 30), + fill_color=self.npc.mood_color, + outline=0 + ) + self.portrait_frame.children.append(self.mood_indicator) + + # Name label + name_label = mcrfpy.Caption( + text=self.npc.name, + pos=(75, 150), + font_size=14, + fill_color=mcrfpy.Color(255, 255, 255) + ) + self.portrait_frame.children.append(name_label) + + # Dialogue box + self.dialogue_box = DialogueBox( + pos=(220, 420), + size=(550, 180), + speaker=self.npc.name, + text="", + chars_per_second=40 + ) + self.ui.append(self.dialogue_box.frame) + + # Choice list + self.choice_list = ChoiceList( + pos=(220, 610), + size=(550, 120), + choices=["Loading..."], + on_select=self.on_choice, + item_height=28 + ) + self.ui.append(self.choice_list.frame) + + # Trust meter + trust_label = mcrfpy.Caption( + text="Trust:", + pos=(820, 430), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(trust_label) + + self.trust_bar_bg = mcrfpy.Frame( + pos=(820, 450), + size=(150, 20), + fill_color=mcrfpy.Color(40, 40, 50), + outline_color=mcrfpy.Color(80, 80, 100), + outline=1 + ) + self.ui.append(self.trust_bar_bg) + + self.trust_bar = mcrfpy.Frame( + pos=(0, 0), + size=(75, 20), # 50% initial + fill_color=mcrfpy.Color(100, 150, 100), + outline=0 + ) + self.trust_bar_bg.children.append(self.trust_bar) + + self.trust_value = mcrfpy.Caption( + text="50", + pos=(895, 473), + font_size=12, + fill_color=mcrfpy.Color(200, 200, 200) + ) + self.ui.append(self.trust_value) + + # Mood display + mood_label = mcrfpy.Caption( + text="Mood:", + pos=(820, 500), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(mood_label) + + self.mood_display = mcrfpy.Caption( + text="Neutral", + pos=(870, 500), + font_size=14, + fill_color=self.npc.mood_color + ) + self.ui.append(self.mood_display) + + # Instructions + instr = mcrfpy.Caption( + text="1-4: Select choice | Enter: Confirm | Space: Skip | R: Restart | ESC: Exit", + pos=(50, 740), + font_size=14, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(instr) + + # Start dialogue + self._load_dialogue_state("greeting") + + def _load_dialogue_state(self, state_name): + """Load a dialogue state.""" + self.npc.dialogue_state = state_name + + if state_name not in self.dialogue_tree: + state_name = "greeting" + self.npc.dialogue_state = state_name + + state = self.dialogue_tree[state_name] + + # Apply mood change + if "mood" in state: + self.npc.mood = state["mood"] + self._update_mood_display() + + # Apply trust change + if "trust_change" in state: + self.npc.trust = max(0, min(100, self.npc.trust + state["trust_change"])) + self._update_trust_display() + + # Update dialogue + self.dialogue_box.set_dialogue(self.npc.name, state["text"], animate=True) + + # Update choices + choices = [choice[0] for choice in state.get("choices", [])] + self._choice_targets = [choice[1] for choice in state.get("choices", [])] + + if choices: + self.choice_list.choices = choices + else: + self.choice_list.choices = ["*Continue*"] + self._choice_targets = ["greeting"] + + def _update_mood_display(self): + """Update the mood indicators.""" + self.mood_indicator.fill_color = self.npc.mood_color + self.mood_display.text = self.npc.mood.capitalize() + self.mood_display.fill_color = self.npc.mood_color + + def _update_trust_display(self): + """Update the trust bar.""" + bar_width = int((self.npc.trust / 100) * 150) + self.trust_bar.w = bar_width + self.trust_value.text = str(self.npc.trust) + + # Color based on trust level + if self.npc.trust >= 70: + self.trust_bar.fill_color = mcrfpy.Color(100, 200, 100) + elif self.npc.trust >= 30: + self.trust_bar.fill_color = mcrfpy.Color(200, 200, 100) + else: + self.trust_bar.fill_color = mcrfpy.Color(200, 100, 100) + + def on_choice(self, index, value): + """Handle choice selection.""" + if index < len(self._choice_targets): + next_state = self._choice_targets[index] + self._load_dialogue_state(next_state) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key in ("Num1", "Num2", "Num3", "Num4"): + idx = int(key[-1]) - 1 + if idx < len(self.choice_list.choices): + self.choice_list.set_selected(idx) + self.choice_list.confirm() + elif key == "Up": + self.choice_list.navigate(-1) + elif key == "Down": + self.choice_list.navigate(1) + elif key == "Enter": + self.choice_list.confirm() + elif key == "Space": + self.dialogue_box.skip_animation() + elif key == "R": + # Restart + self.npc.mood = "neutral" + self.npc.trust = 50 + self._update_mood_display() + self._update_trust_display() + self._load_dialogue_state("greeting") + + def activate(self): + """Activate the dialogue scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the dialogue system demo.""" + dialogue = DialogueSystem() + dialogue.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/apps/dialogue_system.png"), + sys.exit(0) + ), 200) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/automation/__init__.py b/tests/cookbook/automation/__init__.py new file mode 100644 index 0000000..da43058 --- /dev/null +++ b/tests/cookbook/automation/__init__.py @@ -0,0 +1,10 @@ +# McRogueFace Cookbook - Automation Scripts +""" +Automation tools for capturing screenshots and testing interactions. + +Scripts: + capture_primitives.py - Capture all primitive widget demos + capture_features.py - Capture feature demo screenshots + capture_apps.py - Capture mini-application screenshots + test_interactions.py - Automated interaction tests +""" diff --git a/tests/cookbook/automation/capture_primitives.py b/tests/cookbook/automation/capture_primitives.py new file mode 100644 index 0000000..f0960c6 --- /dev/null +++ b/tests/cookbook/automation/capture_primitives.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Capture screenshots for all primitive widget demos. + +Run with: + cd build && ./mcrogueface --headless --exec ../tests/cookbook/automation/capture_primitives.py +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +# Add cookbook to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# List of demos to capture with their setup functions +DEMOS = [ + ("button", "demo_button"), + ("stat_bar", "demo_stat_bar"), + ("choice_list", "demo_choice_list"), + ("text_box", "demo_text_box"), + ("toast", "demo_toast"), +] + +# Screenshot output directory (relative to build/) +OUTPUT_DIR = "../tests/cookbook/screenshots/primitives" + + +class ScreenshotCapture: + """Captures screenshots for all primitive demos.""" + + def __init__(self): + self.current_demo = 0 + self.demos = [] + + def load_demos(self): + """Load all demo modules.""" + for name, module_name in DEMOS: + try: + module = __import__(f"primitives.{module_name}", fromlist=[module_name]) + self.demos.append((name, module)) + print(f"Loaded: {name}") + except Exception as e: + print(f"Failed to load {name}: {e}") + + def capture_next(self, runtime=None): + """Capture the next demo screenshot.""" + if self.current_demo >= len(self.demos): + print(f"\nDone! Captured {self.current_demo} screenshots.") + sys.exit(0) + return + + name, module = self.demos[self.current_demo] + print(f"Capturing: {name}...") + + try: + # Create demo instance + demo_class = None + for attr_name in dir(module): + attr = getattr(module, attr_name) + if isinstance(attr, type) and attr_name.endswith('Demo'): + demo_class = attr + break + + if demo_class: + demo = demo_class() + demo.activate() + + # Schedule screenshot after render + def take_screenshot(rt): + filename = f"{OUTPUT_DIR}/{name}.png" + try: + automation.screenshot(filename) + print(f" Saved: {filename}") + except Exception as e: + print(f" Error saving: {e}") + + self.current_demo += 1 + self.capture_next() + + mcrfpy.Timer(f"capture_{name}", take_screenshot, 100) + else: + print(f" No Demo class found in {name}") + self.current_demo += 1 + self.capture_next() + + except Exception as e: + print(f" Error: {e}") + import traceback + traceback.print_exc() + self.current_demo += 1 + self.capture_next() + + def start(self): + """Start the capture process.""" + print("=" * 50) + print("Cookbook Primitive Screenshot Capture") + print("=" * 50) + + # Ensure output directory exists + os.makedirs(OUTPUT_DIR, exist_ok=True) + + self.load_demos() + print(f"\nCapturing {len(self.demos)} demos...") + print("-" * 50) + + # Start capture process + self.capture_next() + + +def main(): + capture = ScreenshotCapture() + capture.start() + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/compound/__init__.py b/tests/cookbook/compound/__init__.py new file mode 100644 index 0000000..2bc5505 --- /dev/null +++ b/tests/cookbook/compound/__init__.py @@ -0,0 +1,11 @@ +# McRogueFace Cookbook - Compound Widget Demos +""" +Complex UI patterns combining multiple widgets. + +Demos: + shop_demo.py - Multi-grid item transfer with equipment slots + inventory_demo.py - Grid-based inventory with drag & drop + +These demos build on the primitives and lib/ widgets to show +how to compose sophisticated game interfaces. +""" diff --git a/tests/cookbook/compound/shop_demo.py b/tests/cookbook/compound/shop_demo.py new file mode 100644 index 0000000..6ab7559 --- /dev/null +++ b/tests/cookbook/compound/shop_demo.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Shop Demo - Multi-grid item transfer with equipment slots + +Interactive controls: + Left click: Pick up / Place item + Right click: Cancel pickup + ESC: Return to menu + +This demonstrates: + - ItemManager coordinating multiple grids and slots + - Different zoom levels for shop vs inventory + - Equipment slots with type restrictions + - Item tooltips + - Transaction tracking (gold display) +""" +import mcrfpy +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from lib.item_manager import ItemManager, ItemSlot, Item, ITEM_DATABASE, get_item + + +class ShopDemo: + """Demo showing a shop interface with inventory and equipment.""" + + def __init__(self): + self.scene = mcrfpy.Scene("shop_demo") + self.ui = self.scene.children + self.manager = None + self.gold = 100 + + # UI elements for updates + self.gold_display = None + self.tooltip = None + self.item_stats = None + + self.setup() + + def setup(self): + """Build the shop UI.""" + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(25, 22, 30)) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="The Adventurer's Shop", + pos=(512, 25), + font_size=32, + fill_color=(255, 220, 100) + ) + title.outline = 2 + title.outline_color = (80, 60, 0) + self.ui.append(title) + + # Gold display + self.gold_display = mcrfpy.Caption( + text=f"Gold: {self.gold}", + pos=(900, 25), + font_size=20, + fill_color=(255, 215, 0) + ) + self.ui.append(self.gold_display) + + # Initialize item manager + self.manager = ItemManager(self.scene) + self.manager.on_pickup = self._on_pickup + self.manager.on_drop = self._on_drop + + # Create shop grid (left side, zoomed in) + self._create_shop_grid() + + # Create player inventory (right side) + self._create_inventory_grid() + + # Create equipment slots (center) + self._create_equipment_slots() + + # Tooltip area + self._create_tooltip_area() + + # Instructions + instr = mcrfpy.Caption( + text="Left click: Pick up/Place | Right click: Cancel | Items show stats on hover", + pos=(512, 740), + font_size=14, + fill_color=(150, 150, 150) + ) + self.ui.append(instr) + + def _create_shop_grid(self): + """Create the shop's item display grid.""" + # Shop panel + shop_panel = mcrfpy.Frame( + pos=(20, 70), + size=(300, 400), + fill_color=(40, 35, 50), + outline=2, + outline_color=(80, 70, 100) + ) + self.ui.append(shop_panel) + + shop_label = mcrfpy.Caption( + text="Shop Inventory", + pos=(170, 90), + font_size=18, + fill_color=(200, 180, 255) + ) + self.ui.append(shop_label) + + # Shop grid (larger cells for better visibility) + grid_size = (4, 4) + cell_size = 64 # Zoomed in + grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size) + + shop_grid = mcrfpy.Grid( + pos=(40, 120), + size=grid_pixel_size, + grid_size=grid_size, + texture=mcrfpy.default_texture, + zoom=4.0 + ) + + # Fill tile layer with floor tiles + tile_layer = shop_grid.layers[0] + tile_layer.fill(46) + + # Add color layer with slight tint + color_layer = shop_grid.add_layer('color', z_index=-1) + for y in range(grid_size[1]): + for x in range(grid_size[0]): + color_layer.set((x, y), (180, 170, 200, 80)) + + self.ui.append(shop_grid) + self.manager.register_grid("shop", shop_grid, {}) + + # Stock the shop + shop_items = [ + ('longsword', (0, 0)), + ('shield', (1, 0)), + ('axe', (2, 0)), + ('wand', (3, 0)), + ('health_potion', (0, 1)), + ('mana_potion', (1, 1)), + ('str_potion', (2, 1)), + ('staff', (3, 1)), + ('buckler', (0, 2)), + ('hammer', (1, 2)), + ('spear', (2, 2)), + ('double_axe', (3, 2)), + ] + + for item_name, pos in shop_items: + item = get_item(item_name) + if item: + self.manager.add_item_to_grid("shop", pos, item) + + def _create_inventory_grid(self): + """Create the player's inventory grid.""" + # Inventory panel + inv_panel = mcrfpy.Frame( + pos=(704, 70), + size=(300, 400), + fill_color=(35, 40, 50), + outline=2, + outline_color=(70, 80, 100) + ) + self.ui.append(inv_panel) + + inv_label = mcrfpy.Caption( + text="Your Inventory", + pos=(854, 90), + font_size=18, + fill_color=(180, 200, 255) + ) + self.ui.append(inv_label) + + # Inventory grid (smaller cells, more slots) + grid_size = (5, 6) + cell_size = 48 + grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size) + + inv_grid = mcrfpy.Grid( + pos=(729, 120), + size=grid_pixel_size, + grid_size=grid_size, + texture=mcrfpy.default_texture, + zoom=3.0 + ) + + # Fill tile layer with floor tiles + tile_layer = inv_grid.layers[0] + tile_layer.fill(46) + + # Add color layer with slight tint + color_layer = inv_grid.add_layer('color', z_index=-1) + for y in range(grid_size[1]): + for x in range(grid_size[0]): + color_layer.set((x, y), (170, 180, 200, 80)) + + self.ui.append(inv_grid) + self.manager.register_grid("inventory", inv_grid, {}) + + # Give player starting items + starting_items = [ + ('shortsword', (0, 0)), + ('lesser_health', (1, 0)), + ('lesser_health', (2, 0)), + ] + + for item_name, pos in starting_items: + item = get_item(item_name) + if item: + self.manager.add_item_to_grid("inventory", pos, item) + + def _create_equipment_slots(self): + """Create equipment slots in the center.""" + # Equipment panel + equip_panel = mcrfpy.Frame( + pos=(362, 70), + size=(300, 400), + fill_color=(45, 40, 35), + outline=2, + outline_color=(100, 90, 70) + ) + self.ui.append(equip_panel) + + equip_label = mcrfpy.Caption( + text="Equipment", + pos=(512, 90), + font_size=18, + fill_color=(255, 220, 180) + ) + self.ui.append(equip_label) + + # Character silhouette placeholder + char_frame = mcrfpy.Frame( + pos=(437, 150), + size=(150, 200), + fill_color=(60, 55, 50), + outline=1, + outline_color=(100, 95, 85) + ) + self.ui.append(char_frame) + + char_label = mcrfpy.Caption( + text="[Character]", + pos=(512, 240), + font_size=14, + fill_color=(120, 115, 105) + ) + self.ui.append(char_label) + + # Equipment slots around the character + slot_size = (64, 64) + + # Weapon slot (left of character) + weapon_slot = ItemSlot( + pos=(382, 200), + size=slot_size, + slot_type='weapon', + empty_color=(80, 60, 60), + valid_color=(60, 100, 60), + invalid_color=(100, 60, 60), + filled_color=(100, 80, 80) + ) + self.ui.append(weapon_slot) + self.manager.register_slot("weapon", weapon_slot) + + weapon_label = mcrfpy.Caption( + text="Weapon", + pos=(414, 270), + font_size=12, + fill_color=(200, 180, 180) + ) + self.ui.append(weapon_label) + + # Shield slot (right of character) + shield_slot = ItemSlot( + pos=(578, 200), + size=slot_size, + slot_type='shield', + empty_color=(60, 60, 80), + valid_color=(60, 100, 60), + invalid_color=(100, 60, 60), + filled_color=(80, 80, 100) + ) + self.ui.append(shield_slot) + self.manager.register_slot("shield", shield_slot) + + shield_label = mcrfpy.Caption( + text="Shield", + pos=(610, 270), + font_size=12, + fill_color=(180, 180, 200) + ) + self.ui.append(shield_label) + + # Consumable slot (below character) + consumable_slot = ItemSlot( + pos=(480, 360), + size=slot_size, + slot_type='consumable', + empty_color=(60, 80, 60), + valid_color=(60, 100, 60), + invalid_color=(100, 60, 60), + filled_color=(80, 100, 80) + ) + self.ui.append(consumable_slot) + self.manager.register_slot("consumable", consumable_slot) + + consumable_label = mcrfpy.Caption( + text="Quick Item", + pos=(512, 430), + font_size=12, + fill_color=(180, 200, 180) + ) + self.ui.append(consumable_label) + + def _create_tooltip_area(self): + """Create area for item tooltips.""" + # Tooltip panel + tooltip_panel = mcrfpy.Frame( + pos=(20, 490), + size=(984, 100), + fill_color=(30, 30, 40), + outline=1, + outline_color=(60, 60, 80) + ) + self.ui.append(tooltip_panel) + + # Item name + self.tooltip = mcrfpy.Caption( + text="Hover over an item to see details", + pos=(512, 510), + font_size=18, + fill_color=(180, 180, 180) + ) + self.ui.append(self.tooltip) + + # Item stats + self.item_stats = mcrfpy.Caption( + text="", + pos=(512, 545), + font_size=14, + fill_color=(150, 150, 180) + ) + self.ui.append(self.item_stats) + + # Status message + self.status = mcrfpy.Caption( + text="", + pos=(512, 610), + font_size=16, + fill_color=(100, 255, 100) + ) + self.ui.append(self.status) + + def _format_item_stats(self, item): + """Format item stats for display.""" + stats = [] + if item.atk > 0: + stats.append(f"+{item.atk} ATK") + if item.def_ > 0: + stats.append(f"+{item.def_} DEF") + if item.stats.get('int_', 0) > 0: + stats.append(f"+{item.stats['int_']} INT") + if item.stats.get('range_', 0) > 0: + stats.append(f"+{item.stats['range_']} Range") + if item.stats.get('two_handed'): + stats.append("(Two-handed)") + + stats_str = " | ".join(stats) if stats else "No combat stats" + return f"{stats_str} | Price: {item.price} gold" + + def _on_pickup(self, item, source, pos): + """Called when item is picked up.""" + self.tooltip.text = f"Holding: {item.name}" + self.tooltip.fill_color = (100, 200, 255) + self.item_stats.text = self._format_item_stats(item) + + def _on_drop(self, item, target, pos): + """Called when item is dropped.""" + self.tooltip.text = f"Placed: {item.name}" + self.tooltip.fill_color = (100, 255, 100) + self.item_stats.text = "" + + # Simple transaction feedback (in full version, would handle gold) + if target == "shop": + self.status.text = f"Returned {item.name} to shop" + elif target == "inventory": + self.status.text = f"Added {item.name} to inventory" + elif target in ("weapon", "shield", "consumable"): + self.status.text = f"Equipped {item.name}" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + if self.manager.held_item: + self.manager.cancel_pickup() + self.tooltip.text = "Cancelled" + self.tooltip.fill_color = (200, 150, 100) + self.item_stats.text = "" + return + + try: + from cookbook_main import main + main() + except: + sys.exit(0) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the demo.""" + demo = ShopDemo() + demo.activate() + + # Headless screenshot + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Pick up an item for the screenshot + demo.manager._pickup_from_grid("shop", (0, 0)) + demo.manager.cursor_frame.x = 450 + demo.manager.cursor_frame.y = 200 + + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/compound/shop_demo.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/cookbook_main.py b/tests/cookbook/cookbook_main.py new file mode 100644 index 0000000..c48c65b --- /dev/null +++ b/tests/cookbook/cookbook_main.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""McRogueFace Cookbook - Interactive Demo Launcher + +A comprehensive collection of reusable UI widgets and interactive demos +showcasing McRogueFace capabilities. + +Controls: + Up/Down: Navigate menu + Enter: Run selected demo + ESC: Exit (or go back from demo) +""" +import mcrfpy +import sys +import os + +# Ensure lib is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +class CookbookLauncher: + """Main launcher for the cookbook demos.""" + + DEMOS = { + "Primitives": [ + ("Button Widget", "primitives.demo_button"), + ("Stat Bar Widget", "primitives.demo_stat_bar"), + ("Choice List Widget", "primitives.demo_choice_list"), + ("Text Box Widget", "primitives.demo_text_box"), + ("Toast Notifications", "primitives.demo_toast"), + ("Drag & Drop (Frame)", "primitives.demo_drag_drop_frame"), + ("Drag & Drop (Grid)", "primitives.demo_drag_drop_grid"), + ("Click to Pick Up", "primitives.demo_click_pickup"), + ], + "Features": [ + ("Animation Chain/Group", "features.demo_animation_chain"), + ("Shader Effects", "features.demo_shaders"), + ("Rotation & Origin", "features.demo_rotation"), + ("Alignment (TODO)", None), + ], + "Mini-Apps": [ + ("Calculator", "apps.calculator"), + ("Dialogue System", "apps.dialogue_system"), + ("Day/Night Shadows (TODO)", None), + ], + "Compound": [ + ("Shop Demo", "compound.shop_demo"), + ("Inventory UI (TODO)", None), + ("Character Sheet (TODO)", None), + ], + } + + def __init__(self): + self.scene = mcrfpy.Scene("cookbook_main") + self.ui = self.scene.children + self.selected_category = 0 + self.selected_item = 0 + self.categories = list(self.DEMOS.keys()) + self.setup() + + def setup(self): + """Build the launcher UI.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(15, 15, 20) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="McRogueFace Cookbook", + pos=(512, 40), + font_size=36, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 3 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Subtitle + subtitle = mcrfpy.Caption( + text="Widget Library & Interactive Demos", + pos=(512, 85), + font_size=18, + fill_color=mcrfpy.Color(150, 150, 180) + ) + self.ui.append(subtitle) + + # Create category panels + self.category_frames = [] + self.item_labels = {} + + panel_width = 220 + panel_spacing = 30 + start_x = (1024 - (len(self.categories) * panel_width + (len(self.categories) - 1) * panel_spacing)) // 2 + + for i, category in enumerate(self.categories): + x = start_x + i * (panel_width + panel_spacing) + self._create_category_panel(category, x, 130, panel_width) + + # Instructions + instr = mcrfpy.Caption( + text="Arrow Keys: Navigate | Enter: Run Demo | ESC: Exit", + pos=(512, 720), + font_size=14, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(instr) + + # Update display + self._update_selection() + + def _create_category_panel(self, category, x, y, width): + """Create a category panel with demo items.""" + items = self.DEMOS[category] + + # Calculate panel height + header_height = 40 + item_height = 35 + padding = 10 + panel_height = header_height + len(items) * item_height + padding * 2 + + # Panel background + panel = mcrfpy.Frame( + pos=(x, y), + size=(width, panel_height), + fill_color=mcrfpy.Color(30, 30, 40), + outline_color=mcrfpy.Color(60, 60, 80), + outline=2 + ) + self.ui.append(panel) + self.category_frames.append(panel) + + # Category title + cat_title = mcrfpy.Caption( + text=category, + pos=(width // 2, 12), + font_size=16, + fill_color=mcrfpy.Color(200, 200, 220) + ) + panel.children.append(cat_title) + + # Separator line + sep = mcrfpy.Frame( + pos=(10, 35), + size=(width - 20, 2), + fill_color=mcrfpy.Color(60, 60, 80) + ) + panel.children.append(sep) + + # Item list + self.item_labels[category] = [] + for j, (item_name, module) in enumerate(items): + item_y = header_height + j * item_height + 5 + item_label = mcrfpy.Caption( + text=item_name, + pos=(15, item_y), + font_size=13, + fill_color=mcrfpy.Color(150, 150, 150) if module else mcrfpy.Color(80, 80, 80) + ) + panel.children.append(item_label) + self.item_labels[category].append((item_label, module is not None)) + + def _update_selection(self): + """Update the visual selection state.""" + # Update all items + for cat_idx, category in enumerate(self.categories): + # Update panel outline + panel = self.category_frames[cat_idx] + if cat_idx == self.selected_category: + panel.outline_color = mcrfpy.Color(100, 150, 255) + panel.outline = 3 + else: + panel.outline_color = mcrfpy.Color(60, 60, 80) + panel.outline = 2 + + # Update item colors + for item_idx, (label, available) in enumerate(self.item_labels[category]): + if cat_idx == self.selected_category and item_idx == self.selected_item: + if available: + label.fill_color = mcrfpy.Color(100, 200, 255) + else: + label.fill_color = mcrfpy.Color(100, 100, 120) + else: + if available: + label.fill_color = mcrfpy.Color(180, 180, 180) + else: + label.fill_color = mcrfpy.Color(80, 80, 80) + + def _run_selected_demo(self): + """Run the currently selected demo.""" + category = self.categories[self.selected_category] + items = self.DEMOS[category] + + if self.selected_item < len(items): + name, module = items[self.selected_item] + if module: + try: + # Import and run the demo module + exec(f"from {module} import main; main()") + except Exception as e: + print(f"Error running demo: {e}") + import traceback + traceback.print_exc() + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + category = self.categories[self.selected_category] + items = self.DEMOS[category] + + if key == "Escape": + sys.exit(0) + elif key == "Left": + self.selected_category = (self.selected_category - 1) % len(self.categories) + # Clamp item selection to new category + new_category = self.categories[self.selected_category] + self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) + self._update_selection() + elif key == "Right": + self.selected_category = (self.selected_category + 1) % len(self.categories) + new_category = self.categories[self.selected_category] + self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) + self._update_selection() + elif key == "Up": + self.selected_item = (self.selected_item - 1) % len(items) + self._update_selection() + elif key == "Down": + self.selected_item = (self.selected_item + 1) % len(items) + self._update_selection() + elif key == "Enter": + self._run_selected_demo() + + def activate(self): + """Activate the launcher scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Launch the cookbook.""" + launcher = CookbookLauncher() + launcher.activate() + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/features/__init__.py b/tests/cookbook/features/__init__.py new file mode 100644 index 0000000..c31adf1 --- /dev/null +++ b/tests/cookbook/features/__init__.py @@ -0,0 +1,9 @@ +# McRogueFace Cookbook - Feature Demos +""" +Showcase demos for new and advanced features. + +- demo_shaders.py - GLSL shader effects +- demo_rotation.py - Transform rotation and origin +- demo_alignment.py - 9-point alignment system +- demo_animation_chain.py - Animation Chain/Group patterns +""" diff --git a/tests/cookbook/features/demo_animation_chain.py b/tests/cookbook/features/demo_animation_chain.py new file mode 100644 index 0000000..bfd0c83 --- /dev/null +++ b/tests/cookbook/features/demo_animation_chain.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Animation Chain/Group Demo - Complex animation orchestration + +Interactive controls: + 1: Run sequential chain demo + 2: Run parallel group demo + 3: Run callback demo + 4: Run looping demo + 5: Run combined demo + R: Reset all animations + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.anim_utils import ( + AnimationChain, AnimationGroup, delay, callback, + fade_in, fade_out, slide_in_from_left, shake +) + + +class AnimationDemo: + def __init__(self): + self.scene = mcrfpy.Scene("animation_demo") + self.ui = self.scene.children + self.demo_frames = [] + self.active_animations = [] + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Animation Chain/Group Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Create demo areas + self._create_chain_demo() + self._create_group_demo() + self._create_callback_demo() + self._create_loop_demo() + self._create_combined_demo() + + # Status display + self.status = mcrfpy.Caption( + text="Press 1-5 to run demos, R to reset", + pos=(50, 700), + font_size=16, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.status) + + # Instructions + instr = mcrfpy.Caption( + text="1: Chain | 2: Group | 3: Callback | 4: Loop | 5: Combined | R: Reset | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + def _create_chain_demo(self): + """Create the sequential chain demo area.""" + # Label + label = mcrfpy.Caption( + text="1. Sequential Chain", + pos=(50, 80), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Description + desc = mcrfpy.Caption( + text="Move right -> wait -> move down -> wait -> move left", + pos=(50, 100), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(desc) + + # Animated frame + self.chain_frame = mcrfpy.Frame( + pos=(50, 130), + size=(60, 60), + fill_color=mcrfpy.Color(100, 150, 200), + outline_color=mcrfpy.Color(150, 200, 255), + outline=2 + ) + self.demo_frames.append(('chain', self.chain_frame, (50, 130))) + self.ui.append(self.chain_frame) + + def _create_group_demo(self): + """Create the parallel group demo area.""" + # Label + label = mcrfpy.Caption( + text="2. Parallel Group", + pos=(350, 80), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Description + desc = mcrfpy.Caption( + text="Move + resize + change color simultaneously", + pos=(350, 100), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(desc) + + # Animated frame + self.group_frame = mcrfpy.Frame( + pos=(350, 130), + size=(60, 60), + fill_color=mcrfpy.Color(200, 100, 100), + outline_color=mcrfpy.Color(255, 150, 150), + outline=2 + ) + self.demo_frames.append(('group', self.group_frame, (350, 130))) + self.ui.append(self.group_frame) + + def _create_callback_demo(self): + """Create the callback demo area.""" + # Label + label = mcrfpy.Caption( + text="3. Callbacks", + pos=(650, 80), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Description + desc = mcrfpy.Caption( + text="Each step triggers a callback", + pos=(650, 100), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(desc) + + # Animated frame + self.callback_frame = mcrfpy.Frame( + pos=(650, 130), + size=(60, 60), + fill_color=mcrfpy.Color(100, 200, 100), + outline_color=mcrfpy.Color(150, 255, 150), + outline=2 + ) + self.demo_frames.append(('callback', self.callback_frame, (650, 130))) + self.ui.append(self.callback_frame) + + # Callback counter display + self.callback_counter = mcrfpy.Caption( + text="Callbacks: 0", + pos=(720, 160), + font_size=12, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.callback_counter) + + def _create_loop_demo(self): + """Create the looping demo area.""" + # Label + label = mcrfpy.Caption( + text="4. Looping", + pos=(50, 280), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Description + desc = mcrfpy.Caption( + text="Continuous back-and-forth animation", + pos=(50, 300), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(desc) + + # Animated frame + self.loop_frame = mcrfpy.Frame( + pos=(50, 330), + size=(40, 40), + fill_color=mcrfpy.Color(200, 200, 100), + outline_color=mcrfpy.Color(255, 255, 150), + outline=2 + ) + self.demo_frames.append(('loop', self.loop_frame, (50, 330))) + self.ui.append(self.loop_frame) + + self.loop_chain = None + + def _create_combined_demo(self): + """Create the combined demo area.""" + # Label + label = mcrfpy.Caption( + text="5. Combined (Chain of Groups)", + pos=(350, 280), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Description + desc = mcrfpy.Caption( + text="Multiple frames animating in complex patterns", + pos=(350, 300), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(desc) + + # Multiple animated frames + self.combined_frames = [] + colors = [ + mcrfpy.Color(200, 100, 150), + mcrfpy.Color(150, 100, 200), + mcrfpy.Color(100, 150, 200), + ] + for i, color in enumerate(colors): + frame = mcrfpy.Frame( + pos=(350 + i * 70, 330), + size=(50, 50), + fill_color=color, + outline=1 + ) + self.combined_frames.append(frame) + self.demo_frames.append(('combined', frame, (350 + i * 70, 330))) + self.ui.append(frame) + + def run_chain_demo(self): + """Run the sequential chain demo.""" + self.status.text = "Running: Sequential Chain" + + chain = AnimationChain( + (self.chain_frame, "x", 200, 0.5), + delay(0.3), + (self.chain_frame, "y", 200, 0.5), + delay(0.3), + (self.chain_frame, "x", 50, 0.5), + callback=lambda: setattr(self.status, 'text', 'Chain complete!') + ) + chain.start() + self.active_animations.append(chain) + + def run_group_demo(self): + """Run the parallel group demo.""" + self.status.text = "Running: Parallel Group" + + group = AnimationGroup( + (self.group_frame, "x", 500, 1.0), + (self.group_frame, "w", 100, 1.0), + (self.group_frame, "h", 100, 1.0), + callback=lambda: setattr(self.status, 'text', 'Group complete!') + ) + group.start() + self.active_animations.append(group) + + def run_callback_demo(self): + """Run the callback demo.""" + self.status.text = "Running: Callbacks" + self.callback_count = 0 + + def increment_counter(): + self.callback_count += 1 + self.callback_counter.text = f"Callbacks: {self.callback_count}" + + chain = AnimationChain( + callback(increment_counter), + (self.callback_frame, "x", 750, 0.3), + callback(increment_counter), + delay(0.2), + callback(increment_counter), + (self.callback_frame, "y", 200, 0.3), + callback(increment_counter), + (self.callback_frame, "x", 650, 0.3), + callback(increment_counter), + callback=lambda: setattr(self.status, 'text', f'Callback demo complete! ({self.callback_count} callbacks)') + ) + chain.start() + self.active_animations.append(chain) + + def run_loop_demo(self): + """Run the looping demo.""" + self.status.text = "Running: Looping (press R to stop)" + + # Stop any existing loop + if self.loop_chain: + self.loop_chain.stop() + + self.loop_chain = AnimationChain( + (self.loop_frame, "x", 250, 1.0), + (self.loop_frame, "x", 50, 1.0), + loop=True + ) + self.loop_chain.start() + self.active_animations.append(self.loop_chain) + + def run_combined_demo(self): + """Run the combined demo with chain of groups.""" + self.status.text = "Running: Combined" + + # First, all frames slide down together + # Then, each one bounces back up sequentially + frame1, frame2, frame3 = self.combined_frames + + chain = AnimationChain( + # Phase 1: All move down together (as a group effect via separate chains) + (frame1, "y", 450, 0.5), + delay(0.1), + (frame2, "y", 450, 0.5), + delay(0.1), + (frame3, "y", 450, 0.5), + delay(0.5), + # Phase 2: Spread out horizontally + (frame1, "x", 320, 0.3), + (frame3, "x", 520, 0.3), + delay(0.5), + # Phase 3: All return home + (frame1, "x", 350, 0.3), + (frame1, "y", 330, 0.3), + delay(0.1), + (frame2, "y", 330, 0.3), + delay(0.1), + (frame3, "x", 490, 0.3), + (frame3, "y", 330, 0.3), + callback=lambda: setattr(self.status, 'text', 'Combined demo complete!') + ) + chain.start() + self.active_animations.append(chain) + + def reset_all(self): + """Reset all animations to initial state.""" + # Stop all active animations + for anim in self.active_animations: + if hasattr(anim, 'stop'): + anim.stop() + self.active_animations.clear() + + if self.loop_chain: + self.loop_chain.stop() + self.loop_chain = None + + # Reset positions + for name, frame, (orig_x, orig_y) in self.demo_frames: + frame.x = orig_x + frame.y = orig_y + if name == 'group': + frame.w = 60 + frame.h = 60 + + # Reset callback counter + self.callback_count = 0 + self.callback_counter.text = "Callbacks: 0" + + self.status.text = "All animations reset" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Num1": + self.run_chain_demo() + elif key == "Num2": + self.run_group_demo() + elif key == "Num3": + self.run_callback_demo() + elif key == "Num4": + self.run_loop_demo() + elif key == "Num5": + self.run_combined_demo() + elif key == "R": + self.reset_all() + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the animation demo.""" + demo = AnimationDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Run a quick demo then screenshot + demo.run_chain_demo() + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/features/animation_chain_demo.png"), + sys.exit(0) + ), 500) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/features/demo_rotation.py b/tests/cookbook/features/demo_rotation.py new file mode 100644 index 0000000..2284bf7 --- /dev/null +++ b/tests/cookbook/features/demo_rotation.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +"""Rotation Demo - Transform rotation and origin points + +Interactive controls: + Left/Right: Rotate selected element + Up/Down: Adjust rotation speed + 1-4: Select element to rotate + O: Cycle origin point + R: Reset all rotations + A: Toggle auto-rotation + ESC: Exit demo + +Rotation properties: + - rotation: Angle in degrees + - origin: (x, y) rotation center point + - rotate_with_camera: For Grid entities +""" +import mcrfpy +import sys + + +class RotationDemo: + def __init__(self): + self.scene = mcrfpy.Scene("rotation_demo") + self.ui = self.scene.children + self.elements = [] + self.selected = 0 + self.auto_rotate = False + self.rotation_speed = 45 # degrees per second + self.origin_index = 0 + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(15, 15, 20) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Rotation Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Create rotatable elements + self._create_frame_demo(100, 120) + self._create_sprite_demo(400, 120) + self._create_caption_demo(700, 120) + self._create_origin_demo(250, 450) + + # Control panel + self._create_control_panel() + + # Instructions + instr = mcrfpy.Caption( + text="Left/Right: Rotate | 1-4: Select | O: Cycle origin | A: Auto-rotate | R: Reset | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + # Start update timer for auto-rotation + mcrfpy.Timer("rotation_update", self._update, 16) # ~60fps + + def _create_frame_demo(self, x, y): + """Create rotating frame demo.""" + # Label + label = mcrfpy.Caption( + text="1. Frame Rotation", + pos=(x + 100, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Rotatable frame + frame = mcrfpy.Frame( + pos=(x, y), + size=(200, 200), + fill_color=mcrfpy.Color(80, 100, 140), + outline_color=mcrfpy.Color(120, 150, 200), + outline=3 + ) + + # Add child content + inner = mcrfpy.Frame( + pos=(50, 50), + size=(100, 100), + fill_color=mcrfpy.Color(100, 120, 160), + outline=1 + ) + frame.children.append(inner) + + content = mcrfpy.Caption( + text="Rotates!", + pos=(100, 85), + font_size=14, + fill_color=mcrfpy.Color(255, 255, 255) + ) + frame.children.append(content) + + self.ui.append(frame) + self.elements.append(('Frame', frame)) + + # Rotation indicator + angle_label = mcrfpy.Caption( + text="0.0°", + pos=(x + 100, y + 220), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(angle_label) + self.frame_angle_label = angle_label + + def _create_sprite_demo(self, x, y): + """Create rotating sprite demo (using Frame as placeholder).""" + # Label + label = mcrfpy.Caption( + text="2. Sprite Rotation", + pos=(x + 100, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Use a frame to simulate sprite (actual sprite would need texture) + sprite_frame = mcrfpy.Frame( + pos=(x, y), + size=(200, 200), + fill_color=mcrfpy.Color(100, 140, 80), + outline_color=mcrfpy.Color(150, 200, 120), + outline=3 + ) + + # Arrow pattern to show rotation direction + arrow_v = mcrfpy.Caption( + text="^", + pos=(100, 40), + font_size=48, + fill_color=mcrfpy.Color(255, 255, 255) + ) + sprite_frame.children.append(arrow_v) + + sprite_label = mcrfpy.Caption( + text="UP", + pos=(100, 100), + font_size=24, + fill_color=mcrfpy.Color(255, 255, 255) + ) + sprite_frame.children.append(sprite_label) + + self.ui.append(sprite_frame) + self.elements.append(('Sprite', sprite_frame)) + + angle_label = mcrfpy.Caption( + text="0.0°", + pos=(x + 100, y + 220), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(angle_label) + self.sprite_angle_label = angle_label + + def _create_caption_demo(self, x, y): + """Create rotating caption demo.""" + # Label + label = mcrfpy.Caption( + text="3. Caption Rotation", + pos=(x + 100, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Background for visibility + caption_bg = mcrfpy.Frame( + pos=(x, y), + size=(200, 200), + fill_color=mcrfpy.Color(40, 40, 50), + outline_color=mcrfpy.Color(80, 80, 100), + outline=1 + ) + self.ui.append(caption_bg) + + # Rotatable caption + caption = mcrfpy.Caption( + text="Rotating Text!", + pos=(x + 100, y + 100), + font_size=24, + fill_color=mcrfpy.Color(255, 200, 100) + ) + caption.outline = 2 + caption.outline_color = mcrfpy.Color(0, 0, 0) + + self.ui.append(caption) + self.elements.append(('Caption', caption)) + + angle_label = mcrfpy.Caption( + text="0.0°", + pos=(x + 100, y + 220), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(angle_label) + self.caption_angle_label = angle_label + + def _create_origin_demo(self, x, y): + """Create demo showing different origin points.""" + # Label + label = mcrfpy.Caption( + text="4. Origin Point Demo (press O to cycle)", + pos=(x + 200, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Large frame to show origin effects + origin_frame = mcrfpy.Frame( + pos=(x, y), + size=(400, 200), + fill_color=mcrfpy.Color(140, 80, 100), + outline_color=mcrfpy.Color(200, 120, 150), + outline=3 + ) + + # Origin marker + origin_marker = mcrfpy.Frame( + pos=(0, 0), + size=(10, 10), + fill_color=mcrfpy.Color(255, 255, 0), + outline=0 + ) + origin_frame.children.append(origin_marker) + self.origin_marker = origin_marker + + # Origin name + origin_label = mcrfpy.Caption( + text="Origin: Top-Left", + pos=(200, 85), + font_size=16, + fill_color=mcrfpy.Color(255, 255, 255) + ) + origin_frame.children.append(origin_label) + self.origin_name_label = origin_label + + self.ui.append(origin_frame) + self.elements.append(('Origin Demo', origin_frame)) + self.origin_frame = origin_frame + + # Origin positions to cycle through + self.origins = [ + ("Top-Left", (0, 0)), + ("Top-Center", (200, 0)), + ("Top-Right", (400, 0)), + ("Center-Left", (0, 100)), + ("Center", (200, 100)), + ("Center-Right", (400, 100)), + ("Bottom-Left", (0, 200)), + ("Bottom-Center", (200, 200)), + ("Bottom-Right", (400, 200)), + ] + + angle_label = mcrfpy.Caption( + text="0.0°", + pos=(x + 200, y + 220), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(angle_label) + self.origin_angle_label = angle_label + + def _create_control_panel(self): + """Create control panel showing current state.""" + panel = mcrfpy.Frame( + pos=(750, 450), + size=(220, 180), + fill_color=mcrfpy.Color(30, 30, 40), + outline_color=mcrfpy.Color(60, 60, 80), + outline=1 + ) + self.ui.append(panel) + + panel_title = mcrfpy.Caption( + text="Status", + pos=(110, 10), + font_size=16, + fill_color=mcrfpy.Color(200, 200, 200) + ) + panel.children.append(panel_title) + + self.selected_label = mcrfpy.Caption( + text="Selected: Frame", + pos=(15, 40), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + panel.children.append(self.selected_label) + + self.speed_label = mcrfpy.Caption( + text="Speed: 45°/sec", + pos=(15, 65), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + panel.children.append(self.speed_label) + + self.auto_label = mcrfpy.Caption( + text="Auto-rotate: Off", + pos=(15, 90), + font_size=14, + fill_color=mcrfpy.Color(200, 100, 100) + ) + panel.children.append(self.auto_label) + + def _update(self, runtime): + """Update loop for auto-rotation.""" + if self.auto_rotate: + dt = 0.016 # Approximately 16ms per frame + for name, element in self.elements: + try: + element.rotation = (element.rotation + self.rotation_speed * dt) % 360 + except AttributeError: + pass + + # Update angle labels + self._update_angle_labels() + + def _update_angle_labels(self): + """Update the angle display labels.""" + labels = [self.frame_angle_label, self.sprite_angle_label, + self.caption_angle_label, self.origin_angle_label] + + for i, (name, element) in enumerate(self.elements): + if i < len(labels): + try: + labels[i].text = f"{element.rotation:.1f}°" + except AttributeError: + labels[i].text = "N/A" + + def _cycle_origin(self): + """Cycle to the next origin point.""" + self.origin_index = (self.origin_index + 1) % len(self.origins) + name, pos = self.origins[self.origin_index] + + # Update origin + try: + self.origin_frame.origin = pos + self.origin_name_label.text = f"Origin: {name}" + # Move marker to show origin position + self.origin_marker.x = pos[0] - 5 + self.origin_marker.y = pos[1] - 5 + except AttributeError: + pass + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Left": + # Rotate left (counter-clockwise) + name, element = self.elements[self.selected] + try: + element.rotation = (element.rotation - 15) % 360 + except AttributeError: + pass + elif key == "Right": + # Rotate right (clockwise) + name, element = self.elements[self.selected] + try: + element.rotation = (element.rotation + 15) % 360 + except AttributeError: + pass + elif key == "Up": + self.rotation_speed = min(180, self.rotation_speed + 15) + self.speed_label.text = f"Speed: {self.rotation_speed}°/sec" + elif key == "Down": + self.rotation_speed = max(15, self.rotation_speed - 15) + self.speed_label.text = f"Speed: {self.rotation_speed}°/sec" + elif key in ("Num1", "Num2", "Num3", "Num4"): + self.selected = int(key[-1]) - 1 + if self.selected < len(self.elements): + self.selected_label.text = f"Selected: {self.elements[self.selected][0]}" + elif key == "O": + self._cycle_origin() + elif key == "A": + self.auto_rotate = not self.auto_rotate + if self.auto_rotate: + self.auto_label.text = "Auto-rotate: On" + self.auto_label.fill_color = mcrfpy.Color(100, 200, 100) + else: + self.auto_label.text = "Auto-rotate: Off" + self.auto_label.fill_color = mcrfpy.Color(200, 100, 100) + elif key == "R": + # Reset all rotations + for name, element in self.elements: + try: + element.rotation = 0 + except AttributeError: + pass + self.origin_index = 0 + name, pos = self.origins[0] + try: + self.origin_frame.origin = pos + self.origin_name_label.text = f"Origin: {name}" + self.origin_marker.x = pos[0] - 5 + self.origin_marker.y = pos[1] - 5 + except AttributeError: + pass + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the rotation demo.""" + demo = RotationDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Rotate elements for visual interest + for name, element in demo.elements: + try: + element.rotation = 15 + except AttributeError: + pass + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/features/rotation_demo.png"), + sys.exit(0) + ), 200) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/features/demo_shaders.py b/tests/cookbook/features/demo_shaders.py new file mode 100644 index 0000000..ea48ecd --- /dev/null +++ b/tests/cookbook/features/demo_shaders.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Shader Effects Demo - GLSL shader visual effects + +Interactive controls: + 1-4: Focus on specific shader + Space: Toggle shaders on/off + R: Reset all shaders + ESC: Exit demo + +Shader uniforms available: + - float time: Seconds since engine start + - float delta_time: Seconds since last frame + - vec2 resolution: Texture size in pixels + - vec2 mouse: Mouse position in window coordinates +""" +import mcrfpy +import sys + + +class ShaderDemo: + def __init__(self): + self.scene = mcrfpy.Scene("shader_demo") + self.ui = self.scene.children + self.shaders_enabled = True + self.shader_frames = [] + self.shaders = [] + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(15, 15, 20) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Shader Effects Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Create four shader demo quadrants + self._create_pulse_shader(50, 100) + self._create_vignette_shader(530, 100) + self._create_wave_shader(50, 420) + self._create_color_shift_shader(530, 420) + + # Instructions + instr = mcrfpy.Caption( + text="1-4: Focus shader | Space: Toggle on/off | R: Reset | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + # Status + self.status = mcrfpy.Caption( + text="Shaders: Enabled", + pos=(50, 700), + font_size=16, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.status) + + def _create_pulse_shader(self, x, y): + """Create pulsing glow shader demo.""" + # Label + label = mcrfpy.Caption( + text="1. Pulse Glow", + pos=(x + 200, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Frame to apply shader to + frame = mcrfpy.Frame( + pos=(x, y), + size=(420, 280), + fill_color=mcrfpy.Color(80, 60, 120), + outline_color=mcrfpy.Color(150, 100, 200), + outline=2 + ) + + # Add some content + content = mcrfpy.Caption( + text="Brightness pulses\nusing time uniform", + pos=(210, 100), + font_size=18, + fill_color=mcrfpy.Color(255, 255, 255) + ) + frame.children.append(content) + + # Create pulse shader + pulse_shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float time; + + void main() { + vec2 uv = gl_TexCoord[0].xy; + vec4 color = texture2D(texture, uv); + + // Pulse brightness + float pulse = 0.7 + 0.3 * sin(time * 2.0); + color.rgb *= pulse; + + gl_FragColor = color * gl_Color; + } + ''', dynamic=True) + + frame.shader = pulse_shader + self.ui.append(frame) + self.shader_frames.append(frame) + self.shaders.append(pulse_shader) + + def _create_vignette_shader(self, x, y): + """Create vignette (darkened edges) shader demo.""" + # Label + label = mcrfpy.Caption( + text="2. Vignette", + pos=(x + 200, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Frame + frame = mcrfpy.Frame( + pos=(x, y), + size=(420, 280), + fill_color=mcrfpy.Color(100, 80, 60), + outline_color=mcrfpy.Color(200, 150, 100), + outline=2 + ) + + content = mcrfpy.Caption( + text="Darkened edges\nclassic vignette effect", + pos=(210, 100), + font_size=18, + fill_color=mcrfpy.Color(255, 255, 255) + ) + frame.children.append(content) + + # Create vignette shader + vignette_shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform vec2 resolution; + + void main() { + vec2 uv = gl_TexCoord[0].xy; + vec4 color = texture2D(texture, uv); + + // Calculate distance from center + vec2 center = vec2(0.5, 0.5); + float dist = distance(uv, center); + + // Vignette effect - darken edges + float vignette = 1.0 - smoothstep(0.3, 0.8, dist); + color.rgb *= vignette; + + gl_FragColor = color * gl_Color; + } + ''', dynamic=False) + + frame.shader = vignette_shader + self.ui.append(frame) + self.shader_frames.append(frame) + self.shaders.append(vignette_shader) + + def _create_wave_shader(self, x, y): + """Create wave distortion shader demo.""" + # Label + label = mcrfpy.Caption( + text="3. Wave Distortion", + pos=(x + 200, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Frame + frame = mcrfpy.Frame( + pos=(x, y), + size=(420, 280), + fill_color=mcrfpy.Color(60, 100, 120), + outline_color=mcrfpy.Color(100, 180, 220), + outline=2 + ) + + content = mcrfpy.Caption( + text="Sine wave\nUV distortion", + pos=(210, 100), + font_size=18, + fill_color=mcrfpy.Color(255, 255, 255) + ) + frame.children.append(content) + + # Create wave shader + wave_shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float time; + + void main() { + vec2 uv = gl_TexCoord[0].xy; + + // Apply wave distortion + float wave = sin(uv.y * 20.0 + time * 3.0) * 0.01; + uv.x += wave; + + vec4 color = texture2D(texture, uv); + gl_FragColor = color * gl_Color; + } + ''', dynamic=True) + + frame.shader = wave_shader + self.ui.append(frame) + self.shader_frames.append(frame) + self.shaders.append(wave_shader) + + def _create_color_shift_shader(self, x, y): + """Create chromatic aberration / color shift shader demo.""" + # Label + label = mcrfpy.Caption( + text="4. Color Shift", + pos=(x + 200, y - 20), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(label) + + # Frame + frame = mcrfpy.Frame( + pos=(x, y), + size=(420, 280), + fill_color=mcrfpy.Color(80, 80, 80), + outline_color=mcrfpy.Color(150, 150, 150), + outline=2 + ) + + content = mcrfpy.Caption( + text="Chromatic aberration\nRGB channel offset", + pos=(210, 100), + font_size=18, + fill_color=mcrfpy.Color(255, 255, 255) + ) + frame.children.append(content) + + # Create color shift shader + colorshift_shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float time; + + void main() { + vec2 uv = gl_TexCoord[0].xy; + + // Offset for each channel + float offset = 0.005 + 0.003 * sin(time); + + // Sample each channel with slight offset + float r = texture2D(texture, uv + vec2(offset, 0.0)).r; + float g = texture2D(texture, uv).g; + float b = texture2D(texture, uv - vec2(offset, 0.0)).b; + float a = texture2D(texture, uv).a; + + gl_FragColor = vec4(r, g, b, a) * gl_Color; + } + ''', dynamic=True) + + frame.shader = colorshift_shader + self.ui.append(frame) + self.shader_frames.append(frame) + self.shaders.append(colorshift_shader) + + def toggle_shaders(self): + """Toggle all shaders on/off.""" + self.shaders_enabled = not self.shaders_enabled + + for frame, shader in zip(self.shader_frames, self.shaders): + if self.shaders_enabled: + frame.shader = shader + self.status.text = "Shaders: Enabled" + self.status.fill_color = mcrfpy.Color(100, 200, 100) + else: + frame.shader = None + self.status.text = "Shaders: Disabled" + self.status.fill_color = mcrfpy.Color(200, 100, 100) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Space": + self.toggle_shaders() + elif key == "R": + # Re-enable all shaders + self.shaders_enabled = False + self.toggle_shaders() + elif key in ("Num1", "Num2", "Num3", "Num4"): + # Focus on specific shader (could zoom in) + idx = int(key[-1]) - 1 + if idx < len(self.shader_frames): + self.status.text = f"Focused: Shader {idx + 1}" + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the shader demo.""" + demo = ShaderDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/features/shader_demo.png"), + sys.exit(0) + ), 200) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/lib/__init__.py b/tests/cookbook/lib/__init__.py new file mode 100644 index 0000000..2a89172 --- /dev/null +++ b/tests/cookbook/lib/__init__.py @@ -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', +] diff --git a/tests/cookbook/lib/anim_utils.py b/tests/cookbook/lib/anim_utils.py new file mode 100644 index 0000000..77b555a --- /dev/null +++ b/tests/cookbook/lib/anim_utils.py @@ -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), + ) diff --git a/tests/cookbook/lib/button.py b/tests/cookbook/lib/button.py new file mode 100644 index 0000000..1ab4fd6 --- /dev/null +++ b/tests/cookbook/lib/button.py @@ -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 diff --git a/tests/cookbook/lib/choice_list.py b/tests/cookbook/lib/choice_list.py new file mode 100644 index 0000000..25ae030 --- /dev/null +++ b/tests/cookbook/lib/choice_list.py @@ -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 + ) diff --git a/tests/cookbook/lib/grid_container.py b/tests/cookbook/lib/grid_container.py new file mode 100644 index 0000000..0d7bfa4 --- /dev/null +++ b/tests/cookbook/lib/grid_container.py @@ -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 diff --git a/tests/cookbook/lib/item_manager.py b/tests/cookbook/lib/item_manager.py new file mode 100644 index 0000000..437676d --- /dev/null +++ b/tests/cookbook/lib/item_manager.py @@ -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) diff --git a/tests/cookbook/lib/modal.py b/tests/cookbook/lib/modal.py new file mode 100644 index 0000000..62d1394 --- /dev/null +++ b/tests/cookbook/lib/modal.py @@ -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() diff --git a/tests/cookbook/lib/scrollable_list.py b/tests/cookbook/lib/scrollable_list.py new file mode 100644 index 0000000..4168ba8 --- /dev/null +++ b/tests/cookbook/lib/scrollable_list.py @@ -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() diff --git a/tests/cookbook/lib/stat_bar.py b/tests/cookbook/lib/stat_bar.py new file mode 100644 index 0000000..b444cad --- /dev/null +++ b/tests/cookbook/lib/stat_bar.py @@ -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 diff --git a/tests/cookbook/lib/text_box.py b/tests/cookbook/lib/text_box.py new file mode 100644 index 0000000..f6b06cd --- /dev/null +++ b/tests/cookbook/lib/text_box.py @@ -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) diff --git a/tests/cookbook/lib/toast.py b/tests/cookbook/lib/toast.py new file mode 100644 index 0000000..3ca9e65 --- /dev/null +++ b/tests/cookbook/lib/toast.py @@ -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)) diff --git a/tests/cookbook/primitives/__init__.py b/tests/cookbook/primitives/__init__.py new file mode 100644 index 0000000..5dacc20 --- /dev/null +++ b/tests/cookbook/primitives/__init__.py @@ -0,0 +1,6 @@ +# McRogueFace Cookbook - Primitive Widget Demos +""" +Demo scripts for individual widget components. + +Each demo can be run interactively or in headless mode for screenshots. +""" diff --git a/tests/cookbook/primitives/demo_button.py b/tests/cookbook/primitives/demo_button.py new file mode 100644 index 0000000..c58b429 --- /dev/null +++ b/tests/cookbook/primitives/demo_button.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Button Widget Demo - Clickable buttons with hover/press states + +Interactive controls: + Click: Interact with buttons + 1-4: Trigger button actions via keyboard + D: Toggle button 4 enabled/disabled + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.button import Button, create_button_row, create_button_column + + +class ButtonDemo: + def __init__(self): + self.scene = mcrfpy.Scene("button_demo") + self.ui = self.scene.children + self.click_count = 0 + self.buttons = [] + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Button Widget Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Click counter display + self.counter_caption = mcrfpy.Caption( + text="Clicks: 0", + pos=(512, 70), + font_size=18, + fill_color=mcrfpy.Color(200, 200, 100) + ) + self.ui.append(self.counter_caption) + + # Section 1: Basic buttons with different styles + section1_label = mcrfpy.Caption( + text="Basic Buttons (click or press 1-4)", + pos=(100, 130), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section1_label) + + # Default button + btn1 = Button( + "Default", + pos=(100, 160), + callback=lambda: self.on_button_click("Default") + ) + self.buttons.append(btn1) + self.ui.append(btn1.frame) + + # Custom color button + btn2 = Button( + "Custom", + pos=(240, 160), + fill_color=mcrfpy.Color(80, 50, 100), + hover_color=mcrfpy.Color(100, 70, 130), + press_color=mcrfpy.Color(120, 90, 150), + callback=lambda: self.on_button_click("Custom") + ) + self.buttons.append(btn2) + self.ui.append(btn2.frame) + + # Success-style button + btn3 = Button( + "Success", + pos=(380, 160), + fill_color=mcrfpy.Color(40, 120, 60), + hover_color=mcrfpy.Color(50, 150, 75), + press_color=mcrfpy.Color(60, 180, 90), + outline_color=mcrfpy.Color(100, 200, 120), + callback=lambda: self.on_button_click("Success") + ) + self.buttons.append(btn3) + self.ui.append(btn3.frame) + + # Danger-style button (toggleable) + self.btn4 = Button( + "Danger", + pos=(520, 160), + fill_color=mcrfpy.Color(150, 50, 50), + hover_color=mcrfpy.Color(180, 70, 70), + press_color=mcrfpy.Color(200, 90, 90), + outline_color=mcrfpy.Color(200, 100, 100), + callback=lambda: self.on_button_click("Danger") + ) + self.buttons.append(self.btn4) + self.ui.append(self.btn4.frame) + + # Section 2: Different sizes + section2_label = mcrfpy.Caption( + text="Button Sizes", + pos=(100, 240), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section2_label) + + # Small button + small = Button( + "Small", + pos=(100, 270), + size=(80, 30), + font_size=12, + callback=lambda: self.on_button_click("Small") + ) + self.ui.append(small.frame) + + # Medium button (default size) + medium = Button( + "Medium", + pos=(200, 270), + callback=lambda: self.on_button_click("Medium") + ) + self.ui.append(medium.frame) + + # Large button + large = Button( + "Large Button", + pos=(340, 270), + size=(180, 50), + font_size=20, + callback=lambda: self.on_button_click("Large") + ) + self.ui.append(large.frame) + + # Section 3: Button row + section3_label = mcrfpy.Caption( + text="Button Row (auto-layout)", + pos=(100, 360), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section3_label) + + row_buttons = create_button_row( + labels=["File", "Edit", "View", "Help"], + start_pos=(100, 390), + spacing=5, + size=(80, 35), + callbacks=[ + lambda: self.on_button_click("File"), + lambda: self.on_button_click("Edit"), + lambda: self.on_button_click("View"), + lambda: self.on_button_click("Help"), + ] + ) + for btn in row_buttons: + self.ui.append(btn.frame) + + # Section 4: Button column + section4_label = mcrfpy.Caption( + text="Button Column", + pos=(600, 240), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section4_label) + + col_buttons = create_button_column( + labels=["New Game", "Load Game", "Options", "Quit"], + start_pos=(600, 270), + spacing=5, + size=(150, 35), + callbacks=[ + lambda: self.on_button_click("New Game"), + lambda: self.on_button_click("Load Game"), + lambda: self.on_button_click("Options"), + lambda: self.on_button_click("Quit"), + ] + ) + for btn in col_buttons: + self.ui.append(btn.frame) + + # Section 5: Disabled state + section5_label = mcrfpy.Caption( + text="Disabled State (press D to toggle)", + pos=(100, 470), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section5_label) + + self.disabled_btn = Button( + "Disabled", + pos=(100, 500), + enabled=False, + callback=lambda: self.on_button_click("This shouldn't fire!") + ) + self.ui.append(self.disabled_btn.frame) + + self.toggle_info = mcrfpy.Caption( + text="Currently: Disabled", + pos=(240, 510), + font_size=14, + fill_color=mcrfpy.Color(180, 100, 100) + ) + self.ui.append(self.toggle_info) + + # Instructions + instr = mcrfpy.Caption( + text="Click buttons or press 1-4 | D: Toggle disabled | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + # Last action display + self.action_caption = mcrfpy.Caption( + text="Last action: None", + pos=(50, 600), + font_size=16, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.action_caption) + + def on_button_click(self, button_name): + """Handle button click.""" + self.click_count += 1 + self.counter_caption.text = f"Clicks: {self.click_count}" + self.action_caption.text = f"Last action: Clicked '{button_name}'" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Num1" and len(self.buttons) > 0: + self.buttons[0].callback() + elif key == "Num2" and len(self.buttons) > 1: + self.buttons[1].callback() + elif key == "Num3" and len(self.buttons) > 2: + self.buttons[2].callback() + elif key == "Num4" and len(self.buttons) > 3: + self.buttons[3].callback() + elif key == "D": + # Toggle disabled button + self.disabled_btn.enabled = not self.disabled_btn.enabled + if self.disabled_btn.enabled: + self.toggle_info.text = "Currently: Enabled" + self.toggle_info.fill_color = mcrfpy.Color(100, 180, 100) + else: + self.toggle_info.text = "Currently: Disabled" + self.toggle_info.fill_color = mcrfpy.Color(180, 100, 100) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the button demo.""" + demo = ButtonDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/button_demo.png"), + sys.exit(0) + ), 100) + except AttributeError: + # headless_mode() may not exist in all versions + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_choice_list.py b/tests/cookbook/primitives/demo_choice_list.py new file mode 100644 index 0000000..5d89db5 --- /dev/null +++ b/tests/cookbook/primitives/demo_choice_list.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Choice List Widget Demo - Vertical selectable list with keyboard/mouse navigation + +Interactive controls: + Up/Down: Navigate choices + Enter: Confirm selection + Click: Select item + A: Add a new choice + R: Remove selected choice + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.choice_list import ChoiceList, create_menu + + +class ChoiceListDemo: + def __init__(self): + self.scene = mcrfpy.Scene("choice_list_demo") + self.ui = self.scene.children + self.lists = [] + self.active_list_idx = 0 + self.add_counter = 0 + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Choice List Widget Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Section 1: Basic choice list + section1_label = mcrfpy.Caption( + text="Main Menu (keyboard or click)", + pos=(50, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section1_label) + + self.main_list = ChoiceList( + pos=(50, 120), + size=(200, 150), + choices=["New Game", "Continue", "Options", "Credits", "Quit"], + on_select=self.on_main_select + ) + self.lists.append(self.main_list) + self.ui.append(self.main_list.frame) + + # Selection indicator + self.main_selection = mcrfpy.Caption( + text="Selected: New Game", + pos=(50, 280), + font_size=14, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.main_selection) + + # Section 2: Custom styled list + section2_label = mcrfpy.Caption( + text="Difficulty Selection", + pos=(300, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section2_label) + + self.diff_list = ChoiceList( + pos=(300, 120), + size=(180, 120), + choices=["Easy", "Normal", "Hard", "Nightmare"], + on_select=self.on_diff_select, + selected_color=mcrfpy.Color(120, 60, 60), + hover_color=mcrfpy.Color(80, 40, 40), + normal_color=mcrfpy.Color(50, 30, 30) + ) + self.lists.append(self.diff_list) + self.ui.append(self.diff_list.frame) + + self.diff_selection = mcrfpy.Caption( + text="Difficulty: Easy", + pos=(300, 250), + font_size=14, + fill_color=mcrfpy.Color(200, 100, 100) + ) + self.ui.append(self.diff_selection) + + # Section 3: Dynamic list (add/remove items) + section3_label = mcrfpy.Caption( + text="Dynamic List (A: Add, R: Remove)", + pos=(530, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section3_label) + + self.dynamic_list = ChoiceList( + pos=(530, 120), + size=(200, 180), + choices=["Item 1", "Item 2", "Item 3"], + on_select=self.on_dynamic_select + ) + self.lists.append(self.dynamic_list) + self.ui.append(self.dynamic_list.frame) + + self.dynamic_info = mcrfpy.Caption( + text="Items: 3", + pos=(530, 310), + font_size=14, + fill_color=mcrfpy.Color(100, 150, 200) + ) + self.ui.append(self.dynamic_info) + + # Section 4: Menu with title (using helper) + section4_label = mcrfpy.Caption( + text="Menu with Title (helper function)", + pos=(780, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section4_label) + + menu_container, self.titled_list = create_menu( + pos=(780, 120), + choices=["Attack", "Defend", "Magic", "Item", "Flee"], + on_select=self.on_combat_select, + title="Combat", + width=180 + ) + self.lists.append(self.titled_list) + self.ui.append(menu_container) + + self.combat_selection = mcrfpy.Caption( + text="Action: Attack", + pos=(780, 340), + font_size=14, + fill_color=mcrfpy.Color(200, 200, 100) + ) + self.ui.append(self.combat_selection) + + # Section 5: Long list (scrolling needed in future) + section5_label = mcrfpy.Caption( + text="Long List", + pos=(50, 350), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section5_label) + + long_choices = [f"Option {i+1}" for i in range(10)] + self.long_list = ChoiceList( + pos=(50, 380), + size=(200, 300), + choices=long_choices, + on_select=self.on_long_select, + item_height=28 + ) + self.lists.append(self.long_list) + self.ui.append(self.long_list.frame) + + self.long_selection = mcrfpy.Caption( + text="Long list: Option 1", + pos=(50, 690), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 200) + ) + self.ui.append(self.long_selection) + + # Active list indicator + self.active_indicator = mcrfpy.Caption( + text="Active list: Main Menu (Tab to switch)", + pos=(300, 400), + font_size=14, + fill_color=mcrfpy.Color(200, 200, 200) + ) + self.ui.append(self.active_indicator) + + # Instructions + instr = mcrfpy.Caption( + text="Up/Down: Navigate | Enter: Confirm | Tab: Switch list | A: Add | R: Remove | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + def on_main_select(self, index, value): + """Handle main menu selection.""" + self.main_selection.text = f"Selected: {value}" + + def on_diff_select(self, index, value): + """Handle difficulty selection.""" + self.diff_selection.text = f"Difficulty: {value}" + + def on_dynamic_select(self, index, value): + """Handle dynamic list selection.""" + self.dynamic_info.text = f"Selected: {value} (Items: {len(self.dynamic_list.choices)})" + + def on_combat_select(self, index, value): + """Handle combat menu selection.""" + self.combat_selection.text = f"Action: {value}" + + def on_long_select(self, index, value): + """Handle long list selection.""" + self.long_selection.text = f"Long list: {value}" + + def _update_active_indicator(self): + """Update the active list indicator.""" + names = ["Main Menu", "Difficulty", "Dynamic", "Combat", "Long List"] + if self.active_list_idx < len(names): + self.active_indicator.text = f"Active list: {names[self.active_list_idx]} (Tab to switch)" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + + # Get active list + active = self.lists[self.active_list_idx] if self.lists else None + + if key == "Up" and active: + active.navigate(-1) + elif key == "Down" and active: + active.navigate(1) + elif key == "Enter" and active: + active.confirm() + elif key == "Tab": + # Switch active list + self.active_list_idx = (self.active_list_idx + 1) % len(self.lists) + self._update_active_indicator() + elif key == "A": + # Add item to dynamic list + self.add_counter += 1 + self.dynamic_list.add_choice(f"New Item {self.add_counter}") + self.dynamic_info.text = f"Items: {len(self.dynamic_list.choices)}" + elif key == "R": + # Remove selected from dynamic list + if len(self.dynamic_list.choices) > 1: + self.dynamic_list.remove_choice(self.dynamic_list.selected_index) + self.dynamic_info.text = f"Items: {len(self.dynamic_list.choices)}" + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the choice list demo.""" + demo = ChoiceListDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/choice_list_demo.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_click_pickup.py b/tests/cookbook/primitives/demo_click_pickup.py new file mode 100644 index 0000000..171646d --- /dev/null +++ b/tests/cookbook/primitives/demo_click_pickup.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""Click to Pick Up Demo - Toggle-based inventory interaction + +Interactive controls: + Left click on item: Pick up item (cursor changes) + Left click on empty cell: Place item + Right click: Cancel pickup + ESC: Return to menu + +This demonstrates: + - Click-to-toggle pickup mode (not hold-to-drag) + - Cursor sprite following mouse + - ColorLayer for cell highlighting + - Inventory organization pattern +""" +import mcrfpy +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# Item data for sprites +ITEMS = [ + (103, "Shortsword"), + (104, "Longsword"), + (117, "Hammer"), + (119, "Axe"), + (101, "Buckler"), + (102, "Shield"), + (115, "Health Pot"), + (116, "Mana Pot"), + (129, "Wand"), + (130, "Staff"), + (114, "Str Potion"), + (127, "Lesser HP"), +] + + +class ClickPickupDemo: + """Demo showing click-to-pickup inventory interaction.""" + + def __init__(self): + self.scene = mcrfpy.Scene("demo_click_pickup") + self.ui = self.scene.children + self.grid = None + self.tile_layer = None + self.color_layer = None + + # Pickup state + self.held_entity = None + self.pickup_cell = None + self.cursor_sprite = None + self.last_hover_cell = None + + # Track occupied cells + self.occupied_cells = {} # (x, y) -> entity + + self.setup() + + def setup(self): + """Build the demo UI.""" + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(25, 20, 30)) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Click to Pick Up", + pos=(512, 30), + font_size=28, + fill_color=(255, 255, 255) + ) + title.outline = 2 + title.outline_color = (0, 0, 0) + self.ui.append(title) + + # Status caption + self.status = mcrfpy.Caption( + text="Click an item to pick it up", + pos=(512, 70), + font_size=16, + fill_color=(180, 180, 180) + ) + self.ui.append(self.status) + + # Create inventory grid - zoom in constructor for proper centering + grid_size = (8, 6) + cell_size = 64 + grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size) + grid_pos = ((1024 - grid_pixel_size[0]) // 2, 140) + + self.grid = mcrfpy.Grid( + pos=grid_pos, + size=grid_pixel_size, + grid_size=grid_size, + texture=mcrfpy.default_texture, + zoom=4.0 # 16px * 4 = 64px per cell + ) + + # Get tile layer and fill with slot tiles + self.tile_layer = self.grid.layers[0] + self.tile_layer.fill(46) # Floor/slot tile + + # Add color layer for highlighting + self.color_layer = self.grid.add_layer('color', z_index=-1) + # Initialize with slight tint + for y in range(grid_size[1]): + for x in range(grid_size[0]): + self.color_layer.set((x, y), (200, 200, 200, 50)) + + # Add event handlers + self.grid.on_click = self._on_grid_click + self.grid.on_cell_enter = self._on_cell_enter + self.grid.on_move = self._on_grid_move + + self.ui.append(self.grid) + + # Populate with items + self._populate_grid() + + # Create cursor sprite (initially invisible) + # This is a Frame with a Sprite child, positioned outside the grid + self.cursor_frame = mcrfpy.Frame( + pos=(0, 0), + size=(64, 64), + fill_color=(0, 0, 0, 0), # Transparent + 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) + self.ui.append(self.cursor_frame) + + # Item name display + self.item_name = mcrfpy.Caption( + text="", + pos=(512, 560), + font_size=18, + fill_color=(255, 220, 100) + ) + self.ui.append(self.item_name) + + # Instructions + instr = mcrfpy.Caption( + text="Left click: Pick up / Place | Right click: Cancel | ESC to exit", + pos=(512, 700), + font_size=14, + fill_color=(150, 150, 150) + ) + self.ui.append(instr) + + def _populate_grid(self): + """Add items to the grid.""" + # Place items in a pattern + positions = [ + (0, 0), (2, 0), (4, 1), (6, 0), + (1, 2), (3, 2), (5, 2), (7, 3), + (0, 4), (2, 4), (4, 5), (6, 5), + ] + + for i, (x, y) in enumerate(positions): + if i >= len(ITEMS): + break + sprite_idx, name = ITEMS[i] + entity = mcrfpy.Entity() + self.grid.entities.append(entity) + entity.grid_pos = (x, y) # Use grid_pos for tile coordinates + entity.sprite_index = sprite_idx + entity.name = name # Store name for display + self.occupied_cells[(x, y)] = entity + + def _get_grid_cell(self, screen_pos): + """Convert screen position to grid cell coordinates.""" + cell_size = 16 * self.grid.zoom + x = int((screen_pos[0] - self.grid.x) / cell_size) + y = int((screen_pos[1] - self.grid.y) / cell_size) + grid_w, grid_h = self.grid.grid_size + if 0 <= x < grid_w and 0 <= y < grid_h: + return (x, y) + return None + + def _on_grid_click(self, pos, button, action): + """Handle grid click.""" + if action != "start": + return + + cell = self._get_grid_cell(pos) + if cell is None: + return + + x, y = cell + + if button == "right": + # Cancel pickup + if self.held_entity: + self._cancel_pickup() + return + + if button != "left": + return + + if self.held_entity is None: + # Try to pick up + if cell in self.occupied_cells: + self._pickup_item(cell) + else: + # Try to place + if cell not in self.occupied_cells: + self._place_item(cell) + elif cell == self.pickup_cell: + # Clicked on original cell - cancel + self._cancel_pickup() + + def _pickup_item(self, cell): + """Pick up item from cell.""" + entity = self.occupied_cells[cell] + self.held_entity = entity + self.pickup_cell = cell + + # Hide the entity + entity.visible = False + + # Mark source cell yellow + self.color_layer.set(cell, (255, 255, 100, 200)) + + # Setup cursor sprite + self.cursor_sprite.sprite_index = entity.sprite_index + self.cursor_sprite.visible = True + + # Update status + name = getattr(entity, 'name', 'Item') + self.status.text = f"Holding: {name}" + self.status.fill_color = (100, 200, 255) + self.item_name.text = name + + def _place_item(self, cell): + """Place held item in cell.""" + x, y = cell + + # Move entity to new position + self.held_entity.grid_pos = (x, y) + self.held_entity.visible = True + + # Update tracking + del self.occupied_cells[self.pickup_cell] + self.occupied_cells[cell] = self.held_entity + + # Clear source cell highlight + self.color_layer.set(self.pickup_cell, (200, 200, 200, 50)) + + # Clear hover highlight + if self.last_hover_cell and self.last_hover_cell != self.pickup_cell: + self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50)) + + # Hide cursor + self.cursor_sprite.visible = False + + # Update status + self.status.text = f"Placed at ({x}, {y})" + self.status.fill_color = (100, 255, 100) + self.item_name.text = "" + + self.held_entity = None + self.pickup_cell = None + self.last_hover_cell = None + + def _cancel_pickup(self): + """Cancel current pickup operation.""" + if self.held_entity: + # Restore entity visibility + self.held_entity.visible = True + + # Clear source cell highlight + self.color_layer.set(self.pickup_cell, (200, 200, 200, 50)) + + # Clear hover highlight + if self.last_hover_cell and self.last_hover_cell != self.pickup_cell: + self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50)) + + # Hide cursor + self.cursor_sprite.visible = False + + self.status.text = "Cancelled" + self.status.fill_color = (200, 150, 100) + self.item_name.text = "" + + self.held_entity = None + self.pickup_cell = None + self.last_hover_cell = None + + def _on_cell_enter(self, cell_pos): + """Handle cell hover.""" + x, y = int(cell_pos[0]), int(cell_pos[1]) + cell = (x, y) + + # Show item name on hover (when not holding) + if self.held_entity is None: + if cell in self.occupied_cells: + entity = self.occupied_cells[cell] + name = getattr(entity, 'name', 'Item') + self.item_name.text = name + else: + self.item_name.text = "" + return + + # Clear previous hover highlight (if different from source) + if self.last_hover_cell and self.last_hover_cell != self.pickup_cell: + self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50)) + + # Highlight current cell (if different from source) + if cell != self.pickup_cell: + if cell in self.occupied_cells: + self.color_layer.set(cell, (255, 100, 100, 200)) # Red - can't place + else: + self.color_layer.set(cell, (100, 255, 100, 200)) # Green - can place + + self.last_hover_cell = cell + + def _on_grid_move(self, pos): + """Update cursor sprite position. + + Note: #230 - on_move now only receives position, not button/action + """ + if self.cursor_sprite.visible: + # Position cursor centered on mouse + self.cursor_frame.x = pos[0] - 32 + self.cursor_frame.y = pos[1] - 32 + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + if self.held_entity: + self._cancel_pickup() + return + + try: + from cookbook_main import main + main() + except: + sys.exit(0) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the demo.""" + demo = ClickPickupDemo() + demo.activate() + + # Headless screenshot + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Simulate picking up an item for screenshot + demo._pickup_item((0, 0)) + demo.cursor_frame.x = 300 + demo.cursor_frame.y = 350 + + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/click_pickup.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_drag_drop_grid.py b/tests/cookbook/primitives/demo_drag_drop_grid.py new file mode 100644 index 0000000..efaddd1 --- /dev/null +++ b/tests/cookbook/primitives/demo_drag_drop_grid.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Drag and Drop (Grid) Demo - Drag entities between grid cells + +Interactive controls: + Left click + drag: Move entity to new cell + ESC: Return to menu + +This demonstrates: + - Grid entity dragging with on_click and on_cell_enter + - ColorLayer for cell highlighting + - Collision detection (can't drop on occupied cells) + - Visual feedback during drag +""" +import mcrfpy +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# Item data for sprites +ITEMS = [ + (103, "Shortsword"), # +1 atk + (104, "Longsword"), # +2 atk + (117, "Hammer"), # +2 atk + (119, "Axe"), # +3 atk + (101, "Buckler"), # +1 def + (102, "Shield"), # +2 def + (115, "Health Pot"), + (116, "Mana Pot"), + (129, "Wand"), # +1 atk, +4 int + (114, "Str Potion"), +] + + +class GridDragDropDemo: + """Demo showing entity drag and drop on a grid.""" + + def __init__(self): + self.scene = mcrfpy.Scene("demo_drag_drop_grid") + self.ui = self.scene.children + self.grid = None + self.tile_layer = None + self.color_layer = None + self.dragging_entity = None + self.drag_start_cell = None + self.occupied_cells = set() # Track which cells have entities + self.setup() + + def setup(self): + """Build the demo UI.""" + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(20, 25, 30)) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Grid Drag & Drop", + pos=(512, 30), + font_size=28, + fill_color=(255, 255, 255) + ) + title.outline = 2 + title.outline_color = (0, 0, 0) + self.ui.append(title) + + # Status caption + self.status = mcrfpy.Caption( + text="Click and drag items to rearrange", + pos=(512, 70), + font_size=16, + fill_color=(180, 180, 180) + ) + self.ui.append(self.status) + + # Create grid - zoom in constructor for proper centering + grid_size = (10, 8) + cell_size = 48 + grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size) + grid_pos = ((1024 - grid_pixel_size[0]) // 2, 150) + + self.grid = mcrfpy.Grid( + pos=grid_pos, + size=grid_pixel_size, + grid_size=grid_size, + texture=mcrfpy.default_texture, + zoom=3.0 # Each cell is 16px * 3 = 48px + ) + + # Get tile layer and fill with floor tiles + self.tile_layer = self.grid.layers[0] + self.tile_layer.fill(46) # Floor tile + + # Add color layer for highlighting (above tiles, below entities) + self.color_layer = self.grid.add_layer('color', z_index=-1) + + # Add event handlers + self.grid.on_click = self._on_grid_click + self.grid.on_cell_enter = self._on_cell_enter + + self.ui.append(self.grid) + + # Add some entities to the grid + self._populate_grid() + + # Instructions + instr = mcrfpy.Caption( + text="Click to pick up, drag to move, release to drop | Red = occupied | ESC to exit", + pos=(512, 700), + font_size=14, + fill_color=(150, 150, 150) + ) + self.ui.append(instr) + + def _populate_grid(self): + """Add entities to the grid in a scattered pattern.""" + # Place items at various positions + positions = [ + (1, 1), (3, 1), (5, 2), (7, 1), + (2, 4), (4, 3), (6, 5), (8, 4), + (1, 6), (5, 6) + ] + + for i, (x, y) in enumerate(positions): + if i >= len(ITEMS): + break + sprite_idx, name = ITEMS[i] + entity = mcrfpy.Entity() + self.grid.entities.append(entity) + entity.grid_pos = (x, y) # Use grid_pos for tile coordinates + entity.sprite_index = sprite_idx + self.occupied_cells.add((x, y)) + + def _get_entity_at(self, x, y): + """Get entity at grid position, or None.""" + for entity in self.grid.entities: + gp = entity.grid_pos + ex, ey = int(gp[0]), int(gp[1]) + if ex == x and ey == y: + return entity + return None + + def _on_grid_click(self, pos, button, action): + """Handle grid click for drag start/end.""" + if button != "left": + return + + # Convert screen pos to grid cell + grid_x = int((pos[0] - self.grid.x) / (16 * self.grid.zoom)) + grid_y = int((pos[1] - self.grid.y) / (16 * self.grid.zoom)) + + # Bounds check + grid_w, grid_h = self.grid.grid_size + if not (0 <= grid_x < grid_w and 0 <= grid_y < grid_h): + return + + if action == "start": + # Start drag if there's an entity here + entity = self._get_entity_at(grid_x, grid_y) + if entity: + self.dragging_entity = entity + self.drag_start_cell = (grid_x, grid_y) + self.status.text = f"Dragging from ({grid_x}, {grid_y})" + self.status.fill_color = (100, 200, 255) + + # Highlight start cell yellow + self.color_layer.set((grid_x, grid_y), (255, 255, 100, 200)) + + elif action == "end": + if self.dragging_entity: + # Drop the entity + target_cell = (grid_x, grid_y) + + if target_cell == self.drag_start_cell: + # Dropped in same cell - no change + self.status.text = "Cancelled - same cell" + elif target_cell in self.occupied_cells: + # Can't drop on occupied cell + self.status.text = f"Can't drop on occupied cell ({grid_x}, {grid_y})" + self.status.fill_color = (255, 100, 100) + else: + # Valid drop - move entity + self.occupied_cells.discard(self.drag_start_cell) + self.occupied_cells.add(target_cell) + self.dragging_entity.grid_pos = target_cell + self.status.text = f"Moved to ({grid_x}, {grid_y})" + self.status.fill_color = (100, 255, 100) + + # Clear all highlights + self._clear_highlights() + + self.dragging_entity = None + self.drag_start_cell = None + + def _on_cell_enter(self, cell_pos): + """Handle cell hover during drag.""" + if not self.dragging_entity: + return + + x, y = int(cell_pos[0]), int(cell_pos[1]) + + # Clear previous highlights (except start cell) + self._clear_highlights() + + # Re-highlight start cell + if self.drag_start_cell: + self.color_layer.set(self.drag_start_cell, (255, 255, 100, 200)) + + # Highlight current cell + if (x, y) != self.drag_start_cell: + if (x, y) in self.occupied_cells: + self.color_layer.set((x, y), (255, 100, 100, 200)) # Red - can't drop + else: + self.color_layer.set((x, y), (100, 255, 100, 200)) # Green - can drop + # Move entity preview + self.dragging_entity.grid_pos = (x, y) + + def _clear_highlights(self): + """Clear all cell color highlights.""" + grid_w, grid_h = self.grid.grid_size + for y in range(grid_h): + for x in range(grid_w): + self.color_layer.set((x, y), (0, 0, 0, 0)) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + if key == "Escape": + # Cancel any drag in progress + if self.dragging_entity and self.drag_start_cell: + self.dragging_entity.grid_pos = self.drag_start_cell + self._clear_highlights() + self.dragging_entity = None + self.drag_start_cell = None + self.status.text = "Drag cancelled" + return + + # Return to cookbook menu or exit + try: + from cookbook_main import main + main() + except: + sys.exit(0) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the demo.""" + demo = GridDragDropDemo() + demo.activate() + + # Headless screenshot + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/drag_drop_grid.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_stat_bar.py b/tests/cookbook/primitives/demo_stat_bar.py new file mode 100644 index 0000000..28d15a8 --- /dev/null +++ b/tests/cookbook/primitives/demo_stat_bar.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Stat Bar Widget Demo - Progress bars for health, mana, XP, etc. + +Interactive controls: + 1-4: Decrease stat bars + Shift+1-4: Increase stat bars + F: Flash the health bar + R: Reset all bars + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.stat_bar import StatBar, create_stat_bar_group + + +class StatBarDemo: + def __init__(self): + self.scene = mcrfpy.Scene("stat_bar_demo") + self.ui = self.scene.children + self.bars = {} + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Stat Bar Widget Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Section 1: Basic stat bars with labels + section1_label = mcrfpy.Caption( + text="Character Stats (press 1-4 to decrease, Shift+1-4 to increase)", + pos=(50, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section1_label) + + # Health bar + self.bars['hp'] = StatBar( + pos=(50, 120), + size=(250, 25), + current=75, + maximum=100, + fill_color=StatBar.HEALTH_COLOR, + label="HP" + ) + self.ui.append(self.bars['hp'].frame) + + # Mana bar + self.bars['mp'] = StatBar( + pos=(50, 155), + size=(250, 25), + current=50, + maximum=80, + fill_color=StatBar.MANA_COLOR, + label="MP" + ) + self.ui.append(self.bars['mp'].frame) + + # Stamina bar + self.bars['stamina'] = StatBar( + pos=(50, 190), + size=(250, 25), + current=90, + maximum=100, + fill_color=StatBar.STAMINA_COLOR, + label="Stamina" + ) + self.ui.append(self.bars['stamina'].frame) + + # XP bar + self.bars['xp'] = StatBar( + pos=(50, 225), + size=(250, 25), + current=250, + maximum=1000, + fill_color=StatBar.XP_COLOR, + label="XP" + ) + self.ui.append(self.bars['xp'].frame) + + # Section 2: Different sizes + section2_label = mcrfpy.Caption( + text="Different Sizes", + pos=(50, 290), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section2_label) + + # Thin bar + thin_bar = StatBar( + pos=(50, 320), + size=(200, 10), + current=60, + maximum=100, + fill_color=mcrfpy.Color(100, 150, 200), + show_text=False + ) + self.ui.append(thin_bar.frame) + + thin_label = mcrfpy.Caption( + text="Thin (no text)", + pos=(260, 315), + font_size=12, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(thin_label) + + # Wide bar + wide_bar = StatBar( + pos=(50, 345), + size=(400, 35), + current=450, + maximum=500, + fill_color=StatBar.SHIELD_COLOR, + label="Shield", + font_size=16 + ) + self.ui.append(wide_bar.frame) + + # Section 3: Stat bar group + section3_label = mcrfpy.Caption( + text="Stat Bar Group (auto-layout)", + pos=(500, 90), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section3_label) + + group = create_stat_bar_group([ + {"name": "Strength", "current": 15, "max": 20, "color": mcrfpy.Color(200, 80, 80)}, + {"name": "Dexterity", "current": 18, "max": 20, "color": mcrfpy.Color(80, 200, 80)}, + {"name": "Intelligence", "current": 12, "max": 20, "color": mcrfpy.Color(80, 80, 200)}, + {"name": "Wisdom", "current": 14, "max": 20, "color": mcrfpy.Color(200, 200, 80)}, + {"name": "Charisma", "current": 10, "max": 20, "color": mcrfpy.Color(200, 80, 200)}, + ], start_pos=(500, 120), spacing=10, size=(220, 22)) + + for bar in group.values(): + self.ui.append(bar.frame) + + # Section 4: Edge cases + section4_label = mcrfpy.Caption( + text="Edge Cases", + pos=(50, 420), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section4_label) + + # Empty bar + empty_bar = StatBar( + pos=(50, 450), + size=(200, 20), + current=0, + maximum=100, + fill_color=StatBar.HEALTH_COLOR, + label="Empty" + ) + self.ui.append(empty_bar.frame) + + # Full bar + full_bar = StatBar( + pos=(50, 480), + size=(200, 20), + current=100, + maximum=100, + fill_color=StatBar.STAMINA_COLOR, + label="Full" + ) + self.ui.append(full_bar.frame) + + # Overfill attempt (should clamp) + overfill_bar = StatBar( + pos=(50, 510), + size=(200, 20), + current=150, # Will be clamped to 100 + maximum=100, + fill_color=StatBar.XP_COLOR, + label="Overfill" + ) + self.ui.append(overfill_bar.frame) + + # Section 5: Animation demo + section5_label = mcrfpy.Caption( + text="Animation Demo (watch the bars change)", + pos=(500, 290), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section5_label) + + self.anim_bar = StatBar( + pos=(500, 320), + size=(250, 30), + current=50, + maximum=100, + fill_color=mcrfpy.Color(150, 100, 200), + label="Animated" + ) + self.ui.append(self.anim_bar.frame) + + # Start animation loop + self._anim_direction = 1 + mcrfpy.Timer("anim_bar", self._animate_bar, 2000) + + # Section 6: Flash effect + section6_label = mcrfpy.Caption( + text="Flash Effect (press F)", + pos=(500, 400), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section6_label) + + self.flash_bar = StatBar( + pos=(500, 430), + size=(250, 30), + current=80, + maximum=100, + fill_color=StatBar.HEALTH_COLOR, + label="Flash Me" + ) + self.ui.append(self.flash_bar.frame) + + # Instructions + instr = mcrfpy.Caption( + text="1-4: Decrease bars | Shift+1-4: Increase bars | F: Flash | R: Reset | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + # Status display + self.status = mcrfpy.Caption( + text="Status: Ready", + pos=(50, 600), + font_size=16, + fill_color=mcrfpy.Color(100, 200, 100) + ) + self.ui.append(self.status) + + def _animate_bar(self, runtime): + """Animate the demo bar back and forth.""" + current = self.anim_bar.current + if self._anim_direction > 0: + new_val = min(100, current + 30) + if new_val >= 100: + self._anim_direction = -1 + else: + new_val = max(10, current - 30) + if new_val <= 10: + self._anim_direction = 1 + + self.anim_bar.set_value(new_val, animate=True) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + + # Number keys to modify bars + bar_keys = ['hp', 'mp', 'stamina', 'xp'] + key_map = {"Num1": 0, "Num2": 1, "Num3": 2, "Num4": 3} + + if key in key_map: + idx = key_map[key] + if idx < len(bar_keys): + bar = self.bars[bar_keys[idx]] + # Decrease by 10 + bar.set_value(bar.current - 10, animate=True) + self.status.text = f"Status: Decreased {bar_keys[idx].upper()}" + + elif key == "F": + self.flash_bar.flash() + self.status.text = "Status: Flash effect triggered!" + + elif key == "R": + # Reset all bars + self.bars['hp'].set_value(75, 100, animate=True) + self.bars['mp'].set_value(50, 80, animate=True) + self.bars['stamina'].set_value(90, 100, animate=True) + self.bars['xp'].set_value(250, 1000, animate=True) + self.status.text = "Status: All bars reset" + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the stat bar demo.""" + demo = StatBarDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/stat_bar_demo.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_text_box.py b/tests/cookbook/primitives/demo_text_box.py new file mode 100644 index 0000000..a92befa --- /dev/null +++ b/tests/cookbook/primitives/demo_text_box.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Text Box Widget Demo - Word-wrapped text with typewriter effect + +Interactive controls: + 1: Show typewriter text + 2: Show instant text + 3: Skip animation + 4: Clear text + D: Toggle dialogue mode + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.text_box import TextBox, DialogueBox + + +class TextBoxDemo: + def __init__(self): + self.scene = mcrfpy.Scene("text_box_demo") + self.ui = self.scene.children + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Text Box Widget Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Section 1: Basic text box with typewriter + section1_label = mcrfpy.Caption( + text="Typewriter Effect (press 1 to play)", + pos=(50, 80), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section1_label) + + self.typewriter_box = TextBox( + pos=(50, 110), + size=(400, 120), + text="", + chars_per_second=40 + ) + self.ui.append(self.typewriter_box.frame) + + self.sample_text = ( + "Welcome to McRogueFace! This is a demonstration of the " + "typewriter effect. Each character appears one at a time, " + "creating a classic RPG dialogue feel. You can adjust the " + "speed by changing the chars_per_second parameter." + ) + + # Completion indicator + self.completion_label = mcrfpy.Caption( + text="Status: Ready", + pos=(50, 240), + font_size=12, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(self.completion_label) + + # Section 2: Instant text + section2_label = mcrfpy.Caption( + text="Instant Text (press 2 to change)", + pos=(500, 80), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section2_label) + + self.instant_box = TextBox( + pos=(500, 110), + size=(450, 120), + text="This text appeared instantly. Press 2 to change it to different content.", + chars_per_second=0 # Instant display + ) + self.ui.append(self.instant_box.frame) + + # Section 3: Dialogue box with speaker + section3_label = mcrfpy.Caption( + text="Dialogue Box (press D to cycle speakers)", + pos=(50, 290), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section3_label) + + self.dialogue_box = DialogueBox( + pos=(50, 320), + size=(600, 150), + speaker="Elder Sage", + text="Greetings, adventurer. I have been expecting you. The ancient prophecy speaks of one who would come to restore balance to our world.", + chars_per_second=35 + ) + self.ui.append(self.dialogue_box.frame) + + self.dialogue_index = 0 + self.dialogues = [ + ("Elder Sage", "Greetings, adventurer. I have been expecting you. The ancient prophecy speaks of one who would come to restore balance to our world."), + ("Hero", "I'm not sure I'm the right person for this task. What exactly must I do?"), + ("Elder Sage", "You must journey to the Forgotten Temple and retrieve the Crystal of Dawn. Only its light can dispel the darkness that threatens our land."), + ("Mysterious Voice", "Beware... the path is fraught with danger. Many have tried and failed before you..."), + ("Hero", "I accept this quest. Point me to the temple, and I shall not rest until the crystal is recovered!"), + ] + + # Section 4: Different styles + section4_label = mcrfpy.Caption( + text="Custom Styles", + pos=(50, 500), + font_size=16, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(section4_label) + + # Dark theme + dark_box = TextBox( + pos=(50, 530), + size=(280, 100), + text="Dark theme with light text. Good for mysterious or ominous messages.", + chars_per_second=0, + bg_color=mcrfpy.Color(10, 10, 15), + text_color=mcrfpy.Color(180, 180, 200), + outline_color=mcrfpy.Color(60, 60, 80) + ) + self.ui.append(dark_box.frame) + + # Warning theme + warning_box = TextBox( + pos=(350, 530), + size=(280, 100), + text="Warning theme! Use for important alerts or danger notifications.", + chars_per_second=0, + bg_color=mcrfpy.Color(80, 40, 20), + text_color=mcrfpy.Color(255, 200, 100), + outline_color=mcrfpy.Color(200, 100, 50) + ) + self.ui.append(warning_box.frame) + + # System theme + system_box = TextBox( + pos=(650, 530), + size=(280, 100), + text="[SYSTEM] Connection established. Loading game data...", + chars_per_second=0, + bg_color=mcrfpy.Color(20, 40, 30), + text_color=mcrfpy.Color(100, 255, 150), + outline_color=mcrfpy.Color(50, 150, 80) + ) + self.ui.append(system_box.frame) + + # Instructions + instr = mcrfpy.Caption( + text="1: Play typewriter | 2: Change instant text | 3: Skip | 4: Clear | D: Next dialogue | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + def on_typewriter_complete(self): + """Called when typewriter animation finishes.""" + self.completion_label.text = "Status: Animation complete!" + self.completion_label.fill_color = mcrfpy.Color(100, 200, 100) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Num1": + # Start typewriter animation + self.typewriter_box.on_complete = self.on_typewriter_complete + self.typewriter_box.set_text(self.sample_text, animate=True) + self.completion_label.text = "Status: Playing..." + self.completion_label.fill_color = mcrfpy.Color(200, 200, 100) + elif key == "Num2": + # Change instant text + texts = [ + "This text appeared instantly. Press 2 to change it to different content.", + "Here's some different content! Text boxes can hold any message you want.", + "The quick brown fox jumps over the lazy dog. Perfect for testing fonts!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Classic placeholder text.", + ] + import random + self.instant_box.set_text(random.choice(texts), animate=False) + elif key == "Num3": + # Skip animation + self.typewriter_box.skip_animation() + self.completion_label.text = "Status: Skipped" + self.completion_label.fill_color = mcrfpy.Color(150, 150, 150) + elif key == "Num4": + # Clear text + self.typewriter_box.clear() + self.completion_label.text = "Status: Cleared" + self.completion_label.fill_color = mcrfpy.Color(150, 150, 150) + elif key == "D": + # Cycle dialogue + self.dialogue_index = (self.dialogue_index + 1) % len(self.dialogues) + speaker, text = self.dialogues[self.dialogue_index] + self.dialogue_box.set_dialogue(speaker, text, animate=True) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the text box demo.""" + demo = TextBoxDemo() + demo.activate() + + # Headless mode: capture screenshot and exit + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Trigger typewriter then screenshot + demo.typewriter_box.set_text(demo.sample_text[:50], animate=False) + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/text_box_demo.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/primitives/demo_toast.py b/tests/cookbook/primitives/demo_toast.py new file mode 100644 index 0000000..0d80274 --- /dev/null +++ b/tests/cookbook/primitives/demo_toast.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Toast Notification Demo - Auto-dismissing notification popups + +Interactive controls: + 1: Show default toast + 2: Show success toast (green) + 3: Show error toast (red) + 4: Show warning toast (yellow) + 5: Show info toast (blue) + S: Spam multiple toasts + C: Clear all toasts + ESC: Exit demo +""" +import mcrfpy +import sys + +# Add parent to path for imports +sys.path.insert(0, str(__file__).rsplit('/', 2)[0]) +from lib.toast import ToastManager + + +class ToastDemo: + def __init__(self): + self.scene = mcrfpy.Scene("toast_demo") + self.ui = self.scene.children + self.toast_count = 0 + self.setup() + + def setup(self): + """Build the demo scene.""" + # Background + bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 25) + ) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Toast Notification Demo", + pos=(512, 30), + font_size=28, + fill_color=mcrfpy.Color(255, 255, 255) + ) + title.outline = 2 + title.outline_color = mcrfpy.Color(0, 0, 0) + self.ui.append(title) + + # Create toast manager + self.toasts = ToastManager(self.scene, position="top-right", max_toasts=5) + + # Instructions panel + panel = mcrfpy.Frame( + pos=(50, 100), + size=(400, 400), + fill_color=mcrfpy.Color(30, 30, 40), + outline_color=mcrfpy.Color(60, 60, 80), + outline=1 + ) + self.ui.append(panel) + + panel_title = mcrfpy.Caption( + text="Toast Types", + pos=(200, 15), + font_size=18, + fill_color=mcrfpy.Color(200, 200, 200) + ) + panel.children.append(panel_title) + + # Type descriptions + types = [ + ("1 - Default", "Standard notification", mcrfpy.Color(200, 200, 200)), + ("2 - Success", "Confirmation messages", mcrfpy.Color(100, 200, 100)), + ("3 - Error", "Error notifications", mcrfpy.Color(200, 100, 100)), + ("4 - Warning", "Warning alerts", mcrfpy.Color(200, 180, 80)), + ("5 - Info", "Informational messages", mcrfpy.Color(100, 150, 200)), + ] + + for i, (key, desc, color) in enumerate(types): + y = 50 + i * 50 + + key_label = mcrfpy.Caption( + text=key, + pos=(20, y), + font_size=16, + fill_color=color + ) + panel.children.append(key_label) + + desc_label = mcrfpy.Caption( + text=desc, + pos=(20, y + 20), + font_size=12, + fill_color=mcrfpy.Color(150, 150, 150) + ) + panel.children.append(desc_label) + + # Additional controls + controls = [ + ("S - Spam", "Show multiple toasts quickly"), + ("C - Clear", "Dismiss all active toasts"), + ] + + for i, (key, desc) in enumerate(controls): + y = 300 + i * 40 + + key_label = mcrfpy.Caption( + text=key, + pos=(20, y), + font_size=14, + fill_color=mcrfpy.Color(180, 180, 180) + ) + panel.children.append(key_label) + + desc_label = mcrfpy.Caption( + text=desc, + pos=(20, y + 18), + font_size=12, + fill_color=mcrfpy.Color(120, 120, 120) + ) + panel.children.append(desc_label) + + # Stats display + self.stats_label = mcrfpy.Caption( + text="Toasts shown: 0 | Active: 0", + pos=(50, 520), + font_size=14, + fill_color=mcrfpy.Color(150, 150, 150) + ) + self.ui.append(self.stats_label) + + # Preview area + preview_label = mcrfpy.Caption( + text="Toasts appear in the top-right corner ->", + pos=(500, 200), + font_size=16, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(preview_label) + + arrow = mcrfpy.Caption( + text=">>>", + pos=(750, 200), + font_size=24, + fill_color=mcrfpy.Color(100, 100, 100) + ) + self.ui.append(arrow) + + # Instructions + instr = mcrfpy.Caption( + text="Press 1-5 to show different toast types | S: Spam | C: Clear all | ESC: Exit", + pos=(50, 730), + font_size=14, + fill_color=mcrfpy.Color(120, 120, 120) + ) + self.ui.append(instr) + + def update_stats(self): + """Update the stats display.""" + active = len([t for t in self.toasts.toasts if not t.is_dismissed]) + self.stats_label.text = f"Toasts shown: {self.toast_count} | Active: {active}" + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + + if key == "Escape": + sys.exit(0) + elif key == "Num1": + self.toast_count += 1 + self.toasts.show(f"Default notification #{self.toast_count}") + self.update_stats() + elif key == "Num2": + self.toast_count += 1 + self.toasts.show_success("Operation completed successfully!") + self.update_stats() + elif key == "Num3": + self.toast_count += 1 + self.toasts.show_error("An error occurred!") + self.update_stats() + elif key == "Num4": + self.toast_count += 1 + self.toasts.show_warning("Warning: Low health!") + self.update_stats() + elif key == "Num5": + self.toast_count += 1 + self.toasts.show_info("New quest available") + self.update_stats() + elif key == "S": + # Spam multiple toasts + messages = [ + "Game saved!", + "Achievement unlocked!", + "New item acquired!", + "Level up!", + "Quest complete!", + ] + for msg in messages: + self.toast_count += 1 + self.toasts.show(msg) + self.update_stats() + elif key == "C": + self.toasts.dismiss_all() + self.update_stats() + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the toast demo.""" + demo = ToastDemo() + demo.activate() + + # Headless mode: show some toasts and screenshot + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Show a few sample toasts + demo.toasts.show("Game saved!") + demo.toasts.show_success("Achievement unlocked!") + demo.toasts.show_error("Connection lost!") + + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/toast_demo.png"), + sys.exit(0) + ), 500) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/cookbook/run_screenshots.py b/tests/cookbook/run_screenshots.py new file mode 100644 index 0000000..b7baeb6 --- /dev/null +++ b/tests/cookbook/run_screenshots.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Capture all cookbook screenshots. + +This script captures screenshots for all demos in the cookbook. +Run with: + cd build && ./mcrogueface --headless --exec ../tests/cookbook/run_screenshots.py + +Output goes to: + tests/cookbook/screenshots/ + primitives/ + features/ + apps/ + compound/ +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +# Add cookbook to path +COOKBOOK_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, COOKBOOK_DIR) + +# Output directories +SCREENSHOT_BASE = os.path.join(COOKBOOK_DIR, "screenshots") + + +def ensure_dirs(): + """Create screenshot directories.""" + dirs = ["primitives", "features", "apps", "compound"] + for d in dirs: + os.makedirs(os.path.join(SCREENSHOT_BASE, d), exist_ok=True) + + +def capture_demo(module_path, output_name, category): + """Capture a single demo screenshot. + + Args: + module_path: Dotted module path (e.g., "primitives.demo_button") + output_name: Output filename without extension + category: Subdirectory (e.g., "primitives") + + Returns: + True if successful, False otherwise + """ + try: + # Import the module + parts = module_path.rsplit('.', 1) + if len(parts) == 2: + parent, name = parts + module = __import__(module_path, fromlist=[name]) + else: + module = __import__(module_path) + + # Find the Demo class + demo_class = None + for attr_name in dir(module): + attr = getattr(module, attr_name) + if isinstance(attr, type) and 'Demo' in attr_name: + demo_class = attr + break + + if not demo_class: + print(f" No Demo class in {module_path}") + return False + + # Create and activate demo + demo = demo_class() + demo.activate() + + # Take screenshot + output_path = os.path.join(SCREENSHOT_BASE, category, f"{output_name}.png") + automation.screenshot(output_path) + print(f" OK: {output_path}") + return True + + except Exception as e: + print(f" FAIL: {module_path} - {e}") + return False + + +def main(): + """Main screenshot capture routine.""" + print("=" * 60) + print("McRogueFace Cookbook - Screenshot Capture") + print("=" * 60) + + ensure_dirs() + + # Define all demos to capture + demos = [ + # Primitives + ("primitives.demo_button", "button", "primitives"), + ("primitives.demo_stat_bar", "stat_bar", "primitives"), + ("primitives.demo_choice_list", "choice_list", "primitives"), + ("primitives.demo_text_box", "text_box", "primitives"), + ("primitives.demo_toast", "toast", "primitives"), + ("primitives.demo_drag_drop_frame", "drag_drop_frame", "primitives"), + ("primitives.demo_drag_drop_grid", "drag_drop_grid", "primitives"), + ("primitives.demo_click_pickup", "click_pickup", "primitives"), + + # Features + ("features.demo_animation_chain", "animation_chain", "features"), + ("features.demo_shaders", "shaders", "features"), + ("features.demo_rotation", "rotation", "features"), + + # Apps + ("apps.calculator", "calculator", "apps"), + ("apps.dialogue_system", "dialogue_system", "apps"), + + # Compound + ("compound.shop_demo", "shop_demo", "compound"), + ] + + success = 0 + failed = 0 + + for module_path, output_name, category in demos: + print(f"\nCapturing: {output_name}") + if capture_demo(module_path, output_name, category): + success += 1 + else: + failed += 1 + + print("\n" + "=" * 60) + print(f"Complete! Success: {success}, Failed: {failed}") + print(f"Screenshots saved to: {SCREENSHOT_BASE}") + print("=" * 60) + + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + # Use a timer to ensure the scene system is ready + mcrfpy.Timer("run_capture", lambda rt: main(), 50) diff --git a/tests/cookbook/screenshots/features/animation_chain.png b/tests/cookbook/screenshots/features/animation_chain.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/features/rotation.png b/tests/cookbook/screenshots/features/rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/features/shaders.png b/tests/cookbook/screenshots/features/shaders.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/primitives/button.png b/tests/cookbook/screenshots/primitives/button.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/primitives/choice_list.png b/tests/cookbook/screenshots/primitives/choice_list.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/primitives/stat_bar.png b/tests/cookbook/screenshots/primitives/stat_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/primitives/text_box.png b/tests/cookbook/screenshots/primitives/text_box.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001 diff --git a/tests/cookbook/screenshots/primitives/toast.png b/tests/cookbook/screenshots/primitives/toast.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd97fb4c02dd14f81b5e7bf75e420d16fc37882 GIT binary patch literal 39260 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 O$8?#+(sN5FA^!&)<+Wr0 literal 0 HcmV?d00001