As a learning exercise to sharpen my Rust programming skills, I recently toyed with writing a small program that uses a terminal-based user interface which I built using the Cursive crate developed by Alexandre Bury. Cursive provides a high-level framework for building event-driven terminal applications using visual components such as menu bars, text areas, lists, dialog boxes, etc. Conceptually, developing with Cursive is one level of abstraction higher than using a library such as ncurses, which provides a more raw interface to managing screen contents and translating updates to the terminal's native language. In fact, Cursive defaults to using ncurses as one of several possible backends, and allows setting themes to customize various text colors and styles.
Why write terminal applications?
In today's software world, no one writes terminal applications expecting them to be a hit with the masses. Graphical applications (e.g. desktop apps or web apps) provide a uniquely intuitive interface model that allows users to quickly become productive with a minimal learning curve, offer a high-bandwidth flow of information to the user, and remain the only reasonable solution for many problem categories. Many applications would simply not be possible or practical without a GUI. However, terminal programs can find a niche audience in technical users such as software developers and system administrators who are often in need of utilities that are a bit more two-dimensional than the command line's standard input and output, but retain the flexibility to be easily used remotely or on devices of limited capability.
Also, terminal apps are often extremely fast — fast enough to maintain the illusion of the computer being an extension of the mind. I find it frustrating that in 2017 I still spend plenty of time waiting for the computer to do something. Occasionally even typing into a text field in a web browser is laggy on my high-end late-model iMac. For every extra cycle the hardware engineers give us, we software engineers figure out some way to soak it up.
The terminal is not for everyone, but lately I've found it's the one environment that is instantaneous enough that my flow is not thrown off. For kicks, I recently installed XUbuntu on a $150 ARM Chromebook with the idea of mostly just using the terminal (and having a throwaway laptop that I'm not scared to use on the bus/train). I expected to mostly be using it as a dumb terminal to ssh into servers, but to my surprise, it has actually proven to be very capable at performing a wide range of local tasks in the terminal with good performance.
The Cursive framework
Anyone who has developed software with a GUI toolkit (e.g. Windows,
GTK+, Java Swing, Cocoa, etc.) will find most Cursive concepts to be
very familiar. Visual components are called
"views"
(some toolkits use use the terms "widget" or "control" for the same
concept), and are installed into a tree which is traversed when
rendering. Some views may contain child views and are used for layout
(e.g. BoxView
and LinearLayout
), while others are used as leaf nodes that
provide information or interact with the user (e.g. Button
,
EditView
, TextView
, SliderView
,
etc.). Cursive can maintain multiple view trees as "screens" which can
be switched between. Each screen's view tree has a
StackView
as the root element, whose children are subtree "layers" that can be
pushed and popped.
Cursive provides an event model where the main program invokes
Cursive::run()
and the Cursive event loop will render views and dispatch to registered
callbacks (typically Rust closures) as needed until
Cursive::quit()
is called, at which time the event loop exits. Alternately, the main
program may choose to exercise more control by calling
Cursive::step()
as needed to perform a single iteration of input processing, event
dispatch, and view rendering.
Key
events are processed by whichever input view currently has focus,
and the user may cycle focus using the tab key.
Referencing views
Cursive diverges from other UI toolkits with respect to referencing views. In many environments, we would simply store references or pointers to any views that we need to reference later, in addition to whatever references are needed internally by the view tree to form the parent-child relationships. However, Rust's strict ownership model requires us to be very explicit about how we allow multiple references to the same memory.
After the main program instantiates and configures a view object, it
generally adds it to the view tree by making it the child of an existing
view (e.g.
LinearLayout::add_child()
)
or adding it to a screen's StackView
as a layer. Rust
ownership of the object is moved at that time, and it is no longer
directly accessible to the main program.
To access specific views after they have been integrated into a view
tree, views may be wrapped in an
IdView
via .with_id(&str)
which allows them to be referenced
later using the provided string identifier. A borrowed mutable
reference to the wrapped view may be retrieved with
Cursive::find_id()
or a closure operating on the view may be invoked with
Cursive::call_on_id()
.
Under the hood, these methods provide interior mutability by making use
of RefCell
and its runtime borrow checking to provide the
caller with a borrowed mutable reference.
The following code demonstrates how views can be referenced by providing a callback which copies text from one view to the other:
|
|
Early in my exploration of Cursive, this method of accessing views
proved to be somewhat challenging since fetching references to two views
in the same lexical scope would result in BorrowMutError
panics, since the internals of the second find_id()
would
try to mutably borrow a reference to the first view while traversing the
tree. Cursive's view lookup code has since been
adjusted
so that this is no longer an issue.
Model-View-Controller
While developing a full application, I quickly ran into
BorrowMutError
panics again.
With application logic tied to my custom view implementations, and some
such code needing to call methods on other custom views, inevitably some
code would need to mutably borrow a view that was already borrowed
somewhere further up the stack.
My solution was to completely decouple UI concerns from the application
logic, resulting in something along the lines of the well-known
Model-View-Controller (MVC) design pattern. A Ui
struct
encapsulates all Cursive operations, and a Controller
struct contains all application logic. Each struct contains a message
queue which allows one to receive messages sent by the other. These
messages are simple enums whose variants may contain associated data
specific to the message type.
Instead of calling Cursive::run()
, the controller will
provide its own main loop where each iteration will operate as follows:
- The controller main loop will call
Ui::step()
. - The
Ui::step()
method will process any messages that the controller may have added to its message queue. These messages allow the controller to change the UI state in various ways. - The
Ui::step()
method will then step the Cursive UI withCursive::step()
. Cursive will block until input is received. Any pending UI events will be processed and any registered callbacks will be executed. Callbacks may result in messages being posted to the controller's message queue (for example, the contents of a dialog box's form). - The controller main loop will then process any messages that the UI may have added to its message queue. The controller may perform tasks related to these messages, and optionally post messages to the UI's message queue to indicate the outcome.
This scheme worked great for my needs where it's okay for the program to completely block while waiting for user input.
For the message queue, I used Rust's
std::sync::mpsc
(multi-producer, single consumer FIFO
queue), which provides a convenient way for different code components to
own a cloned Sender
object which inserts elements into a
shared queue. The use of mpsc
is really overkill for the
single-threaded applications I was working with, since any thread
synchronization work being performed is wasted.
Here's an example of adapting the above text copy program to such an MVC model. It's admittedly much lengthier.
|
|
Miscellaneous notes
- Cursive is very much a work in progress and there are still some rough edges to be worked out. However, Alexandre Bury is lightning fast at responding to bug reports and fixing issues. One recent issue I filed went from report to patch to commit in 14 minutes.
- It's unclear how you would develop a lightweight single-threaded
program that uses reactor-style asynchronous I/O dispatch. For
example, a central
select()
loop which dispatches stdin/stdout events to Cursive, network socket events to other code, and so on. (I'm not even sure if backends such as ncurses would even support this.) - I'm also not sure how I would go about structuring
a multi-threaded application where the UI needs to process events from
other threads. Cursive does provide a
Cursive::set_fps()
method which, in conjunction withCursive::cb_sink()
, can poll for new events at specified time intervals. But I've always preferred a purely event-driven design for such things instead of needlessly burning cycles periodically while waiting. (Again, there may be complications at the ncurses layer.) - Cursive wants callback closures to have static lifetime, which can lead to some Rust puzzles if you'd like to access non-static non-owned items from within closures. This may be inevitable, and the issue mostly goes away with the MVC decoupling technique mentioned above.
As a learning exercise, I wrote a Cursive-based interface to UPM password manager databases. However, nobody should use it for reasons outlined in its README.
posted at 2017-08-24 10:21:45 US/Mountain
by David Simmons
tags: rust terminal cursive
permalink
comments