Tired of noisy interfaces? Bored of clicking and scrolling? Want to build a console interface but having a hard time with curses? Maybe it's time to ...
In this article, we are going to write a simple file browser. In a handful of lines of code you'll have the basics of something like the venerable midnight-commander. We'll also learn enough of the Raku programming language to impress your friends and scare your enemies. This entire example is in the mc.raku file in the eg directory of the Terminal-UI source code.
First, install Raku and Terminal::UI
. For Raku, head over to
rakubrew.org and follow the instructions there.
This will also give you zef
, Raku's package manager, so you can run:
zef install Terminal::UI
Okay! Now let's start with a "hello, world"ish program to see how things work. Our starting program will divide the screen in half, print a message, and wait for a keystroke. It looks like this:
#!/usr/bin/env raku use Terminal::UI 'ui'; ui.setup: :2panes; ui.panes[0].put: "welcome!"; ui.get-key; ui.shutdown;
╔═════════════════════════════════════╗ ║welcome! ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ╟─────────────────────────────────────╢ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ╚═════════════════════════════════════╝
Let's break this down.
Using the module with 'ui'
and calling setup
gives us a
new ui
object to work with, and a frame with two
panes which divide the screen in half vertically.
Note that ui.setup: :2panes
is the same as ui.setup(panes => 2)
;
method calls can use a colon instead of parentheses, and numeric
named arguments can precede the name of the argument if you put a
colon first. This syntax sort of reminds me of Larry's "First Law of
Language Redesign: Everyone Wants the Colon".
Anyway, ui.panes
are the two panes -- each one is a Terminal::UI::Pane
object, which has a method put
which puts things on the screen.
More on this later. Also ui.get-key
waits for a single key.
We need a way to get a directory listing! Let's dive right in and make this fancy! I'll explain afterwards.
use Terminal::ANSI::OO 't'; use Terminal::UI 'ui'; ui.setup: :2panes; sub show-dir($dir) { my \p = ui.panes[0]; p.clear; p.put: [t.yellow => "$dir"]; p.put: ".. (up)", :meta( dir => $dir.parent ); for $dir.dir.sort({.d.Int,.fc}) { next if .basename.starts-with('.'); p.put: [t.cyan => .basename ~ '/'], :meta(:dir($_)), :!scroll-ok when .d; p.put: .basename, :meta(:file($_)), :!scroll-ok when .f; } p.select-first; } show-dir($*CWD); ui.interact; ui.shutdown;
First, using Terminal::ANSI::OO 't'
brings a t
object which can set the color.
When sending color strings, use an array [ ... ]
, and send pairs, where the key
is the color and the value is the string.
Also -- put
can take a named parameter meta
-- this associates metadata with the
current line. We're going to use this in a minute.
We send a function to sort
to put directories first, then sort
alphabetically, case-insensitively.
The put
method can also take a boolean -- scroll-ok
-- we send a false value for
that because we don't want to scroll automatically when we show the directory contents.
The p.select-first
line selects (and highlights) the first line of the pane.
Calling ui.interact
enters an interaction event loop -- arrows move the selected line
up and down, and tab will switch between panes. So at this point, you can navigate up
and down, and scroll.
Oh, and $*CWD
is the current working directory, and methods like parent
and basename
and starts-with
do what you think.
╔═════════════════════════════════════════════════╗ ║/home/bduggan/code/raku-terminal-ui ║ ║.. (up) ║ ║eg/ ║ ║lib/ ║ ║script/ ║ ║t/ ║ ║LICENSE ║ ║make ║ ║Makefile ║ ║META6.json ║ ║README.md ║ ╟─────────────────────────────────────────────────╢ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ╚═════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════╗ ║/home/bduggan/code/raku-terminal-ui ║ ║.. (up) ║ ║eg/ ║ ║lib/ ║ ║script/ ║ ║t/ ║ ║LICENSE ║ ║make ║ ║Makefile ║ ║META6.json ║ ║README.md ║ ╟─────────────────────────────────────────────────╢ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ╚═════════════════════════════════════════════════╝
We're almost done with our example -- to allow selection to actually work, we
use the on
method -- setting up the action to perform when select
is performed
on the first pane. That looks like this:
ui.panes[0].on: select => -> :%meta { show-dir($_) with %meta<dir>; show-file($_) with %meta<file>; }
Now we can for instance, select the eg/
directory. Oh first here's show-file
:
sub show-file($file) { ui.panes[1].clear; start ui.panes[1].put: $file, :!scroll-ok; }
We use start
to run in a thread (safe, **cough** unlike curses **cough**). And
this works because put
calls .lines
on whatever is being put -- so in this
case it calls $file.lines
and then puts each line on the screen one at a time.
Now we have a way to browse a file system and read text files.
As a final piece, let's add an example of how to set up a custom
key binding to show some information about a file. Typing an i
will show information about a file.
ui.bind: 'pane', i => 'info'; ui.panes[0].on: info => -> :%meta ( :$file, :$dir ) { with $file { ui.alert([ $file.basename, "Last modified: { $file.modified.DateTime }" ]) } }
We call bind
with two arguments: pane
, and a key value Pair.
The second argument -- the key-value Pair -- indicates that the
action info
will be called when the i
key is pressed.
The argument pane
ties the parameters for the callback to the user
interface context. It indicates that the currently selected line (within
the currently active pane), will be examined, and the incoming %meta
will correspond to the metadata for that line.
An additional named argument -- $raw
-- will also be sent, which will
have the raw text of the selected line.
Using on
sets up this callback. In this case we're being a little
bit fancy and using argument destructuring to automatically extract the
file or dir metadata arguments in the signature of the callback. Then
we call ui.alert
to display a little box with some information.
╔═════════════════════════════════════════════════╗ ║/home/bduggan/code/raku-terminal-ui/eg ║ ║.. (up) ║ ║alert.raku ║ ║color.raku ║ ║fixed-height.raku ║ ║full.raku ║ ║hello-world.raku ║ ║help.raku ║ ║input.raku ║ ║mc.raku ║ ║meta.raku ║ ╟─────────────────────────────────────────────────╢ ║#!/usr/bin/env raku ║ ║use Terminal::UI 'ui'; ║ ║use Terminal::ANSI::OO 't'; ║ ║ ║ ║ui.setup: :2panes; ║ ║ui.panes[0].put: "welcome!"; ║ ║ui.get-key; ║ ║ ║ ║my \p = ui.panes[0]; ║ ║p.clear; ║ ║p.put: [t.yellow => "$dir"]; ║ ╚═════════════════════════════════════════════════╝
Here are the final results -->
There's still some work to do to emulate all of the midnight-commander functionality, but hopefully this is enough to demonstrate some of the capabilities of Terminal UI.
Have fun and happy hacking!
╔═══════════════════════════════════════════════════════╗ ║/home/bduggan/code/raku-terminal-ui/eg ║ ║.. (up) ║ ║alert.raku ║ ║color.raku ║ ║fixed-height.raku ║ ║full.raku ║ ║hell╔════════════════════════════════════════════╗ ║ ║help║ ║ ║ ║inpu║ mc.raku ║ ║ ║mc.r║ Last modified: 2021-03-08T14:03:13.083051Z ║ ║ ╟────║ ║─────╢ ║#!/u║ ok ║ ║ ║ ╚════════════════════════════════════════════╝ ║ ║use Terminal::UI 'ui'; ║ ║use Terminal::ANSI::OO :t; ║ ║ ║ ║ui.setup: :2panes; ║ ║ui.panes[0].put: "welcome!"; ║ ║ui.get-key; ║ ║ ║ ╚═══════════════════════════════════════════════════════╝