Allround tutorial on gb.ncurses

Writing gb.ncurses projects

So, you want to write a project using gb.ncurses but cannot find any documentation (I'm sorry about that!). This tutorial can perhaps help you get started. (We'll assume that you know how to program in Gambas and that you know what ncurses actually is.)

This tutorial is divided into three major sections:
  • a "Hello world" program

  • window and character decorations

  • creating other windows and interacting with the user

All source code presented here is amalgamated into this project: ncurses-basic-3.5.3.tar.gz.

Seit 3.5.3

As the archive name suggests, it will work with Gambas 3.5.3 which is not released at the time of writing this. So in the meantime, take everything greater than or equal to revision 6135.

The archive is really hosted on http://www.gambas-buch.de.

Preliminaries

First, of course, you need to create a new (console!) project and tick the gb.ncurses component in your Project -> Properties... -> Components tab. Now, note that ncurses programming is about terminals and while the Gambas console in the IDE knows quite a lot of tricks, it isn't a full-featured terminal emulator. Instead, to test our program, we must use a true terminal emulator, i.e. an external program like Konsole from KDE or xterm. So go to Project -> Properties..., select the "Options" tab and activate the use of a terminal emulator.

You can change the terminal emulator used in Tools -> Preferences -> Help & applications. In the course of this tutorial, we'll stick to xterm.

Seit 3.5.3

Since revision #6146, you can opt (in the project properties dialog) to redirect the stderr of your project away from the terminal emulator and back to the IDE. This is really helpful in debugging gb.ncurses programs as DEBUG and ERROR statements in your code don't mess up your screen anymore but can be read in a separate window.

"Hello world!" (of course)

Because it is so cliche, we'll do this quickly:

Window.Print(("Hello World!"))

And this is pretty much it.

Behind the scenes, when gb.ncurses is loaded, it initialises the terminal to a sane state (as almost all ncurses programs do) so that everything is ready when our Main() routine is executed. The Window class in gb.ncurses is auto-creatable which means that you can use the class name to refer to a single instance of the Window (just as you use FMain as the single instance of your main form in GUI applications).

This single instance is a Window representing the whole screen, it wraps for Gambas what ncurses calls stdscr.

So by Window.Print("Hello World!"), we are printing "Hello World!" at the current cursor location, (0,0) after instantiation, on the standard screen.

In ncurses mode, you can think of the screen as a fixed-size two-dimensional character array: if you print something to it, you overwrite what was previously there. There is no automatic shifting of letters like in, e.g., a GUI TextArea.

Similarly, in ncurses mode, if you print some long text which reaches the end of the current line, the text will not automatically wrap to the next line. However, if Window.Wrap = True (which is the default), the Window class takes care of this behind the scenes. If you want to disable automatic wrapping at line ends, set this property to False.

"Curses" (mangled from "cursor optimisation") keeps internal data structures to optimise any output sent to the terminal: only the characters that really changed. So if you accidentally use a Print or Debug statement in your Gambas code, this will not only mess up your screen but also the ncurses bookkeeping. The text may even survive a Window.Clear() call because ncurses still thinks that there is nothing is to be erased (because it didn't print anything).

Window and character cosmetics

You likely use ncurses in favour of Print in a console project because it makes pretty output easier. This section deals with Window decoration and character attributes - which includes colour.

Window.Border and Window.Caption

First observe something in the above program: your Text wasn't actually printed at position (0,0). This is because the singleton Window has a one character wide border around it. You can't disable this feature at the moment as the border settings are definite from object instantiation on.

You can, however, change how the Window's border looks:

Window.Border = Border.ACS ' or: Border.Ascii or: Border.None

You've already seen Border.None (the default). Border.ACS uses line-drawing characters from a special character set of your terminal emulator. It looks good on the terminals that are not broken in this regard. There is also Border.Ascii which uses... ASCII and works in all circumstances.

Talking about the border, you can also set a window title if the window has a border. The title (or caption) is printed on the upper border of the window:

Window.Caption = ("Chapter 2: Window and character cosmetics")

Window.Attributes and Window.Pair

The Window.Attributes and Window.Pair properties contain default settings for all characters printed after these properties are set. They are combined with every character as it is written. In ncurses, every character on screen can have multiple attributes and a pair (foreground, background) of colours associated with it. For character-wise manipulation of these attributes, see below.

First, we need to clarify the term attribute a bit because in ncurses terms, colours are also character attributes. So we will use the word "attributes" as a superset of ordinary true-or-false attributes and colour attributes. These true-or-false attributes (flags) are contained as constants in the Attr class:

Constant Meaning
Attr.Normal No flag attributes, just normal display.
Attr.Underline Underline characters. This is not supported by all terminals. E.g., my Linux console just emphasises the character with cyan colour instead of underlining it.
Attr.Reverse Reverse video. This flag specifies to interchange fore- and background colour of the character.
Attr.Blink Make the character blinking. This does not work on my Linux console either and does nothing there. You can try xterm to see it.
Attr.Bold Make character look bold. Also not supported everywhere. On my Linux console, this makes the foreground colour of the character brighter.

Window.Attributes is a bit mask of the true-or-false attributes. If you know that a bit is not set you can add it (similarly, you can subtract the bit if you know it's set). If you are not sure, you are always safe with using the bitwise operators Or and And:

Window.Attributs += Attr.Blink ' I'd personally rather not use a program using Blink ;-)

The Window.Pair property encodes the fore- and background colours of a character in a single integer, the "pair number". To obtain a pair number, you have to use the Pair class (as the mapping of colour pairs to pair numbers is internally done in gb.ncurses and you should not worry about it). You give the foreground colour as a first argument and the background colour as a second argument to its array accessors. Colour constants are in the Color class.

Window.Pair = Pair[Color.Cyan, Color.White]

Now let's see how a printed text looks like:

Window.Print(("Default rendition\r\n"))

This should now print the given text but it should also blink have cyan foreground and white background.

In ncurses mode you need to use \r\n to go to column 0 on the next line. The usual \n character will only jump to the next line but remain at the current column, while \r goes back to column 0 on the current line.

However, the Window.PrintCenter() method (which is like Window.Print() but vertically and horizontally centers the text) can only work as expected if you use \n as it automatically indents the lines it prints. The \r is interpreted afterwards by the terminal emulator and ruins the indentation!

The Window.Clear() method clears the screen, i.e. it writes blanks to all characters in the window. Therefore, Window.Clear() colours your entire window with Window.Pair besides erasing all text on it.

Window.Print() in detail

Now, if you work a lot with different colours (imagine ls --color=always), always setting Window.Pair between prints can get cumbersome. For this reason, the Window.Print() method allows you to associate custom attributes to the printed string. The full signature of Window.Print() is:

Sub Window.Print(Text As String, Optional X As Integer, Optional Y As Integer, Optional Attr As Integer, Optional Pair As Integer)

This is quite a signature. You already know the Text argument. X and Y allow you to print the text to the specified position without changing the cursor position of the window. Attr and Pair allow you to set custom flags and colours:

Window.Print(("Some custom attributes"),,,, Pair[Color.Red, Color.Blue])

Note that omitting X, Y, Attr designs to use the default values which are: the current cursor position and the window's default flags as given by Window.Attributes. So the above line prints the text with red foreground and blue background - but blinking! (Not very good-looking, admittedly.)

Window.Foreground (Window.Pen) and Window.Background (Window.Paper)

In contrast to Window.Pair, these two properties immediately apply to all characters in the window and so erase any custom fore- or background colour previously given to a character. After the above lines, we may decide that after exactly one second we need a yellow background (to frighten away all the users still sitting in front of our program):

Wait 1
Window.Background = Color.Yellow

There is no analogous facility to apply some flag to all characters immediately.

Per-character attributes and colours

As said earlier, every character on screen has its attributes right to its side and the Window class allows us to change every single bit of them. The Window class implements the array accessors by which you can access a virtual object refering to the attributes of the very character at coordinates (X,Y) in the window: Window[Y, X]. This virtual class allows you to set the attribute flags and the fore- and background colour of that character immediately.

Since all the spectators left our program in the last section, we might as well do whatever we want now. How about this loop:

iRow = Window.Y
iDelta = 1
For iCol = Window.X To Window.W - 1
  Window[iRow, iCol].Background = iCol Mod Color.Count
  iRow += iDelta
  If iRow Mod Window.H = 0 Then
    iRow -= 2 * iDelta
    iDelta = - iDelta
  Endif
  Wait 0.05
Next

Try it out ;-)

Other windows and interactivity

The last major section of this tutorial is here to let you know two things: (1) the singleton window is not the only window you can have. Actually a Window is just some region of memory independent of the terminal screen. Windows can even be stacked and overlap each other, (2) how I/O works with the Window and the Screen classes of gb.ncurses. Both topics are covered by an application: writing a dialog.

We may start right off with the code and explain later:

Dim hDialog As New Window(True, 0, 0, Window.W / 2, 6)
Dim sChoice As String

hDialog.Border = Border.ACS
hDialog.Caption = ("Make a choice")
hDialog.Center()
hDialog.Show()

hDialog.PrintCenter(Subst$(("What to do?\n\nEvaluate 42^2 [&1]\nQuit [&2]"), ("E"), ("q")))
Screen.Echo = False
sChoice = hDialog.Ask(("Eq"))
hDialog = Null ' Destroy the dialog window
If sChoice = ("e") Then
  Window.PrintCenter(Subst$(("Here it is: 42^2 = &1"), 42 ^ 2), Attr.Bold, Pair[Color.Green, Color.Black])
Endif
Window.Print(("Press any key to continue"), 0, Window.H - 1)
Window.Read()

You see: Window is a creatable class. You specify whether your new Window will get a border frame (as explained above), the coordinates of the upper left corner of the window and its dimensions. Then we set the new dialog window's border and caption and center it on the terminal screen. Then it is shown using the Show() method. This makes the window visible and raises it to the front, thus it now covers the singleton Window object. (You have the same methods in the GUI components' Window class.)

The aforementioned PrintCenter() method then obviously presents the user their options. As it is convention, single ASCII letters represent options. An uppercase letter signifies the default option (chosen when the user hits Return). Note that it is always nice to keep your strings translatable. (There is also a document around which explains how to do this[0].)

The Screen.Echo = False line is interesting. It sets a terminal-global option, namely that typed characters are not echoed anymore into the terminal since this would ruin our display.

Afterwards, the Window.Ask() is called with the option letters we presented the user earlier. Window.Ask() waits until the user presses one of these characters or hits Return. In the latter case, the options string is scanned for an uppercase letter which is then returned as the default option. Note that always the lowercase version of the letter is returned - the Window.Ask() method is entirely case-insensitive.

Then we delete the dialog window assigning Null to its last reference because it is only in the way now. If the user hits "e" or "E", we fulfill their wish and square 42.

The last two lines are idiomatic. To let the user see the result, the program must be kept running. So the user is prompted to type anything and until they do, the program is suspended. Precisely, Window.Read() waits until at least one character is readable and returns it.

By default, Screen.Input which is the input mode, is set to Input.CBreak which is realtime input, not line-wise as you may be accustomed to! Consult the ncurses manpage for the other input modes.

The Window.SetFocus() method and event-driven I/O

There is another means to take input (I hope you missed some event-driven aspects in the above paragraphs!). First note that our program has only one input stream, as most terminal programs do, stdin. However, using an event system similar to Application_Read doesn't seem to be satisfying. We can have multiple windows and our program may have multiple states like taking data input or command input to different windows each (both would be nice with line editing, i.e. we would like to use the backspace key to correct ourselves - which is not to be taken for granted in ncurses programs). Or we may want to intercept keystrokes as part of shortcuts, or we want to temporarily display a dialog which the user should reply to, etc.. We would end up with one huge Application_Read() event handler.

No. Instead, gb.ncurses provides a way to multiplex the single input file descriptor available to us. This is the Window.SetFocus() method. You can draw the attention to one single window with it:

Private $hText As Window
Private $hCommand As Window

...

  $hText = New Window(False, 0, 0, Screen.W, Screen.H - 1) As "Text"
  $hCommand = New Window(False, 0, Screen.H - 1, Screen.W, 1) As "Command"

...

  $hText.Raise()
  $hText.SetFocus()

There are two windows: one for text and one for command input (like in the vi editor). Note that we will not use the singleton Window object in this section! With $hText.Raise(), the text window is brought to the foreground, so that the physical cursor of the terminal is at the right place. You also see that the windows got event names. $hText.SetFocus() does nothing more than registering $hText as the active window. As soon as input arrives now, it will be that very window which raises the Read event. Let's look at the event handler:

Public Sub Text_Read()
  Dim iKey As Integer = Window.Read()

  If iKey = Key.Esc Then
    $hCommand.Raise()
    $hCommand.SetFocus()
  Else If iKey < 128 Then
    $hText.Print(Chr$(iKey))
  Endif
End

As you can see by now: we're reading the typed character and look if it's the Escape character. This is the signal to switch to command mode, again using Raise() and SetFocus(). If not and the character is ASCII, we print it. There is no line editing or anything here. That's left as an exercise for the reader. (Hint: the "backspace" key deserves his name.)

After we switched to command mode, every typed character will trigger the Command_Read event handler:

Public Sub Command_Read()
  Dim iKey As Integer = Window.Read()
  Dim sCommand As String

  If iKey = Key.Esc Then
    $hText.Raise()
    $hText.SetFocus()
  Else If iKey < 128 Then
    If iKey = Key.Return Then ' End of command
      sCommand = RTrim$($hCommand.Get(0, 0))
      $hCommand.Clear()
      If sCommand = ":q" Then Quit
    Else
      $hCommand.Print(Chr$(iKey))
    Endif
  Endif
End

It's basically the same here: Escape switches back to text mode and the ASCII characters are echoed. Except that the Return key is also intercepted. $hCommand.Get(0, 0) copies the entire contents of the first line in the command window (as you can see above, the command window has only one line). If the command is ":q" (ignoring trailing blanks), the program quits.

If we cared about the trailing blanks, we would have used the third argument to the Get() method, which is the length of the string to extract:

sCommand = $hCommand.Get(0, 0, $hCommand.CursorX)

Since there is no line editing (no arrow keys to change the cursor position, e.g.) this is obviously everything the user typed into the command line.

hWindow.Read() and hWindow.Ask() will directly attach to the input file descriptor and "steal away" any input from there to fulfill their task. You won't see Read events then.

Last words

You've got pretty much of a tour around all the classes in the gb.ncurses component. Not a complete one but you got a good overall picture of what is (or can be) available. Now that you're fit enough, you may also look into the Gambas gb.ncurses examples: Pong and Invaders in the Games section.


[0] http://gambaswiki.org/wiki/howto/translate