Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

accessibility dictation: first round #9

Merged
merged 20 commits into from
Mar 22, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6911f9b
dictation_context: accessibility context for dictation mode
phillco Mar 19, 2022
0cc0edd
dictation_context: naming tweaks
phillco Mar 19, 2022
cabebf4
electron: add enable_electron_accessibility
phillco Mar 19, 2022
2d6010d
dictation_context: gaurd range
phillco Mar 19, 2022
de13292
dictation_context: handle missing selection range
phillco Mar 19, 2022
26c2f1e
dictation_context: return None if both content and range are empty
phillco Mar 20, 2022
cbf2f60
electron: check in support for enabling accessibility in electron
phillco Mar 20, 2022
1e43f0c
dictation_context: wrap peek in try/catch so we will fall back to kna…
phillco Mar 20, 2022
d46ee3c
dictation_context: dictation_current_element() so contexts can choose…
phillco Mar 20, 2022
4f4d460
dictation_context: add override for Messages, to support empty buffer…
phillco Mar 20, 2022
1f72b1a
dictation_context: improve messages printed during an exception
phillco Mar 20, 2022
32ee479
debugging: add hissing to debug accessibility, using the `hiss_to_deb…
phillco Mar 20, 2022
e556088
electron: rename setting_electron_accessibility
phillco Mar 20, 2022
fe830b0
debugging: remove extra rule
phillco Mar 20, 2022
b8e2f7c
dictation_context: improve comment
phillco Mar 20, 2022
af4ce8a
messages: fix action class
phillco Mar 22, 2022
8c0a574
debugging, dictation_context, electron: code review fixes
phillco Mar 22, 2022
2b85bad
dictation_context: shortened docstrings
phillco Mar 22, 2022
02461aa
dictation_context: enum
phillco Mar 22, 2022
2e13484
dictation_context, electron: review comments
phillco Mar 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions dictation/app_overrides/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from talon import Context, Module

ctx = Context()
ctx.matches = """
os: mac
app: messages
"""
mod = Module()

@ctx.action_class("self")
class Actions:

def accessibility_adjust_context_for_application(el, context):
# Messages reports an empty buffer as having None as content instead of "".
# We use None as a signal for "accessibility not available", so make sure it is reported as "".
if context.content is None:
context.content = ""

return context

94 changes: 94 additions & 0 deletions dictation/debugging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import time
import traceback

from talon import Module, actions, cron, noise, ui
from talon.mac.ui import Element

HISS_DEBUG_ENABLED = True

mod = Module()
setting_enabled = mod.setting(
"hiss_to_debug_accessibility",
type=bool,
default=False,
desc="Use a hissing sound to print accessibility debugging information to the Talon log.",
)
setting_threshold = mod.setting(
"hiss_to_debug_accessibility_threshold",
type=float,
default=0.35,
desc="If hiss_to_debug_accessibility is enabled, the hissing duration (in seconds) needed to trigger the debug output.",
)


@mod.action_class
class Actions:

def debug_accessibility(el: Element = None):
"""Prints information about the currently focused UI element to the terminal, for debugging"""

if not el:
el = ui.focused_element()

try:
# TODO(pcohen): make this work without Rich installed
from rich.console import Console
console = Console(color_system="truecolor", soft_wrap=True)

console.rule(f"{str(el)}'s attributes:")

# Attempt to sort the keys by relying on insertion order.
attributed = {}
for k in sorted(el.attrs):
attributed[k] = el.get(k)

console.print(attributed, markup=False)
except Exception as e:
print(f"Exception while debugging accessibility: \"{e}\":")
traceback.print_exc()


active_hiss = {"cron": None}


def hiss_over_threshold():
if not active_hiss.get("start"):
return False

return time.time() - active_hiss["start"] > setting_threshold.get()


def stop_hiss():
trigger = hiss_over_threshold()

if active_hiss["cron"]:
cron.cancel(active_hiss["cron"])
active_hiss["cron"] = None

active_hiss["start"] = None

if trigger:
actions.user.debug_accessibility()


def check_hiss():
if hiss_over_threshold():
stop_hiss()


def start_hiss():
active_hiss["start"] = time.time()
active_hiss["cron"] = cron.interval("32ms", check_hiss)


def on_hiss(noise_active: bool):
if not setting_enabled.get():
return

if noise_active:
start_hiss()
else:
stop_hiss()


noise.register('hiss', on_hiss)
140 changes: 140 additions & 0 deletions dictation/dictation_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import traceback
from dataclasses import dataclass
from enum import Enum
from typing import Optional

from talon import Context, Module, actions, ui
from talon.mac.ui import Element
from talon.types import Span

ctx = Context()
ctx.matches = "os: mac"

mod = Module()
setting_accessibility_dictation = mod.setting(
"accessibility_dictation",
type=bool,
default=False,
desc="Use accessibility APIs to implement context aware dictation.",
)

# Default number of characters to use to acquire context. Somewhat arbitrary.
# The current dictation formatter doesn't need very many, but that could change in the future.
DEFAULT_CONTEXT_CHARACTERS = 30


@dataclass
class AccessibilityContext:
"""Records the context needed for dictation"""
content: str
selection: Span

def left_context(self, num_chars: int = DEFAULT_CONTEXT_CHARACTERS) -> str:
"""Returns `num_chars`' worth of context to the left of the cursor"""
start = max(0, self.selection.left - num_chars)
return self.content[start:self.selection.left]

def right_context(self, num_chars: int = DEFAULT_CONTEXT_CHARACTERS) -> str:
"""Returns `num_chars`' worth of context to the right of the cursor"""
end = min(self.selection.right + num_chars, len(self.content))
return self.content[self.selection.right:end]


@mod.action_class
class ModActions:

def accessibility_dictation_enabled() -> bool:
"""Returns whether accessibility dictation should be used"""
# NB: for access within other files, since they can't import `setting_accessibility_dictation`
return setting_accessibility_dictation.get()

def dictation_current_element() -> Element:
"""Returns the accessibility element that should be used for dictation (i.e. the current input textbox).

This is almost always the focused (current) element, however, this action
exists so that context can overwrite it, for applications with strange behavior.
"""
return ui.focused_element()

def accessibility_adjust_context_for_application(el: Element,
context: AccessibilityContext) -> AccessibilityContext:
"""Hook for applications to override the reported buffer contents/cursor location.

Sometimes the accessibility context reported by the application is wrong, but fixable in predictable ways (this is most common in Electron apps). This method can be overwritten in those applications to do so.

TODO(pcohen): it's a it strange to have both this and dictation_current_element;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything after a ":" gets stripped in actions.list() output (empirically) so consider moving into a comment.

possibly refactor.
"""
return context

def accessibility_create_dictation_context(el: Element) -> Optional[AccessibilityContext]:
"""Creates a `AccessibilityContext` representing the state of the input buffer for dictation mode
"""
if not actions.user.accessibility_dictation_enabled():
return None

if not el or not el.attrs:
# No accessibility support.
return None

# NOTE(pcohen): In Microsoft apps (Word, OneNote), selection will be none when the cursor
# is that the start of the input buffer.
# TODO(pcohen): this should probably be an app-specific `accessibility_adjust_context_for_application`
selection = el.get("AXSelectedTextRange")
if selection is None:
selection = Span(0, 0)

context = AccessibilityContext(content=el.get("AXValue"), selection=selection)

# Support application-specific overrides:
context = actions.user.accessibility_adjust_context_for_application(el, context)

# If we don't appear to have any accessibility information, don't use it.
if context.content is None or context.selection is None:
return None

return context


# TODO(pcohen): relocate this
class Colors(Enum):
RESET = '\033[0m'
RED = '\033[31m'
YELLOW = '\033[33m'

@ctx.action_class("self")
class Actions:
"""Wires this into the knausj dictation formatter"""

def dictation_peek_left(clobber=False):
try:
el = actions.user.dictation_current_element()
context = actions.user.accessibility_create_dictation_context(el)
if context is None:
print(f"{Colors.YELLOW.value}Accessibility not available for context-aware dictation{Colors.RESET.value}; falling back to cursor method")
return actions.next()

return context.left_context()
except Exception as e:
print(f"{Colors.RED.value}{type(e).__name__} while querying accessibility for context-aware dictation:{Colors.RESET.value} '{e}':")
traceback.print_exc()

# Fallback to the original (keystrokes) knausj method.
actions.next()

def dictation_peek_right():
try:
el = actions.user.dictation_current_element()
context = actions.user.accessibility_create_dictation_context(el)
if context is None:
print(
f"{Colors.YELLOW.value}Accessibility not available for context-aware dictation{Colors.RESET.value}; falling back to cursor method")
return actions.next()

return context.right_context()
except Exception as e:
print(f"{Colors.RED.value}{type(e).__name__} while querying accessibility for context-aware dictation:{Colors.RESET.value} '{e}':")
traceback.print_exc()

# Fallback to the original (keystrokes) knausj method.
actions.next()
36 changes: 36 additions & 0 deletions dictation/electron.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional

from talon import Context, Module, actions, ui
from talon.mac.ui import App
from talon.ui import UIErr

ctx = Context()
ctx.matches = "os: mac"

mod = Module()
setting_electron_accessibility = mod.setting(
"enable_electron_accessibility",
type=bool,
default=False,
desc="Tells Electron apps to enable their accessibility trees, so that you can use accessibility dictation with them. Note that this could cause worse performance, depending on the app.",
)

@mod.action_class
class ModActions:
def enable_electron_accessibility(app: Optional[App] = None):
"""Enables AX support in Electron - may affect performance"""
if not app:
app = ui.active_app()

try:
app.element.AXManualAccessibility = True
except UIErr as e:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can just use except UIErr: here.

# this will raise "Error setting element attribute" even on success.
pass


def app_activate(app):
if setting_electron_accessibility.get():
actions.user.enable_electron_accessibility(app)

ui.register("app_activate", app_activate)