From 5e858cfde151092eed15fc838e09a6476940b306 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 9 Jul 2025 01:06:54 -0400 Subject: [PATCH] Expirmental improvements to highlight tracking. --- .../commands/commands/review_line_begin.py | 21 ++ .../commands/commands/review_line_end.py | 21 ++ .../commands/review_line_first_char.py | 23 ++ .../commands/review_line_last_char.py | 23 ++ .../core/attributeManager.py | 309 ++++++++++++++++++ src/fenrirscreenreader/core/tableManager.py | 145 ++++++++ src/fenrirscreenreader/fenrirVersion.py | 2 +- 7 files changed, 543 insertions(+), 1 deletion(-) diff --git a/src/fenrirscreenreader/commands/commands/review_line_begin.py b/src/fenrirscreenreader/commands/commands/review_line_begin.py index 3850ff74..1d4f1021 100644 --- a/src/fenrirscreenreader/commands/commands/review_line_begin.py +++ b/src/fenrirscreenreader/commands/commands/review_line_begin.py @@ -24,6 +24,27 @@ class command: ) def run(self): + # Check if we're in table mode first + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_first_cell() + if table_info: + output_text = ( + f"{table_info['cell_content']} {table_info['column_header']}" + ) + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + self.env["runtime"]["OutputManager"].present_text( + _("first cell"), interrupt=False + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return + + # Regular line begin navigation (when not in table mode) cursor_pos = self.env["runtime"][ "CursorManager" ].get_review_or_text_cursor() diff --git a/src/fenrirscreenreader/commands/commands/review_line_end.py b/src/fenrirscreenreader/commands/commands/review_line_end.py index 4118af91..385ad375 100644 --- a/src/fenrirscreenreader/commands/commands/review_line_end.py +++ b/src/fenrirscreenreader/commands/commands/review_line_end.py @@ -24,6 +24,27 @@ class command: ) def run(self): + # Check if we're in table mode first + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_last_cell() + if table_info: + output_text = ( + f"{table_info['cell_content']} {table_info['column_header']}" + ) + self.env["runtime"]["OutputManager"].present_text( + output_text, interrupt=True, flush=False + ) + self.env["runtime"]["OutputManager"].present_text( + _("last cell"), interrupt=False + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return + + # Regular line end navigation (when not in table mode) cursor_pos = self.env["runtime"][ "CursorManager" ].get_review_or_text_cursor() diff --git a/src/fenrirscreenreader/commands/commands/review_line_first_char.py b/src/fenrirscreenreader/commands/commands/review_line_first_char.py index 703821a3..16222cb5 100644 --- a/src/fenrirscreenreader/commands/commands/review_line_first_char.py +++ b/src/fenrirscreenreader/commands/commands/review_line_first_char.py @@ -23,6 +23,29 @@ class command: return _("Move Review to the first character on the line") def run(self): + # Check if we're in table mode first + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_first_char_in_cell() + if table_info: + char_utils.present_char_for_review( + self.env, + table_info['character'], + interrupt=True, + announce_capital=True, + flush=False, + ) + self.env["runtime"]["OutputManager"].present_text( + _("first character in cell {0}").format(table_info['column_header']), + interrupt=False, + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return + + # Regular line first character navigation (when not in table mode) cursor_pos = self.env["runtime"][ "CursorManager" ].get_review_or_text_cursor() diff --git a/src/fenrirscreenreader/commands/commands/review_line_last_char.py b/src/fenrirscreenreader/commands/commands/review_line_last_char.py index 28da01e5..ebfb4692 100644 --- a/src/fenrirscreenreader/commands/commands/review_line_last_char.py +++ b/src/fenrirscreenreader/commands/commands/review_line_last_char.py @@ -22,6 +22,29 @@ class command: return _("Move Review to the last character on the line") def run(self): + # Check if we're in table mode first + is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() + if is_table_mode: + table_info = self.env["runtime"]["TableManager"].move_to_last_char_in_cell() + if table_info: + char_utils.present_char_for_review( + self.env, + table_info['character'], + interrupt=True, + announce_capital=True, + flush=False, + ) + self.env["runtime"]["OutputManager"].present_text( + _("last character in cell {0}").format(table_info['column_header']), + interrupt=False, + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _("no table data"), interrupt=True, flush=False + ) + return + + # Regular line last character navigation (when not in table mode) cursor_pos = self.env["runtime"][ "CursorManager" ].get_review_or_text_cursor() diff --git a/src/fenrirscreenreader/core/attributeManager.py b/src/fenrirscreenreader/core/attributeManager.py index 706ec184..543927d3 100644 --- a/src/fenrirscreenreader/core/attributeManager.py +++ b/src/fenrirscreenreader/core/attributeManager.py @@ -322,6 +322,9 @@ class AttributeManager: This is crucial for screen readers to announce when text becomes highlighted, selected, or changes visual emphasis (bold, reverse video, color changes, etc.) + Enhanced version includes bracket pattern detection for better context in applications + like ninjam that use patterns like [X]mute or [ ]mute. + Returns: tuple: (highlighted_text, cursor_position) - highlighted_text: string of characters that gained highlighting @@ -352,6 +355,9 @@ class AttributeManager: if len(text_lines) != len(self.currAttributes): return result, curr_cursor + # Track highlighted positions for context analysis + highlighted_positions = [] + # Compare attributes line by line, character by character for line in range(len(self.prevAttributes)): if self.prevAttributes[line] != self.currAttributes[line]: @@ -373,13 +379,316 @@ class AttributeManager: # for navigation if not curr_cursor: curr_cursor = {"x": column, "y": line} + # Store position for context analysis + highlighted_positions.append((line, column)) # Accumulate highlighted characters result += text_lines[line][column] # Add space between lines of highlighted text for speech # clarity result += " " + + # Enhanced bracket pattern detection for better context + if highlighted_positions: + try: + enhanced_result = self._detect_bracket_context(text_lines, highlighted_positions, result) + if enhanced_result and enhanced_result != result: + # Debug logging for bracket detection + try: + self.env["runtime"]["DebugManager"].write_debug_out( + f"AttributeManager bracket detection: Original='{result}' Enhanced='{enhanced_result}'", + debug.DebugLevel.INFO + ) + except Exception: + pass # Don't let debug logging break functionality + result = enhanced_result + except Exception as e: + # If bracket detection fails, fall back to original result + try: + self.env["runtime"]["DebugManager"].write_debug_out( + f"AttributeManager bracket detection error: {e}", + debug.DebugLevel.ERROR + ) + except Exception: + pass # Don't let debug logging break functionality + return result, curr_cursor + def _detect_bracket_context(self, text_lines, highlighted_positions, original_result): + """ + Analyzes highlighted positions to detect bracket patterns and provide better context. + + This method specifically looks for patterns like [X]mute, [ ]mute, [X]xmit, etc. + that are common in applications like ninjam where the bracket content indicates + state but the meaningful context is the surrounding text. + + Args: + text_lines: List of text lines from the screen + highlighted_positions: List of (line, column) tuples of highlighted characters + original_result: The original highlighted text result + + Returns: + str: Enhanced result with context, or None if no bracket pattern detected + """ + if not highlighted_positions: + return None + + # Group positions by line for easier analysis + positions_by_line = {} + for line, column in highlighted_positions: + if line not in positions_by_line: + positions_by_line[line] = [] + positions_by_line[line].append(column) + + enhanced_results = [] + + for line_num, columns in positions_by_line.items(): + if line_num >= len(text_lines): + continue + + line_text = text_lines[line_num] + columns.sort() # Process columns in order + + # Look for bracket patterns in this line + bracket_context = self._analyze_bracket_pattern(line_text, columns) + if bracket_context: + enhanced_results.append(bracket_context) + + # If we found enhanced context, return it; otherwise return None to use original + if enhanced_results: + # Remove duplicates while preserving order + unique_results = [] + seen = set() + for result in enhanced_results: + if result not in seen: + unique_results.append(result) + seen.add(result) + return " ".join(unique_results) + return None + + def _analyze_bracket_pattern(self, line_text, highlighted_columns): + """ + Analyzes a single line for bracket patterns around highlighted positions. + + Looks for patterns like: + - master: [X]mute -> "master mute on" + - metronome: [ ]mute -> "metronome mute off" + - [Left] [X]xmit -> "Left xmit on" + + Args: + line_text: The text of the line + highlighted_columns: List of column positions that are highlighted + + Returns: + str: Context-aware description, or None if no pattern found + """ + if not line_text or not highlighted_columns: + return None + + # Look for bracket patterns around highlighted positions + # Process columns in order and only return the first meaningful match + for col in highlighted_columns: + bracket_info = self._find_bracket_at_position(line_text, col) + if bracket_info: + bracket_start, bracket_end, bracket_content = bracket_info + + # Get context before and after the bracket + context_before = self._get_context_before(line_text, bracket_start) + context_after = self._get_context_after(line_text, bracket_end) + + # Build meaningful description + description = self._build_bracket_description( + context_before, bracket_content, context_after + ) + + # Only return if we have meaningful context (not just state) + if description and (context_before or context_after): + return description + + return None + + def _find_bracket_at_position(self, line_text, position): + """ + Determines if a position is within a bracket pattern like [X] or [ ]. + + Args: + line_text: The text line + position: Column position to check + + Returns: + tuple: (bracket_start, bracket_end, bracket_content) or None + """ + if position >= len(line_text): + return None + + # Look for opening bracket before or at position + bracket_start = None + for i in range(max(0, position - 2), min(len(line_text), position + 3)): + if line_text[i] == '[': + bracket_start = i + break + + if bracket_start is None: + return None + + # Look for closing bracket after bracket_start + bracket_end = None + for i in range(bracket_start + 1, min(len(line_text), bracket_start + 5)): + if line_text[i] == ']': + bracket_end = i + break + + if bracket_end is None: + return None + + # Check if our position is within the bracket + if bracket_start <= position <= bracket_end: + bracket_content = line_text[bracket_start + 1:bracket_end] + + # Filter out brackets that are likely not controls + # Skip brackets that contain complex content like "[-10.5dB center]" + if len(bracket_content) > 3 and ('dB' in bracket_content or 'Hz' in bracket_content): + return None + + return bracket_start, bracket_end, bracket_content + + return None + + def _get_context_before(self, line_text, bracket_start): + """ + Gets meaningful context words before a bracket. + + Args: + line_text: The text line + bracket_start: Position of opening bracket + + Returns: + str: Context words before bracket, or empty string + """ + if bracket_start <= 0: + return "" + + # Get text before bracket + before_text = line_text[:bracket_start].rstrip() + + # Special handling for common ninjam patterns + # Look for patterns like "master: " or "metronome: " or bracketed labels + + # Check if we have a colon-separated label immediately before + if ':' in before_text: + # Get the last colon-separated part + parts = before_text.split('|') # Split by pipe first + last_part = parts[-1].strip() + + if ':' in last_part: + # Extract the label before the colon + label_parts = last_part.split(':') + if len(label_parts) >= 2: + return label_parts[-2].strip() + + # Look for bracketed content that might be a label + # Pattern: [Something] [X]target -> "Something" + bracket_matches = [] + i = 0 + while i < len(before_text): + if before_text[i] == '[': + start = i + i += 1 + while i < len(before_text) and before_text[i] != ']': + i += 1 + if i < len(before_text): # Found closing bracket + content = before_text[start+1:i] + if content.strip(): + bracket_matches.append(content.strip()) + i += 1 + + # If we found bracketed content, use the last one + if bracket_matches: + return bracket_matches[-1] + + # Fall back to removing bracketed content and getting words + cleaned_text = "" + bracket_level = 0 + for char in before_text: + if char == '[': + bracket_level += 1 + elif char == ']': + bracket_level -= 1 + elif bracket_level == 0: + cleaned_text += char + + # Clean up separators and get meaningful words + words = [] + for word in cleaned_text.replace(':', '').replace('|', '').strip().split(): + if word and not word.startswith('[') and not word.endswith(']'): + words.append(word) + + # Return last word as context + if words: + return words[-1] + + return "" + + def _get_context_after(self, line_text, bracket_end): + """ + Gets meaningful context words after a bracket. + + Args: + line_text: The text line + bracket_end: Position of closing bracket + + Returns: + str: Context words after bracket, or empty string + """ + if bracket_end >= len(line_text) - 1: + return "" + + # Get text after bracket and find first meaningful word + after_text = line_text[bracket_end + 1:].lstrip() + + # Get first word, removing common separators + words = after_text.replace(':', '').replace('|', '').strip().split() + + if words: + return words[0] + + return "" + + def _build_bracket_description(self, context_before, bracket_content, context_after): + """ + Builds a human-readable description from bracket context. + + Args: + context_before: Words before the bracket + bracket_content: Content inside brackets (X, space, etc.) + context_after: Words after the bracket + + Returns: + str: Human-readable description + """ + # Clean up bracket content + bracket_content = bracket_content.strip() + + # Determine state based on bracket content + if bracket_content == 'X': + state = "on" + elif bracket_content == '' or bracket_content == ' ': + state = "off" + else: + state = bracket_content # For other patterns + + # Build description prioritizing context_before, then context_after + # Add spaces between components for better speech clarity + components = [] + + if context_before: + components.append(context_before) + if context_after: + components.append(context_after) + components.append(state) + + # Join with spaces for better speech flow + return " ".join(components) + def is_useful_for_tracking( self, line, diff --git a/src/fenrirscreenreader/core/tableManager.py b/src/fenrirscreenreader/core/tableManager.py index 58063c64..8b111dcd 100644 --- a/src/fenrirscreenreader/core/tableManager.py +++ b/src/fenrirscreenreader/core/tableManager.py @@ -382,6 +382,151 @@ class TableManager: # 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() diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 382ee35c..b17d7e73 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.08" +version = "2025.07.09" codeName = "testing" code_name = "testing"