A ClojureScript library for creating terminal-based, curses
interfaces. Being built off Node.js allows applications made with
i9n
to be snappy, light and mostly instant to boot up. Don’t worry
much if you’re not into node, most of the little beast is half-hidden
behind bars.
This project’s main goal is to simultaneously allow simple interfaces
to be done declaratively–hopefully within the grasp of someone who
isn’t a programmer (though don’t ask me why this someone would want a
terminal UI)–and provide a simple, solid backbone for complex curses
applications like file managers, music players, ascii-based games and
even text editors, all the while remaining a lib and not a framework,
by having little enough opinion on the rest of your code except for
the use of core.async
.
The way i9n
(standing for interaction) lays out interfaces is
through a navigation abstraction, composed of screens–think like
pages in a website, though any given screen can be highly interactive
much like a Single Page Application in web dev. There’s a special
declarative ease reserved for list-based navigation, because it’s such
a common use case (e.g. menus), but screens can contain any number of
UI widgets and interactions in addition to lists.
These widgets, interactions and the navigation hierarchy are all represented declaratively by plain Clojure data structures like vectors and maps, though of course “declarative” ends whenever you plug in functions to trigger custom actions (which you are very much encouraged to do all the time.) Here’s a full interface’s navigation hierarchy:
[["my german vocab" ["review words" :review
"add new word" :add
"quit" i9n-os/exit]]
[:review "review" ["ich" (fn [] "I")
"bin" (fn [] "am")
"praktisch" (fn [] "practical")
"perfekt" (constantly "perfect")]]
[:add "add word" "not implemented (or really necessary) yet."]]
The example above fully describes a flashcard (memorization) program
which lets you achieve a four-word strong German vocabulary and form a
full sentence. Of course, a more expansive vocabulary should probably
be backed by reading word/translation pairs from a text file. The
first screen, entitled “my german vocab”, is where the application
will start, and is implicitly given the id :root
. The others have
ids :review
and :add
which we use as keyword actions on the root
menu screen to link them up. Actions can also be arbitrary functions,
as shown by the especially useful functions that return a string,
which declaratively generates an action to add that string as an
alert-like message beside the menu option selected (in these cases the
German words you’re memorizing.)
Interfaces in i9n
are meant to be asynchronous, lazy and responsive
through the use of core.async
and an input channel where you can put
messages. This channel then serves as the backbone of the navigation’s
loop, as every operation message (adding and modifying screens, moving
from screen to screen, among others) is processed sequentially and
synchronously, even as they’re accepted asynchronously. This
guarantees, for instance, that you can send an operation message to
add a new screen, then another message to go to this new screen, and
be sure the screen exists in the navigation’s hierarchy.
Takes as first argument either the id of the screen to go next, or the full nav-entry–that is, a vector with three elements: the id, the title and a body. In this latter case, the nav-entry is added to the hierarchy (or updated, if the id already exists.)
:next
messages are the standard way to navigate the interface,
because it saves a link so you can go back to the previous screen. It
also optionally takes the list position in the current screen (default
to 0
if not supplied.), so that when going back you end up in the
position you left, which is very natural and even expected behavior in
navigation screens.
The third–also optional–argument is the list position to go to in
the next screen, defaulting to 0
as well. Going to a specific
position might be useful e.g. you’re jumping to some search result.
Like :next
, except it doesn’t save a link to go back to, and so it
doesn’t take :next
’s current position argument, instead taking an
optional position to go to as second and last argument. This also
makes it useful for hopping to another position within the same
screen.
Note that when using :next
the new link created overwrites the
former link, since there can only be one meaning when you
press back. Here there’s no overwriting, so if you hop to a random
screen, you might be able to back into a former connection that was
made to it when :next
was used. This is precisely what happens in
the action of going back itself, which uses :hop
for its internal
implementation. This allows you to go next many screens and then be
able to go all the way back.
Like :hop
, except that it must always take a full nav-entry as first
argument, never a screen id. A :set
can be useful for setting the
interface to a temporary screen with a dummy id (say, :temp
or
really anything, though not nil
) that won’t go into the hierarchy.
In reality I was lying and :hop
is not used internally to implement
the action of going back screens. It’s :set
that’s used, because
when a link is saved, the whole nav-entry is copied there, which
allows local, temporary state to be preserved for when you go back.
So this operation can be used for similar effect.
Updates nav-entries as surgically as you need, taking care to apply
your fixes both in the hierarchy (i.e. persist the changes) and in any
temporary state such as the user’s current screen. It can be used to
replace, add or remove an option or part of it. Takes the id of the
screen to change as first argument, the place
(where to change) as
second, and what to change as rest arguments.
The most basic place
designation is an integer (equal or greater
than zero–with a negative number the fix operation will be ignored),
which signifies a fix starting from that index. That means if you
give it 4
as the place and then supply four following arguments in
the :fix
operation, the option starting at index 4
as well as the
one starting at index 6
will be changed–or added if those indexes
didn’t exist.
The only way to change the title of an entry, as opposed to the
options of its body, is to use :title
as the place. The table below
features other keywords accepted as a place. Note that option means
a pair of elements (label and action) from the body’s vector, so
option 3 would start at index 6 and end at index 7.
keyword | place meaning |
---|---|
:last | Last option’s label, i.e. last even index |
:last-action | Last option’s action, i.e. very last index |
:pop | Removes the last option, takes no argument after the place |
:append | Adds after the last option |
:prepend | Adds before the first option |
The append and prepend keywords above are useful in that they always add new stuff, but what about adding to the middle of the list? The next table shows vector-based place syntax, allowing you to do that and other hopefully helpful things.
syntax | place meaning |
---|---|
[:insert n] | Adds before the start of option n |
[:insert-after & xs] | Adds after option matching any of elements xs |
[:insert-before & xs] | Adds before option matching any of elements xs |
[:action n] | Changes action(s) of option(s) starting from n |
[:label n] | Changes label(s) of option(s) starting from n |
[:action-find & xs] | Like :action but from first matching any of xs |
[:label-find & xs] | Like :label but from first matching any of xs |
[:from & xs] | Replaces from first option matching any of xs |
[:after n & xs] | Like :from but starts n options after |
[:before n & xs] | Like :from but starts n options before |
[:shrink n start] | Remove n options from option start |
[:remove n & xs] | Remove n options from first matching any of xs |
When there are several needles xs
from which to find a matching
result in the haystack, each needle is first searched over the whole
haystack before trying the next one. Trying needles is less dangerous
than it sounds.
Takes all the same arguments as :fix
, but doesn’t persist the fix
into the hierarchy. Thus, the fix is only applied to temporary state
nav-entries, such as the one representing the user’s current screen.
If there is no such temporary state target where to apply the fix,
nothing is done by the :put
operation.
Example: (a/put! in [:select 2])
Select option at index n in whatever screen is the current. Accepts
:last
as an index.
Example: (a/put! in [:dirty :screen1 :screen4])
Takes the id(s) of the screen(s) to be made dirty. Dirtying is
exclusively for the library user, to facilitate his keeping track of
which parts of the hierarchy will have to be lazily recomputed, if at
all, when they’re finally accessed–this is coupled with passing a
:flush
channel inside the cfg
parameter’s :watches
property, on
which to listen to flush messages.
Even though dirtying could be managed externally by the user, building it into the navigation loop takes care of a few things for you:
- a flush notification is sent out when a dirty screen is finally accessed, after first clearing the screen’s dirty status;
- you can send in
:stub
messages, which are just like:add
messages, except that the screen is created dirty, which means you lazily create just a stub, and wait for a flush message to finish building the screen only when it’s first needed.
Example: (a/put! in [:state :user-setting1 :foo :user-setting2 :bar])
Use for any application-specific state that you need to keep between
screens–globally in fact, stored within the nav
object as a map
under the :state
key.
Built-in user-facing facilities may interact with state to make the
use of state easier and more high-level than sending :state
messages; see enlightened.os.navigation/pick-option
for example.
Allows setting properties of columnar data in a given screen. Takes the id of the screen as first argument and the integer index of the column as second. The rest of the arguments should consist of pairs of keyword and value (keyword arguments). All arguments are optional. The keywords accepted are:
keyword | arguments type | description |
---|---|---|
:width | int | Always keep col width at this character length. |
:sort | (fn [a b]) | Sort table by this col, using supplied fn. |
Copyright © 2014 Vic Goldfeld
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.