From 0930a86ce7524f0219040962507578d7f8d1d9c3 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 6 Jun 2025 22:58:08 -0400 Subject: [PATCH 1/7] --ignore-screen (-i) flag added. --- README.md | 5 +++++ docs/fenrir.1 | 4 ++++ docs/fenrir.adoc | 3 +++ docs/user.md | 1 + src/fenrir | 6 ++++++ src/fenrirscreenreader/core/settingsManager.py | 8 ++++++++ 6 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 7a809d23..da37b5ee 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,7 @@ fenrir [OPTIONS] - `-e, --emulated-pty` - Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage) - `-E, --emulated-evdev` - Use PTY emulation with evdev for input (single instance) - `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignoreScreen setting +- `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s). Can be used multiple times. Combines with existing ignore settings. ### Examples: ```bash @@ -525,6 +526,10 @@ sudo fenrir -o "speech#rate=0.8;sound#volume=0.5" # Force Fenrir to work on all screens (ignore ignoreScreen setting) sudo fenrir -F + +# Ignore specific screens +sudo fenrir --ignore-screen 1 +sudo fenrir -i 1 -i 2 # Ignore screens 1 and 2 ``` ## Localization diff --git a/docs/fenrir.1 b/docs/fenrir.1 index c6347dd0..3d50c0fb 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -65,6 +65,10 @@ Use PTY emulation with evdev for input (single instance mode). .BR \-F ", " \-\-force-all-screens Force Fenrir to respond on all screens, ignoring the ignoreScreen setting. This temporarily overrides screen filtering for the current session. +.TP +.BR \-i ", " \-I ", " \-\-ignore-screen " \fISCREEN\fR" +Ignore specific screen(s). Can be used multiple times to ignore multiple screens. This is equivalent to setting ignoreScreen in the configuration file and will be combined with any existing ignore settings. + .SH KEY CONCEPTS .SS Fenrir Key diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index 80a419da..1f2a5b4f 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -1240,6 +1240,9 @@ Use PTY emulation with evdev for input (single instance mode). `+-F, --force-all-screens+`:: Force Fenrir to respond on all screens, ignoring the ignoreScreen setting. This temporarily overrides screen filtering for the current session. +`+-i, -I, --ignore-screen +`:: +Ignore specific screen(s). Can be used multiple times to ignore multiple screens. This is equivalent to setting ignoreScreen in the configuration file and will be combined with any existing ignore settings. + ==== Set settings options You can specify options that overwrite the setting.conf. This is done diff --git a/docs/user.md b/docs/user.md index e1e9a04d..22af87ad 100644 --- a/docs/user.md +++ b/docs/user.md @@ -297,6 +297,7 @@ fenrir [OPTIONS] - `-e, --emulated-pty` - PTY emulation for desktop use - `-E, --emulated-evdev` - PTY + evdev emulation - `-F, --force-all-screens` - Ignore ignoreScreen setting +- `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s), can be used multiple times ## Troubleshooting diff --git a/src/fenrir b/src/fenrir index 02fb6347..8f91aba1 100755 --- a/src/fenrir +++ b/src/fenrir @@ -72,6 +72,12 @@ def create_argument_parser(): action='store_true', help='Force Fenrir to respond on all screens, ignoring ignoreScreen setting' ) + argumentParser.add_argument( + '-i', '-I', '--ignore-screen', + metavar='SCREEN', + action='append', + help='Ignore specific screen(s). Can be used multiple times. Same as ignoreScreen setting.' + ) return argumentParser def validate_arguments(cliArgs): diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 83227365..897e2d12 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -320,6 +320,14 @@ class settingsManager(): if cliArgs.force_all_screens: environment['runtime']['force_all_screens'] = True + + if cliArgs.ignore_screen: + currentIgnoreScreen = self.getSetting('screen', 'ignoreScreen') + if currentIgnoreScreen: + ignoreScreens = currentIgnoreScreen.split(',') + cliArgs.ignore_screen + else: + ignoreScreens = cliArgs.ignore_screen + self.setSetting('screen', 'ignoreScreen', ','.join(ignoreScreens)) if not os.path.exists(self.getSetting('sound','theme') + '/soundicons.conf'): if os.path.exists(soundRoot + self.getSetting('sound','theme')): From 97e2da614bdd198fc705caa754d12835d06cf798 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 00:52:13 -0400 Subject: [PATCH 2/7] 2 new features, silence speech until prompt returns and progress bar beeps. --- config/keyboard/desktop.conf | 3 +- config/keyboard/nvda-desktop.conf | 3 +- config/settings/settings.conf | 3 + .../commands/commands/last_incoming.py | 23 --- .../commands/commands/progress_bar_monitor.py | 121 +++++++++++++ .../commands/commands/silence_until_prompt.py | 69 +++++++ .../onScreenUpdate/65000-progress_detector.py | 168 ++++++++++++++++++ .../onScreenUpdate/66000-prompt_detector.py | 64 +++++++ src/fenrirscreenreader/fenrirVersion.py | 2 +- 9 files changed, 430 insertions(+), 26 deletions(-) delete mode 100644 src/fenrirscreenreader/commands/commands/last_incoming.py create mode 100644 src/fenrirscreenreader/commands/commands/progress_bar_monitor.py create mode 100644 src/fenrirscreenreader/commands/commands/silence_until_prompt.py create mode 100644 src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py create mode 100644 src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 561c4754..29b3eb02 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -73,7 +73,8 @@ KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10 KEY_FENRIR,KEY_0=bookmark_10 KEY_FENRIR,KEY_KPSLASH=set_window_application 2,KEY_FENRIR,KEY_KPSLASH=clear_window_application -KEY_KPPLUS=last_incoming +KEY_KPPLUS=progress_bar_monitor +KEY_FENRIR,KEY_KPPLUS=silence_until_prompt KEY_FENRIR,KEY_F2=toggle_braille KEY_FENRIR,KEY_F3=toggle_sound KEY_FENRIR,KEY_F4=toggle_speech diff --git a/config/keyboard/nvda-desktop.conf b/config/keyboard/nvda-desktop.conf index 6c98c440..8c9ab1d9 100644 --- a/config/keyboard/nvda-desktop.conf +++ b/config/keyboard/nvda-desktop.conf @@ -72,7 +72,8 @@ KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10 KEY_FENRIR,KEY_0=bookmark_10 KEY_FENRIR,KEY_KPSLASH=set_window_application 2,KEY_FENRIR,KEY_KPSLASH=clear_window_application -KEY_KPPLUS=last_incoming +KEY_KPPLUS=progress_bar_monitor +KEY_FENRIR,KEY_KPPLUS=silence_until_prompt #=toggle_braille KEY_FENRIR,KEY_F3=toggle_sound KEY_FENRIR,KEY_F4=toggle_speech diff --git a/config/settings/settings.conf b/config/settings/settings.conf index d65bdf3a..81439351 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -28,6 +28,9 @@ genericPlayFileCommand=play -q -v fenrirVolume fenrirSoundFile #the following command is used to generate a frequency beep genericFrequencyCommand=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence +# Enable progress bar monitoring with ascending tones by default +progressMonitoring=False + [speech] # Turn speech on or off: enabled=True diff --git a/src/fenrirscreenreader/commands/commands/last_incoming.py b/src/fenrirscreenreader/commands/commands/last_incoming.py deleted file mode 100644 index 75cef645..00000000 --- a/src/fenrirscreenreader/commands/commands/last_incoming.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributers. - -from fenrirscreenreader.core import debug - -class command(): - def __init__(self): - pass - def initialize(self, environment): - self.env = environment - def shutdown(self): - pass - def getDescription(self): - return _('Presents the text which was last received') - - def run(self): - self.env['runtime']['outputManager'].presentText(self.env['screen']['newDelta'], interrupt=True) - - def setCallback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py b/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py new file mode 100644 index 00000000..083e4b6e --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug +import re +import time +import threading + +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: + defaultEnabled = self.env['runtime']['settingsManager'].getSettingAsBool('sound', 'progressMonitoring') + except: + # If setting doesn't exist, default to False + defaultEnabled = False + self.env['commandBuffer']['progressMonitoring'] = defaultEnabled + self.env['commandBuffer']['lastProgressTime'] = 0 + self.env['commandBuffer']['lastProgressValue'] = -1 + + def shutdown(self): + self.stopProgressMonitoring() + + def getDescription(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.stopProgressMonitoring() + self.env['runtime']['outputManager'].presentText(_("Progress monitoring disabled"), interrupt=True) + else: + self.startProgressMonitoring() + self.env['runtime']['outputManager'].presentText(_("Progress monitoring enabled"), interrupt=True) + + def startProgressMonitoring(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 stopProgressMonitoring(self): + self.env['commandBuffer']['progressMonitoring'] = False + # Don't control speech - progress monitor is beep-only + + def detectProgress(self, text): + if not self.env['runtime']['progressMonitoring']: + return + + currentTime = time.time() + + # Pattern 1: Percentage (50%, 25.5%, etc.) + percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) + if percentMatch: + percentage = float(percentMatch.group(1)) + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 2: Fraction (15/100, 3 of 10, etc.) + fractionMatch = re.search(r'(\d+)\s*(?:of|/)\s*(\d+)', text) + if fractionMatch: + current = int(fractionMatch.group(1)) + total = int(fractionMatch.group(2)) + if total > 0: + percentage = (current / total) * 100 + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 3: Progress bars ([#### ], [====> ], etc.) + barMatch = re.search(r'\[([#=\-\*]+)([^\]]*)\]', text) + if barMatch: + filled = len(barMatch.group(1)) + total = filled + len(barMatch.group(2)) + if total > 0: + percentage = (filled / total) * 100 + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) + activityPattern = re.search(r'(loading|processing|working|installing|downloading|compiling|building).*\.{2,}', text, re.IGNORECASE) + if activityPattern: + # Play a steady beep every 2 seconds for ongoing activity + if currentTime - self.env['runtime']['lastProgressTime'] >= 2.0: + self.playActivityBeep() + self.env['runtime']['lastProgressTime'] = currentTime + + def playProgressTone(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'].playFrequence(frequency, 0.15, interrupt=False) + + def playActivityBeep(self): + # Single tone for generic activity + self.env['runtime']['outputManager'].playFrequence(800, 0.1, interrupt=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py new file mode 100644 index 00000000..ebcf6d56 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug +import re + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + # Use commandBuffer like other commands + if 'silenceUntilPrompt' not in self.env['commandBuffer']: + self.env['commandBuffer']['silenceUntilPrompt'] = False + + def shutdown(self): + pass + + def getDescription(self): + return _('Toggle speech silence until shell prompt returns') + + def run(self): + if self.env['commandBuffer']['silenceUntilPrompt']: + self.disableSilence() + else: + self.enableSilence() + + def enableSilence(self): + self.env['commandBuffer']['silenceUntilPrompt'] = True + self.env['runtime']['outputManager'].presentText(_("Speech silenced until prompt returns"), soundIcon='SpeechOff', interrupt=True) + # Disable speech but don't use the normal temp disable that reactivates on keypress + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'False') + + def disableSilence(self): + self.env['commandBuffer']['silenceUntilPrompt'] = False + # Re-enable speech + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') + self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) + + def checkForPrompt(self, text): + """Check if the current line contains a shell prompt pattern""" + if not self.env['commandBuffer']['silenceUntilPrompt']: + return False + + # Look for common shell prompt patterns + # $ prompt (user) + # # prompt (root) + # > prompt (some shells) + # Also check for common prompt prefixes like user@host:path$ + promptPatterns = [ + r'[^$]*\$$', # Ends with $ (user prompt) + r'[^#]*#$', # Ends with # (root prompt) + r'[^>]*>$', # Ends with > (some shells) + r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style + ] + + for pattern in promptPatterns: + if re.search(pattern, text.strip()): + self.disableSilence() + return True + + return False + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py new file mode 100644 index 00000000..ea32fa36 --- /dev/null +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return 'Detects progress patterns for progress bar monitoring' + + def run(self): + # Only run if progress monitoring is enabled + try: + if 'progressMonitoring' in self.env['commandBuffer'] and self.env['commandBuffer']['progressMonitoring']: + # Only check new incoming text (newDelta), but filter out screen changes + if self.env['screen']['newDelta'] and self.isRealProgressUpdate(): + self.detectProgress(self.env['screen']['newDelta']) + except Exception as e: + # Silently ignore errors to avoid disrupting normal operation + pass + + def isRealProgressUpdate(self): + """Check if this is a real progress update vs screen change/window switch""" + # If the screen/application changed, it's not a progress update + if self.env['runtime']['screenManager'].isScreenChange(): + return False + + # If there was a large cursor movement, it's likely navigation, not progress + if self.env['runtime']['cursorManager'].isCursorVerticalMove(): + xMove = abs(self.env['screen']['newCursor']['x'] - self.env['screen']['oldCursor']['x']) + yMove = abs(self.env['screen']['newCursor']['y'] - self.env['screen']['oldCursor']['y']) + # Large movements suggest navigation, not progress output + if yMove > 2 or xMove > 20: + return False + + # Check if delta is too large (screen change) vs small incremental updates + deltaLength = len(self.env['screen']['newDelta']) + if deltaLength > 200: # Allow longer progress lines like Claude Code's status + return False + + return True + + def detectProgress(self, text): + import re + import time + + currentTime = time.time() + + # Debug: Print what we're checking + self.env['runtime']['debug'].writeDebugOut("Progress detector checking: '" + text + "'", debug.debugLevel.INFO) + + # Check if progress monitoring should automatically stop + self.checkProgressCompletion(text, currentTime) + + # Pattern 1: Percentage (50%, 25.5%, etc.) + percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) + if percentMatch: + percentage = float(percentMatch.group(1)) + self.env['runtime']['debug'].writeDebugOut("Found percentage: " + str(percentage), debug.debugLevel.INFO) + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.env['runtime']['debug'].writeDebugOut("Playing tone for: " + str(percentage), debug.debugLevel.INFO) + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 1b: Time/token activity (not percentage-based, so use single beep) + timeMatch = re.search(r'(\d+)s\s', text) + tokenMatch = re.search(r'(\d+)\s+tokens', text) + + if timeMatch or tokenMatch: + # For non-percentage progress, use a single activity beep every 2 seconds + if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: + self.env['runtime']['debug'].writeDebugOut("Playing activity beep for Claude Code progress", debug.debugLevel.INFO) + self.playActivityBeep() + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 2: Fraction (15/100, 3 of 10, etc.) + fractionMatch = re.search(r'(\d+)\s*(?:of|/)\s*(\d+)', text) + if fractionMatch: + current = int(fractionMatch.group(1)) + total = int(fractionMatch.group(2)) + if total > 0: + percentage = (current / total) * 100 + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 3: Progress bars ([#### ], [====> ], etc.) + barMatch = re.search(r'\[([#=\-\*]+)([^\]]*)\]', text) + if barMatch: + filled = len(barMatch.group(1)) + total = filled + len(barMatch.group(2)) + if total > 0: + percentage = (filled / total) * 100 + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) + activityPattern = re.search(r'(loading|processing|working|installing|downloading|compiling|building).*\.{2,}', text, re.IGNORECASE) + if activityPattern: + # Play a steady beep every 2 seconds for ongoing activity + if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: + self.playActivityBeep() + self.env['commandBuffer']['lastProgressTime'] = currentTime + + def playProgressTone(self, percentage): + # Map 0-100% to 400-1200Hz frequency range + frequency = 400 + (percentage * 8) + frequency = max(400, min(1200, frequency)) # Clamp to safe range + + # Use Sox directly for clean quiet tones like: play -qn synth .1 tri 400 gain -8 + self.playQuietTone(frequency, 0.1) + + def playActivityBeep(self): + # Single tone for generic activity + self.playQuietTone(800, 0.08) + + def playQuietTone(self, frequency, duration): + """Play a quiet tone using Sox directly""" + import subprocess + import shlex + + # Build the Sox command: play -qn synth tri gain -8 + command = f"play -qn synth {duration} tri {frequency} gain -8" + + try: + # Only play if sound is enabled + if self.env['runtime']['settingsManager'].getSettingAsBool('sound', 'enabled'): + subprocess.Popen(shlex.split(command), stdin=None, stdout=None, stderr=None, shell=False) + except Exception as e: + self.env['runtime']['debug'].writeDebugOut("Sox tone error: " + str(e), debug.debugLevel.ERROR) + + def checkProgressCompletion(self, text, currentTime): + """Check if progress is complete and should auto-disable monitoring""" + # Progress monitor is now beep-only - user controls speech separately + # Only auto-disable on clear 100% completion for convenience + import re + + if re.search(r'100\s*%', text): + self.env['runtime']['debug'].writeDebugOut("Progress complete: 100%", debug.debugLevel.INFO) + self.stopProgressMonitoring() + return + + def stopProgressMonitoring(self): + """Stop progress monitoring - beep-only, no speech control""" + self.env['commandBuffer']['progressMonitoring'] = False + # Just disable monitoring, don't touch speech settings + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py new file mode 100644 index 00000000..5ccc083a --- /dev/null +++ b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return 'Detects shell prompts for silence until prompt feature' + + def run(self): + # Only run if silence until prompt is active + try: + if 'silenceUntilPrompt' in self.env['commandBuffer'] and self.env['commandBuffer']['silenceUntilPrompt']: + # Check the current line for prompt patterns + if self.env['screen']['newContentText']: + lines = self.env['screen']['newContentText'].split('\n') + if lines and self.env['screen']['newCursor']['y'] < len(lines): + currentLine = lines[self.env['screen']['newCursor']['y']] + self.checkForPrompt(currentLine) + except Exception as e: + # Silently ignore errors to avoid disrupting normal operation + pass + + def checkForPrompt(self, text): + """Check if the current line contains a shell prompt pattern""" + import re + + # Debug: Print what we're checking + self.env['runtime']['debug'].writeDebugOut("Prompt detector checking: '" + text + "'", debug.debugLevel.INFO) + + # Look for common shell prompt patterns + promptPatterns = [ + r'[^$]*\$$', # Ends with $ (user prompt) + r'[^#]*#$', # Ends with # (root prompt) + r'[^>]*>$', # Ends with > (some shells) + r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style + ] + + for pattern in promptPatterns: + if re.search(pattern, text.strip()): + self.env['runtime']['debug'].writeDebugOut("Found prompt pattern: " + pattern, debug.debugLevel.INFO) + # Disable silence mode + self.env['commandBuffer']['silenceUntilPrompt'] = False + # Re-enable speech + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') + self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) + return True + + return False + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 01b1fbc0..333f665a 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributers. -version = "2025.06.06" +version = "2025.06.07" codeName = "testing" From 26c6e32c5972e9a679708552d2108e2df10ad74b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 01:26:21 -0400 Subject: [PATCH 3/7] Add the ability to add custom prompts to the settings file, generic prompts are covered with the existing detection code. --- config/settings/settings.conf | 16 +++++ .../commands/commands/silence_until_prompt.py | 58 ++++++++++++---- .../onScreenUpdate/66000-prompt_detector.py | 67 ++++++++++++++----- 3 files changed, 112 insertions(+), 29 deletions(-) diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 81439351..0c00682c 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -214,6 +214,22 @@ list= vmenuPath= quickMenu=speech#rate;speech#pitch;speech#volume +[prompt] +# Custom prompt patterns for silence until prompt feature +# You can add your own shell prompt patterns as regular expressions +# Each pattern should be on a separate line, format: customPatterns=pattern1,pattern2,pattern3 +# Examples: +# For PS1='[\u@\h \W] \$ ' use: \[.*@.*\s.*\]\s*[$#>]\s* +# For "[storm@fenrir ~] $" use: \[.*@.*\s.*\]\s*[$#>]\s* +# For custom prompts ending with specific strings, use patterns like: .*your_prompt_ending$ +customPatterns= + +# Specific prompt strings to match exactly (useful for very specific custom prompts) +# Format: exactMatches=prompt1,prompt2,prompt3 +# Examples: +# exactMatches=[storm@fenrir ~] $,[root@fenrir ~] # +exactMatches= + [time] # automatic time anouncement enabled=False diff --git a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py index ebcf6d56..4c9008cf 100644 --- a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py +++ b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py @@ -46,22 +46,52 @@ class command(): if not self.env['commandBuffer']['silenceUntilPrompt']: return False - # Look for common shell prompt patterns - # $ prompt (user) - # # prompt (root) - # > prompt (some shells) - # Also check for common prompt prefixes like user@host:path$ - promptPatterns = [ - r'[^$]*\$$', # Ends with $ (user prompt) - r'[^#]*#$', # Ends with # (root prompt) - r'[^>]*>$', # Ends with > (some shells) - r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style - ] + # First check for exact matches from settings (with backward compatibility) + try: + exactMatches = self.env['runtime']['settingsManager'].getSetting('prompt', 'exactMatches') + if exactMatches: + exactList = [match.strip() for match in exactMatches.split(',') if match.strip()] + for exactMatch in exactList: + if text.strip() == exactMatch: + self.env['runtime']['debug'].writeDebugOut("Found exact prompt match: " + exactMatch, debug.debugLevel.INFO) + self.disableSilence() + return True + except: + # Prompt section doesn't exist in settings, skip custom exact matches + pass + + # Get custom patterns from settings (with backward compatibility) + promptPatterns = [] + try: + customPatterns = self.env['runtime']['settingsManager'].getSetting('prompt', 'customPatterns') + # Add custom patterns from settings if they exist + if customPatterns: + customList = [pattern.strip() for pattern in customPatterns.split(',') if pattern.strip()] + promptPatterns.extend(customList) + except: + # Prompt section doesn't exist in settings, skip custom patterns + pass + + # Add default shell prompt patterns + promptPatterns.extend([ + r'^\s*\\\$\s*$', # Just $ (with whitespace) + r'^\s*#\s*$', # Just # (with whitespace) + r'^\s*>\s*$', # Just > (with whitespace) + r'.*@.*[\\\$#>]\s*$', # Contains @ and ends with prompt char (user@host style) + r'^\[.*\]\s*[\\\$#>]\s*$', # [anything]$ style prompts + r'^[a-zA-Z0-9._-]+[\\\$#>]\s*$', # Simple shell names like bash-5.1$ + ]) for pattern in promptPatterns: - if re.search(pattern, text.strip()): - self.disableSilence() - return True + try: + if re.search(pattern, text.strip()): + self.env['runtime']['debug'].writeDebugOut("Found prompt pattern: " + pattern, debug.debugLevel.INFO) + self.disableSilence() + return True + except re.error as e: + # Invalid regex pattern, skip it and log the error + self.env['runtime']['debug'].writeDebugOut("Invalid prompt pattern: " + pattern + " Error: " + str(e), debug.debugLevel.ERROR) + continue return False diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py index 5ccc083a..d172ba8a 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py @@ -40,25 +40,62 @@ class command(): # Debug: Print what we're checking self.env['runtime']['debug'].writeDebugOut("Prompt detector checking: '" + text + "'", debug.debugLevel.INFO) - # Look for common shell prompt patterns - promptPatterns = [ - r'[^$]*\$$', # Ends with $ (user prompt) - r'[^#]*#$', # Ends with # (root prompt) - r'[^>]*>$', # Ends with > (some shells) - r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style - ] + # First check for exact matches from settings (with backward compatibility) + try: + exactMatches = self.env['runtime']['settingsManager'].getSetting('prompt', 'exactMatches') + if exactMatches: + exactList = [match.strip() for match in exactMatches.split(',') if match.strip()] + for exactMatch in exactList: + if text.strip() == exactMatch: + self.env['runtime']['debug'].writeDebugOut("Found exact prompt match: " + exactMatch, debug.debugLevel.INFO) + self._restoreSpeech() + return True + except: + # Prompt section doesn't exist in settings, skip custom exact matches + pass + + # Get custom patterns from settings (with backward compatibility) + promptPatterns = [] + try: + customPatterns = self.env['runtime']['settingsManager'].getSetting('prompt', 'customPatterns') + # Add custom patterns from settings if they exist + if customPatterns: + customList = [pattern.strip() for pattern in customPatterns.split(',') if pattern.strip()] + promptPatterns.extend(customList) + except: + # Prompt section doesn't exist in settings, skip custom patterns + pass + + # Add default shell prompt patterns + promptPatterns.extend([ + r'^\s*\\\$\s*$', # Just $ (with whitespace) + r'^\s*#\s*$', # Just # (with whitespace) + r'^\s*>\s*$', # Just > (with whitespace) + r'.*@.*[\\\$#>]\s*$', # Contains @ and ends with prompt char (user@host style) + r'^\[.*\]\s*[\\\$#>]\s*$', # [anything]$ style prompts + r'^[a-zA-Z0-9._-]+[\\\$#>]\s*$', # Simple shell names like bash-5.1$ + ]) for pattern in promptPatterns: - if re.search(pattern, text.strip()): - self.env['runtime']['debug'].writeDebugOut("Found prompt pattern: " + pattern, debug.debugLevel.INFO) - # Disable silence mode - self.env['commandBuffer']['silenceUntilPrompt'] = False - # Re-enable speech - self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') - self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) - return True + try: + if re.search(pattern, text.strip()): + self.env['runtime']['debug'].writeDebugOut("Found prompt pattern: " + pattern, debug.debugLevel.INFO) + self._restoreSpeech() + return True + except re.error as e: + # Invalid regex pattern, skip it and log the error + self.env['runtime']['debug'].writeDebugOut("Invalid prompt pattern: " + pattern + " Error: " + str(e), debug.debugLevel.ERROR) + continue return False + + def _restoreSpeech(self): + """Helper method to restore speech when prompt is detected""" + # Disable silence mode + self.env['commandBuffer']['silenceUntilPrompt'] = False + # Re-enable speech + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') + self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) def setCallback(self, callback): pass \ No newline at end of file From 998c63cc719b4ab1184fa80551a29c22fe0a93fa Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 01:48:58 -0400 Subject: [PATCH 4/7] Fixed a few typos in settings file, changed progressbars to true by default. --- config/settings/settings.conf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 0c00682c..d6f74425 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -29,7 +29,7 @@ genericPlayFileCommand=play -q -v fenrirVolume fenrirSoundFile genericFrequencyCommand=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence # Enable progress bar monitoring with ascending tones by default -progressMonitoring=False +progressMonitoring=True [speech] # Turn speech on or off: @@ -166,7 +166,7 @@ autoPresentIndent=False # 1 = sound only # 2 = speak only autoPresentIndentMode=1 -# play a sound when attributes are changeing +# play a sound when attributes change hasAttributes=True # shell for PTY emulatiun (empty = default shell) shell= @@ -220,7 +220,7 @@ quickMenu=speech#rate;speech#pitch;speech#volume # Each pattern should be on a separate line, format: customPatterns=pattern1,pattern2,pattern3 # Examples: # For PS1='[\u@\h \W] \$ ' use: \[.*@.*\s.*\]\s*[$#>]\s* -# For "[storm@fenrir ~] $" use: \[.*@.*\s.*\]\s*[$#>]\s* +# For "[user@hostname ~] $" use: \[.*@.*\s.*\]\s*[$#>]\s* # For custom prompts ending with specific strings, use patterns like: .*your_prompt_ending$ customPatterns= @@ -231,7 +231,7 @@ customPatterns= exactMatches= [time] -# automatic time anouncement +# automatic time announcement enabled=False # present time presentTime=True From ec6c1355811ee867cb9fa792e524ad6ccff7a23a Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 10:20:09 -0400 Subject: [PATCH 5/7] Keybinding for silence until prompt returns added to laptop.conf. Fenrir+Shift+Enter --- config/keyboard/laptop.conf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index 0493eae4..54180a27 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -75,9 +75,10 @@ KEY_FENRIR,KEY_F2=toggle_braille KEY_FENRIR,KEY_F3=toggle_sound KEY_FENRIR,KEY_F4=toggle_speech KEY_FENRIR,KEY_ENTER=temp_disable_speech +KEY_FENRIR,KEY_SHIFT,KEY_ENTER=silence_until_prompt KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check -KEY_FENRIR,KEY_SHIFT,KEY_ENTER=toggle_output +KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons KEY_FENRIR,KEY_ENTER=toggle_auto_read KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time From 7e87ebf04bffe25a5925e8e5d91101c2124b61d2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 10:51:02 -0400 Subject: [PATCH 6/7] Attempt to improve how Fenrir reads multiple characters in a row, e.g. [------]. --- src/fenrirscreenreader/core/textManager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fenrirscreenreader/core/textManager.py b/src/fenrirscreenreader/core/textManager.py index f91a8d51..827c6391 100644 --- a/src/fenrirscreenreader/core/textManager.py +++ b/src/fenrirscreenreader/core/textManager.py @@ -38,7 +38,7 @@ class textManager(): if name[0] == name[1]: newText += ' ' + str(numberOfChars) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[0], True) + ' ' else: - newText += ' ' + str(int(numberOfChars / 2)) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name, True) + ' ' + newText += ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[0], True) + ' ' + str(int(numberOfChars / 2)) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[1], True) + ' ' lastPos = span[1] if lastPos != 0: newText += ' ' @@ -46,7 +46,7 @@ class textManager(): lastPos = 0 for match in self.regExSingle.finditer(newText): span = match.span() - result += text[lastPos:span[0]] + result += newText[lastPos:span[0]] numberOfChars = len(newText[span[0]:span[1]]) name = newText[span[0]:span[1]][:2] if not self.env['runtime']['punctuationManager'].isPuctuation(name[0]): @@ -55,7 +55,7 @@ class textManager(): if name[0] == name[1]: result += ' ' + str(numberOfChars) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[0], True) + ' ' else: - result += ' ' + str(int(numberOfChars / 2)) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name, True) + ' ' + result += ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[0], True) + ' ' + str(int(numberOfChars / 2)) + ' ' + self.env['runtime']['punctuationManager'].proceedPunctuation(name[1], True) + ' ' lastPos = span[1] if lastPos != 0: result += ' ' From 27c35939b152221ce19126f4c1e6d875eacdc734 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 11:13:15 -0400 Subject: [PATCH 7/7] A few minor tweaks to progressbar beeps. --- .../onScreenUpdate/65000-progress_detector.py | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py index ea32fa36..270d3915 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -60,29 +60,32 @@ class command(): # Debug: Print what we're checking self.env['runtime']['debug'].writeDebugOut("Progress detector checking: '" + text + "'", debug.debugLevel.INFO) - # Check if progress monitoring should automatically stop - self.checkProgressCompletion(text, currentTime) + # Note: Auto-disable on 100% completion removed to respect user settings # Pattern 1: Percentage (50%, 25.5%, etc.) percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) if percentMatch: percentage = float(percentMatch.group(1)) - self.env['runtime']['debug'].writeDebugOut("Found percentage: " + str(percentage), debug.debugLevel.INFO) - if percentage != self.env['commandBuffer']['lastProgressValue']: - self.env['runtime']['debug'].writeDebugOut("Playing tone for: " + str(percentage), debug.debugLevel.INFO) - self.playProgressTone(percentage) - self.env['commandBuffer']['lastProgressValue'] = percentage - self.env['commandBuffer']['lastProgressTime'] = currentTime - return + # Only trigger on realistic progress percentages (0-100%) + if 0 <= percentage <= 100: + self.env['runtime']['debug'].writeDebugOut("Found percentage: " + str(percentage), debug.debugLevel.INFO) + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.env['runtime']['debug'].writeDebugOut("Playing tone for: " + str(percentage), debug.debugLevel.INFO) + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return # Pattern 1b: Time/token activity (not percentage-based, so use single beep) timeMatch = re.search(r'(\d+)s\s', text) tokenMatch = re.search(r'(\d+)\s+tokens', text) + # Pattern 1c: Curl-style transfer data (bytes, speed indicators) + curlMatch = re.search(r'(\d+\s+\d+\s+\d+\s+\d+.*?(?:k|M|G)?.*?--:--:--|Speed)', text) - if timeMatch or tokenMatch: + if timeMatch or tokenMatch or curlMatch: # For non-percentage progress, use a single activity beep every 2 seconds if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: - self.env['runtime']['debug'].writeDebugOut("Playing activity beep for Claude Code progress", debug.debugLevel.INFO) + self.env['runtime']['debug'].writeDebugOut("Playing activity beep for transfer progress", debug.debugLevel.INFO) self.playActivityBeep() self.env['commandBuffer']['lastProgressTime'] = currentTime return @@ -148,21 +151,6 @@ class command(): except Exception as e: self.env['runtime']['debug'].writeDebugOut("Sox tone error: " + str(e), debug.debugLevel.ERROR) - def checkProgressCompletion(self, text, currentTime): - """Check if progress is complete and should auto-disable monitoring""" - # Progress monitor is now beep-only - user controls speech separately - # Only auto-disable on clear 100% completion for convenience - import re - - if re.search(r'100\s*%', text): - self.env['runtime']['debug'].writeDebugOut("Progress complete: 100%", debug.debugLevel.INFO) - self.stopProgressMonitoring() - return - - def stopProgressMonitoring(self): - """Stop progress monitoring - beep-only, no speech control""" - self.env['commandBuffer']['progressMonitoring'] = False - # Just disable monitoring, don't touch speech settings def setCallback(self, callback): pass \ No newline at end of file