Terms and Interruptions: Making a Command-Line Analog Clock
Clock is mine. Earthrise picture is from Apollo 8 and NASA.
Making analog clocks with computers is one of my favorite things. Back in the days of CGI, I learned how to make an image in Perl, then
Lately, my playing has been much more in the world of Javascript and SVG, making the least traditional designs that can still be read as clocks.
But lately I’ve been thinking more about making one for terminal use. I’ve never done much with command-line that uses more than basic Getopt-style flags. That’s not quite true: I’ve adapted code that uses the symbol table as a dispatch table and reads the desired commands like git does.
$ git pulp
git: 'pulp' is not a git command. See 'git --help'.
The most similar command is
pull
But by and large, I followed the Unix philosophy allowing the output to be pumped into grep
or awk
or whatever I needed to feed it into, or just used it as a trigger, with most cases having no visible output. By and large, nothing needed or wanted to, for example, know the width and height of the terminal window.
I guess I have to credit Mohammad S. Anwar and the Weekly Challenges, like Challenge #120, with bringing this to mind.
Skimming through the code, there are only a few “clever” things I’m doing.
“Nice” Exit
I decided that I would want to have a “nice” exit, with a cleared screen instead of the last case of the clock, so I used %SIG
to trap ctrl-C
to exit the program how I wanted to.
Be sparing with your abuse of this, because it can cause surprise, while the “Principle of Least Surprise” is a UI/UX bedrock.
$SIG{INT} = \&tsktsk;
sub tsktsk {
clear_screen();
say "THANK YOU";
exit;
}
Width and Height
If we’re to draw anything, we’ll want to get the height and width of the terminal window, which we get with GetTerminalSize
from Term::ReadKey. It returns the dimensions both in characters and pixels, but here, we’re just using characters.
Beyond that, characters are taller than they are wide. I think it’s roughly 2/3s as wide as tall, but for our purposes here, using half the size makes the conversion easy. We also cut a line off the height to be sure we’re short and not jittery.
# docs say this mneeds to be called with an
# output filehandle on Windows.
my ( $wchar, $hchar, $wpixels, $hpixels ) = GetTerminalSize();
$hchar -= 2;
$wchar /= 2;
my $centerx = int( $wchar / 2 );
my $centery = int( $hchar / 2 );
Screen Clearing
This is mentioned but moved past in the %SIG
section, but here we see how to use Term::Cap to give us access to Tputs
, which allows us toclear_screen
.
my ($delay,$tcap);
sub clear_screen { $tcap->Tputs( 'cl', 1, *STDOUT ) }
sub init {
$| = 1;
$delay = ( shift() || 0 ) * 0.005;
my $termios = POSIX::Termios->new();
$termios->getattr;
my $ospeed = $termios->getospeed;
$tcap = Term::Cap->Tgetent( { TERM => undef, OSPEED => $ospeed } );
$tcap->Trequire(qw(cl cm cd));
}
Avoiding the “Right” Way
I’m sure there’s a way to write a character to a specific X,Y coordinate in a terminal with Perl. I have never known it. It didn’t come to me in the casual Googling as I wrote this. But I knew that, knowing the size of the terminal, I could assign something to every position. Arrays!
my @array;
for my $y ( 0 .. -1 + $hchar ) {
for my $x ( 0 .. -1 + $wchar ) {
$array[$y][$x] = ' ';
}
}
Did I Promise There Would Be No Math? I Didn’t Think So.
Ever wonder why we define a circle as being 360 degrees? Because you have a wide number of factors to split it in and still keep simple integer math. The key ones to keep in mind are 12 * 30 and 6 * 60, meaning that you can do simple multiplication to get the degrees.
But1, we don’t want that, because with the orientation we’re using, 0,0 is at the top left, not the bottom left. We can work around that, though.
But2, we don’t really want that pure integer math, because we can do something clever. I’m writing this at 5:06pm, and if we use that, feed 5.1
into the system instead of 5
, then as we get closer to the next hour, the hour hand looks closer to the next hour, instead of making a 30° jump from, in this case, 5
to 6
. The 6° jumps for the minute hand are smaller and less jarring, but we can smooth them out a bit as well. I’m refreshing on the second, not using fractional-second sleep, so that’s as low as we can go.
But3, we don’t actually want degrees, we want radians! and can get them with deg2rad
from Math::Trig.
So, we have a direction in radians, and using min
from List::Util to find the smalles of $centerx
and $centery
to allow us to dynamically size the clock face, how do we draw the hands? Math it out from the previously-discovered center.
my $xpos = $centerx + int xpos( $l, $rad );
my $ypos = $centery - int ypos( $l, $rad );
sub xpos ( $length, $radians ) { return $length * sin($radians) }
sub ypos ( $length, $radians ) { return $length * cos($radians) }
I’m handwaving DateTime because I refer to it a lot and the documentation is very good.
Show Me The Code
Here is the complete thing. There are a few things I could do to improve this. I could put characters outside the circle of the face to make the circle easier to distinguish. I could follow brain d foy’s suggestion and use Term::ANSIColor to do things like make the second hand red (which I recall from every classroom clock I’ve seen). I could actually learn how to write to specific locations. I’m certainly open to suggestions.
#!/usr/bin/env perl
use strict;
use warnings;
use feature qw{ say signatures state };
no warnings qw{ experimental };
use DateTime;
use List::Util qw{ min };
use Math::Trig qw(deg2rad);
use Term::Cap;
use Term::ReadKey;
$SIG{INT} = \&tsktsk;
sub tsktsk {
clear_screen();
say "THANK YOU";
exit;
}
my ( $delay, $tcap );
init(); # Initialize Term::Cap.
tick();
exit;
sub tick() {
while (1) {
clear_screen();
my ( $wchar, $hchar, $wpixels, $hpixels ) = GetTerminalSize();
$hchar -= 2;
$wchar /= 2;
my $centerx = int( $wchar / 2 );
my $centery = int( $hchar / 2 );
my @array;
for my $y ( 0 .. -1 + $hchar ) {
for my $x ( 0 .. -1 + $wchar ) {
$array[$y][$x] = ' ';
}
}
my $now = DateTime->now;
$now->set_time_zone('America/New_York');
my $h = $now->hour;
my $m = $now->minute;
my $s = $now->second;
my $ss = $s % 10;
my $r = ( min $centerx, $centery ) - 2;
# hour marks
for ( my $deg = 30 ; $deg <= 360 ; $deg += 30 ) {
my $d = $deg / 30;
my $rad = deg2rad($deg);
my $xpos = $centerx + int xpos( $r, $rad );
my $ypos = $centery - int ypos( $r, $rad );
$array[$ypos][$xpos] = sprintf '%02d', $d;
}
# hour hand
$h %= 12;
my $mfrac = ( $m / 60 );
for my $l ( 1 .. $r * ( 2 / 3 ) ) {
my $rad = deg2rad( ( $h + $mfrac ) * 30 );
my $xpos = $centerx + int xpos( $l, $rad );
my $ypos = $centery - int ypos( $l, $rad );
$array[$ypos][$xpos] = 'h ';
}
# minute hand
my $sfrac = ( $s / 60 );
for my $l ( 1 .. $r * 0.9 ) {
my $rad = deg2rad( ( $m + $sfrac ) * 6 );
my $xpos = $centerx + int xpos( $l, $rad );
my $ypos = $centery - int ypos( $l, $rad );
$array[$ypos][$xpos] = 'm ';
}
# second hand
for my $l ( 1 .. $r * 0.8 ) {
my $rad = deg2rad( $s * 6 );
my $xpos = $centerx + int xpos( $l, $rad );
my $ypos = $centery - int ypos( $l, $rad );
$array[$ypos][$xpos] = 's ';
}
$array[$centery][$centerx] = '# ';
say join "\n", map { join '', $_->@* } @array;
sleep 1;
}
}
sub xpos ( $length, $radians ) { return $length * sin($radians) }
sub ypos ( $length, $radians ) { return $length * cos($radians) }
sub clear_screen { $tcap->Tputs( 'cl', 1, *STDOUT ) }
sub init {
$| = 1;
$delay = ( shift() || 0 ) * 0.005;
my $termios = POSIX::Termios->new();
$termios->getattr;
my $ospeed = $termios->getospeed;
$tcap = Term::Cap->Tgetent( { TERM => undef, OSPEED => $ospeed } );
$tcap->Trequire(qw(cl cm cd));
}