[Demo] Widget Focus System Example #143

Closed
opened 2025-11-27 18:34:45 +00:00 by john · 2 comments
Owner

Python-level demonstration of a focus system using McRogueFace's event callbacks.

Context

Widget focus (click-to-focus, tab cycling, keyboard routing to focused widget) is a common UI pattern but highly application-specific:

  • Does mouseover change focus?
  • Does clicking?
  • Are some elements not focusable?
  • Should focus be visible? How?

Rather than bake one specific focus model into C++, this issue creates a Python demo showing how game developers can implement focus using the engine's primitives.

Philosophy

McRogueFace provides:

  • Mouse events: on_click, on_enter, on_exit (per-drawable)
  • Keyboard events: Scene-level on_key handler
  • Queryable state: mouse_pos, hovered

Game developers combine these to create their desired focus behavior. This demo shows one approach.

Definition of Done

  • demos/focus_system.py - working example
  • Demonstrates click-to-focus
  • Demonstrates tab cycling
  • Demonstrates visual focus indicator (outline, glow, etc.)
  • Routes keyboard input to focused widget
  • Well-commented to serve as documentation
  • Shows how to make elements non-focusable

Demo Design

# Conceptual structure - not final code

class FocusManager:
    def __init__(self, scene):
        self.focusable_widgets = []
        self.focused_index = -1
        scene.on_key = self.handle_key
    
    def register(self, widget, on_key_handler):
        widget.on_click = lambda x, y: self.focus(widget)
        self.focusable_widgets.append((widget, on_key_handler))
    
    def focus(self, widget):
        # Remove visual from old focused
        # Add visual to new focused
        # Update focused_index
        pass
    
    def handle_key(self, key, state):
        if key == "Tab" and state == "start":
            self.cycle_focus()
        elif self.focused_index >= 0:
            widget, handler = self.focusable_widgets[self.focused_index]
            handler(key, state)
    
    def cycle_focus(self):
        self.focused_index = (self.focused_index + 1) % len(self.focusable_widgets)
        self.focus(self.focusable_widgets[self.focused_index][0])

What This Is NOT

This is not a C++ engine feature. It's a demo showing that:

  1. McRogueFace provides sufficient primitives
  2. Focus can be implemented in pure Python
  3. Developers can customize focus behavior for their game

If patterns emerge that would benefit from C++ support, that's a future issue.

Dependencies

  • Blocked by: Callback Naming Standardization (NEW) - need on_click, on_enter
  • Blocked by: Mouse Enter/Exit Events (NEW) - for hover-to-focus variant
  • Demonstrates: #45 (Accessibility) - focus is key to keyboard navigation
  • Uses: All the new mouse event infrastructure
Python-level demonstration of a focus system using McRogueFace's event callbacks. ## Context Widget focus (click-to-focus, tab cycling, keyboard routing to focused widget) is a common UI pattern but highly application-specific: - Does mouseover change focus? - Does clicking? - Are some elements not focusable? - Should focus be visible? How? Rather than bake one specific focus model into C++, this issue creates a **Python demo** showing how game developers can implement focus using the engine's primitives. ## Philosophy McRogueFace provides: - **Mouse events**: on_click, on_enter, on_exit (per-drawable) - **Keyboard events**: Scene-level on_key handler - **Queryable state**: mouse_pos, hovered Game developers combine these to create their desired focus behavior. This demo shows one approach. ## Definition of Done - [ ] `demos/focus_system.py` - working example - [ ] Demonstrates click-to-focus - [ ] Demonstrates tab cycling - [ ] Demonstrates visual focus indicator (outline, glow, etc.) - [ ] Routes keyboard input to focused widget - [ ] Well-commented to serve as documentation - [ ] Shows how to make elements non-focusable ## Demo Design ```python # Conceptual structure - not final code class FocusManager: def __init__(self, scene): self.focusable_widgets = [] self.focused_index = -1 scene.on_key = self.handle_key def register(self, widget, on_key_handler): widget.on_click = lambda x, y: self.focus(widget) self.focusable_widgets.append((widget, on_key_handler)) def focus(self, widget): # Remove visual from old focused # Add visual to new focused # Update focused_index pass def handle_key(self, key, state): if key == "Tab" and state == "start": self.cycle_focus() elif self.focused_index >= 0: widget, handler = self.focusable_widgets[self.focused_index] handler(key, state) def cycle_focus(self): self.focused_index = (self.focused_index + 1) % len(self.focusable_widgets) self.focus(self.focusable_widgets[self.focused_index][0]) ``` ## What This Is NOT This is **not** a C++ engine feature. It's a demo showing that: 1. McRogueFace provides sufficient primitives 2. Focus can be implemented in pure Python 3. Developers can customize focus behavior for their game If patterns emerge that would benefit from C++ support, that's a future issue. ## Dependencies - **Blocked by**: Callback Naming Standardization (NEW) - need on_click, on_enter - **Blocked by**: Mouse Enter/Exit Events (NEW) - for hover-to-focus variant ## Related Issues - Demonstrates: #45 (Accessibility) - focus is key to keyboard navigation - Uses: All the new mouse event infrastructure
Author
Owner

Implementation Details - First Pass

Available Engine Primitives

Primitive Location Signature Use Case
scene.on_key Scene property (key: str, action: str) Route all keyboard input
drawable.on_click All drawables (x, y, button, state) Click-to-focus
drawable.on_enter All drawables (x, y, button, action) Hover effects
drawable.on_exit All drawables (x, y, button, action) Hover effects
drawable.hovered All drawables (read-only) bool Query hover state
drawable.visible All drawables bool Show/hide focus indicators
drawable.outline Frame float Visual focus ring
drawable.outline_color Frame Color Focus indicator color

Note: on_enter, on_exit, and hovered are missing from stubs/mcrfpy.pyi - should be added.

Answering the Design Questions

Question Demo's Answer Rationale
Does mouseover change focus? No Games typically use click/tab; hover-focus is jarring
Does clicking change focus? Yes Standard UI expectation
Are some elements not focusable? Yes Labels, decorations, locked UI are non-focusable
Should focus be visible? How? Yes, outline Blue outline (3px) on focused element

Three Widget Types for Demo

1. FocusableGrid - WASD Movement

class FocusableGrid:
    """Grid where WASD keys move a character entity."""
    
    def __init__(self, grid: mcrfpy.Grid, player_entity: mcrfpy.Entity):
        self.grid = grid
        self.player = player_entity
        self.grid.on_click = lambda x, y, btn, st: self.request_focus()
        
    def handle_key(self, key: str, action: str) -> bool:
        """Returns True if key was consumed."""
        if action != "start":
            return False
        
        dx, dy = {"W": (0, -1), "A": (-1, 0), "S": (0, 1), "D": (1, 0)}.get(key, (0, 0))
        if dx or dy:
            new_x = self.player.x + dx
            new_y = self.player.y + dy
            # Bounds/collision check here
            self.player.x, self.player.y = new_x, new_y
            return True
        return False
    
    def on_focus(self):
        self.grid.outline = 3
        self.grid.outline_color = mcrfpy.Color(0, 120, 255)
    
    def on_blur(self):
        self.grid.outline = 1
        self.grid.outline_color = mcrfpy.Color(100, 100, 100)

2. MenuIcon - Modal Activation

class MenuIcon:
    """Icon that opens a modal frame when activated."""
    
    def __init__(self, sprite: mcrfpy.Sprite, modal: mcrfpy.Frame, name: str):
        self.sprite = sprite
        self.modal = modal
        self.name = name
        self.sprite.on_click = lambda x, y, btn, st: self.request_focus()
        self.modal.visible = False
        
    def handle_key(self, key: str, action: str) -> bool:
        if action != "start":
            return False
            
        if key in ("Space", "Return"):
            self.modal.visible = True
            # Focus manager should now focus the modal's first child
            return True
        return False
    
    def on_focus(self):
        # Add glow or border around icon
        self.highlight.visible = True  # Pre-created highlight frame
    
    def on_blur(self):
        self.highlight.visible = False

3. TextInput - Character Entry

Already exists in src/scripts/text_input_widget.py - demonstrates:

  • Cursor position tracking
  • Backspace/Delete/Home/End handling
  • Visual placeholder text
  • Click-to-focus integration

FocusManager Architecture

class FocusManager:
    """Central focus coordinator."""
    
    def __init__(self, scene_name: str):
        self.widgets = []          # List of (widget, is_focusable)
        self.focus_index = -1
        self.focus_stack = []      # For modal push/pop
        
        # Register as scene's key handler
        scene = mcrfpy.Scene(scene_name)  # or use on_key property
        scene.on_key = self._handle_key
    
    def register(self, widget, focusable: bool = True):
        """Add widget to focus order."""
        self.widgets.append((widget, focusable))
        widget.request_focus = lambda: self.focus(widget)
    
    def focus(self, widget):
        """Move focus to specific widget."""
        # Blur old
        if self.focus_index >= 0:
            old_widget, _ = self.widgets[self.focus_index]
            old_widget.on_blur()
        
        # Focus new
        idx = next((i for i, (w, _) in enumerate(self.widgets) if w is widget), -1)
        if idx >= 0:
            self.focus_index = idx
            widget.on_focus()
    
    def cycle(self, direction: int = 1):
        """Tab through focusable widgets."""
        if not self.widgets:
            return
            
        # Find next focusable
        start = self.focus_index
        for _ in range(len(self.widgets)):
            self.focus_index = (self.focus_index + direction) % len(self.widgets)
            widget, focusable = self.widgets[self.focus_index]
            if focusable:
                self.focus(widget)
                return
        self.focus_index = start  # No focusable found
    
    def _handle_key(self, key: str, action: str):
        """Route keyboard input."""
        # Tab cycling (handle before widget gets it)
        if key == "Tab" and action == "start":
            self.cycle(1)
            return
        if key == "Tab" and action == "start" and shift_held:  # Need modifier tracking
            self.cycle(-1)
            return
        
        # Route to focused widget
        if self.focus_index >= 0:
            widget, _ = self.widgets[self.focus_index]
            if hasattr(widget, 'handle_key'):
                consumed = widget.handle_key(key, action)
                if consumed:
                    return
        
        # Global keys (Escape to close modals, etc.)
        if key == "Escape" and action == "start":
            self._handle_escape()

Modal Focus Stack

def push_modal(self, modal_frame, first_focus_widget):
    """Push modal onto focus stack, saving current focus."""
    self.focus_stack.append(self.focus_index)
    modal_frame.visible = True
    self.focus(first_focus_widget)

def pop_modal(self, modal_frame):
    """Pop modal, restore previous focus."""
    modal_frame.visible = False
    if self.focus_stack:
        self.focus_index = self.focus_stack.pop()
        widget, _ = self.widgets[self.focus_index]
        widget.on_focus()

Demo Layout Proposal

┌─────────────────────────────────────────────────────────┐
│  Focus System Demo                              [?] [X] │ <- Menu icons (tab targets)
├─────────────────────────────────────────────────────────┤
│                                                         │
│   ┌─────────────────┐    ┌─────────────────────────┐   │
│   │                 │    │  Name: [____________]   │   │
│   │   WASD Grid     │    │  Class: [____________]  │   │ <- Text inputs
│   │     (@)         │    │                         │   │
│   │                 │    │  [OK]    [Cancel]       │   │ <- Button icons
│   └─────────────────┘    └─────────────────────────┘   │
│                                                         │
│  Press Tab to cycle focus | WASD moves in grid         │
│  Space/Enter activates | Escape closes modals          │
└─────────────────────────────────────────────────────────┘

Gaps/Issues Discovered

  1. Modifier key state: No current way to detect Shift+Tab (need to track shift state in key handler)
  2. Stubs incomplete: on_enter, on_exit, hovered missing from stubs/mcrfpy.pyi
  3. Key repeat: Should key handlers receive repeated events for held keys, or just start/end?

Files to Create

  • tests/demo/screens/focus_system_demo.py - Main demo file
  • Consider extracting reusable FocusManager to src/scripts/focus_manager.py

Suggested Implementation Order

  1. Create basic FocusManager class
  2. Implement visual focus indicator (outline change)
  3. Add Grid widget with WASD handling
  4. Add TextInput widgets
  5. Add MenuIcon widgets with modal activation
  6. Add Escape handling for modal dismissal
  7. Add Tab cycling
  8. Polish with on_enter/on_exit hover effects
## Implementation Details - First Pass ### Available Engine Primitives | Primitive | Location | Signature | Use Case | |-----------|----------|-----------|----------| | `scene.on_key` | Scene property | `(key: str, action: str)` | Route all keyboard input | | `drawable.on_click` | All drawables | `(x, y, button, state)` | Click-to-focus | | `drawable.on_enter` | All drawables | `(x, y, button, action)` | Hover effects | | `drawable.on_exit` | All drawables | `(x, y, button, action)` | Hover effects | | `drawable.hovered` | All drawables (read-only) | `bool` | Query hover state | | `drawable.visible` | All drawables | `bool` | Show/hide focus indicators | | `drawable.outline` | Frame | `float` | Visual focus ring | | `drawable.outline_color` | Frame | `Color` | Focus indicator color | **Note:** `on_enter`, `on_exit`, and `hovered` are missing from `stubs/mcrfpy.pyi` - should be added. ### Answering the Design Questions | Question | Demo's Answer | Rationale | |----------|---------------|-----------| | Does mouseover change focus? | **No** | Games typically use click/tab; hover-focus is jarring | | Does clicking change focus? | **Yes** | Standard UI expectation | | Are some elements not focusable? | **Yes** | Labels, decorations, locked UI are non-focusable | | Should focus be visible? How? | **Yes, outline** | Blue outline (3px) on focused element | ### Three Widget Types for Demo #### 1. FocusableGrid - WASD Movement ```python class FocusableGrid: """Grid where WASD keys move a character entity.""" def __init__(self, grid: mcrfpy.Grid, player_entity: mcrfpy.Entity): self.grid = grid self.player = player_entity self.grid.on_click = lambda x, y, btn, st: self.request_focus() def handle_key(self, key: str, action: str) -> bool: """Returns True if key was consumed.""" if action != "start": return False dx, dy = {"W": (0, -1), "A": (-1, 0), "S": (0, 1), "D": (1, 0)}.get(key, (0, 0)) if dx or dy: new_x = self.player.x + dx new_y = self.player.y + dy # Bounds/collision check here self.player.x, self.player.y = new_x, new_y return True return False def on_focus(self): self.grid.outline = 3 self.grid.outline_color = mcrfpy.Color(0, 120, 255) def on_blur(self): self.grid.outline = 1 self.grid.outline_color = mcrfpy.Color(100, 100, 100) ``` #### 2. MenuIcon - Modal Activation ```python class MenuIcon: """Icon that opens a modal frame when activated.""" def __init__(self, sprite: mcrfpy.Sprite, modal: mcrfpy.Frame, name: str): self.sprite = sprite self.modal = modal self.name = name self.sprite.on_click = lambda x, y, btn, st: self.request_focus() self.modal.visible = False def handle_key(self, key: str, action: str) -> bool: if action != "start": return False if key in ("Space", "Return"): self.modal.visible = True # Focus manager should now focus the modal's first child return True return False def on_focus(self): # Add glow or border around icon self.highlight.visible = True # Pre-created highlight frame def on_blur(self): self.highlight.visible = False ``` #### 3. TextInput - Character Entry Already exists in `src/scripts/text_input_widget.py` - demonstrates: - Cursor position tracking - Backspace/Delete/Home/End handling - Visual placeholder text - Click-to-focus integration ### FocusManager Architecture ```python class FocusManager: """Central focus coordinator.""" def __init__(self, scene_name: str): self.widgets = [] # List of (widget, is_focusable) self.focus_index = -1 self.focus_stack = [] # For modal push/pop # Register as scene's key handler scene = mcrfpy.Scene(scene_name) # or use on_key property scene.on_key = self._handle_key def register(self, widget, focusable: bool = True): """Add widget to focus order.""" self.widgets.append((widget, focusable)) widget.request_focus = lambda: self.focus(widget) def focus(self, widget): """Move focus to specific widget.""" # Blur old if self.focus_index >= 0: old_widget, _ = self.widgets[self.focus_index] old_widget.on_blur() # Focus new idx = next((i for i, (w, _) in enumerate(self.widgets) if w is widget), -1) if idx >= 0: self.focus_index = idx widget.on_focus() def cycle(self, direction: int = 1): """Tab through focusable widgets.""" if not self.widgets: return # Find next focusable start = self.focus_index for _ in range(len(self.widgets)): self.focus_index = (self.focus_index + direction) % len(self.widgets) widget, focusable = self.widgets[self.focus_index] if focusable: self.focus(widget) return self.focus_index = start # No focusable found def _handle_key(self, key: str, action: str): """Route keyboard input.""" # Tab cycling (handle before widget gets it) if key == "Tab" and action == "start": self.cycle(1) return if key == "Tab" and action == "start" and shift_held: # Need modifier tracking self.cycle(-1) return # Route to focused widget if self.focus_index >= 0: widget, _ = self.widgets[self.focus_index] if hasattr(widget, 'handle_key'): consumed = widget.handle_key(key, action) if consumed: return # Global keys (Escape to close modals, etc.) if key == "Escape" and action == "start": self._handle_escape() ``` ### Modal Focus Stack ```python def push_modal(self, modal_frame, first_focus_widget): """Push modal onto focus stack, saving current focus.""" self.focus_stack.append(self.focus_index) modal_frame.visible = True self.focus(first_focus_widget) def pop_modal(self, modal_frame): """Pop modal, restore previous focus.""" modal_frame.visible = False if self.focus_stack: self.focus_index = self.focus_stack.pop() widget, _ = self.widgets[self.focus_index] widget.on_focus() ``` ### Demo Layout Proposal ``` ┌─────────────────────────────────────────────────────────┐ │ Focus System Demo [?] [X] │ <- Menu icons (tab targets) ├─────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────────────┐ │ │ │ │ │ Name: [____________] │ │ │ │ WASD Grid │ │ Class: [____________] │ │ <- Text inputs │ │ (@) │ │ │ │ │ │ │ │ [OK] [Cancel] │ │ <- Button icons │ └─────────────────┘ └─────────────────────────┘ │ │ │ │ Press Tab to cycle focus | WASD moves in grid │ │ Space/Enter activates | Escape closes modals │ └─────────────────────────────────────────────────────────┘ ``` ### Gaps/Issues Discovered 1. **Modifier key state**: No current way to detect Shift+Tab (need to track shift state in key handler) 2. **Stubs incomplete**: `on_enter`, `on_exit`, `hovered` missing from `stubs/mcrfpy.pyi` 3. **Key repeat**: Should key handlers receive repeated events for held keys, or just start/end? ### Files to Create - `tests/demo/screens/focus_system_demo.py` - Main demo file - Consider extracting reusable `FocusManager` to `src/scripts/focus_manager.py` ### Suggested Implementation Order 1. Create basic FocusManager class 2. Implement visual focus indicator (outline change) 3. Add Grid widget with WASD handling 4. Add TextInput widgets 5. Add MenuIcon widgets with modal activation 6. Add Escape handling for modal dismissal 7. Add Tab cycling 8. Polish with on_enter/on_exit hover effects
Author
Owner

Implementation Complete

Commit b6ec0fe adds tests/demo/screens/focus_system_demo.py with a full working implementation.

Checklist Status

  • tests/demo/screens/focus_system_demo.py - working example (810 lines)
  • Demonstrates click-to-focus
  • Demonstrates tab cycling (including Shift+Tab via ModifierTracker)
  • Demonstrates visual focus indicator (blue 3px outline)
  • Routes keyboard input to focused widget
  • Well-commented to serve as documentation
  • Shows how to make elements non-focusable (focusable=False parameter)

Widget Types Implemented

Widget Keys Behavior
FocusableGrid WASD/Arrows Moves player marker within grid bounds
TextInputWidget Printable chars, Backspace, Delete, Home, End, Left, Right Text editing with cursor
MenuIcon Space, Enter Opens associated modal dialog

Bonus Features

  • Modal stack: push_modal() / pop_modal() with Escape handling
  • ModifierTracker: Python workaround for tracking Shift/Ctrl/Alt (see #160)
  • Status display: Shows current focus state and widget-specific info

API Discoveries

During implementation, found some stubs were outdated:

  • Grid uses size=(w, h) and zoom property, not tile_width/tile_height/scale
  • GridPoint has walkable, transparent, entities - no color attribute

These should be updated in a separate stubs fix.

Screenshot

The demo renders correctly with all elements visible and focus indication working.

## Implementation Complete Commit `b6ec0fe` adds `tests/demo/screens/focus_system_demo.py` with a full working implementation. ### Checklist Status - [x] `tests/demo/screens/focus_system_demo.py` - working example (810 lines) - [x] Demonstrates click-to-focus - [x] Demonstrates tab cycling (including Shift+Tab via ModifierTracker) - [x] Demonstrates visual focus indicator (blue 3px outline) - [x] Routes keyboard input to focused widget - [x] Well-commented to serve as documentation - [x] Shows how to make elements non-focusable (`focusable=False` parameter) ### Widget Types Implemented | Widget | Keys | Behavior | |--------|------|----------| | `FocusableGrid` | WASD/Arrows | Moves player marker within grid bounds | | `TextInputWidget` | Printable chars, Backspace, Delete, Home, End, Left, Right | Text editing with cursor | | `MenuIcon` | Space, Enter | Opens associated modal dialog | ### Bonus Features - **Modal stack**: `push_modal()` / `pop_modal()` with Escape handling - **ModifierTracker**: Python workaround for tracking Shift/Ctrl/Alt (see #160) - **Status display**: Shows current focus state and widget-specific info ### API Discoveries During implementation, found some stubs were outdated: - Grid uses `size=(w, h)` and `zoom` property, not `tile_width`/`tile_height`/`scale` - GridPoint has `walkable`, `transparent`, `entities` - no `color` attribute These should be updated in a separate stubs fix. ### Screenshot The demo renders correctly with all elements visible and focus indication working.
john closed this issue 2025-12-28 20:31:11 +00:00
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
john/McRogueFace#143
No description provided.