From d1a42835e46fab753660b4bfe771c206d1f71953 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 7 Jul 2025 09:46:12 -0400 Subject: [PATCH] Initial table mode added. Probably bugs. --- .../commands/commands/review_curr_word.py | 43 ++- .../commands/commands/review_next_word.py | 38 +- .../commands/commands/review_prev_word.py | 34 +- .../commands/commands/set_mark.py | 14 + .../commands/toggle_highlight_tracking.py | 57 ++- src/fenrirscreenreader/core/screenManager.py | 11 + src/fenrirscreenreader/core/tableManager.py | 354 +++++++++++++++++- src/fenrirscreenreader/fenrirVersion.py | 2 +- 8 files changed, 527 insertions(+), 26 deletions(-) diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word.py b/src/fenrirscreenreader/commands/commands/review_curr_word.py index 71c053f2..d25026c9 100644 --- a/src/fenrirscreenreader/commands/commands/review_curr_word.py +++ b/src/fenrirscreenreader/commands/commands/review_curr_word.py @@ -6,6 +6,7 @@ from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import word_utils +from fenrirscreenreader.core import debug class command: @@ -38,14 +39,42 @@ class command: self.env["screen"]["new_content_text"], ) - if curr_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) + # Check if we're in table mode and provide table-aware output + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + self.env["runtime"]["DebugManager"].write_debug_out( + f"review_curr_word: is_table_mode={is_table_mode}", + debug.DebugLevel.INFO + ) + if is_table_mode: + # Get current cell info using internal column tracking + table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info() + if table_info: + # Announce with table context - cell content first, then header + output_text = f"{table_info['cell_content']} {table_info['column_header']}" + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + return # Exit early for table mode + else: + # Fallback to regular word announcement + if curr_word.isspace(): + self.env["runtime"]["OutputManager"].present_text( + _("blank"), interrupt=True, flush=False + ) + else: + self.env["runtime"]["OutputManager"].present_text( + curr_word, interrupt=True, flush=False + ) else: - self.env["runtime"]["OutputManager"].present_text( - curr_word, interrupt=True, flush=False - ) + # Regular word announcement + if curr_word.isspace(): + self.env["runtime"]["OutputManager"].present_text( + _("blank"), interrupt=True, flush=False + ) + else: + self.env["runtime"]["OutputManager"].present_text( + curr_word, interrupt=True, flush=False + ) if end_of_screen: if self.env["runtime"]["SettingsManager"].get_setting_as_bool( "review", "end_of_screen" diff --git a/src/fenrirscreenreader/commands/commands/review_next_word.py b/src/fenrirscreenreader/commands/commands/review_next_word.py index debb5a3f..e101869c 100644 --- a/src/fenrirscreenreader/commands/commands/review_next_word.py +++ b/src/fenrirscreenreader/commands/commands/review_next_word.py @@ -6,6 +6,7 @@ from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import word_utils +from fenrirscreenreader.core import debug class command: @@ -22,6 +23,40 @@ class command: return _("moves review to the next word ") def run(self): + # Check if we're in table mode FIRST - before any word navigation + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + self.env["runtime"]["DebugManager"].write_debug_out( + f"review_next_word: is_table_mode={is_table_mode}", + debug.DebugLevel.INFO + ) + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_next_column() + if table_info and table_info.get("at_end"): + # Stay on current cell and play end of line sound + current_info = table_info["current_info"] + if current_info: + output_text = f"{current_info['cell_content']}" + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + # Play end of line sound + self.env["runtime"]["OutputManager"].present_text( + _("end of line"), interrupt=False, sound_icon="EndOfLine" + ) + elif table_info: + # Normal column navigation - announce cell content with column info + output_text = f"{table_info['cell_content']} {table_info['column_header']}" + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + else: + # No table info available, but still in table mode + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return # ALWAYS return in table mode to prevent regular word navigation + + # Regular word navigation (only when NOT in table mode) self.env["screen"]["oldCursorReview"] = self.env["screen"][ "newCursorReview" ] @@ -41,7 +76,8 @@ class command: self.env["screen"]["newCursorReview"]["y"], self.env["screen"]["new_content_text"], ) - + + # Regular word announcement if next_word.isspace(): self.env["runtime"]["OutputManager"].present_text( _("blank"), interrupt=True, flush=False diff --git a/src/fenrirscreenreader/commands/commands/review_prev_word.py b/src/fenrirscreenreader/commands/commands/review_prev_word.py index 19268abe..a2a56fe6 100644 --- a/src/fenrirscreenreader/commands/commands/review_prev_word.py +++ b/src/fenrirscreenreader/commands/commands/review_prev_word.py @@ -6,6 +6,7 @@ from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import word_utils +from fenrirscreenreader.core import debug class command: @@ -22,6 +23,36 @@ class command: return _("moves review focus to the previous word ") def run(self): + # Check if we're in table mode FIRST - before any word navigation + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + self.env["runtime"]["DebugManager"].write_debug_out( + f"review_prev_word: is_table_mode={is_table_mode}", + debug.DebugLevel.INFO + ) + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_prev_column() + if table_info and table_info.get("at_start"): + # Stay on current cell at beginning of line + current_info = table_info["current_info"] + if current_info: + output_text = f"{current_info['cell_content']} {current_info['column_header']}" + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + elif table_info: + # Normal column navigation - announce cell content with column info + output_text = f"{table_info['cell_content']} {table_info['column_header']}" + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + else: + # No table info available, but still in table mode + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return # ALWAYS return in table mode to prevent regular word navigation + + # Regular word navigation (only when NOT in table mode) self.env["runtime"][ "CursorManager" ].enter_review_mode_curr_text_cursor() @@ -37,7 +68,8 @@ class command: self.env["screen"]["newCursorReview"]["y"], self.env["screen"]["new_content_text"], ) - + + # Regular word announcement if prev_word.isspace(): self.env["runtime"]["OutputManager"].present_text( _("blank"), interrupt=True, flush=False diff --git a/src/fenrirscreenreader/commands/commands/set_mark.py b/src/fenrirscreenreader/commands/commands/set_mark.py index e9056af0..6f50e4b4 100644 --- a/src/fenrirscreenreader/commands/commands/set_mark.py +++ b/src/fenrirscreenreader/commands/commands/set_mark.py @@ -28,6 +28,20 @@ class command: ) return + # If in table mode, set header row instead of regular mark + if self.env["runtime"]["TableManager"].is_table_mode(): + success = self.env["runtime"]["TableManager"].set_header_row_from_cursor() + if success: + self.env["runtime"]["OutputManager"].present_text( + _("header row set"), sound_icon="PlaceStartMark", interrupt=True + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _("could not set header row"), interrupt=True + ) + return + + # Regular mark functionality curr_mark = self.env["runtime"]["CursorManager"].set_mark() if curr_mark == 1: self.env["runtime"]["OutputManager"].present_text( diff --git a/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py b/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py index d7a1eeb3..f5a51ee3 100644 --- a/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py +++ b/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py @@ -5,6 +5,7 @@ # By Chrys, Storm Dragon, and contributors. from fenrirscreenreader.core.i18n import _ +from fenrirscreenreader.core import debug class command: @@ -21,26 +22,56 @@ class command: return _("enables or disables tracking of highlighted text") def run(self): - curr_mode = self.env["runtime"]["SettingsManager"].get_setting_as_bool( + highlight_mode = self.env["runtime"]["SettingsManager"].get_setting_as_bool( "focus", "highlight" ) + cursor_mode = self.env["runtime"]["SettingsManager"].get_setting_as_bool( + "focus", "cursor" + ) + table_mode = self.env["runtime"]["TableManager"].is_table_mode() + + self.env["runtime"]["DebugManager"].write_debug_out( + f"toggle_highlight_tracking: highlight={highlight_mode}, cursor={cursor_mode}, table={table_mode}", + debug.DebugLevel.INFO + ) - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "highlight", str(not curr_mode) - ) - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "cursor", str(curr_mode) - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "highlight" - ): - self.env["runtime"]["OutputManager"].present_text( - _("highlight tracking"), sound_icon="", interrupt=True + # Cycle through modes: highlight → cursor → table → highlight + if highlight_mode and not table_mode: + # Switch to cursor mode + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "highlight", "False" ) - else: + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "cursor", "True" + ) + self.env["runtime"]["TableManager"].set_table_mode(False) self.env["runtime"]["OutputManager"].present_text( _("cursor tracking"), sound_icon="", interrupt=True ) + elif cursor_mode and not table_mode: + # Switch to table mode + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "highlight", "False" + ) + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "cursor", "False" + ) + self.env["runtime"]["TableManager"].set_table_mode(True) + self.env["runtime"]["OutputManager"].present_text( + _("table mode"), sound_icon="", interrupt=True + ) + else: + # Switch to highlight mode (default) - also handles stuck table mode + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "highlight", "True" + ) + self.env["runtime"]["SettingsManager"].set_setting( + "focus", "cursor", "False" + ) + self.env["runtime"]["TableManager"].set_table_mode(False) + self.env["runtime"]["OutputManager"].present_text( + _("highlight tracking"), sound_icon="", interrupt=True + ) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/core/screenManager.py b/src/fenrirscreenreader/core/screenManager.py index 8ceca438..54a2618e 100644 --- a/src/fenrirscreenreader/core/screenManager.py +++ b/src/fenrirscreenreader/core/screenManager.py @@ -55,6 +55,17 @@ class ScreenManager: def get_screen_text(self): return self.currScreenText + + def get_line_text(self, line_number): + """Get text from a specific line (0-based index)""" + if not self.env["screen"]["new_content_text"]: + return "" + + lines = self.env["screen"]["new_content_text"].split("\n") + if line_number < 0 or line_number >= len(lines): + return "" + + return lines[line_number] def get_curr_screen(self): try: diff --git a/src/fenrirscreenreader/core/tableManager.py b/src/fenrirscreenreader/core/tableManager.py index 5e17c1a6..7d0434fe 100644 --- a/src/fenrirscreenreader/core/tableManager.py +++ b/src/fenrirscreenreader/core/tableManager.py @@ -16,12 +16,350 @@ class TableManager: 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 reset_table_mode(self): self.set_head_line() @@ -34,11 +372,21 @@ class TableManager: self.coun_no_of_head_columns() def coun_no_of_head_columns(self): - pass + if self.headLine and self.headColumnSep: + self.noOfHeadLineColumns = len(self.headLine.split(self.headColumnSep)) def search_for_head_column_sep(self, headLine): - if " " in headLine: - return " " + """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=""): diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 4e13bfe4..989b0ffd 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,6 +4,6 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2025.07.05" +version = "2025.07.07" codeName = "testing" code_name = "testing"