Cookbook structure: - lib/: Reusable component library (Button, StatBar, AnimationChain, etc.) - primitives/: Demo apps for individual components - features/: Demo apps for complex features (animation chaining, shaders) - apps/: Complete mini-applications (calculator, dialogue system) - automation/: Screenshot capture utilities API signature updates applied: - on_enter/on_exit/on_move callbacks now only receive (pos) per #230 - on_cell_enter/on_cell_exit callbacks only receive (cell_pos) per #230 - Animation chain library uses Timer-based sequencing (unaffected by #229) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2daebc84b5
commit
55f6ea9502
41 changed files with 8493 additions and 0 deletions
351
tests/cookbook/lib/scrollable_list.py
Normal file
351
tests/cookbook/lib/scrollable_list.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
# McRogueFace Cookbook - Scrollable List Widget
|
||||
"""
|
||||
Scrolling list with arbitrary item rendering.
|
||||
|
||||
Example:
|
||||
from lib.scrollable_list import ScrollableList
|
||||
|
||||
def render_item(item, frame, index, selected):
|
||||
# Add item content to frame
|
||||
label = mcrfpy.Caption(text=item['name'], pos=(10, 5))
|
||||
frame.children.append(label)
|
||||
|
||||
items = [{'name': f'Item {i}'} for i in range(50)]
|
||||
|
||||
scroll_list = ScrollableList(
|
||||
pos=(100, 100),
|
||||
size=(300, 400),
|
||||
items=items,
|
||||
render_item=render_item,
|
||||
item_height=40
|
||||
)
|
||||
scene.children.append(scroll_list.frame)
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
|
||||
class ScrollableList:
|
||||
"""Scrolling list with arbitrary item rendering.
|
||||
|
||||
Args:
|
||||
pos: (x, y) position tuple
|
||||
size: (width, height) tuple
|
||||
items: List of items to display
|
||||
item_height: Height of each item row (default: 30)
|
||||
render_item: Callback(item, frame, index, selected) to render item content
|
||||
on_select: Callback(index, item) when item is selected
|
||||
bg_color: Background color (default: dark)
|
||||
item_bg_color: Normal item background
|
||||
selected_bg_color: Selected item background
|
||||
hover_bg_color: Hovered item background
|
||||
outline_color: Border color
|
||||
outline: Border thickness
|
||||
|
||||
Attributes:
|
||||
frame: The outer frame (add this to scene)
|
||||
items: List of items
|
||||
selected_index: Currently selected index
|
||||
scroll_offset: Current scroll position
|
||||
"""
|
||||
|
||||
DEFAULT_BG = mcrfpy.Color(30, 30, 35)
|
||||
DEFAULT_ITEM_BG = mcrfpy.Color(35, 35, 40)
|
||||
DEFAULT_SELECTED_BG = mcrfpy.Color(60, 80, 120)
|
||||
DEFAULT_HOVER_BG = mcrfpy.Color(50, 50, 60)
|
||||
DEFAULT_OUTLINE = mcrfpy.Color(80, 80, 90)
|
||||
|
||||
def __init__(self, pos, size, items=None, item_height=30,
|
||||
render_item=None, on_select=None,
|
||||
bg_color=None, item_bg_color=None,
|
||||
selected_bg_color=None, hover_bg_color=None,
|
||||
outline_color=None, outline=1):
|
||||
self.pos = pos
|
||||
self.size = size
|
||||
self._items = list(items) if items else []
|
||||
self.item_height = item_height
|
||||
self.render_item = render_item or self._default_render
|
||||
self.on_select = on_select
|
||||
|
||||
# Colors
|
||||
self.bg_color = bg_color or self.DEFAULT_BG
|
||||
self.item_bg_color = item_bg_color or self.DEFAULT_ITEM_BG
|
||||
self.selected_bg_color = selected_bg_color or self.DEFAULT_SELECTED_BG
|
||||
self.hover_bg_color = hover_bg_color or self.DEFAULT_HOVER_BG
|
||||
self.outline_color = outline_color or self.DEFAULT_OUTLINE
|
||||
|
||||
# State
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._hovered_index = -1
|
||||
self._visible_count = int(size[1] / item_height)
|
||||
|
||||
# Create outer frame with clipping
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=size,
|
||||
fill_color=self.bg_color,
|
||||
outline_color=self.outline_color,
|
||||
outline=outline
|
||||
)
|
||||
self.frame.clip_children = True
|
||||
|
||||
# Content container (scrolls within frame)
|
||||
self._content = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(size[0], max(size[1], len(self._items) * item_height)),
|
||||
fill_color=mcrfpy.Color(0, 0, 0, 0), # Transparent
|
||||
outline=0
|
||||
)
|
||||
self.frame.children.append(self._content)
|
||||
|
||||
# Set up scroll handler
|
||||
self.frame.on_move = self._on_mouse_move
|
||||
|
||||
# Build initial items
|
||||
self._rebuild_items()
|
||||
|
||||
def _default_render(self, item, frame, index, selected):
|
||||
"""Default item renderer - just shows string representation."""
|
||||
text = str(item) if not isinstance(item, dict) else item.get('name', str(item))
|
||||
label = mcrfpy.Caption(
|
||||
text=text,
|
||||
pos=(10, (self.item_height - 14) / 2),
|
||||
fill_color=mcrfpy.Color(200, 200, 200),
|
||||
font_size=14
|
||||
)
|
||||
frame.children.append(label)
|
||||
|
||||
def _rebuild_items(self):
|
||||
"""Rebuild all item frames."""
|
||||
# Clear content
|
||||
while len(self._content.children) > 0:
|
||||
self._content.children.pop()
|
||||
|
||||
# Update content size
|
||||
total_height = len(self._items) * self.item_height
|
||||
self._content.h = max(self.size[1], total_height)
|
||||
|
||||
# Create item frames
|
||||
self._item_frames = []
|
||||
for i, item in enumerate(self._items):
|
||||
item_frame = mcrfpy.Frame(
|
||||
pos=(0, i * self.item_height),
|
||||
size=(self.size[0], self.item_height),
|
||||
fill_color=self.item_bg_color,
|
||||
outline=0
|
||||
)
|
||||
|
||||
# Set up click handler
|
||||
def make_click_handler(index):
|
||||
def handler(pos, button, action):
|
||||
if button == "left" and action == "end":
|
||||
self.select(index)
|
||||
return handler
|
||||
|
||||
def make_enter_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_enter(index)
|
||||
return handler
|
||||
|
||||
def make_exit_handler(index):
|
||||
def handler(pos, button, action):
|
||||
self._on_item_exit(index)
|
||||
return handler
|
||||
|
||||
item_frame.on_click = make_click_handler(i)
|
||||
item_frame.on_enter = make_enter_handler(i)
|
||||
item_frame.on_exit = make_exit_handler(i)
|
||||
|
||||
# Render item content
|
||||
is_selected = i == self._selected_index
|
||||
self.render_item(item, item_frame, i, is_selected)
|
||||
|
||||
self._item_frames.append(item_frame)
|
||||
self._content.children.append(item_frame)
|
||||
|
||||
def _on_item_enter(self, index):
|
||||
"""Handle mouse entering an item."""
|
||||
self._hovered_index = index
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.hover_bg_color
|
||||
|
||||
def _on_item_exit(self, index):
|
||||
"""Handle mouse leaving an item."""
|
||||
if self._hovered_index == index:
|
||||
self._hovered_index = -1
|
||||
if index != self._selected_index:
|
||||
self._item_frames[index].fill_color = self.item_bg_color
|
||||
|
||||
def _on_mouse_move(self, pos, button, action):
|
||||
"""Handle mouse movement for scroll wheel detection."""
|
||||
# Note: This is a placeholder - actual scroll wheel handling
|
||||
# depends on the engine's input system
|
||||
pass
|
||||
|
||||
def _update_item_display(self):
|
||||
"""Update visual state of items."""
|
||||
for i, frame in enumerate(self._item_frames):
|
||||
if i == self._selected_index:
|
||||
frame.fill_color = self.selected_bg_color
|
||||
elif i == self._hovered_index:
|
||||
frame.fill_color = self.hover_bg_color
|
||||
else:
|
||||
frame.fill_color = self.item_bg_color
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
"""List of items."""
|
||||
return list(self._items)
|
||||
|
||||
@items.setter
|
||||
def items(self, value):
|
||||
"""Set new items list."""
|
||||
self._items = list(value)
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._rebuild_items()
|
||||
|
||||
@property
|
||||
def selected_index(self):
|
||||
"""Currently selected index (-1 if none)."""
|
||||
return self._selected_index
|
||||
|
||||
@property
|
||||
def selected_item(self):
|
||||
"""Currently selected item (None if none)."""
|
||||
if 0 <= self._selected_index < len(self._items):
|
||||
return self._items[self._selected_index]
|
||||
return None
|
||||
|
||||
@property
|
||||
def scroll_offset(self):
|
||||
"""Current scroll position in pixels."""
|
||||
return self._scroll_offset
|
||||
|
||||
def select(self, index):
|
||||
"""Select an item by index.
|
||||
|
||||
Args:
|
||||
index: Item index to select
|
||||
"""
|
||||
if not self._items:
|
||||
return
|
||||
|
||||
old_index = self._selected_index
|
||||
self._selected_index = max(-1, min(index, len(self._items) - 1))
|
||||
|
||||
if old_index != self._selected_index:
|
||||
self._update_item_display()
|
||||
if self.on_select and self._selected_index >= 0:
|
||||
self.on_select(self._selected_index, self._items[self._selected_index])
|
||||
|
||||
def scroll(self, delta):
|
||||
"""Scroll the list by a number of items.
|
||||
|
||||
Args:
|
||||
delta: Number of items to scroll (positive = down)
|
||||
"""
|
||||
max_scroll = max(0, len(self._items) * self.item_height - self.size[1])
|
||||
new_offset = self._scroll_offset + delta * self.item_height
|
||||
new_offset = max(0, min(new_offset, max_scroll))
|
||||
|
||||
if new_offset != self._scroll_offset:
|
||||
self._scroll_offset = new_offset
|
||||
self._content.y = -self._scroll_offset
|
||||
|
||||
def scroll_to(self, index):
|
||||
"""Scroll to make an item visible.
|
||||
|
||||
Args:
|
||||
index: Item index to scroll to
|
||||
"""
|
||||
if not 0 <= index < len(self._items):
|
||||
return
|
||||
|
||||
item_top = index * self.item_height
|
||||
item_bottom = item_top + self.item_height
|
||||
|
||||
if item_top < self._scroll_offset:
|
||||
self._scroll_offset = item_top
|
||||
elif item_bottom > self._scroll_offset + self.size[1]:
|
||||
self._scroll_offset = item_bottom - self.size[1]
|
||||
|
||||
self._content.y = -self._scroll_offset
|
||||
|
||||
def navigate(self, direction):
|
||||
"""Navigate selection up or down.
|
||||
|
||||
Args:
|
||||
direction: +1 for down, -1 for up
|
||||
"""
|
||||
if not self._items:
|
||||
return
|
||||
|
||||
if self._selected_index < 0:
|
||||
new_index = 0 if direction > 0 else len(self._items) - 1
|
||||
else:
|
||||
new_index = self._selected_index + direction
|
||||
# Wrap around
|
||||
if new_index < 0:
|
||||
new_index = len(self._items) - 1
|
||||
elif new_index >= len(self._items):
|
||||
new_index = 0
|
||||
|
||||
self.select(new_index)
|
||||
self.scroll_to(new_index)
|
||||
|
||||
def add_item(self, item, index=None):
|
||||
"""Add an item to the list.
|
||||
|
||||
Args:
|
||||
item: Item to add
|
||||
index: Position to insert (None = end)
|
||||
"""
|
||||
if index is None:
|
||||
self._items.append(item)
|
||||
else:
|
||||
self._items.insert(index, item)
|
||||
self._rebuild_items()
|
||||
|
||||
def remove_item(self, index):
|
||||
"""Remove an item by index.
|
||||
|
||||
Args:
|
||||
index: Index of item to remove
|
||||
"""
|
||||
if 0 <= index < len(self._items):
|
||||
del self._items[index]
|
||||
if self._selected_index >= len(self._items):
|
||||
self._selected_index = len(self._items) - 1
|
||||
self._rebuild_items()
|
||||
|
||||
def clear(self):
|
||||
"""Remove all items."""
|
||||
self._items.clear()
|
||||
self._selected_index = -1
|
||||
self._scroll_offset = 0
|
||||
self._rebuild_items()
|
||||
|
||||
def filter(self, predicate):
|
||||
"""Filter displayed items.
|
||||
|
||||
Args:
|
||||
predicate: Function(item) -> bool to filter items
|
||||
|
||||
Note: This creates a new filtered view, not modifying original items.
|
||||
"""
|
||||
# Store original items if not already stored
|
||||
if not hasattr(self, '_original_items'):
|
||||
self._original_items = list(self._items)
|
||||
|
||||
self._items = [item for item in self._original_items if predicate(item)]
|
||||
self._selected_index = -1
|
||||
self._rebuild_items()
|
||||
|
||||
def reset_filter(self):
|
||||
"""Reset to show all items (undo filter)."""
|
||||
if hasattr(self, '_original_items'):
|
||||
self._items = list(self._original_items)
|
||||
del self._original_items
|
||||
self._selected_index = -1
|
||||
self._rebuild_items()
|
||||
Loading…
Add table
Add a link
Reference in a new issue