matatu blog

Brian writes about computing

Mar 9, 2021

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 ...

Get Started with Raku Terminal-UI

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 things first

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
    

second things second

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;
    

midnight commander
╔═════════════════════════════════════╗
║welcome!                             ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
╟─────────────────────────────────────╢
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
║                                     ║
╚═════════════════════════════════════╝
    
raku commander

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.

building it up

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                                        ║
╟─────────────────────────────────────────────────╢
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
║                                                 ║
╚═════════════════════════════════════════════════╝
      

making it work

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.

adding more actions

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;                                            ║
║                                                       ║
╚═══════════════════════════════════════════════════════╝