McRogueFace/tests/cookbook/lib/choice_list.py

317 lines
10 KiB
Python
Raw Permalink Normal View History

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