#!/usr/bin/perl
use strict;
use utf8;
=encoding utf8
=head1 NAME
scroller - view a command's output in a scrolling window
=head1 SYNOPSIS
scroller [-h|--help] [-s|--size SIZE] [-c|--color COLOR]
[-t|--tab-width WIDTH] [--on-exit keep|error|print]
[-w|--window WINDOWSPEC]
COMMAND ARGS..
See the manpage I<scroller(1)> for more details.
=head1 DESCRIPTION
scroller runs a provided command and displays its output (both stderr
and stdout) in a scrolling window in the terminal. By default, the window
is 10 lines tall and as wide as the currently connected terminal, although
this size can be set manually using the I<--size>/I<-s> option.
Interactive commands or commands that themselves manipulate the terminal will
I<not> play nice with scroller and will likely produce garbled output.
For a module that can display a window like this for arbitrary text input,
see L<Term::Scroller>.
=head1 EXAMPLES
# Default options (window is 10 lines tall with no border)
scroller mycommand
# Adjust the window height to 25 lines, and use a preset border
scroller --size 25 --window box mycommand
# Adjust window height and width, use a custom window design
scroller --size 25x40 --window '-#|#-#|#'
# If 'mycommand' fails, display its entire output when its done
scroller --on-exit error mycommand
# Pipe into another command
# 'myothercommand' will see the unaltered stdout of 'mycommand'!
scroller mycommand | myothercommand
=head1 OPTIONS
=over 4
=item * I<-h>, I<--help>
Display help and exit.
=item * I<-s>, I<--size> B<SIZE>
Set the size of view window. B<SIZE> is of the form I<H[xW]> where I<H> is the
height (in lines) of the window and I<W> is the width (in columns) of the
window. If width isn't specifed, it will default to the width the connected
terminal (or 80 if the width couldn't be determined for some reason).
=item * I<-c>, I<--color> B<COLOR>
Set the color of the text within the window. B<COLOR> is any ANSI escape
sequence B<without> the initial escape character (e.g. "[34m" for blue text).
If this is set, any escape sequences within the command's actual output will
be ignored. Without it, color-setting escape sequences in the output are passed
through.
=item * I<-t>, I<--tab-width> B<WIDTH>
Set the width of tabs (in characters) when viewed in the viewport.
For consistent printing, tabs are replaced with this number of spaces.
Defaults to 4.
=item * I<--on-exit> B<keep|error|print>
Set the behavior of scroller after the command exits. Value is one of I<keep>,
I<error> or I<print>. If I<keep>, then the window will remain with the
last lines of output still visible. If I<print>, then the window will be erased
and the B<entire> output of the command will be printed. I<error> is like
I<print> except the output will only be printed if the command fails
(non-zero exit status). If this option is not specified, then the window
disappears after the command exits.
=item * I<-w>, I<--window> B<WINDOWSPEC>
Specify the borders of a window to draw around the view of the output text.
See the B<WINDOW DRAWING> section for how to create a B<WINDOWSPEC>.
Alternatively, you can provide one of the words, I<box>, I<flagpole>, I<pipe>,
I<box-ascii>, I<flagpole-acii> or I<pipe-ascii>, to use a preset design. The
regular presets use Unicode box drawing characters, so if you're limited to
only ascii, use one of the "-ascii" variants. See the B<WINDOW PRESETS> section
for examples of the presets.
=back
=head2 OUTPUT REDIRECTION & PIPES
If scroller is called such that it outputs directly to the terminal, then
the the scrolling window is printed on stderr. However, scroller is designed
to play well with pipelines and redirection, so if the output (of either
stdout or stderr) is not a terminal (such as a pipe or file) then the scrolling
window is printed directly to I</dev/tty> and the command's stdout and stderr
will pass through unchanged.
=head2 WINDOW DRAWING
A B<WINDOWSPEC> is a string up to 8 characters long indicating which character
to use for a part of the window, in clockwise order. That is, the characters
specify the top side, top-right corner, right side, bottom-right corner,
bottom side, bottom-left corner, left side and top-left corner respectively.
If any character is a whitespace or is missing (due to the string not being
long enough), then that part of the window will not be drawn.
=head2 WINDOW PRESETS
(Depending on how you're viewing this document, the Unicode text may not
be displayed correctly)
=over 4
=item * B<box>: '─┐│┘─└│┌'
┌────────────┐
│your text here│
└────────────┘
=item * B<flagpole>: ' ·│·'
·
│your text here
·
=item * B<pipe>: ' │ '
│your text here
=item * B<box-ascii>: '-#|#-#|#'
#--------------#
|your text here|
#--------------#
=item * B<flagpole-ascii>: ' #|#'
#
|your text here
#
=item * B<pipe-ascii>: ' | '
|your text here
=back
=cut
use Term::Scroller::Linefeed qw(linefeed);
use Getopt::Long qw(:config auto_help pass_through require_order);
use Pod::Usage qw(pod2usage);
use POSIX qw(:sys_wait_h);
use Scalar::Util qw(openhandle);
use IO::Pty;
use Encode::Locale qw(decode_argv);
my %default_windows = (
'box' => '─┐│┘─└│┌',
'flagpole' => ' ·│·',
'pipe' => ' │ ',
'box-ascii' => '-#|#-#|#',
'flagpole-ascii' => ' #|#',
'pipe-ascii' => ' | '
);
# Parse options
my ($width, $height, $style, $tabwidth, $onexit, $windowspec);
decode_argv();
my $success = GetOptions(
's|size=s' => sub {
my ($name, $val) = @_;
($height, $width) = ( $val =~ m/^(\d+)(?:x(\d+))?$/ );
unless (defined $height) {
die "Invalid size spec. Must be 'H[xW]'";
}
},
'c|color=s' => sub {
my ($name, $val) = @_;
unless ( $val =~ m/^\[\d+(?>(;\d)+)*m$/ ) {
die "Invalid escape sequence for color."
}
$style = $val;
},
't|tab-width=s' => sub {
my ($name, $val) = @_;
unless ( $val =~ m/^\d+$/) {
die "Invalid value for tab-width. Must be a positive integer";
}
$tabwidth = $val;
},
'on-exit=s' => sub {
my ($name, $val) = @_;
unless ( $val =~ m/^(?:keep|print|error)$/i ) {
die "Invalid --on-exit value. Must be 'keep','print' or 'error'.";
}
$onexit = lc $val;
},
'w|window=s' => sub {
my ($name, $val) = @_;
if ($default_windows{$val}) {
$windowspec = $default_windows{$val};
} else {
$windowspec = $val;
}
}
);
pod2usage("Error in arguments.") unless $success;
pod2usage("Must specify command") unless @ARGV;
my @command = @ARGV;
# Create a temporary file to store scroller output if we
# may need to print it afterwards
my $passthru;
if ( defined $onexit and $onexit ne 'keep' ) {
$passthru = File::Temp->new;
}
# Create a filehandle for scroller output.
my $scroll_out;
if (-t *STDOUT and -t *STDERR) {
# Use stderr
binmode(*STDERR, ':encoding(console_out)');
$scroll_out = *STDERR;
}
else {
# Use /dev/tty
open($scroll_out, '> :encoding(console_out)', '/dev/tty')
or die "cannot open tty: $!";
}
# Create scroller
my $scroller = Term::Scroller->new(
height => $height,
width => $width,
style => defined $style ? "\033$style" : undef,
tabwidth => $tabwidth,
window => $windowspec,
hide => (not defined $onexit) || ($onexit ne 'keep'),
out => $scroll_out,
passthrough => $passthru
);
my $scroller_pid = $scroller->pid;
close $passthru if openhandle($passthru);
# Filehandles for command output and error
# If not connected to a terminal, then we want to pass the output
# through untouched. So we fork a new pty that will tee
# the command's output between our stdout/stderr and the scroller.
# (in this case, the scroller will be connected directly to /dev/tty)
my %comm_out = (src => *STDOUT);
my %comm_err = (src => *STDERR);
for (\%comm_out, \%comm_err) {
if (-t $_->{src}) {
$_->{fh} = $scroller;
$_->{pid} = $scroller_pid;
} else {
my ($tee, $teepid) = tee_fh( $_->{src} );
$_->{fh} = $tee;
$_->{pid} = $teepid;
}
}
# Create a pty that tee's input between the scroller and the
# given filehandle. Retruns a hash with the pty filehandle
# and the pid of the forked process.
sub tee_fh {
my $out = shift;
my $pty = IO::Pty->new;
defined(my $pid = fork) or die "unable to fork: $!";
if ($pid) { # return pty in parent
return wantarray ? ($pty, $pid) : $pty;
}
# Child
close $pty;
while (my $line = linefeed($pty) ) {
print $scroller $line; $scroller->flush;
print $out $line; $out->flush;
}
exit;
}
# Fork and exec command
defined(my $comm_pid = fork) or die "unable to fork: $!";
if ($comm_pid == 0) {
# Child
open STDOUT, '>&', $comm_out{fh};
open STDERR, '>&', $comm_err{fh};
exec @command;
}
# Reap Children
waitpid($comm_pid, 0);
my $exitcode = $?;
# If the command's stdout or stderr were redirected to the intermediate
# pty, send an EOF to that pty and wait for it to close.
if ($comm_out{pid} != $scroller_pid) {
my $fh = $comm_out{fh};
print $fh "\n\04";
waitpid($comm_out{pid}, 0);
close $comm_out{fh};
}
if ($comm_err{pid} != $scroller_pid) {
my $fh = $comm_err{fh};
print $fh "\n\04";
waitpid($comm_err{pid}, 0);
close $comm_err{fh};
}
$scroller->end();
# Print output if 'print' or 'error' was specifed for the on-exit action.
if (defined $passthru) {
open(my $tempfile, '<', $passthru);
my $shouldprint = (
( $onexit eq 'print' )
or
( $onexit eq 'error' and ( $exitcode >> 8 != 0 ) )
);
if ($shouldprint) {
print while (<$tempfile>);
}
close $tempfile;
}
exit $exitcode >> 8;
=head1 SEE ALSO
L<Term::Scroller>
=head1 AUTHOR
Cameron Tauxe, C<camerontauxe at gmail.com>
=head1 LICENSE AND COPYRIGHT
This software is copyright (c) 2020 by Cameron Tauxe.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut