#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. from collections import Counter from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ class AttributeManager: def __init__(self): self.currAttributes = None self.prevAttributes = None self.currAttributeDelta = "" self.currAttributeCursor = None self.prefAttributeCursor = None self.init_default_attributes() self.prevLastCursorAttribute = None self.currLastCursorAttribute = None def initialize(self, environment): self.env = environment def shutdown(self): pass def set_last_cursor_attribute(self, lastCursorAttribute): self.prevLastCursorAttribute = self.currLastCursorAttribute self.currLastCursorAttribute = lastCursorAttribute def reset_last_cursor_attribute(self): self.prevLastCursorAttribute = None self.currLastCursorAttribute = None def is_last_cursor_attribute_change(self): if self.prevLastCursorAttribute is None: return False return self.prevLastCursorAttribute != self.currLastCursorAttribute def get_curr_attribute_cursor(self): return self.currAttributeCursor def is_attribute_cursor_active(self): return self.currAttributeCursor is not None def is_attribute_change(self): if not self.prevAttributes: return False return self.currAttributes != self.prevAttributes def reset_attribute_all(self): self.reset_attribute_delta() self.reset_attribute_cursor() def get_attribute_delta(self): return self.currAttributeDelta def reset_attribute_delta(self): self.currAttributeDelta = "" def set_attribute_delta(self, currAttributeDelta): self.currAttributeDelta = currAttributeDelta def reset_attribute_cursor(self): self.currAttributeCursor = None self.prefAttributeCursor = None def set_attribute_cursor(self, currAttributeCursor): self.prefAttributeCursor = self.currAttributeCursor self.currAttributeCursor = currAttributeCursor.copy() def reset_attributes(self, currAttributes): self.prevAttributes = None self.currAttributes = currAttributes def set_attributes(self, currAttributes): self.prevAttributes = self.currAttributes self.currAttributes = currAttributes.copy() def get_attribute_by_xy(self, x, y): if not self.currAttributes: return None if len(self.currAttributes) < y: return None if len(self.currAttributes[y]) < x - 1: return None try: return self.currAttributes[y][x] except KeyError: try: return self.defaultAttributes[0] except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "AttributeManager get_attribute_by_xy: Error accessing default attributes: " + str(e), debug.DebugLevel.ERROR, ) return None def append_default_attributes(self, attribute): if not attribute: return if len(attribute) != 10: return self.defaultAttributes.append(attribute) def init_default_attributes(self): self.defaultAttributes = [None] self.defaultAttributes.append( [ "default", # fg "default", # bg False, # bold False, # italics False, # underscore False, # strikethrough False, # reverse False, # blink "default", # fontsize "default", # fontfamily ] ) # end attribute def is_default_attribute(self, attribute): return attribute in self.defaultAttributes def has_attributes(self, cursor, update=True): if not cursor: return False cursor_pos = cursor.copy() try: attribute = self.get_attribute_by_xy( cursor_pos["x"], cursor_pos["y"] ) if update: self.set_last_cursor_attribute(attribute) if not self.is_last_cursor_attribute_change(): return False if self.is_default_attribute(attribute): return False except Exception as e: return False return True def format_attributes(self, attribute, attribute_format_string=""): # "black", # "red", # "green", # "brown", # "blue", # "magenta", # "cyan", # "white", # "default" # white. # _order_ # "fg", # "bg", # "bold", # "italics", # "underscore", # "strikethrough", # "reverse", # "blink" # "fontsieze" # "fontfamily" if attribute_format_string == "": attribute_format_string = self.env["runtime"][ "SettingsManager" ].get_setting("general", "attribute_format_string") if not attribute_format_string: return "" if attribute_format_string == "": return "" if not attribute: return "" if len(attribute) != 10: return "" # 0 FG color (name) try: attribute_format_string = attribute_format_string.replace( "fenrirFGColor", _(attribute[0]) ) except Exception as e: attribute_format_string = attribute_format_string.replace( "fenrirFGColor", "" ) # 1 BG color (name) try: attribute_format_string = attribute_format_string.replace( "fenrirBGColor", _(attribute[1]) ) except Exception as e: attribute_format_string = attribute_format_string.replace( "fenrirBGColor", "" ) # 2 bold (True/ False) try: if attribute[2]: attribute_format_string = attribute_format_string.replace( "fenrirBold", _("bold") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirBold", "" ) # 3 italics (True/ False) try: if attribute[3]: attribute_format_string = attribute_format_string.replace( "fenrirItalics", _("italic") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirItalics", "" ) # 4 underline (True/ False) try: if attribute[4]: attribute_format_string = attribute_format_string.replace( "fenrirUnderline", _("underline") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirUnderline", "" ) # 5 strikethrough (True/ False) try: if attribute[5]: attribute_format_string = attribute_format_string.replace( "fenrirStrikethrough", _("strikethrough") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirStrikethrough", "" ) # 6 reverse (True/ False) try: if attribute[6]: attribute_format_string = attribute_format_string.replace( "fenrirReverse", _("reverse") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirReverse", "" ) # 7 blink (True/ False) try: if attribute[7]: attribute_format_string = attribute_format_string.replace( "fenrirBlink", _("blink") ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirBlink", "" ) # 8 font size (int/ string) try: try: attribute_format_string = attribute_format_string.replace( "fenrirFontSize", int(attribute[8]) ) except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "AttributeManager formatAttributeToString: Error formatting font size as int: " + str(e), debug.DebugLevel.ERROR, ) try: attribute_format_string = attribute_format_string.replace( "fenrirFontSize", str(attribute[8]) ) except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "AttributeManager formatAttributeToString: Error formatting font size as string: " + str(e), debug.DebugLevel.ERROR, ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirFontSize", _("default") ) # 9 font family (string) try: attribute_format_string = attribute_format_string.replace( "fenrirFont", attribute[9] ) except Exception as e: pass attribute_format_string = attribute_format_string.replace( "fenrirFont", _("default") ) return attribute_format_string def track_highlights(self): """ Detects text with changed attributes (highlighting) between screen updates. 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 - cursor_position: dict {'x': col, 'y': row} of first highlighted char """ result = "" curr_cursor = None # Early exit conditions - no attribute comparison possible if self.prevAttributes is None: # First screen load - no previous attributes to compare against return result, curr_cursor if self.prevAttributes == self.currAttributes: # No attribute changes detected return result, curr_cursor if self.currAttributes is None: # Error condition - current attributes missing return result, curr_cursor if len(self.currAttributes) == 0: # Special case for PTY environments with no text content return result, curr_cursor # Get current screen text to correlate with attribute changes text = self.env["runtime"]["ScreenManager"].get_screen_text() text_lines = text.split("\n") # Sanity check: text lines must match attribute array dimensions 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]: # This line has attribute changes - examine each character # position for column in range(len(self.prevAttributes[line])): if ( self.prevAttributes[line][column] == self.currAttributes[line][column] ): # No change at this position continue # Attribute changed at this position - check if it's worth # announcing if self.is_useful_for_tracking( line, column, self.currAttributes, self.prevAttributes ): # First highlighted character becomes cursor position # 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, column, currAttributes, prevAttributes, attribute=1, mode="zaxe", ): """ Determines if an attribute change at a specific position is worth announcing. This prevents announcing every minor attribute change and focuses on meaningful highlighting that indicates user selections, focus changes, or important emphasis. Args: line, column: Position of the attribute change currAttributes, prevAttributes: Current and previous attribute arrays attribute: Which attribute to examine (1=background color by default) mode: Detection algorithm ('zaxe', 'default', 'barrier') Returns: bool: True if this attribute change should be announced to user """ # Sanity checks for valid position and sufficient screen content if len(currAttributes) <= 3: return False if line < 0: return False if line > len(currAttributes): return False useful = False if mode == "default": # Simple mode: announce any non-default attribute useful = not self.is_default_attribute( currAttributes[line][column] ) elif (mode == "zaxe") or (mode == ""): # Context-aware mode: only announce attributes that stand out from surroundings # This prevents announcing entire blocks of highlighted text character by character # by checking if the attribute differs from adjacent lines if line == 0: # Top line: compare against lines below useful = ( currAttributes[line][column][attribute] != currAttributes[line + 1][column][attribute] ) and ( currAttributes[line][column][attribute] != currAttributes[line + 2][column][attribute] ) elif line >= len(prevAttributes): # Bottom line: compare against lines above useful = ( currAttributes[line][column][attribute] != currAttributes[line - 1][column][attribute] ) and ( currAttributes[line][column][attribute] != currAttributes[line - 2][column][attribute] ) else: # Middle lines: compare against both directions useful = ( currAttributes[line][column][attribute] != currAttributes[line + 1][column][attribute] ) and ( currAttributes[line][column][attribute] != currAttributes[line - 1][column][attribute] ) elif mode == "barrier": # Barrier mode: future enhancement for detecting screen # boundaries/separators useful = True return useful