diff --git a/src/fenrirscreenreader/core/attributeManager.py b/src/fenrirscreenreader/core/attributeManager.py index 7578a080..b16992b7 100644 --- a/src/fenrirscreenreader/core/attributeManager.py +++ b/src/fenrirscreenreader/core/attributeManager.py @@ -233,57 +233,110 @@ class attributeManager(): return attributeFormatString def trackHighlights(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.) + + 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 = '' currCursor = None - # screen change + + # Early exit conditions - no attribute comparison possible if self.prevAttributes == None: + # First screen load - no previous attributes to compare against return result, currCursor - # no change if self.prevAttributes == self.currAttributes: + # No attribute changes detected return result, currCursor - # error case if self.currAttributes == None: + # Error condition - current attributes missing return result, currCursor - # special case for pty if not text exists. if len(self.currAttributes) == 0: + # Special case for PTY environments with no text content return result, currCursor + + # Get current screen text to correlate with attribute changes text = self.env['runtime']['screenManager'].getScreenText() textLines = text.split('\n') + # Sanity check: text lines must match attribute array dimensions if len(textLines) != len(self.currAttributes): return result, currCursor + + # 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.isUsefulForTracking(line, column, self.currAttributes, self.prevAttributes): + # First highlighted character becomes cursor position for navigation if not currCursor: currCursor = {'x': column, 'y': line} + # Accumulate highlighted characters result += textLines[line][column] + # Add space between lines of highlighted text for speech clarity result += ' ' return result, currCursor def isUsefulForTracking(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': - # non default tracking + # Simple mode: announce any non-default attribute useful = not self.isDefaultAttribute(currAttributes[line][column]) + elif (mode == 'zaxe') or (mode == ''): - # arround me tracking for bg + # 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: - useful = (currAttributes[line][column][attribute] != currAttributes[line + 1][column][attribute]) and (currAttributes[line][column][attribute] != currAttributes[line + 2][column][attribute]) + # 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): - useful = (currAttributes[line][column][attribute] != currAttributes[line - 1][column][attribute]) and (currAttributes[line][column][attribute] != currAttributes[line - 2][column][attribute]) + # 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: - useful = (currAttributes[line][column][attribute] != currAttributes[line + 1][column][attribute]) and (currAttributes[line][column][attribute] != currAttributes[line - 1][column][attribute]) + # 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': - # to be implement + # Barrier mode: future enhancement for detecting screen boundaries/separators useful = True return useful diff --git a/src/fenrirscreenreader/core/screenManager.py b/src/fenrirscreenreader/core/screenManager.py index 5a3abea4..1341aa98 100644 --- a/src/fenrirscreenreader/core/screenManager.py +++ b/src/fenrirscreenreader/core/screenManager.py @@ -120,47 +120,94 @@ class screenManager(): self.env['screen']['newDelta'] = '' self.env['runtime']['attributeManager'].resetAttributeDelta() - # changes on the screen + # Diff generation - critical for screen reader functionality + # This code detects and categorizes screen content changes to provide appropriate + # speech feedback (typing echo vs incoming text vs screen updates) + + # Pre-process screen text for comparison - collapse multiple spaces to single space + # This normalization prevents spurious diffs from spacing inconsistencies oldScreenText = re.sub(' +',' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['oldContentText'])) newScreenText = re.sub(' +',' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['newContentText'])) + + # Track whether this appears to be typing (user input) vs other screen changes typing = False diffList = [] if (self.env['screen']['oldContentText'] != self.env['screen']['newContentText']): + # Special case: Initial screen content (going from empty to populated) + # This handles first screen load or TTY switch scenarios if self.env['screen']['newContentText'] != '' and self.env['screen']['oldContentText'] == '': if oldScreenText == '' and\ newScreenText != '': self.env['screen']['newDelta'] = newScreenText else: + # Calculate byte positions for the current cursor's line in the flat text buffer + # Formula: (line_number * columns) + line_number accounts for newlines + # Each line contributes 'columns' chars + 1 newline char cursorLineStart = self.env['screen']['newCursor']['y'] * self.env['screen']['columns'] + self.env['screen']['newCursor']['y'] cursorLineEnd = cursorLineStart + self.env['screen']['columns'] + + # TYPING DETECTION ALGORITHM + # Determines if this screen change is likely user typing vs other content changes + # All conditions must be met for typing detection: if abs(self.env['screen']['oldCursor']['x'] - self.env['screen']['newCursor']['x']) >= 1 and \ self.env['screen']['oldCursor']['y'] == self.env['screen']['newCursor']['y'] and \ self.env['screen']['newContentText'][:cursorLineStart] == self.env['screen']['oldContentText'][:cursorLineStart] and \ self.env['screen']['newContentText'][cursorLineEnd:] == self.env['screen']['oldContentText'][cursorLineEnd:]: + # Condition 1: Cursor moved horizontally by at least 1 position (typical of typing) + # Condition 2: Cursor stayed on same line (typing doesn't usually change lines) + # Condition 3: Content BEFORE cursor line is unchanged (text above typing area) + # Condition 4: Content AFTER cursor line is unchanged (text below typing area) + # Together: only the current line changed, cursor moved horizontally = likely typing + + # Optimize diff calculation for typing by focusing on a small window around cursor cursorLineStartOffset = cursorLineStart cursorLineEndOffset = cursorLineEnd + + # Limit analysis window to avoid processing entire long lines + # +3 provides safety buffer beyond cursor position to catch edge cases if cursorLineEnd > cursorLineStart + self.env['screen']['newCursor']['x'] + 3: cursorLineEndOffset = cursorLineStart + self.env['screen']['newCursor']['x'] + 3 + + # Extract just the relevant text sections for character-level diff oldScreenText = self.env['screen']['oldContentText'][cursorLineStartOffset:cursorLineEndOffset] newScreenText = self.env['screen']['newContentText'][cursorLineStartOffset:cursorLineEndOffset] + + # Character-level diff for typing detection (not line-level) diff = self.differ.compare(oldScreenText, newScreenText) diffList = list(diff) typing = True + + # Validate typing assumption by checking if detected changes match cursor movement tempNewDelta = ''.join(x[2:] for x in diffList if x[0] == '+') if tempNewDelta.strip() != '': - if tempNewDelta != ''.join(newScreenText[self.env['screen']['oldCursor']['x']:self.env['screen']['newCursor']['x']].rstrip()): + # Compare diff result against expected typing pattern + # Expected: characters between old and new cursor positions + expectedTyping = ''.join(newScreenText[self.env['screen']['oldCursor']['x']:self.env['screen']['newCursor']['x']].rstrip()) + + # If diff doesn't match expected typing pattern, treat as general screen change + if tempNewDelta != expectedTyping: + # Fallback: treat entire current line as new content diffList = ['+ ' + self.env['screen']['newContentText'].split('\n')[self.env['screen']['newCursor']['y']] +'\n'] typing = False else: + # GENERAL SCREEN CHANGE DETECTION + # Not typing - handle as line-by-line content change + # This catches: incoming messages, screen updates, application output, etc. diff = self.differ.compare(oldScreenText.split('\n'),\ newScreenText.split('\n')) diffList = list(diff) + # Extract added and removed content from diff results + # Output format depends on whether this was detected as typing or general change if not typing: + # Line-based changes: join with newlines for proper speech cadence self.env['screen']['newDelta'] = '\n'.join(x[2:] for x in diffList if x[0] == '+') else: + # Character-based changes: no newlines for smooth typing echo self.env['screen']['newDelta'] = ''.join(x[2:] for x in diffList if x[0] == '+') + + # Negative delta (removed content) - used for backspace/delete detection self.env['screen']['newNegativeDelta'] = ''.join(x[2:] for x in diffList if x[0] == '-') # track highlighted