ToggleMenus.jl

This package provides a ToggleMenu: a TerminalMenu where each option has one of several states, which may be toggled through with the [Tab] key, cycled back and forth with the left and right arrow keys, or selected directly by entering the letter representing that state.

It exports two types: ToggleMenu itself, and ToggleMenuMaker, which is used to prepare a template from which any number of ToggleMenus may be created.

Using ToggleMenu

Like any other TerminalMenu, a ToggleMenu is launched with request.

julia> request(menu)Press [t]odo, [d]one, [b]locked, [s]omeday, or [tab] to cycle.
 → [🔵] invent antigravity
   [🔵] pay robot butler bill
   [🔴] escape the gravity well
   [🔵] prepare Tuvan eggplant with kumis sauce
   [🔵] buy stock in TurboEncabulator LLC.
   [🔴] trade Bitcoin for ammunition

Pressing s, or [Tab] three times:

Press [t]odo, [d]one, [b]locked, [s]omeday, or [tab] to cycle.
 → [🤔] invent antigravity
   [🔵] pay robot butler bill
   [🔴] escape the gravity well
   [🔵] prepare Tuvan eggplant with kumis sauce
   [🔵] buy stock in TurboEncabulator LLC.
   [🔴] trade Bitcoin for ammunition

Down arrow some, hit tab

Press [t]odo, [d]one, [b]locked, [s]omeday, or [tab] to cycle.
   [🤔] invent antigravity
   [🔵] pay robot butler bill
   [🔴] escape the gravity well
 → [🟢] prepare Tuvan eggplant with kumis sauce
   [🔵] buy stock in TurboEncabulator LLC.
   [🔴] trade Bitcoin for ammunition

When you're all set, hit [Enter], or quit with q.

So that's how a ToggleMenu works, and how users will use them. Now let's cover how to create them, and how to work with the results.

ToggleMenuMaker

While it's possible to create a ToggleMenu directly, by calling the constructor, this is not the intended workflow. Toggle menus have more setup associated with them than the usual sort of TerminalMenu, and it's often the case that one will want to use one sort of menu to present many menus with different data.

ToggleMenus provides a ToggleMenuMaker for setting up these sorts of templates. This is a callable struct, which will return a menu when supplied with the remaining fields. Even if you just want a once-off menu, you'll want to make a ToggleMenuMaker and then call it, because calling the constructor directly bypasses several sanity checks, and requires correctly providing all default values, which gets tricky.

Such a workflow looks like this.

using ToggleMenus

header = "a sample togglemenu, select [a], [b], [c]"
settings = ['a', 'b', 'c']
icons = ["A", "B", "C"]

template = ToggleMenuMaker(header, settings, icons; charset=:unicode)
options = ["first option", "second option", "third option"]
menu = template(options)
a sample togglemenu, select [a], [b], [c]
 → [A] first option
   [A] second option
   [A] third option

At the REPL, a menu will show as it will be displayed when passed to request, this is useful for interactively writing code to put the menu into the desired initial state.

Note that charset=:unicode is one of the configurations for TerminalMenus. Any such keyword arguments are passed through to TerminalMenus, except for ToggleMenu-specific ones. Custom configurations are always passed to the ToggleMenuMaker, not to the menu itself.

You may notice that all of the options are in the initial setting, this is the default when custom selections aren't provided. To provide a different initial selection state, pass that in next:

selections = ['a', 'b', 'c']
menu2 = template(options, selections)
menu2.cursor[] = 2
menu2
a sample togglemenu, select [a], [b], [c]
   [A] first option
 → [B] second option
   [C] third option

This also shows that the ToggleMenu has a Ref on the .cursor field, which is provided to the TerminalMenus code as a keyword in the overloaded methods of request defined for toggle menus. This allows user functions to change the cursor line directly, in a way which the menu code understands.

In the case where the header of the menu should be custom to each menu, pass that first:

otherheader = "A different header, select [a], [b], [c]"
template(otherheader, options, selections)
A different header, select [a], [b], [c]
 → [A] first option
   [B] second option
   [C] third option

The header can also be a user function, see below.

Settings and Icons

Settings provide the possible states for any menu option. They have to be a Vector{Char}, and really should be characters which are easy to type on a keyboard. Settings may be toggled through with tab, or cycled with the left and right arrow keys, but also set directly by pressing the key which sends that character.

Icons are optional, the ToggleMenu will use the settings directly if they aren't provided.

template2 = ToggleMenuMaker(header, settings)
menu3 = template2(options, selections)
a sample togglemenu, select [a], [b], [c]
 > [a] first option
   [b] second option
   [c] third option

When provided, they can be a Vector of Strings or Chars, but not a mix. Converting a mixed Vector of Char and String to a Vector{String} is easy: [string(c) for c in vec] will do the trick.

ToggleMenus will handle spacing if icons are of different lengths:

settings = ['l', 'm', 'c']
icons = ["Larry", "Moe", "Curly"]
header = "Please assign a Stooge to each line:"

stoogetemplate = ToggleMenuMaker(header, settings, icons; scroll_wrap=true)
options = [
    "Nyuk nyuk nyuk!",
    "A burden the hand is worth two in the bush.",
    "He's got five dollars!!!!",
    "Don't worry, I got what it takes to cure him.",
    "This is gettin' on my noives!",
]

selections = ['m', 'l', 'c', 'm', 'c']

menu = stoogetemplate(options, selections)
Please assign a Stooge to each line:
 > [Moe]   Nyuk nyuk nyuk!
   [Larry] A burden the hand is worth two in the bush.
   [Curly] He's got five dollars!!!!
   [Moe]   Don't worry, I got what it takes to cure him.
   [Curly] This is gettin' on my noives!

Doing so in a way which correctly handles terminal color:

coloricons = ["\e[32mLarry\e[m", "\e[33mMoe\e[m", "\e[36mCurly\e[m"]
colorfulstooges = ToggleMenuMaker(header, settings, coloricons)

colorfulstooges(options, selections)
Please assign a Stooge to each line:
 > [Moe]   Nyuk nyuk nyuk!
   [Larry] A burden the hand is worth two in the bush.
   [Curly] He's got five dollars!!!!
   [Moe]   Don't worry, I got what it takes to cure him.
   [Curly] This is gettin' on my noives!

Although note that textwidth, which the measurement uses, via the excellent StringManipulation.jl, is, shall we say, not infallible:

julia> textwidth("🫶🏼")
4

For icons, this can be compensated for, if necessary, by setting menumaker.maxicon to the correct value.

When a menu is provided with initial selections, the ToggleMenuMaker will check that those selections are valid, and throw an error if they aren't.

The '\0' Special Case

Sometimes it's useful to have lines in the menu which aren't associated with states. This is necessary to have multiple lines, because the option printer will replace all newlines with the string "\n" (aka "\n"), and truncate text to fit the width of the display.

Note

The code which handles option truncation uses the same escape-code-aware version of textwidth as icon printing, meaning that for Unicode where textwidth gives the wrong answer, truncation may be incorrect. Code which needs to handle this situation will have to perform truncation itself, using displaysize(stdout) and such manual adjustments as prove to be necessary.

To make an option un-togglable, set the desired lines to '\0' in the selections passed in to the menu. You only need to include it in the settings if you want an icon which isn't just enough spaces to pad alignment correctly. Note that if you do provide such an icon, it will not be wrapped in braces ([ and ] by default, but this is configurable, see below), so such lines will be visually distinguishable from selectable ones.

In the following example, the 7 passed to ToggleMenuMaker is the pagesize, controlling how many menu items are displayed. This defaults to 15.

options = [string(c)^15 for c in 'a':'z']
settings = ['y', 'n']
icons = ["👍", "👎"]
selections = [c ∉ ['a', 'e', 'i', 'o', 'u'] ? '\0' : rand(['y', 'n']) for c in 'a':'z']
template = ToggleMenuMaker("which vowels do you like?", settings, icons, 7, charset=:unicode)
menu = template(options, selections)
which vowels do you like?
 → [👍] aaaaaaaaaaaaaaa
        bbbbbbbbbbbbbbb
        ccccccccccccccc
        ddddddddddddddd
   [👍] eeeeeeeeeeeeeee
        fffffffffffffff
↓       ggggggggggggggg

Press [Down] then [Tab]:

which vowels do you like?
   [👍] aaaaaaaaaaaaaaa
        bbbbbbbbbbbbbbb
        ccccccccccccccc
        ddddddddddddddd
 → [👎] eeeeeeeeeeeeeee
        fffffffffffffff
↓       ggggggggggggggg

Practical menus will generally have the first line togglable, but in the event that it isn't, the default cursor position will be on the first togglable line. It's possible to override this by setting the cursor to point to an inert line. This has no practical purpose, but the construction and request logic won't correct it. It is harmless to have the cursor pointing at an inert line, or for all selections to be set to '\0', in that ToggleMenus will not throw an error, or go into an infinite loop trying to find a valid line to rest the cursor on.

ToggleMenus will pick a valid line when paging up or down, but the effect of [Home] and [End] are hard-coded in the REPL, and if your first or last lines aren't togglable, the cursor will still point at them. Any further navigation will return the cursor to a usable line, however.

Return Values

The TerminalMenus interface has two distinct types of return: these are called cancel and pick. Cancel is what you get from pressing [q], and pick is what you get from pressing [Enter]. Either form of exit then calls selected, which prepares the return values.

In either circumstance, ToogleMenus will return a Vector of Tuples, where [1] is the selection, and [2] is the option it corresponds to. We do this, rather than merely returning the selections, so that user functions can rearrange and delete lines. If canceled, this Vector will be exactly [('\0', "")]. This makes it convenient to write code which iterates over the results and does things with states which aren't '\0', since in the event of a cancel, such code will do nothing. If you wish to specifically detect the return condition, import didcancelmenu from ToggleMenus and call it on the result.

User Functions

To customize the behavior of the menus, a ToggleMenuMaker may be configured with either or both user functions. The header, passed first to the menu maker, is normally a String, but may also be a function. This function will receive the menu as its only argument, and must return a string, which is then printed as a header. This will be called any time a keystroke is entered, after the keystroke, and before the menu is printed.

The other optional user function is keypress. The TerminalMenus code handles [Up] and [Down], [PgUp] and [PgDown], [Home] and [End], [q], and [Enter], while ToggleMenus also defines [Tab], [Left], [Right], and any keystroke corresponding to a setting. If the keystroke doesn't correspond to this, menu.keypress(menu, i::UInt32) is called.

This is provided to a ToggleMenuMaker as a keyword:

ToggleMenuMaker(settings, icons; keypress=λ)

A keypress function must return a Bool. A true value will exit the menu, while false will not. The UInt32 value comes from a bespoke keypress parser found only in TerminalMenus, with somewhat disappointing behavior. Notably, it will turn anything starting with '\x1b', escape, into a bare escape, if it doesn't happen to read one of the predefined keystrokes. I had wanted to add [Esc] for quitting, but too many unrelated keystrokes trigger it. Unlike the rest of the REPL, reading a combined keystroke is quite out of the question, although control-$letter, which in a terminal sends the associated control code, are passed through to the keystroke function successfully.

That complaint out of the way, turning the UInt32 into a Char will, for ASCII at least, provide an accurate accounting of the keystroke, with which, you may do as you please. To empirically determine the result of various keystrokes, you can use the following function as a keypress function for a test ToggleMenu.

function reportkey(menu, i)
    menu.header = repr(string(Char(i))) * ", " * string(i)
    return false
end

To facilitate the use of user functions, a ToggleMenu has a bonus field, .aux, of type Any, which defaults to nothing. If you want this to have a value, you must set it on the menu before calling request. A future release might make this a keyword option when calling the ToggleMenuMaker to construct a menu, I would cheerfully accept a PR which adds this.

Other Configuration

The ToggleMenuMaker will accept all keywords defined in TerminaMenus, as well as braces=("【","】"), to provide an example argument. This is a Tuple of Strings, which will enclose the togglable icons on selectable lines. The printer also accounts for the width of these when deciding where to truncate lines.