In the previous article, we specified the overall behavior we want from the TODO application. In this article, we’re going to focus on one particular design element: the menus.
The behavior of each menu screen is consistent (if you are old enough to have used BBSes back in the mid-80s/-90s, think of how most BBS menus worked back then. That’s a good analog for the overall look and feel I’m shooting for). This suggests that each menu screen can be described by a data structure, and a very small number of routines can be used to display and handle menu input.
The simplest menu is the entry menu. We can fairly easily describe this menu using a made-up domain-specific language, like so:
MENU Entry {
TITLE "Welcome to TODO App V0.1".
ITEM "Create a new TODO list."
KEY '1'
ON SELECT CALL CreateNewToDoList
THEN GOTO Main.
ITEM "Load an old TODO list."
KEY '2'
ON SELECT CALL LoadToDoList
THEN GOTO Main
ON ERROR GOTO Entry.
ITEM "Quit"
KEY 'Q'
ON SELECT RETURN.
}
The main menu can be described with a similar structure, albeit one with more items. The Show Existing TODO Items menu is different because it involves a customized display of items inline with the other menu options.
MENU ShowExisting {
ITEM "Next page."
KEY 'N'
ON SELECT CALL GoToNextPage
THEN GOTO ShowExisting.
ITEM "Previous page."
KEY 'P'
ON SELECT CALL GoToPrevPage
THEN GOTO ShowExisting.
ITEM "Toggle complete flag."
KEY 'X'
ON SELECT CALL ToggleCompleteFlag
THEN GOTO ShowExisting.
ITEM "Return to previous menu."
KEY 'Q'
ON SELECT RETURN.
BEFORE PROMPT CALL RenderCurrentToDoPage.
}
So, what we can see is that a MENU is a vector of ITEM-like descriptions.
typedef struct menu menu_t;
typedef struct item item_t;
typedef void callback_t();
struct menu {
char * title;
int count;
item_t * items;
callback_t *before_prompt;
};
struct item {
char * title;
callback_t * on_select;
menu_t * next_menu;
menu_t * error_menu;
char key;
}
This is a perfectly valid C/C++ way of creating this data. Since we’re writing the TODO app in assembly language (for reasons outlined in the first article), it would be easy to just say, “this is how we’ll do it.”
Resist this temptation. Let’s apply the Forth Software Development Process and consider how else we could approach this problem while still keeping the essential and desirable attributes. Besides being a C and assembly language programmer, I’m also a bit of a Forth and Rust developer as well (vis-a-vis the official VM/OS emulator is written in Rust). I’m going to skip over a native Forth implementation of the menu structure as a data structure, because it turns out not to be significantly different from a straight C implementation. However, a comparable Rust implementation is where things get interesting.
As a Rust developer, I find the self-referential nature of item_t
to be a red flag. This would be forbidden in Rust, (no?) thanks to the borrow checker’s rules.
The only way to handle this is to redefine our structures to avoid self-referentiality, as shown:
struct MenuCollection(Vec<Menu>);
type HMenu = usize;
struct Menu {
title: Option<String>,
items: Vec<Item>,
on_before_prompt: bool, // true if we want custom processing here.
}
struct Item {
title: String,
key: char,
action: Option<NextStep>,
next_menu: Option<HMenu>,
error_menu: Option<HMenu>,
}
enum NextStep {
BeforePrompt,
CreateNewToDoList,
LoadToDoList,
CreateToDoItem,
DiscardToDoItem,
DiscardAllCompleteItems,
// etc...
}
This code structure differs markedly from a pure C implementation in some key ways:
match
statement). After the event has been handled, the caller would be expected to re-enter the menu display/handling code again. This is actually huge; this is an event loop by definition.MenuDisplay
, not shown above for brevity); in some respects, this is the true activation frame of the display code. Since this structure is part of its operating state, and persists from call to call, one might consider implementing the menu display code as an iterator.Something like this:
// Start with the entry menu.
let mut menu_display = MenuDisplay::new(MID_ENTRY);
'menu_loop: loop {
let ns = menu_display.present();
match ns {
NextStep::QuitApp => break 'menu_loop,
NextStep::BeforePrompt => {
if menu_display.currently_showing(MID_SHOW_TODOS) {
// show current page of TODOs here...
}
}
NextStep::CreateNewToDoList => ...,
// etc...
}
}
Most people would look at that and think to themselves that this is a somewhat worse way to structure the program code. After all, the menu description is no longer purely defined in data, right? It is now spread between code and data, by virtue of the match statement. Or, is it?
See, that NextStep
enumeration is really a list of events. The menu_display.present()
function is something which reports received events. It could show a GUI, or it could show a text-based interface, or it could be something else entirely unforeseen. So, yes, you do need slightly more code to deal with this additional characteristic. However, it buys you significantly greater modularity!
Breaking the dispatch code into separate enum and dispatcher logic guarantees fully independent modules that are entirely self-contained. Yes, they share a common understanding of the set of events that can be generated (this is indeed part of what makes an interface an interface); however, you no longer have a menu structure which has a hard dependency on the link-time addresses of individual handler procedures. So, it turns out the Rust approach is more modular in the end after all. It is significantly easier to change the above code to support a future GUI, or even a command-line interface, or on AmigaOS machines, an ARexx port, etc.
OBSERVATION: Those familiar with Jackson Software Development might identify this code structure as well; it features very prominently in JSD when decomposing designs into actionable chunks of code.
ASIDE: I frequently reflect when I design some code or data structure that won’t work because the borrow checker refuses to accept how I constructed the code. After much gnashing of teeth, I usually give up in frustration, restructuring said code to deal with said annoyance. Then, after reviewing my work, I begrudgingly admit that I almost always end up with a markedly superior design in the end. But, I digress.
I described several methods of implementing a menu display procedure. Even though I’m implementing the TODO application in assembly language (out of current necessity), I intend on following a Rusty approach towards implementing the menu display and event handling logic. This ensures much better modularity, which translates to a design which is significantly easier to test, reuse in other projects, and evolve to work with future user interfaces.
Next, I’ll start implementing the actual menu display logic using test-driven techniques and either falsify or confirm my hypothesis.