#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributers. from fenrirscreenreader.core import debug from fenrirscreenreader.utils import screen_utils import time, os, re, difflib class screenManager(): def __init__(self): self.differ = difflib.Differ() self.currScreenIgnored = False self.prevScreenIgnored = False self.prevScreenText = '' self.currScreenText = '' self.colums = None self.rows = None # Compile regex once for better performance self._space_normalize_regex = re.compile(' +') def getRows(self): return self.rows def getColumns(self): return self.colums def initialize(self, environment): self.env = environment self.env['runtime']['settingsManager'].loadDriver(\ self.env['runtime']['settingsManager'].getSetting('screen', 'driver'), 'screenDriver') self.getCurrScreen() self.getCurrScreen() self.getSessionInformation() self.updateScreenIgnored() self.updateScreenIgnored() def resetScreenText(self, screenText): self.prevScreenText = '' self.currScreenText = screenText def setScreenText(self, screenText): self.prevScreenText = self.currScreenText self.currScreenText = screenText def getScreenText(self): return self.currScreenText def getCurrScreen(self): try: self.env['runtime']['screenDriver'].getCurrScreen() except Exception as e: self.env['runtime']['debug'].writeDebugOut('screenManager getCurrScreen: Error getting current screen: ' + str(e), debug.debugLevel.ERROR) def getSessionInformation(self): try: self.env['runtime']['screenDriver'].getSessionInformation() except Exception as e: self.env['runtime']['debug'].writeDebugOut('screenManager getSessionInformation: Error getting session info: ' + str(e), debug.debugLevel.ERROR) def shutdown(self): self.env['runtime']['settingsManager'].shutdownDriver('screenDriver') def isCurrScreenIgnoredChanged(self): return self.getCurrScreenIgnored() != self.getPrevScreenIgnored() def handleScreenChange(self, eventData): self.getCurrScreen() self.getSessionInformation() self.updateScreenIgnored() if self.isCurrScreenIgnoredChanged(): self.env['runtime']['inputManager'].setExecuteDeviceGrab() self.env['runtime']['inputManager'].handleDeviceGrab() if not self.isIgnoredScreen(self.env['screen']['newTTY']): self.update(eventData, 'onScreenChange') self.env['screen']['lastScreenUpdate'] = time.time() else: self.env['runtime']['outputManager'].interruptOutput() def handleScreenUpdate(self, eventData): self.env['screen']['oldApplication'] = self.env['screen']['newApplication'] self.updateScreenIgnored() if self.isCurrScreenIgnoredChanged(): self.env['runtime']['inputManager'].setExecuteDeviceGrab() self.env['runtime']['inputManager'].handleDeviceGrab() if not self.getCurrScreenIgnored(): self.update(eventData, 'onScreenUpdate') self.env['screen']['lastScreenUpdate'] = time.time() elif self.isCurrScreenIgnoredChanged(): self.env['runtime']['outputManager'].interruptOutput() def getCurrScreenIgnored(self): return self.currScreenIgnored def getPrevScreenIgnored(self): return self.prevScreenIgnored def updateScreenIgnored(self): self.prevScreenIgnored = self.currScreenIgnored self.currScreenIgnored = self.isIgnoredScreen(self.env['screen']['newTTY']) def update(self, eventData, trigger='onUpdate'): # set new "old" values self.env['screen']['oldContentBytes'] = self.env['screen']['newContentBytes'] self.env['screen']['oldContentText'] = self.env['screen']['newContentText'] self.env['screen']['oldCursor'] = self.env['screen']['newCursor'].copy() self.env['screen']['oldDelta'] = self.env['screen']['newDelta'] self.env['screen']['oldNegativeDelta'] = self.env['screen']['newNegativeDelta'] self.env['screen']['newContentBytes'] = eventData['bytes'] # get metadata like cursor or screensize self.env['screen']['lines'] = int( eventData['lines']) self.env['screen']['columns'] = int( eventData['columns']) self.colums = int( eventData['columns']) self.rows = int( eventData['lines']) self.env['screen']['newCursor']['x'] = int( eventData['textCursor']['x']) self.env['screen']['newCursor']['y'] = int( eventData['textCursor']['y']) self.env['screen']['newTTY'] = eventData['screen'] self.env['screen']['newContentText'] = eventData['text'] # screen change if self.isScreenChange(): self.env['screen']['oldContentBytes'] = b'' self.resetScreenText(eventData['text']) self.env['runtime']['attributeManager'].resetAttributes(eventData['attributes']) self.env['runtime']['attributeManager'].resetAttributeCursor() self.env['screen']['oldContentText'] = '' self.env['screen']['oldCursor']['x'] = 0 self.env['screen']['oldCursor']['y'] = 0 self.env['screen']['oldDelta'] = '' self.env['screen']['oldNegativeDelta'] = '' else: self.setScreenText(eventData['text']) self.env['runtime']['attributeManager'].setAttributes(eventData['attributes']) # initialize current deltas self.env['screen']['newNegativeDelta'] = '' self.env['screen']['newDelta'] = '' self.env['runtime']['attributeManager'].resetAttributeDelta() # 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) # 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'] == '': # Pre-process screen text for comparison - collapse multiple spaces to single space # This normalization prevents spurious diffs from spacing inconsistencies oldScreenText = self._space_normalize_regex.sub(' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['oldContentText'])) newScreenText = self._space_normalize_regex.sub(' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['newContentText'])) 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() != '': # 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. # Pre-process screen text for comparison - collapse multiple spaces to single space # This normalization prevents spurious diffs from spacing inconsistencies oldScreenText = self._space_normalize_regex.sub(' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['oldContentText'])) newScreenText = self._space_normalize_regex.sub(' ',self.env['runtime']['screenManager'].getWindowAreaInText(self.env['screen']['newContentText'])) 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 try: if self.env['runtime']['attributeManager'].isAttributeChange(): if self.env['runtime']['settingsManager'].getSettingAsBool('focus', 'highlight'): attributeDelta, attributeCursor = self.env['runtime']['attributeManager'].trackHighlights() if attributeCursor: self.env['runtime']['attributeManager'].setAttributeCursor(attributeCursor) self.env['runtime']['attributeManager'].setAttributeDelta(attributeDelta) except Exception as e: self.env['runtime']['debug'].writeDebugOut('screenManager:update:highlight: ' + str(e),debug.debugLevel.ERROR) def isIgnoredScreen(self, screen = None): if screen == None: screen = self.env['screen']['newTTY'] # Check if force all screens flag is set if self.env['runtime'].get('force_all_screens', False): return False ignoreScreens = [] fixIgnoreScreens = self.env['runtime']['settingsManager'].getSetting('screen', 'ignoreScreen') if fixIgnoreScreens != '': ignoreScreens.extend(fixIgnoreScreens.split(',')) if self.env['runtime']['settingsManager'].getSettingAsBool('screen', 'autodetectIgnoreScreen'): ignoreScreens.extend(self.env['screen']['autoIgnoreScreens']) self.env['runtime']['debug'].writeDebugOut('screenManager:isIgnoredScreen ignore:' + str(ignoreScreens) + ' current:'+ str(screen ), debug.debugLevel.INFO) return (screen in ignoreScreens) def isScreenChange(self): if not self.env['screen']['oldTTY']: return False return self.env['screen']['newTTY'] != self.env['screen']['oldTTY'] def isDelta(self, ignoreSpace=False): newDelta = self.env['screen']['newDelta'] if ignoreSpace: newDelta = newDelta.strip() return newDelta != '' def isNegativeDelta(self): return self.env['screen']['newNegativeDelta'] != '' def getWindowAreaInText(self, text): if not self.env['runtime']['cursorManager'].isApplicationWindowSet(): return text windowText = '' windowList = text.split('\n') currApp = self.env['runtime']['applicationManager'].getCurrentApplication() windowList = windowList[self.env['commandBuffer']['windowArea'][currApp]['1']['y']:self.env['commandBuffer']['windowArea'][currApp]['2']['y'] + 1] for line in windowList: windowText += line[self.env['commandBuffer']['windowArea'][currApp]['1']['x']:self.env['commandBuffer']['windowArea'][currApp]['2']['x'] + 1] + '\n' return windowText def injectTextToScreen(self, text, screen = None): try: self.env['runtime']['screenDriver'].injectTextToScreen(text, screen) except Exception as e: self.env['runtime']['debug'].writeDebugOut('screenManager:injectTextToScreen ' + str(e),debug.debugLevel.ERROR)