UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

206 lines 7.11 kB
import { match } from '../events/index.js'; const UNFOCUS = Symbol('UNFOCUS'); export class FocusManager { #didCommit = false; #currentFocusView; #prevFocusView; #lastCommittedFocus; #focusRing = []; #hotKeys = []; #keyboardListeners = []; /** * If the previous focus-view is not mounted, we can clear out the current * focus-view and focus the first that registers. * * If the previous focus-view is mounted but does not request focus, we can't know * that until _after_ the first render. In that case, after render, 'needsRerender' * selects the first focus-view and triggers a re-render. */ reset(isRootView) { if (isRootView) { this.#prevFocusView = this.#currentFocusView; } this.#currentFocusView = undefined; this.#focusRing = []; this.#hotKeys = []; this.#keyboardListeners = []; this.#didCommit = false; } trigger(event) { for (const [view, key] of this.#hotKeys) { if (match(key, event)) { return view.receiveKey(event); } } if (event.name === 'tab' && !event.ctrl && !event.alt && !event.gui) { if (event.shift) { this.prevFocus(); } else { this.nextFocus(); } } else if (this.#currentFocusView && this.#currentFocusView !== UNFOCUS) { this.#currentFocusView.receiveKey(event); } else if (this.#keyboardListeners.length > 0) { // Last registered = innermost view = highest priority this.#keyboardListeners[this.#keyboardListeners.length - 1].receiveKey(event); } } triggerPaste(text) { if (this.#currentFocusView && this.#currentFocusView !== UNFOCUS) { this.#currentFocusView.receivePaste(text); } } /** * Returns whether the current view has focus. */ registerFocus(view, isDefault) { if (!this.#didCommit) { this.#focusRing.push(view); } if (!this.#currentFocusView && this.#prevFocusView === view) { // The previously-focused view is re-registering — restore its focus // regardless of isDefault (it was explicitly focused via tab/click). this.#currentFocusView = view; return true; } else if (!this.#currentFocusView && !this.#prevFocusView && isDefault) { // First render: only isDefault views can claim initial focus. this.#currentFocusView = view; return true; } else if (this.#currentFocusView === view) { return true; } else { return false; } } get currentFocusView() { if (!this.#didCommit) { return this.#prevFocusView !== UNFOCUS ? this.#prevFocusView : undefined; } return this.#currentFocusView && this.#currentFocusView !== UNFOCUS ? this.#currentFocusView : undefined; } get hotKeyViews() { return this.#hotKeys; } registerHotKey(view, key) { if (this.#didCommit) { return; } this.#hotKeys.push([view, key]); } /** * Registers a fallback keyboard listener. When no hotkey matches and no view * has focus, key events are sent to the last (innermost) registered listener. */ registerKeyboard(view) { if (this.#didCommit) { return; } this.#keyboardListeners.push(view); } requestFocus(view) { this.#currentFocusView = view; return true; } unfocus() { this.#currentFocusView = UNFOCUS; } determineFocus() { if (this.#prevFocusView === UNFOCUS && !this.#currentFocusView) { this.#currentFocusView = UNFOCUS; } else if (this.#focusRing.length > 0 && this.#prevFocusView && !this.#currentFocusView) { // The previously-focused view didn't re-register — fall back to the // first view in the ring so focus doesn't disappear. this.#currentFocusView = this.#focusRing[0]; } else if (this.#focusRing.length > 0 && !this.#prevFocusView && !this.#currentFocusView) { // First render with focusable views but no default view claimed focus — // enter the unfocused state so that tab can move into the focus ring. this.#currentFocusView = UNFOCUS; } // Detect focus changes and fire lifecycle events const prev = this.#lastCommittedFocus; const current = this.#currentFocusView; if (prev !== current) { if (prev && prev !== UNFOCUS) { prev.didBlur(); } if (current && current !== UNFOCUS) { current.didFocus(); } this.#lastCommittedFocus = current; return true; } return false; } /** * @return boolean Whether the focus changed */ commit() { this.#didCommit = true; return this.determineFocus(); } #reorderRing() { if (!this.#currentFocusView || this.#currentFocusView === UNFOCUS) { return; } const index = this.#focusRing.indexOf(this.#currentFocusView); if (~index) { const pre = this.#focusRing.slice(0, index); this.#focusRing = this.#focusRing.slice(index).concat(pre); } } prevFocus() { if (!this.#currentFocusView || this.#currentFocusView === UNFOCUS) { this.#currentFocusView = this.#focusRing.at(-1); return; } if (this.#focusRing.length <= 1) { this.#currentFocusView = UNFOCUS; return; } // If focused on the first item, unfocus instead of wrapping. const index = this.#focusRing.indexOf(this.#currentFocusView); if (index === 0) { this.#currentFocusView = UNFOCUS; return; } this.#reorderRing(); const last = this.#focusRing.pop(); this.#focusRing.unshift(last); this.#currentFocusView = last; } nextFocus() { if (!this.#currentFocusView || this.#currentFocusView === UNFOCUS) { this.#currentFocusView = this.#focusRing[0]; return; } if (this.#focusRing.length <= 1) { this.#currentFocusView = UNFOCUS; return; } // If focused on the last item, unfocus instead of wrapping. const index = this.#focusRing.indexOf(this.#currentFocusView); if (index === this.#focusRing.length - 1) { this.#currentFocusView = UNFOCUS; return; } this.#reorderRing(); const first = this.#focusRing.shift(); this.#focusRing.push(first); this.#currentFocusView = this.#focusRing[0]; } } //# sourceMappingURL=FocusManager.js.map