I wish to programmatically (ANSI codes or platform-specific codes, doesn't matter) make a terminal screen freeze 1 or 2 rows either at the top or the bottom of the screen while new material written to the screen scrolls as normal -- including making use of the scrollbar to go back to much older material. The setup is very similar to something like this:
clear screen
print header line 1
print header line 2
set scrolling region to lines 3 to last line
tail -f some_file
This "almost" works but using the standard ANSI sequence for scroll region lock ESC[n;mr
inhibits the scrollbar functionality. New material coming in the tail -f
pushes the old material off the top leaving the headers in place (correct!) but the old material is lost; the scrollbar scrollback does not get the old material.
I am open to freezing a footer or even getting exotic and programmatically splitting the pane (terminal, OS X). It does not need to be portable. The basic requirement is the header lines stay put and other material flows into the scrollbar scrollback memory.
Alternately, if a full-fledged program kind of like tail++
already does this, I am willing to look at that too although I currently I have full programmatic control over the header so as tail -f
material comes in, I can write it to the scroll region AND also update, say, a timestamp in the header, then return the cursor to the "waiting" position.
I don't have a solution that gives you scrollbar functionaility as that is just not supported but here is a workaround with keyboard scrolling.
Run the process to a file output, and tail that file. Then read the keyboard to allow the arrow keys to scroll and possibly jump back to follow mode. Added a trap to cleanup on exit. (gen_numbers is just a holding script for some long running process which could be passed in as a parameter)
#!/bin/zsh
gen_script="./gen_numbers.sh"
# --- Temp File ---
temp_file=$(mktemp)
if [[ -z "$temp_file" || ! -f "$temp_file" ]]; then
echo "Error: Could not create temporary file." >&2
exit 1
fi
# --- State Variables ---
following_mode=true # Start in follow mode
gen_pid=""
tail_pid=""
scroll_top_line=1 # Line number at the top of the scroll view (1-based)
total_lines=0 # Total lines in the temp file
window_height=0 # Number of lines in the scrollable view
# ANSI rows/cols are 1-based.
status_line=1
separator_line=$((status_line + 1))
scroll_start_row=$((separator_line + 1))
# --- ANSI Escape Codes ---
ansi_cup() { printf "\033[%d;%dH" "$1" "$2"; } # Cursor position ROW COL (1-based)
ansi_el() { printf "\033[K"; } # Clear line from cursor to end
ansi_cls_scroll() { # Clear the scrollable area
local i
for (( i=scroll_start_row; i<=LINES; i++ )); do
ansi_cup $i 1
ansi_el
done
}
ansi_hide_cursor() { printf "\033[?25l"; } # Hide cursor
ansi_show_cursor() { printf "\033[?25h"; } # Show cursor
ansi_set_scroll() { printf "\033[%d;%dr" "$1" "$2"; } # Set scroll region START END
ansi_reset_scroll() { printf "\033[r"; } # Reset scroll region to full screen
# --- Functions ---
# Get total lines in the temp file robustly
get_total_lines() {
# awk is generally safer than wc -l for empty files or files without newline at the end
total_lines=$(awk 'END{print NR}' "$temp_file")
# Handle case where file might be completely empty
[[ -z "$total_lines" ]] && total_lines=0
}
# Calculate the height of the scrollable window
calculate_window_height() {
window_height=$((LINES - scroll_start_row + 1))
# Ensure minimum height of 1 if terminal is tiny
(( window_height < 1 )) && window_height=1
}
# Display the current scroll window (when not following)
display_scroll_window() {
get_total_lines
calculate_window_height
# 1. Can't scroll past the end (allow showing last full page)
local max_top_line=$((total_lines - window_height + 1))
(( max_top_line < 1 )) && max_top_line=1 # Ensure it's at least 1
(( scroll_top_line > max_top_line )) && scroll_top_line=$max_top_line
# 2. Can't scroll before the beginning
(( scroll_top_line < 1 )) && scroll_top_line=1
local end_line=$((scroll_top_line + window_height - 1))
# Don't try to read past the actual end of the file
(( end_line > total_lines )) && end_line=$total_lines
# Clear the scroll area first
ansi_cls_scroll
# Position cursor at the start of the scroll area
ansi_cup $scroll_start_row 1
# Display the lines using sed if there are lines to show
if (( total_lines > 0 && scroll_top_line <= end_line )); then
# Use sed to extract and print the specific lines
sed -n "${scroll_top_line},${end_line}p" "$temp_file"
else
# Optionally display a message if file is empty or range is invalid
ansi_cup $scroll_start_row 1
print -n "(No lines to display)"
fi
update_status
}
# Function to update the status line
update_status() {
ansi_cup $status_line 1 # Move cursor to start of status line (Row 1, Col 1)
ansi_el # Clear the line
if [[ "$following_mode" == true ]]; then
print -n "Press Esc/Q to quit | Mode: Following (Press Up/Down to scroll)"
else
# In scroll mode, show position
local display_end_line=$((scroll_top_line + window_height - 1))
(( display_end_line > total_lines )) && display_end_line=$total_lines
(( display_end_line < scroll_top_line )) && display_end_line=$((scroll_top_line)) # Handle tiny window/file
print -n "Press Esc/Q | Mode: Scrolling (Lines ${scroll_top_line}-${display_end_line} of ${total_lines}) | F/T to Follow"
fi
}
# Function to start the tail process
start_tail() {
if [[ -z "$tail_pid" ]]; then # Only start if not already running
# Clear the manually scrolled content before tail starts writing
ansi_cls_scroll
following_mode=true
update_status
ansi_cup $scroll_start_row 1 # Position cursor for tail output
tail -f -n $LINES "$temp_file" &
tail_pid=$!
fi
}
# Function to stop the tail process
stop_tail() {
if [[ -n "$tail_pid" ]] && kill -0 "$tail_pid" 2>/dev/null; then
kill "$tail_pid"
tail_pid="" # Clear the PID
fi
# Even if kill failed or pid was already gone, ensure state is correct
following_mode=false
# Don't update status here, let the caller decide (usually display_scroll_window)
}
# Function to clean up background processes and the temp file
cleanup() {
# Use kill -0 to check if process exists before trying to kill
if [[ -n "$gen_pid" ]] && kill -0 "$gen_pid" 2>/dev/null; then kill "$gen_pid"; fi
# Use the stop_tail function to handle tail cleanup robustly (clears pid)
stop_tail
rm -f "$temp_file"
# Reset terminal settings
ansi_reset_scroll # Reset scroll region
ansi_show_cursor # Ensure cursor is visible
stty echo # Ensure echo is re-enabled
clear # Clear the screen finally
echo "Done"
}
# --- Main Script ---
# Trap EXIT, INT (Ctrl+C), TERM signals to ensure cleanup runs
trap cleanup EXIT INT TERM
# Initial setup
clear
stty -echo # Disable terminal echo for key presses
ansi_hide_cursor # Hide cursor
calculate_window_height # Initial calculation
# Set scroll region below status line and separator
ansi_set_scroll $scroll_start_row $LINES
# Display initial status and separator
# Status will be updated by start_tail shortly
ansi_cup $separator_line 1 # Move to separator line (Row 2, Col 1)
echo "-------------------------"
# Start gen_numbers.sh, redirecting output to the temp file
# Make sure the script exists and is executable
if [[ ! -x "$gen_script" ]]; then
echo "Error: Generator script '$gen_script' not found or not executable." >&2
exit 1
fi
"$gen_script" > "$temp_file" &
gen_pid=$!
# Give generator a moment to create some output, otherwise wc/awk might read 0 initially
sleep 0.1
# Start initial tail -f process (sets following_mode=true and updates status)
start_tail
# Main loop: Wait for user input
while true; do
# Read one character initially
read -k1 key
# Check for Quit keys
if [[ $key == 'q' || $key == 'Q' || $key == $'\e' ]]; then
# Check if it's ESCAPE key potentially starting a sequence
if [[ $key == $'\e' ]]; then
# Try reading the rest of the escape sequence with a short timeout
read -k2 -t 0.01 rest_of_key
if [[ $? -eq 0 ]]; then # Read succeeded (not a timeout) -> Arrow key or other sequence
case "$rest_of_key" in
'[A') # Up arrow
if [[ "$following_mode" == true ]]; then
stop_tail
# Enter scroll mode: show the last page initially
get_total_lines
calculate_window_height
scroll_top_line=$((total_lines - window_height + 1))
(( scroll_top_line < 1 )) && scroll_top_line=1
display_scroll_window
else
# Already scrolling: scroll up one line
((scroll_top_line--))
display_scroll_window # Handles boundary checks and redraw
fi
;;
'[B') # Down arrow
if [[ "$following_mode" == true ]]; then
stop_tail
# Enter scroll mode: show the last page initially
get_total_lines
calculate_window_height
scroll_top_line=$((total_lines - window_height + 1))
(( scroll_top_line < 1 )) && scroll_top_line=1
display_scroll_window
else
# Already scrolling: scroll down one line
((scroll_top_line++))
display_scroll_window # Handles boundary checks and redraw
fi
;;
*)
;;
esac
continue
else
break # Quit the loop
fi
else
# It was 'q' or 'Q'
break
fi
fi # End of Quit/Escape key check
# Check for Follow toggle keys (only if not already following)
if [[ "$following_mode" == false && ($key == 'f' || $key == 't') ]]; then
start_tail
continue
fi
done
exit 0