Source code for seiscat.print.pager

# -*- coding: utf8 -*-
# SPDX-License-Identifier: GPL-3.0-or-later
# NOTE: This file is entirely generated by GitHub Copilot
"""
Interactive table pager using curses.

:copyright:
    2022-2026 Claudio Satriano <satriano@ipgp.fr>
:license:
    GNU General Public License v3.0 or later
    (https://www.gnu.org/licenses/gpl-3.0-standalone.html)
"""
import curses
from contextlib import suppress


[docs] class PagerException(Exception): """Exception raised when the pager fails."""
DEFAULT_MAX_COLUMN_WIDTH = 40 CLIPBOARD_FAIL_MESSAGE = ( 'Clipboard copy failed.\nInstall wl-copy, xclip, or xsel.' ) def _flush_pending_input(): """Clear queued keypresses to prevent lag after expensive operations.""" with suppress(curses.error, AttributeError): curses.flushinp() def _display_busy_popup(stdscr, message='Sorting... please wait'): """Display a transient centered popup while a long task is running.""" with suppress(curses.error): max_y, max_x = stdscr.getmaxyx() popup_width = min(len(message) + 6, max_x - 4) popup_height = 3 popup_y = (max_y - popup_height) // 2 popup_x = (max_x - popup_width) // 2 top_border = f'╔{"═" * (popup_width - 2)}╗' content = _format_centered_popup_line(popup_width, message) bottom_border = f'╚{"═" * (popup_width - 2)}╝' stdscr.addstr(popup_y, popup_x, top_border, curses.A_BOLD) stdscr.addstr(popup_y + 1, popup_x, content, curses.A_BOLD) stdscr.addstr(popup_y + 2, popup_x, bottom_border, curses.A_BOLD) stdscr.refresh() def _sort_with_feedback(stdscr, col_index, raw_data, pager_state): """Show busy feedback, sort rows, then clear queued input.""" _display_busy_popup(stdscr) _sort_rows_by_column(col_index, raw_data, pager_state) _flush_pending_input() def _restore_default_sort(stdscr, raw_data, pager_state): """Restore default sorting or initial row order.""" pager_state['sort_col'] = pager_state.get('default_sort_col') pager_state['sort_asc'] = pager_state.get('default_sort_asc', True) if pager_state['sort_col'] is not None: with suppress(IndexError, TypeError): _sort_with_feedback( stdscr, pager_state['sort_col'], raw_data, pager_state ) return # No explicit default sort column: restore original input order. initial_rows = pager_state.get('initial_rows') if initial_rows is not None: raw_data['rows'][:] = initial_rows pager_state['offset'] = 0 pager_state['selected_row'] = 0 pager_state['format_dirty'] = True def _copy_with_osc52(text): """Try terminal clipboard copy via OSC52 escape sequence.""" # Lazy imports: this path is only used when external tools fail. # pylint: disable=import-outside-toplevel import base64 import os import sys if not sys.stdout.isatty(): return False term = os.environ.get('TERM', '') if term == 'dumb': return False encoded = base64.b64encode(text.encode('utf-8')).decode('ascii') sequence = f'\033]52;c;{encoded}\a' # Wrap OSC52 when running inside tmux/screen passthrough layers. if 'TMUX' in os.environ: sequence = f'\033Ptmux;\033{sequence}\033\\' elif term.startswith('screen'): sequence = f'\033P{sequence}\033\\' try: with open('/dev/tty', 'wb') as tty: tty.write(sequence.encode('ascii')) tty.flush() return True except OSError: return False def _copy_with_iterm2(text): """Try iTerm2 clipboard copy via OSC 1337 escape sequence.""" # Lazy imports: this path is only used when external tools fail. # pylint: disable=import-outside-toplevel import base64 import os import sys if os.environ.get('TERM_PROGRAM') != 'iTerm.app': return False if not sys.stdout.isatty(): return False term = os.environ.get('TERM', '') if term == 'dumb': return False encoded = base64.b64encode(text.encode('utf-8')).decode('ascii') sequence = f'\033]1337;Copy=:{encoded}\a' # Wrap sequence when running inside tmux/screen passthrough layers. if 'TMUX' in os.environ: sequence = f'\033Ptmux;\033{sequence}\033\\' elif term.startswith('screen'): sequence = f'\033P{sequence}\033\\' try: with open('/dev/tty', 'wb') as tty: tty.write(sequence.encode('ascii')) tty.flush() return True except OSError: return False def _copy_to_clipboard(text): """ Copy text to system clipboard (cross-platform). :param text: text to copy to clipboard :return: True if successful, False otherwise """ # Lazy import to avoid slowdown at module load time # pylint: disable=import-outside-toplevel import platform import subprocess def _is_wsl(): """Detect if running under Windows Subsystem for Linux.""" try: with open('/proc/version', 'r', encoding='utf-8') as fp: return 'microsoft' in fp.read().lower() except OSError: return False system = platform.system() utf8_text = text.encode('utf-8') utf16_text = text.encode('utf-16le') commands = [] if system == 'Darwin': # macOS commands = [(['pbcopy'], utf8_text)] elif system == 'Windows': commands = [(['clip'], utf16_text)] else: # Linux and other Unix-like systems commands = [ (['wl-copy'], utf8_text), (['xclip', '-selection', 'clipboard'], utf8_text), (['xsel', '--clipboard', '--input'], utf8_text), ] if _is_wsl(): commands.append((['clip.exe'], utf16_text)) for cmd, payload in commands: try: subprocess.run( cmd, input=payload, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return True except (subprocess.CalledProcessError, OSError): continue return True if _copy_with_iterm2(text) else _copy_with_osc52(text) def _truncate_for_column(text, max_col_width): """Truncate text to a fixed column width using an ellipsis.""" if max_col_width is None or max_col_width <= 0: return text if len(text) <= max_col_width: return text if max_col_width <= 3: return '.' * max_col_width return f'{text[:max_col_width - 3]}...' def _format_rows(fields, rows, max_col_width=DEFAULT_MAX_COLUMN_WIDTH): """ Format raw data into aligned string rows. :param fields: list of column names :param rows: list of row data (each row is a list/tuple) :return: tuple of (header_string, body_rows_list, max_detail_line_len) """ max_len = [len(_truncate_for_column(f, max_col_width)) for f in fields] # "field: value" length baseline for popup details lines. max_detail_len = [len(f) + 2 for f in fields] for row in rows: for i, val in enumerate(row): if i < len(max_len): table_val = 'None' if val is None else str(val) table_val = _truncate_for_column(table_val, max_col_width) detail_val = '' if val is None else str(val) max_len[i] = max(max_len[i], len(table_val)) max_detail_len[i] = max( max_detail_len[i], len(fields[i]) + 2 + len(detail_val) ) # Build header header = ' '.join( f'{_truncate_for_column(f, max_col_width):{max_len[i]}}' for i, f in enumerate(fields) ) # Build body rows body_rows = [] for row in rows: formatted_vals = [] for i, val in enumerate(row): if i >= len(max_len): break table_val = 'None' if val is None else str(val) table_val = _truncate_for_column(table_val, max_col_width) formatted_vals.append(f'{table_val:{max_len[i]}}') row_str = ' '.join( formatted_vals ) body_rows.append(row_str) max_detail_line_len = max(max_detail_len, default=0) return header, body_rows, max_detail_line_len def _update_formatted_rows_cache(raw_data, pager_state): """Rebuild and cache formatted rows/header after sorting changes.""" max_col_width = pager_state.get( 'max_col_width', DEFAULT_MAX_COLUMN_WIDTH ) header, body_rows, max_detail_line_len = _format_rows( raw_data['fields'], raw_data['rows'], max_col_width=max_col_width ) pager_state['formatted_header'] = header pager_state['formatted_body_rows'] = body_rows # Rows formatted by _format_rows all have uniform width equal to header. pager_state['formatted_max_row_width'] = len(header) pager_state['popup_max_detail_line_len'] = max_detail_line_len pager_state['format_dirty'] = False def _display_message_popup(stdscr, message): """ Display a message popup and wait for any keypress. :param stdscr: curses window :param message: message to display """ max_y, max_x = stdscr.getmaxyx() # Support one or more centered message lines. message_lines = message.splitlines() or [''] max_message_len = max(len(line) for line in message_lines) popup_width = min(max_message_len + 6, max_x - 4) popup_height = len(message_lines) + 4 popup_y = (max_y - popup_height) // 2 popup_x = (max_x - popup_width) // 2 with suppress(curses.error): # Clear popup area with spaces to remove background text for y in range(popup_y, popup_y + popup_height): stdscr.addstr(y, popup_x, ' ' * popup_width) # Draw top border top_border = f'╔{"═" * (popup_width - 2)}╗' stdscr.addstr(popup_y, popup_x, top_border, curses.A_BOLD) for i, line in enumerate(message_lines): message_line = _format_centered_popup_line(popup_width, line) stdscr.addstr(popup_y + 1 + i, popup_x, message_line, curses.A_BOLD) # Draw instruction instruction = 'Press any key' inst_line = _format_centered_popup_line(popup_width, instruction) stdscr.addstr(popup_y + 1 + len(message_lines), popup_x, inst_line) # Draw bottom border bottom_border = f'╚{"═" * (popup_width - 2)}╝' stdscr.addstr(popup_y + 2 + len(message_lines), popup_x, bottom_border, curses.A_BOLD) stdscr.refresh() # Wait for any keypress stdscr.getch() def _format_centered_popup_line(popup_width, text): """Return a popup line with text centered between vertical borders.""" message_padding = popup_width - 2 - len(text) message_left = message_padding // 2 message_right = message_padding - message_left return f'║{" " * message_left}{text}{" " * message_right}║' def _build_detail_lines(fields, row): """Build 'field: value' lines for a single event row.""" lines = [] for i, field in enumerate(fields): val = row[i] if i < len(row) else '' val_str = '' if val is None else str(val) lines.append(f'{field}: {val_str}') return lines def _display_event_details_popup(stdscr, fields, rows, row_idx, draw_bg_fn=None, popup_max_detail_line_len=None, popup_key_handler=None): """ Display a popup with all event details organized line by line. Supports navigating between events with j (next) and k (previous). If draw_bg_fn is provided, the table behind the popup is redrawn (with updated highlight and scroll) on each event change. :param stdscr: curses window :param fields: list of field names :param rows: list of all row data :param row_idx: index of the currently displayed row :param draw_bg_fn: optional callable(row_idx) that redraws the table behind the popup without calling stdscr.refresh() :param popup_key_handler: optional callable(key, row_idx) to handle table-navigation keys while popup is open """ max_y, max_x = stdscr.getmaxyx() title = ' Event Details ' # Keep a stable width for the whole popup session. if popup_max_detail_line_len is None: # Fallback: current row only (fast, no full-catalog scan). initial_lines = _build_detail_lines(fields, rows[row_idx]) popup_max_detail_line_len = max( (len(line) for line in initial_lines), default=0 ) max_popup_width = min( max(popup_max_detail_line_len + 4, len(title) + 4), max_x - 4 ) def _render(lines, row_idx, scroll_offset): """Render the popup for the given lines and scroll state.""" can_scroll = len(lines) > display_lines popup_width = max_popup_width popup_x = (max_x - popup_width) // 2 # Clear popup area clear_x = (max_x - max_popup_width) // 2 for y in range(popup_y, popup_y + popup_height): stdscr.addstr(y, clear_x, ' ' * max_popup_width) # Top border with event counter counter = f' {row_idx + 1}/{len(rows)} ' counter_len = len(counter) inner = popup_width - 2 top_border = f'╔{counter}{"═" * (inner - counter_len)}╗' stdscr.addstr(popup_y, popup_x, top_border, curses.A_BOLD) # Title line title_padding = popup_width - 2 - len(title) title_left = title_padding // 2 title_right = title_padding - title_left title_line = f'║{" " * title_left}{title}{" " * title_right}║' stdscr.addstr(popup_y + 1, popup_x, title_line, curses.A_BOLD) # Separator sep_line = f'╠{"═" * (popup_width - 2)}╣' stdscr.addstr(popup_y + 2, popup_x, sep_line, curses.A_BOLD) # Content lines available_width = popup_width - 4 for i in range(display_lines): line_idx = scroll_offset + i if line_idx < len(lines): content = lines[line_idx] if len(content) > available_width: content = content[:available_width] else: content = content.ljust(available_width) stdscr.addstr( popup_y + 3 + i, popup_x, f'║ {content} ║' ) # Bottom border with hint nav_hint = ' ↑/↓: next/prev |' scroll_hint = ' j/k: scroll |' if can_scroll else '' hint = f'{nav_hint}{scroll_hint} q/Esc/↵: close ' inner_width = popup_width - 2 # Always keep a visible footer hint: progressively shorten/truncate # when terminal space is tight instead of dropping it entirely. hint_candidates = [ hint, ' ↑/↓ ev | j/k scroll | q/Esc/↵ ', ' ↑/↓ | j/k | q/Esc ', ' q/Esc ' ] hint_to_draw = next( (candidate for candidate in hint_candidates if len(candidate) <= inner_width), '' ) if not hint_to_draw and inner_width > 0: hint_to_draw = hint_candidates[-1][:inner_width] hint_fill = inner_width - len(hint_to_draw) left_fill = hint_fill // 2 right_fill = hint_fill - left_fill bottom_border = ( f'╚{"═" * left_fill}{hint_to_draw}{"═" * right_fill}╝' ) stdscr.addstr( popup_y + popup_height - 1, popup_x, bottom_border, curses.A_BOLD ) stdscr.refresh() # Keep geometry stable; width is computed per-row at render time. max_display_lines = max(1, max_y - 8) lines = _build_detail_lines(fields, rows[row_idx]) display_lines = min(len(lines), max_display_lines) popup_height = display_lines + 4 popup_y = (max_y - popup_height) // 2 scroll_offset = 0 while True: lines = _build_detail_lines(fields, rows[row_idx]) display_lines = min(len(lines), max_display_lines) scroll_offset = min( scroll_offset, max(0, len(lines) - display_lines) ) with suppress(curses.error): if draw_bg_fn is not None: draw_bg_fn(row_idx) _render(lines, row_idx, scroll_offset) key = stdscr.getch() if key in [ord('q'), 27, ord('\n'), ord('\r'), curses.KEY_ENTER]: break elif key in [ord('j')]: if scroll_offset + display_lines < len(lines): scroll_offset += 1 elif key in [ord('k')]: if scroll_offset > 0: scroll_offset -= 1 elif key in [curses.KEY_DOWN, ord(',')]: if row_idx < len(rows) - 1: row_idx += 1 scroll_offset = 0 elif key in [curses.KEY_UP, ord('.')]: if row_idx > 0: row_idx -= 1 scroll_offset = 0 elif popup_key_handler is not None: new_row_idx = popup_key_handler(key, row_idx) if new_row_idx is not None: row_idx = new_row_idx scroll_offset = 0 return row_idx def _draw_sort_popup_and_get_input( stdscr, popup_y, popup_x, popup_width, popup_height, fields, selected_idx): """ Draw sort selector popup and handle user input. :param stdscr: curses window :param popup_y: popup top position :param popup_x: popup left position :param popup_width: popup width :param popup_height: popup height :param fields: list of field names :param selected_idx: currently selected field index :return: tuple (selected_col, new_selected_idx) where selected_col is the column to select (or None to continue), and new_selected_idx is the updated selection index for next iteration. Returns (-1, selected_idx) to signal cancellation (e.g., Q or Esc key) """ try: # Clear popup area with spaces to remove background text for y in range(popup_y, popup_y + popup_height): stdscr.addstr(y, popup_x, ' ' * popup_width) # Draw top border top_border = f'╔{"═" * (popup_width - 2)}╗' stdscr.addstr(popup_y, popup_x, top_border, curses.A_BOLD) # Draw title title = ' Sort by column ' title_padding = popup_width - 2 - len(title) title_left = title_padding // 2 title_right = title_padding - title_left title_line = f'║{" " * title_left}{title}{" " * title_right}║' stdscr.addstr(popup_y + 1, popup_x, title_line, curses.A_BOLD) # Draw separator sep_line = f'╠{"═" * (popup_width - 2)}╣' stdscr.addstr(popup_y + 2, popup_x, sep_line, curses.A_BOLD) # Draw field options display_rows = popup_height - 4 start_idx = max(0, selected_idx - display_rows // 2) end_idx = min(len(fields), start_idx + display_rows) for i in range(display_rows): y = popup_y + 3 + i if start_idx + i < end_idx: field_idx = start_idx + i field_name = fields[field_idx] # First option is "0. default", others follow 1..9. if field_idx == 0: prefix = '0. ' elif field_idx <= 9: prefix = f'{field_idx}. ' else: prefix = ' ' content = f'{prefix}{field_name}' else: content = '' # Pad or truncate to fit width (accounting for borders) padding = ' ' * (popup_width - 4) content = f'{content}{padding}'[:popup_width - 4] is_selected = ( start_idx + i < len(fields) and start_idx + i == selected_idx ) option_line = f'║ {content} ║' if is_selected: stdscr.addstr(y, popup_x, option_line, curses.A_REVERSE) else: stdscr.addstr(y, popup_x, option_line) # Draw bottom border with hint hint = ' ↑/↓: navigate | 0-9: select | q/Esc: cancel ' inner_width = popup_width - 2 # Progressively shorten hint to fit available space hint_candidates = [ hint, ' ↑/↓ nav | 0-9 sel | q/Esc ', ' ↑/↓ | 0-9 | q/Esc ', ' q/Esc ' ] fitting_candidates = ( candidate for candidate in hint_candidates if len(candidate) <= inner_width ) hint_to_draw = next(fitting_candidates, '') if not hint_to_draw and inner_width > 0: hint_to_draw = hint_candidates[-1][:inner_width] hint_fill = inner_width - len(hint_to_draw) left_fill = hint_fill // 2 right_fill = hint_fill - left_fill bottom_border = ( f'╚{"═" * left_fill}{hint_to_draw}{"═" * right_fill}╝' ) bottom_y = popup_y + popup_height - 1 stdscr.addstr(bottom_y, popup_x, bottom_border, curses.A_BOLD) stdscr.refresh() # Handle input key = stdscr.getch() if key in [ord('q'), 27]: # q or Esc return (-1, selected_idx) if key in [ord('\n'), ord(' ')]: # Enter or Space return (selected_idx, selected_idx) if key in [curses.KEY_DOWN, ord('j')]: selected_idx = min(len(fields) - 1, selected_idx + 1) return (None, selected_idx) if key in [curses.KEY_UP, ord('k')]: selected_idx = max(0, selected_idx - 1) return (None, selected_idx) if chr(key).isdigit(): digit = int(chr(key)) col_num = 0 if digit == 0 else digit if 0 <= col_num < len(fields): return (col_num, selected_idx) return (None, selected_idx) return (None, selected_idx) except curses.error: return (None, selected_idx) def _display_sort_selector(stdscr, raw_data): """ Display an interactive sort field selector popup. :param stdscr: curses window :param raw_data: dict with 'fields' list :return: selected column index (0-indexed), -2 for default sort, or None if cancelled """ max_y, max_x = stdscr.getmaxyx() # Prepend "default" option to the fields list fields = ['default'] + raw_data['fields'] # Calculate popup dimensions max_field_len = max(len(f) for f in fields) if fields else 0 title_len = len(' Sort by column ') # Ensure popup is wide enough for both title and fields min_width = max(max_field_len + 6, title_len + 4) popup_width = min(min_width, max_x - 4) popup_height = min(len(fields) + 4, max_y - 4) popup_y = (max_y - popup_height) // 2 popup_x = (max_x - popup_width) // 2 selected_idx = 0 while True: selected_col, selected_idx = _draw_sort_popup_and_get_input( stdscr, popup_y, popup_x, popup_width, popup_height, fields, selected_idx ) if selected_col == -1: # User cancelled with Q or Esc return None if selected_col is not None: # If "default" (index 0 in expanded list) is selected, # return -2 as signal to restore default sort return -2 if selected_col == 0 else selected_col - 1 def _handle_pager_input(stdscr, pager_state, body_rows, available_rows, enable_h_scroll, max_row_width, max_x, raw_data=None, redraw_bg=None, popup_key_handler=None): """ Handle keyboard input for pager navigation and sorting. :param stdscr: curses window :param pager_state: dictionary with pager state :param body_rows: list of body row strings :param available_rows: number of rows available for display :param enable_h_scroll: whether horizontal scrolling is enabled :param max_row_width: maximum row width in characters :param max_x: terminal width :param raw_data: optional dict with fields and rows for sorting :param redraw_bg: optional callable(row_idx) to redraw the table behind the event-details popup :param popup_key_handler: optional callable(key, row_idx) to handle table-navigation keys while popup is open :return: True to continue, False to exit """ try: key = stdscr.getch() except (OSError, KeyboardInterrupt): return False if key in [ord('q'), 27]: # q or Esc return False # Handle copy event ID with 'c' key if raw_data and key == ord('c'): selected_row_idx = pager_state['selected_row'] if 0 <= selected_row_idx < len(raw_data['rows']): if row := raw_data['rows'][selected_row_idx]: # Copy first column (event ID) to clipboard event_id = str(row[0]) if _copy_to_clipboard(event_id): message = f'evid {event_id} copied to clipboard' else: message = CLIPBOARD_FAIL_MESSAGE _display_message_popup(stdscr, message) return True # Handle Enter key to show event details popup if raw_data and key in [ord('\n'), ord('\r'), curses.KEY_ENTER]: selected_row_idx = pager_state['selected_row'] if 0 <= selected_row_idx < len(raw_data['rows']): new_idx = _display_event_details_popup( stdscr, raw_data['fields'], raw_data['rows'], selected_row_idx, draw_bg_fn=redraw_bg, popup_max_detail_line_len=pager_state.get( 'popup_max_detail_line_len' ), popup_key_handler=popup_key_handler ) pager_state['selected_row'] = new_idx return True # Handle sort field selector with 's' key if raw_data and key in [ord('0')]: _restore_default_sort(stdscr, raw_data, pager_state) return True # Handle sort field selector with 's' key if raw_data and key == ord('s'): selected_col = _display_sort_selector(stdscr, raw_data) if selected_col == -2: _restore_default_sort(stdscr, raw_data, pager_state) elif selected_col is not None: # Check if sorting the same column (toggle sort direction) if pager_state.get('sort_col') == selected_col: pager_state['sort_asc'] = not pager_state.get( 'sort_asc', True ) else: pager_state['sort_col'] = selected_col pager_state['sort_asc'] = True # Sort the raw data with suppress(IndexError, TypeError): _sort_with_feedback( stdscr, selected_col, raw_data, pager_state ) return True # Handle column sorting with number keys (1-9) if raw_data and chr(key) in '123456789': col_num = int(chr(key)) - 1 # Convert to 0-indexed if col_num < len(raw_data['fields']): # Check if sorting the same column (toggle sort direction) if pager_state.get('sort_col') == col_num: pager_state['sort_asc'] = not pager_state.get( 'sort_asc', True ) else: pager_state['sort_col'] = col_num pager_state['sort_asc'] = True # Sort the raw data with suppress(IndexError, TypeError): _sort_with_feedback(stdscr, col_num, raw_data, pager_state) elif key == curses.KEY_LEFT: if enable_h_scroll: # Scroll left pager_state['h_scroll'] = max(0, pager_state['h_scroll'] - 5) elif key == curses.KEY_RIGHT: if enable_h_scroll: # Scroll right pager_state['h_scroll'] = min( max_row_width - max_x, pager_state['h_scroll'] + 5 ) elif key == curses.KEY_DOWN: if pager_state['selected_row'] < len(body_rows) - 1: pager_state['selected_row'] += 1 # Auto-scroll if selected row goes off screen if pager_state['selected_row'] >= ( pager_state['offset'] + available_rows): pager_state['offset'] += 1 elif key == curses.KEY_UP: if pager_state['selected_row'] > 0: pager_state['selected_row'] -= 1 # Auto-scroll if selected row goes off screen if pager_state['selected_row'] < pager_state['offset']: pager_state['offset'] -= 1 elif key in [curses.KEY_PPAGE, ord('b')]: # Page Up pager_state['offset'] = max( 0, pager_state['offset'] - available_rows ) pager_state['selected_row'] = max( 0, pager_state['selected_row'] - available_rows ) elif key in [curses.KEY_NPAGE, ord('f'), ord(' ')]: # Page Down pager_state['offset'] = max( 0, min( len(body_rows) - available_rows, pager_state['offset'] + available_rows ) ) pager_state['selected_row'] = min( len(body_rows) - 1, pager_state['selected_row'] + available_rows ) elif key in [curses.KEY_HOME, ord('g')]: # Home pager_state['offset'] = 0 pager_state['selected_row'] = 0 elif key in [curses.KEY_END, ord('G')]: # End pager_state['selected_row'] = len(body_rows) - 1 pager_state['offset'] = max(0, len(body_rows) - available_rows) return True def _sort_rows_by_column(col_index, raw_data, pager_state): """ Sort rows by the specified column index. :param col_index: column index to sort by :param raw_data: dict with 'rows' list to sort :param pager_state: dict to update offset and selected_row """ def _sort_key(row): val = row[col_index] if col_index < len(row) else None # None values always sort to the end, regardless of direction. # Use a tuple (is_none, val) where is_none is 0 or 1 so None rows # end up last in both ascending and descending order. if val is None: return (1, '') try: return (0, val) except TypeError: return (0, str(val)) raw_data['rows'].sort( key=_sort_key, reverse=not pager_state['sort_asc'] ) pager_state['format_dirty'] = True # Reset offset and selected row after sorting pager_state['offset'] = 0 pager_state['selected_row'] = 0 def _draw_table( stdscr, header, body_rows, pager_state, available_rows, max_y, max_x, num_help_lines, help_line1, help_line2, sort_info, total_events, refresh=True): """ Draw the table (header, body rows, help lines) onto stdscr. :param refresh: if True, call stdscr.refresh() after drawing """ stdscr.erase() with suppress(curses.error): header_scroll = header[pager_state['h_scroll']:] header_display = ( header_scroll[:max_x] if len(header_scroll) > max_x else header_scroll ) stdscr.addstr(0, 0, header_display, curses.A_REVERSE) for i, row in enumerate( body_rows[ pager_state['offset']: pager_state['offset'] + available_rows ] ): row_scroll = row[pager_state['h_scroll']:] row_display = ( row_scroll[:max_x] if len(row_scroll) > max_x else row_scroll ) row_index = pager_state['offset'] + i if row_index == pager_state['selected_row']: color_attr = curses.A_REVERSE else: try: color_attr = ( curses.color_pair(1) if row_index % 2 == 0 else curses.color_pair(2) ) except curses.error: color_attr = 0 stdscr.addstr(1 + i, 0, row_display, color_attr) first_visible = pager_state['offset'] + 1 last_visible = min( pager_state['offset'] + available_rows, len(body_rows) ) status_text = ( f'Events {first_visible}-{last_visible} of ' f'{total_events}{sort_info}' ) with suppress(curses.error): if num_help_lines == 1: full_help = f'{help_line1} | {help_line2}' full_line = f'{full_help} | {status_text}' if len(full_line) <= max_x: help_display = f'{full_line}{" " * max_x}'[:max_x] else: help_display = f'{status_text}{" " * max_x}'[:max_x] stdscr.addstr(max_y - 1, 0, help_display, curses.A_REVERSE) else: width_available = ( max_x - len(help_line1) - len(status_text) - 3 ) padding_width = max(0, width_available) line1 = f'{help_line1} | {" " * padding_width}{status_text}' line1 = f'{line1}{" " * max_x}'[:max_x] line2 = f'{help_line2}{" " * max_x}'[:max_x] stdscr.addstr(max_y - 2, 0, line1, curses.A_REVERSE) stdscr.addstr(max_y - 1, 0, line2, curses.A_REVERSE) if refresh: stdscr.refresh() def _pager_loop_iteration( stdscr, header, body_rows, pager_state, raw_data=None): """ Handle one iteration of the table pager loop. :param stdscr: curses window :param header: header row string :param body_rows: list of body row strings :param pager_state: dictionary with 'offset', 'selected_row', 'h_scroll', 'sort_col', 'sort_asc' state :param raw_data: optional dict with fields and rows for sorting :return: True to continue, False to exit """ max_y, max_x = stdscr.getmaxyx() # Determine how many help lines we'll need # Build help text components (will be reused below) help_line1 = ( 'q/Esc: quit | ↵: details | ↓/↑: move' ' | ←/→: scroll | c: copy evid' ) help_line2 = ( 'Space/f: page↓ | b: page↑ | g: home | G: end | ' '0: dflt | 1-9: sort | s: sort' ) # Build status text (needed to determine help layout) total_events = len(body_rows) sort_info = '' if raw_data and pager_state.get('sort_col') is not None: sort_col = pager_state['sort_col'] if sort_col < len(raw_data['fields']): field_name = raw_data['fields'][sort_col] else: field_name = f'Col{sort_col + 1}' sort_dir = '↑' if pager_state.get('sort_asc', True) else '↓' sort_info = f' | Sorted by {field_name} {sort_dir}' # Placeholder status text status_text = f'Events 1-{total_events} of {total_events}{sort_info}' # Determine number of help lines needed full_help = f'{help_line1} | {help_line2}' full_line = f'{full_help} | {status_text}' num_help_lines = 1 if len(full_line) > max_x and max_y > 3: num_help_lines = 2 # -1 for header, -num_help_lines for help lines available_rows = max_y - 1 - num_help_lines # Build formatted rows lazily and cache them: this avoids O(N) work # on every frame after sorting large catalogs. if raw_data and ( pager_state.get('format_dirty', False) or pager_state.get('sort_col') is not None): if pager_state.get('format_dirty', True): _update_formatted_rows_cache(raw_data, pager_state) header = pager_state['formatted_header'] body_rows = pager_state['formatted_body_rows'] max_row_width = pager_state['formatted_max_row_width'] else: # Calculate max row width for horizontal scrolling max_row_width = max( len(header), max((len(row) for row in body_rows), default=0) ) # Only enable horizontal scrolling if table is wider than terminal enable_h_scroll = max_row_width > max_x # Constrain horizontal scroll to valid range if enable_h_scroll: pager_state['h_scroll'] = max( 0, min(pager_state['h_scroll'], max_row_width - max_x) ) else: pager_state['h_scroll'] = 0 _draw_table( stdscr, header, body_rows, pager_state, available_rows, max_y, max_x, num_help_lines, help_line1, help_line2, sort_info, total_events ) # Closure passed to the event-details popup so it can redraw the # table (with updated row highlight and scroll) while open. def _redraw_bg(new_row_idx): pager_state['selected_row'] = new_row_idx if new_row_idx >= pager_state['offset'] + available_rows: pager_state['offset'] = new_row_idx - available_rows + 1 elif new_row_idx < pager_state['offset']: pager_state['offset'] = new_row_idx _draw_table( stdscr, header, body_rows, pager_state, available_rows, max_y, max_x, num_help_lines, help_line1, help_line2, sort_info, total_events, refresh=False ) def _handle_popup_key(key, current_row_idx): """Handle selected main-table keys while popup is open.""" if key == curses.KEY_LEFT: if enable_h_scroll: pager_state['h_scroll'] = max(0, pager_state['h_scroll'] - 5) return current_row_idx if key == curses.KEY_RIGHT: if enable_h_scroll: pager_state['h_scroll'] = min( max_row_width - max_x, pager_state['h_scroll'] + 5 ) return current_row_idx if key == ord('c') and raw_data: if 0 <= current_row_idx < len(raw_data['rows']): if row := raw_data['rows'][current_row_idx]: event_id = str(row[0]) if _copy_to_clipboard(event_id): message = f'evid {event_id} copied to clipboard' else: message = CLIPBOARD_FAIL_MESSAGE _display_message_popup(stdscr, message) return current_row_idx if key in [curses.KEY_PPAGE, ord('b')]: # Page Up pager_state['offset'] = max( 0, pager_state['offset'] - available_rows ) pager_state['selected_row'] = max( 0, pager_state['selected_row'] - available_rows ) return pager_state['selected_row'] if key in [curses.KEY_NPAGE, ord('f'), ord(' ')]: # Page Down pager_state['offset'] = max( 0, min( len(body_rows) - available_rows, pager_state['offset'] + available_rows ) ) pager_state['selected_row'] = min( len(body_rows) - 1, pager_state['selected_row'] + available_rows ) return pager_state['selected_row'] if key in [curses.KEY_HOME, ord('g')]: # Home pager_state['offset'] = 0 pager_state['selected_row'] = 0 return pager_state['selected_row'] if key in [curses.KEY_END, ord('G')]: # End pager_state['selected_row'] = len(body_rows) - 1 pager_state['offset'] = max(0, len(body_rows) - available_rows) return pager_state['selected_row'] return None # Handle input return _handle_pager_input( stdscr, pager_state, body_rows, available_rows, enable_h_scroll, max_row_width, max_x, raw_data=raw_data, redraw_bg=_redraw_bg, popup_key_handler=_handle_popup_key )
[docs] def display_table_pager( header, body_rows, raw_data=None, default_sort_col=None, default_sort_asc=True, max_col_width=DEFAULT_MAX_COLUMN_WIDTH, ): """ Display table with fixed header and alternating row font colors using curses scrolling. Supports interactive sorting by column. :param header: header row string :param body_rows: list of body row strings :param raw_data: optional dict with 'fields' (list of column names) and 'rows' (list of row data lists) for interactive sorting :param default_sort_col: default column index for sorting (can be restored with 0 key) :param default_sort_asc: default sort direction (True for ascending) :param max_col_width: maximum width for table columns in characters (longer values are truncated with ...) :raises PagerException: if the pager fails to initialize or run """ def _display_table_pager_wrapper(stdscr): """Wrapper for curses.wrapper.""" # Use terminal's default colors (may not be supported on all terminals) with suppress(curses.error, AttributeError): curses.use_default_colors() # Initialize color pairs for alternating rows # Color pair 1: normal row (default foreground) # Color pair 2: alternating row (cyan/blue foreground) if curses.has_colors(): # Suppress errors if color pair initialization fails with suppress(curses.error): # default fg/bg curses.init_pair(1, -1, -1) # cyan fg, default bg curses.init_pair(2, curses.COLOR_CYAN, -1) stdscr.keypad(True) # Disable mouse events to prevent buffer overflow with suppress(curses.error, AttributeError): curses.mousemask(0) # Hide cursor (not supported on all terminals) with suppress(curses.error, AttributeError): curses.curs_set(0) # Reduce Esc key delay to make it as responsive as Q with suppress(curses.error, AttributeError): curses.set_escdelay(25) # Track pager state (offset, selected row, horizontal scroll, sorting) pager_state = { 'offset': 0, 'selected_row': 0, 'h_scroll': 0, 'sort_col': default_sort_col, 'sort_asc': default_sort_asc, 'default_sort_col': default_sort_col, 'default_sort_asc': default_sort_asc, 'initial_rows': list(raw_data['rows']) if raw_data else None, 'max_col_width': max_col_width } local_header = header local_body_rows = body_rows # If raw rows are available but preformatted strings are not, # show UI immediately and build table strings inside curses. if raw_data and not local_body_rows: _display_busy_popup(stdscr, 'Loading table... please wait') _update_formatted_rows_cache(raw_data, pager_state) local_header = pager_state['formatted_header'] local_body_rows = pager_state['formatted_body_rows'] while True: should_continue = _pager_loop_iteration( stdscr, local_header, local_body_rows, pager_state, raw_data=raw_data ) if not should_continue: break try: curses.wrapper(_display_table_pager_wrapper) except (curses.error, OSError, KeyboardInterrupt) as e: raise PagerException(f"Pager failed: {e}") from e