getting-started
Getting Started with Terminal::Widgets
Overview
As the name implies, Terminal::Widgets (AKA "T-W") is a collection of modules for creating Terminal User Interfaces (TUIs) using various visual widgets. Widgets are rectangular tools that can be placed arbitrarily within the terminal window, each doing a single thing. Simple widgets include text labels, buttons, checkboxes, menus, and specialty add-ons such as scrollbars. More complex widgets include editor inputs, tree navigators, rich text viewers, smoke charts, and more.
The smaller widgets used to design a particular TUI are laid out within a special large widget called a toplevel that covers the entire terminal window/viewport. An application can be made up of many toplevels, each of which lays out a single "screen" within the app, such as the main menu, settings menu, online help docs, and so on.
While a toplevel manages screen layout, another critical object represents the terminal itself -- terminal emulator capabilities, input decoding, active user themes and preferences, the user's current locale and translation context, and the reactive event dispatcher for that terminal session.
Here's a diagram of a trivial T-W UI in use:
USER WINDOW OBJECTS
App
๐ค๐ปโโโโโโโโโโHelloโโโโโ โต โโTerminal
โฒ โ โ โ โ
โฒ โ โ โต โโTopLevel (HelloUI)
โฒ โHello, World!โ โโPlainText
โฒ โโQuitโ โ โโButton
โฒโ โ
โโโโโโโโโโโโโโโHello, World!
With that short overview in mind, let's start off by creating the classic "Hello, World!" app from the diagram above. Here's what that looks like in Raku code:
use Terminal::Widgets::Simple;
#| A top level UI container based on Terminal::Widgets::Simple::TopLevel
class HelloUI is TopLevel {
#| Define the initial UI layout when the TopLevel first starts up
method initial-layout($builder, $width, $height) {
# Use the layout builder to add a PlainText widget and a quit button,
# centered in the terminal window and taking minimal space.
with $builder {
.center(:vertical, style => %(:minimize-h, :minimize-w),
.plain-text(text => 'Hello, World!', color => 'bold'),
.button(label => 'Quit',
process-input => { $.terminal.quit }),
)
}
}
}
sub MAIN() {
# Boot a Terminal::Widgets::Simple::App and jump right to the main screen
App.new.boot-to-screen('hello-world', HelloUI, title => 'Hello');
}Using Terminal::Widgets::Simple defines and imports classes that handle all
of the basic TUI behaviors, including App (representing the application
lifecycle) and TopLevel (representing a full-window UI). The App startup
process will eventually call our initial-layout method (more on this in the
next section) to define the widget layout constraints, and that's where most of
the code in this example resides.
A Layout::Builder object is provided as the first argument, and we use that
to request a centered, vertically stacked, minimum-size UI layout, containing a
simple plain-text message and a 'Quit' button that when clicked will tell
the terminal event reactor to quit (thus exiting the program as a whole).
For your convenience the above program has been saved in the first hello-world example; go ahead and run this to see what the result looks like. You can mouse-click the Quit button or even just press Enter to quit, since the first active input is automatically focused for you.
Startup Behind the Scenes
This section details the startup process and explains why MAIN looks the way
it does; feel free to skip to the next section if this doesn't interest you
yet.
The trivial MAIN used above creates a default App object and immediately
calls its boot-to-screen helper method to start up the application. The
arguments are:
An internal moniker for the initial screen (used by multi-screen apps)
The
TopLevelUI subclass that the helper method should instantiate firstA title for the terminal window to use while showing the initial screen
As the name implies, boot-to-screen starts a UI bootstrapping sequence,
eventually ending with a fully rendered and interactive initial screen.
After some initial housekeeping, the user's terminal is autodetected and a
Terminal object created to manage it. Next the specified TopLevel subclass
(in this case HelloUI) is instantiated with a reference to its controlling
Terminal object. The boot sequence continues by calling
Terminal.initialize to blank the terminal window and start its input decoder,
and then calling Terminal.set-toplevel to prepare the HelloUI screen for
display.
set-toplevel begins by setting the terminal window title and then setting the
UI's height and width to the current window size, measured in character cells.
Finally it asks HelloUI to perform a relayout on itself.
A relayout begins by computing the actual layout details based on the
constraints specified by HelloUI.initial-layout. It then builds the
requested child widgets and places them in the layout's computed rectangular
layout slots, and sends a LayoutBuilt event to all widgets to let them know
their siblings all exist and have been placed. relayout finishes by setting
the input focus, then requesting a redraw and recomposite of all placed
widgets.
Finally, with the bootup process complete, the App object hands off control
to the user by starting Terminal's primary input/event reactor.
Event Handling
T-W event handling is similar to web browser event handling. Events such as
KeyboardEvent, MouseEvent, TakeFocus, or LayoutBuilt are injected into
the currently visible TopLevel, which trickles each event down to its
children recursively until reaching the event's target (if there is one) or the
leaves of the widget tree, where it begins a journey of bubbling back up to the
TopLevel.
Thus there are three phases to an event's journey that widgets can act on:
TrickleDownAtTargetBubbleUp
This allows any widget to choose to handle an event either before or after its children, or only when it is the event target, simply by choosing which phase to listen to.
In order to better control which widgets should receive a particular event, there are several event classes:
GlobalEvent- Sent to every widgetTargetedEvent- Sent to one particular widget, ignored by othersLocalizedEvent- Sent to widgets overlapping a particular X,Y locationFocusFollowingEvent- Sent to only the focused widget and its parents
Here's how the standard event types fit into those categories:
GlobalEvent-LayoutBuiltTargetedEvent-TakeFocusLocalizedEvent-MouseEventFocusFollowingEvent-KeyboardEvent
The base Widget class does the EventHandling role, which allows a widget
to handle some subset of events by adding a multi method handle-event with
sufficiently precise arguments. Here's an example of a mouse event handler
shared among several of the Input widget types:
#| Handle basic mouse click event
multi method handle-event(Terminal::Widgets::Events::MouseEvent:D
$event where !*.mouse.pressed, AtTarget) {
# Always focus on mouse click, but only perform click action if enabled
self.toplevel.focus-on(self);
self.click if $.enabled;
}Note how the multi method parameters specify only MouseEvents where the
mouse button is being released (the end of a click), and only when the event
has reached its target (phase AtTarget).
Event Handling in hello-world
As a concrete case, the hello-world program above has a Quit button. The
standard Input::Button class has builtin handle-event multi methods for
both keyboard and mouse inputs. When an Event of either type is sent from
the Terminal reactor to the current TopLevel, that Event will trickle
down through the widget hierarchy until it reaches its target widget.
If the current focus (for a KeyboardEvent) or the current mouse cursor
location (for a MouseEvent) target the Quit button, the appropriate
handle-event method will be called, which in turn will call the
process-input block that was specified in the layout.
In this particular case, our process-input finds the current controlling
Terminal object and requests that it quit and shutdown, eventually exiting
the program completely.
Widget Layout and the Box Model
Widgets are laid out in a hierarchy of X-Y grids, each laying flat within a stack of Z-planes. Even without any Z-offset, child widgets are assumed to be infinitesimally closer to the viewer than their parent so that the painting and compositing orders are well-defined:
โPARENTโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โCHILDโโโโโโโโโโ โCHILDโโโโโโโโโโ โ
โ โ โGRANDCHILDโ โ โ โGRANDCHILDโ โ โ
โ โ โโโโโโโโโโโโ โ โ โโโโโโโโโโโโ โ โ
โ โ โGRANDCHILDโ โ โ โGRANDCHILDโ โ โ
โ โ โโโโโโโโโโโโ โ โ โโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโWithin each widget, T-W uses a similar layout to the CSS box model. The active content-area sits in the middle and is surrounded by three types of framing -- from innermost to outermost, the padding, border, and margin:
โWIDGETโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ margin โ
โ โ
โ โโโโโโโโโโborderโโโโโโโโโโ โ
โ m โ โ m โ
โ a โ padding โ a โ
โ r โ p โโโโโโโโโโโโโโโโ p โ r โ
โ g โ a โ Content Area โ a โ g โ
โ i โ d โโโโโโโโโโโโโโโโ d โ i โ
โ n โ padding โ n โ
โ โ โ โ
โ โโโโโโโโโโborderโโโโโโโโโโ โ
โ โ
โ margin โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโThe upper left corner within a widget grid is at x=0,y=0,z=0, but may be offset
by arbitrary integer offsets from its parent (and through the chain of parents,
the entire TopLevel screen). Positive values are to the RIGHT, DOWN, and
CLOSER to the viewer.
Here's the widget box model again, with coordinates added:
โ
โ +y
โผ
โโโโถโWIDGETโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถโฎ
+x โ(0,0) margin โ โ
โ โ โ
โ โโโโโโโโโโborderโโโโโโโโโโ โ โ
โ m โ โ m โ โ
โ a โ padding โ a โ โ
โ r โ p โโโโโโโโโโโโโโโโ p โ r โ โ
โ g โ a โ Content Area โ a โ g โ โ h (height)
โ i โ d โโโโโโโโโโโโโโโโ d โ i โ โ
โ n โ padding โ n โ โ
โ โ โ โ โ
โ โโโโโโโโโโborderโโโโโโโโโโ โ โ
โ โ โ
โ margin (w-1,h-1)โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถโฏ
โฐโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฏ
w (width)Let's add some framing to our hello-world widgets by replacing the original
layout constraint request:
.center(:vertical, style => %(:minimize-h, :minimize-w),
.plain-text(text => 'Hello, World!', color => 'bold',
style => %(border-width => 1,
margin-width => (0,0,1,0),
padding-width => (0,1,2,3))),
.button(label => 'Quit',
style => %(border-width => 1),
process-input => { $.terminal.quit }),
)When a framing width is specified as a single value, it is applied on all four sides of the widget equally. If it is specified as multiple values, they apply to the top, right, bottom, and left sides respectively.
This version has been saved in the second hello-world example. Here's the result when you run it:
โโโโโโโโHelloโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโ
โโ Hello, World! โโ
โโ โโ
โโ โโ
โโโโโโโโโโโโโโโโโโโโโ
โ โ
โโโโโโโโโโโโโโโโโโโโโ
โโQuit โโ
โโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโBoth widgets have a width-1 border all the way around, and they are separated by the width-1 bottom margin of the plain-text widget. The content of the top widget has padding that differs on each side: none on the top, 1 space on the right, 2 spaces on the bottom, and 3 spaces on the left.
There are a couple interesting things to note about the Quit button:
The
โโbutton corner markers disappeared automatically.The button has been laid out to be as wide as the plain-text widget.
The first of these is default button behavior in order to avoid placing button corner markers inside of border corners (which looks rather odd).
The second is because our layout has requested only that the center node be
minimized, not the individual layout nodes within it. By default widgets
stacked vertically (:vertical) are given the same layout width, and widgets
stacked horizontally (the default) are given the same layout height.
In this case that looks a bit odd, so we can change the layout request to push the button into the left portion of the area reserved for it:
.center(:vertical, style => %(:minimize-h, :minimize-w),
.plain-text(text => 'Hello, World!', color => 'bold',
style => %(border-width => 1,
margin-width => (0,0,1,0),
padding-width => (0,1,2,3))),
.push-left(style => %(:minimize-w),
.button(label => 'Quit',
style => %(border-width => 1),
process-input => { $.terminal.quit })),
)Here we've requested a new push-left node with minimize-w set, meaning
that the button within it will be pushed to the left and shrunk horizontally
as much as possible.
This is now the third hello-world example. Here's the new result:
โโโโโโโโHelloโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโ
โโ Hello, World! โโ
โโ โโ
โโ โโ
โโโโโโโโโโโโโโโโโโโโโ
โ โ
โโโโโโโ โ
โโQuitโ โ
โโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโDrawing Sequence
Overall Refresh Sequence
To fully refresh its contents, a widget goes through a simple sequence:
clear-framedraw-framecomposite
Each of these has several sub-steps; clear-frame is the simplest:
Clear widget's underlying grid
Mark the entire widget dirty, so the widget will composite later
We'll get back to draw-frame in a moment, since it varies considerably for
different widgets. For now let's take a look at the final step (composite),
which copies the widget's newly drawn content to its parent and/or the terminal
screen. Most of its complexity comes from optimizing for several different
possible paths:
Snapshot this widget's current dirty areas (and implicitly clear them)
Depending on the widget's
parent:If
parentis the current T-W toplevel:parent.print-to-content-area
If
parentis a non-toplevel T-W widget:parent.copy-to-content-areaparent.add-dirty-rect
Otherwise (
parentis probably the T-P screen):parent.add-dirty-rectif parent understands theDirtyAreasprotocolInvalidate the underlying T-P grid-string cache
Hand off to the underlying T-P
compositemethod (AKA the "old path")
Drawing Leaf Widgets
The complexity of draw-frame depends on whether the widget has any children;
if none (meaning the widget is a leaf node), it's just two steps:
draw-framingdraw-content
Of course, those unroll a bit; draw-framing breaks down as follows:
Check the widget's computed layout to see which framing has been requested
draw-margin(if requested)draw-border(if requested)draw-padding(if requested)
draw-content is specific to each widget type; the version in the Widget
base class is just a stub that does nothing.
Summarizing, here's the full refresh sequence for a leaf widget:
clear-frameClear underlying grid
Mark widget all-dirty
draw-framedraw-framingCheck requested framing
draw-margindraw-borderdraw-padding
draw-content(unique per widget type)
compositeSnapshot current dirty areas
Ask
parentto do one of:print-to-content-areacopy-to-content-area+add-dirty-rectadd-dirty-rect+ invalidate T-P cache + T-Pcomposite
Drawing Parent Widgets
The major difference for parent widgets (those with children) is that
draw-frame now has to account for the Z-ordering of those children relative
to itself:
Sort children by Z order, back to front
For each child behind the parent widget (Z <= 0):
compositethe child onto the parent widget's gridAdd a dirty rect for the child's area (if it didn't do so itself)
draw-framingfor the parent widget itself (same as for leaf)draw-contentfor the parent widget itself (same as for leaf)Repeat step 2 for each child in front of the parent widget (Z > 0)
Widget Builtin Roles
In order to provide many common behaviors and utility methods, the base
Widget class is fairly extensive and does numerous roles. Most of these
you won't have to think about when building your own apps and custom widget
classes, except maybe to use provided helpers and standard boilerplate; they
mostly Just Work.
Here's the list for the curious:
Terminal::Print::Widget- Use T-P's low-level cells, grid, and compositorTerminal::Print::Animated- Allow timestamp-aware full-hierarchy redrawsTerminal::Print::BoxDrawing- Draw borders using various line stylesTerminal::Widgets::Common- Provide common debugging and profiling helpersTerminal::Widgets::Themable- Control color and attributes via semantic statesTerminal::Widgets::DirtyAreas- Support dirty-area compositing optimizationTerminal::Widgets::WidgetRegistry- Register new widget types on loadTerminal::Widgets::Events::EventHandling- See Event Handling section
Note that the first three of those are from Terminal::Print ('T-P'), which T-W is based on and interoperable with.
Debugging and Profiling
Because of the highly-interconnected nature of T-W objects, it can be confusing
to log, debug, trace, or profile a T-W app's execution; dd or .raku on any
widget are likely to end up dumping many pages of output. To make this easier,
the Common role and Widget class together provide a number of helper methods:
gist- Avoids recursive dumping and summarizes key attributesgist-name- Class name shortened for readabilitygist-flags- Used bygistto report special flags on the widgetgist-dirty-areas- Used bygistto summarize the widget's dirty areasdebug- Cache of$*DEBUGverbosity at time of object creationdebug-grid- Return an optionally framed snapshot of a single widgetdebug-elapsed- Write a debug note for elapsed time during an operationtoplevel- Chase parent links to find widget'sTopLevelterminal- Find this widget's controllingTerminal(viatoplevel)default-focus- Find descendent widget that should get focus by defaultfirst-widget- FIRST matching widget in subtree, starting atselflast-widget- LAST matching widget in subtree, ending withselfnext-widget- Next matching widget AFTERselfin full treeprev-widget- Previous matching widget BEFOREselfin full tree
TopLevel Widgets
Standard Widget Classes
The Application Object
The Terminal Object
Terminal Capabilities
Further Reading
Now that you've gotten through this document, you're ready to take a more detailed look at various parts of Terminal::Widgets. Here are a few suggestions:
Design Goals and Philosophy - The overall design goals that led to T-W's current design and implementation
Configuration and User Preferences - Environment variables and terminal instance attributes used to configure the user's T-W experience
Text Content Model - Deeper details about the RenderSpan content model and the associated string conversion pipeline
Concurrency Model - Deeper details on how concurrency is generated and managed by T-W, including startup and event handling
Adding New Widget Types - A guide to creating your own custom widgets to supplement the premade widget collection
XXXX: UNUSED PIECES
As simple as it is, our initial-layout method doesn't use its other two
arguments, which provide the width and height of the terminal window (measured
in character cells) in case the programmer wants to provide entirely different
layouts for small or large terminal windows. This isn't needed when simply
resizing the same basic layout -- the builder's layout constraint solver does
that automatically -- but could for example instead be used to switch between
overview and detailed screen layouts based on available terminal real estate.
For example, Widget does the WidgetRegistry role and provides a register
helper method, which introspects various subclass declarations to set up a
proper call to self.register-widget with all the right arguments. But you
don't have to care about how any of that works. You just have to know that
when you're creating a new widget type, if you follow the boilerplate in the
Adding New Widget Types doc, and put a simple
register call at the end of your implementation file like this:
Terminal::Widgets::Your::Classname.register;... your new widget type can then be used just like any builtin type would be.