UNPKG

asciitorium

Version:
177 lines (176 loc) 6.33 kB
import { Component } from './Component.js'; import { State } from './State.js'; export class FocusManager { constructor() { this.contextStack = []; this.index = 0; this.hotkeyMap = new Map(); this.hotkeyVisibilityState = new State(false); } pushContext(components) { this.clearFocus(); const focusables = components.filter((c) => c.focusable); this.contextStack.push(focusables); this.index = 0; this.setFocus(0); } popContext() { this.clearFocus(); this.contextStack.pop(); this.index = 0; this.setFocus(0); } get currentContext() { return this.contextStack[this.contextStack.length - 1] || []; } setFocus(index) { this.index = index; const current = this.currentContext[this.index]; if (current) { current.hasFocus = true; } } clearFocus() { const current = this.currentContext[this.index]; if (current) { current.hasFocus = false; } } focusNext() { if (this.currentContext.length === 0) return; this.clearFocus(); this.index = (this.index + 1) % this.currentContext.length; this.setFocus(this.index); } // TODO: this doesn't work, still goes forward focusPrevious() { if (this.currentContext.length === 0) return; this.clearFocus(); this.index = (this.index - 1 + this.currentContext.length) % this.currentContext.length; this.setFocus(this.index); } /** * Unified key handling for both hotkeys and navigation */ handleKey(key) { // Get currently focused component const current = this.currentContext[this.index]; // Check if focused component is in capture mode if (current?.captureModeActive) { // Only bypass keys (Tab, Shift+Tab) escape capture mode if (!Component.BYPASS_KEYS.includes(key)) { // Send key directly to component, skip hotkey processing const handled = current.handleEvent?.(key); return handled ?? false; } } // Handle hotkey visibility toggle first if (key === '`' || key === 'F1') { this.hotkeyVisibilityState.value = !this.hotkeyVisibilityState.value; return true; } // Handle direct hotkeys const hotkeyComponent = this.hotkeyMap.get(key.toLowerCase()); if (hotkeyComponent) { // Focus the component const targetIndex = this.currentContext.indexOf(hotkeyComponent); if (targetIndex !== -1) { this.clearFocus(); this.index = targetIndex; this.setFocus(targetIndex); // For buttons: trigger action if (hotkeyComponent.onClick) { hotkeyComponent.onClick(); } return true; } } // Handle navigation keys if (key === 'Tab') { this.focusNext(); return true; } if (key === 'Shift+Tab') { this.focusPrevious(); return true; } // Pass other keys to focused component const handled = current?.handleEvent?.(key); return handled ?? false; } reset(component) { const focusables = this.getFocusableDescendants(component).filter((c) => c.focusable); this.contextStack = [focusables]; this.index = 0; this.setFocus(0); this.buildHotkeyMap(focusables); } refresh(component) { const currentlyFocused = this.currentContext[this.index]; const focusables = this.getFocusableDescendants(component).filter((c) => c.focusable); this.contextStack = [focusables]; this.buildHotkeyMap(focusables); // Try to find the same component in the new list if (currentlyFocused) { const newIndex = focusables.indexOf(currentlyFocused); if (newIndex !== -1) { this.index = newIndex; this.setFocus(newIndex); return; } } // Fallback to index 0 if current component not found this.index = 0; this.setFocus(0); } /** * Build hotkey map from focusable components */ buildHotkeyMap(focusableComponents) { this.hotkeyMap.clear(); for (const component of focusableComponents) { if (component.hotkey) { if (this.isReservedKey(component.hotkey)) { console.error(`ERROR: Hotkey '${component.hotkey}' is reserved for system navigation and cannot be used. Reserved keys: ${Array.from(FocusManager.RESERVED_KEYS).join(', ')}`); } else { const key = component.hotkey.toLowerCase(); if (this.hotkeyMap.has(key)) { console.warn(`Hotkey '${component.hotkey}' is already assigned to another component`); } else { this.hotkeyMap.set(key, component); } } } } } isReservedKey(key) { return FocusManager.RESERVED_KEYS.has(key) || FocusManager.RESERVED_KEYS.has(key.toLowerCase()); } getFocusableDescendants(parent) { const focusables = []; for (const child of parent.getChildren()) { // Skip invisible components for focus navigation if (!child.visible) continue; if (child.focusable) { focusables.push(child); } // Now all components can have children, so recursively check all focusables.push(...this.getFocusableDescendants(child)); } return focusables; } } // Reserved keys that should not be used for hotkeys FocusManager.RESERVED_KEYS = new Set([ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // Arrow keys 'Tab', 'Enter', ' ', 'Escape', // Navigation and control 'Backspace', 'Delete', 'Home', 'End', // Text editing 'PageUp', 'PageDown', // Scrolling ]);