#!/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)