351 lines
11 KiB
Python
351 lines
11 KiB
Python
|
|
# 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()
|