zombiebox
Version:
ZombieBox is a JavaScript framework for development of Smart TV and STB applications
643 lines (534 loc) • 12.5 kB
JavaScript
/*
* This file is part of the ZombieBox package.
*
* Copyright © 2012-2019, Interfaced
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import app from 'generated/app';
import Direction, {Value} from '../geometry/direction';
import IFocusable from '../interfaces/i-focusable';
import IKeyHandler from '../interfaces/i-key-handler';
import InputDispatcher from '../input-dispatcher';
import Keys from '../device/input/keys';
import EventPublisher from '../events/event-publisher';
import IStateful from '../history/interfaces/i-stateful';
import Point from '../geometry/point';
import Rect from '../geometry/rect';
import INavigation from './interfaces/i-navigation';
import IWidget from './interfaces/i-widget';
import SpatialNavigation from './navigation/spatial-navigation';
/**
* @implements {IKeyHandler}
* @implements {IStateful}
* @implements {IFocusable}
*/
export default class Container extends EventPublisher {
/**
*/
constructor() {
super();
const defaultRecursionFilter = () => true;
/**
* The array of widgets - basic user-controlled elements
* @type {Array<IWidget>}
* @protected
*/
this._widgets = [];
/**
* The array of widgets - basic user-controlled elements
* @type {!Object<string, IWidget>}
* @protected
*/
this._namedWidgets = {};
/**
* @type {INavigation}
* @protected
*/
this._navigation = this._createNavigation();
/**
* Current active widget
* @type {IWidget}
* @protected
*/
this._activeWidget = null;
/**
* @type {?IWidget}
* @protected
*/
this._defaultWidget = null;
/**
* @type {string}
* @protected
*/
this._theme = '';
/**
* @type {boolean}
* @protected
*/
this._navigationDebug = false;
/**
* @type {RecursionFilter}
* @protected
*/
this._themeRecursionFilter = defaultRecursionFilter;
/**
* Defines whether the container is focused
* @type {boolean}
* @private
*/
this._focused = false;
/**
* @const {string}
*/
this.THEME_NONE = '';
/**
* @const {string}
*/
this.THEME_DEFAULT = 'default';
this._onWidgetWantToFocus = this._onWidgetWantToFocus.bind(this);
this._setupDefaultTheme();
}
/**
* @override
*/
processKey(zbKey, event) {
if (this._doDip(zbKey, event)) {
return true;
}
return this._processKey(zbKey, event);
}
/**
* @override
*/
takeSnapshot() {
return this._takeWidgetsSnapshot();
}
/**
* @override
*/
focus(prevRect) {
this._focused = true;
if (this.hasWidgets()) {
let activeWidget = this.getActiveWidget();
if (activeWidget && !activeWidget.isFocusable()) {
activeWidget = null;
}
if (!activeWidget && this._defaultWidget && this._defaultWidget.isFocusable()) {
activeWidget = this._defaultWidget;
}
if (!activeWidget && prevRect && this._navigation instanceof SpatialNavigation) {
const sortedByDistanceWidgets = this._navigation.sortWidgetsByDistance(prevRect)
.filter((widget) => widget.isFocusable());
activeWidget = sortedByDistanceWidgets[0] || null;
}
this.activateWidget(activeWidget || this.getFirstFocusableWidget(), prevRect);
}
}
/**
* @override
*/
blur() {
this._focused = false;
if (this._activeWidget) {
this._activeWidget.blur();
}
}
/**
* @override
*/
isFocused() {
return this._focused;
}
/**
* @param {IWidget} widget
*/
setDefaultWidget(widget) {
this.isMyWidget(widget);
this._defaultWidget = widget;
}
/**
* Get widget index
* @param {IWidget} widget
* @return {number}
*/
widgetIndex(widget) {
return this._widgets.indexOf(widget);
}
/**
* Membership test
* @param {IWidget} widget
* @throws {Error}
*/
isMyWidget(widget) {
if (this.widgetIndex(widget) === -1) {
throw new Error('Foreign widget!');
}
}
/**
* @return {?IWidget}
*/
getActiveWidget() {
return this._activeWidget;
}
/**
* @return {?IWidget}
*/
getFirstFocusableWidget() {
if (this.hasWidgets()) {
for (let i = 0; i < this._widgets.length; i++) {
if (this._widgets[i].isFocusable()) {
return this._widgets[i];
}
}
}
return null;
}
/**
* @return {!Object<string, IWidget>}
*/
getNamedWidgets() {
return this._namedWidgets;
}
/**
* @return {Array<IWidget>}
*/
getWidgets() {
return this._widgets;
}
/**
* @return {boolean}
*/
hasWidgets() {
return this._widgets.length > 0;
}
/**
* @return {boolean}
*/
hasVisibleWidgets() {
return this.getVisibleWidgets().length > 0;
}
/**
* @return {Array<IWidget>}
*/
getVisibleWidgets() {
return this._widgets.filter((widget) => widget.isVisible());
}
/**
* @return {boolean}
*/
hasFocusableWidgets() {
return this.getFocusableWidgets().length > 0;
}
/**
* @return {Array<IWidget>}
*/
getFocusableWidgets() {
return this._widgets.filter((widget) => widget.isFocusable());
}
/**
* Append widget to the container
* @param {IWidget} widget
* @param {string=} name
* @return {boolean}
*/
appendWidget(widget, name) {
this._widgets.push(widget);
this._navigation.addWidget(widget);
if (name) {
this._namedWidgets[name] = widget;
}
widget.on(widget.EVENT_WANT_FOCUS, this._onWidgetWantToFocus);
const widgetContainer = widget.getContainer();
if (widgetContainer) {
this._getInputDispatcher().addMouseHoverArea(widget, widgetContainer);
}
this._applyThemeToChildWidget(widget);
return true;
}
/**
* @param {string} theme
* @param {RecursionFilter=} recursionFilter
*/
setTheme(theme, recursionFilter) {
this._theme = theme;
if (recursionFilter) {
this._themeRecursionFilter = recursionFilter;
}
for (let i = 0; i < this._widgets.length; i++) {
this._applyThemeToChildWidget(this._widgets[i]);
}
}
/**
* @return {string}
*/
getTheme() {
return this._theme;
}
/**
* @param {IWidget} widget
* @return {boolean}
*/
removeWidget(widget) {
const index = this.widgetIndex(widget);
if (index === -1) {
return false;
}
this._widgets.splice(index, 1);
this._navigation.removeWidget(widget);
widget.off(widget.EVENT_WANT_FOCUS, this._onWidgetWantToFocus);
const widgetContainer = widget.getContainer();
if (widgetContainer) {
this._getInputDispatcher().removeMouseHoverArea(widget, widgetContainer);
}
for (const name in this._namedWidgets) {
if (this._namedWidgets.hasOwnProperty(name)) {
if (this._namedWidgets[name] === widget) {
delete this._namedWidgets[name];
}
}
}
if (this.getActiveWidget() === widget) {
this.activateWidget(null);
}
return true;
}
/**
* Remove all appended widgets
*/
removeAllWidgets() {
for (let widgetIndex = this._widgets.length; widgetIndex--;) {
this.removeWidget(this._widgets[widgetIndex]);
}
this.activateWidget(null);
}
/**
* @param {?IWidget} widget
* @return {string}
*/
findNameByWidget(widget) {
for (const p in this._namedWidgets) {
if (this._namedWidgets.hasOwnProperty(p) && this._namedWidgets[p] === widget) {
return p;
}
}
return '';
}
/**
* @param {string} name
* @return {?IWidget}
*/
findWidgetByName(name) {
return this._namedWidgets[name] || null;
}
/**
* @param {IWidget} widget
* @param {Value} direction
* @param {?IWidget} widgetTo When null is given this mean "stop navigation"
* @param {boolean=} bidirectional
*/
setNavigationRule(widget, direction, widgetTo, bidirectional = false) {
this._navigation.setRule(widget, direction, widgetTo);
if (bidirectional) {
const opposite = (new Direction(direction))
.invert()
.getKey();
this._navigation.setRule(widgetTo, opposite, widget);
}
}
/**
* @param {IWidget} widget
* @param {Value} direction
*/
removeNavigationRule(widget, direction) {
this._navigation.removeRule(widget, direction);
}
/**
* Drop all navigation rules
*/
dropNavigation() {
this._navigation.clearRules();
}
/**
* Set widget active
* @param {?IWidget} widget
* @param {?Rect=} prevRect
* @return {boolean}
*/
activateWidget(widget, prevRect) {
const selfFocused = this.isFocused();
if (widget === this._activeWidget) {
if (selfFocused && widget && !widget.isFocused()) {
widget.focus(prevRect);
}
return true;
}
if (widget) {
this.isMyWidget(widget);
if (!widget.isFocusable()) {
return false;
}
}
if (this._activeWidget) {
this._activeWidget.blur();
}
const oldWidget = this._activeWidget;
const oldFocusedRect = oldWidget ? oldWidget.getFocusedRect() : null;
this._activeWidget = widget;
if (this._activeWidget && selfFocused) {
this._activeWidget.focus(prevRect || oldFocusedRect);
}
return true;
}
/**
* Set widget active
* @param {Rect|Point} rectOrPoint
* @return {boolean}
*/
activateNearestWidget(rectOrPoint) {
let widgets = [];
if (this._navigation instanceof SpatialNavigation) {
widgets = this._navigation.sortWidgetsByDistance(rectOrPoint);
}
return widgets.length > 0 && this.activateWidget(widgets[0]);
}
/**
* @param {boolean} on
*/
setNavigationDebug(on) {
this._navigationDebug = on;
}
/**
* @return {INavigation}
* @protected
*/
_createNavigation() {
return new SpatialNavigation();
}
/**
* @return {function()}
* @protected
*/
_takeWidgetsSnapshot() {
/** @type {!Object} */
const snapshots = {};
const namedWidgets = this.getNamedWidgets();
for (const p of Object.keys(namedWidgets)) {
snapshots[p] = namedWidgets[p].takeSnapshot();
}
const activeWidgetName = this.findNameByWidget(this.getActiveWidget());
return () => {
const existsWidgets = Object.keys(this.getNamedWidgets());
Object.keys(snapshots)
.filter((widgetName) => existsWidgets.indexOf(widgetName) !== -1)
.map((widgetName) => snapshots[widgetName]());
const activeWidget = this.findWidgetByName(activeWidgetName);
if (activeWidget) {
this.activateWidget(activeWidget);
}
};
}
/**
* @return {InputDispatcher}
* @protected
*/
_getInputDispatcher() {
return app.getInputDispatcher();
}
/**
* Activates widget using its predefined direction
* @param {Value} direction
* @return {boolean}
* @protected
*/
_activateByDirection(direction) {
const lastActiveWidget = this._activeWidget;
const widgets = this._navigation.findWidgets(this._activeWidget, direction);
if (!widgets) {
// Stop navigation
return true;
}
let widget;
let activated = false;
while (!activated && widgets.length) {
widget = widgets.shift();
activated = this.activateWidget(widget);
}
if (widget && this._inNavigationDebug() && this._navigation instanceof SpatialNavigation) {
this._navigation.visualize(lastActiveWidget, widget, widgets, direction);
}
return activated;
}
/**
* @param {Keys} zbKey
* @param {(KeyboardEvent|WheelEvent)=} event
* @return {boolean}
* @protected
*/
_doDip(zbKey, event) {
if (this.hasWidgets()) {
const activeWidget = this.getActiveWidget();
if (activeWidget && activeWidget.isVisible() && activeWidget.isEnabled()) {
return activeWidget.processKey(zbKey, event);
}
}
return false;
}
/**
* @param {Keys} zbKey
* @param {(KeyboardEvent|WheelEvent)=} event
* @return {boolean}
* @protected
*/
_processKey(zbKey, event) { // eslint-disable-line no-unused-vars
switch (zbKey) {
case Keys.LEFT:
return this._activateByDirection(Value.LEFT);
case Keys.UP:
return this._activateByDirection(Value.UP);
case Keys.RIGHT:
return this._activateByDirection(Value.RIGHT);
case Keys.DOWN:
return this._activateByDirection(Value.DOWN);
}
return false;
}
/**
*
* @param {string} event
* @param {IWidget} widget
* @protected
*/
_onWidgetWantToFocus(event, widget) {
this.activateWidget(widget);
}
/**
* @protected
*/
_setupDefaultTheme() {
this._theme = this.THEME_NONE;
this._themeRecursionFilter = () => true;
this.setTheme(this.THEME_DEFAULT);
}
/**
* @param {IWidget} widget
* @protected
*/
_applyThemeToChildWidget(widget) {
if (this._themeRecursionFilter(widget)) {
widget.setTheme(this._theme, this._themeRecursionFilter);
}
}
/**
* @return {boolean}
* @private
*/
_inNavigationDebug() {
return this._navigationDebug;
}
}
/**
* @typedef {function(IWidget): boolean}
*/
export let RecursionFilter;