Last active
May 4, 2026 01:58
-
-
Save jaggzh/e4523dfe2ea909f47f2bb315e4b873b4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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