Skip to content

Instantly share code, notes, and snippets.

@jaggzh
Last active May 4, 2026 01:58
Show Gist options
  • Select an option

  • Save jaggzh/e4523dfe2ea909f47f2bb315e4b873b4 to your computer and use it in GitHub Desktop.

Select an option

Save jaggzh/e4523dfe2ea909f47f2bb315e4b873b4 to your computer and use it in GitHub Desktop.
#!/usr/bin/perl
# htt: head-shoulder-knees-toes (okay, head torso toes for convenience)
# Preview file start, middles, and end.
# Fit in screen.
# Like head and tail.
# gist: https://gist.github.com/jaggzh/e4523dfe2ea909f47f2bb315e4b873b4
# License, MIT: Copyright 2026 jaggz.h {who is at} gmail.com
use v5.24;
use strict;
use warnings;
use Term::ReadKey;
use Getopt::Long;
# Settings:
my $def_extraroom_lines = 3; # Some lines to DEDUCT from term height for prompt and stuff
my $def_theight = 24;
my $def_twidth = 80;
my $tabstop = 8; # Standard tab stop width
# Color settings
my $clr_sep = "\033[30;1m";
my $rst = "\033[m";
my $clr_chunk_fg = "\033[37;1m";
my $cllr = "\033[K"; # clear to eol
my $cll = "\033[2K"; # clear line
# Command line options
my $no_color = 0;
my $use_separators = 0;
my $help = 0;
my $opt_lines;
my $opt_cols;
# Color fading settings
sub a24bg { $no_color ? '' : "\033[48;2;$_[0];$_[1];$_[2]m"; }
my @bg_rgb_fade = ( [ 60, 0, 55 ], [ 0, 13, 38 ] );
my $bg_rgb_fade_lines = 4;
sub uncolor() {
$clr_sep = $rst = $clr_chunk_fg = $cllr = $cll = '';
}
GetOptions(
'C|no-color' => \$no_color,
's|sep' => \$use_separators,
'l|lines=i' => \$opt_lines,
'w|cols=i' => \$opt_cols,
'h|help' => \$help,
) or die("Error in command line arguments\n");
if ($help) {
print_usage();
exit(0);
}
# If no color, automatically enable separators
if ($no_color) {
$use_separators = 1;
uncolor();
}
sub print_usage {
print <<'EOF';
htt - head-shoulder-knees-toes file previewer
Usage: htt [options] [file...]
cat file | htt [options]
Shows a preview of file contents with samples from beginning, middle sections, and end.
Automatically handles line wrapping and fits output to terminal size.
Options:
-C, --no-color Disable color output and enable separators
-s, --sep Use text separators between chunks (even with color)
-h, --help Show this help message
-l, --lines Total lines requested, including separators if -s is used
-w, --cols Columns. Accuracy matters for the line calculations to
result in a proper fit with wrapping.
Size: We will obtain the lines and cols from the terminal by default.
if you specify -l and/or -w, the other will still be obtained.
We will only avoid the call to get the terminal if both -l and -w
are specified.
Examples:
htt largefile.txt # Show colored preview
cat file.c | htt # Preview from stdin
htt -C script.sh # No color, with separators
htt -s --no-color log # Explicit separator mode
Without files, reads from standard input.
EOF
}
# Get terminal dimensions
my ($width, $height);
if (defined $opt_lines && defined $opt_cols) {
($width, $height) = ($opt_cols, $opt_lines);
} else {
($width, $height) = GetTerminalSize();
if (!defined $height) {
say STDERR "Couldn't determine term height. Using $def_theight.";
$height = $def_theight;
}
if (!defined $width) {
say STDERR "Couldn't determine term width. Using $def_twidth.";
$width = $def_twidth;
}
$height = $opt_lines if defined $opt_lines;
$width = $opt_cols if defined $opt_cols;
}
# Calculate visual width of text, handling tabs and escape sequences
sub visual_width {
my ($text, $start_col) = @_;
$start_col //= 0;
my $pos = $start_col;
my $i = 0;
while ($i < length($text)) {
my $char = substr($text, $i, 1);
if ($char eq "\t") {
# Move to next tabstop
$pos = int(($pos + $tabstop) / $tabstop) * $tabstop;
$i++;
} elsif ($char eq "\033") {
# Skip escape sequence
$i++;
if ($i < length($text) && substr($text, $i, 1) eq '[') {
$i++; # skip [
# Skip until we find the final character (letter)
while ($i < length($text)) {
my $c = substr($text, $i, 1);
$i++;
last if $c =~ /[a-zA-Z]/;
}
}
} else {
$pos++;
$i++;
}
}
return $pos - $start_col;
}
# Find a safe position to insert color codes (not in middle of escape sequence)
sub find_safe_insert_pos {
my ($text, $target_pos) = @_;
return 0 if $target_pos <= 0;
return length($text) if $target_pos >= length($text);
# If we're not on an escape sequence, we're good
my $char = substr($text, $target_pos, 1);
return $target_pos if $char ne "\033";
# We're on an escape sequence, find the end
my $pos = $target_pos + 1;
if ($pos < length($text) && substr($text, $pos, 1) eq '[') {
$pos++; # skip [
while ($pos < length($text)) {
my $c = substr($text, $pos, 1);
$pos++;
last if $c =~ /[a-zA-Z]/;
}
}
return $pos;
}
# Split a line into terminal-width chunks, inserting color codes
sub split_line_with_colors {
my ($line, $chunk_line_num, $chunk_num) = @_;
chomp $line;
my @result_lines;
my $remaining = $line;
my $line_num_in_chunk = $chunk_line_num;
while (length($remaining) > 0) {
my $visual_len = visual_width($remaining);
if ($visual_len <= $width) {
# Line fits, add color and we're done
if ($no_color) {
push @result_lines, "$remaining\n";
} else {
my $color = get_line_color($line_num_in_chunk, $chunk_num);
push @result_lines, "$color$cll$remaining$rst\n";
}
last;
}
# Line is too long, need to wrap
my $cut_pos = find_wrap_position($remaining, $width);
my $safe_pos = find_safe_insert_pos($remaining, $cut_pos);
my $line_part = substr($remaining, 0, $safe_pos);
if ($no_color) {
push @result_lines, "$line_part\n";
} else {
my $color = get_line_color($line_num_in_chunk, $chunk_num);
push @result_lines, "$color$cll$line_part$rst\n";
}
$remaining = substr($remaining, $safe_pos);
$line_num_in_chunk++;
}
return @result_lines;
}
# Find where to wrap a line based on visual width
sub find_wrap_position {
my ($text, $max_width) = @_;
my $pos = 0;
my $visual_pos = 0;
my $last_safe_break = 0;
while ($pos < length($text) && $visual_pos < $max_width) {
my $char = substr($text, $pos, 1);
if ($char eq "\t") {
my $new_visual = int(($visual_pos + $tabstop) / $tabstop) * $tabstop;
if ($new_visual > $max_width) {
last;
}
$visual_pos = $new_visual;
$pos++;
$last_safe_break = $pos;
} elsif ($char eq "\033") {
# Skip escape sequence
$pos++;
if ($pos < length($text) && substr($text, $pos, 1) eq '[') {
$pos++; # skip [
while ($pos < length($text)) {
my $c = substr($text, $pos, 1);
$pos++;
last if $c =~ /[a-zA-Z]/;
}
}
$last_safe_break = $pos;
} else {
if ($visual_pos >= $max_width) {
last;
}
$visual_pos++;
$pos++;
$last_safe_break = $pos;
}
}
return $last_safe_break;
}
# Generate color for a line based on its position in chunk
sub get_line_color {
my ($line_num, $chunk_num) = @_;
if ($line_num < $bg_rgb_fade_lines) {
# Fade from color 0 to color 1
my $ratio = $line_num / ($bg_rgb_fade_lines - 1);
$ratio = 1 if $ratio > 1;
my @start_rgb = @{$bg_rgb_fade[0]};
my @end_rgb = @{$bg_rgb_fade[1]};
my @current_rgb;
for my $i (0..2) {
$current_rgb[$i] = int($start_rgb[$i] + ($end_rgb[$i] - $start_rgb[$i]) * $ratio);
}
return a24bg(@current_rgb) . $clr_chunk_fg;
} else {
# Use the end color
return a24bg(@{$bg_rgb_fade[1]}) . $clr_chunk_fg;
}
}
# Calculate how many terminal lines a logical line will take
sub count_terminal_lines {
my ($line) = @_;
chomp $line;
return 1 if length($line) == 0;
my $lines = 0;
my $remaining = $line;
while (length($remaining) > 0) {
my $visual_len = visual_width($remaining);
if ($visual_len <= $width) {
$lines++;
last;
}
my $cut_pos = find_wrap_position($remaining, $width);
$cut_pos = 1 if $cut_pos == 0; # Ensure progress
$remaining = substr($remaining, $cut_pos);
$lines++;
}
return $lines;
}
# Read input
my @lines = <>;
my $total_lines = scalar @lines;
# Calculate terminal line mapping - for each logical line, track how many terminal lines it takes
# and the cumulative terminal line position
my @line_terminal_counts = ();
my @cumulative_terminal_lines = ();
my $total_terminal_lines = 0;
for my $i (0 .. $#lines) {
my $line_count = count_terminal_lines($lines[$i]);
$line_terminal_counts[$i] = $line_count;
$cumulative_terminal_lines[$i] = $total_terminal_lines;
$total_terminal_lines += $line_count;
}
# Determine available space and chunk setup
my $chunks = 4;
my $separator_lines = $use_separators ? ($chunks - 1) : 0; # 3 separators for 4 chunks
my $available_lines = $height - $def_extraroom_lines - $separator_lines;
# If file is short enough, just show it all
if ($total_terminal_lines <= $available_lines) {
my $line_num = 0;
for my $line (@lines) {
my @display_lines = split_line_with_colors($line, $line_num, 0);
print @display_lines;
$line_num += scalar(@display_lines);
}
exit;
}
# For longer files, calculate chunks distributed across the file (head-shoulder-knees-toes)
my $chunk_terminal_lines = int($available_lines / $chunks);
for my $chunk_i (0 .. $chunks - 1) {
# Calculate where in the file this chunk should start (as terminal line position)
my $target_terminal_start = int($chunk_i * $total_terminal_lines / $chunks);
# Find the logical line that contains this terminal line position
my $start_logical = 0;
for my $i (0 .. $#lines) {
if ($cumulative_terminal_lines[$i] <= $target_terminal_start &&
$cumulative_terminal_lines[$i] + $line_terminal_counts[$i] > $target_terminal_start) {
$start_logical = $i;
last;
} elsif ($cumulative_terminal_lines[$i] > $target_terminal_start) {
$start_logical = $i > 0 ? $i - 1 : 0;
last;
}
$start_logical = $i; # fallback for last iteration
}
# Make sure we don't go past the end
if ($start_logical >= $total_lines) {
last;
}
# Find how many logical lines we can fit within our terminal line budget
my $end_logical = $start_logical;
my $terminal_lines_used = 0;
while ($end_logical < $total_lines && $terminal_lines_used < $chunk_terminal_lines) {
my $line_terminal_count = $line_terminal_counts[$end_logical];
if ($terminal_lines_used + $line_terminal_count <= $chunk_terminal_lines) {
$terminal_lines_used += $line_terminal_count;
$end_logical++;
} else {
# This line would exceed our budget
if ($terminal_lines_used == 0) {
# We must show at least part of this line, crop it
my @display_lines = split_line_with_colors($lines[$end_logical], 0, $chunk_i);
my $lines_to_show = $chunk_terminal_lines;
for my $i (0 .. min($#display_lines, $lines_to_show - 1)) {
print $display_lines[$i];
}
$end_logical++;
}
last;
}
}
# Display the chunk (if we haven't already displayed a cropped line)
if ($terminal_lines_used > 0) {
my $chunk_line_num = 0;
for my $line_i ($start_logical .. $end_logical - 1) {
my @display_lines = split_line_with_colors($lines[$line_i], $chunk_line_num, $chunk_i);
print @display_lines;
$chunk_line_num += scalar(@display_lines);
}
}
# Print separator between chunks (but not after the last one)
if ($use_separators && $chunk_i < $chunks - 1) {
print "$clr_sep$cll..$rst\n";
}
}
sub min { $_[0] < $_[1] ? $_[0] : $_[1] }
exit;
# vim: et ts=4 sw=4 sts=4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment