#!/usr/bin/env python3 """Focus System Demo for McRogueFace Demonstrates a Python-level focus management system using engine primitives. This shows how game developers can implement keyboard navigation without requiring C++ engine changes. Features demonstrated: - Click-to-focus - Tab/Shift+Tab cycling - Visual focus indicators - Keyboard routing to focused widget - Modal focus stack - Three widget types: Grid (WASD), TextInput, MenuIcon Issue: #143 """ import mcrfpy import sys # ============================================================================= # Modifier Key Tracker (workaround until #160 is implemented) # ============================================================================= class ModifierTracker: """Tracks modifier key state since engine doesn't expose this yet.""" def __init__(self): self.shift = False self.ctrl = False self.alt = False def update(self, key: str, action: str): """Call this from your key handler to update modifier state.""" if key in ("LShift", "RShift"): self.shift = (action == "start") elif key in ("LControl", "RControl"): self.ctrl = (action == "start") elif key in ("LAlt", "RAlt"): self.alt = (action == "start") # ============================================================================= # Focus Manager # ============================================================================= class FocusManager: """Central focus coordinator for a scene. Manages which widget receives keyboard input, handles tab cycling, and maintains a modal stack for popup dialogs. """ # Focus indicator colors FOCUS_COLOR = mcrfpy.Color(0, 150, 255) # Blue UNFOCUS_COLOR = mcrfpy.Color(80, 80, 80) # Dark gray FOCUS_OUTLINE = 3.0 UNFOCUS_OUTLINE = 1.0 def __init__(self): self.widgets = [] # List of (widget, focusable: bool) self.focus_index = -1 # Currently focused widget index self.modal_stack = [] # Stack of (modal_frame, previous_focus_index) self.modifiers = ModifierTracker() def register(self, widget, focusable: bool = True): """Add a widget to the focus order. Args: widget: Object implementing on_focus(), on_blur(), handle_key() focusable: Whether this widget can receive focus via Tab """ self.widgets.append((widget, focusable)) # Give widget a reference back to us for click-to-focus widget._focus_manager = self widget._focus_index = len(self.widgets) - 1 def focus(self, widget_or_index): """Set focus to a specific widget.""" # Resolve to index if isinstance(widget_or_index, int): new_index = widget_or_index else: new_index = next( (i for i, (w, _) in enumerate(self.widgets) if w is widget_or_index), -1 ) if new_index < 0 or new_index >= len(self.widgets): return # Blur old widget if 0 <= self.focus_index < len(self.widgets): old_widget, _ = self.widgets[self.focus_index] if hasattr(old_widget, 'on_blur'): old_widget.on_blur() # Focus new widget self.focus_index = new_index new_widget, _ = self.widgets[new_index] if hasattr(new_widget, 'on_focus'): new_widget.on_focus() def cycle(self, direction: int = 1): """Cycle focus to next/previous focusable widget. Args: direction: 1 for next (Tab), -1 for previous (Shift+Tab) """ if not self.widgets: return start = self.focus_index if self.focus_index >= 0 else 0 current = start for _ in range(len(self.widgets)): current = (current + direction) % len(self.widgets) widget, focusable = self.widgets[current] if focusable: self.focus(current) return # No focusable widget found, stay where we are def push_modal(self, modal_frame, first_focus_widget=None): """Push a modal onto the focus stack. Args: modal_frame: The Frame to show as modal first_focus_widget: Widget to focus inside modal (optional) """ # Save current focus self.modal_stack.append((modal_frame, self.focus_index)) # Show modal modal_frame.visible = True # Focus first widget in modal if specified if first_focus_widget is not None: self.focus(first_focus_widget) def pop_modal(self): """Pop the top modal and restore previous focus.""" if not self.modal_stack: return False modal_frame, previous_focus = self.modal_stack.pop() modal_frame.visible = False # Restore focus if previous_focus >= 0: self.focus(previous_focus) return True def handle_key(self, key: str, action: str) -> bool: """Main key handler - route to focused widget or handle global keys. Returns True if key was consumed. """ # Always update modifier state self.modifiers.update(key, action) # Only process on key press, not release (key repeat sends multiple "start") if action != "start": return False # Global: Escape closes modals if key == "Escape": if self.pop_modal(): return True # Global: Tab cycles focus if key == "Tab": direction = -1 if self.modifiers.shift else 1 self.cycle(direction) return True # Route to focused widget if 0 <= self.focus_index < len(self.widgets): widget, _ = self.widgets[self.focus_index] if hasattr(widget, 'handle_key'): if widget.handle_key(key, action): return True return False # ============================================================================= # Focusable Widgets # ============================================================================= class FocusableGrid: """A grid where WASD keys move a player entity. Demonstrates focus on a game-world element. """ def __init__(self, x: float, y: float, grid_w: int, grid_h: int, tile_size: int = 16, zoom: float = 2.0): self.grid_w = grid_w self.grid_h = grid_h self.tile_size = tile_size self.zoom = zoom self.base_x = x self.base_y = y # Calculate pixel dimensions self.cell_px = tile_size * zoom # Pixels per cell grid_pixel_w = grid_w * self.cell_px grid_pixel_h = grid_h * self.cell_px # Create the grid background self.grid = mcrfpy.Grid( pos=(x, y), grid_size=(grid_w, grid_h), size=(grid_pixel_w, grid_pixel_h) ) self.grid.zoom = zoom self.grid.fill_color = mcrfpy.Color(40, 40, 55) # Add outline frame for focus indication self.outline_frame = mcrfpy.Frame( pos=(x - 2, y - 2), size=(grid_pixel_w + 4, grid_pixel_h + 4), fill_color=mcrfpy.Color(0, 0, 0, 0), outline_color=FocusManager.UNFOCUS_COLOR, outline=FocusManager.UNFOCUS_OUTLINE ) # Player marker (a bright square overlay) self.player_x = grid_w // 2 self.player_y = grid_h // 2 marker_size = self.cell_px - 4 # Slightly smaller than cell self.player_marker = mcrfpy.Frame( pos=(0, 0), # Will be positioned by _update_player_display size=(marker_size, marker_size), fill_color=mcrfpy.Color(255, 200, 50), outline_color=mcrfpy.Color(255, 150, 0), outline=2 ) self._update_player_display() # Click handler self.grid.on_click = self._on_click # Focus manager reference (set by FocusManager.register) self._focus_manager = None self._focus_index = -1 def _on_click(self, x, y, button, action): """Handle click to focus this grid.""" if self._focus_manager and action == "start": self._focus_manager.focus(self._focus_index) def _update_player_display(self): """Update the visual representation of player position.""" # Position the player marker px = self.base_x + (self.player_x * self.cell_px) + 2 py = self.base_y + (self.player_y * self.cell_px) + 2 self.player_marker.x = px self.player_marker.y = py def on_focus(self): """Called when this widget gains focus.""" self.outline_frame.outline_color = FocusManager.FOCUS_COLOR self.outline_frame.outline = FocusManager.FOCUS_OUTLINE def on_blur(self): """Called when this widget loses focus.""" self.outline_frame.outline_color = FocusManager.UNFOCUS_COLOR self.outline_frame.outline = FocusManager.UNFOCUS_OUTLINE def handle_key(self, key: str, action: str) -> bool: """Handle WASD movement.""" moves = { "W": (0, -1), "Up": (0, -1), "A": (-1, 0), "Left": (-1, 0), "S": (0, 1), "Down": (0, 1), "D": (1, 0), "Right": (1, 0), } if key in moves: dx, dy = moves[key] new_x = self.player_x + dx new_y = self.player_y + dy # Bounds check if 0 <= new_x < self.grid_w and 0 <= new_y < self.grid_h: self.player_x = new_x self.player_y = new_y self._update_player_display() return True return False def add_to_scene(self, ui): """Add all components to a scene's UI collection.""" ui.append(self.outline_frame) ui.append(self.grid) ui.append(self.player_marker) class TextInputWidget: """A text input field with cursor and editing. Demonstrates text entry with focus indication. """ def __init__(self, x: float, y: float, width: float, label: str = "", placeholder: str = ""): self.x = x self.y = y self.width = width self.height = 28 self.label_text = label self.placeholder_text = placeholder # State self.text = "" self.cursor_pos = 0 self.focused = False # Create UI elements self._create_ui() # Focus manager reference self._focus_manager = None self._focus_index = -1 def _create_ui(self): """Create the visual components.""" # Label above input if self.label_text: self.label = mcrfpy.Caption( text=self.label_text, pos=(self.x, self.y - 20) ) self.label.fill_color = mcrfpy.Color(200, 200, 200) # Input background self.frame = mcrfpy.Frame( pos=(self.x, self.y), size=(self.width, self.height), fill_color=mcrfpy.Color(40, 40, 50), outline_color=FocusManager.UNFOCUS_COLOR, outline=FocusManager.UNFOCUS_OUTLINE ) self.frame.on_click = self._on_click # Placeholder text self.placeholder = mcrfpy.Caption( text=self.placeholder_text, pos=(self.x + 6, self.y + 5) ) self.placeholder.fill_color = mcrfpy.Color(100, 100, 100) # Actual text display self.display = mcrfpy.Caption( text="", pos=(self.x + 6, self.y + 5) ) self.display.fill_color = mcrfpy.Color(255, 255, 255) # Cursor (thin frame) self.cursor = mcrfpy.Frame( pos=(self.x + 6, self.y + 4), size=(2, self.height - 8), fill_color=mcrfpy.Color(255, 255, 255) ) self.cursor.visible = False def _on_click(self, x, y, button, action): """Handle click to focus.""" if self._focus_manager and action == "start": self._focus_manager.focus(self._focus_index) def _update_display(self): """Update visual state.""" self.display.text = self.text self.placeholder.visible = (not self.text and not self.focused) self._update_cursor() def _update_cursor(self): """Update cursor position.""" # Approximate character width (monospace assumption) char_width = 10 self.cursor.x = self.x + 6 + (self.cursor_pos * char_width) def on_focus(self): """Called when gaining focus.""" self.focused = True self.frame.outline_color = FocusManager.FOCUS_COLOR self.frame.outline = FocusManager.FOCUS_OUTLINE self.cursor.visible = True self._update_display() def on_blur(self): """Called when losing focus.""" self.focused = False self.frame.outline_color = FocusManager.UNFOCUS_COLOR self.frame.outline = FocusManager.UNFOCUS_OUTLINE self.cursor.visible = False self._update_display() def handle_key(self, key: str, action: str) -> bool: """Handle text input and editing keys.""" if not self.focused: return False old_text = self.text handled = True if key == "BackSpace": if self.cursor_pos > 0: self.text = self.text[:self.cursor_pos-1] + self.text[self.cursor_pos:] self.cursor_pos -= 1 elif key == "Delete": if self.cursor_pos < len(self.text): self.text = self.text[:self.cursor_pos] + self.text[self.cursor_pos+1:] elif key == "Left": self.cursor_pos = max(0, self.cursor_pos - 1) elif key == "Right": self.cursor_pos = min(len(self.text), self.cursor_pos + 1) elif key == "Home": self.cursor_pos = 0 elif key == "End": self.cursor_pos = len(self.text) elif key in ("Return", "Tab"): # Don't consume - let focus manager handle handled = False elif len(key) == 1 and key.isprintable(): # Insert character self.text = self.text[:self.cursor_pos] + key + self.text[self.cursor_pos:] self.cursor_pos += 1 else: handled = False self._update_display() return handled def get_text(self) -> str: """Get the current text value.""" return self.text def set_text(self, text: str): """Set the text value.""" self.text = text self.cursor_pos = len(text) self._update_display() def add_to_scene(self, ui): """Add all components to the scene.""" if hasattr(self, 'label'): ui.append(self.label) ui.append(self.frame) ui.append(self.placeholder) ui.append(self.display) ui.append(self.cursor) class MenuIcon: """An icon that opens a modal dialog when activated. Demonstrates activation via Space/Enter and modal focus. """ def __init__(self, x: float, y: float, size: float, icon_char: str, tooltip: str, modal_content_builder=None): self.x = x self.y = y self.size = size self.tooltip = tooltip self.modal_content_builder = modal_content_builder self.modal = None # Create icon frame self.frame = mcrfpy.Frame( pos=(x, y), size=(size, size), fill_color=mcrfpy.Color(60, 60, 80), outline_color=FocusManager.UNFOCUS_COLOR, outline=FocusManager.UNFOCUS_OUTLINE ) self.frame.on_click = self._on_click # Icon character (centered) self.icon = mcrfpy.Caption( text=icon_char, pos=(x + size//3, y + size//6) ) self.icon.fill_color = mcrfpy.Color(200, 200, 220) # Tooltip (shown on hover/focus) self.tooltip_caption = mcrfpy.Caption( text=tooltip, pos=(x, y + size + 4) ) self.tooltip_caption.fill_color = mcrfpy.Color(150, 150, 150) self.tooltip_caption.visible = False # Focus manager reference self._focus_manager = None self._focus_index = -1 def _on_click(self, x, y, button, action): """Handle click to focus or activate.""" if not self._focus_manager: return if action == "start": # If already focused, activate; otherwise just focus if self._focus_manager.focus_index == self._focus_index: self._activate() else: self._focus_manager.focus(self._focus_index) def _activate(self): """Open the modal dialog.""" if self.modal and self._focus_manager: self._focus_manager.push_modal(self.modal) def on_focus(self): """Called when gaining focus.""" self.frame.outline_color = FocusManager.FOCUS_COLOR self.frame.outline = FocusManager.FOCUS_OUTLINE self.frame.fill_color = mcrfpy.Color(80, 80, 110) self.tooltip_caption.visible = True def on_blur(self): """Called when losing focus.""" self.frame.outline_color = FocusManager.UNFOCUS_COLOR self.frame.outline = FocusManager.UNFOCUS_OUTLINE self.frame.fill_color = mcrfpy.Color(60, 60, 80) self.tooltip_caption.visible = False def handle_key(self, key: str, action: str) -> bool: """Handle activation keys.""" if key in ("Space", "Return"): self._activate() return True return False def set_modal(self, modal_frame): """Set the modal frame this icon opens.""" self.modal = modal_frame def add_to_scene(self, ui): """Add all components to the scene.""" ui.append(self.frame) ui.append(self.icon) ui.append(self.tooltip_caption) # ============================================================================= # Modal Dialog Builder # ============================================================================= def create_modal(x: float, y: float, width: float, height: float, title: str) -> mcrfpy.Frame: """Create a modal dialog frame.""" # Semi-transparent backdrop # Note: This is simplified - real implementation might want fullscreen backdrop # Modal frame modal = mcrfpy.Frame( pos=(x, y), size=(width, height), fill_color=mcrfpy.Color(40, 40, 50), outline_color=mcrfpy.Color(100, 100, 120), outline=2 ) modal.visible = False # Title title_caption = mcrfpy.Caption( text=title, pos=(x + 10, y + 8) ) title_caption.fill_color = mcrfpy.Color(220, 220, 240) modal.children.append(title_caption) # Close hint close_hint = mcrfpy.Caption( text="[Esc to close]", pos=(x + width - 100, y + 8) ) close_hint.fill_color = mcrfpy.Color(120, 120, 140) modal.children.append(close_hint) return modal # ============================================================================= # Demo Scene Setup # ============================================================================= def create_demo_scene(): """Create and populate the focus system demo scene.""" # Create scene mcrfpy.createScene("focus_demo") ui = mcrfpy.sceneUI("focus_demo") # Background bg = mcrfpy.Frame( pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(25, 25, 35) ) ui.append(bg) # Title title = mcrfpy.Caption( text="Focus System Demo", pos=(20, 15) ) title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Instructions instructions = mcrfpy.Caption( text="Tab: cycle focus | Shift+Tab: reverse | WASD: move in grid | Space/Enter: activate | Esc: close modal", pos=(20, 45) ) instructions.fill_color = mcrfpy.Color(150, 150, 170) ui.append(instructions) # Create focus manager focus_mgr = FocusManager() # --- Grid Section --- grid_label = mcrfpy.Caption(text="Game Grid (WASD to move)", pos=(50, 90)) grid_label.fill_color = mcrfpy.Color(180, 180, 200) ui.append(grid_label) grid_widget = FocusableGrid(50, 115, 10, 8, tile_size=16, zoom=2.0) grid_widget.add_to_scene(ui) focus_mgr.register(grid_widget) # --- Text Inputs Section --- input_label = mcrfpy.Caption(text="Text Inputs", pos=(400, 90)) input_label.fill_color = mcrfpy.Color(180, 180, 200) ui.append(input_label) name_input = TextInputWidget(400, 130, 250, label="Name:", placeholder="Enter your name") name_input.add_to_scene(ui) focus_mgr.register(name_input) class_input = TextInputWidget(400, 200, 250, label="Class:", placeholder="e.g. Warrior, Mage") class_input.add_to_scene(ui) focus_mgr.register(class_input) notes_input = TextInputWidget(400, 270, 350, label="Notes:", placeholder="Additional notes...") notes_input.add_to_scene(ui) focus_mgr.register(notes_input) # --- Menu Icons Section --- icons_label = mcrfpy.Caption(text="Menu Icons", pos=(50, 390)) icons_label.fill_color = mcrfpy.Color(180, 180, 200) ui.append(icons_label) # Help icon help_icon = MenuIcon(50, 420, 48, "?", "Help") help_icon.add_to_scene(ui) focus_mgr.register(help_icon) help_modal = create_modal(200, 150, 400, 300, "Help") ui.append(help_modal) help_text = mcrfpy.Caption( text="This demo shows focus management.\n\nUse Tab to move between widgets.\nWASD moves the player in the grid.\nType in text fields.\nPress Space on icons to open dialogs.", pos=(210, 190) ) help_text.fill_color = mcrfpy.Color(200, 200, 200) help_modal.children.append(help_text) help_icon.set_modal(help_modal) # Settings icon settings_icon = MenuIcon(110, 420, 48, "S", "Settings") settings_icon.add_to_scene(ui) focus_mgr.register(settings_icon) settings_modal = create_modal(200, 150, 400, 250, "Settings") ui.append(settings_modal) settings_text = mcrfpy.Caption( text="Settings would go here.\n\n(This is a placeholder modal)", pos=(210, 190) ) settings_text.fill_color = mcrfpy.Color(200, 200, 200) settings_modal.children.append(settings_text) settings_icon.set_modal(settings_modal) # Inventory icon inv_icon = MenuIcon(170, 420, 48, "I", "Inventory") inv_icon.add_to_scene(ui) focus_mgr.register(inv_icon) inv_modal = create_modal(200, 150, 400, 300, "Inventory") ui.append(inv_modal) inv_text = mcrfpy.Caption( text="Your inventory:\n\n- Sword\n- Shield\n- 3x Potions", pos=(210, 190) ) inv_text.fill_color = mcrfpy.Color(200, 200, 200) inv_modal.children.append(inv_text) inv_icon.set_modal(inv_modal) # --- Status Display --- status_frame = mcrfpy.Frame( pos=(50, 520), size=(700, 80), fill_color=mcrfpy.Color(35, 35, 45), outline_color=mcrfpy.Color(60, 60, 70), outline=1 ) ui.append(status_frame) status_label = mcrfpy.Caption(text="Status", pos=(60, 530)) status_label.fill_color = mcrfpy.Color(150, 150, 170) ui.append(status_label) status_text = mcrfpy.Caption(text="Click or Tab to focus a widget", pos=(60, 555)) status_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status_text) # Store references for status updates demo_state = { 'focus_mgr': focus_mgr, 'status_text': status_text, 'grid': grid_widget, 'inputs': [name_input, class_input, notes_input], 'icons': [help_icon, settings_icon, inv_icon], } # Key handler that routes to focus manager def on_key(key: str, action: str): focus_mgr.handle_key(key, action) # Update status display if focus_mgr.focus_index >= 0: widget, _ = focus_mgr.widgets[focus_mgr.focus_index] if widget is grid_widget: status_text.text = f"Grid focused - Player at ({grid_widget.player_x}, {grid_widget.player_y})" elif widget in demo_state['inputs']: idx = demo_state['inputs'].index(widget) labels = ["Name", "Class", "Notes"] status_text.text = f"{labels[idx]} input focused - Text: '{widget.get_text()}'" elif widget in demo_state['icons']: status_text.text = f"Icon focused: {widget.tooltip}" else: status_text.text = "No widget focused" # Register key handler using Scene API scene = mcrfpy.sceneUI("focus_demo") # Note: We use keypressScene for function-based handler since we're not subclassing Scene mcrfpy.keypressScene(on_key) # Set initial focus focus_mgr.focus(0) # Activate scene mcrfpy.setScene("focus_demo") return demo_state # ============================================================================= # Entry Point # ============================================================================= def run_demo(): """Run the focus system demo.""" print("=== Focus System Demo ===") print("Demonstrating Python-level focus management") print() print("Controls:") print(" Tab / Shift+Tab - Cycle between widgets") print(" WASD / Arrows - Move player in grid (when focused)") print(" Type - Enter text in inputs (when focused)") print(" Space / Enter - Activate icons (when focused)") print(" Escape - Close modal dialogs") print(" Click - Focus clicked widget") print() demo_state = create_demo_scene() # Set up exit timer for headless testing def check_exit(dt): # In headless mode, exit after a short delay # In interactive mode, this won't trigger pass # mcrfpy.setTimer("demo_check", check_exit, 100) # Run if executed directly if __name__ == "__main__": import sys from mcrfpy import automation run_demo() # If --screenshot flag, take a screenshot and exit if "--screenshot" in sys.argv or len(sys.argv) > 1: def take_screenshot(dt): automation.screenshot("focus_demo_screenshot.png") print("Screenshot saved: focus_demo_screenshot.png") sys.exit(0) mcrfpy.setTimer("screenshot", take_screenshot, 200)