Dr. Brian Robert Callahan
academic, developer, with an eye towards a brighter techno-social life
You can see all the code (and more) for this blog post here.
In our previous blog post we compared a new language, Cowgol, to C. I was interested in syntactical differences. I simply assumed that any functionality differences would stem from the fact that one language has been around for over 40 years and the other has not. Even so, some concessions were made in order to make our RPN calculator work; glaringly noticeable was that the arguments to our RPN calculator had to be input on the command line. There was no way to write interactive programs in Cowgol when I wrote that RPN calculator.
Not satisfied with that, I implemented the needed single-character input rouines for 8080 CP/M, Z80 CP/M, and the generic Unix C backends. Now we can write interactive programs. And with some shallow diving into a standard interface, we can even write curses-like programs that present a consistent user experience over an extremely wide range of platforms.
Sometimes you have more(1), sometimes you have less(1), sometimes you break even(1).
Bad jokes aside, even is a very basic pager written in Cowgol that works on Unix and CP/M from a single source file, with only the most minimal of changes needed between the two (and I am hoping to get that down to zero). It is very basic as we will see and so it has some important limitations:
However, even with those restrictions, a basic pager is still a useful utility. It is even more useful to me on CP/M, since the built-in type
command effectively acts like cat(1); type
blasts the entire file to the console even if the file is longer than the terminal. Yes, you can pause the printing of the file. But I think scrolling up and down at will is a much nicer user interface than hoping you pause at the right moment and never need to read what has already scrolled off the top of the terminal.
How can we write interactive utilities that will provide an consistent user experience regardless of underlying operating system? This question can be trickier than it appears, though we can certainly name libraries and standards that aim to ensure this. Whether they be standard graphical toolkits like GTK+ and Qt (and others) that provide consistent frameworks for GUI UI, or the W3C who govern the openness and standards for the World Wide Web, consistency, where we can provide it, appears to be something we value.
A long time ago, we had many companies producing video terminals for interactivity with our computers. These manufacturers would often put in their own proprietary routines for graphical operations such as placing the cursor at an arbitrary position. Eventually, it made sense to try to provide a consistent standard interface rather than kludging all the different proprietary potentials into a single ever-growing library (hello termcap!). The ANSI escape codes are the result of that effort. This standard is old enough that many terminals produced during CP/M's heyday would have supported them, and many famous CP/M programs, like Turbo Pascal, supported it. And all modern Unix terminal emulators like XTerm support ANSI escape codes, so we are going to use this standard for our screen handling routines to produce a consistent user experience for our pager.
The way it works is that we let the terminal handle our screen routines by writing special characters to the terminal, which then get interpreted as commands rather than text to simply be output to the user. Canonically, the commands we are going to use begin with the hex character 1B, 27 in decimal or 033 in octal, which corresponds to the Escape key in ASCII.
Looking up those commands, I found \033[2J
will clear the screen and \033[H
will place the cursor at (0, 0) or the top left corner of the screen.
Armed with this new knowledge, let's think about how our pager will perform its work.
For our even(1) utility, we must perform the tasks of opening a file, reading that file into memory, and keeping track of which portion of the file is currently being displayed. We shouldn't need anything more than that. Cowgol does not have any mmap(2) interface that I am aware of, though this might be more about the limitations of CP/M rather than of Cowgol, and so I chose the much less efficient route of allocating all available memory space (a little under 64 KB on CP/M and a little under 256 KB on Unix), reading in one character at a time and writing each character into the memory space, and stopping at the first non-printable character. This is a limitation of CP/M, which does not handle files anything like Unix. While it's a bit more complex than this blog post permits, the important difference for us is that on CP/M, the EOF character is not -1 but 26. So we will have to live with stopping as soon as we find a non-printable character and accept that our pager isn't perfect.
As we read in our file we can track the number of newline characters we encounter. That should provide us with the number of lines contained in the file. After reading in the file, we can close the file handler itself since we will use the copy in memory from now on. We then print the first screen's worth of text.
When the user wants to scroll, first we see what line was the first line we printed for the currently displayed screen, then do some basic sanity checks to make sure we don't attempt to print (first line - 1) or (last line + 1). We then go back to the beginning of the buffer, read through the file counting newlines until we reach the new first line of our new screen, then clear the screen, place the cursor at the top left, and finally print our new screen of text on the console. Perhaps not the most efficient algorithm, but hopefully it is easy to understand.
Now that we have planned out our program we can write it.
Here is the code. Nothing too complicated here. You could make even(1) better by implementing horizontal scrolling so that lines longer than 80 characters can be printed in full. We are using vi keys: j to scroll down, k to scroll up, q to quit.
# Pager # Assumes ANSI terminal include "cowgol.coh"; include "file.coh"; include "argv.coh"; const YMAX := 24; const XMAX := 80; const UP := 0; const DOWN := 1; var BufferStart: [uint8]; var BufferEnd: [uint8]; var BufferLoc: [uint8]; var AtLine: uint16 := 0; var lines: uint16 := 0; var c: uint8 := 0; # # Misc # Comment out this section when compiling for CP/M # sub SystemOn() is @asm "system(\"stty -icanon\");"; end sub; sub SystemOff() is @asm "system(\"stty icanon\");"; print_char(0o033); print("[2J"); print_char(0o033); print("[H"); end sub; # # Curses, kinda # # \033[ sub ConsoleControl() is print_char(0o033); print_char('['); end sub; sub ConsoleClear() is ConsoleControl(); print("2J"); end sub; sub ConsoleGoto() is ConsoleControl(); print_char('H'); end sub; # # Printing # sub LinePrint(): (done: uint8) is var x: uint8 := 0; c := [BufferLoc]; while x < XMAX and c != 0 loop print_char(c); BufferLoc := BufferLoc + 1; if c == '\n' or c == 0 then break; end if; x := x + 1; c := [BufferLoc]; end loop; while c != '\n' and c != 0 loop c := [BufferLoc]; BufferLoc := BufferLoc + 1; end loop; if c == 0 then done := 1; else done := 0; end if; end sub; # # Scrolling # sub Scroll(direction: uint8) is var line: uint16 := 0; var y: uint16 := 0; var done: uint8 := 0; if direction == DOWN then if AtLine != lines then AtLine := AtLine + 1; end if; else if AtLine != 0 then AtLine := AtLine - 1; end if; end if; BufferLoc := BufferStart; while line < AtLine loop c := [BufferLoc]; while c != '\n' and c != 0 loop BufferLoc := BufferLoc + 1; c := [BufferLoc]; end loop; BufferLoc := BufferLoc + 1; line := line + 1; end loop; ConsoleClear(); ConsoleGoto(); while y < YMAX and done == 0 loop done := LinePrint(); y := y + 1; end loop; end sub; # # File input # sub LoadFile(name: [uint8]) is var fcb: FCB; var len: uint32; if FCBOpenIn(&fcb, name) != 0 then SystemOff(); ExitWithError(); end if; len := FCBExt(&fcb); while len > 0 loop c := FCBGetChar(&fcb); if c < 0x20 or c > 0x7e then if c != 9 and c != 10 and c != 13 then break; end if; end if; if BufferLoc == BufferEnd then break; end if; if c == '\n' then lines := lines + 1; end if; [BufferLoc] := c; BufferLoc := BufferLoc + 1; len := len - 1; end loop; [BufferLoc] := 0; if FCBClose(&fcb) != 0 then SystemOff(); ExitWithError(); end if; end sub; # # Main loop # ArgvInit(); var FileName: [uint8] := ArgvNext(); if FileName == (0 as [uint8]) then ExitWithError(); end if; SystemOn(); BufferStart := LOMEM; BufferEnd := HIMEM - 1; [BufferEnd] := 0; BufferLoc := BufferStart; LoadFile(FileName); BufferLoc := BufferStart; ConsoleClear(); ConsoleGoto(); var done: uint8 := 0; var y: uint8 := 0; while y < YMAX and done == 0 loop done := LinePrint(); y := y + 1; end loop; loop var ch: uint8 := get_char(); case ch is when 'j': Scroll(DOWN); when 'k': Scroll(UP); when 'q': SystemOff(); Exit(); # CP/M version, when 'q': ConsoleClear(); Exit(); end case; end loop;
I hope we saw how a little leveraging of standard interfaces can go a long way towards delivering consistent user interfaces and experiences regardless of your choice of language or operating system or output device. Additionally, these standard interfaces can help smooth novel language choices: the fact that we can make a quality pager program that can run on anything that understands ANSI terminal commands should make choosing a novel language like Cowgol as little less risky. And it's just cool that we can run the same program on Unix and CP/M virtually without changes!
As far as I am aware, this is the first truly interactive program written in Cowgol. It is also the first curses-like program written in Cowgol. At this point, many more possibilities are open to us. Most interestingly, games. Perhaps we can rewrite our SnakeQR game in Cowgol so that it runs on both Unix and CP/M.