bashscrollbaransi-escape

Freeze header or footer lines in OS X Ventura ANSI terminal and retain scrollbar functionality


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.


Solution

  • 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