256 lines
10 KiB
Python
256 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Fenrir TTY screen reader
|
|
# By Chrys, Storm Dragon, and contributors.
|
|
|
|
import re
|
|
import threading
|
|
import time
|
|
|
|
from fenrirscreenreader.core.i18n import _
|
|
|
|
|
|
class command:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def initialize(self, environment):
|
|
self.env = environment
|
|
# Use commandBuffer like other commands
|
|
if "progressMonitoring" not in self.env["commandBuffer"]:
|
|
# Check if progress monitoring should be enabled by default from
|
|
# settings
|
|
try:
|
|
default_enabled = self.env["runtime"][
|
|
"SettingsManager"
|
|
].get_setting_as_bool("sound", "progressMonitoring")
|
|
except Exception as e:
|
|
# If setting doesn't exist, default to False
|
|
default_enabled = False
|
|
self.env["commandBuffer"]["progressMonitoring"] = default_enabled
|
|
self.env["commandBuffer"]["lastProgressTime"] = 0
|
|
self.env["commandBuffer"]["lastProgressValue"] = -1
|
|
|
|
def shutdown(self):
|
|
self.stop_progress_monitoring()
|
|
|
|
def get_description(self):
|
|
return _("Toggle progress bar monitoring with ascending tones")
|
|
|
|
def run(self):
|
|
# Check if commandBuffer exists
|
|
if "progressMonitoring" not in self.env["commandBuffer"]:
|
|
self.env["commandBuffer"]["progressMonitoring"] = False
|
|
self.env["commandBuffer"]["lastProgressTime"] = 0
|
|
self.env["commandBuffer"]["lastProgressValue"] = -1
|
|
|
|
if self.env["commandBuffer"]["progressMonitoring"]:
|
|
self.stop_progress_monitoring()
|
|
self.env["runtime"]["OutputManager"].present_text(
|
|
_("Progress monitoring disabled"), interrupt=True
|
|
)
|
|
else:
|
|
self.start_progress_monitoring()
|
|
self.env["runtime"]["OutputManager"].present_text(
|
|
_("Progress monitoring enabled"), interrupt=True
|
|
)
|
|
|
|
def start_progress_monitoring(self):
|
|
self.env["commandBuffer"]["progressMonitoring"] = True
|
|
self.env["commandBuffer"]["lastProgressTime"] = time.time()
|
|
self.env["commandBuffer"]["lastProgressValue"] = -1
|
|
# Don't control speech - let user decide with silence_until_prompt
|
|
|
|
def stop_progress_monitoring(self):
|
|
self.env["commandBuffer"]["progressMonitoring"] = False
|
|
# Don't control speech - progress monitor is beep-only
|
|
|
|
def detect_progress(self, text):
|
|
if not self.env["commandBuffer"]["progressMonitoring"]:
|
|
return
|
|
|
|
# Skip progress detection if current screen looks like a prompt
|
|
if self.is_current_line_prompt():
|
|
return
|
|
|
|
current_time = time.time()
|
|
|
|
# Pattern 1: Percentage (50%, 25.5%, etc.)
|
|
percent_match = re.search(r"(\d+(?:\.\d+)?)\s*%", text)
|
|
if percent_match:
|
|
percentage = float(percent_match.group(1))
|
|
if percentage != self.env["commandBuffer"]["lastProgressValue"]:
|
|
self.play_progress_tone(percentage)
|
|
self.env["commandBuffer"]["lastProgressValue"] = percentage
|
|
self.env["commandBuffer"]["lastProgressTime"] = current_time
|
|
return
|
|
|
|
# Pattern 2: Fraction (15/100, 3 of 10, etc.)
|
|
fraction_match = re.search(r"(\d+)\s*(?:of|/)\s*(\d+)", text)
|
|
if fraction_match:
|
|
current = int(fraction_match.group(1))
|
|
total = int(fraction_match.group(2))
|
|
if total > 0:
|
|
percentage = (current / total) * 100
|
|
if (
|
|
percentage
|
|
!= self.env["commandBuffer"]["lastProgressValue"]
|
|
):
|
|
self.play_progress_tone(percentage)
|
|
self.env["commandBuffer"]["lastProgressValue"] = percentage
|
|
self.env["commandBuffer"][
|
|
"lastProgressTime"
|
|
] = current_time
|
|
return
|
|
|
|
# Pattern 3: Progress bars ([#### ], [====> ], etc.)
|
|
# Improved pattern to avoid matching IRC channels like [#channel]
|
|
bar_match = re.search(r"\[([#=\-\*]+)([\s\.]*)\]", text)
|
|
if bar_match:
|
|
filled = len(bar_match.group(1))
|
|
unfilled = len(bar_match.group(2))
|
|
total = filled + unfilled
|
|
# Require at least 2 progress chars total and unfilled portion must
|
|
# be spaces/dots
|
|
if total >= 2 and (
|
|
not bar_match.group(2)
|
|
or re.match(r"^[\s\.]*$", bar_match.group(2))
|
|
):
|
|
percentage = (filled / total) * 100
|
|
if (
|
|
percentage
|
|
!= self.env["commandBuffer"]["lastProgressValue"]
|
|
):
|
|
self.play_progress_tone(percentage)
|
|
self.env["commandBuffer"]["lastProgressValue"] = percentage
|
|
self.env["commandBuffer"][
|
|
"lastProgressTime"
|
|
] = current_time
|
|
return
|
|
|
|
# Pattern 4: Generic activity indicators (Loading..., Working..., etc.)
|
|
activity_pattern = re.search(
|
|
(
|
|
r"(loading|processing|working|installing|downloading|"
|
|
r"compiling|building).*\.{2,}"
|
|
),
|
|
text,
|
|
re.IGNORECASE,
|
|
)
|
|
if activity_pattern:
|
|
# Play a steady beep every 2 seconds for ongoing activity
|
|
if (
|
|
current_time - self.env["commandBuffer"]["lastProgressTime"]
|
|
>= 2.0
|
|
):
|
|
self.play_activity_beep()
|
|
self.env["commandBuffer"]["lastProgressTime"] = current_time
|
|
|
|
def play_progress_tone(self, percentage):
|
|
# Map 0-100% to 400-1200Hz frequency range
|
|
frequency = 400 + (percentage * 8)
|
|
frequency = max(400, min(1200, frequency)) # Clamp to safe range
|
|
self.env["runtime"]["OutputManager"].play_frequence(
|
|
frequency, 0.15, interrupt=False
|
|
)
|
|
|
|
def play_activity_beep(self):
|
|
# Single tone for generic activity
|
|
self.env["runtime"]["OutputManager"].play_frequence(
|
|
800, 0.1, interrupt=False
|
|
)
|
|
|
|
def is_current_line_prompt(self):
|
|
"""Check if the current line looks like a standalone prompt (not command with progress)"""
|
|
import re
|
|
|
|
try:
|
|
# Get the current screen content
|
|
if not self.env["screen"]["new_content_text"]:
|
|
return False
|
|
|
|
lines = self.env["screen"]["new_content_text"].split("\n")
|
|
if not lines:
|
|
return False
|
|
|
|
# Check the last line (most common) and current cursor line for
|
|
# prompt patterns
|
|
lines_to_check = []
|
|
|
|
# Add last line (most common for prompts)
|
|
if lines:
|
|
lines_to_check.append(lines[-1])
|
|
|
|
# Add current cursor line if different from last line
|
|
if (
|
|
self.env["screen"]["new_cursor"]["y"] < len(lines)
|
|
and self.env["screen"]["new_cursor"]["y"] != len(lines) - 1
|
|
):
|
|
lines_to_check.append(
|
|
lines[self.env["screen"]["new_cursor"]["y"]]
|
|
)
|
|
|
|
# Standalone prompt patterns (no commands mixed in)
|
|
standalone_prompt_patterns = [
|
|
r"^\s*\$\s*$", # Just $ (with whitespace)
|
|
r"^\s*#\s*$", # Just # (with whitespace)
|
|
r"^\s*>\s*$", # Just > (with whitespace)
|
|
r"^\[.*\]\s*[\\\$#>]\s*$", # [path]$ without commands
|
|
r"^[a-zA-Z0-9._-]+[\\\$#>]\s*$", # bash-5.1$ without commands
|
|
# Interactive prompt patterns (these ARE standalone)
|
|
r".*\?\s*\[[YyNn]/[YyNn]\]\s*$", # ? [Y/n] or ? [y/N] style
|
|
r".*\?\s*\[[Yy]es/[Nn]o\]\s*$", # ? [Yes/No] style
|
|
# "continue? [Y/n]" style
|
|
r".*continue\?\s*\[[YyNn]/[YyNn]\].*$",
|
|
r"^::.*\?\s*\[[YyNn]/[YyNn]\].*$", # pacman style prompts
|
|
# Authentication prompts (these ARE standalone)
|
|
r"^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$", # [sudo] password
|
|
r"^[Pp]assword\s*:\s*$", # Password:
|
|
r".*[Pp]assword\s*:\s*$", # general password prompts
|
|
# Continuation prompts (these ARE standalone)
|
|
r"^[Pp]ress\s+any\s+key\s+to\s+continue.*$", # Press any key
|
|
r"^[Aa]re\s+you\s+sure\?\s*.*$", # Are you sure?
|
|
]
|
|
|
|
for line in lines_to_check:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Check if this line contains both a prompt AND other content (like commands)
|
|
# If so, don't treat it as a standalone prompt
|
|
has_prompt_marker = bool(
|
|
re.search(r".*@.*[\\\$#>]", line)
|
|
or re.search(r"^\[.*\]\s*[\\\$#>]", line)
|
|
)
|
|
if has_prompt_marker:
|
|
# If line has prompt marker but also has significant content after it,
|
|
# it's a command line, not a standalone prompt
|
|
prompt_end = max(
|
|
line.rfind("$"),
|
|
line.rfind("#"),
|
|
line.rfind(">"),
|
|
line.rfind("\\"),
|
|
)
|
|
if (
|
|
prompt_end >= 0 and prompt_end < len(line) - 5
|
|
): # More than just whitespace after prompt
|
|
continue # This is a command line, not a standalone prompt
|
|
|
|
for pattern in standalone_prompt_patterns:
|
|
try:
|
|
if re.search(pattern, line):
|
|
return True
|
|
except re.error:
|
|
continue
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
# If anything fails, assume it's not a prompt to be safe
|
|
return False
|
|
|
|
def set_callback(self, callback):
|
|
pass
|