# -*- 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
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
)