diff --git a/core/full_menu_view.js b/core/full_menu_view.js
new file mode 100644
index 00000000..212b4d15
--- /dev/null
+++ b/core/full_menu_view.js
@@ -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;
+};
diff --git a/core/mci_view_factory.js b/core/mci_view_factory.js
index 037121e5..d6c37865 100644
--- a/core/mci_view_factory.js
+++ b/core/mci_view_factory.js
@@ -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');
diff --git a/core/menu_view.js b/core/menu_view.js
index d9016153..9c750aba 100644
--- a/core/menu_view.js
+++ b/core/menu_view.js
@@ -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) {
diff --git a/core/view.js b/core/view.js
index fdf78916..c31ccca1 100644
--- a/core/view.js
+++ b/core/view.js
@@ -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();
};
diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md
index 42cc7bff..cd635928 100644
--- a/docs/_includes/nav.md
+++ b/docs/_includes/nav.md
@@ -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
diff --git a/docs/art/mci.md b/docs/art/mci.md
index 174643a4..79135554 100644
--- a/docs/art/mci.md
+++ b/docs/art/mci.md
@@ -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 |
@@ -233,4 +234,4 @@ Suppose a format object contains the following elements: `userName` and `affils`

-:bulb: Remember that a Python [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language) style syntax is available for widths, alignment, number prevision, etc. as well. A number can be made to be more human readable for example: `{byteSize:,}` may yield "1,123,456".
\ No newline at end of file
+:bulb: Remember that a Python [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language) style syntax is available for widths, alignment, number prevision, etc. as well. A number can be made to be more human readable for example: `{byteSize:,}` may yield "1,123,456".
diff --git a/docs/art/views/full_menu_view.md b/docs/art/views/full_menu_view.md
new file mode 100644
index 00000000..03db2cbb
--- /dev/null
+++ b/docs/art/views/full_menu_view.md
@@ -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
+
+
+
+
+Configuration fragment (expand to view)
+
+```
+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
+ }
+ ]
+}
+
+```
+
+
+### A simple horizontal menu - similar to HM
+
+
+
+
+Configuration fragment (expand to view)
+
+```
+FM2: {
+ focus: true
+ height: 1
+ width: 60 // set as desired
+ submit: true
+ argName: navSelect
+ items: [
+ "prev", "next", "details", "toggle queue", "rate", "help", "quit"
+ ]
+}
+```
+
+
+### A multi-column navigation menu with hotkeys
+
+
+
+
+
+Configuration fragment (expand to view)
+
+```
+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
+ }
+ ]
+}
+```
+
+
diff --git a/docs/assets/images/full_menu_view_example1.gif b/docs/assets/images/full_menu_view_example1.gif
new file mode 100644
index 00000000..7366d813
Binary files /dev/null and b/docs/assets/images/full_menu_view_example1.gif differ
diff --git a/docs/assets/images/full_menu_view_example2.gif b/docs/assets/images/full_menu_view_example2.gif
new file mode 100644
index 00000000..e02c60a4
Binary files /dev/null and b/docs/assets/images/full_menu_view_example2.gif differ
diff --git a/docs/assets/images/full_menu_view_example3.gif b/docs/assets/images/full_menu_view_example3.gif
new file mode 100644
index 00000000..b57375fa
Binary files /dev/null and b/docs/assets/images/full_menu_view_example3.gif differ