Merge pull request #379 from cognitivegears/feature/full_menu_view

New view type - Full Menu View
This commit is contained in:
Bryan Ashby 2022-01-24 13:17:07 -07:00 committed by GitHub
commit 79f9ff8c6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 806 additions and 9 deletions

511
core/full_menu_view.js Normal file
View File

@ -0,0 +1,511 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const MenuView = require('./menu_view.js').MenuView;
const ansi = require('./ansi_term.js');
const strUtil = require('./string_util.js');
const formatString = require('./string_format');
const pipeToAnsi = require('./color_codes.js').pipeToAnsi;
// deps
const util = require('util');
const _ = require('lodash');
exports.FullMenuView = FullMenuView;
function FullMenuView(options) {
options.cursor = options.cursor || 'hide';
options.justify = options.justify || 'left';
MenuView.call(this, options);
// Initialize paging
this.pages = [];
this.currentPage = 0;
this.initDefaultWidth();
// we want page up/page down by default
if (!_.isObject(options.specialKeyMap)) {
Object.assign(this.specialKeyMap, {
'page up': ['page up'],
'page down': ['page down'],
});
}
this.autoAdjustHeightIfEnabled = () => {
if (this.autoAdjustHeight) {
this.dimens.height = (this.items.length * (this.itemSpacing + 1)) - (this.itemSpacing);
this.dimens.height = Math.min(this.dimens.height, this.client.term.termHeight - this.position.row);
}
this.positionCacheExpired = true;
};
this.autoAdjustHeightIfEnabled();
this.clearPage = () => {
let width = this.dimens.width;
if (this.oldDimens) {
if (this.oldDimens.width > width) {
width = this.oldDimens.width;
}
delete this.oldDimens;
}
for (let i = 0; i < this.dimens.height; i++) {
const text = `${strUtil.pad(this.fillChar, width, this.fillChar, 'left')}`;
this.client.term.write(`${ansi.goto(this.position.row + i, this.position.col)}${this.getSGR()}${text}`);
}
}
this.cachePositions = () => {
if (this.positionCacheExpired) {
// first, clear the page
this.clearPage();
this.autoAdjustHeightIfEnabled();
this.pages = []; // reset
// Calculate number of items visible per column
this.itemsPerRow = Math.floor(this.dimens.height / (this.itemSpacing + 1));
// handle case where one can fit at the end
if (this.dimens.height > (this.itemsPerRow * (this.itemSpacing + 1))) {
this.itemsPerRow++;
}
// Final check to make sure we don't try to display more than we have
if (this.itemsPerRow > this.items.length) {
this.itemsPerRow = this.items.length;
}
let col = this.position.col;
let row = this.position.row;
const spacer = new Array(this.itemHorizSpacing + 1).join(this.fillChar);
let itemInRow = 0;
let itemInCol = 0;
let pageStart = 0;
for (let i = 0; i < this.items.length; ++i) {
itemInRow++;
this.items[i].row = row;
this.items[i].col = col;
this.items[i].itemInRow = itemInRow;
row += this.itemSpacing + 1;
// have to calculate the max length on the last entry
if (i == this.items.length - 1) {
let maxLength = 0;
for (let j = 0; j < this.itemsPerRow; j++) {
if (this.items[i - j].col != this.items[i].col) {
break;
}
const itemLength = this.items[i - j].text.length;
if (itemLength > maxLength) {
maxLength = itemLength;
}
}
// set length on each item in the column
for (let j = 0; j < this.itemsPerRow; j++) {
if (this.items[i - j].col != this.items[i].col) {
break;
}
this.items[i - j].fixedLength = maxLength;
}
// Check if we have room for this column
// skip for column 0, we need at least one
if (itemInCol != 0 && (col + maxLength > this.dimens.width)) {
// save previous page
this.pages.push({ start: pageStart, end: i - itemInRow });
// fix the last column processed
for (let j = 0; j < this.itemsPerRow; j++) {
if (this.items[i - j].col != col) {
break;
}
this.items[i - j].col = this.position.col;
pageStart = i - j;
}
}
// Since this is the last page, save the current page as well
this.pages.push({ start: pageStart, end: i });
}
// also handle going to next column
else if (itemInRow == this.itemsPerRow) {
itemInRow = 0;
// restart row for next column
row = this.position.row;
let maxLength = 0;
for (let j = 0; j < this.itemsPerRow; j++) {
// TODO: handle complex items
let itemLength = this.items[i - j].text.length;
if (itemLength > maxLength) {
maxLength = itemLength;
}
}
// set length on each item in the column
for (let j = 0; j < this.itemsPerRow; j++) {
this.items[i - j].fixedLength = maxLength;
}
// Check if we have room for this column in the current page
// skip for first column, we need at least one
if (itemInCol != 0 && (col + maxLength > this.dimens.width)) {
// save previous page
this.pages.push({ start: pageStart, end: i - this.itemsPerRow });
// restart page start for next page
pageStart = i - this.itemsPerRow + 1;
// reset
col = this.position.col;
itemInRow = 0;
// fix the last column processed
for (let j = 0; j < this.itemsPerRow; j++) {
this.items[i - j].col = col;
}
}
// increment the column
col += maxLength + spacer.length;
itemInCol++;
}
// Set the current page if the current item is focused.
if (this.focusedItemIndex === i) {
this.currentPage = this.pages.length;
}
}
}
this.positionCacheExpired = false;
};
this.drawItem = (index) => {
const item = this.items[index];
if (!item) {
return;
}
const cached = this.getRenderCacheItem(index, item.focused);
if (cached) {
return this.client.term.write(`${ansi.goto(item.row, item.col)}${cached}`);
}
let text;
let sgr;
if (item.focused && this.hasFocusItems()) {
const focusItem = this.focusItems[index];
text = focusItem ? focusItem.text : item.text;
sgr = '';
} else if (this.complexItems) {
text = pipeToAnsi(formatString(item.focused && this.focusItemFormat ? this.focusItemFormat : this.itemFormat, item));
sgr = this.focusItemFormat ? '' : (index === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR());
} else {
text = strUtil.stylizeString(item.text, item.focused ? this.focusTextStyle : this.textStyle);
sgr = (index === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR());
}
let renderLength = strUtil.renderStringLength(text);
if (this.hasTextOverflow() && (item.col + renderLength) > this.dimens.width) {
text = strUtil.renderSubstr(text, 0, this.dimens.width - (item.col + this.textOverflow.length)) + this.textOverflow;
}
let padLength = Math.min(item.fixedLength + 1, this.dimens.width);
text = `${sgr}${strUtil.pad(text, padLength, this.fillChar, this.justify)}${this.getSGR()}`;
this.client.term.write(`${ansi.goto(item.row, item.col)}${text}`);
this.setRenderCacheItem(index, text, item.focused);
};
}
util.inherits(FullMenuView, MenuView);
FullMenuView.prototype.redraw = function() {
FullMenuView.super_.prototype.redraw.call(this);
this.cachePositions();
if (this.items.length) {
for (let i = this.pages[this.currentPage].start; i <= this.pages[this.currentPage].end; ++i) {
this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
}
}
};
FullMenuView.prototype.setHeight = function(height) {
this.oldDimens = Object.assign({}, this.dimens);
FullMenuView.super_.prototype.setHeight.call(this, height);
this.positionCacheExpired = true;
this.autoAdjustHeight = false;
};
FullMenuView.prototype.setWidth = function(width) {
this.oldDimens = Object.assign({}, this.dimens);
FullMenuView.super_.prototype.setWidth.call(this, width);
this.positionCacheExpired = true;
};
FullMenuView.prototype.setTextOverflow = function(overflow) {
FullMenuView.super_.prototype.setTextOverflow.call(this, overflow);
this.positionCacheExpired = true;
}
FullMenuView.prototype.setPosition = function(pos) {
FullMenuView.super_.prototype.setPosition.call(this, pos);
this.positionCacheExpired = true;
};
FullMenuView.prototype.setFocus = function(focused) {
FullMenuView.super_.prototype.setFocus.call(this, focused);
this.positionCacheExpired = true;
this.autoAdjustHeight = false;
this.redraw();
};
FullMenuView.prototype.setFocusItemIndex = function(index) {
FullMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex
};
FullMenuView.prototype.onKeyPress = function(ch, key) {
if (key) {
if (this.isKeyMapped('up', key.name)) {
this.focusPrevious();
} else if (this.isKeyMapped('down', key.name)) {
this.focusNext();
} else if (this.isKeyMapped('left', key.name)) {
this.focusPreviousColumn();
} else if (this.isKeyMapped('right', key.name)) {
this.focusNextColumn();
} else if (this.isKeyMapped('page up', key.name)) {
this.focusPreviousPageItem();
} else if (this.isKeyMapped('page down', key.name)) {
this.focusNextPageItem();
} else if (this.isKeyMapped('home', key.name)) {
this.focusFirst();
} else if (this.isKeyMapped('end', key.name)) {
this.focusLast();
}
}
FullMenuView.super_.prototype.onKeyPress.call(this, ch, key);
};
FullMenuView.prototype.getData = function() {
const item = this.getItem(this.focusedItemIndex);
return _.isString(item.data) ? item.data : this.focusedItemIndex;
};
FullMenuView.prototype.setItems = function(items) {
// if we have items already, save off their drawing area so we don't leave fragments at redraw
if (this.items && this.items.length) {
this.oldDimens = Object.assign({}, this.dimens);
}
FullMenuView.super_.prototype.setItems.call(this, items);
this.positionCacheExpired = true;
};
FullMenuView.prototype.removeItem = function(index) {
if (this.items && this.items.length) {
this.oldDimens = Object.assign({}, this.dimens);
}
FullMenuView.super_.prototype.removeItem.call(this, index);
this.positionCacheExpired = true;
};
FullMenuView.prototype.focusNext = function() {
if (this.items.length - 1 === this.focusedItemIndex) {
this.clearPage();
this.focusedItemIndex = 0;
this.currentPage = 0;
}
else {
this.focusedItemIndex++;
if (this.focusedItemIndex > this.pages[this.currentPage].end) {
this.clearPage();
this.currentPage++;
}
}
this.redraw();
FullMenuView.super_.prototype.focusNext.call(this);
};
FullMenuView.prototype.focusPrevious = function() {
if (0 === this.focusedItemIndex) {
this.clearPage();
this.focusedItemIndex = this.items.length - 1;
this.currentPage = this.pages.length - 1;
}
else {
this.focusedItemIndex--;
if (this.focusedItemIndex < this.pages[this.currentPage].start) {
this.clearPage();
this.currentPage--;
}
}
this.redraw();
FullMenuView.super_.prototype.focusPrevious.call(this);
};
FullMenuView.prototype.focusPreviousColumn = function() {
const currentRow = this.items[this.focusedItemIndex].itemInRow;
this.focusedItemIndex = this.focusedItemIndex - this.itemsPerRow;
if (this.focusedItemIndex < 0) {
this.clearPage();
const lastItemRow = this.items[this.items.length - 1].itemInRow;
if (lastItemRow > currentRow) {
this.focusedItemIndex = this.items.length - (lastItemRow - currentRow) - 1;
}
else {
// can't go to same column, so go to last item
this.focusedItemIndex = this.items.length - 1;
}
// set to last page
this.currentPage = this.pages.length - 1;
}
else {
if (this.focusedItemIndex < this.pages[this.currentPage].start) {
this.clearPage();
this.currentPage--;
}
}
this.redraw();
// TODO: This isn't specific to Previous, may want to replace in the future
FullMenuView.super_.prototype.focusPrevious.call(this);
};
FullMenuView.prototype.focusNextColumn = function() {
const currentRow = this.items[this.focusedItemIndex].itemInRow;
this.focusedItemIndex = this.focusedItemIndex + this.itemsPerRow;
if (this.focusedItemIndex > this.items.length - 1) {
this.focusedItemIndex = currentRow - 1;
this.currentPage = 0;
this.clearPage();
}
else if (this.focusedItemIndex > this.pages[this.currentPage].end) {
this.clearPage();
this.currentPage++;
}
this.redraw();
// TODO: This isn't specific to Next, may want to replace in the future
FullMenuView.super_.prototype.focusNext.call(this);
};
FullMenuView.prototype.focusPreviousPageItem = function() {
// handle first page
if (this.currentPage == 0) {
// Do nothing, page up shouldn't go down on last page
return;
}
this.currentPage--;
this.focusedItemIndex = this.pages[this.currentPage].start;
this.clearPage();
this.redraw();
return FullMenuView.super_.prototype.focusPreviousPageItem.call(this);
};
FullMenuView.prototype.focusNextPageItem = function() {
// handle last page
if (this.currentPage == this.pages.length - 1) {
// Do nothing, page up shouldn't go down on last page
return;
}
this.currentPage++;
this.focusedItemIndex = this.pages[this.currentPage].start;
this.clearPage();
this.redraw();
return FullMenuView.super_.prototype.focusNextPageItem.call(this);
};
FullMenuView.prototype.focusFirst = function() {
this.currentPage = 0;
this.focusedItemIndex = 0;
this.clearPage();
this.redraw();
return FullMenuView.super_.prototype.focusFirst.call(this);
};
FullMenuView.prototype.focusLast = function() {
this.currentPage = this.pages.length - 1;
this.focusedItemIndex = this.pages[this.currentPage].end;
this.clearPage();
this.redraw();
return FullMenuView.super_.prototype.focusLast.call(this);
};
FullMenuView.prototype.setFocusItems = function(items) {
FullMenuView.super_.prototype.setFocusItems.call(this, items);
this.positionCacheExpired = true;
};
FullMenuView.prototype.setItemSpacing = function(itemSpacing) {
FullMenuView.super_.prototype.setItemSpacing.call(this, itemSpacing);
this.positionCacheExpired = true;
};
FullMenuView.prototype.setJustify = function(justify) {
FullMenuView.super_.prototype.setJustify.call(this, justify);
this.positionCacheExpired = true;
};
FullMenuView.prototype.setItemHorizSpacing = function(itemHorizSpacing) {
FullMenuView.super_.prototype.setItemHorizSpacing.call(this, itemHorizSpacing);
this.positionCacheExpired = true;
};

View File

@ -8,6 +8,7 @@ const EditTextView = require('./edit_text_view.js').EditTextView;
const ButtonView = require('./button_view.js').ButtonView;
const VerticalMenuView = require('./vertical_menu_view.js').VerticalMenuView;
const HorizontalMenuView = require('./horizontal_menu_view.js').HorizontalMenuView;
const FullMenuView = require('./full_menu_view.js').FullMenuView;
const SpinnerMenuView = require('./spinner_menu_view.js').SpinnerMenuView;
const ToggleMenuView = require('./toggle_menu_view.js').ToggleMenuView;
const MaskEditTextView = require('./mask_edit_text_view.js').MaskEditTextView;
@ -27,7 +28,7 @@ function MCIViewFactory(client) {
}
MCIViewFactory.UserViewCodes = [
'TL', 'ET', 'ME', 'MT', 'PL', 'BT', 'VM', 'HM', 'SM', 'TM', 'KE',
'TL', 'ET', 'ME', 'MT', 'PL', 'BT', 'VM', 'HM', 'FM', 'SM', 'TM', 'KE',
//
// XY is a special MCI code that allows finding positions
@ -164,6 +165,18 @@ MCIViewFactory.prototype.createFromMCI = function(mci) {
view = new HorizontalMenuView(options);
break;
// Full Menu
case 'FM' :
setOption(0, 'itemSpacing');
setOption(1, 'itemHorizSpacing');
setOption(2, 'justify');
setOption(3, 'textStyle');
setFocusOption(0, 'focusTextStyle');
view = new FullMenuView(options);
break;
case 'SM' :
setOption(0, 'textStyle');
setOption(1, 'justify');

View File

@ -38,14 +38,14 @@ function MenuView(options) {
this.focusedItemIndex = options.focusedItemIndex || 0;
this.focusedItemIndex = this.items.length >= this.focusedItemIndex ? this.focusedItemIndex : 0;
this.itemSpacing = _.isNumber(options.itemSpacing) ? options.itemSpacing : 0;
this.itemSpacing = _.isNumber(options.itemSpacing) ? options.itemSpacing : 0;
this.itemHorizSpacing = _.isNumber(options.itemHorizSpacing) ? options.itemHorizSpacing : 0;
// :TODO: probably just replace this with owner draw / pipe codes / etc. more control, less specialization
this.focusPrefix = options.focusPrefix || '';
this.focusSuffix = options.focusSuffix || '';
this.fillChar = miscUtil.valueWithDefault(options.fillChar, ' ').substr(0, 1);
this.justify = options.justify || 'none';
this.hasFocusItems = function() {
return !_.isUndefined(self.focusItems);
@ -68,6 +68,15 @@ function MenuView(options) {
util.inherits(MenuView, View);
MenuView.prototype.setTextOverflow = function(overflow) {
this.textOverflow = overflow;
this.invalidateRenderCache();
}
MenuView.prototype.hasTextOverflow = function() {
return this.textOverflow != undefined;
}
MenuView.prototype.setItems = function(items) {
if(Array.isArray(items)) {
this.sorted = false;
@ -253,19 +262,32 @@ MenuView.prototype.setItemSpacing = function(itemSpacing) {
this.positionCacheExpired = true;
};
MenuView.prototype.setItemHorizSpacing = function(itemHorizSpacing) {
itemHorizSpacing = parseInt(itemHorizSpacing);
assert(_.isNumber(itemHorizSpacing));
this.itemHorizSpacing = itemHorizSpacing;
this.positionCacheExpired = true;
};
MenuView.prototype.setPropertyValue = function(propName, value) {
switch(propName) {
case 'itemSpacing' : this.setItemSpacing(value); break;
case 'itemHorizSpacing' : this.setItemHorizSpacing(value); break;
case 'items' : this.setItems(value); break;
case 'focusItems' : this.setFocusItems(value); break;
case 'hotKeys' : this.setHotKeys(value); break;
case 'textOverflow' : this.setTextOverflow(value); break;
case 'hotKeySubmit' : this.hotKeySubmit = value; break;
case 'justify' : this.justify = value; break;
case 'justify' : this.setJustify(value); break;
case 'fillChar' : this.setFillChar(value); break;
case 'focusItemIndex' : this.focusedItemIndex = value; break;
case 'itemFormat' :
case 'focusItemFormat' :
this[propName] = value;
// if there is a cache currently, invalidate it
this.invalidateRenderCache();
break;
case 'sort' : this.setSort(value); break;
@ -274,6 +296,17 @@ MenuView.prototype.setPropertyValue = function(propName, value) {
MenuView.super_.prototype.setPropertyValue.call(this, propName, value);
};
MenuView.prototype.setFillChar = function(fillChar) {
this.fillChar = miscUtil.valueWithDefault(fillChar, ' ').substr(0, 1);
this.invalidateRenderCache();
}
MenuView.prototype.setJustify = function(justify) {
this.justify = justify;
this.invalidateRenderCache();
this.positionCacheExpired = true;
}
MenuView.prototype.setHotKeys = function(hotKeys) {
if(_.isObject(hotKeys)) {
if(this.caseInsensitiveHotKeys) {

View File

@ -186,7 +186,7 @@ View.prototype.setPropertyValue = function(propName, value) {
case 'height' : this.setHeight(value); break;
case 'width' : this.setWidth(value); break;
case 'focus' : this.setFocus(value); break;
case 'focus' : this.setFocusProperty(value); break;
case 'text' :
if('setText' in this) {
@ -252,10 +252,16 @@ View.prototype.redraw = function() {
this.client.term.write(ansi.goto(this.position.row, this.position.col));
};
View.prototype.setFocus = function(focused) {
enigAssert(this.acceptsFocus, 'View does not accept focus');
View.prototype.setFocusProperty = function(focused) {
// Either this should accept focus, or the focus should be false
enigAssert(this.acceptsFocus || !focused, 'View does not accept focus');
this.hasFocus = focused;
};
View.prototype.setFocus = function(focused) {
// Call separate method to differentiate between a value set as a
// property vs focus programmatically called.
this.setFocusProperty(focused);
this.restoreCursor();
};

View File

@ -49,6 +49,8 @@
- [General]({{ site.baseurl }}{% link art/general.md %})
- [Themes]({{ site.baseurl }}{% link art/themes.md %})
- [MCI Codes]({{ site.baseurl }}{% link art/mci.md %})
- Views
- [Full Menu]({{ site.baseurl }}{% link art/views/full_menu_view.md %})
- Servers
- Login Servers

View File

@ -123,6 +123,7 @@ a Vertical Menu (`%VM`): Old-school BBSers may recognize this as a lightbar menu
| `BT` | Button | A button | ...it's a button |
| `VM` | Vertical Menu | A vertical menu | AKA a vertical lightbar; Useful for lists |
| `HM` | Horizontal Menu | A horizontal menu | AKA a horizontal lightbar |
| `FM` | Full Menu | A menu that can go both vertical and horizontal. See [Full Menu](views/full_menu_view.md) |
| `SM` | Spinner Menu | A spinner input control | Select *one* from multiple options |
| `TM` | Toggle Menu | A toggle menu | Commonly used for Yes/No style input |
| `KE` | Key Entry | A *single* key input control | Think hotkeys |

View File

@ -0,0 +1,231 @@
---
layout: page
title: Full Menu View
---
## Full Menu View
A full menu view supports displaying a list of times on a screen in a very configurable manner. A full menu view supports either a single row or column of values, similar to Horizontal Menu (HM) and Vertical Menu (VM), or in multiple columns.
## General Information
Items can be selected on a menu via the cursor keys, Page Up, Page Down, Home, and End, or by selecting them via a `hotKey` - see ***Hot Keys*** below.
:information_source: A full menu view is defined with a percent (%) and the characters FM, followed by the view number. For example: `%FM1`
:information_source: See [Art](../art.md) for general information on how to use views and common configuration properties available for them.
### Properties
| Property | Description |
|-------------|--------------|
| `textStyle` | Sets the standard (non-focus) text style. See **Text Styles** in [Art](../art.md) |
| `focusTextStyle` | Sets focus text style. See **Text Styles** in [Art](../art.md)|
| `itemSpacing` | Used to separate items vertically in the menu |
| `itemHorizSpacing` | Used to separate items horizontally in the menu |
| `height` | Sets the height of views to display multiple items vertically (default 1) |
| `width` | Sets the width of a view to display one or more columns horizontally (default 15)|
| `focus` | If set to `true`, establishes initial focus |
| `submit` | If set to `true` any `accept` action upon this view will submit the encompassing **form** |
| `hotKeys` | Sets hot keys to activate specific items. See **Hot Keys** below |
| `hotKeySubmit` | Set to submit a form on hotkey selection |
| `argName` | Sets the argument name for this selection in the form |
| `justify` | Sets the justification of each item in the list. Options: left (default), right, center |
| `itemFormat` | Sets the format for a list entry. See **Entry Formatting** in [Art](../art.md) |
| `fillChar` | Specifies a character to fill extra space in the menu with. Defaults to an empty space |
| `textOverflow` | If a single column cannot be displayed due to `width`, set overflow characters. See **Text Overflow** below |
| `items` | List of items to show in the menu. See **Items** below.
| `focusItemFormat` | Sets the format for a focused list entry. See **Entry Formatting** in [Art](../art.md) |
### Hot Keys
A set of `hotKeys` are used to allow the user to press a character on the keyboard to select that item, and optionally submit the form.
Example:
```
hotKeys: { A: 0, B: 1, C: 2, D: 3 }
hotKeySubmit: true
```
This would select and submit the first item if `A` is typed, second if `B`, etc.
### Items
A full menu, similar to other menus, take a list of items to display in the menu. For example:
```
items: [
{
text: First Item
data: first
}
{
text: Second Item
data: second
}
]
```
### Text Overflow
The `textOverflow` option is used to specify what happens when a text string is too long to fit in the `width` defined. Note, because columns are automatically calculated, this can only occur when the text is too long to fit the `width` using a single column.
:information_source: If `textOverflow` is not specified at all, a menu can become wider than the `width` if needed to display a single column.
:information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed
:information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...`
## Examples
### A simple vertical menu - similar to VM
![Example](../../assets/images/full_menu_view_example1.gif "Vertical menu")
<details>
<summary>Configuration fragment (expand to view)</summary>
```
FM1: {
submit: true
argName: navSelect
width: 1
items: [
{
text: login
data: login
}
{
text: apply
data: new user
}
{
text: about
data: about
}
{
text: log off
data: logoff
}
]
}
```
</details>
### A simple horizontal menu - similar to HM
![Example](../../assets/images/full_menu_view_example2.gif "Horizontal menu")
<details>
<summary>Configuration fragment (expand to view)</summary>
```
FM2: {
focus: true
height: 1
width: 60 // set as desired
submit: true
argName: navSelect
items: [
"prev", "next", "details", "toggle queue", "rate", "help", "quit"
]
}
```
</details>
### A multi-column navigation menu with hotkeys
![Example](../../assets/images/full_menu_view_example3.gif "Multi column menu")
<details>
<summary>Configuration fragment (expand to view)</summary>
```
FM1: {
focus: true
height: 6
width: 60
submit: true
argName: navSelect
hotKeys: { M: 0, E: 1, D: 2 ,F: 3,!: 4, A: 5, C: 6, Y: 7, S: 8, R: 9, O: 10, L:11, U:12, W: 13, B:14, G:15, T: 16, Q:17 }
hotKeySubmit: true
items: [
{
text: M) message area
data: message
}
{
text: E) private email
data: email
}
{
text: D) doors
data: doors
}
{
text: F) file base
data: files
}
{
text: !) global newscan
data: newscan
}
{
text: A) achievements
data: achievements
}
{
text: C) configuration
data: config
}
{
text: Y) user stats
data: userstats
}
{
text: S) system stats
data: systemstats
}
{
text: R) rumorz
data: rumorz
}
{
text: O) onelinerz
data: onelinerz
}
{
text: L) last callers
data: callers
}
{
text: U) user list
data: userlist
}
{
text: W) whos online
data: who
}
{
text: B) bbs list
data: bbslist
}
{
text: G) node-to-node messages
data: nodemessages
}
{
text: T) multi relay chat
data: mrc
}
{
text: Q) quit
data: quit
}
]
}
```
</details>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB