ADV3: Most Modest Menu Mangling

I think I have a love/hate relationship with the ADV3 menu/hint system. On the one hand, it feels like the MOST idiosyncratic, dated part of TADS (which is wild, given how parser IF itself trades on nostalgia). On the other, it is certainly a robust system, whose very specificity underlines the nostalgia factor. Over the last three years I have repeatedly struggled internally with whether I want to rewrite it altogether - an impulse that usually fails either because I don’t really have an alternate paradigm to offer, or if I come up with one, because of the daunting work required.

In the end, I decided the pros outweighed the cons, and resigned myself to tweaking the system to correct the MOST grating (to me) aspects of it: inability to communicate unread v read elements, and ‘dead ending’ “Chapter hint” sequence.

Because each of these requires extremely fiddly code bases, will do two more posts detailing each.

Communicating Unread Hint/Menu Items

What I mean by this is quickly differentiating to the player if there are unread menu elements, especially when new dynamic entries appear. The most intuitive solution (to me) seemed to be to bold unread menu elements, and plain text them after reading. Note that this meant every nested menu must ALSO detect all unread elements below it. Because menus are a bit fiddly, I chose a kind of fiddly solution to minimize hidden gotchas.

The algorithm is this: include an unread property in all menu elements, then preInit to replace the title property with a method that conditionally bolds it. First the unread property:

modify MenuObject
	unread = (allContents.indexWhich(
        {itm: itm.unread && (!itm.ofKind(Goal) || (itm.goalState ==OpenGoal))}
        ) != nil)
	titleTxt = title // title replaced by method in preInit
;
modify Goal unread = !goalFullyDisplayed;
modify MenuLongTopicItem
	unread = true
	menuContentsTxt = nil // menuContents replaced by method in preInit
;

The two Menu ‘leaf’ types are LongTopicItems and Goals. MenuObjects are given the Menu definition of unread (any visible contents unread). Note that this takes advantage of allContents property which only exists in HintMenu. Effectively, this means ALWAYS use HintMenu instead of raw MenuItem. (I suppose I could retrofit MenuItem to provision allContents, but why bother?) While I probably could have played with the contents property, which code comments suggest only capture active elements, I was not confident I had fully plumbed the nuances of how/when that is updated.

Step Two is manage the leaf unread properties. Goal has a property that tracks when it has been fully revealed (see above). LongTopicItems do not natively have that capability. This is ALSO managed by replacing a property with method in preInit. The full preInit:



allDaHints : TopHintMenu 'Top of Tree';

hMenuPreinit: PreinitObject
    execute() {
		initMenuTree(allDaHints); // recurses entire tree
    }
	initMenuTree(menu) {
		// replace menu title property with method
        // to conditionally highlight if unread
		// note menus stay 'unread' until all contents are read
		menu.titleTxt = menu.title;
		local ameth = method() {
			local ttxt = titleTxt;

        // flagging text with * instead of bold in Screen reader mode
        // if (unread) ttxt = '<<gScreenRdrEnabled ? '*' :'<b>'>>'
        //        + ttxt + '<<if !gScreenRdrEnabled>></b><<end>>';

            if (unread) ttxt = '<b>' + ttxt + '</b>';
			return ttxt;
		};
		menu.setMethod(&title, ameth);

        // allContents only works if ALL menus are HintMenus!
        //
		menu.allContents.forEach(function(itm) {
			if (itm.ofKind(MenuItem) && !itm.ofKind(MenuLongTopicItem)
                && !itm.ofKind(Goal))
				initMenuTree(itm);
			else {
				// replace menuItem title property with method to 
                // conditionally highlight if unread
				itm.titleTxt = itm.title;
				local tmeth = method() {
					local ttxt = titleTxt;
        // flagging text with * instead of bold in Screen reader mode
        // if (unread) ttxt = '<<gScreenRdrEnabled ? '*' :'<b>'>>'
        //        + ttxt + '<<if !gScreenRdrEnabled>></b><<end>>';

                    if (unread) ttxt = '<b>' + ttxt + '</b>';
					return ttxt;
				};
				itm.setMethod(&title, tmeth);

				if (itm.ofKind(MenuLongTopicItem)) {
					// replace item menuContents with method to mark when read

					itm.menuContentsTxt = itm.menuContents;
					local meth = method() {
						if (unread) unread = nil;
						return menuContentsTxt;
					};
					itm.setMethod(&menuContents, meth);
				}
			}
		});
	}
;

The end of all this is to bold all menu items that are unread or have subordinate unread components like so:

1 Like

Last “Chapter” Hint Advancement

This opaque phrase refers to ADV3 menu behavior that auto-advances (usually) LongTopicItems whose isChapterMenu property is set, allowing the player to frictionlessly advance from one menu entry to the next without having to back out, advance, enter. It is a nice touch with one gotcha: When encountering the LAST hint in a menu, it pops you back to the containing menu which is, assuming every entry was read, now ALSO exhausted. I don’t know why this chafed me so. It’s just, here I was space-barring my way from one entry to the next, only to be pulled up short into an exhausted menu that needed more clumsy navigation.

The behavior I decided I wanted was to pop up to the first menu in the branch that was NOT now exhausted. Granted, this is also potentially confusing to the player, suddenly displacing them up the menu tree. Perhaps it is my super-human sense of spatial awareness that made this preferable to me.

This does require some gymnastics. First, I add a menu return code, which are specified in adv3.h. Technically, these defines are more than a code, they are an index into a libMessages list-of-lists property which defines acceptable navigation keys for the menu system. I am not adding anything there, just adding a code which I can process. Here are the standard defs…

/* ------------------------------------------------------------------------ */
/*
 *   Definitions for the menu system
 */

/* 
 *   The indices for the key values used to navigate menus, which are held
 *   in the keyList array of MenuItems.  
 */
#define M_QUIT      1
#define M_PREV      2
#define M_UP        3
#define M_DOWN      4
#define M_SEL       5

To which I added

/* =====================================================================
 *
 *  Menu system enhancement, end submenu when topics exhausted
 *  (extends adv3.h defs)
 *
 * ======================================================================
 */
#define M_PREVPREV 6

in my own adv3_jjmcc.h

A word about how menus are implemented. The TopHintMenu is a special menu. When invoked, its display() method massages banners, clears the screen and populates it with top level menu items. It then enters a do..while loop spinning until it receives valid input (defined in libMessages). Depending on input it might navigate into subordinate elements, but only exits the loop on a up/quit. Each subordinate element does the same, meaning you end up in a series of nested, active do..while loops depending on how deep your menu tree is.

To get the behavior I want, when chapter advancement is in play, you must pop out of these nested loops one at a time, detecting either a menu with unread entries, or the final (top) level.

Here is innermost while code to do that, mostly stock with just my added code to process:

            /* if the player selected a sub-menu, invoke the selection */
            while (loc == M_SEL
                   && selection != 0
                   && selection <= contents.length())
            {
                /* 
                 *   Invoke the sub-menu, checking for a QUIT result.  If
                 *   the user isn't quitting, we'll display our own menu
                 *   again; in this case, update it now, in case something
                 *   in the sub-menu changed our own contents. 
                 */
                loc = contents[selection].showMenuHtml(topMenu);
                
                /* see what we have */
                switch (loc)
                {
                case M_UP:
                    /* they want to go directly to the previous menu */
                    loc = M_SEL;
                    --selection;
                    break;

                case M_DOWN:
                    /* they want to go directly to the next menu */
                    loc = M_SEL;
                    ++selection;
                    break;

				// JJMcC: NEW PROCESSING
				// if return code is M_PREVPREV means we were in the last chapter
				// if fully read, recurse up until hit menu with unread elements
				//   (add loop exit condition below as well)
                //   note the value of 'loc' is returned when exiting this level
				case M_PREVPREV:
					if (!unread) { // ie this menu level fully read
                        // must top out at topMenu
						if (location == topMenu) loc = M_PREV;

                        // otherwise carry M_PREVPREV up to next level
						break;
					} // if unread fall into M_PREV case
                case M_PREV:
                    /* they want to return to this menu level */
                    loc = nil;

                    /* update our contents */
                    updateContents();

                    /* make sure we refresh the title area */
                    refreshTitle = true;
                    break;
				}

WHERE this code has to go is kludgey. Basically it has to go DEEP into the showMenuHtml and, if you go to text mode for screen readers, showMenuText methods. Note that this code ONLY fixes console mode TADS. HTMLTADS has yet a different processing tree that I did NOT try to adjust. Before I dump that wall of code on you, there is one more thing needs doing. The chapter code must detect and return the M_PREVPREV code when last in line is consumed. Here is the code for that, ALSO deep in a method (look for JJMcC below):

modify MenuLongTopicItem
	unread = true
	menuContentsTxt = nil // menuContents replaced by method in preInit

	// only change is at the end of this method
	//
    showMenuCommon(topMenu)
    {
        local evt, key, loc, nxt;

        /* update our contents, as needed */
        updateContents();

        /* show our heading, centered */
        "<CENTER><b><<heading>></b></CENTER>\b";

        /* show our contents */
        "<<menuContents>>\b";

        /* check to see if we should offer chapter navigation */
        nxt = (isChapterMenu ? location.getNextMenu(self) : nil);

        /* if there's a next chapter, show how we can navigate to it */
        if (nxt != nil)
        {
            /* show the navigation */
            gLibMessages.menuNextChapter(topMenu.keyList, nxt.title,
                                         'next', 'menu');
        }
        else
        {
            /* no chaptering - just print the ending message */
            "<<menuLongTopicEnd>>";
        }

        /* wait for an event */
        for (;;)
        {
            evt = inputManager.getEvent(nil, nil);
            switch(evt[1])
            {
            case InEvtHref:
                /* check for a 'next' or 'prev' command */
                if (evt[2] == 'next')
                    return M_DOWN;
                else if (evt[2] == 'prev')
                    return M_UP;
                else if (evt[2] == 'menu')
                    return M_PREV;
                break;

            case InEvtKey:
                /* get the key */
                key = evt[2].toLower();

                /* 
                 *   if we're in plain text mode, add a blank line after
                 *   the key input 
                 */
                if (statusLine.statusDispMode == StatusModeText)
                    "\b";

                /* look up the command key */
                loc = topMenu.keyList.indexWhich({x: x.indexOf(key) != nil});

                /* 
                 *   if it's 'next', either proceed to the next menu or
                 *   return to the previous menu, depending on whether
                 *   we're in chapter mode or not
				 *   JJMcC ONLY CHANGE: add test for immediate menu top or unread,
                 *   pop to first unread level
                 */
                if (loc == M_SEL)
                    // return (nxt == nil ? M_PREV : M_DOWN); // orig code
                    // if no nxt item (see above), pop once if containing menu
                    //    is Top or unread
                    //    otherwise let menus know to recurse until true
                    // if is a nxt item, nav to that directly with M_DOWN 
                    return (nxt == nil ?
					        ((location == topMenu) || location.unread ?
                              M_PREV : M_PREVPREV)
                            : M_DOWN);

                /* if it's 'prev', return to the previous menu */
                if (loc == M_PREV || loc == M_QUIT)
                    return loc;

                /* ignore other keys */
                break;
            }
        }
    }
;

Ok, final code snippet is menu loop recursion. Must be replicated (with minor tweak for do..while conditional details) in showMenuHtml and showMenuText both DEEP within those respecitve methods. Eye glazing ahead.

/* ==============================================================================
 *	digest M_PREVPREV return code from above.
 *  includes additional return value in subMenu do loop
 *  look for JJMcC for only changes
 * ============================================================================
 */
modify MenuItem
    showMenuText(topMenu)
    {
        local i, selection, len, key = '', loc;

        // * remember the key list *
        curKeyList = topMenu.keyList;

        // * bring our contents up to date, as needed *
        updateContents();

        // * keep going until the player exits this menu level *
        do
        {
            // * 
            // *   For text mode, print the title, then show the menu
            // *   options as a numbered list, then ask the player to make a
            // *   selection.  
            // *
            
            // * get the number of items in the menu *
            len = contents.length();              

            // * show the menu heading *
            "\n<b><<heading>></b>\b";

            // * show the contents as a numbered list *
            for (i = 1; i <= len; i++)
            {
                // * leave room for two-digit numeric labels if needed *
                if (len > 9 && i <= 10) "\ ";

                // * show the item's number and title *
                "<<i>>.\ <<contents[i].title>>\n";
            }

            // * show the main prompt *
            gLibMessages.textMenuMainPrompt(topMenu.keyList);

            // * main input loop *
            do
            {
                // * 
                // *   Get a key, and convert any alphabetics to lower-case.
                // *   Do not allow real-time interruptions, as menus are
                // *   meta-game interactions. 
                // *
                key = inputManager.getKey(nil, nil).toLower();

                // * check for a command key *
                loc = topMenu.keyList.indexWhich({x: x.indexOf(key) != nil});

                // * also check for a numeric selection *
                selection = toInteger(key);
            } while ((selection < 1 || selection > len)
                     && loc != M_QUIT && loc != M_PREV);

            // * 
            // *   show the selection if it's an ordinary key (an ordinary
            // *   key is represented by a single character; if we have more
            // *   than one character, it's one of the '[xxx]' special key
            // *   representations) 
            // *
            if (key.length() == 1)
                "<<key>>";

            // * add a blank line *
            "\b";
            
            // * 
            // *   If the selection is a number, then the player selected
            // *   that menu option.  Call that submenu or topic's display
            // *   routine.  If the routine returns nil, the player selected
            // *   QUIT, so we should quit as well. 
            // *
            while (selection != 0 && selection <= contents.length())
            {
                // * invoke the child menu *
                loc = contents[selection].showMenuText(topMenu);

                // *   
                // *   Check the result.  If it's nil, it means QUIT; if it's
                // *   'next', it means we're to proceed directly to our next
                // *   sub-menu.  If the user didn't select QUIT, then
                // *   refresh our menu contents, as we'll be displaying our
                // *   menu again and its contents could have been affected
                // *   by the sub-menu invocation.  
                // *
                switch(loc)
                {
                case M_QUIT:
                    // * they want to quit - leave the submenu loop *
                    selection = 0;
                    break;

                case M_UP:
                    // * they want to go to the previous menu directly *
                    --selection;
                    break;

                case M_DOWN:
                    // * they want to go to the next menu directly *
                    ++selection;
                    break;

				// JJMcC: NEW PROCESSING
				// if return code is M_PREVPREV means we were in the last chapter
				// if fully read, recurse up until hit menu with unread elements
				//   (add loop exit condition below as well)
                //   note the value of 'loc' is returned when exiting this level
				case M_PREVPREV:
					if (!unread) {  // ie this menu level fully read
						selection = 0;  // text-mode specific loop end case
                        // must top out at topMenu
						if (location == topMenu) loc = M_PREV;

                        // otherwise carry M_PREVPREV up to next level
						break;
					} // otherwise fall into M_PREV case
                case M_PREV:
                    // * 
                    // *   they want to show this menu again - update our
                    // *   contents so that we account for any changes made
                    // *   while running the submenu, then leave the submenu
                    // *   loop 
                    // *
                    updateContents();
                    selection = 0;

                    // * 
                    // *   forget the 'prev' command - we don't want to back
                    // *   up any further just yet, since the submenu just
                    // *   wanted to get back to this point 
                    // *
                    loc = nil;
                    break;
                }
            }
        } while (loc != M_QUIT && loc != M_PREV && loc != M_PREVPREV);

        // return the desired next action 
        return loc;
    }
    showMenuHtml(topMenu)
    {
        local len, selection = 1, loc;
        local refreshTitle = true;
        
        /* remember the key list */
        curKeyList = topMenu.keyList;

        /* update the menu contents, as needed */
        updateContents();

        /* keep going until the user exits this menu level */
        do
        {
            /* refresh our title in the instructions area if necessary */
            if (refreshTitle)
            {
                refreshTopMenuBanner(topMenu);
                refreshTitle = nil;
            }

            /* get the number of items in the menu */
            len = contents.length();
              
            /* check whether we're in banner API or <banner> tag mode */
            if (statusLine.statusDispMode == StatusModeApi)
            {
                /* banner API mode - clear our window */
                contentsMenuBanner.clearWindow();

                /* advise the interpreter of our best guess for our size */
                if (fullScreenMode)
                    contentsMenuBanner.setSize(100, BannerSizePercent, nil);
                else
                    contentsMenuBanner.setSize(len + 1, BannerSizeAbsolute,
                                               true);

                /* set up our desired color scheme */
                "<body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
            }
            else
            {
                /* 
                 *   <banner> tag mode - set up our tag.  In full-screen
                 *   mode, set our height to 100% immediately; otherwise,
                 *   leave the height unspecified so that we'll use the
                 *   size of our contents.  Use a border only if we're not
                 *   taking up the full screen. 
                 */
                "<banner id=MenuBody align=top
                <<fullScreenMode ? 'height=100%' : 'border'>>
                ><body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
            }

            /* display our contents as a table */
            "<table><tr><td width=<<indent>> > </td><td>";
            for (local i = 1; i <= len; i++)
            {
                /* 
                 *   To get the alignment right, we have to print '>' on
                 *   each and every line. However, we print it in the
                 *   background color to make it invisible everywhere but
                 *   in front of the current selection.
                 */
                if (selection != i)
                    "<font color=<<bgcolor>> >&gt;</font>";
                else
                    "&gt;";
                
                /* make each selection a plain (i.e. unhilighted) HREF */
                "<a plain href=<<i>> ><<contents[i].title>></a><br>";
            }

            /* end the table */
            "</td></tr></table>";

            /* finish our display as appropriate */
            if (statusLine.statusDispMode == StatusModeApi)
            {
                /* banner API - size the window to its contents */
                if (!fullScreenMode)
                    contentsMenuBanner.sizeToContents();
            }
            else
            {
                /* <banner> tag - just close the tag */
                "</banner>";
            }

            /* main input loop */
            do
            {
                local key, events;

                /* 
                 *   Read an event - don't allow real-time interruptions,
                 *   since menus are meta-game interactions.  Read an
                 *   event rather than just a keystroke, because we want
                 *   to let the user click on a menu item's HREF.  
                 */
                events = inputManager.getEvent(nil, nil);

                /* check the event type */
                switch (events[1])
                {
                case InEvtHref:
                    /* 
                     *   the HREF's value is the selection number, or a
                     *   'previous' command 
                     */
                    if (events[2] == 'previous')
                        loc = M_PREV;
                    else
                    {
                        selection = toInteger(events[2]);
                        loc = M_SEL;
                    }
                    break;

                case InEvtKey:
                    /* keystroke - convert any alphabetic to lower case */
                    key = events[2].toLower();

                    /* scan for a valid command key */
                    loc = topMenu.keyList.indexWhich(
                        {x: x.indexOf(key) != nil});
                    break;
                }

                /* handle arrow keys */
                if (loc == M_UP)
                {
                    selection--;
                    if (selection < 1)
                        selection = len;
                }
                else if (loc == M_DOWN)
                {
                    selection++;
                    if (selection > len)
                        selection = 1;
                }
            } while (loc == nil);

            /* if the player selected a sub-menu, invoke the selection */
            while (loc == M_SEL
                   && selection != 0
                   && selection <= contents.length())
            {
                /* 
                 *   Invoke the sub-menu, checking for a QUIT result.  If
                 *   the user isn't quitting, we'll display our own menu
                 *   again; in this case, update it now, in case something
                 *   in the sub-menu changed our own contents. 
                 */
                loc = contents[selection].showMenuHtml(topMenu);
                
                /* see what we have */
                switch (loc)
                {
                case M_UP:
                    /* they want to go directly to the previous menu */
                    loc = M_SEL;
                    --selection;
                    break;

                case M_DOWN:
                    /* they want to go directly to the next menu */
                    loc = M_SEL;
                    ++selection;
                    break;

				// JJMcC: NEW PROCESSING
				// if return code is M_PREVPREV means we were in the last chapter
				// if fully read, recurse up until hit menu with unread elements
				//   (add loop exit condition below as well)
                //   note the value of 'loc' is returned when exiting this level
				case M_PREVPREV:
					if (!unread) { // ie this menu level fully read
                        // must top out at topMenu
						if (location == topMenu) loc = M_PREV;

                        // otherwise carry M_PREVPREV up to next level
						break;
					} // if unread fall into M_PREV case
                case M_PREV:
                    /* they want to return to this menu level */
                    loc = nil;

                    /* update our contents */
                    updateContents();

                    /* make sure we refresh the title area */
                    refreshTitle = true;
                    break;
				}
            }
        } while (loc != M_QUIT && loc != M_PREV && loc != M_PREVPREV); // JJMcC

        /* return the next status */
        return loc;
    }
; 

There you go! Two changes that kept me from rewriting the thing whole hog.

1 Like

I especially like the emboldening of unread menu items… may swipe that someday.

1 Like