317 lines
10 KiB
Python
317 lines
10 KiB
Python
|
|
# 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
|
||
|
|
)
|