Fix stuff, apply ruff rules.

This commit is contained in:
2026-04-11 20:55:30 -04:00
parent b38d10575e
commit 09c03ad06a
26 changed files with 1466 additions and 30 deletions
+175 -6
View File
@@ -64,7 +64,9 @@ from .cthulhu_platform import tablesdir
_logger = None _logger = None
log = None log = None
_monitor = None _monitor = None
_monitorCallback = None
_settingsManager = None _settingsManager = None
_enableComputerBrailleAtCursor = True
def _ensureLogger(): def _ensureLogger():
"""Ensure logger is initialized.""" """Ensure logger is initialized."""
@@ -99,6 +101,30 @@ else:
tokens = ["BRAILLE: brlapi imported", brlapi] tokens = ["BRAILLE: brlapi imported", brlapi]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
def _brlapi_command(name):
if not _brlAPIAvailable:
return None
return getattr(brlapi, name, None)
BRLAPI_KEY_CMD_HWINLT = _brlapi_command("KEY_CMD_HWINLT")
BRLAPI_KEY_CMD_FWINLT = _brlapi_command("KEY_CMD_FWINLT")
BRLAPI_KEY_CMD_FWINLTSKIP = _brlapi_command("KEY_CMD_FWINLTSKIP")
BRLAPI_KEY_CMD_HWINRT = _brlapi_command("KEY_CMD_HWINRT")
BRLAPI_KEY_CMD_FWINRT = _brlapi_command("KEY_CMD_FWINRT")
BRLAPI_KEY_CMD_FWINRTSKIP = _brlapi_command("KEY_CMD_FWINRTSKIP")
BRLAPI_KEY_CMD_LNUP = _brlapi_command("KEY_CMD_LNUP")
BRLAPI_KEY_CMD_LNDN = _brlapi_command("KEY_CMD_LNDN")
BRLAPI_KEY_CMD_FREEZE = _brlapi_command("KEY_CMD_FREEZE")
BRLAPI_KEY_CMD_TOP_LEFT = _brlapi_command("KEY_CMD_TOP_LEFT")
BRLAPI_KEY_CMD_BOT_LEFT = _brlapi_command("KEY_CMD_BOT_LEFT")
BRLAPI_KEY_CMD_HOME = _brlapi_command("KEY_CMD_HOME")
BRLAPI_KEY_CMD_SIXDOTS = _brlapi_command("KEY_CMD_SIXDOTS")
BRLAPI_KEY_CMD_ROUTE = _brlapi_command("KEY_CMD_ROUTE")
BRLAPI_KEY_CMD_CUTBEGIN = _brlapi_command("KEY_CMD_CUTBEGIN")
BRLAPI_KEY_CMD_CUTLINE = _brlapi_command("KEY_CMD_CUTLINE")
BRLAPI_PRIORITY_IDLE = 0 BRLAPI_PRIORITY_IDLE = 0
BRLAPI_PRIORITY_DEFAULT = 50 BRLAPI_PRIORITY_DEFAULT = 50
BRLAPI_PRIORITY_HIGH = 70 BRLAPI_PRIORITY_HIGH = 70
@@ -936,9 +962,15 @@ class Line:
def addRegion(self, region): def addRegion(self, region):
self.regions.append(region) self.regions.append(region)
def add_region(self, region):
self.addRegion(region)
def addRegions(self, regions): def addRegions(self, regions):
self.regions.extend(regions) self.regions.extend(regions)
def add_regions(self, regions):
self.addRegions(regions)
def getLineInfo(self, getLinkMask=True): def getLineInfo(self, getLinkMask=True):
"""Computes the complete string for this line as well as a """Computes the complete string for this line as well as a
0-based index where the focused region starts on this line. 0-based index where the focused region starts on this line.
@@ -998,6 +1030,9 @@ class Line:
return [string, focusOffset, attributeMask, ranges] return [string, focusOffset, attributeMask, ranges]
def get_line_info(self, get_link_mask=True):
return self.getLineInfo(get_link_mask)
def getRegionAtOffset(self, offset): def getRegionAtOffset(self, offset):
"""Finds the Region at the given 0-based offset in this line. """Finds the Region at the given 0-based offset in this line.
@@ -1500,18 +1535,20 @@ def refresh(panToCursor=True, targetCursorCell=0, getLinkMask=True, stopFlash=Tr
shutdown() shutdown()
if settings.enableBrailleMonitor: if settings.enableBrailleMonitor:
if not _monitor: if attributeMask:
subMask = attributeMask[startPos:endPos]
else:
subMask = None
if _monitorCallback:
_monitorCallback(cursorCell, substring, subMask, _displaySize[0])
elif not _monitor:
try: try:
_monitor = brlmon.BrlMon(_displaySize[0]) _monitor = brlmon.BrlMon(_displaySize[0])
_monitor.show_all() _monitor.show_all()
except Exception: except Exception:
debug.printMessage(debug.LEVEL_WARNING, "brlmon failed") debug.printMessage(debug.LEVEL_WARNING, "brlmon failed")
_monitor = None _monitor = None
if attributeMask: if _monitor and not _monitorCallback:
subMask = attributeMask[startPos:endPos]
else:
subMask = None
if _monitor:
_monitor.writeText(cursorCell, substring, subMask) _monitor.writeText(cursorCell, substring, subMask)
elif _monitor: elif _monitor:
_monitor.destroy() _monitor.destroy()
@@ -2110,3 +2147,135 @@ def shutdown():
msg = "BRAILLE: Braille shutdown complete." msg = "BRAILLE: Braille shutdown complete."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
def check_braille_setting():
return checkBrailleSetting()
def disable_braille():
return disableBraille()
def display_line(
line,
focused_region=None,
pan_to_cursor=True,
indicate_links=True,
stop_flash=True,
):
clear()
addLine(line)
setFocus(focused_region, getLinkMask=indicate_links)
refresh(
panToCursor=pan_to_cursor,
getLinkMask=indicate_links,
stopFlash=stop_flash,
)
def display_message(message, cursor=-1, flash_time=0):
return displayMessage(message, cursor=cursor, flashTime=flash_time)
def get_caret_context(event):
return getCaretContext(event)
def get_default_contraction_table():
return globals().get("_defaultContractionTable", "")
def get_region_at_cell(cell):
return getRegionAtCell(cell)
def is_flash_active():
return bool(_flashEventSourceId)
def kill_flash(restore_saved=True):
return killFlash(restoreSaved=restore_saved)
def pan_left(pan_amount=0):
return panLeft(pan_amount)
def pan_right(pan_amount=0):
return panRight(pan_amount)
def process_routing_key(event):
return processRoutingKey(event)
def return_to_region_with_focus(input_event=None):
return returnToRegionWithFocus(input_event)
def set_brlapi_priority(level=BRLAPI_PRIORITY_DEFAULT):
return setBrlapiPriority(level)
def set_contraction_table(file_path):
settings.brailleContractionTable = file_path
def set_enable_braille(value):
settings.enableBraille = bool(value)
def set_enable_computer_braille_at_cursor(value):
global _enableComputerBrailleAtCursor
_enableComputerBrailleAtCursor = bool(value)
def set_enable_contracted_braille(value):
settings.enableContractedBraille = bool(value)
for line in _lines:
line.setContractedBraille(settings.enableContractedBraille)
def set_enable_eol(value):
settings.disableBrailleEOL = not bool(value)
def set_enable_word_wrap(value):
settings.enableBrailleWordWrap = bool(value)
def set_link_indicator(value):
settings.brailleLinkIndicator = value
def set_monitor_callback(callback):
global _monitorCallback
_monitorCallback = callback
def set_selector_indicator(value):
settings.brailleSelectorIndicator = value
def set_text_attributes_indicator(value):
settings.textAttributesBrailleIndicator = value
def setup_key_ranges(keys):
return setupKeyRanges(keys)
def toggle_contracted_braille(event):
return setContractedBraille(event)
def try_reposition_cursor(obj):
if not _regionWithFocus or not isinstance(_regionWithFocus, Text):
return False
if _regionWithFocus.accessible != obj:
return False
if not _regionWithFocus.repositionCursor():
return False
refresh()
return True
+22
View File
@@ -37,6 +37,28 @@ __license__ = "LGPL"
from .cthulhu_i18n import _ from .cthulhu_i18n import _
BYPASS_MODE_TOGGLE = _("Toggle bypass mode")
CHAT_NEXT_MESSAGE = _("Speak and braille the next chat room message")
CLIPBOARD_PRESENT_CONTENTS = _("Present clipboard contents")
DEBUG_CLEAR_ATSPI_CACHE_FOR_APPLICATION = _("Clear the AT-SPI cache for the current application")
DEBUG_CYCLE_LEVEL = _("Cycle the debug level at run time")
LIVE_REGIONS_ARE_ANNOUNCED = _("Toggle live region announcements")
LIVE_REGIONS_NEXT = _("Speak the next live region announcement")
LIVE_REGIONS_PREVIOUS = _("Speak the previous live region announcement")
PRESENT_BATTERY_STATUS = _("Present battery status")
PRESENT_CELL_FORMULA = _("Present spreadsheet cell formula")
PRESENT_CPU_AND_MEMORY_USAGE = _("Present CPU and memory usage")
PRESENT_CURRENT_PROFILE = _("Present current settings profile")
STRUCTURAL_NAVIGATION_MODE_CYCLE = _("Cycle structural navigation mode")
TABLE_CELL_BEGINNING_OF_ROW = _("Go to the beginning of the row")
TABLE_CELL_BOTTOM_OF_COLUMN = _("Go to the bottom of the column")
TABLE_CELL_END_OF_ROW = _("Go to the end of the row")
TABLE_CELL_TOP_OF_COLUMN = _("Go to the top of the column")
TABLE_NAVIGATION_TOGGLE = _("Toggle table navigation keys")
TOGGLE_BRAILLE_MONITOR = _("Toggle the braille monitor")
TOGGLE_KEYBOARD_LAYOUT = _("Toggle keyboard layout")
TOGGLE_SPEECH_MONITOR = _("Toggle the speech monitor")
# Translators: this command will move the mouse pointer to the current item # Translators: this command will move the mouse pointer to the current item
# without clicking on it. # without clicking on it.
ROUTE_POINTER_TO_ITEM = _("Route the pointer to the current item") ROUTE_POINTER_TO_ITEM = _("Route the pointer to the current item")
+5
View File
@@ -122,6 +122,11 @@ class FocusManager:
appName = (AXObject.get_name(app) or "").lower() appName = (AXObject.get_name(app) or "").lower()
return appName == "cthulhu" return appName == "cthulhu"
def is_in_preferences_window(self) -> bool:
"""Returns True if focus is inside Cthulhu's preferences window."""
return self.active_window_is_cthulhu()
def focus_and_window_are_unknown(self) -> bool: def focus_and_window_are_unknown(self) -> bool:
"""Returns True if we have no knowledge about what is focused.""" """Returns True if we have no knowledge about what is focused."""
+1 -1
View File
@@ -115,7 +115,7 @@ class InputEventManager:
def add_grabs_for_keybinding( def add_grabs_for_keybinding(
self, self,
binding: keybindings.KeyBinding, binding: keybindings.KeyBinding,
cthulhu_modifiers: list[str], cthulhu_modifiers: list[str] | None = None,
) -> list[int]: ) -> list[int]:
"""Adds grabs for binding, returns grab IDs.""" """Adds grabs for binding, returns grab IDs."""
+63 -11
View File
@@ -67,6 +67,9 @@ CTHULHU_CTRL_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
CTHULHU_CTRL_ALT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU | CTHULHU_CTRL_ALT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
1 << Atspi.ModifierType.CONTROL | 1 << Atspi.ModifierType.CONTROL |
1 << Atspi.ModifierType.ALT) 1 << Atspi.ModifierType.ALT)
CTHULHU_ALT_SHIFT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
1 << Atspi.ModifierType.ALT |
1 << Atspi.ModifierType.SHIFT)
CTHULHU_SHIFT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU | CTHULHU_SHIFT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
1 << Atspi.ModifierType.SHIFT) 1 << Atspi.ModifierType.SHIFT)
SHIFT_MODIFIER_MASK = 1 << Atspi.ModifierType.SHIFT SHIFT_MODIFIER_MASK = 1 << Atspi.ModifierType.SHIFT
@@ -171,6 +174,8 @@ def getModifierNames(mods):
text += _("Shift") + "+" text += _("Shift") + "+"
return text return text
get_modifier_names = getModifierNames
def get_click_countString(count): def get_click_countString(count):
"""Returns a human-consumable string representing the number of """Returns a human-consumable string representing the number of
clicks, such as 'double click' and 'triple click'.""" clicks, such as 'double click' and 'triple click'."""
@@ -189,7 +194,7 @@ def get_click_countString(count):
return _("triple click") return _("triple click")
return "" return ""
def create_key_definitions(keycode, keyval, modifiers): def create_key_definitions(keycode, keyval, modifiers, cthulhu_modifiers=None):
"""Returns a list of Atspi key definitions for the given keycode, keyval, and modifiers.""" """Returns a list of Atspi key definitions for the given keycode, keyval, and modifiers."""
ret = [] ret = []
if modifiers & CTHULHU_MODIFIER_MASK: if modifiers & CTHULHU_MODIFIER_MASK:
@@ -197,7 +202,7 @@ def create_key_definitions(keycode, keyval, modifiers):
other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK
from . import input_event_manager from . import input_event_manager
manager = input_event_manager.get_manager() manager = input_event_manager.get_manager()
for key in settings.cthulhuModifierKeys: for key in cthulhu_modifiers or settings.cthulhuModifierKeys:
mod_keyval, mod_keycode = get_keycodes(key) mod_keyval, mod_keycode = get_keycodes(key)
if mod_keycode == 0 and key == "Shift_Lock": if mod_keycode == 0 and key == "Shift_Lock":
mod_keyval, mod_keycode = get_keycodes("Caps_Lock") mod_keyval, mod_keycode = get_keycodes("Caps_Lock")
@@ -224,14 +229,22 @@ class KeyBinding:
and the InputEventHandler. and the InputEventHandler.
""" """
def __init__(self, keysymstring, modifier_mask, modifiers, handler, def __init__(
click_count = 1, enabled=True): self,
keysymstring,
modifier_mask_or_modifiers,
*args,
click_count=1,
enabled=True,
):
"""Creates a new key binding. """Creates a new key binding.
Arguments: Arguments:
- keysymstring: the keysymstring - this is typically a string - keysymstring: the keysymstring - this is typically a string
from /usr/include/X11/keysymdef.h with the preceding 'XK_' from /usr/include/X11/keysymdef.h with the preceding 'XK_'
removed (e.g., XK_KP_Enter becomes the string 'KP_Enter'). removed (e.g., XK_KP_Enter becomes the string 'KP_Enter').
- modifier_mask_or_modifiers: either the modifier mask in the legacy form,
or the required modifier state in the lightweight command-manager form.
- modifier_mask: bit mask where a set bit tells us what modifiers - modifier_mask: bit mask where a set bit tells us what modifiers
we care about (see Atspi.ModifierType.*) we care about (see Atspi.ModifierType.*)
- modifiers: the state the modifiers we care about must be in for - modifiers: the state the modifiers we care about must be in for
@@ -240,6 +253,24 @@ class KeyBinding:
- handler: the InputEventHandler for this key binding - handler: the InputEventHandler for this key binding
""" """
if len(args) >= 2:
modifier_mask = modifier_mask_or_modifiers
modifiers = args[0]
handler = args[1]
if len(args) >= 3:
click_count = args[2]
if len(args) >= 4:
enabled = args[3]
elif len(args) == 1:
modifier_mask = defaultModifierMask
modifiers = modifier_mask_or_modifiers
handler = None
click_count = args[0]
else:
modifier_mask = defaultModifierMask
modifiers = modifier_mask_or_modifiers
handler = None
self.keysymstring = keysymstring self.keysymstring = keysymstring
self.modifier_mask = modifier_mask self.modifier_mask = modifier_mask
self.modifiers = modifiers self.modifiers = modifiers
@@ -296,14 +327,21 @@ class KeyBinding:
"""Returns the grab IDs for this KeyBinding.""" """Returns the grab IDs for this KeyBinding."""
return self._grab_ids return self._grab_ids
def set_grab_ids(self, grab_ids):
"""Sets the grab IDs for this KeyBinding."""
self._grab_ids = grab_ids
def has_grabs(self): def has_grabs(self):
"""Returns True if there are existing grabs associated with this KeyBinding.""" """Returns True if there are existing grabs associated with this KeyBinding."""
return bool(self._grab_ids) return bool(self._grab_ids)
def add_grabs(self): def add_grabs(self, cthulhu_modifiers=None):
"""Adds key grabs for this KeyBinding.""" """Adds key grabs for this KeyBinding."""
from . import input_event_manager from . import input_event_manager
self._grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(self) self._grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(
self,
cthulhu_modifiers or settings.cthulhuModifierKeys,
)
def remove_grabs(self): def remove_grabs(self):
"""Removes key grabs for this KeyBinding.""" """Removes key grabs for this KeyBinding."""
@@ -311,21 +349,35 @@ class KeyBinding:
input_event_manager.get_manager().remove_grabs_for_keybinding(self) input_event_manager.get_manager().remove_grabs_for_keybinding(self)
self._grab_ids = [] self._grab_ids = []
def key_definitions(self): def key_definitions(self, cthulhu_modifiers=None):
"""Return a list of Atspi key definitions for the given binding.""" """Return a list of Atspi key definitions for the given binding."""
ret = [] ret = []
if not self.keycode: if not self.keycode:
self.keyval, self.keycode = get_keycodes(self.keysymstring) self.keyval, self.keycode = get_keycodes(self.keysymstring)
ret.extend(create_key_definitions(self.keycode, self.keyval, self.modifiers)) ret.extend(
create_key_definitions(
self.keycode,
self.keyval,
self.modifiers,
cthulhu_modifiers,
)
)
if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK: if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK:
upper_keyval = Gdk.keyval_to_upper(self.keyval) upper_keyval = Gdk.keyval_to_upper(self.keyval)
if upper_keyval != self.keyval: if upper_keyval != self.keyval:
ret.extend(create_key_definitions(self.keycode, upper_keyval, self.modifiers)) ret.extend(
create_key_definitions(
self.keycode,
upper_keyval,
self.modifiers,
cthulhu_modifiers,
)
)
return ret return ret
def keyDefs(self): def keyDefs(self, cthulhu_modifiers=None):
"""Legacy wrapper. Use key_definitions() instead.""" """Legacy wrapper. Use key_definitions() instead."""
return self.key_definitions() return self.key_definitions(cthulhu_modifiers)
class KeyBindings: class KeyBindings:
"""Structure that maintains a set of KeyBinding instances. """Structure that maintains a set of KeyBinding instances.
+24
View File
@@ -64,6 +64,7 @@ class LearnModePresenter:
self.app = app self.app = app
self._handlers = self._setup_handlers() self._handlers = self._setup_handlers()
self._bindings = self._setup_bindings() self._bindings = self._setup_bindings()
self._initialized = False
self._is_active = False self._is_active = False
self._gui = None self._gui = None
@@ -82,6 +83,27 @@ class LearnModePresenter:
return self._handlers return self._handlers
def set_up_commands(self):
"""Sets up commands with CommandManager."""
if self._initialized:
return
self._initialized = True
from . import command_manager
kb = keybindings.KeyBinding("h", keybindings.CTHULHU_MODIFIER_MASK)
command_manager.get_manager().add_command(
command_manager.KeyboardCommand(
"enter_learn_mode",
self.start,
guilabels.KB_GROUP_LEARN_MODE,
cmdnames.ENTER_LEARN_MODE,
desktop_keybinding=kb,
laptop_keybinding=kb,
),
)
def _setup_handlers(self): def _setup_handlers(self):
"""Sets up and returns the learn-mode-presenter input event handlers.""" """Sets up and returns the learn-mode-presenter input event handlers."""
@@ -405,3 +427,5 @@ def getPresenter():
_presenter = LearnModePresenter(cthulhu.cthulhuApp) _presenter = LearnModePresenter(cthulhu.cthulhuApp)
return _presenter return _presenter
get_presenter = getPresenter
+3 -1
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import html import html
import re import re
import sys
from typing import Any, Optional from typing import Any, Optional
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
@@ -188,7 +189,8 @@ class MakoNotificationMonitor:
return self._generation return self._generation
def _get_presenter(self): def _get_presenter(self):
presenter = self._presenter or notification_presenter.getPresenter() presenter_module = sys.modules.get("cthulhu.notification_presenter", notification_presenter)
presenter = self._presenter or presenter_module.getPresenter()
if presenter is not self._presenter: if presenter is not self._presenter:
self._presenter = presenter self._presenter = presenter
+22
View File
@@ -364,6 +364,7 @@ class MouseReviewer:
self._useAtspi = False self._useAtspi = False
self._handlers = self._setup_handlers() self._handlers = self._setup_handlers()
self._bindings = self._setup_bindings() self._bindings = self._setup_bindings()
self._initialized = False
atspiVersion = Atspi.get_version() atspiVersion = Atspi.get_version()
capabilityEnum = getattr(Atspi, "DeviceCapability", None) capabilityEnum = getattr(Atspi, "DeviceCapability", None)
@@ -434,6 +435,24 @@ class MouseReviewer:
return self._handlers return self._handlers
def set_up_commands(self):
"""Sets up commands with CommandManager."""
if self._initialized:
return
self._initialized = True
from . import command_manager, guilabels
command_manager.get_manager().add_command(
command_manager.KeyboardCommand(
"toggle_mouse_review",
self.toggle,
guilabels.KB_GROUP_MOUSE_REVIEW,
cmdnames.MOUSE_REVIEW_TOGGLE,
),
)
def _setup_handlers(self): def _setup_handlers(self):
"""Sets up and returns the mouse-review input event handlers.""" """Sets up and returns the mouse-review input event handlers."""
@@ -831,3 +850,6 @@ def getReviewer():
from . import cthulhu from . import cthulhu
_reviewer = MouseReviewer(cthulhu.cthulhuApp) _reviewer = MouseReviewer(cthulhu.cthulhuApp)
return _reviewer return _reviewer
get_reviewer = getReviewer
+254 -9
View File
@@ -30,6 +30,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import gi import gi
@@ -54,6 +55,28 @@ if TYPE_CHECKING:
from .scripts import default from .scripts import default
@dataclass
class NotificationEntry:
"""A notification history entry."""
message: str
timestamp: float
source: str = ""
source_generation: int = 0
notification_id: int | None = None
live: bool = False
actions: dict[str, str] = field(default_factory=dict)
app_name: str = ""
summary: str = ""
body: str = ""
urgency: int = -1
desktop_entry: str = ""
def __iter__(self):
yield self.message
yield self.timestamp
class NotificationPresenter: class NotificationPresenter:
"""Provides access to the notification history.""" """Provides access to the notification history."""
@@ -65,9 +88,10 @@ class NotificationPresenter:
# the list. The current index is relative to, and used directly, with the # the list. The current index is relative to, and used directly, with the
# python list, i.e. self._notifications[-3] would return the third-to-last # python list, i.e. self._notifications[-3] would return the third-to-last
# notification message. # notification message.
self._notifications: list[tuple[str, float]] = [] self._notifications: list[NotificationEntry] = []
self._current_index: int = -1 self._current_index: int = -1
self._initialized: bool = False self._initialized: bool = False
self._mako_monitor = None
msg = "NOTIFICATION PRESENTER: Registering D-Bus commands." msg = "NOTIFICATION PRESENTER: Registering D-Bus commands."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
@@ -122,14 +146,42 @@ class NotificationPresenter:
msg = "NOTIFICATION PRESENTER: Commands set up." msg = "NOTIFICATION PRESENTER: Commands set up."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
def save_notification(self, message: str) -> None: def save_notification(
self,
message: str,
source: str = "",
source_generation: int = 0,
notification_id: int | None = None,
live: bool = False,
actions: dict[str, str] | None = None,
app_name: str = "",
summary: str = "",
body: str = "",
urgency: int = -1,
desktop_entry: str = "",
) -> NotificationEntry:
"""Adds message to the list of notification messages.""" """Adds message to the list of notification messages."""
tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."] tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
to_remove = max(len(self._notifications) - self._max_size + 1, 0) entry = NotificationEntry(
message=message,
timestamp=time.time(),
source=source,
source_generation=source_generation,
notification_id=notification_id,
live=live,
actions=dict(actions or {}),
app_name=app_name,
summary=summary,
body=body,
urgency=urgency,
desktop_entry=desktop_entry,
)
self._notifications.append(entry)
to_remove = max(len(self._notifications) - self._max_size, 0)
self._notifications = self._notifications[to_remove:] self._notifications = self._notifications[to_remove:]
self._notifications.append((message, time.time())) return entry
def clear_list(self) -> None: def clear_list(self) -> None:
"""Clears the notifications list.""" """Clears the notifications list."""
@@ -155,6 +207,101 @@ class NotificationPresenter:
days = round(diff / 86400) days = round(diff / 86400)
return messages.days_ago(days) return messages.days_ago(days)
def set_mako_monitor(self, monitor) -> None:
"""Sets the monitor used to control live Mako notifications."""
self._mako_monitor = monitor
def mark_source_unavailable(self, source: str) -> None:
"""Marks all entries from source as no longer live."""
for entry in self._notifications:
if entry.source == source:
entry.live = False
def sync_live_notifications(
self,
source: str,
notifications: dict[int, dict],
source_generation: int,
) -> None:
"""Updates the live state for notifications from source."""
for entry in self._notifications:
if entry.source != source or entry.source_generation != source_generation:
continue
data = notifications.get(entry.notification_id)
entry.live = data is not None
if data is None:
continue
entry.message = data.get("message", entry.message)
entry.actions = dict(data.get("actions", entry.actions) or {})
entry.app_name = data.get("app_name", entry.app_name)
entry.summary = data.get("summary", entry.summary)
entry.body = data.get("body", entry.body)
entry.urgency = data.get("urgency", entry.urgency)
entry.desktop_entry = data.get("desktop_entry", entry.desktop_entry)
def can_control_entry(self, entry: NotificationEntry | None) -> bool:
"""Returns True if entry is a live notification we can control."""
if entry is None or not entry.live or self._mako_monitor is None:
return False
return self._mako_monitor.is_current_entry(entry)
def get_actions_for_entry(self, entry: NotificationEntry | None) -> dict[str, str]:
"""Returns actions for entry if it can still be controlled."""
if not self.can_control_entry(entry):
return {}
return dict(entry.actions)
def dismiss_entry(self, script: default.Script, entry: NotificationEntry | None) -> bool:
"""Dismisses entry if it is still live."""
if (
not self.can_control_entry(entry)
or entry is None
or entry.notification_id is None
or self._mako_monitor is None
):
return False
if not self._mako_monitor.dismiss_notification(entry.notification_id):
return False
if entry in self._notifications:
self._notifications.remove(entry)
script.presentMessage(messages.NOTIFICATION_DISMISSED)
return True
def invoke_action_for_entry(
self,
script: default.Script,
entry: NotificationEntry | None,
action_key: str,
) -> bool:
"""Invokes action_key for entry if it is still live."""
if (
not self.can_control_entry(entry)
or entry is None
or entry.notification_id is None
or action_key not in entry.actions
or self._mako_monitor is None
):
return False
if not self._mako_monitor.invoke_action(entry.notification_id, action_key):
return False
script.presentMessage(messages.NOTIFICATION_ACTION_INVOKED)
return True
@dbus_service.command @dbus_service.command
def present_last_notification( def present_last_notification(
self, self,
@@ -316,8 +463,8 @@ class NotificationPresenter:
return True return True
rows = [ rows = [
(message, self._timestamp_to_string(timestamp)) (entry.message, self._timestamp_to_string(entry.timestamp), entry)
for message, timestamp in reversed(self._notifications) for entry in reversed(self._notifications)
] ]
title = guilabels.notifications_count(len(self._notifications)) title = guilabels.notifications_count(len(self._notifications))
column_headers = [ column_headers = [
@@ -330,6 +477,7 @@ class NotificationPresenter:
column_headers, column_headers,
rows, rows,
self.on_dialog_destroyed, self.on_dialog_destroyed,
self,
) )
self._gui.show_gui() self._gui.show_gui()
return True return True
@@ -348,11 +496,17 @@ class NotificationListGUI:
script: default.Script, script: default.Script,
title: str, title: str,
column_headers: list[str], column_headers: list[str],
rows: list[tuple[str, str]], rows: list[tuple[str, str, NotificationEntry]],
destroyed_callback: Callable[[Gtk.Dialog], None], destroyed_callback: Callable[[Gtk.Dialog], None],
presenter: NotificationPresenter | None = None,
): ):
self._script: default.Script = script self._script: default.Script = script
self._presenter: NotificationPresenter = presenter or get_presenter()
self._model: Gtk.ListStore | None = None self._model: Gtk.ListStore | None = None
self._selection = None
self._dismiss_button = None
self._actions_box = None
self._actions_status_label = None
self._gui: Gtk.Dialog = self._create_dialog(title, column_headers, rows) self._gui: Gtk.Dialog = self._create_dialog(title, column_headers, rows)
self._gui.connect("destroy", destroyed_callback) self._gui.connect("destroy", destroyed_callback)
@@ -360,7 +514,7 @@ class NotificationListGUI:
self, self,
title: str, title: str,
column_headers: list[str], column_headers: list[str],
rows: list[tuple[str, str]], rows: list[tuple[str, str, NotificationEntry]],
) -> Gtk.Dialog: ) -> Gtk.Dialog:
dialog = Gtk.Dialog( dialog = Gtk.Dialog(
title, title,
@@ -381,8 +535,9 @@ class NotificationListGUI:
tree.set_hexpand(True) tree.set_hexpand(True)
tree.set_vexpand(True) tree.set_vexpand(True)
scrolled_window.add(tree) # pylint: disable=no-member scrolled_window.add(tree) # pylint: disable=no-member
self._selection = tree.get_selection()
cols = len(column_headers) * [GObject.TYPE_STRING] cols = len(column_headers) * [GObject.TYPE_STRING] + [GObject.TYPE_PYOBJECT]
for i, header in enumerate(column_headers): for i, header in enumerate(column_headers):
cell = Gtk.CellRendererText() cell = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(header, cell, text=i) column = Gtk.TreeViewColumn(header, cell, text=i)
@@ -400,6 +555,93 @@ class NotificationListGUI:
dialog.connect("response", self.on_response) dialog.connect("response", self.on_response)
return dialog return dialog
def _get_selected_entry(self) -> NotificationEntry | None:
if self._model is None or self._selection is None:
return None
model, paths = self._selection.get_selected_rows()
if not paths:
return None
row_iter = model.get_iter(paths[0])
if row_iter is None:
return None
return model.get_value(row_iter, 2)
def _dismiss_selected_notification(self) -> None:
if self._model is None or self._selection is None:
return
model, paths = self._selection.get_selected_rows()
if not paths:
return
row_iter = model.get_iter(paths[0])
if row_iter is None:
return
entry = model.get_value(row_iter, 2)
if not self._presenter.dismiss_entry(self._script, entry):
self._update_action_buttons()
return
has_next_row = model.remove(row_iter)
if model.iter_n_children(None):
if has_next_row:
self._selection.select_path(row_iter)
else:
self._selection.select_path(model.iter_n_children(None) - 1)
self._update_action_buttons()
def _clear_action_buttons(self) -> None:
if self._actions_box is None:
return
for child in self._actions_box.get_children():
self._actions_box.remove(child)
def _on_action_button_clicked(
self,
_button: Gtk.Button,
entry: NotificationEntry,
action_key: str,
) -> None:
self._presenter.invoke_action_for_entry(self._script, entry, action_key)
def _update_action_buttons(self) -> None:
if (
self._dismiss_button is None
or self._actions_box is None
or self._actions_status_label is None
):
return
self._clear_action_buttons()
entry = self._get_selected_entry()
can_control = self._presenter.can_control_entry(entry)
self._dismiss_button.set_sensitive(can_control)
if not can_control:
self._actions_status_label.set_text(messages.NOTIFICATION_UNAVAILABLE)
self._actions_status_label.set_visible(True)
return
actions = self._presenter.get_actions_for_entry(entry)
if not actions:
self._actions_status_label.set_text(messages.NOTIFICATION_NO_ACTIONS)
self._actions_status_label.set_visible(True)
return
self._actions_status_label.set_visible(False)
for action_key, label in actions.items():
button = Gtk.Button(label=label)
button.set_margin_top(6)
button.connect("clicked", self._on_action_button_clicked, entry, action_key)
self._actions_box.pack_start(button, False, False, 0)
self._actions_box.show_all()
def on_response(self, _dialog: Gtk.Dialog, response: int) -> None: def on_response(self, _dialog: Gtk.Dialog, response: int) -> None:
"""The handler for the 'response' signal.""" """The handler for the 'response' signal."""
@@ -428,3 +670,6 @@ def get_presenter() -> NotificationPresenter:
"""Returns the Notification Presenter""" """Returns the Notification Presenter"""
return _presenter return _presenter
getPresenter = get_presenter
+14
View File
@@ -31,6 +31,7 @@ from . import (
braille_generator, braille_generator,
chat_presenter, chat_presenter,
debug, debug,
formatting,
script_utilities, script_utilities,
sound_generator, sound_generator,
speech_generator, speech_generator,
@@ -62,6 +63,7 @@ class Script:
self.listeners = self.get_listeners() self.listeners = self.get_listeners()
self.utilities = self.get_utilities() self.utilities = self.get_utilities()
self.formatting = self.get_formatting()
self._braille_generator = self._create_braille_generator() self._braille_generator = self._create_braille_generator()
self._sound_generator = self._create_sound_generator() self._sound_generator = self._create_sound_generator()
@@ -137,6 +139,18 @@ class Script:
return braille_generator.BrailleGenerator(self) return braille_generator.BrailleGenerator(self)
def get_formatting(self) -> formatting.Formatting:
"""Returns the formatting strings for this script."""
if type(self).getFormatting is not Script.getFormatting:
return self.getFormatting()
return formatting.Formatting(self)
def getFormatting(self) -> formatting.Formatting:
"""Returns the formatting strings for this script."""
return formatting.Formatting(self)
def _create_sound_generator(self) -> sound_generator.SoundGenerator: def _create_sound_generator(self) -> sound_generator.SoundGenerator:
"""Creates and returns the sound generator for this script.""" """Creates and returns the sound generator for this script."""
+34
View File
@@ -49,6 +49,7 @@ class SleepModeManager:
def __init__(self): def __init__(self):
self._handlers = self.getHandlers(True) self._handlers = self.getHandlers(True)
self._bindings = keybindings.KeyBindings() self._bindings = keybindings.KeyBindings()
self._initialized = False
self._apps = set() self._apps = set()
self._disabledAutoSleepApps = set() self._disabledAutoSleepApps = set()
self._autoSleepAppNames = set() self._autoSleepAppNames = set()
@@ -80,6 +81,31 @@ class SleepModeManager:
return self._handlers return self._handlers
def set_up_commands(self):
"""Sets up commands with CommandManager."""
if self._initialized:
return
self._initialized = True
import cthulhu.command_manager as command_manager
import cthulhu.guilabels as guilabels
kb = keybindings.KeyBinding(
"q",
keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK,
)
command_manager.get_manager().add_command(
command_manager.KeyboardCommand(
"toggle_sleep_mode",
self.toggleSleepMode,
guilabels.KB_GROUP_SLEEP_MODE,
cmdnames.TOGGLE_SLEEP_MODE,
desktop_keybinding=kb,
laptop_keybinding=kb,
),
)
def isActiveForApp(self, app): def isActiveForApp(self, app):
"""Returns True if sleep mode is active for app.""" """Returns True if sleep mode is active for app."""
@@ -96,6 +122,11 @@ class SleepModeManager:
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
return result return result
def is_active_for_app(self, app):
"""Returns True if sleep mode is active for app."""
return self.isActiveForApp(app)
def _getAutoSleepPath(self): def _getAutoSleepPath(self):
prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu") prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu")
try: try:
@@ -301,3 +332,6 @@ _manager = SleepModeManager()
def getManager(): def getManager():
"""Returns the Sleep Mode Manager singleton.""" """Returns the Sleep Mode Manager singleton."""
return _manager return _manager
get_manager = getManager
+29 -2
View File
@@ -76,6 +76,9 @@ _timestamp: float = 0.0
# Optional callback for live monitoring of spoken text. # Optional callback for live monitoring of spoken text.
_monitorWriteTextCallback: Optional[Callable[[str], None]] = None _monitorWriteTextCallback: Optional[Callable[[str], None]] = None
_monitorWriteKeyCallback: Optional[Callable[[str], None]] = None
_monitorBeginGroupCallback: Optional[Callable[[], None]] = None
_monitorEndGroupCallback: Optional[Callable[[], None]] = None
def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool: def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool:
if not moduleName: if not moduleName:
@@ -310,6 +313,10 @@ def getSpeechServer() -> Optional[SpeechServer]:
"""Returns the speech server instance.""" """Returns the speech server instance."""
return _speechserver return _speechserver
def get_speech_server() -> Optional[SpeechServer]:
"""Returns the speech server instance."""
return getSpeechServer()
def setSpeechServer(speechServer: SpeechServer) -> None: def setSpeechServer(speechServer: SpeechServer) -> None:
"""Sets the speech server to be used. """Sets the speech server to be used.
@@ -320,10 +327,30 @@ def setSpeechServer(speechServer: SpeechServer) -> None:
_speechserver = speechServer _speechserver = speechServer
_refreshEchoSpeechServer() _refreshEchoSpeechServer()
def set_monitor_callbacks(writeText: Optional[Callable[[str], None]] = None) -> None: def set_server(speech_server: Optional[SpeechServer]) -> None:
"""Sets the speech server to be used."""
setSpeechServer(speech_server)
def set_monitor_callbacks(
writeText: Optional[Callable[[str], None]] = None,
writeKey: Optional[Callable[[str], None]] = None,
beginGroup: Optional[Callable[[], None]] = None,
endGroup: Optional[Callable[[], None]] = None,
*,
write_text: Optional[Callable[[str], None]] = None,
write_key: Optional[Callable[[str], None]] = None,
begin_group: Optional[Callable[[], None]] = None,
end_group: Optional[Callable[[], None]] = None,
) -> None:
"""Sets runtime callbacks for live speech monitoring.""" """Sets runtime callbacks for live speech monitoring."""
global _monitorWriteTextCallback global _monitorWriteTextCallback
_monitorWriteTextCallback = writeText global _monitorWriteKeyCallback
global _monitorBeginGroupCallback
global _monitorEndGroupCallback
_monitorWriteTextCallback = write_text if write_text is not None else writeText
_monitorWriteKeyCallback = write_key if write_key is not None else writeKey
_monitorBeginGroupCallback = begin_group if begin_group is not None else beginGroup
_monitorEndGroupCallback = end_group if end_group is not None else endGroup
def _write_to_monitor(text: str) -> None: def _write_to_monitor(text: str) -> None:
"""Writes text to the active speech monitor callback if set.""" """Writes text to the active speech monitor callback if set."""
+157
View File
@@ -207,6 +207,11 @@ class SpeechServer(object):
"""Returns a localized name describing this factory.""" """Returns a localized name describing this factory."""
pass pass
@classmethod
def get_factory_name(cls):
"""Returns a localized name describing this factory."""
return cls.getFactoryName()
@staticmethod @staticmethod
def getSpeechServers(): def getSpeechServers():
"""Gets available speech servers as a list. The caller """Gets available speech servers as a list. The caller
@@ -215,6 +220,11 @@ class SpeechServer(object):
""" """
pass pass
@classmethod
def get_speech_servers(cls):
"""Gets available speech servers as a list."""
return cls.getSpeechServers()
@staticmethod @staticmethod
def getSpeechServer(info): def getSpeechServer(info):
"""Gets a given SpeechServer based upon the info. """Gets a given SpeechServer based upon the info.
@@ -222,12 +232,22 @@ class SpeechServer(object):
""" """
pass pass
@classmethod
def get_speech_server(cls, info=None):
"""Gets a given SpeechServer based upon the info."""
return cls.getSpeechServer(info)
@staticmethod @staticmethod
def shutdownActiveServers(): def shutdownActiveServers():
"""Cleans up and shuts down this factory. """Cleans up and shuts down this factory.
""" """
pass pass
@classmethod
def shutdown_active_servers(cls):
"""Cleans up and shuts down this factory."""
return cls.shutdownActiveServers()
def __init__(self): def __init__(self):
pass pass
@@ -236,11 +256,72 @@ class SpeechServer(object):
""" """
pass pass
def get_info(self):
"""Returns [name, id]."""
return self.getInfo()
def getVoiceFamilies(self): def getVoiceFamilies(self):
"""Returns a list of VoiceFamily instances representing all """Returns a list of VoiceFamily instances representing all
voice families known by the speech server.""" voice families known by the speech server."""
pass pass
def get_voice_families(self):
"""Returns a list of VoiceFamily instances representing all voice families."""
return self.getVoiceFamilies()
def get_voice_families_for_language(self, language, dialect="", variant="", maximum=None):
"""Returns available voice families for the specified language."""
if not dialect and variant:
dialect = variant
method = getattr(self, "getVoiceFamiliesForLanguage", None)
if method is None:
return []
return method(language, dialect, maximum=maximum)
def _get_current_voice_properties(self):
if hasattr(self, "_current_voice_properties"):
return self._current_voice_properties
if hasattr(self, "_currentVoiceProperties"):
return self._currentVoiceProperties
self._current_voice_properties = {}
return self._current_voice_properties
def set_default_voice(self, voice):
"""Sets the default voice properties."""
if voice is None:
voice = {}
elif hasattr(voice, "copy"):
voice = voice.copy()
else:
voice = dict(voice)
if hasattr(self, "_currentVoiceProperties"):
self._currentVoiceProperties = voice
else:
self._current_voice_properties = voice
def clear_cached_voice_properties(self):
"""Clears cached voice properties so they are applied on the next utterance."""
self._get_current_voice_properties().clear()
def get_voice_family(self):
"""Returns the active voice family."""
from .acss import ACSS
return self._get_current_voice_properties().get(ACSS.FAMILY)
def set_voice_family(self, voice_family):
"""Sets the active voice family."""
from .acss import ACSS
voice = self._get_current_voice_properties().copy()
voice[ACSS.FAMILY] = voice_family
self.set_default_voice(voice)
setter = getattr(self, "_set_family", None) or getattr(self, "_setFamily", None)
if setter is not None:
setter(voice_family)
def speakCharacter(self, character, acss=None): def speakCharacter(self, character, acss=None):
"""Speaks a single character immediately. """Speaks a single character immediately.
@@ -254,6 +335,10 @@ class SpeechServer(object):
""" """
pass pass
def speak_character(self, character, acss=None):
"""Speaks a single character immediately."""
return self.speakCharacter(character, acss)
def speakKeyEvent(self, event, acss=None): def speakKeyEvent(self, event, acss=None):
"""Speaks a key event immediately. """Speaks a key event immediately.
@@ -262,6 +347,10 @@ class SpeechServer(object):
""" """
pass pass
def speak_key_event(self, event, acss=None):
"""Speaks a key event immediately."""
return self.speakKeyEvent(event, acss)
def speak(self, text=None, acss=None, interrupt=True): def speak(self, text=None, acss=None, interrupt=True):
"""Speaks all queued text immediately. If text is not None, """Speaks all queued text immediately. If text is not None,
it is added to the queue before speaking. it is added to the queue before speaking.
@@ -294,34 +383,84 @@ class SpeechServer(object):
""" """
pass pass
def say_all(self, utterance_iterator, progress_callback):
"""Speaks each utterance from the given iterator."""
return self.sayAll(utterance_iterator, progress_callback)
def increaseSpeechRate(self, step=5): def increaseSpeechRate(self, step=5):
"""Increases the speech rate. """Increases the speech rate.
""" """
pass pass
def increase_speech_rate(self, step=5):
"""Increases the speech rate."""
return self.increaseSpeechRate(step)
def decreaseSpeechRate(self, step=5): def decreaseSpeechRate(self, step=5):
"""Decreases the speech rate. """Decreases the speech rate.
""" """
pass pass
def decrease_speech_rate(self, step=5):
"""Decreases the speech rate."""
return self.decreaseSpeechRate(step)
def increaseSpeechPitch(self, step=0.5): def increaseSpeechPitch(self, step=0.5):
"""Increases the speech pitch. """Increases the speech pitch.
""" """
pass pass
def increase_speech_pitch(self, step=0.5):
"""Increases the speech pitch."""
return self.increaseSpeechPitch(step)
def decreaseSpeechPitch(self, step=0.5): def decreaseSpeechPitch(self, step=0.5):
"""Decreases the speech pitch. """Decreases the speech pitch.
""" """
pass pass
def decrease_speech_pitch(self, step=0.5):
"""Decreases the speech pitch."""
return self.decreaseSpeechPitch(step)
def increase_speech_volume(self, step=0.5):
"""Increases the speech volume."""
method = getattr(self, "increaseSpeechVolume", None)
if method is not None:
return method(step)
return None
def decrease_speech_volume(self, step=0.5):
"""Decreases the speech volume."""
method = getattr(self, "decreaseSpeechVolume", None)
if method is not None:
return method(step)
return None
def updateCapitalizationStyle(self): def updateCapitalizationStyle(self):
"""Updates the capitalization style used by the speech server.""" """Updates the capitalization style used by the speech server."""
pass pass
def update_capitalization_style(self, style=None):
"""Updates the capitalization style used by the speech server."""
if style is not None:
from . import settings
settings.capitalizationStyle = getattr(style, "value", style)
return self.updateCapitalizationStyle()
def updatePunctuationLevel(self): def updatePunctuationLevel(self):
"""Punctuation level changed, inform this speechServer.""" """Punctuation level changed, inform this speechServer."""
pass pass
def update_punctuation_level(self, level=None):
"""Updates the punctuation level used by the speech server."""
if level is not None:
from . import settings
settings.verbalizePunctuationStyle = getattr(level, "value", level)
return self.updatePunctuationLevel()
def stop(self): def stop(self):
"""Stops ongoing speech and flushes the queue.""" """Stops ongoing speech and flushes the queue."""
pass pass
@@ -333,3 +472,21 @@ class SpeechServer(object):
def reset(self, text=None, acss=None): def reset(self, text=None, acss=None):
"""Resets the speech engine.""" """Resets the speech engine."""
pass pass
def get_output_module(self):
"""Returns the current output module."""
method = getattr(self, "getOutputModule", None)
if method is not None:
return method()
return ""
def set_output_module(self, module):
"""Sets the current output module."""
method = getattr(self, "setOutputModule", None)
if method is not None:
return method(module)
return None
def list_output_modules(self):
"""Returns available output modules."""
return ()
+36
View File
@@ -0,0 +1,36 @@
"""Regression tests for BrlAPI command exports."""
from __future__ import annotations
import pytest
from cthulhu import braille
@pytest.mark.unit
def test_braille_exports_brlapi_command_aliases():
expected_names = [
"BRLAPI_KEY_CMD_HWINLT",
"BRLAPI_KEY_CMD_FWINLT",
"BRLAPI_KEY_CMD_FWINLTSKIP",
"BRLAPI_KEY_CMD_HWINRT",
"BRLAPI_KEY_CMD_FWINRT",
"BRLAPI_KEY_CMD_FWINRTSKIP",
"BRLAPI_KEY_CMD_LNUP",
"BRLAPI_KEY_CMD_LNDN",
"BRLAPI_KEY_CMD_FREEZE",
"BRLAPI_KEY_CMD_TOP_LEFT",
"BRLAPI_KEY_CMD_BOT_LEFT",
"BRLAPI_KEY_CMD_HOME",
"BRLAPI_KEY_CMD_SIXDOTS",
"BRLAPI_KEY_CMD_ROUTE",
"BRLAPI_KEY_CMD_CUTBEGIN",
"BRLAPI_KEY_CMD_CUTLINE",
]
for name in expected_names:
assert hasattr(braille, name), name
if braille._brlAPIAvailable:
assert braille.BRLAPI_KEY_CMD_HWINLT == braille.brlapi.KEY_CMD_HWINLT
assert braille.BRLAPI_KEY_CMD_ROUTE == braille.brlapi.KEY_CMD_ROUTE
+128
View File
@@ -0,0 +1,128 @@
"""Regression tests for braille API compatibility."""
from __future__ import annotations
import pytest
from cthulhu import braille
@pytest.mark.unit
def test_braille_exports_snake_case_compatibility_apis():
expected_names = [
"check_braille_setting",
"disable_braille",
"display_line",
"display_message",
"get_caret_context",
"get_default_contraction_table",
"get_region_at_cell",
"is_flash_active",
"kill_flash",
"pan_left",
"pan_right",
"process_routing_key",
"return_to_region_with_focus",
"set_brlapi_priority",
"set_contraction_table",
"set_enable_braille",
"set_enable_computer_braille_at_cursor",
"set_enable_contracted_braille",
"set_enable_eol",
"set_enable_word_wrap",
"set_link_indicator",
"set_monitor_callback",
"set_selector_indicator",
"set_text_attributes_indicator",
"setup_key_ranges",
"toggle_contracted_braille",
"try_reposition_cursor",
]
for name in expected_names:
assert hasattr(braille, name), name
@pytest.mark.unit
def test_line_exports_snake_case_helpers():
line = braille.Line()
first = braille.Region("first")
second = braille.Region("second")
line.add_region(first)
line.add_regions([second])
assert line.regions == [first, second]
assert line.get_line_info() == line.getLineInfo()
@pytest.mark.unit
def test_setup_key_ranges_delegates_to_legacy_api(monkeypatch):
calls = []
def setup_key_ranges(keys):
calls.append(keys)
monkeypatch.setattr(braille, "setupKeyRanges", setup_key_ranges)
braille.setup_key_ranges({1, 2})
assert calls == [{1, 2}]
@pytest.mark.unit
def test_snake_case_runtime_setters_update_legacy_settings(monkeypatch):
monkeypatch.setattr(braille.settings, "enableBraille", True)
monkeypatch.setattr(braille.settings, "enableContractedBraille", False)
monkeypatch.setattr(braille.settings, "brailleContractionTable", "")
monkeypatch.setattr(braille.settings, "disableBrailleEOL", False)
monkeypatch.setattr(braille.settings, "enableBrailleWordWrap", False)
monkeypatch.setattr(braille.settings, "brailleSelectorIndicator", 0)
monkeypatch.setattr(braille.settings, "brailleLinkIndicator", 0)
monkeypatch.setattr(braille.settings, "textAttributesBrailleIndicator", 0)
braille.set_enable_braille(False)
braille.set_enable_contracted_braille(True)
braille.set_contraction_table("/tmp/table.ctb")
braille.set_enable_eol(False)
braille.set_enable_word_wrap(True)
braille.set_selector_indicator(64)
braille.set_link_indicator(128)
braille.set_text_attributes_indicator(192)
assert braille.settings.enableBraille is False
assert braille.settings.enableContractedBraille is True
assert braille.settings.brailleContractionTable == "/tmp/table.ctb"
assert braille.settings.disableBrailleEOL is True
assert braille.settings.enableBrailleWordWrap is True
assert braille.settings.brailleSelectorIndicator == 64
assert braille.settings.brailleLinkIndicator == 128
assert braille.settings.textAttributesBrailleIndicator == 192
@pytest.mark.unit
def test_display_message_accepts_snake_case_keyword(monkeypatch):
calls = []
def display_message(message, cursor=-1, flashTime=0):
calls.append((message, cursor, flashTime))
monkeypatch.setattr(braille, "displayMessage", display_message)
braille.display_message("hello", flash_time=250)
assert calls == [("hello", -1, 250)]
@pytest.mark.unit
def test_kill_flash_accepts_snake_case_keyword(monkeypatch):
calls = []
def kill_flash(restoreSaved=True):
calls.append(restoreSaved)
monkeypatch.setattr(braille, "killFlash", kill_flash)
braille.kill_flash(restore_saved=False)
assert calls == [False]
+38
View File
@@ -0,0 +1,38 @@
"""Regression tests for command-name exports."""
from __future__ import annotations
import pytest
from cthulhu import cmdnames
@pytest.mark.unit
@pytest.mark.parametrize(
"name",
[
"BYPASS_MODE_TOGGLE",
"CHAT_NEXT_MESSAGE",
"CLIPBOARD_PRESENT_CONTENTS",
"DEBUG_CLEAR_ATSPI_CACHE_FOR_APPLICATION",
"DEBUG_CYCLE_LEVEL",
"LIVE_REGIONS_ARE_ANNOUNCED",
"LIVE_REGIONS_NEXT",
"LIVE_REGIONS_PREVIOUS",
"PRESENT_BATTERY_STATUS",
"PRESENT_CELL_FORMULA",
"PRESENT_CPU_AND_MEMORY_USAGE",
"PRESENT_CURRENT_PROFILE",
"STRUCTURAL_NAVIGATION_MODE_CYCLE",
"TABLE_CELL_BEGINNING_OF_ROW",
"TABLE_CELL_BOTTOM_OF_COLUMN",
"TABLE_CELL_END_OF_ROW",
"TABLE_CELL_TOP_OF_COLUMN",
"TABLE_NAVIGATION_TOGGLE",
"TOGGLE_BRAILLE_MONITOR",
"TOGGLE_KEYBOARD_LAYOUT",
"TOGGLE_SPEECH_MONITOR",
],
)
def test_referenced_command_name_is_exported(name: str) -> None:
assert getattr(cmdnames, name)
+33
View File
@@ -0,0 +1,33 @@
"""Regression tests for focus manager API compatibility."""
from __future__ import annotations
import pytest
from cthulhu import focus_manager
@pytest.mark.unit
def test_is_in_preferences_window_tracks_cthulhu_active_window(monkeypatch):
manager = focus_manager.FocusManager.__new__(focus_manager.FocusManager)
window = object()
app = object()
manager._window = window
monkeypatch.setattr(focus_manager.AXObject, "get_application", lambda obj: app)
monkeypatch.setattr(focus_manager.AXObject, "get_name", lambda obj: "Cthulhu")
assert manager.is_in_preferences_window() is True
@pytest.mark.unit
def test_is_in_preferences_window_is_false_for_non_cthulhu_window(monkeypatch):
manager = focus_manager.FocusManager.__new__(focus_manager.FocusManager)
window = object()
app = object()
manager._window = window
monkeypatch.setattr(focus_manager.AXObject, "get_application", lambda obj: app)
monkeypatch.setattr(focus_manager.AXObject, "get_name", lambda obj: "Terminal")
assert manager.is_in_preferences_window() is False
+23
View File
@@ -348,6 +348,29 @@ class TestInputEventManager:
if case["expects_debug_call"]: if case["expects_debug_call"]:
essential_modules["cthulhu.debug"].print_tokens.assert_called() essential_modules["cthulhu.debug"].print_tokens.assert_called()
def test_add_grabs_for_keybinding_allows_default_cthulhu_modifiers(
self,
test_context: CthulhuTestContext,
) -> None:
"""Plugin bindings can be grabbed before a script supplies modifier keys."""
input_event_manager, _essential_modules = self._setup_input_event_manager(test_context)
mock_device = test_context.Mock()
mock_device.add_key_grab.return_value = 123
input_event_manager._device = mock_device
mock_kd = test_context.Mock()
mock_binding = test_context.Mock()
mock_binding.has_grabs.return_value = False
mock_binding.key_definitions.return_value = [mock_kd]
result = input_event_manager.add_grabs_for_keybinding(mock_binding)
assert result == [123]
mock_binding.key_definitions.assert_called_once_with(None)
mock_device.add_key_grab.assert_called_once_with(mock_kd, None)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"case", "case",
[ [
+88
View File
@@ -0,0 +1,88 @@
"""Regression tests for keybinding API compatibility."""
from __future__ import annotations
import pytest
from cthulhu import keybindings
class DummyHandler:
description = "dummy handler"
@pytest.mark.unit
def test_keybinding_accepts_lightweight_command_manager_form():
binding = keybindings.KeyBinding("KP_Divide", keybindings.CTHULHU_MODIFIER_MASK)
assert binding.keysymstring == "KP_Divide"
assert binding.modifier_mask == keybindings.defaultModifierMask
assert binding.modifiers == keybindings.CTHULHU_MODIFIER_MASK
assert binding.handler is None
assert binding.click_count == 1
@pytest.mark.unit
def test_keybinding_accepts_lightweight_positional_click_count():
binding = keybindings.KeyBinding("a", keybindings.CTHULHU_MODIFIER_MASK, 2)
assert binding.modifier_mask == keybindings.defaultModifierMask
assert binding.modifiers == keybindings.CTHULHU_MODIFIER_MASK
assert binding.click_count == 2
@pytest.mark.unit
def test_keybinding_preserves_legacy_handler_form():
handler = DummyHandler()
binding = keybindings.KeyBinding(
"b",
keybindings.defaultModifierMask,
keybindings.CTHULHU_SHIFT_MODIFIER_MASK,
handler,
3,
)
assert binding.modifier_mask == keybindings.defaultModifierMask
assert binding.modifiers == keybindings.CTHULHU_SHIFT_MODIFIER_MASK
assert binding.handler is handler
assert binding.click_count == 3
@pytest.mark.unit
def test_keybinding_exposes_command_manager_helpers():
binding = keybindings.KeyBinding("c", keybindings.CTHULHU_MODIFIER_MASK)
assert keybindings.get_modifier_names is keybindings.getModifierNames
assert keybindings.CTHULHU_ALT_SHIFT_MODIFIER_MASK == (
keybindings.CTHULHU_MODIFIER_MASK
| keybindings.ALT_MODIFIER_MASK
| keybindings.SHIFT_MODIFIER_MASK
)
binding.set_grab_ids([42])
assert binding.get_grab_ids() == [42]
@pytest.mark.unit
def test_keybinding_add_grabs_accepts_explicit_cthulhu_modifiers(monkeypatch):
binding = keybindings.KeyBinding("d", keybindings.CTHULHU_MODIFIER_MASK)
captured = {}
class FakeInputEventManager:
def add_grabs_for_keybinding(self, grab_binding, cthulhu_modifiers):
captured["binding"] = grab_binding
captured["cthulhu_modifiers"] = cthulhu_modifiers
return [7]
from cthulhu import input_event_manager
monkeypatch.setattr(
input_event_manager,
"get_manager",
lambda: FakeInputEventManager(),
)
binding.add_grabs(["Insert"])
assert captured == {"binding": binding, "cthulhu_modifiers": ["Insert"]}
assert binding.get_grab_ids() == [7]
+6
View File
@@ -17,6 +17,12 @@ from cthulhu import learn_mode_presenter
class LearnModePresenterRegressionTests(unittest.TestCase): class LearnModePresenterRegressionTests(unittest.TestCase):
def test_get_presenter_alias_matches_legacy_get_presenter(self):
self.assertIs(learn_mode_presenter.get_presenter, learn_mode_presenter.getPresenter)
def test_exposes_default_extension_setup_hook(self):
self.assertTrue(hasattr(learn_mode_presenter.LearnModePresenter, "set_up_commands"))
def test_escape_keyval_exits_learn_mode_when_event_string_is_control_character(self): def test_escape_keyval_exits_learn_mode_when_event_string_is_control_character(self):
presenter = learn_mode_presenter.LearnModePresenter(mock.Mock()) presenter = learn_mode_presenter.LearnModePresenter(mock.Mock())
presenter._is_active = True presenter._is_active = True
@@ -59,6 +59,12 @@ class InputEventManagerPointerMonitorTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.manager = input_event_manager.InputEventManager() self.manager = input_event_manager.InputEventManager()
def test_get_reviewer_alias_matches_legacy_get_reviewer(self):
self.assertIs(mouse_review.get_reviewer, mouse_review.getReviewer)
def test_exposes_default_extension_setup_hook(self):
self.assertTrue(hasattr(mouse_review.MouseReviewer, "set_up_commands"))
def test_activate_device_creates_the_device_only_once(self): def test_activate_device_creates_the_device_only_once(self):
device = FakeDevice() device = FakeDevice()
deviceFactory = FakeDeviceFactory(device) deviceFactory = FakeDeviceFactory(device)
@@ -0,0 +1,15 @@
"""Compatibility tests for the notification presenter."""
from __future__ import annotations
import pytest
from cthulhu import notification_presenter
@pytest.mark.unit
def test_get_presenter_alias_matches_legacy_get_presenter() -> None:
"""The Mako monitor still uses the legacy camelCase singleton accessor."""
assert notification_presenter.getPresenter is notification_presenter.get_presenter
assert notification_presenter.getPresenter() is notification_presenter.get_presenter()
+61
View File
@@ -0,0 +1,61 @@
"""Regression tests for script startup ordering."""
from __future__ import annotations
import pytest
from cthulhu.scripts import default
script_module = default.script
@pytest.mark.unit
def test_script_initializes_formatting_before_generators(monkeypatch):
"""Generator construction needs script.formatting during startup."""
monkeypatch.setattr(script_module.AXObject, "get_name", staticmethod(lambda _app: "test-app"))
init_order = []
formatting = {"sentinel": True}
class FormattingAwareScript(script_module.Script):
def get_formatting(self):
init_order.append("formatting")
return formatting
def get_listeners(self):
return {}
def get_utilities(self):
return object()
def _create_braille_generator(self):
init_order.append("braille")
assert self.formatting is formatting
return "braille"
def _create_sound_generator(self):
init_order.append("sound")
assert self.formatting is formatting
return "sound"
def _create_speech_generator(self):
init_order.append("speech")
assert self.formatting is formatting
return "speech"
def get_label_inference(self):
return None
def get_chat(self):
return None
def set_up_commands(self):
pass
test_script = FormattingAwareScript(object())
assert init_order == ["formatting", "braille", "sound", "speech"]
assert test_script.formatting is formatting
assert test_script.get_braille_generator() == "braille"
assert test_script.get_sound_generator() == "sound"
assert test_script.get_speech_generator() == "speech"
+55
View File
@@ -0,0 +1,55 @@
"""Compatibility tests for the sleep mode manager."""
from __future__ import annotations
import importlib
import sys
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from .cthulhu_test_context import CthulhuTestContext
@pytest.mark.unit
def test_get_manager_alias_matches_legacy_get_manager(
test_context: CthulhuTestContext,
) -> None:
"""The default script expects the snake_case singleton accessor."""
test_context.setup_shared_dependencies()
sys.modules.pop("cthulhu.sleep_mode_manager", None)
sleep_mode_manager = importlib.import_module("cthulhu.sleep_mode_manager")
assert sleep_mode_manager.get_manager is sleep_mode_manager.getManager
assert sleep_mode_manager.get_manager() is sleep_mode_manager.getManager()
@pytest.mark.unit
def test_sleep_mode_manager_exposes_default_extension_setup_hook(
test_context: CthulhuTestContext,
) -> None:
"""The default script calls set_up_commands() on all extensions."""
test_context.setup_shared_dependencies()
sys.modules.pop("cthulhu.sleep_mode_manager", None)
sleep_mode_manager = importlib.import_module("cthulhu.sleep_mode_manager")
assert hasattr(sleep_mode_manager.get_manager(), "set_up_commands")
@pytest.mark.unit
def test_sleep_mode_manager_exposes_snake_case_app_state_accessor(
test_context: CthulhuTestContext,
) -> None:
"""The script manager checks sleep mode using the snake_case method name."""
test_context.setup_shared_dependencies()
sys.modules.pop("cthulhu.sleep_mode_manager", None)
sleep_mode_manager = importlib.import_module("cthulhu.sleep_mode_manager")
manager = sleep_mode_manager.get_manager()
app = object()
assert manager.is_active_for_app(app) == manager.isActiveForApp(app)
+53
View File
@@ -0,0 +1,53 @@
"""Regression tests for speech monitor callback registration."""
from __future__ import annotations
import pytest
from cthulhu import speech
@pytest.mark.unit
def test_set_monitor_callbacks_accepts_snake_case_keywords() -> None:
captured: list[str] = []
speech.set_monitor_callbacks(
write_text=captured.append,
write_key=lambda _key: None,
begin_group=lambda: None,
end_group=lambda: None,
)
try:
speech._write_to_monitor("hello")
finally:
speech.set_monitor_callbacks()
assert captured == ["hello"]
@pytest.mark.unit
def test_set_monitor_callbacks_preserves_legacy_write_text_argument() -> None:
captured: list[str] = []
speech.set_monitor_callbacks(writeText=captured.append)
try:
speech._write_to_monitor("legacy")
finally:
speech.set_monitor_callbacks()
assert captured == ["legacy"]
@pytest.mark.unit
def test_speech_server_snake_case_accessors_delegate_to_legacy_api(monkeypatch) -> None:
monkeypatch.setattr(speech, "_refreshEchoSpeechServer", lambda: None)
original_server = speech.getSpeechServer()
server = object()
try:
speech.set_server(server)
assert speech.get_speech_server() is server
assert speech.getSpeechServer() is server
finally:
speech.setSpeechServer(original_server)
+121
View File
@@ -0,0 +1,121 @@
"""Regression tests for speech server API compatibility."""
from __future__ import annotations
import pytest
from cthulhu import speechserver
from cthulhu.acss import ACSS
class DummySpeechServer(speechserver.SpeechServer):
factory_calls = []
active_servers_shutdown = False
@staticmethod
def getFactoryName():
return "Dummy"
@staticmethod
def getSpeechServers():
return ["server-a"]
@staticmethod
def getSpeechServer(info=None):
DummySpeechServer.factory_calls.append(info)
return DummySpeechServer()
@staticmethod
def shutdownActiveServers():
DummySpeechServer.active_servers_shutdown = True
def __init__(self):
self.calls = []
self._current_voice_properties = {}
def getInfo(self):
return ["Dummy", "dummy"]
def getVoiceFamilies(self):
return ["voice-a"]
def getVoiceFamiliesForLanguage(self, language, dialect="", maximum=None):
return [(language, dialect, maximum)]
def getOutputModule(self):
return "module-a"
def setOutputModule(self, module):
self.calls.append(("setOutputModule", module))
def updateCapitalizationStyle(self):
self.calls.append(("updateCapitalizationStyle",))
def updatePunctuationLevel(self):
self.calls.append(("updatePunctuationLevel",))
def increaseSpeechRate(self, step=5):
self.calls.append(("increaseSpeechRate", step))
def decreaseSpeechVolume(self, step=0.5):
self.calls.append(("decreaseSpeechVolume", step))
@pytest.mark.unit
def test_snake_case_factory_methods_delegate_to_legacy_methods():
DummySpeechServer.factory_calls.clear()
DummySpeechServer.active_servers_shutdown = False
server = DummySpeechServer.get_speech_server(("Dummy", "dummy"))
assert isinstance(server, DummySpeechServer)
assert DummySpeechServer.factory_calls == [("Dummy", "dummy")]
assert DummySpeechServer.get_speech_servers() == ["server-a"]
assert DummySpeechServer.get_factory_name() == "Dummy"
DummySpeechServer.shutdown_active_servers()
assert DummySpeechServer.active_servers_shutdown is True
@pytest.mark.unit
def test_snake_case_instance_methods_delegate_to_legacy_methods():
server = DummySpeechServer()
assert server.get_info() == ["Dummy", "dummy"]
assert server.get_voice_families() == ["voice-a"]
assert server.get_voice_families_for_language("en", variant="us") == [("en", "us", None)]
assert server.get_output_module() == "module-a"
server.set_output_module("module-b")
server.update_capitalization_style("none")
server.update_punctuation_level(speechserver.PunctuationStyle.SOME)
server.increase_speech_rate(9)
server.decrease_speech_volume(1.5)
assert server.calls == [
("setOutputModule", "module-b"),
("updateCapitalizationStyle",),
("updatePunctuationLevel",),
("increaseSpeechRate", 9),
("decreaseSpeechVolume", 1.5),
]
@pytest.mark.unit
def test_voice_property_helpers_store_and_clear_default_voice():
server = DummySpeechServer()
default_voice = {ACSS.FAMILY: {speechserver.VoiceFamily.NAME: "Voice A"}}
next_family = {speechserver.VoiceFamily.NAME: "Voice B"}
server.set_default_voice(default_voice)
assert server.get_voice_family() == default_voice[ACSS.FAMILY]
server.set_voice_family(next_family)
assert server.get_voice_family() == next_family
server.clear_cached_voice_properties()
assert server.get_voice_family() is None