Files
fenrir/src/fenrirscreenreader/core/attributeManager.py
2025-07-09 01:06:54 -04:00

771 lines
27 KiB
Python

#!/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