Dr. Brian Robert Callahan

academic, developer, with an eye towards a brighter techno-social life



[prev]
[next]

2020-11-24
I wrote a pager that works on Unix and CP/M

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.

Even, a pager for Unix and CP/M

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.

ANSI escape codes

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.

What screen routines does a pager need?

Before we get to writing any code, let's think about what screen handling routines we need. Let's ask ourselves what a pager does at its most basic. Let's even fire up less(1) and interact with it. If you're like me, you've noticed that less clears the screen, prints as much of the requested file on the screen as it can, and then hands control over to us to navigate. When we move the arrow keys up and down, the whole terminal scrolls in order to show us the requested portion of the file. We can also press q to quit and return to the shell. I think that is enough for us. So really it seems like the only screen handling routines we need are for clearing the screen and placing the cursor at the top left corner to begin writing our page of text. Sounds good to me.

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.

What must a pager do?

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.

The code

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;

Conclusion

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.

Top

RSS