NAME

Term::Fabulous::Cookbook::LiveData - Recipes: timers, logs and the output of commands

DESCRIPTION

This page is part of Term::Fabulous::Cookbook. Previous page: Term::Fabulous::Cookbook::KeyboardAndMouse. Next page: Term::Fabulous::Cookbook::Forms.

This page shows programs whose screen changes while the user does nothing: a clock updated by a timer, a log that grows, a view that follows the newest line of the log, and the output of a command that runs in the background. They use IO::Async timers and processes together with Term::Fabulous::Widget::ScrollBox and Term::Fabulous::Widget::Text. How the event loop, timers and frames work together is explained in the timers section of the programs chapter; scrolling in the scrolling section of the events chapter. For charts that follow new data, see "A live chart that follows new data (append, max_points, span)" in Term::Fabulous::Cookbook::ChartTechniques.

The recipes on this page:

Update the screen from a timer (a clock)

Goal: change what the screen shows at regular intervals, here a clock in the middle of the terminal.

This program is shipped as examples/cookbook/clock.pl.

use v5.32;
use warnings;
use feature 'signatures';
no warnings 'experimental::signatures';

use IO::Async::Loop;
use IO::Async::Timer::Periodic;
use POSIX qw(strftime);
use Term::Fabulous;
use Term::Fabulous::Widget::Box;
use Term::Fabulous::Widget::Text;
use Clay::XS qw(sizing_grow CLAY_ALIGN_X_CENTER CLAY_ALIGN_Y_CENTER);

my $clock = Term::Fabulous::Widget::Text->new( text => '--:--:--', text_color => [ 255, 200, 80, 255 ] );
my $root  = Term::Fabulous::Widget::Box->new(
	layout => {
		sizing          => { width => sizing_grow(),       height => sizing_grow() },
		child_alignment => { x     => CLAY_ALIGN_X_CENTER, y      => CLAY_ALIGN_Y_CENTER },
	},
);
$root->add_child($clock);

my $timer = IO::Async::Timer::Periodic->new(
	interval       => 1,
	first_interval => 0,
	on_tick        => sub { $clock->text( strftime( '%H:%M:%S', localtime ) ) },
);
$timer->start;
IO::Async::Loop->new->add($timer);

Term::Fabulous->new( root => $root, width => 80, height => 24 )->run;

A clock in the middle of the terminal

Add lines to a scrolling log (ScrollBox)

Goal: a box that receives a new line every half second, can be scrolled with the mouse wheel, and never holds more than 500 lines.

This program is shipped as examples/cookbook/scrolling-log.pl.

use v5.32;
use warnings;
use feature 'signatures';
no warnings 'experimental::signatures';

use IO::Async::Loop;
use IO::Async::Timer::Periodic;
use POSIX qw(strftime);
use Term::Fabulous;
use Term::Fabulous::Enum::BorderStyle;
use Term::Fabulous::Widget::Box;
use Term::Fabulous::Widget::ScrollBox;
use Term::Fabulous::Widget::Text;
use Clay::XS qw(sizing_grow CLAY_TOP_TO_BOTTOM);

use constant MAX_LINES => 500;

my $root = Term::Fabulous::Widget::Box->new(
	layout => {
		layout_direction => CLAY_TOP_TO_BOTTOM,
		sizing           => { width => sizing_grow(), height => sizing_grow() },
		padding          => { left  => 1,             right  => 1 },
	},
);

# A ScrollBox needs an id: Clay keeps the scroll position by id.
my $log = Term::Fabulous::Widget::ScrollBox->new(
	id               => 'log',
	background_color => [ 30, 35, 50, 255 ],
	bordered         => 1,
	border_color     => [ 120, 160, 220, 255 ],
	border_style     => Term::Fabulous::Enum::BorderStyle->Round,
	layout           => {
		layout_direction => CLAY_TOP_TO_BOTTOM,
		sizing           => { width => sizing_grow(), height => sizing_grow() },
		padding          => { left  => 1,             right  => 1 },
	},
);
$root->add_child($log);

my $count = 0;
my $timer = IO::Async::Timer::Periodic->new(
	interval => 0.5,
	on_tick  => sub {
		$count++;
		$log->add_child(
			Term::Fabulous::Widget::Text->new(
				text       => strftime( '%H:%M:%S', localtime ) . " event number $count",
				text_color => [ 200, 210, 230, 255 ],
			)
		);

		# Keep the log from growing forever: drop the oldest line.
		my $oldest = $log->children->[0];
		$log->remove_child($oldest) if @{ $log->children } > MAX_LINES;
		return;
	},
);
$timer->start;
IO::Async::Loop->new->add($timer);

Term::Fabulous->new( root => $root, width => 80, height => 24 )->run;

A framed log with ten timestamped lines

Scroll a ScrollBox from code (keep a log at the newest line)

Goal: a log that always shows its newest line, until the user scrolls up to read older lines; keys that scroll the log up, down and sideways.

This program is shipped as examples/cookbook/follow-log.pl.

use v5.32;
use warnings;
use feature 'signatures';
no warnings 'experimental::signatures';

use IO::Async::Loop;
use IO::Async::Timer::Periodic;
use Term::Fabulous;
use Term::Fabulous::Enum::BorderStyle;
use Term::Fabulous::Scrolling qw(scroll_by_columns scroll_by_rows scroll_to_end);
use Term::Fabulous::Widget::Box;
use Term::Fabulous::Widget::ScrollBox;
use Term::Fabulous::Widget::Text;
use Clay::XS qw(sizing_grow CLAY_TOP_TO_BOTTOM);

my $root = Term::Fabulous::Widget::Box->new(
	layout => {
		layout_direction => CLAY_TOP_TO_BOTTOM,
		sizing           => { width => sizing_grow(), height => sizing_grow() },
		padding          => { left  => 1,             right  => 1 },
	},
);
$root->add_child(
	Term::Fabulous::Widget::Text->new(
		text       => 'Wheel or Up/Down scroll, Left/Right scroll sideways, Home goes to the top, End follows the newest line.',
		text_color => [ 230, 230, 230, 255 ],
	)
);

# follow_end keeps the newest line in view until the user scrolls up;
# back at the end, the box follows again.
my $log = Term::Fabulous::Widget::ScrollBox->new(
	id               => 'log',
	horizontal       => 1,    # vertical scrolling is on by default
	follow_end       => 1,
	background_color => [ 30, 35, 50, 255 ],
	bordered         => 1,
	border_color     => [ 120, 160, 220, 255 ],
	border_style     => Term::Fabulous::Enum::BorderStyle->Round,
	layout           => {
		layout_direction => CLAY_TOP_TO_BOTTOM,
		sizing           => { width => sizing_grow(), height => sizing_grow() },
	},
);
$root->add_child($log);

my $ui = Term::Fabulous->new( root => $root, width => 80, height => 24 );

# The moves stop at the edges of the content. Moving up leaves the end,
# which stops the following; End goes back to it, which resumes it.
my %action_by_key = (
	Up    => sub { scroll_by_rows( $ui, $log, -1 ) },
	Down  => sub { scroll_by_rows( $ui, $log,  1 ) },
	Left  => sub { scroll_by_columns( $ui, $log, -4 ) },
	Right => sub { scroll_by_columns( $ui, $log,  4 ) },
	Home  => sub { $ui->scroll_to( $log, { x => 0, y => 0 } ) },
	End   => sub { scroll_to_end( $ui, $log ) },
);
$root->on(
	KeyPress => sub ($event) {
		my $action = $action_by_key{ $event->main_key_name // '' } or return;
		$action->();
		return;
	}
);

my $count = 0;
my $loop  = IO::Async::Loop->new;
$loop->add(
	IO::Async::Timer::Periodic->new(
		interval => 0.25,
		on_tick  => sub {
			$count++;
			my $text = sprintf 'Line %4d', $count;
			$text .= ' ' . ( '-' x 150 ) . ' a wide line' if $count % 5 == 0;    # wider than the box
			$log->add_child( Term::Fabulous::Widget::Text->new( text => $text, text_color => [ 200, 210, 230, 255 ] ) );
			return;
		},
	)->start
);

$ui->run;

A log that shows its newest lines; every fifth line is wider than the box and cut at its edge

  • follow_end => 1 makes the box follow the end of its content (see "follow_end" in Term::Fabulous::Widget::ScrollBox): while it shows the last row, a line added at the bottom scrolls into view. The size of a new line is known only after the frame that lays it out, so the box moves after that frame and the newest line shows one frame later, which is due at once. The box stops following as soon as the user scrolls up, with the wheel, the scrollbar or a key, and follows again once it shows the last row, here with End.

  • Term::Fabulous::Scrolling moves the view by rows (scroll_by_rows), by columns (scroll_by_columns) and to the end (scroll_to_end). The moves stop at the edges of the content and do nothing before the first frame has laid the box out. For the full set of pager keys in one call, see scroll_by_key and the recipe "Scroll any scroll box like a pager (Term::Fabulous::Scrolling)" in Term::Fabulous::Cookbook::DocumentTechniques.

  • $ui->scroll_to( $scroll_box, { x => 0, y => 0 } ) sets the position directly, here the top left corner; an axis left out keeps its position. It makes a frame due, which shows the new position even when nothing else changed. See "scroll_to" in Clay::UI and, for the position of the last frame, "scroll_state" in Clay::UI; Term::Fabulous inherits both from Clay::UI.

  • horizontal => 1 lets the content be wider than the box. A horizontal wheel (or a sideways tilt of the wheel) scrolls it by three columns per notch; for a mouse without one, add code such as the Left and Right keys here.

  • To position the content entirely yourself, set the scroll box's child_offset to { x => ..., y => ... } (negative values, as Clay keeps the scroll position: { x => 0, y => -3 } shows the content from its fourth row). While it is set, it alone decides what is shown; the wheel and scroll_to still move the scroll position, which shows again once child_offset is set back to undef. See Term::Fabulous::Widget::ScrollBox and "SCROLLING" in Term::Fabulous::Manual::Events.

Show the output of a running command

Goal: run a command, show its output and its errors line by line while it runs, and its exit status when it ends.

This program is shipped as examples/cookbook/show-output.pl.

use v5.32;
use warnings;
use feature 'signatures';
no warnings 'experimental::signatures';

use Encode qw(decode);
use IO::Async::Loop;
use IO::Async::Process;
use Term::Fabulous;
use Term::Fabulous::Enum::BorderStyle;
use Term::Fabulous::Widget::Box;
use Term::Fabulous::Widget::ScrollBox;
use Term::Fabulous::Widget::Text;
use Clay::XS qw(sizing_grow CLAY_TOP_TO_BOTTOM);

# The command to run: the program's arguments, or a default.
my @command = @ARGV ? @ARGV : ( 'ls', '-l', '/' );

my $root = Term::Fabulous::Widget::Box->new(
	layout => {
		layout_direction => CLAY_TOP_TO_BOTTOM,
		sizing           => { width => sizing_grow(), height => sizing_grow() },
		padding          => { left  => 1,             right  => 1 },
	},
);

# The arguments are bytes, like the command's output.
my $title = Term::Fabulous::Widget::Text->new( text => decode( 'UTF-8', "Running: @command" ), text_color => [ 255, 200, 80, 255 ] );

# follow_end keeps the newest line in view (see the recipe "Scroll a
# ScrollBox from code").
my $output = Term::Fabulous::Widget::ScrollBox->new(
	id               => 'output',
	follow_end       => 1,
	background_color => [ 30, 35, 50, 255 ],
	bordered         => 1,
	border_color     => [ 120, 160, 220, 255 ],
	border_style     => Term::Fabulous::Enum::BorderStyle->Round,
	layout           => { layout_direction => CLAY_TOP_TO_BOTTOM, sizing => { width => sizing_grow(), height => sizing_grow() } },
);
$root->add_child( $title, $output );

sub add_line ( $line, $color = [ 200, 210, 230, 255 ] ) {
	$output->add_child( Term::Fabulous::Widget::Text->new( text => $line, text_color => $color ) );
	return;
}

# Turns the bytes of one output line into a character string; bytes
# that are not valid UTF-8 become U+FFFD.
sub clean ($bytes) {
	$bytes =~ s/\r\z//;
	return decode( 'UTF-8', $bytes );
}

# Hands every complete line of a stream to add_line.
sub line_reader ($color) {
	return sub ( $stream, $buffer_ref, $eof ) {
		add_line( clean($1), $color ) while $$buffer_ref =~ s/\A([^\n]*)\n//;
		if ( $eof && length $$buffer_ref ) {    # a last line without a line break
			add_line( clean($$buffer_ref), $color );
			$$buffer_ref = '';
		}
		return 0;
	};
}

my $loop    = IO::Async::Loop->new;
my $process = IO::Async::Process->new(
	command   => \@command,
	stdin     => { from    => '' },    # the command must not read the terminal
	stdout    => { on_read => line_reader( [ 200, 210, 230, 255 ] ) },
	stderr    => { on_read => line_reader( [ 255, 110, 110, 255 ] ) },
	on_finish => sub ( $process, $exit_code ) {
		add_line( sprintf( 'Finished with exit status %d. Press Ctrl+C to quit.', $exit_code >> 8 ), [ 255, 200, 80, 255 ] );
		return;
	},
	on_exception => sub ( $process, $exception, $errno, $exit_code ) {

		# A failed exec leaves $exception empty and the reason in $errno.
		my $reason = length( $exception // '' ) ? $exception : "$errno";
		add_line( "Could not run the command: $reason", [ 255, 110, 110, 255 ] );
		return;
	},
);
$loop->add($process);

my $ui = Term::Fabulous->new( root => $root, width => 80, height => 24 );

$ui->run;

The last lines of the output of seq and the exit status in a framed box

Run it with a command and its arguments, for example perl examples/cookbook/show-output.pl ping -c 5 localhost; without arguments it runs ls -l /. The picture shows it running seq -f 'Line %g' 1 40.

  • IO::Async::Process runs the command in the event loop that Term::Fabulous uses, so the screen stays responsive while the command runs. Its on_read callbacks get the output as it arrives, which may be several lines or part of a line at a time; the reader takes complete lines out of the buffer and keeps the rest for the next call.

  • stdin => { from => '' } gives the command an empty input. Without it the command would share the terminal with the program and could read the user's key presses.

  • Command output is bytes, and Text widgets take character strings, so clean decodes every line. Decoding also turns bytes that are not valid UTF-8 into U+FFFD. The same decoded lines can go into a Term::Fabulous::Widget::TextArea or a canvas.

  • Errors (stderr) are shown in red. on_finish reports the exit status, on_exception a command that could not be started.

  • follow_end => 1 keeps the newest line in view, as explained in "Scroll a ScrollBox from code (keep a log at the newest line)"; scrolling up stops it, scrolling back to the end resumes it.

SEE ALSO

This page is part of Term::Fabulous::Cookbook. Previous page: Term::Fabulous::Cookbook::KeyboardAndMouse. Next page: Term::Fabulous::Cookbook::Forms.