-
Notifications
You must be signed in to change notification settings - Fork 2
Shared Memory UI
- 1. Motivation
- 2. Implementation Details
- 3. Conclusion
- 4. Appendix: Attach mode
- 5. Appendix:
endwin()
The shared memory UI (or "shm ui") is a program that displays a live view of Runtime's shared memory. This means the user can see in realtime whenever something in shared memory changes, including the state of devices, user inputs, and the run mode. The shm UI is a handy debugging tool and may be the first thing to check when the behavior of Runtime needs to be verified.
The shm UI is a C program that uses ncurses, a third-party dependency. ncurses provides an API that allows us to create text-based user interfaces through the terminal. As a result, you can ssh
into a Raspberry Pi with ncurses installed and run the shm ui with any terminal.
Since blocks in Runtime's shared memory will change over time, we'll provide only a high-level overview of how the UI is implemented.
The most notable ncurses functions take in a subset of the following as arguments:
- Position(s) using coordinates (x, y)
- A window
- A string
ncurses treats the UI as a grid of characters. The terminal is your canvas, and you specify what to draw at a specified place.
The character at (x = 0, y = 0) is located the top-left corner of the terminal. The character at (x = 1, y = 0) is the character to the right. The character at (x = 0, y = 1) is the character to the bottom. Note the position of a character is independent of the terminal window size, so a small terminal window may not display the entire UI.
Some functions don't take coordinates as arguments, but rather output at the current position of the "cursor". Most functions in ncurses have two variations: one that takes coordinates (ex: mvprintw()
and one that doesn't (ex: printw()
). The former tells ncurses to first move the cursor to the specified position before displaying output. This gives us more control over the UI and is preferred over the latter variation. The cursor can be moved with move()
or wmove()
. Functions like mvprintw()
are wrapper functions for a call to move()
then printw()
. Note that printw()
will move the cursor to the end of the output string.
You can initialize a rectangular "window", provided the coordinates of the desired top-left corner and desired height and width. A window is essentially a "sub-grid" of the larger UI.
Most functions in ncurses have two variations: one that takes a window as an argument (ex: wprintw()
) and one that doesn't (ex: printw()
). If you use a function that takes in a window as an argument, the provided coordinates will be interpreted as relative to the window. For example, if you pass in a window initialized to the center of the screen and coordinates (0, 0), the function will do work at the top-left corner of the window rather than the top-left corner of the entire terminal. This is useful because we can use simpler, relative positions rather than absolute positions.
If you use the window-variation of a function, the visual output will be contained inside the window. For example, if you use a function that may generate a large output (ex: printw()
displays a string), it will not overflow outside of the window's borders.
It's a good idea to use the window-variation of ncurses functions when possible because each window is isolated which makes for easier development and debugging.
printw()
takes the same arguments as printf()
and displays the string on the UI at the current cursor. You must, however, "refresh" the UI before any output will be displayed. This is because these functions merely modify data structures in the backend. Calling functions like printw()
won't actually generate output until you refresh the UI. An explicit call to refresh()
(or more preferred, wrefresh()
) will update the display.
For example:
printw("Hello!")
// No output
refresh()
Hello!
It's a good idea to refresh the page as late as possible (i.e. at the refresh rate). Refreshing the UI can be costly, so you should update every character in the UI as intended, then call refresh()
.
Since ncurses treats the UI as a grid of characters, each character in the string is independent of each other. This can cause surprising behavior that you should watch out for. Consider the following example of steps:
move(0, 0)
printw("Hello!")
move(0, 0)
printw("Hi!")
refresh()
Hi!lo!
Notice that printw("Hi!")
simply replaced the first three characters of printw("Hello!")
. It will not replace the original string entirely.
If the intended result is to print below or to the right of the first output, you should advance the cursor to the desired position first (see mvprintw()
). Alternatively, you may want to remove step 3 altogether, as printw()
advances the cursor to the end of the string.
If the intended result is to replace the entire string Hello!
, you should clear the original string first.
Due to the aforementioned phenonemon, we need to clear the output of the screen if we want to display new information. We can do this with clear()
, or more preferred, wclear()
. Consider the previous example but with clearing:
move(0, 0); // Advance cursor to (0, 0)
printw("Hello!"); // Print "Hello!"
// No output yet
refresh(); // Display "Hello!"
// Hello!
clear(); // Clear the entire UI
// No output
move(0, 0);
printw("Hi!"); // Print "Hi!"
// No output
refresh(); // Display "Hi!"
// Hi!
clear()
clears the entire screen, which can be costly--the UI may appear to flicker. It is better to use wclear()
and clear only the windows that need to be entirely cleared. This prevents unnecessarily clearing characters that are already cleared, like whitespace between windows.
You may find that even wclear()
causes screen flickering, especially if the window is big. In that case, you may want to use wclrtoeol()
. This function will affect only the specific row of the current cursor position. More specifically, it clears everything between the cursor and the right-most edge ("end of line", or "EOL") of the specified window.
wclrtoeol()
is very useful because we can
- Advance our cursor to a row,
- Clear the line, then
- Display the new information.
Ex: Assume these functions were called sometime earlier:
wmove(WIN, 0, 0);
printw("Hello!");
wrefresh(WIN);
The output would be
Hello!
Now let's say we want to replace Hello!
with Hi!
wmove(WIN, 0, 0); // Move to where "Hello!" is
wclrtoeol(WIN); // Clear the line (clear all of "Hello!")
printw("Hi!"); // Print "Hi!"
wrefresh(WIN);
The output is now
Hi!
This works! It's more efficient, however, to switch the last two steps and
- Advance our cursor to a row,
- Display the new information, then
- Clear the remainder of the line
The output will be the exact same. If we display Hi!
, it will already make the first three characters correct, as in Hi!lo!
. printw()
has the added benefit of advancing the cursor to the end of the string, so the cursor will be after the first !
. Calling wclrtoeol()
will clear everything after the first !
, so we will get Hi!
as intended.
This is more efficient because we need to do only a single pass over the first three characters. If we had cleared before printing, we would clear the first three characters (first pass), then fill in with Hi!
(second pass). The less efficient approach may be more intuitive, but the extra processing may cause the UI to be slow or flicker.
The following is a very simple example of using ncurses to update a single string in the UI:
while True:
int line = 2; // The y-coordinate
wmove(WIN, line++, 0); // Cursor is at (x=0, y=2)
wprintw(WIN, Run Mode: %s", run_mode_str);
wclrtoeol(WIN);
wmove(WIN, line++, 0); // Cursor is at (x=0, y=3)
wprintw(WIN, "Start Position: %s", start_pos_str);
wclrtoeol(WIN);
box(WIN, 0, 0); // Draws a box around the window (purely aesthetic)
wrefresh(WIN);
Note: wmove()
and wprintw()
can be combined as a single call to mvwprintw()
The general "template" for updating a window is as follows:
- Get the current shared memory data (see
shm_wrapper.h
) - Display any "headers" (ex: the name of the window)
- Initialize the cursor to where we need to start displaying.
- For each line of data:
- Display the string containing shared memory data
- Clear to EOL (remove old data from the previous refresh)
- Increment our line pointer (move to the next line)
- Redraw the box around the window (which is fragmented due to line clearing in the for-loop)
- Refresh the window with our new changes.
We repeat this procedure for every window.
All the information here should be enough for you to start working on the shm UI. It can be tedious to get the rows and columns to be aligned correctly--it's expected to recompile, run, find a mistake, and repeat! It's highly recommended to #define
the dimensions of the windows or other constants (ex: indenting), as it will make resizing a lot easier to manage.
A more complete guide to ncurses can be found here: https://tldp.org/HOWTO/NCURSES-Programming-HOWTO/. Function-specific documentation can be found at the man
pages.
If the UI is ran without arguments, we initialize shared memory so other Runtime processes can use it. When the UI is killed, it destroys shared memory. Sometimes, shared memory is already initialized, such as when we use systemd
on the Raspberry Pi. In this case, we pass an attach
argument, which tells the UI to not create shared memory and exit without destroying it. This makes the state of shared memory independent of the lifetime of the UI process.
If UI process needs to be stopped or exited, you must call endwin()
before exiting, even if it's due to an external signal. Without a call to endwin()
, the terminal will be messed up. In this state you won't be able to type in the terminal or do anything useful--to get out of this nasty situation, you'll need to close the terminal completely.
- Important
- Advanced/Specific