Make Your Own less in C
2020-03-29
Lately, I’ve had a new appreciation for minimalist C applications. There is a certain Zen quality about C that I really like. Knowledge of the possible “footguns” actually makes me more focused and insistent on writing good code. It is a bit like weight training but for programmers. Coding in other languages like Python feels trivial after spending a bit of time wrangling some C. less
is one of the original Unix terminal applications for viewing files. It is a good example of the Unix philosophy: “Do one thing, and do it well”. It differentiates itself from editors by not loading the entire file into ram. The wikipedia page has a very detailed explanation. If you haven’t used less
before, I would recommend giving it a spin before continuing with the rest of this post.
In summary what we will be making is a small terminal application that uses a file stream to view text. We will be using termbox for rendering and general terminal application behavior. termbox is an alternative to the popular curses library and is recommend by suckless. It is very small and easy to use.
On Debian we can install it like so:
sudo apt install libtermbox-dev
Once we have that installed we will start with a simple hello world using termbox. For compilation I am using a very basic Makefile. Notice that in the build command I am linking the termbox library. I decided to name this application bless
because it is “basically less” :p. Here is what the Makefile looks like:
all: build
build:
$(CC) -o bless bless.c -Wall -W -pedantic -std=c99 -ltermbox
clean:
rm bless
Most of this code is basic boiler plate to get termbox up and running. We initialize termbox, render our text, and then wait for the escape key to shutdown. Easy!
#include <termbox.h>
#include <stdio.h>
void
render_line(char *text)
{
int i = 0;
while (text[i] != '\0') {
tb_change_cell(i, 0, text[i], TB_WHITE, TB_DEFAULT);
i++;
}
}
int
main()
{
int ret = tb_init();
if (ret) {
fprintf(stderr, "tb_init failed with error code %d\n", ret);
return 1;
}
tb_clear();
tb_select_output_mode(TB_OUTPUT_NORMAL);
render_line("Hello, World!\n\0");
tb_present();
struct tb_event ev;
while (tb_poll_event(&ev)) {
switch (ev.type) {
case TB_EVENT_KEY:
switch (ev.key) {
case TB_KEY_ESC:
goto done;
break;
}
break;
}
}
done:
tb_shutdown();
return 0;
}
Now we are going to step our game up by rendering a file in the limits of our terminal screen. The filename is given as user input. So lets change our main function to accept a filename and attempt to open it.
int
main(int argc, char **argv)
{
(void)argc; (void)argv;
FILE *fp = fopen(argv[1], "r");
When we shutdown termbox we also want to close the file:
done:
tb_shutdown();
fclose(fp);
The next step is to add the capability of rendering the file. We do this by replacing render_line with render_file, which will accept a file pointer instead a character string.
void
render_file(FILE *fp)
{
int line_count = 0;
int x = 0;
while (line_count < tb_height()) {
char c = fgetc(fp);
if (c != EOF) {
if (c == '\n' || x == tb_width()) {
line_count++;
x = 0;
} else {
if (c == '\t') {
x+=8;
} else {
tb_change_cell(x, line_count, c, TB_WHITE, TB_DEFAULT);
x++;
}
}
} else {
break;
}
}
}
Since all we need to do is render a portion of the file stream we can simply loop until we reach the terminal height (tb_height
) or reach the end of the file. In order to do basic line wrapping we check if our x coordinate is equivalent to the terminal width (tb_width
). Another thing to note is we have to manually handle tabs or else termbox will have rendering bugs. In this case we simply skip over 8 spaces.
Now when we execute bless
we pass in the name of the file we want to view.
./bless *filename*
Great we can view the file in our terminal. However, you will quickly notice we can’t view parts of the file that are greater than the terminal height. Lets add some navigation features to fix this.
We will start by adding scroll down by pressing the down arrow key. To do this, we will need to add an event handler for when we press the down arrow.
case TB_KEY_ARROW_DOWN:
cursor = scroll_down(fp, cursor);
render(fp, cursor);
break;
You’ll notice that we are calling two new functions when the down arrow is pressed. scroll_down will set the file stream at the beginning of the next line, and return an integer. That integer is a simple variable called cursor which tracks our position in the file. Once we change position we have to render the new view.
I’ll start by breaking down render. It mostly includes termbox boiler plate and we call the render_file inside of it. The reason we need this is that when we scroll the characters need to be reset or else we will see artifacts from the last scene.
void
render(FILE *fp, int cursor)
{
tb_clear();
tb_select_output_mode(TB_OUTPUT_NORMAL);
render_file(fp);
tb_present();
fseek(fp, cursor, SEEK_SET);
}
After rendering the file we set the file position to the cursor offset since it changes every time we call fgetc.
The scroll down function looks like this:
int
scroll_down(FILE *fp, int cursor)
{
char c;
while (c != '\n') {
c = fgetc(fp);
cursor++;
}
fseek(fp, cursor, SEEK_SET);
return cursor;
}
scroll_down loops from the cursor position until it finds a newline, and sets the file position at the new cursor position. Which will be at the start of the next line.
How about scrolling up? Well that is a little more tricky since we fgetc only goes down the file stream, not up. Using a combination of fseek and fgetc we can implement scrolling up in a concise manner.
The implementation of scroll up looks like this:
int
scroll_up(FILE *fp, int cursor)
{
char c;
while (c != '\n' && cursor != 0) {
cursor--;
fseek(fp, cursor, SEEK_SET);
c = fgetc(fp);
}
return cursor;
}
And voila! Basically less
with scroll down and up features in less than 100 lines of code. After messing around with this you may notice a few bugs, but for simplicity sake I think this implementation is okay. The real version of less
has a lot more features, so you probably won’t want to use this for personal use. I hope this post has inspired to make your own termbox app in C. At the very least it was a good learning experience for myself.