Selection Menu Widget
Keyboard-navigable menus are essential for any game. This recipe shows how to build a reusable menu system using Frame and Caption elements with keyboard input handling.
The Pattern
A selection menu consists of:
- Container Frame - Background panel holding the menu
- Option Captions - Text for each menu option
- Selection index - Tracks which option is highlighted
- Visual feedback - Changes color/style of selected option
- Callback system - Executes actions when options are selected
Basic Implementation
import mcrfpy
class Menu:
"""A keyboard-navigable menu widget."""
def __init__(self, x, y, options, on_select, title=None):
"""
Create a menu.
Args:
x, y: Position on screen
options: List of option strings
on_select: Callback function(index, option_text)
title: Optional title string
"""
self.x = x
self.y = y
self.options = options
self.on_select = on_select
self.selected_index = 0
# Style settings
self.padding = 15
self.line_height = 30
self.width = 250
# Colors
self.color_normal = mcrfpy.Color(200, 200, 200)
self.color_selected = mcrfpy.Color(255, 255, 100)
self.color_bg = mcrfpy.Color(30, 30, 50, 230)
# Calculate frame height
title_offset = self.line_height if title else 0
height = self.padding * 2 + len(options) * self.line_height + title_offset
# Create container frame
self.frame = mcrfpy.Frame(pos=(x, y), size=(self.width, height))
self.frame.fill_color = self.color_bg
self.frame.outline = 2
self.frame.outline_color = mcrfpy.Color(100, 100, 150)
# Create title if provided
self.title_caption = None
if title:
self.title_caption = mcrfpy.Caption(
text=title,
x=x + self.padding,
y=y + self.padding
)
self.title_caption.fill_color = mcrfpy.Color(255, 255, 255)
# Create option captions
self.option_captions = []
for i, option in enumerate(options):
caption_y = y + self.padding + title_offset + i * self.line_height
caption = mcrfpy.Caption(
text=option,
x=x + self.padding,
y=caption_y
)
caption.fill_color = self.color_normal
self.option_captions.append(caption)
# Update visual selection
self._update_selection()
def _update_selection(self):
"""Update visual highlight for selected option."""
for i, caption in enumerate(self.option_captions):
if i == self.selected_index:
caption.fill_color = self.color_selected
caption.text = "> " + self.options[i]
else:
caption.fill_color = self.color_normal
caption.text = " " + self.options[i]
def move_up(self):
"""Move selection up (with wraparound)."""
self.selected_index = (self.selected_index - 1) % len(self.options)
self._update_selection()
def move_down(self):
"""Move selection down (with wraparound)."""
self.selected_index = (self.selected_index + 1) % len(self.options)
self._update_selection()
def select(self):
"""Trigger the callback for the current selection."""
if self.on_select:
self.on_select(self.selected_index, self.options[self.selected_index])
def add_to_scene(self, ui):
"""Add menu to scene UI."""
ui.append(self.frame)
if self.title_caption:
ui.append(self.title_caption)
for caption in self.option_captions:
ui.append(caption)
def handle_key(self, key):
"""
Handle keyboard input.
Args:
key: A mcrfpy.Key enum value (as delivered to scene.on_key)
Returns:
True if key was handled, False otherwise
"""
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
self.move_up()
return True
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
self.move_down()
return True
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
self.select()
return True
return False
# Usage Example
scene = mcrfpy.Scene("menu_demo")
# Create other scenes for navigation
game_scene = mcrfpy.Scene("game")
options_scene = mcrfpy.Scene("options")
def on_menu_select(index, option):
print(f"Selected: {option} (index {index})")
if option == "Start Game":
game_scene.activate()
elif option == "Options":
options_scene.activate()
elif option == "Quit":
mcrfpy.exit()
menu = Menu(
300, 200,
["Start Game", "Options", "Quit"],
on_menu_select,
title="Main Menu"
)
menu.add_to_scene(scene.children)
def handle_keys(key, state):
if state != mcrfpy.InputState.PRESSED:
return
menu.handle_key(key)
scene.on_key = handle_keys
scene.activate()
Enhanced Menu with Submenus and Descriptions
A more feature-rich version with option descriptions and submenu support:
import mcrfpy
class EnhancedMenu:
"""Menu with descriptions, submenus, and visual polish."""
def __init__(self, x, y, options, title=None):
"""
Create an enhanced menu.
Args:
x, y: Position
options: List of dicts with keys:
'label': Display text
'action': Callback function (optional)
'description': Help text (optional)
'submenu': EnhancedMenu instance (optional)
'enabled': Boolean (optional, default True)
"""
self.x = x
self.y = y
self.options = options
self.title = title
self.selected_index = 0
self.active = True
self.parent_menu = None
# Layout
self.padding = 20
self.line_height = 32
self.width = 300
# Colors
self.colors = {
'bg': mcrfpy.Color(25, 25, 40, 240),
'border': mcrfpy.Color(80, 80, 120),
'title': mcrfpy.Color(255, 220, 100),
'normal': mcrfpy.Color(180, 180, 180),
'selected': mcrfpy.Color(255, 255, 255),
'disabled': mcrfpy.Color(80, 80, 80),
'description': mcrfpy.Color(150, 150, 180),
'highlight_bg': mcrfpy.Color(60, 60, 100),
}
self._build_ui()
def _build_ui(self):
"""Construct UI elements."""
title_offset = self.line_height + 10 if self.title else 0
desc_height = 50 # Space for description
height = (
self.padding * 2 +
len(self.options) * self.line_height +
title_offset +
desc_height
)
# Main frame
self.frame = mcrfpy.Frame(pos=(self.x, self.y), size=(self.width, height))
self.frame.fill_color = self.colors['bg']
self.frame.outline = 2
self.frame.outline_color = self.colors['border']
# Title
self.title_caption = None
if self.title:
self.title_caption = mcrfpy.Caption(
text=self.title,
x=self.x + self.padding,
y=self.y + self.padding
)
self.title_caption.fill_color = self.colors['title']
# Selection highlight frame
self.highlight = mcrfpy.Frame(
pos=(self.x + 5, self.y + self.padding + title_offset),
size=(self.width - 10, self.line_height)
)
self.highlight.fill_color = self.colors['highlight_bg']
self.highlight.outline = 0
# Option labels
self.option_captions = []
for i, opt in enumerate(self.options):
caption_y = self.y + self.padding + title_offset + i * self.line_height + 5
caption = mcrfpy.Caption(
text=opt.get('label', '???'),
x=self.x + self.padding + 10,
y=caption_y
)
enabled = opt.get('enabled', True)
caption.fill_color = self.colors['normal'] if enabled else self.colors['disabled']
self.option_captions.append(caption)
# Add arrow for submenus
if opt.get('submenu'):
arrow = mcrfpy.Caption(
text=">",
x=self.x + self.width - self.padding - 15,
y=caption_y
)
arrow.fill_color = caption.fill_color
opt['_arrow'] = arrow
# Description text at bottom
desc_y = self.y + height - desc_height + 5
self.description = mcrfpy.Caption(
text="",
x=self.x + self.padding,
y=desc_y
)
self.description.fill_color = self.colors['description']
self._update_selection()
def _update_selection(self):
"""Update visuals for current selection."""
title_offset = self.line_height + 10 if self.title else 0
# Move highlight
self.highlight.y = self.y + self.padding + title_offset + self.selected_index * self.line_height
# Update caption colors
for i, caption in enumerate(self.option_captions):
opt = self.options[i]
enabled = opt.get('enabled', True)
if i == self.selected_index:
caption.fill_color = self.colors['selected'] if enabled else self.colors['disabled']
else:
caption.fill_color = self.colors['normal'] if enabled else self.colors['disabled']
# Update description
desc = self.options[self.selected_index].get('description', '')
self.description.text = desc
def move_up(self):
"""Move selection up, skipping disabled options."""
if not self.active:
return
start = self.selected_index
while True:
self.selected_index = (self.selected_index - 1) % len(self.options)
if self.options[self.selected_index].get('enabled', True):
break
if self.selected_index == start:
break # All disabled, stay put
self._update_selection()
def move_down(self):
"""Move selection down, skipping disabled options."""
if not self.active:
return
start = self.selected_index
while True:
self.selected_index = (self.selected_index + 1) % len(self.options)
if self.options[self.selected_index].get('enabled', True):
break
if self.selected_index == start:
break
self._update_selection()
def select(self):
"""Execute the selected option."""
if not self.active:
return None
opt = self.options[self.selected_index]
if not opt.get('enabled', True):
return None
if opt.get('submenu'):
return opt['submenu']
if opt.get('action'):
opt['action']()
return None
def back(self):
"""Return to parent menu."""
return self.parent_menu
def add_to_scene(self, ui):
"""Add all elements to scene."""
ui.append(self.frame)
ui.append(self.highlight)
if self.title_caption:
ui.append(self.title_caption)
for i, caption in enumerate(self.option_captions):
ui.append(caption)
if '_arrow' in self.options[i]:
ui.append(self.options[i]['_arrow'])
ui.append(self.description)
def remove_from_scene(self, ui):
"""Remove all elements from scene."""
elements = [self.frame, self.highlight, self.description]
if self.title_caption:
elements.append(self.title_caption)
elements.extend(self.option_captions)
for i, opt in enumerate(self.options):
if '_arrow' in opt:
elements.append(opt['_arrow'])
for elem in elements:
try:
ui.remove(elem)
except:
pass
# Usage with submenus
scene = mcrfpy.Scene("menu")
# Create options submenu
options_menu = EnhancedMenu(350, 220, [
{'label': 'Sound Volume', 'description': 'Adjust sound effects volume'},
{'label': 'Music Volume', 'description': 'Adjust background music volume'},
{'label': 'Fullscreen', 'description': 'Toggle fullscreen mode'},
{'label': 'Back', 'action': lambda: switch_menu(main_menu)},
])
options_menu.title = "Options"
# Create main menu
main_menu = EnhancedMenu(300, 200, [
{
'label': 'New Game',
'description': 'Start a new adventure',
'action': lambda: print("Starting new game...")
},
{
'label': 'Continue',
'description': 'Load your saved game',
'enabled': False, # Disabled if no save exists
},
{
'label': 'Options',
'description': 'Configure game settings',
'submenu': options_menu
},
{
'label': 'Quit',
'description': 'Exit the game',
'action': lambda: mcrfpy.exit()
},
])
main_menu.title = "My Game"
options_menu.parent_menu = main_menu
current_menu = main_menu
main_menu.add_to_scene(scene.children)
def switch_menu(new_menu):
global current_menu
current_menu.remove_from_scene(scene.children)
current_menu = new_menu
current_menu.add_to_scene(scene.children)
def handle_keys(key, state):
global current_menu
if state != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
current_menu.move_up()
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
current_menu.move_down()
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
result = current_menu.select()
if result: # Submenu returned
switch_menu(result)
elif key == mcrfpy.Key.ESCAPE:
parent = current_menu.back()
if parent:
switch_menu(parent)
scene.on_key = handle_keys
scene.activate()
Horizontal Menu Bar
For top-of-screen menu bars:
import mcrfpy
class MenuBar:
"""Horizontal menu bar with dropdown submenus."""
def __init__(self, y=0, items=None):
"""
Create a menu bar.
Args:
y: Y position (usually 0 for top)
items: List of dicts with 'label' and 'options' keys
"""
self.y = y
self.items = items or []
self.selected_item = 0
self.dropdown_open = False
self.dropdown_selected = 0
self.item_width = 100
self.height = 30
# Main bar frame
self.bar = mcrfpy.Frame(pos=(0, y), size=(1024, self.height))
self.bar.fill_color = mcrfpy.Color(50, 50, 70)
self.bar.outline = 0
# Item captions
self.item_captions = []
for i, item in enumerate(items):
cap = mcrfpy.Caption(
text=item['label'],
x=10 + i * self.item_width,
y=y + 7
)
cap.fill_color = mcrfpy.Color(200, 200, 200)
self.item_captions.append(cap)
# Dropdown panel (hidden initially)
self.dropdown = None
self.dropdown_captions = []
def _update_highlight(self):
"""Update visual selection on bar."""
for i, cap in enumerate(self.item_captions):
if i == self.selected_item and self.dropdown_open:
cap.fill_color = mcrfpy.Color(255, 255, 100)
else:
cap.fill_color = mcrfpy.Color(200, 200, 200)
def _show_dropdown(self, ui):
"""Show dropdown for selected item."""
# Remove existing dropdown
self._hide_dropdown(ui)
item = self.items[self.selected_item]
options = item.get('options', [])
if not options:
return
x = 5 + self.selected_item * self.item_width
y = self.y + self.height
width = 150
height = len(options) * 25 + 10
self.dropdown = mcrfpy.Frame(pos=(x, y), size=(width, height))
self.dropdown.fill_color = mcrfpy.Color(40, 40, 60, 250)
self.dropdown.outline = 1
self.dropdown.outline_color = mcrfpy.Color(80, 80, 100)
ui.append(self.dropdown)
self.dropdown_captions = []
for i, opt in enumerate(options):
cap = mcrfpy.Caption(
text=opt['label'],
x=x + 10,
y=y + 5 + i * 25
)
cap.fill_color = mcrfpy.Color(200, 200, 200)
self.dropdown_captions.append(cap)
ui.append(cap)
self.dropdown_selected = 0
self._update_dropdown_highlight()
def _hide_dropdown(self, ui):
"""Hide dropdown menu."""
if self.dropdown:
try:
ui.remove(self.dropdown)
except:
pass
self.dropdown = None
for cap in self.dropdown_captions:
try:
ui.remove(cap)
except:
pass
self.dropdown_captions = []
def _update_dropdown_highlight(self):
"""Update dropdown selection highlight."""
for i, cap in enumerate(self.dropdown_captions):
if i == self.dropdown_selected:
cap.fill_color = mcrfpy.Color(255, 255, 100)
else:
cap.fill_color = mcrfpy.Color(200, 200, 200)
def add_to_scene(self, ui):
ui.append(self.bar)
for cap in self.item_captions:
ui.append(cap)
def handle_key(self, key, ui):
"""Handle keyboard navigation."""
if not self.dropdown_open:
if key == mcrfpy.Key.LEFT:
self.selected_item = (self.selected_item - 1) % len(self.items)
self._update_highlight()
elif key == mcrfpy.Key.RIGHT:
self.selected_item = (self.selected_item + 1) % len(self.items)
self._update_highlight()
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.DOWN:
self.dropdown_open = True
self._show_dropdown(ui)
self._update_highlight()
else:
if key == mcrfpy.Key.UP:
options = self.items[self.selected_item].get('options', [])
self.dropdown_selected = (self.dropdown_selected - 1) % len(options)
self._update_dropdown_highlight()
elif key == mcrfpy.Key.DOWN:
options = self.items[self.selected_item].get('options', [])
self.dropdown_selected = (self.dropdown_selected + 1) % len(options)
self._update_dropdown_highlight()
elif key == mcrfpy.Key.ENTER:
opt = self.items[self.selected_item]['options'][self.dropdown_selected]
if opt.get('action'):
opt['action']()
self.dropdown_open = False
self._hide_dropdown(ui)
self._update_highlight()
elif key == mcrfpy.Key.ESCAPE:
self.dropdown_open = False
self._hide_dropdown(ui)
self._update_highlight()
McRogueFace-Specific Considerations
-
Keyboard Input: The
scene.on_keycallback receives(key: mcrfpy.Key, state: mcrfpy.InputState)enum values, not strings (e.g.mcrfpy.Key.UP,mcrfpy.Key.ENTER,mcrfpy.Key.ESCAPE). Handle both arrow keys and WASD for accessibility. -
Focus Management: Only one menu should handle input at a time. Use an
activeflag or a globalcurrent_menureference. -
Z-Order: Elements added later render on top. For dropdowns/submenus, add them after the main menu or use
removeandappendto bring to front. -
State Persistence: Menus retain their state when hidden. Reset
selected_indexif you want fresh state on re-open. -
Key Repeat: McRogueFace sends
mcrfpy.InputState.PRESSEDandmcrfpy.InputState.RELEASEDstates for key events. Filter to onlyPRESSEDto avoid double-processing.
Complete Example
import mcrfpy
# Setup
scene = mcrfpy.Scene("main_menu")
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768))
bg.fill_color = mcrfpy.Color(20, 20, 35)
scene.children.append(bg)
# Title
title = mcrfpy.Caption(text="DUNGEON QUEST", x=350, y=100)
title.fill_color = mcrfpy.Color(255, 200, 50)
scene.children.append(title)
# Menu
def start_game():
print("Starting game...")
def show_options():
print("Options...")
menu = Menu(
362, 250,
["New Game", "Continue", "Options", "Quit"],
lambda i, opt: {
0: start_game,
1: lambda: print("Continue..."),
2: show_options,
3: mcrfpy.exit
}.get(i, lambda: None)(),
title="Main Menu"
)
menu.add_to_scene(scene.children)
# Input
def on_key(key, state):
if state != mcrfpy.InputState.PRESSED:
return
menu.handle_key(key)
scene.on_key = on_key
scene.activate()