1#charset "us-ascii"
2
3/*
4 *   TADS 3 Library - Menu System
5 *
6 *   Copyright 2003 by Stephen Granade
7 *.  Modifications copyright 2003 Michael J. Roberts
8 *
9 *   This module is designed to make it easy to add on-screen menus to a
10 *   game.  Stephen Granade adapted this module from his TADS 2 menu
11 *   system, and Mike Roberts made some minor cosmetic changes to integrate
12 *   it with the main TADS 3 library.
13 *
14 *   N.B. in plain-text mode (for interpreters without banner
15 *   capabilities), a menu won't be fully usable if it exceeds 9 subitems:
16 *   each item in a menu is numbered, and the user selects an item by
17 *   entering its number; but we only accept a single digit as input, so
18 *   only items 1 through 9 can be selected on any given menu.  Good
19 *   usability design usually dictates that menus shouldn't be so large
20 *   anyway, so most menus will naturally avoid this problem, but this is
21 *   something to keep in mind.
22 */
23
24#include "adv3.h"
25
26
27/*
28 *   General instructions:
29 *
30 *   Menus consist of MenuItems, MenuTopicItems, and MenuLongTopicItems.
31 *
32 *   * MenuItems are the menu (and sub-menu) items that the player will
33 *   select.  Their "title" attribute is what will be shown in the menu,
34 *   and the "heading" attribute is shown as the heading while the menu
35 *   itself is active; by default, the heading simply uses the title.
36 *
37 *   * MenuTopicItems are for lists of topic strings that the player will
38 *   be shown, like hints. "title" is what will be shown in the menu;
39 *   "menuContents" is a list of either strings to be displayed, one at a
40 *   time, or objects which must return a string via a "menuContents"
41 *   method
42 *
43 *   * MenuLongTopicItems are for longer discources. "title" is what will
44 *   be shown in the menu; "menuContents" is either a string to be printed
45 *   or a routine to be called.
46 *
47 *   adv3.h contains templates for MenuItems, for your convenience.
48 *
49 *   A simple example menu:
50 *
51 *   FirstMenu: MenuItem 'Test menu';
52 *.  + MenuItem 'Pets';
53 *.  ++ MenuItem 'Chinchillas';
54 *.  +++ MenuTopicItem 'About them'
55 *.    menuContents = ['Furry', 'South American', 'Curious',
56 *   'Note: Not a coat'];
57 *.  +++ MenuTopicItem 'Benefits'
58 *.    menuContents = ['Non-allergenic', 'Cute', 'Require little space'];
59 *.  +++ MenuTopicItem 'Downsides'
60 *.     menuContents = ['Require dust baths', 'Startle easily'];
61 *.  ++ MenuItem 'Cats';
62 *.  +++ MenuLongTopicItem 'Pure evil'
63 *.     menuContents = 'Cats are, quite simply, pure evil. I would provide
64 *.                     ample evidence were there room for it in this
65 *.                     simple example.';
66 *.  +++ MenuTopicItem 'Benefits'
67 *.    menuContents = ['They, uh, well...', 'Okay, I can\'t think of any.'];
68 */
69
70
71/*
72 *   The very top banner of the menu, which holds its title and
73 *   instructions.
74 */
75topMenuBanner: BannerWindow
76;
77
78/*
79 *   The actual menu contents banner window.  This displays the list of
80 *   menu items to choose from.
81 */
82contentsMenuBanner: BannerWindow
83;
84
85/*
86 *   The long topic banner.  This takes over the screen when we're
87 *   displaying a long topic item.
88 */
89longTopicBanner: BannerWindow
90;
91
92/* ------------------------------------------------------------------------ */
93/*
94 *   A basic menu object.  This is an abstract base class that
95 *   encapsulates some behavior common to different menu classes, and
96 *   allows the use of the + syntax (like "+ MenuItem") to define
97 *   containment.
98 */
99class MenuObject: object
100    /* our contents list */
101    contents = []
102
103    /*
104     *   Since we're inheriting from object, but need to use the "+"
105     *   syntax, we need to set up the contents appropriately
106     */
107    initializeLocation()
108    {
109        if (location != nil)
110            location.addToContents(self);
111    }
112
113    /* add a menu item */
114    addToContents(obj)
115    {
116        /*
117         *   If the menu has a nil menuOrder, and it inherits menuOrder
118         *   from us, then it must be a dynamically-created object that
119         *   doesn't provide a custom menuOrder.  Provide a suitable
120         *   default of a value one higher than the highest menuOrder
121         *   currently in our list, to ensure that the item always sorts
122         *   after any items currently in the list.
123         */
124        if (obj.menuOrder == nil && !overrides(obj, MenuObject, &menuOrder))
125        {
126            local maxVal;
127
128            /* find the maximum current menuOrder value */
129            maxVal = nil;
130            foreach (local cur in contents)
131            {
132                /*
133                 *   if this one has a value, and it's the highest so far
134                 *   (or the only one with a value we've found so far),
135                 *   take it as the maximum so far
136                 */
137                if (cur.menuOrder != nil
138                    && (maxVal == nil || cur.menuOrder > maxVal))
139                    maxVal = cur.menuOrder;
140            }
141
142            /* if we didn't find any values, use 0 as the arbitrary default */
143            if (maxVal == nil)
144                maxVal = 0;
145
146            /* go one higher than the maximum of the existing items */
147            obj.menuOrder = maxVal;
148        }
149
150        /* add the item to our contents list */
151        contents += obj;
152    }
153
154    /*
155     *   The menu order.  When we're about to show a list of menu items,
156     *   we'll sort the list in ascending order of this property, then in
157     *   ascending order of title.  By default, we set this order value to
158     *   be equal to the menu item's sourceTextOrder. This makes the menu
159     *   order default to the order of objects as defined in the source. If
160     *   some other basis is desired, override topicOrder.
161     */
162    menuOrder = (sourceTextOrder)
163
164    /*
165     *   Compare this menu object to another, for the purposes of sorting a
166     *   list of menu items. Returns a positive number if this menu item
167     *   sorts after the other one, a negative number if this menu item
168     *   sorts before the other one, 0 if the relative order is arbitrary.
169     *
170     *   By default, we'll sort by menuOrder if the menuOrder values are
171     *   different, otherwise arbitrarily.
172     */
173    compareForMenuSort(other)
174    {
175        /*
176         *   if one menuOrder value is nil, sort it earlier than the other;
177         *   if they're both nil, they sort as equivalent
178         */
179        if (menuOrder == nil && other.menuOrder == nil)
180            return 0;
181        else if (menuOrder == nil)
182            return -1;
183        else if (other.menuOrder == nil)
184            return 1;
185
186        /* return the difference of the sort order values */
187        return menuOrder - other.menuOrder;
188    }
189
190    /*
191     *   Finish initializing our contents list.  This will be called on
192     *   each MenuObject *after* we've called initializeLocation() on every
193     *   object.  In other words, every menu will already have been added
194     *   to its parent's contents; this can do anything else that's needed
195     *   to initialize the contents list.  For example, some subclasses
196     *   might want to sort their contents here, so that they list their
197     *   menus in a defined order.  By default, we sort the menu items by
198     *   menuOrder; subclasses can override this as needed.
199     */
200    initializeContents()
201    {
202        /* sort our contents list in the object-defined sorting order */
203        contents = contents.sort(
204            SortAsc, {a, b: a.compareForMenuSort(b)});
205    }
206;
207
208/*
209 *   This preinit object makes sure the MenuObjects all have their
210 *   contents initialized properly.
211 */
212PreinitObject
213    execute()
214    {
215        /* initialize each menu's location */
216        forEachInstance(MenuObject, { menu: menu.initializeLocation() });
217
218        /* do any extra work to initialize each menu's contents list */
219        forEachInstance(MenuObject, { menu: menu.initializeContents() });
220    }
221;
222
223/*
224 *   A MenuItem is a given item in the menu tree.  In general all you need
225 *   to do to use menus is create a tree of MenuItems with titles.
226 */
227class MenuItem: MenuObject
228    /* the name of the menu; this is listed in the parent menu */
229    title = ''
230
231    /*
232     *   the heading - this is shown when this menu is active; by default,
233     *   we simply use the title
234     */
235    heading = (title)
236
237    /*
238     *   Display properties.  These properties control the way the menu
239     *   appears on the screen.  By default, a menu looks to its parent
240     *   menu for display properties; this makes it easy to customize an
241     *   entire menu tree, since changes in the top-level menu will cascade
242     *   to all children that don't override these settings.  However, each
243     *   menu can customize its own appearance by overriding these
244     *   properties itself.
245     *
246     *   'fgcolor' and 'bgcolor' are the foreground (text) and background
247     *   colors, expressed as HTML color names (so '#nnnnnn' values can be
248     *   used to specify RGB colors).
249     *
250     *   'indent' is the number of pixels to indent the menu's contents
251     *   from the left margin.  This is used only in HTML mode.
252     *
253     *   'fullScreenMode' indicates whether the menu should take over the
254     *   entire screen, or limit itself to the space it actually requires.
255     *   Full screen mode makes the menu block out any game window text.
256     *   Limited mode leaves the game window partially uncovered, but can
257     *   be a bit jumpy, since the window changes size as the user
258     *   navigates through different menus.
259     */
260
261    /* foreground (text) and background colors, as HTML color names */
262    fgcolor = (location != nil ? location.fgcolor : 'text')
263    bgcolor = (location != nil ? location.bgcolor : 'bgcolor')
264
265    /*
266     *   Foreground and background colors for the top instructions bar.
267     *   By default, we use the color scheme of the parent menu, or the
268     *   inverse of our main menu color scheme if we're the top menu.
269     */
270    topbarfg = (location != nil ? location.topbarfg : 'statustext')
271    topbarbg = (location != nil ? location.topbarbg : 'statusbg')
272
273    /* number of spaces to indent the menu's contents */
274    indent = (location != nil ? location.indent : '10')
275
276    /*
277     *   full-screen mode: make our menu take up the whole screen (apart
278     *   from the instructions bar, of course)
279     */
280    fullScreenMode = (location != nil ? location.fullScreenMode : true)
281
282    /*
283     *   The keys used to navigate the menus, in order:
284     *
285     *   [quit, previous, up, down, select.]
286     *
287     *   Since multiple keys can be used for the same navigation, the list
288     *   is implemented as a List of Lists.  Keys must be given as
289     *   lower-case in order to match input, since we convert all input
290     *   keys to lower-case before matching them.
291     *
292     *   In the sublist for each key, we use the first element as the key
293     *   name we show in the instruction bar at the top of the screen.
294     *
295     *   By default, we use our parent menu's key list, if we have a
296     *   parent; if we have no parent, we use the standard keys from the
297     *   library messages.
298     */
299    keyList = (location != nil ? location.keyList : libMessages.menuKeyList)
300
301    /*
302     *   the current key list - we'll set this on entry to the start of
303     *   each showMenuXxx method, so that we keep track of the actual key
304     *   list in use, as inherited from the top-level menu
305     */
306    curKeyList = nil
307
308    /*
309     *   Title for the link to the previous menu, if any.  If the menu has
310     *   a parent menu, we'll display this link next to the menu title in
311     *   the top instructions/title bar.  If this is nil, we won't display
312     *   a link at all.  Note that this can contain an HTML fragment; for
313     *   example, you could use an <IMG> tag to display an icon here.
314     */
315    prevMenuLink = (location != nil ? libMessages.prevMenuLink : nil)
316
317    /*
318     *   Update our contents.  By default, we'll do nothing; subclasses
319     *   can override this to manage dynamic menus if desired.  This is
320     *   called just before the menu is displayed, each time it's
321     *   displayed.
322     */
323    updateContents() { }
324
325    /*
326     *   Call menu.display when you're ready to show the menu.  This
327     *   should be called on the top-level menu; we run the entire menu
328     *   display process, and return when the user exits from the menu
329     *   tree.
330     */
331    display()
332    {
333        local oldStr;
334        local flags;
335
336        /* make sure the main window is flushed before we get going */
337        flushOutput();
338
339        /* set up with the top menu banner in place of the status line */
340        removeStatusLine();
341        showTopMenuBanner(self);
342
343        /*
344         *   display the menu using the same mode that the statusline
345         *   has decided to use
346         */
347        switch (statusLine.statusDispMode)
348        {
349        case StatusModeApi:
350            /* use a border, unless we're taking over the whole screen */
351            flags = (fullScreenMode ? 0 : BannerStyleBorder);
352
353            /*
354             *   use a scrollbar if possible; keep the text scrolled into
355             *   view as we show it
356             */
357            flags |= BannerStyleVScroll | BannerStyleAutoVScroll;
358
359            /* banner API mode - show our banner window */
360            contentsMenuBanner.showBanner(nil, BannerLast, nil,
361                                          BannerTypeText, BannerAlignTop,
362                                          nil, nil, flags);
363
364            /* make the banner window the default output stream */
365            oldStr = contentsMenuBanner.setOutputStream();
366
367            /* make sure we restore the default output stream when done */
368            try
369            {
370                /* display and run our menu in HTML mode */
371                showMenuHtml(self);
372            }
373            finally
374            {
375                /* restore the original default output stream */
376                outputManager.setOutputStream(oldStr);
377
378                /* remove the menu banner */
379                contentsMenuBanner.removeBanner();
380            }
381            break;
382
383        case StatusModeTag:
384            /* HTML <banner> tag mode - just show our HTML contents */
385            showMenuHtml(self);
386
387            /* remove the banner for the menu display */
388            "<banner remove id=MenuTitle>";
389            break;
390
391        case StatusModeText:
392            /* display and run our menu in text mode */
393            showMenuText(self);
394            break;
395        }
396
397        /* we're done, so remove the top menu banner */
398        removeTopMenuBanner();
399    }
400
401    /*
402     *   Display the menu in plain text mode.  This is used when the
403     *   interpreter only supports the old tads2-style text-mode
404     *   single-line status area.
405     *
406     *   Returns true if we should return to the parent menu, nil if the
407     *   user selected QUIT to exit the menu system entirely.
408     */
409    showMenuText(topMenu)
410    {
411        local i, selection, len, key = '', loc;
412
413        /* remember the key list */
414        curKeyList = topMenu.keyList;
415
416        /* bring our contents up to date, as needed */
417        updateContents();
418
419        /* keep going until the player exits this menu level */
420        do
421        {
422            /*
423             *   For text mode, print the title, then show the menu
424             *   options as a numbered list, then ask the player to make a
425             *   selection.
426             */
427
428            /* get the number of items in the menu */
429            len = contents.length();
430
431            /* show the menu heading */
432            "\n<b><<heading>></b>\b";
433
434            /* show the contents as a numbered list */
435            for (i = 1; i <= len; i++)
436            {
437                /* leave room for two-digit numeric labels if needed */
438                if (len > 9 && i <= 10) "\ ";
439
440                /* show the item's number and title */
441                "<<i>>.\ <<contents[i].title>>\n";
442            }
443
444            /* show the main prompt */
445            libMessages.textMenuMainPrompt(topMenu.keyList);
446
447            /* main input loop */
448            do
449            {
450                /*
451                 *   Get a key, and convert any alphabetics to lower-case.
452                 *   Do not allow real-time interruptions, as menus are
453                 *   meta-game interactions.
454                 */
455                key = inputManager.getKey(nil, nil).toLower();
456
457                /* check for a command key */
458                loc = topMenu.keyList.indexWhich({x: x.indexOf(key) != nil});
459
460                /* also check for a numeric selection */
461                selection = toInteger(key);
462            } while ((selection < 1 || selection > len)
463                     && loc != M_QUIT && loc != M_PREV);
464
465            /*
466             *   show the selection if it's an ordinary key (an ordinary
467             *   key is represented by a single character; if we have more
468             *   than one character, it's one of the '[xxx]' special key
469             *   representations)
470             */
471            if (key.length() == 1)
472                "<<key>>";
473
474            /* add a blank line */
475            "\b";
476
477            /*
478             *   If the selection is a number, then the player selected
479             *   that menu option.  Call that submenu or topic's display
480             *   routine.  If the routine returns nil, the player selected
481             *   QUIT, so we should quit as well.
482             */
483            while (selection != 0 && selection <= contents.length())
484            {
485                /* invoke the child menu */
486                loc = contents[selection].showMenuText(topMenu);
487
488                /*
489                 *   Check the result.  If it's nil, it means QUIT; if it's
490                 *   'next', it means we're to proceed directly to our next
491                 *   sub-menu.  If the user didn't select QUIT, then
492                 *   refresh our menu contents, as we'll be displaying our
493                 *   menu again and its contents could have been affected
494                 *   by the sub-menu invocation.
495                 */
496                switch(loc)
497                {
498                case M_QUIT:
499                    /* they want to quit - leave the submenu loop */
500                    selection = 0;
501                    break;
502
503                case M_UP:
504                    /* they want to go to the previous menu directly */
505                    --selection;
506                    break;
507
508                case M_DOWN:
509                    /* they want to go to the next menu directly */
510                    ++selection;
511                    break;
512
513                case M_PREV:
514                    /*
515                     *   they want to show this menu again - update our
516                     *   contents so that we account for any changes made
517                     *   while running the submenu, then leave the submenu
518                     *   loop
519                     */
520                    updateContents();
521                    selection = 0;
522
523                    /*
524                     *   forget the 'prev' command - we don't want to back
525                     *   up any further just yet, since the submenu just
526                     *   wanted to get back to this point
527                     */
528                    loc = nil;
529                    break;
530                }
531            }
532        } while (loc != M_QUIT && loc != M_PREV);
533
534        /* return the desired next action */
535        return loc;
536    }
537
538    /*
539     *   Show the menu using HTML.  Return nil when the user selects QUIT
540     *   to exit the menu entirely.
541     */
542    showMenuHtml(topMenu)
543    {
544        local len, selection = 1, loc;
545        local refreshTitle = true;
546
547        /* remember the key list */
548        curKeyList = topMenu.keyList;
549
550        /* update the menu contents, as needed */
551        updateContents();
552
553        /* keep going until the user exits this menu level */
554        do
555        {
556            /* refresh our title in the instructions area if necessary */
557            if (refreshTitle)
558            {
559                refreshTopMenuBanner(topMenu);
560                refreshTitle = nil;
561            }
562
563            /* get the number of items in the menu */
564            len = contents.length();
565
566            /* check whether we're in banner API or <banner> tag mode */
567            if (statusLine.statusDispMode == StatusModeApi)
568            {
569                /* banner API mode - clear our window */
570                contentsMenuBanner.clearWindow();
571
572                /* advise the interpreter of our best guess for our size */
573                if (fullScreenMode)
574                    contentsMenuBanner.setSize(100, BannerSizePercent, nil);
575                else
576                    contentsMenuBanner.setSize(len + 1, BannerSizeAbsolute,
577                                               true);
578
579                /* set up our desired color scheme */
580                "<body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
581            }
582            else
583            {
584                /*
585                 *   <banner> tag mode - set up our tag.  In full-screen
586                 *   mode, set our height to 100% immediately; otherwise,
587                 *   leave the height unspecified so that we'll use the
588                 *   size of our contents.  Use a border only if we're not
589                 *   taking up the full screen.
590                 */
591                "<banner id=MenuBody align=top
592                <<fullScreenMode ? 'height=100%' : 'border'>>
593                ><body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
594            }
595
596            /* display our contents as a table */
597            "<table><tr><td width=<<indent>> > </td><td>";
598            for (local i = 1; i <= len; i++)
599            {
600                /*
601                 *   To get the alignment right, we have to print '>' on
602                 *   each and every line. However, we print it in the
603                 *   background color to make it invisible everywhere but
604                 *   in front of the current selection.
605                 */
606                if (selection != i)
607                    "<font color=<<bgcolor>> >&gt;</font>";
608                else
609                    "&gt;";
610
611                /* make each selection a plain (i.e. unhilighted) HREF */
612                "<a plain href=<<i>> ><<contents[i].title>></a><br>";
613            }
614
615            /* end the table */
616            "</td></tr></table>";
617
618            /* finish our display as appropriate */
619            if (statusLine.statusDispMode == StatusModeApi)
620            {
621                /* banner API - size the window to its contents */
622                if (!fullScreenMode)
623                    contentsMenuBanner.sizeToContents();
624            }
625            else
626            {
627                /* <banner> tag - just close the tag */
628                "</banner>";
629            }
630
631            /* main input loop */
632            do
633            {
634                local key, events;
635
636                /*
637                 *   Read an event - don't allow real-time interruptions,
638                 *   since menus are meta-game interactions.  Read an
639                 *   event rather than just a keystroke, because we want
640                 *   to let the user click on a menu item's HREF.
641                 */
642                events = inputManager.getEvent(nil, nil);
643
644                /* check the event type */
645                switch (events[1])
646                {
647                case InEvtHref:
648                    /*
649                     *   the HREF's value is the selection number, or a
650                     *   'previous' command
651                     */
652                    if (events[2] == 'previous')
653                        loc = M_PREV;
654                    else
655                    {
656                        selection = toInteger(events[2]);
657                        loc = M_SEL;
658                    }
659                    break;
660
661                case InEvtKey:
662                    /* keystroke - convert any alphabetic to lower case */
663                    key = events[2].toLower();
664
665                    /* scan for a valid command key */
666                    loc = topMenu.keyList.indexWhich(
667                        {x: x.indexOf(key) != nil});
668                    break;
669                }
670
671                /* handle arrow keys */
672                if (loc == M_UP)
673                {
674                    selection--;
675                    if (selection < 1)
676                        selection = len;
677                }
678                else if (loc == M_DOWN)
679                {
680                    selection++;
681                    if (selection > len)
682                        selection = 1;
683                }
684            } while (loc == nil);
685
686            /* if the player selected a sub-menu, invoke the selection */
687            while (loc == M_SEL
688                   && selection != 0
689                   && selection <= contents.length())
690            {
691                /*
692                 *   Invoke the sub-menu, checking for a QUIT result.  If
693                 *   the user isn't quitting, we'll display our own menu
694                 *   again; in this case, update it now, in case something
695                 *   in the sub-menu changed our own contents.
696                 */
697                loc = contents[selection].showMenuHtml(topMenu);
698
699                /* see what we have */
700                switch (loc)
701                {
702                case M_UP:
703                    /* they want to go directly to the previous menu */
704                    loc = M_SEL;
705                    --selection;
706                    break;
707
708                case M_DOWN:
709                    /* they want to go directly to the next menu */
710                    loc = M_SEL;
711                    ++selection;
712                    break;
713
714                case M_PREV:
715                    /* they want to return to this menu level */
716                    loc = nil;
717
718                    /* update our contents */
719                    updateContents();
720
721                    /* make sure we refresh the title area */
722                    refreshTitle = true;
723                    break;
724                }
725            }
726        } while (loc != M_QUIT && loc != M_PREV);
727
728        /* return the next status */
729        return loc;
730    }
731
732    /*
733     *   showTopMenuBanner creates the banner for the menu using the
734     *   banner API.  The banner contains the title of the menu on the
735     *   left and the navigation keys on the right.
736     */
737    showTopMenuBanner(topMenu)
738    {
739        /* do not show the top banner if we're in text mode */
740        if (statusLine.statusDispMode == StatusModeText)
741            return;
742
743        /*
744         *   Since the status line has already figured out the terp's
745         *   capabilities, piggyback off of what it learned.  If we're
746         *   using banner API mode, show our banner window.
747         */
748        if (statusLine.statusDispMode == StatusModeApi)
749        {
750            /* banner API mode - show our banner window */
751            topMenuBanner.showBanner(nil, BannerFirst, nil, BannerTypeText,
752                                     BannerAlignTop, nil, nil,
753                                     BannerStyleBorder | BannerStyleTabAlign);
754
755            /* advise the terp that we need two lines */
756            topMenuBanner.setSize(2, BannerSizeAbsolute, true);
757        }
758
759        /* show our contents */
760        refreshTopMenuBanner(topMenu);
761    }
762
763    /*
764     *   Refresh the contents of the top bar with the instructions
765     */
766    refreshTopMenuBanner(topMenu)
767    {
768        local oldStr;
769
770        /* clear our old contents using the appropriate mode */
771        switch (statusLine.statusDispMode)
772        {
773        case StatusModeApi:
774            /* clear the window */
775            topMenuBanner.clearWindow();
776
777            /* set the default output stream to our menu window */
778            oldStr = topMenuBanner.setOutputStream();
779
780            /* set our color scheme */
781            "<body bgcolor=<<topbarbg>> text=<<topbarfg>> >";
782            break;
783
784        case StatusModeTag:
785            /* start a new <banner> tag */
786            "<banner id=MenuTitle align=top><body bgcolor=<<topbarbg>>
787            text=<<topbarfg>> >";
788            break;
789        }
790
791        /* show our heading */
792        say(heading);
793
794        /* show our keyboard assignments */
795        libMessages.menuInstructions(topMenu.keyList, prevMenuLink);
796
797        /* finish up according to our mode */
798        switch (statusLine.statusDispMode)
799        {
800        case StatusModeApi:
801            /* banner API mode - restore the old output stream */
802            outputManager.setOutputStream(oldStr);
803
804            /* size the window to the actual content size */
805            topMenuBanner.sizeToContents();
806            break;
807
808        case StatusModeTag:
809            /* close the <banner> tag */
810            "</banner>";
811            break;
812        }
813    }
814
815    /*
816     *   Remove the top banner window
817     */
818    removeTopMenuBanner()
819    {
820        /* remove the window according to the banner mode */
821        switch (statusLine.statusDispMode)
822        {
823        case StatusModeApi:
824            /* banner API mode - remove the banner window */
825            topMenuBanner.removeBanner();
826            break;
827
828        case StatusModeTag:
829            /* banner tag mode - remove our banner tag */
830            "<banner remove id=MenuTitle>";
831        }
832    }
833
834    /*
835     *   Remove the status line banner prior to displaying the menu
836     */
837    removeStatusLine()
838    {
839        local oldStr;
840
841        /* remove the banner according to our banner display mode */
842        switch (statusLine.statusDispMode)
843        {
844        case StatusModeApi:
845            /*
846             *   banner API mode - simply set the banner window to zero
847             *   size, which will effectively make it invisible
848             */
849            statuslineBanner.setSize(0, BannerSizeAbsolute, nil);
850            break;
851
852        case StatusModeTag:
853            /* <banner> tag mode - remove the statusline banner */
854            oldStr = outputManager.setOutputStream(statusTagOutputStream);
855            "<banner remove id=StatusLine>";
856            outputManager.setOutputStream(oldStr);
857            break;
858
859        case StatusModeText:
860            /* tads2-style statusline - there's no way to remove it */
861            break;
862        }
863    }
864
865    /*
866     *   Get the next menu in our list following the given menu.  Returns
867     *   nil if we don't find the given menu, or the given menu is the last
868     *   menu.
869     */
870    getNextMenu(menu)
871    {
872        /* find the menu in our contents list */
873        local idx = contents.indexOf(menu);
874
875        /*
876         *   if we found it, and it's not the last, return the menu at the
877         *   next index; otherwise return nil
878         */
879        return (idx != nil && idx < contents.length()
880                ? contents[idx + 1] : nil);
881    }
882
883    /*
884     *   Get the menu previous tot he given menu.  Returns nil if we don't
885     *   find the given menu or the given menu is the first one.
886     */
887    getPrevMenu(menu)
888    {
889        /* find the menu in our contents list */
890        local idx = contents.indexOf(menu);
891
892        /*
893         *   if we found it, and it's not the first, return the menu at the
894         *   prior index; otherwise return nil
895         */
896        return (idx != nil && idx > 1 ? contents[idx - 1] : nil);
897    }
898;
899
900/*
901 *   MenuTopicItem displays a series of entries successively.  This is
902 *   intended to be used for displaying something like a list of hints for
903 *   a topic.  Set menuContents to be a list of strings to be displayed.
904 */
905class MenuTopicItem: MenuItem
906    /* the name of this topic, as it appears in our parent menu */
907    title = ''
908
909    /* heading, displayed while we're showing this topic list */
910    heading = (title)
911
912    /* hyperlink text for showing the next menu */
913    nextMenuTopicLink = (libMessages.nextMenuTopicLink)
914
915    /*
916     *   A list of strings and/or MenuTopicSubItem items.  Each one of
917     *   these is meant to be something like a single hint on our topic.
918     *   We display these items one at a time when our menu item is
919     *   selected.
920     */
921    menuContents = []
922
923    /* the index of the last item we displayed from our menuContents list */
924    lastDisplayed = 1
925
926    /*
927     *   The maximum number of our sub-items that we'll display at once.
928     *   This is only used on interpreters with banner capabilities, and is
929     *   ignored in full-screen mode.
930     */
931    chunkSize = 6
932
933    /* we'll display this after we've shown all of our items */
934    menuTopicListEnd = (libMessages.menuTopicListEnd)
935
936    /*
937     *   Display and run our menu in text mode.
938     */
939    showMenuText(topMenu)
940    {
941        local i, len, loc;
942
943        /* remember the key list */
944        curKeyList = topMenu.keyList;
945
946        /* update our contents, as needed */
947        updateContents();
948
949        /* get the number of items in our list */
950        len = menuContents.length();
951
952        /* show our heading and instructions */
953        "\n<b><<heading>></b>";
954        libMessages.textMenuTopicPrompt();
955
956        /*
957         *   Show all of the items up to and including the last one we
958         *   displayed on any past invocation.  Append "[#/#]" to each
959         *   item to show where we are in the overall list.
960         */
961        for (i = 1 ; i <= lastDisplayed ; ++i)
962        {
963            /* display this item */
964            displaySubItem(i, i == lastDisplayed, '\b');
965        }
966
967        /* main input loop */
968        for (;;)
969        {
970            local key;
971
972            /* read a keystroke */
973            key = inputManager.getKey(nil, nil).toLower();
974
975            /* look it up in the key list */
976            loc = topMenu.keyList.indexWhich({x: x.indexOf(key) != nil});
977
978            /* check to see if they want to quit the menu system */
979            if (loc == M_QUIT)
980                return M_QUIT;
981
982            /*
983             *   check to see if they want to return to the previous menu;
984             *   if we're out of items to show, return to the previous
985             *   menu on any other keystrok as well
986             */
987            if (loc == M_PREV || self.lastDisplayed == len)
988                 return M_PREV;
989
990            /* for any other keystroke, just show the next item */
991            lastDisplayed++;
992            displaySubItem(lastDisplayed, lastDisplayed == len, '\b');
993        }
994    }
995
996    /*
997     *   Display and run our menu in HTML mode.
998     */
999    showMenuHtml(topMenu)
1000    {
1001        local len;
1002        local topIdx;
1003
1004        /* remember the key list */
1005        curKeyList = topMenu.keyList;
1006
1007        /* refresh the top instructions bar with our heading */
1008        refreshTopMenuBanner(topMenu);
1009
1010        /* update our contents, as needed */
1011        updateContents();
1012
1013        /* get the number of items in our list */
1014        len = menuContents.length();
1015
1016        /*
1017         *   initially show the first item at the top of the window (we
1018         *   might scroll the list later to show a later item at the top,
1019         *   if we're limiting the number of items we can show at once)
1020         */
1021        topIdx = 1;
1022
1023        /* main interaction loop */
1024        for (;;)
1025        {
1026            local lastIdx;
1027
1028            /* redraw the window with the current top item */
1029            lastIdx = redrawWinHtml(topIdx);
1030
1031            /* process input */
1032            for (;;)
1033            {
1034                local events;
1035                local loc;
1036                local key;
1037
1038                /* read an event */
1039                events = inputManager.getEvent(nil, nil);
1040                switch(events[1])
1041                {
1042                case InEvtHref:
1043                    /* check for a 'next' or 'previous' command */
1044                    switch(events[2])
1045                    {
1046                    case 'next':
1047                        /* we want to go to the next item */
1048                        loc = M_SEL;
1049                        break;
1050
1051                    case 'previous':
1052                        /* we want to go to the previous menu */
1053                        loc = M_PREV;
1054                        break;
1055
1056                    default:
1057                        /* ignore other hyperlinks */
1058                        loc = nil;
1059                    }
1060                    break;
1061
1062                case InEvtKey:
1063                    /* get the key, converting alphabetic to lower case */
1064                    key = events[2].toLower();
1065
1066                    /* look up the keystroke in our key mappings */
1067                    loc = topMenu.keyList.indexWhich(
1068                        {x: x.indexOf(key) != nil});
1069                    break;
1070                }
1071
1072                /*
1073                 *   if they're quitting or returning to the previous
1074                 *   menu, we're done
1075                 */
1076                if (loc == M_QUIT || loc == M_PREV)
1077                    return loc;
1078
1079                /* advance to the next item if desired */
1080                if (loc == M_SEL)
1081                {
1082                    /*
1083                     *   if the last item we showed is the last item in
1084                     *   our entire list, then the normal selection keys
1085                     *   simply return to the previous menu
1086                     */
1087                    if (lastIdx == len)
1088                        return M_PREV;
1089
1090                    /*
1091                     *   If we haven't yet reached the last revealed item,
1092                     *   it means we're limited by the chunk size, so show
1093                     *   the next chunk.  Otherwise, reveal the next item.
1094                     */
1095                    if (lastIdx < lastDisplayed)
1096                    {
1097                        /* advance to the next chunk */
1098                        topIdx += chunkSize;
1099                    }
1100                    else
1101                    {
1102                        /* reveal the next item */
1103                        ++lastDisplayed;
1104
1105                        /*
1106                         *   if we're not in full-screen mode, and we've
1107                         *   already filled the window, scroll down a line
1108                         *   by advancing the index of the item at the top
1109                         *   of the window
1110                         */
1111                        if (!fullScreenMode
1112                            && lastIdx == topIdx + chunkSize - 1)
1113                            ++topIdx;
1114                    }
1115
1116                    /* done processing input */
1117                    break;
1118                }
1119            }
1120        }
1121    }
1122
1123    /*
1124     *   redraw the window in HTML mode, starting with the given item at
1125     *   the top of the window
1126     */
1127    redrawWinHtml(topIdx)
1128    {
1129        local len;
1130        local idx;
1131
1132        /* get the number of items in our list */
1133        len = menuContents.length();
1134
1135        /* check the banner mode (based on the statusline mode) */
1136        if (statusLine.statusDispMode == StatusModeApi)
1137        {
1138            /* banner API mode - clear the window */
1139            contentsMenuBanner.clearWindow();
1140
1141            /*
1142             *   Advise the terp of our best guess at our size: assume one
1143             *   line per item, and max out at either our actual number of
1144             *   items or our maximum chunk size, whichever is lower.  If
1145             *   we're in full-screen mode, though, simply size to 100% of
1146             *   the available space.
1147             */
1148            if (fullScreenMode)
1149                contentsMenuBanner.setSize(100, BannerSizePercent, nil);
1150            else
1151                contentsMenuBanner.setSize(chunkSize < len ? chunkSize : len,
1152                                           BannerSizeAbsolute, true);
1153
1154            /* set up our color scheme */
1155            "<body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
1156        }
1157        else
1158        {
1159            /* <banner> tag mode - open our tag */
1160            "<banner id=MenuBody align=top
1161            <<fullScreenMode ? 'height=100%' : 'border'>>
1162            ><body bgcolor=<<bgcolor>> text=<<fgcolor>>  >";
1163        }
1164
1165        /* start a table to show the items */
1166        "<table><tr><td width=<<self.indent>> > </td><td>";
1167
1168        /* show the items */
1169        for (idx = topIdx ; ; ++idx)
1170        {
1171            local isLast;
1172
1173            /*
1174             *   Note if this is the last item we're going to show just
1175             *   now.  It's the last item we're showing if it's the last
1176             *   item in the list, or it's the 'lastDisplayed' item, or
1177             *   we've filled out the chunk size.
1178             */
1179            isLast = (idx == len
1180                      || (!fullScreenMode && idx == topIdx + chunkSize - 1)
1181                      || idx == lastDisplayed);
1182
1183            /* display the next item */
1184            displaySubItem(idx, isLast, '<br>');
1185
1186            /* if that was the last item, we're done */
1187            if (isLast)
1188                break;
1189        }
1190
1191        /* finish the table */
1192        "</td></tr></table>";
1193
1194        /* finish the window */
1195        switch(statusLine.statusDispMode)
1196        {
1197        case StatusModeApi:
1198            /* if we're not in full-screen mode, set the final size */
1199            if (!fullScreenMode)
1200                contentsMenuBanner.sizeToContents();
1201            break;
1202
1203        case StatusModeTag:
1204            /* end the banner tag */
1205            "</banner>";
1206            break;
1207        }
1208
1209        /* return the index of the last item displayed */
1210        return idx;
1211    }
1212
1213    /*
1214     *   Display an item from our list.  'idx' is the index in our list of
1215     *   the item to display.  'lastBeforeInput' indicates whether or not
1216     *   this is the last item we're going to show before pausing for user
1217     *   input.  'eol' gives the newline sequence to display at the end of
1218     *   the line.
1219     */
1220    displaySubItem(idx, lastBeforeInput, eol)
1221    {
1222        local item;
1223
1224        /* get the item from our list */
1225        item = menuContents[idx];
1226
1227        /*
1228         *   show the item: if it's a simple string, just display it;
1229         *   otherwise, assume it's an object, and call its getItemText
1230         *   method to get its text (and possibly trigger any needed
1231         *   side-effects)
1232         */
1233        say(dataType(item) == TypeSString ? item : item.getItemText());
1234
1235        /* add the [n/m] indicator */
1236        libMessages.menuTopicProgress(idx, menuContents.length());
1237
1238        /*
1239         *   if this is the last item we're going to display before asking
1240         *   for input, and it's not the last item in the list overall,
1241         *   and we're in HTML mode, show a hyperlink for advancing to the
1242         *   next item
1243         */
1244        if (lastBeforeInput && idx != menuContents.length())
1245            "&emsp;<<aHrefAlt('next', nextMenuTopicLink, '')>>";
1246
1247        /* show the desired line-ending separator */
1248        say(eol);
1249
1250        /* if it's the last item, add the end-of-list marker */
1251        if (idx == menuContents.length())
1252            "<<menuTopicListEnd>>\n";
1253    }
1254;
1255
1256/*
1257 *   A menu topic sub-item can be used to represent an item in a
1258 *   MenuTopicItem's list of display items.  This can be useful when
1259 *   displaying a topic must trigger a side-effect.
1260 */
1261class MenuTopicSubItem: object
1262    /*
1263     *   Get the item's text.  By default, we just return an empty string.
1264     *   This should be overridden to return the appropriate text, and can
1265     *   also trigger any desired side-effects.
1266     */
1267    getItemText() { return ''; }
1268;
1269
1270/*
1271 *   Long Topic Items are used to print out big long gobs of text on a
1272 *   subject.  Use it for printing long treatises on your design
1273 *   philosophy and the like.
1274 */
1275class MenuLongTopicItem: MenuItem
1276    /* the title of the menu, shown in parent menus */
1277    title = ''
1278
1279    /* the heading, shown while we're displaying our contents */
1280    heading = (title)
1281
1282    /* either a string to be displayed, or a routine */
1283    menuContents = ''
1284
1285    /*
1286     *   Flag - this is a "chapter" in a list of chapters.  If this is set
1287     *   to true, then we'll offer the options to proceed directly to the
1288     *   next and previous chapters.  If this is nil, we'll simply wait for
1289     *   acknowledgment and return to the parent menu.
1290     */
1291    isChapterMenu = nil
1292
1293    /* the message we display at the end of our text */
1294    menuLongTopicEnd = (libMessages.menuLongTopicEnd)
1295
1296    /* display and run our menu in text mode */
1297    showMenuText(topMenu)
1298    {
1299        local ret;
1300
1301        /* remember the key list */
1302        curKeyList = topMenu.keyList;
1303
1304        /* take over the entire screen */
1305        cls();
1306
1307        /* use the common handling */
1308        ret = showMenuCommon(topMenu);
1309
1310        /* we're done, so clear the screen again */
1311        cls();
1312
1313        /* return the result from the common handler */
1314        return ret;
1315    }
1316
1317    /* display and run our menu in HTML mode */
1318    showMenuHtml(topMenu)
1319    {
1320        local ret;
1321        local oldStr;
1322
1323        /* remember the key list */
1324        curKeyList = topMenu.keyList;
1325
1326        /* update our contents, as needed */
1327        updateContents();
1328
1329        /* hide the two menu system banners */
1330        if (statusLine.statusDispMode == StatusModeApi)
1331        {
1332            local flags;
1333
1334            /*
1335             *   Our banner window might already be showing, because we
1336             *   could be coming here directly from a prior chapter.  If it
1337             *   is, we don't need to show it again.  If it isn't showing,
1338             *   show it now.
1339             */
1340            if (longTopicBanner.handle_ != nil)
1341            {
1342                /* simply clear our existing window */
1343                longTopicBanner.clearWindow();
1344            }
1345            else
1346            {
1347                /* hide the top menu banner */
1348                topMenuBanner.setSize(0, BannerSizeAbsolute, nil);
1349
1350                /* figure our flags */
1351                flags = (fullScreenMode ? 0 : BannerStyleBorder)
1352                    | BannerStyleVScroll
1353                    | BannerStyleMoreMode
1354                    | BannerStyleAutoVScroll;
1355
1356                /* banner API mode - show the long-topic banner */
1357                longTopicBanner.showBanner(contentsMenuBanner, BannerLast,
1358                                           nil, BannerTypeText,
1359                                           BannerAlignTop,
1360                                           100, BannerSizePercent, flags);
1361            }
1362
1363            /* use its output stream */
1364            oldStr = longTopicBanner.setOutputStream();
1365
1366            /* set up our color scheme in the new banner */
1367            "<body bgcolor=<<bgcolor>> text=<<fgcolor>> >";
1368        }
1369        else
1370        {
1371            /*
1372             *   use the main game window output stream for printing this
1373             *   text (we need to switch back to it explicitly, because
1374             *   HTML-mode menus normally run in the context of the menu's
1375             *   banner output stream)
1376             */
1377            oldStr = outputManager.setOutputStream(mainOutputStream);
1378
1379            /* we're using the main window, so clear out the game text */
1380            cls();
1381        }
1382
1383        try
1384        {
1385            /* show our contents using the normal text display */
1386            ret = showMenuCommon(topMenu);
1387        }
1388        finally
1389        {
1390            local chapter;
1391
1392            /* restore the original output stream */
1393            outputManager.setOutputStream(oldStr);
1394
1395            /*
1396             *   If we're going directly to the next or previous "chapter,"
1397             *   and the next menu is itself a long-topic item, don't clean
1398             *   up the screen: simply leave it in place for the next item.
1399             *   First, check for a next/previous chapter return code, and
1400             *   get the menu object for the next/previous chapter.
1401             */
1402            if (ret == M_DOWN)
1403                chapter = location.getNextMenu(self);
1404            else if (ret == M_UP)
1405                chapter = location.getPrevMenu(self);
1406
1407            /*
1408             *   if we have a next/previous chapter, and it's a long-topic
1409             *   menu, we don't need cleanup; otherwise we do
1410             */
1411            if (isChapterMenu
1412                && chapter != nil && chapter.ofKind(MenuLongTopicItem))
1413            {
1414                /* we don't need any cleanup */
1415            }
1416            else
1417            {
1418                /* clean up the window */
1419                if (statusLine.statusDispMode == StatusModeApi)
1420                {
1421                    /* API mode - remove our long-topic banner */
1422                    longTopicBanner.removeBanner();
1423                }
1424                else
1425                {
1426                    /* tag mode - we used the main game window, so clear it */
1427                    cls();
1428                }
1429
1430                /* restore the top menu banner window */
1431                topMenu.showTopMenuBanner(topMenu);
1432            }
1433        }
1434
1435        /* return the quit/continue indication */
1436        return ret;
1437    }
1438
1439    /* show our contents - common handler for text and HTML modes */
1440    showMenuCommon(topMenu)
1441    {
1442        local evt, key, loc, nxt;
1443
1444        /* update our contents, as needed */
1445        updateContents();
1446
1447        /* show our heading, centered */
1448        "<CENTER><b><<heading>></b></CENTER>\b";
1449
1450        /* show our contents */
1451        "<<menuContents>>\b";
1452
1453        /* check to see if we should offer chapter navigation */
1454        nxt = (isChapterMenu ? location.getNextMenu(self) : nil);
1455
1456        /* if there's a next chapter, show how we can navigate to it */
1457        if (nxt != nil)
1458        {
1459            /* show the navigation */
1460            libMessages.menuNextChapter(topMenu.keyList, nxt.title,
1461                                        'next', 'menu');
1462        }
1463        else
1464        {
1465            /* no chaptering - just print the ending message */
1466            "<<menuLongTopicEnd>>";
1467        }
1468
1469        /* wait for an event */
1470        for (;;)
1471        {
1472            evt = inputManager.getEvent(nil, nil);
1473            switch(evt[1])
1474            {
1475            case InEvtHref:
1476                /* check for a 'next' or 'prev' command */
1477                if (evt[2] == 'next')
1478                    return M_DOWN;
1479                else if (evt[2] == 'prev')
1480                    return M_UP;
1481                else if (evt[2] == 'menu')
1482                    return M_PREV;
1483                break;
1484
1485            case InEvtKey:
1486                /* get the key */
1487                key = evt[2].toLower();
1488
1489                /*
1490                 *   if we're in plain text mode, add a blank line after
1491                 *   the key input
1492                 */
1493                if (statusLine.statusDispMode == StatusModeText)
1494                    "\b";
1495
1496                /* look up the command key */
1497                loc = topMenu.keyList.indexWhich({x: x.indexOf(key) != nil});
1498
1499                /*
1500                 *   if it's 'next', either proceed to the next menu or
1501                 *   return to the previous menu, depending on whether
1502                 *   we're in chapter mode or not
1503                 */
1504                if (loc == M_SEL)
1505                    return (nxt == nil ? M_PREV : M_DOWN);
1506
1507                /* if it's 'prev', return to the previous menu */
1508                if (loc == M_PREV || loc == M_QUIT)
1509                    return loc;
1510
1511                /* ignore other keys */
1512                break;
1513            }
1514        }
1515    }
1516;
1517