Files
fenrir/src/fenrirscreenreader/core/tableManager.py
2025-07-09 01:06:54 -04:00

569 lines
21 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import re
from fenrirscreenreader.core import debug
class TableManager:
def __init__(self):
self.headLine = ""
self.defaultSeparators = ["+", ";", "|", " "]
self.noOfHeadLineColumns = 0
self.headColumnSep = ""
self.rowColumnSep = ""
self.headerRow = []
self.tableMode = False
self.currentColumn = 0
self.currentRow = 0
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def is_table_mode(self):
return self.tableMode
def set_table_mode(self, active):
self.tableMode = active
if not active:
self.clear_header_row()
else:
# Initialize current position when entering table mode
self.sync_table_position_from_cursor()
def set_header_row_from_cursor(self):
"""Set header row from current cursor position"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return False
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return False
# Get the line text at cursor position
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return False
# Parse the line into columns
self.headerRow = self.parse_line_into_columns(line_text)
return len(self.headerRow) > 0
def clear_header_row(self):
"""Clear the stored header row"""
self.headerRow = []
def has_header_row(self):
"""Check if header row is set"""
return len(self.headerRow) > 0
def get_column_header(self, column_index):
"""Get header for a specific column (0-based)"""
if column_index < 0:
return f"Column {column_index + 1}"
if len(self.headerRow) == 0:
return f"Column {column_index + 1}"
if column_index >= len(self.headerRow):
return f"Column {column_index + 1}"
header = self.headerRow[column_index].strip()
if not header: # If header is empty
return f"Column {column_index + 1}"
return header
def get_column_count(self):
"""Get number of columns based on header row"""
return len(self.headerRow)
def parse_line_into_columns(self, line_text):
"""Parse a line into columns using various separators"""
if not line_text:
return []
# Try different separators in order of preference
separators = [',', '|', ';', '\t']
for sep in separators:
if sep in line_text:
columns = line_text.split(sep)
if len(columns) > 1:
return columns
# If no clear separator, try to detect aligned columns
return self.detect_aligned_columns(line_text)
def detect_aligned_columns(self, line_text):
"""Detect columns in space-aligned text"""
if not line_text:
return []
# Split on multiple spaces (2 or more)
parts = re.split(r' +', line_text)
if len(parts) > 1:
return parts
# Fallback: treat as single column
return [line_text]
def get_cell_content(self, line_text, column_index):
"""Get content of a specific cell in a line"""
columns = self.parse_line_into_columns(line_text)
if column_index < 0 or column_index >= len(columns):
return ""
return columns[column_index].strip()
def get_table_cell_info(self, cursor_pos):
"""Get table cell information for speech output"""
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Find which column we're in based on cursor x position
column_index = self.get_column_index_from_x_position(line_text, cursor_pos["x"])
if column_index < 0 or column_index >= len(columns):
return None
# Use header if available, otherwise generic column name
if self.has_header_row():
column_header = self.get_column_header(column_index)
else:
column_header = f"Column {column_index + 1}"
return {
'column_index': column_index,
'column_header': column_header,
'cell_content': columns[column_index].strip(),
'total_columns': len(columns),
'row_number': cursor_pos["y"] + 1
}
def get_column_index_from_x_position(self, line_text, x_pos):
"""Determine which column an x position falls into"""
columns = self.parse_line_into_columns(line_text)
if not columns:
return 0
# Handle CSV/delimited text
if ',' in line_text or '|' in line_text or ';' in line_text or '\t' in line_text:
current_pos = 0
for i, column in enumerate(columns):
column_end = current_pos + len(column)
if current_pos <= x_pos <= column_end:
return i
current_pos = column_end + 1 # +1 for separator
else:
# Handle space-aligned text - find the column by position ranges
current_pos = 0
for i, column in enumerate(columns):
# Find where this column starts in the original text
if i == 0:
column_start = 0
else:
# Find the column by searching from current position
column_start = line_text.find(column.strip(), current_pos)
if column_start == -1:
column_start = current_pos
column_end = column_start + len(column.strip())
if column_start <= x_pos <= column_end:
return i
current_pos = column_end
# If past the end, return last column
return len(columns) - 1
def sync_table_position_from_cursor(self):
"""Sync internal table position from current cursor position"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if cursor_pos:
old_column = self.currentColumn
self.currentRow = cursor_pos["y"]
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if line_text:
self.currentColumn = self.get_column_index_from_x_position(line_text, cursor_pos["x"])
else:
self.currentColumn = 0
self.env["runtime"]["DebugManager"].write_debug_out(
f"TableManager sync: old_column={old_column}, new_column={self.currentColumn}, cursor_x={cursor_pos['x']}, cursor_y={cursor_pos['y']}",
debug.DebugLevel.INFO
)
def move_to_next_column(self):
"""Move to next column in table"""
self.env["runtime"]["DebugManager"].write_debug_out(
f"TableManager move_to_next_column: currentColumn={self.currentColumn}",
debug.DebugLevel.INFO
)
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
self.env["runtime"]["DebugManager"].write_debug_out(
f"TableManager: line_text='{line_text}', columns={columns}, currentColumn={self.currentColumn}",
debug.DebugLevel.INFO
)
# Don't sync from cursor position - maintain our own tracking
# Check if we're already at the last column
if self.currentColumn >= len(columns) - 1:
# At end of line - return special indicator but keep position
return {"at_end": True, "current_info": self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)}
# Move to next column
self.currentColumn += 1
self.env["runtime"]["DebugManager"].write_debug_out(
f"TableManager: moved to column {self.currentColumn}",
debug.DebugLevel.INFO
)
# Return info for the new column without moving cursor position
return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)
def move_to_prev_column(self):
"""Move to previous column in table"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Don't sync from cursor position - maintain our own tracking
# Check if we're already at the first column
if self.currentColumn <= 0:
# At beginning of line - return special indicator but keep position
return {"at_start": True, "current_info": self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)}
# Move to previous column
self.currentColumn -= 1
# Return info for the new column without moving cursor position
return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)
def get_current_table_cell_info(self):
"""Get current table cell info using internal position tracking"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)
def get_table_cell_info_by_indices(self, row, column_index):
"""Get table cell info for specific row and column indices"""
line_text = self.env["runtime"]["ScreenManager"].get_line_text(row)
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns or column_index < 0 or column_index >= len(columns):
return None
# Always get column header (fallback to generic if needed)
column_header = self.get_column_header(column_index)
cell_content = columns[column_index].strip()
if not cell_content: # Handle empty cells
cell_content = "blank"
return {
'column_index': column_index,
'column_header': column_header,
'cell_content': cell_content,
'total_columns': len(columns),
'row_number': row + 1
}
def get_column_start_position(self, line_text, column_index):
"""Get the starting x position of a specific column"""
columns = self.parse_line_into_columns(line_text)
if not columns or column_index < 0 or column_index >= len(columns):
return 0
# For CSV/delimited text - find the actual position in the original line
if ',' in line_text or '|' in line_text or ';' in line_text or '\t' in line_text:
# Determine the separator being used
separator = ','
if '|' in line_text and line_text.count('|') > line_text.count(','):
separator = '|'
elif ';' in line_text and line_text.count(';') > line_text.count(','):
separator = ';'
elif '\t' in line_text:
separator = '\t'
# Find the position by splitting and calculating
if column_index == 0:
return 0
# Count characters up to the target column
parts = line_text.split(separator)
position = 0
for i in range(column_index):
if i < len(parts):
position += len(parts[i]) + 1 # +1 for separator
return position
else:
# For space-aligned text, find the actual position of the column
if column_index == 0:
return 0
# Find the column text in the line
target_text = columns[column_index].strip()
search_start = 0
for i in range(column_index):
prev_text = columns[i].strip()
found_pos = line_text.find(prev_text, search_start)
if found_pos != -1:
search_start = found_pos + len(prev_text)
column_pos = line_text.find(target_text, search_start)
return column_pos if column_pos != -1 else search_start
def is_cursor_within_current_cell(self, cursor_x, cursor_y):
"""Check if the given cursor position is within the current table cell"""
if not self.is_table_mode():
return False
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_y)
if not line_text:
return False
columns = self.parse_line_into_columns(line_text)
if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns):
return False
# Get the bounds of the current column
column_start = self.get_column_start_position(line_text, self.currentColumn)
column_text = columns[self.currentColumn]
column_end = column_start + len(column_text)
# Check if cursor is within the column bounds
return column_start <= cursor_x < column_end
def move_to_first_cell(self):
"""Move to first cell in current row"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Set current column to first column
self.currentColumn = 0
# Return info for the first column
return self.get_table_cell_info_by_indices(cursor_pos["y"], 0)
def move_to_last_cell(self):
"""Move to last cell in current row"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Set current column to last column
self.currentColumn = len(columns) - 1
# Return info for the last column
return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)
def move_to_first_char_in_cell(self):
"""Move to first character in current cell"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns):
return None
# Get column start position
column_start = self.get_column_start_position(line_text, self.currentColumn)
# Find first non-space character in the column
column_text = columns[self.currentColumn]
first_char_offset = len(column_text) - len(column_text.lstrip())
# Set cursor position to first character in cell
new_x = column_start + first_char_offset
self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"])
# Get the character at the new position
from fenrirscreenreader.utils import char_utils
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
curr_char,
) = char_utils.get_current_char(
new_x,
cursor_pos["y"],
self.env["screen"]["new_content_text"],
)
return {
'cell_content': column_text.strip(),
'column_header': self.get_column_header(self.currentColumn),
'character': curr_char,
'position': 'first'
}
def move_to_last_char_in_cell(self):
"""Move to last character in current cell"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns):
return None
# Get column start position
column_start = self.get_column_start_position(line_text, self.currentColumn)
column_text = columns[self.currentColumn]
# Find last non-space character in the column
trimmed_text = column_text.rstrip()
if not trimmed_text:
# If empty cell, go to column start
new_x = column_start
else:
# Find the position of the last character
new_x = column_start + len(trimmed_text) - 1
# Set cursor position to last character in cell
self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"])
# Get the character at the new position
from fenrirscreenreader.utils import char_utils
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
curr_char,
) = char_utils.get_current_char(
new_x,
cursor_pos["y"],
self.env["screen"]["new_content_text"],
)
return {
'cell_content': column_text.strip(),
'column_header': self.get_column_header(self.currentColumn),
'character': curr_char,
'position': 'last'
}
def reset_table_mode(self):
self.set_head_line()
def set_head_column_sep(self, columnSep=""):
self.headColumnSep = columnSep
if columnSep == "":
self.noOfHeadLineColumns = 0
else:
self.coun_no_of_head_columns()
def coun_no_of_head_columns(self):
if self.headLine and self.headColumnSep:
self.noOfHeadLineColumns = len(self.headLine.split(self.headColumnSep))
def search_for_head_column_sep(self, headLine):
"""Find the most likely column separator in a header line"""
separators = [',', '|', ';', '\t']
for sep in separators:
if sep in headLine:
return sep
# Check for multiple spaces (aligned columns)
if ' ' in headLine:
return ' '
return ""
def set_row_column_sep(self, columnSep=""):
self.rowColumnSep = columnSep
def set_head_line(self, headLine=""):
self.set_head_column_sep()
self.set_row_column_sep()
if headLine != "":
sep = self.search_for_head_column_sep(headLine)
if sep != "":
self.headLine = headLine
self.set_head_column_sep(sep)