From 1b70405e032b243fa53128c5c79f3a9ecc98e89a Mon Sep 17 00:00:00 2001
From: nightfox <>
Date: Sat, 4 Apr 2020 22:06:12 +0000
Subject: [PATCH] When displaying the menu items, rather than directly
 referring to the list of menu item objects, there are now a couple of
 functions (mainly for internal use), NumItems() (which returns the number of
 items) and GetItem() (which returns a specific menu item object).  The intent
 is that calling code can replace these two functions in the DDLightbarMenu
 object to have the DDLightbarMenu effectively access a different list of
 items rather than its own list of items.  This can be more efficient, for
 instance, in scripts that are working with a Synchronet messagebase, to avoid
 calling DDLightbarMenu's Add() function to add/copy a bunch of data, which
 can take significant time (i.e., if a messagebase contains a lot of
 messages).

The colors.itemColor and colors.selectedItemColor properties in a DDLightbarMenu object can now be either a string (with Synchronet color/attribute codes for the item text) or an array with objects specifying color/attribute codes for different parts of an item's text string displayed on the menu.
Item color arrays: Currently, colors.itemColor and colors.seletedItemColor within a DDLightbarMenu object can be either a string (containing color/attribute codes) or an array with color/attribute codes for different sections of the item strings to display in the menu.  The array is to contain objects with the following properties:
 start: The index of the first character in the item string to apply the colors to
 end: One past the last character index in the string to apply the colors to
 attrs: The Synchronet attribute codes to apply to the section of the item string
For the last item, the 'end' property can be -1, 0, or greater than the length of the item to apply the color/attribute codes to the rest of the string.
---
 exec/load/dd_lightbar_menu.js | 456 +++++++++++++++++++++++++++-------
 1 file changed, 364 insertions(+), 92 deletions(-)

diff --git a/exec/load/dd_lightbar_menu.js b/exec/load/dd_lightbar_menu.js
index bd5f817807..2435138448 100644
--- a/exec/load/dd_lightbar_menu.js
+++ b/exec/load/dd_lightbar_menu.js
@@ -27,19 +27,41 @@ SetPos()
 SetSize()
 GetVal()
 AddAdditionalSelectItemKeys()
+SetBorderChars()
+SetColors()
 
 To change the colors used for displaying the items, you can change the values
 in the colors object within the DDLightbarMenu object.  These are the current
 supported colors:
 itemColor: The color to use for non-selected items (current default is white
-           on blue)
+           on blue).  This can be a string (with the color/attribute values)
+           or an array to specify colors for different sections of the item
+		   text to display in the menu.  See the note on item color arrays
+		   below.
 selectedItemColor: The color to use for selected items (current default is blue
-                   on white)
+                   on white).  This can be a string (with the color/attribute values)
+                   or an array to specify colors for different sections of the item
+		           text to display in the menu.  See the note on item color arrays
+		           below.
 itemTextCharHighlightColor: The color of a highlighted non-space character in an
                             item text (specified by having a & in the item text).
 							It's important not to specify a "\1n" in here in case
 							the item text should have a background color.
 borderColor: The color for the borders (if borders are enabled)
+You can also call SetColors() and pass in a JS object with any or all of the
+above properties to set the colors internally in the DDLightbarMenu object.
+
+Item color arrays: Currently, colors.itemColor and colors.seletedItemColor within
+a DDLightbarMenu object can be either a string (containing color/attribute codes)
+or an array with color/attribute codes for different sections of the item strings
+to display in the menu.  The array is to contain objects with the following
+properties:
+start: The index of the first character in the item string to apply the colors to
+end: One past the last character index in the string to apply the colors to
+attrs: The Synchronet attribute codes to apply to the section of the item string
+For the last item, the 'end' property can be -1, 0, or greater than the length
+of the item to apply the color/attribute codes to the rest of the string.
+
 
 By default, the menu selection will wrap around to the beginning/end when using
 the down/up arrows.  That behavior can be disabled by setting the wrapNavigation
@@ -158,19 +180,55 @@ setting the following properties of the borderChars object:
   right: The character to use for the right border
 For example:
 lbMenu.borderChars.upperLeft = "\xDA"; // Single-line upper-left character
+Alternately, you can call the SetBorderChars() function and pass in a JS object
+with any or all of the above properties to set those values internally in the
+DDLightbarMenu object.
 
 If you want hotkeys to be case-sensitive, you can set the hotkeyCaseSensitive
 property to true (it is false by default).  For example:
 lbMenu.hotkeyCaseSensitive = true;
 
 To add additional key characters as quit keys (in addition to ESC), call
-AddAdditionalQuitKeys() with an array of keys as strings.  For example:
-lbMenu.AddAdditionalQuitKeys(["q", "Q"]);
+AddAdditionalQuitKeys() with a string of characters.  For example:
+lbMenu.AddAdditionalQuitKeys("qQ");
 
 To enable the border and set top and bottom border text:
 lbMenu.borderEnabled = true;
 lbMenu.topBorderText = "Options";
 lbMenu.bottomBorderText = "Enter = Select";
+
+
+For a more advanced usage, if you have another large list of items you want
+to use in the menu instead of the menu's own list of items, you can replace
+the NumItems and GetItem functions in the menu object and write your own
+versions that access a different list of items.  This can be useful, for instance,
+if you're working with a Synchronet messagebase (which may include a large number
+of messages), so you can avoid the time taken to add those items to a DDLightbarMenu.
+NumItems() needs to return the number of items in the list.  GetItem() takes an item
+index as a parameter and needs to return an item object that is compatible with
+DDLightbarMenu.  You can get a default item object by calling MakeItemWithRetval()
+or MakeItemWithTextAndRetval(), then change its text and retval properties as
+needed, then return the item object.  In the item object, the 'text' property
+is the text to display in the menu, and the 'retval' proprety is the value to return
+when the user chooses that item.
+An example (assuming the lightbar menu object is called lbMenu):
+lbMenu.NumItems = function() {
+	// Do your own thing to get the number of items in your list.
+	// ...
+	// Assuming myNumItems is the number of items in your list:
+	return myNumItems;
+};
+lbMenu.GetItem = function(pItemIndex) {
+	// Get a default item object from the menu with an initial return value of -1
+	var menuItemObj = this.MakeItemWithRetval(-1);
+	// Do your own thing to get the item text and return value for the menu.
+	// ...
+	// Assuming itemText is the text to display in the menu and itemRetval is
+	// the return value to return from the menu:
+	menuItemObj.text = itemText;
+	menuItemObj.retval = itemRetval;
+	return menuItemObj; // The DDLightbarMenu object will use this when displaying the menu
+};
 */
 
 if (typeof(require) === "function")
@@ -260,8 +318,8 @@ function DDLightbarMenu(pX, pY, pWidth, pHeight)
 	this.borderEnabled = false;
 	this.drawnAlready = false;
 	this.colors = {
-		itemColor: "\1n\1w\1" + "4",
-		selectedItemColor: "\1n\1b\1" + "7",
+		itemColor: "\1n\1w\1" + "4", // Can be either a string or an array specifying colors within the item
+		selectedItemColor: "\1n\1b\1" + "7", // Can be either a string or an array specifying colors within the item
 		itemTextCharHighlightColor: "\1y\1h",
 		borderColor: "\1n\1b",
 		scrollbarScrollBlockColor: "\1h\1w",
@@ -307,6 +365,8 @@ function DDLightbarMenu(pX, pY, pWidth, pHeight)
 	this.Add = DDLightbarMenu_Add;
 	this.Remove = DDLightbarMenu_Remove;
 	this.RemoveAllItems = DDLightbarMenu_RemoveAllItems;
+	this.NumItems = DDLightbarMenu_NumItems;
+	this.GetItem = DDLightbarMenu_GetItem;
 	this.SetPos = DDLightbarMenu_SetPos;
 	this.SetSize = DDLightbarMenu_SetSize;
 	this.SetWidth = DDLightbarMenu_SetWidth;
@@ -322,6 +382,7 @@ function DDLightbarMenu(pX, pY, pWidth, pHeight)
 	this.RemoveAllItemHotkeys = DDLightbarMenu_RemoveAllItemHotkeys;
 	this.GetVal = DDLightbarMenu_GetVal;
 	this.SetBorderChars = DDLightbarMenu_SetBorderChars;
+	this.SetColors = DDLightbarMenu_SetColors;
 	this.GetNumItemsPerPage = DDLightbarMenu_GetNumItemsPerPage;
 	this.GetTopItemIdxToTopOfLastPage = DDLightbarMenu_GetTopItemIdxToTopOfLastPage;
 	this.SetTopItemIdxToTopOfLastPage = DDLightbarMenu_SetTopItemIdxToTopOfLastPage;
@@ -337,6 +398,8 @@ function DDLightbarMenu(pX, pY, pWidth, pHeight)
 	this.CalcScrollbarSolidBlockStartRow = DDLightbarMenu_CalcScrollbarSolidBlockStartRow;
 	this.UpdateScrollbarWithHighlightedItem = DDLightbarMenu_UpdateScrollbarWithHighlightedItem;
 	this.CanShowAllItemsInWindow = DDLightbarMenu_CanShowAllItemsInWindow;
+	this.MakeItemWithTextAndRetval = DDLightbarMenu_MakeItemWithTextAndRetval;
+	this.MakeItemWithRetval = DDLightbarMenu_MakeItemWithRetval;
 
 	// Set some things based on the parameters passed in
 	if ((typeof(pX) == "number") && (typeof(pY) == "number"))
@@ -418,6 +481,25 @@ function DDLightbarMenu_RemoveAllItems()
 	this.topItemIdx = 0;
 }
 
+// Returns the number of items in the menu
+function DDLightbarMenu_NumItems()
+{
+	return this.items.length;
+}
+
+// Returns an item from the list
+//
+// Parameters:
+//  pItemIndex: The index of the item to get
+//
+// Return value: The item (or null if pItemIndex is invalid)
+function DDLightbarMenu_GetItem(pItemIndex)
+{
+	if ((pItemIndex < 0) || (pItemIndex >= this.items.length))
+		return null;
+	return this.items[pItemIndex];
+}
+
 // Sets the menu's upper-left corner position
 //
 // Parameters:
@@ -526,7 +608,7 @@ function DDLightbarMenu_Draw(pSelectedItemIndexes, pDrawBorders, pDrawScrollbar)
 	// so that we can use that space to display the item numbers.
 	if (this.numberedMode)
 	{
-		this.itemNumLen = this.items.length.toString().length;
+		this.itemNumLen = this.NumItems().toString().length;
 		itemLen -= this.itemNumLen;
 		--itemLen; // Have a space for separation between the numbers and items
 	}
@@ -534,7 +616,7 @@ function DDLightbarMenu_Draw(pSelectedItemIndexes, pDrawBorders, pDrawScrollbar)
 	// Write the menu items, only up to the height of the menu
 	var numPossibleItems = (this.borderEnabled ? this.size.height - 2 : this.size.height);
 	var numItemsWritten = 0;
-	for (var idx = this.topItemIdx; (idx < this.items.length) && (numItemsWritten < numPossibleItems); ++idx)
+	for (var idx = this.topItemIdx; (idx < this.NumItems()) && (numItemsWritten < numPossibleItems); ++idx)
 	{
 		console.gotoxy(curPos.x, curPos.y++);
 		var showMultiSelectMark = (this.multiSelect && (typeof(pSelectedItemIndexes) == "object") && pSelectedItemIndexes.hasOwnProperty(idx));
@@ -650,7 +732,8 @@ function DDLightbarMenu_DrawBorder()
 //             at the end of the item's text.
 function DDLightbarMenu_WriteItem(pIdx, pItemLen, pHighlight, pSelected)
 {
-	if ((pIdx >= 0) && (pIdx < this.items.length))
+	var numItems = this.NumItems();
+	if ((pIdx >= 0) && (pIdx < numItems))
 	{
 		var itemLen = 0;
 		if (typeof(pItemLen) == "number")
@@ -670,7 +753,7 @@ function DDLightbarMenu_WriteItem(pIdx, pItemLen, pHighlight, pSelected)
 			// so that we can use that space to display the item numbers.
 			if (this.numberedMode)
 			{
-				this.itemNumLen = this.items.length.toString().length;
+				this.itemNumLen = numItems.toString().length;
 				itemLen -= this.itemNumLen;
 				--itemLen; // Have a space for separation between the numbers and items
 			}
@@ -684,11 +767,11 @@ function DDLightbarMenu_WriteItem(pIdx, pItemLen, pHighlight, pSelected)
 		var selected = (typeof(pSelected) == "boolean" ? pSelected : false);
 
 		// Get the item text, and truncate it to the displayable item width
-		var itemText = this.items[pIdx].text;
+		var itemText = this.GetItem(pIdx).text;
 		if (itemTextDisplayableLen(itemText, this.ampersandHotkeysInItems) > itemLen)
 			itemText = itemText.substr(0, itemLen);
 		// Add the item color to the text
-		itemText = itemColor + itemText;
+		itemText = addAttrsToString(itemText, itemColor);
 		// If ampersandHotkeysInItems is true, see if there's an ampersand in
 		// the item text.  If so, we'll want to highlight the next character
 		// with a different color.
@@ -822,7 +905,8 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 {
 	this.lastUserInput = null;
 
-	if (this.items.length == 0)
+	var numItems = this.NumItems();
+	if (numItems == 0)
 		return null;
 
 	var draw = (typeof(pDraw) == "boolean" ? pDraw : true);
@@ -885,10 +969,10 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 						console.gotoxy(this.pos.x, this.pos.y+this.selectedItemIdx-this.topItemIdx);
 					this.WriteItem(this.selectedItemIdx, null, false, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
 					// Go to the last item and scroll to the bottom if necessary
-					this.selectedItemIdx = this.items.length - 1;
+					this.selectedItemIdx = numItems - 1;
 					var oldTopItemIdx = this.topItemIdx;
 					var numItemsPerPage = (this.borderEnabled ? this.size.height - 2 : this.size.height);
-					this.topItemIdx = this.items.length - numItemsPerPage;
+					this.topItemIdx = numItems - numItemsPerPage;
 					if (this.topItemIdx < 0)
 						this.topItemIdx = 0;
 					if (this.topItemIdx != oldTopItemIdx)
@@ -907,7 +991,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 		}
 		else if ((this.lastUserInput == KEY_DOWN) || (this.lastUserInput == KEY_RIGHT))
 		{
-			if (this.selectedItemIdx < this.items.length-1)
+			if (this.selectedItemIdx < numItems-1)
 			{
 				// Draw the current item in regular colors
 				if (this.borderEnabled)
@@ -980,19 +1064,36 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 					this.selectedItemIdx = 0;
 				this.Draw(selectedItemIndexes);
 			}
+			else
+			{
+				// The top index is the top index for the last page.
+				// If wrapping is enabled, then go back to the first page.
+				if (this.wrapNavigation)
+				{
+					var topIndexForLastPage = numItems - numItemsPerPage;
+					if (topIndexForLastPage < 0)
+						topIndexForLastPage = 0;
+					else if (topIndexForLastPage >= numItems)
+						topIndexForLastPage = numItems - 1;
+
+					this.topItemIdx = topIndexForLastPage;
+					this.selectedItemIdx = topIndexForLastPage;
+					this.Draw(selectedItemIndexes);
+				}
+			}
 		}
 		else if (this.lastUserInput == KEY_PAGE_DOWN)
 		{
 			var numItemsPerPage = (this.borderEnabled ? this.size.height - 2 : this.size.height);
 			// Figure out how many pages are needed to list all the items
-			//var numPages = Math.ceil(this.items.length / this.size.height);
+			//var numPages = Math.ceil(numItems / this.size.height);
 			// Figure out the top index for the last page.
 			//var topIndexForLastPage = (this.size.height * numPages) - this.size.height;
-			var topIndexForLastPage = this.items.length - numItemsPerPage;
+			var topIndexForLastPage = numItems - numItemsPerPage;
 			if (topIndexForLastPage < 0)
 				topIndexForLastPage = 0;
-			else if (topIndexForLastPage >= this.items.length)
-				topIndexForLastPage = this.items.length - 1;
+			else if (topIndexForLastPage >= numItems)
+				topIndexForLastPage = numItems - 1;
 			if (topIndexForLastPage != this.topItemIdx)
 			{
 				// Update the selected & top item indexes
@@ -1004,6 +1105,17 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 					this.topItemIdx = topIndexForLastPage;
 				this.Draw(selectedItemIndexes);
 			}
+			else
+			{
+				// The top index is the top index for the last page.
+				// If wrapping is enabled, then go back to the first page.
+				if (this.wrapNavigation)
+				{
+					this.topItemIdx = 0;
+					this.selectedItemIdx = 0;
+				}
+				this.Draw(selectedItemIndexes);
+			}
 		}
 		else if (this.lastUserInput == KEY_HOME)
 		{
@@ -1044,12 +1156,12 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 		{
 			// Go to the last item in the list
 			var numItemsPerPage = this.GetNumItemsPerPage();
-			if (this.selectedItemIdx < this.items.length-1)
+			if (this.selectedItemIdx < numItems-1)
 			{
-				var lastPossibleTop = this.items.length - numItemsPerPage;
+				var lastPossibleTop = numItems - numItemsPerPage;
 				if (lastPossibleTop < 0)
 					lastPossibleTop = 0;
-				var lastItemIdx = this.items.length - 1;
+				var lastItemIdx = numItems - 1;
 				// If the last item index is below the current page, then scroll.
 				// Otherwise, draw more efficiently by drawing the current item in
 				// regular colors and the last item in highlighted colors.
@@ -1070,7 +1182,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 						console.gotoxy(this.pos.x, this.pos.y+this.selectedItemIdx-this.topItemIdx);
 					this.WriteItem(this.selectedItemIdx, null, false, selectedItemIndexes.hasOwnProperty(this.selectedItemIdx));
 					this.selectedItemIdx = this.topItemIdx + numItemsPerPage - 1;
-					if (this.selectedItemIdx >= this.items.length)
+					if (this.selectedItemIdx >= numItems)
 						this.selectedItemIdx = lastItemIdx;
 					// Draw the new current item in selected colors
 					if (this.borderEnabled)
@@ -1093,7 +1205,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 					selectedItemIndexes[this.selectedItemIdx] = true;
 			}
 			else
-				retVal = this.items[this.selectedItemIdx].retval;
+				retVal = this.GetItem(this.selectedItemIdx).retval;
 			continueOn = false;
 		}
 		else if (this.lastUserInput == " ")
@@ -1135,8 +1247,9 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 					// If any of the item text is right at the end, then display it.  Otherwise,
 					// display 2 spaces.
 					var textToPrint = "  ";
-					if (this.items[this.selectedItemIdx].text.length >= this.size.width)
-						textToPrint = this.items[this.selectedItemIdx].text.substr(this.size.width-2, 2);
+					var theItem = this.GetItem(this.selectedItemIdx);
+					if (theItem.text.length >= this.size.width)
+						textToPrint = theItem.text.substr(this.size.width-2, 2);
 					console.print(this.colors.selectedItemColor + textToPrint + "\1n");
 				}
 			}
@@ -1167,7 +1280,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 			printf("\1n%" + this.size.width + "s", ""); // Blank out what might be on the screen already
 			console.gotoxy(promptX, promptY);
 			console.print("\1cItem #: \1h");
-			var userEnteredItemNum = console.getnum(this.items.length);
+			var userEnteredItemNum = console.getnum(numItems);
 			// Blank out the input prompt
 			console.gotoxy(promptX, promptY);
 			printf("\1n%" + this.size.width + "s", "");
@@ -1193,7 +1306,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 				}
 				else
 				{
-					retVal = this.items[this.selectedItemIdx].retval;
+					retVal = this.GetItem(this.selectedItemIdx).retval;
 					continueOn = false;
 				}
 			}
@@ -1204,15 +1317,16 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 		{
 			// See if the user pressed a hotkey set for one of the items.  If so,
 			// then choose that item.
-			for (var i = 0; i < this.items.length; ++i)
+			for (var i = 0; i < numItems; ++i)
 			{
-				for (var h = 0; h < this.items[i].hotkeys.length; ++h)
+				var theItem = this.GetItem(i);
+				for (var h = 0; h < theItem.hotkeys.length; ++h)
 				{
 					var userPressedHotkey = false;
 					if (this.hotkeyCaseSensitive)
-						userPressedHotkey = (this.lastUserInput == this.items[i].hotkeys[h]);
+						userPressedHotkey = (this.lastUserInput == theItem.hotkeys[h]);
 					else
-						userPressedHotkey = (this.lastUserInput.toUpperCase() == this.items[i].hotkeys[h].toUpperCase());
+						userPressedHotkey = (this.lastUserInput.toUpperCase() == theItem.hotkeys[h].toUpperCase());
 					if (userPressedHotkey)
 					{
 						if (this.multiSelect)
@@ -1231,7 +1345,7 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 						}
 						else
 						{
-							retVal = this.items[i].retval;
+							retVal = theItem.retval;
 							this.selectedItemIdx = i;
 							continueOn = false;
 						}
@@ -1252,13 +1366,15 @@ function DDLightbarMenu_GetVal(pDraw, pSelectedItemIndexes)
 	{
 		userChoices = [];
 		for (var prop in selectedItemIndexes)
-			userChoices.push(this.items[prop].retval);
+			userChoices.push(this.GetItem(prop).retval);
 	}
 
 	return (this.multiSelect ? userChoices : retVal);
 }
 
-// Sets the characters to use for drawing the border
+// Sets the characters to use for drawing the border.  Takes an object specifying
+// the values to set, but does not overwrite the whole borderChars object in the
+// menu object.
 //
 // Parameters:
 //  pBorderChars: An object with the following properties:
@@ -1284,6 +1400,37 @@ function DDLightbarMenu_SetBorderChars(pBorderChars)
 	}
 }
 
+// Sets the colors to use with the menu.  Takes an object specifying the values
+// to set, but does not overwrite the whole colors object in the menu object.
+//
+// Parameters:
+//  pColors: An object with the following properties:
+//           itemColor: The color to use for non-highlighted items
+//           selectedItemColor: The color to use for selected items
+//           itemTextCharHighlightColor: The color to use for a highlighted
+//                                       non-space character in an item text
+//                                       (specified by having a & in the item
+//                                       text).
+//                                       It's important not to specify a "\1n"
+//                                       in here in case the item text should
+//                                       have a background color.
+//           borderColor: The color to use for the border
+//           scrollbarScrollBlockColor: The color to use for the scrollbar block
+//           scrollbarBGColor: The color to use for the scrollbar background
+function DDLightbarMenu_SetColors(pColors)
+{
+	if (typeof(pColors) != "object")
+		return;
+
+	var colorPropNames = [ "itemColor", "selectedItemColor", "itemTextCharHighlightColor",
+	                       "borderColor", "scrollbarScrollBlockColor", "scrollbarBGColor" ];
+	for (var i = 0; i < colorPropNames.length; ++i)
+	{
+		if (pColors.hasOwnProperty(colorPropNames[i]))
+			this.colors[colorPropNames[i]] = pColors[colorPropNames[i]];
+	}
+}
+
 // Returns the number of (possible) items per page
 function DDLightbarMenu_GetNumItemsPerPage()
 {
@@ -1299,7 +1446,7 @@ function DDLightbarMenu_GetTopItemIdxToTopOfLastPage()
 	var numItemsPerPage = this.size.height;
 	if (this.borderEnabled)
 		numItemsPerPage -= 2;
-	var topItemIndex = this.items.length - numItemsPerPage;
+	var topItemIndex = this.NumItems() - numItemsPerPage;
 	if (topItemIndex < 0)
 		topItemIndex = 0;
 	return topItemIndex;
@@ -1311,7 +1458,7 @@ function DDLightbarMenu_SetTopItemIdxToTopOfLastPage()
 	var numItemsPerPage = this.size.height;
 	if (this.borderEnabled)
 		numItemsPerPage -= 2;
-	this.topItemIdx = this.items.length - numItemsPerPage;
+	this.topItemIdx = this.NumItems() - numItemsPerPage;
 	if (this.topItemIdx < 0)
 		this.topItemIdx = 0;
 }
@@ -1423,51 +1570,6 @@ function DDLightbarMenu_DisplayInitialScrollbar(pSolidBlockStartRow, pNumSolidBl
 	}
 }
 
-// Calculates the starting row for the solid blocks on the scrollbar
-//
-// Return value: The starting row for the solid blocks on the scrollbar
-function DDLightbarMenu_CalcScrollbarSolidBlockStartRow()
-{
-	var scrollbarStartY = this.pos.y;
-	var scrollbarHeight = this.size.height;
-	if (this.borderEnabled)
-	{
-		++scrollbarStartY;
-		scrollbarHeight -= 2;
-	}
-	var scrollbarBottomY = scrollbarStartY + scrollbarHeight - 1;
-	var solidBlockStartRow = scrollbarStartY;
-	if (this.items.length > 0)
-	{
-		var scrollbarFraction = this.selectedItemIdx / this.items.length;
-		var scrollbarStartRow = scrollbarStartY + Math.floor(scrollbarHeight * scrollbarFraction);
-		solidBlockStartRow = scrollbarStartRow - Math.floor(this.scrollbarInfo.numSolidScrollBlocks / 2);
-		// Don't let the solid blocks go above the starting screen row or below the ending
-		// screen row of the scrollbar
-		if (solidBlockStartRow < scrollbarStartY)
-			solidBlockStartRow = scrollbarStartY;
-		else if (solidBlockStartRow + this.scrollbarInfo.numSolidScrollBlocks > scrollbarBottomY)
-			solidBlockStartRow = scrollbarBottomY - this.scrollbarInfo.numSolidScrollBlocks + 1;
-	}
-	return solidBlockStartRow;
-}
-
-// Updates the scrollbar position based on the currently-selected
-// item index, this.selectedItemIdx.
-function DDLightbarMenu_UpdateScrollbarWithHighlightedItem()
-{
-	var solidBlockStartRow = this.CalcScrollbarSolidBlockStartRow();
-	if (solidBlockStartRow != this.scrollbarInfo.solidBlockLastStartRow)
-		this.UpdateScrollbar(solidBlockStartRow, this.scrollbarInfo.solidBlockLastStartRow, this.scrollbarInfo.numSolidScrollBlocks);
-	this.scrollbarInfo.solidBlockLastStartRow = solidBlockStartRow;
-}
-
-function DDLightbarMenu_CanShowAllItemsInWindow()
-{
-	var pageHeight = (this.borderEnabled ? this.size.height - 2 : this.size.height);
-	return (this.items.length <= pageHeight);
-}
-
 // For the DigDistMsgReader class: Updates the scrollbar for a message, for use
 // in enhanced reader mode.  This does only the necessary character updates to
 // minimize the number of characters that need to be updated on the screen.
@@ -1581,6 +1683,85 @@ function DDLightbarMenu_UpdateScrollbar(pNewStartRow, pOldStartRow, pNumSolidBlo
 	}
 }
 
+// Calculates the starting row for the solid blocks on the scrollbar
+//
+// Return value: The starting row for the solid blocks on the scrollbar
+function DDLightbarMenu_CalcScrollbarSolidBlockStartRow()
+{
+	var scrollbarStartY = this.pos.y;
+	var scrollbarHeight = this.size.height;
+	if (this.borderEnabled)
+	{
+		++scrollbarStartY;
+		scrollbarHeight -= 2;
+	}
+	var scrollbarBottomY = scrollbarStartY + scrollbarHeight - 1;
+	var solidBlockStartRow = scrollbarStartY;
+	var numMenuItems = this.NumItems();
+	if (numMenuItems > 0)
+	{
+		var scrollbarFraction = this.selectedItemIdx / numMenuItems;
+		var scrollbarStartRow = scrollbarStartY + Math.floor(scrollbarHeight * scrollbarFraction);
+		solidBlockStartRow = scrollbarStartRow - Math.floor(this.scrollbarInfo.numSolidScrollBlocks / 2);
+		// Don't let the solid blocks go above the starting screen row or below the ending
+		// screen row of the scrollbar
+		if (solidBlockStartRow < scrollbarStartY)
+			solidBlockStartRow = scrollbarStartY;
+		else if (solidBlockStartRow + this.scrollbarInfo.numSolidScrollBlocks > scrollbarBottomY)
+			solidBlockStartRow = scrollbarBottomY - this.scrollbarInfo.numSolidScrollBlocks + 1;
+	}
+	return solidBlockStartRow;
+}
+
+// Updates the scrollbar position based on the currently-selected
+// item index, this.selectedItemIdx.
+function DDLightbarMenu_UpdateScrollbarWithHighlightedItem()
+{
+	var solidBlockStartRow = this.CalcScrollbarSolidBlockStartRow();
+	if (solidBlockStartRow != this.scrollbarInfo.solidBlockLastStartRow)
+		this.UpdateScrollbar(solidBlockStartRow, this.scrollbarInfo.solidBlockLastStartRow, this.scrollbarInfo.numSolidScrollBlocks);
+	this.scrollbarInfo.solidBlockLastStartRow = solidBlockStartRow;
+}
+
+function DDLightbarMenu_CanShowAllItemsInWindow()
+{
+	var pageHeight = (this.borderEnabled ? this.size.height - 2 : this.size.height);
+	return (this.NumItems() <= pageHeight);
+}
+
+// Makes an item object that is compatible with DDLightbarMenu, with a given
+// item text and return value.
+//
+// Parameters:
+//  pText: The text to show in the menu for the item
+//  pRetval: The return value of the item when the user selects it from the menu
+//
+// Return value: An object with the given text & return value compatible with DDLightbarMenu
+function DDLightbarMenu_MakeItemWithTextAndRetval(pText, pRetval)
+{
+	return {
+		text: pText,
+		retval: pRetval,
+		hotkeys: []
+	};
+}
+
+// Makes an item object that is compatible with DDLightbarMenu, with a given
+// return value.
+//
+// Parameters:
+//  pRetval: The return value of the item when the user selects it from the menu
+//
+// Return value: An object with the given return value compatible with DDLightbarMenu
+function DDLightbarMenu_MakeItemWithRetval(pRetval)
+{
+	return {
+		text: "",
+		retval: pRetval,
+		hotkeys: []
+	};
+}
+
 // Calculates the number of solid scrollbar blocks & non-solid scrollbar blocks
 // to use.  Saves the information in this.scrollbarInfo.numSolidScrollBlocks and
 // this.scrollbarInfo.numNonSolidScrollBlocks.
@@ -1589,15 +1770,24 @@ function DDLightbarMenu_CalcScrollbarBlocks()
 	var menuDisplayHeight = this.size.height;
 	if (this.borderEnabled)
 		menuDisplayHeight -= 2;
-	var menuListFractionShown = menuDisplayHeight / this.items.length;
-	if (menuListFractionShown > 1)
-		menuListFractionShown = 1.0;
-	this.scrollbarInfo.numSolidScrollBlocks = Math.floor(menuDisplayHeight * menuListFractionShown);
-	if (this.scrollbarInfo.numSolidScrollBlocks <= 0)
-		this.scrollbarInfo.numSolidScrollBlocks = 1;
-	else if (this.scrollbarInfo.numSolidScrollBlocks > menuDisplayHeight)
+	var numMenuItems = this.NumItems();
+	if (numMenuItems > 0)
+	{
+		var menuListFractionShown = menuDisplayHeight / numMenuItems;
+		if (menuListFractionShown > 1)
+			menuListFractionShown = 1.0;
+		this.scrollbarInfo.numSolidScrollBlocks = Math.floor(menuDisplayHeight * menuListFractionShown);
+		if (this.scrollbarInfo.numSolidScrollBlocks <= 0)
+			this.scrollbarInfo.numSolidScrollBlocks = 1;
+		else if (this.scrollbarInfo.numSolidScrollBlocks > menuDisplayHeight)
+			this.scrollbarInfo.numSolidScrollBlocks = menuDisplayHeight;
+		this.scrollbarInfo.numNonSolidScrollBlocks = menuDisplayHeight - this.scrollbarInfo.numSolidScrollBlocks;
+	}
+	else
+	{
 		this.scrollbarInfo.numSolidScrollBlocks = menuDisplayHeight;
-	this.scrollbarInfo.numNonSolidScrollBlocks = menuDisplayHeight - this.scrollbarInfo.numSolidScrollBlocks;
+		this.scrollbarInfo.numNonSolidScrollBlocks = 0;
+	}
 }
 
 //////////////////////////////////////////////////////////
@@ -1743,4 +1933,86 @@ function shortenStrWithAttrCodes(pStr, pNewLength, pFromLeft)
 		}
 	}
 	return strCopy;
-}
\ No newline at end of file
+}
+
+// Adds color/attribute codes to a string.
+//
+// Parameters:
+//  pStr: The string to add attribute codes to
+//  pAttrs: This can be either a string containing attribute codes or an array
+//          of objects with start, end, and color properties, for applying attribute
+//          codes to different parts of the string.  These are the properties of
+//          each object in the string (note: for the last one, end can be 0 or -1
+//          to apply the attributes to the rest of the string):
+//           start: The start index in the string to apply the attributes to
+//           end: One past the last index in the part of the string to apply the attributes to
+//           attrs: The attributes to apply to that part of the string
+//
+// Return value: A copy of the string with attributes applied
+function addAttrsToString(pStr, pAttrs)
+{
+	if (typeof(pStr) != "string")
+		return "";
+	else if (pStr.length == 0)
+		return "";
+
+	var str;
+	if (Array.isArray(pAttrs))
+	{
+		if (pAttrs.length > 0)
+		{
+			// Ensure each element of the array has start, end, and attrs properties
+			var allElementsHaveCorrectProps = true;
+			for (var i = 0; (i < pAttrs.length) && allElementsHaveCorrectProps; ++i)
+			{
+				allElementsHaveCorrectProps = ((typeof(pAttrs[i]) == "object") &&
+				                               pAttrs[i].hasOwnProperty("start") &&
+				                               pAttrs[i].hasOwnProperty("end") &&
+				                               pAttrs[i].hasOwnProperty("attrs") &&
+				                               (typeof(pAttrs[i].start) == "number") &&
+				                               (typeof(pAttrs[i].end) == "number") &&
+				                               (typeof(pAttrs[i].attrs) == "string"));
+			}
+			if (!allElementsHaveCorrectProps)
+				return pStr;
+
+			// Colorize the string with the object in pAttrs.
+			// Don't do the last object in this loop, because for the last object,
+			// we'll want to check if its end index is valid.
+			str = "";
+			var lastEnd = -1;
+			for (var i = 0; i < pAttrs.length; ++i)
+			{
+				// If the current object's start is more than 1 character after
+				// the last's end, then append the gap in the string with the
+				// normal attribute
+				if ((i > 0) && (pAttrs[i].start > pAttrs[i-1].end))
+					str += "\1n" + pStr.substring(pAttrs[i-1].end, pAttrs[i].start);
+				// If the properties for the current attrib object are all valid, append
+				// the current part of the string with the given attributes
+				if ((pAttrs[i].start >= lastEnd) && (pAttrs[i].start >= 0) && (pAttrs[i].start < pStr.length) && (pAttrs[i].end > pAttrs[i].start) && (pAttrs[i].end <= pStr.length))
+					str += "\1n" + pAttrs[i].attrs + pStr.substring(pAttrs[i].start, pAttrs[i].end);
+				// For the last attribute object, allow the end index to be <= 0 or
+				// more than the length of the string to apply the attributes to the
+				// rest of the string.
+				//else if ((i == pAttrs.length-1) && (pAttrs[i].start >= lastEnd) && (pAttrs[i].start >= 0) && (pAttrs[i].start < pStr.length) && (pAttrs[i].end <= 0))
+				else if ((i == pAttrs.length-1) && (pAttrs[i].start >= lastEnd) && (pAttrs[i].start >= 0) && (pAttrs[i].start < pStr.length) && ((pAttrs[i].end <= 0) || (pAttrs[i].end > pStr.length)))
+					str += "\1n" + pAttrs[i].attrs + pStr.substring(pAttrs[i].start);
+				lastEnd = pAttrs[i].end;
+			}
+
+			// If str is shorter than the passed-in string, then append the rest of the string
+			// with the normal attribute.
+			var theStrLen = console.strlen(str);
+			if (theStrLen < pStr.length)
+				str += "\1n" + pStr.substring(theStrLen);
+		}
+		else
+			str = pStr;
+	}
+	else if (typeof(pAttrs) == "string")
+		str = "\1n" + pAttrs + pStr;
+	else
+		str = pStr;
+	return str;
+}
-- 
GitLab