diff --git a/src/cthulhu/braille.py b/src/cthulhu/braille.py index 71f1b4c..9445144 100644 --- a/src/cthulhu/braille.py +++ b/src/cthulhu/braille.py @@ -64,7 +64,9 @@ from .cthulhu_platform import tablesdir _logger = None log = None _monitor = None +_monitorCallback = None _settingsManager = None +_enableComputerBrailleAtCursor = True def _ensureLogger(): """Ensure logger is initialized.""" @@ -99,6 +101,30 @@ else: tokens = ["BRAILLE: brlapi imported", brlapi] 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_DEFAULT = 50 BRLAPI_PRIORITY_HIGH = 70 @@ -936,9 +962,15 @@ class Line: def addRegion(self, region): self.regions.append(region) + def add_region(self, region): + self.addRegion(region) + def addRegions(self, regions): self.regions.extend(regions) + def add_regions(self, regions): + self.addRegions(regions) + def getLineInfo(self, getLinkMask=True): """Computes the complete string for this line as well as a 0-based index where the focused region starts on this line. @@ -998,6 +1030,9 @@ class Line: return [string, focusOffset, attributeMask, ranges] + def get_line_info(self, get_link_mask=True): + return self.getLineInfo(get_link_mask) + def getRegionAtOffset(self, offset): """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() 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: _monitor = brlmon.BrlMon(_displaySize[0]) _monitor.show_all() except Exception: debug.printMessage(debug.LEVEL_WARNING, "brlmon failed") _monitor = None - if attributeMask: - subMask = attributeMask[startPos:endPos] - else: - subMask = None - if _monitor: + if _monitor and not _monitorCallback: _monitor.writeText(cursorCell, substring, subMask) elif _monitor: _monitor.destroy() @@ -2110,3 +2147,135 @@ def shutdown(): msg = "BRAILLE: Braille shutdown complete." debug.printMessage(debug.LEVEL_INFO, msg, 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 diff --git a/src/cthulhu/cmdnames.py b/src/cthulhu/cmdnames.py index 7b3ab06..7bcc75d 100644 --- a/src/cthulhu/cmdnames.py +++ b/src/cthulhu/cmdnames.py @@ -37,6 +37,28 @@ __license__ = "LGPL" 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 # without clicking on it. ROUTE_POINTER_TO_ITEM = _("Route the pointer to the current item") diff --git a/src/cthulhu/focus_manager.py b/src/cthulhu/focus_manager.py index 47a3be4..b4a0e1b 100644 --- a/src/cthulhu/focus_manager.py +++ b/src/cthulhu/focus_manager.py @@ -122,6 +122,11 @@ class FocusManager: appName = (AXObject.get_name(app) or "").lower() 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: """Returns True if we have no knowledge about what is focused.""" diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 6b84839..616e595 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -115,7 +115,7 @@ class InputEventManager: def add_grabs_for_keybinding( self, binding: keybindings.KeyBinding, - cthulhu_modifiers: list[str], + cthulhu_modifiers: list[str] | None = None, ) -> list[int]: """Adds grabs for binding, returns grab IDs.""" diff --git a/src/cthulhu/keybindings.py b/src/cthulhu/keybindings.py index d16f380..6f27d4d 100644 --- a/src/cthulhu/keybindings.py +++ b/src/cthulhu/keybindings.py @@ -67,6 +67,9 @@ CTHULHU_CTRL_MODIFIER_MASK = (1 << MODIFIER_CTHULHU | CTHULHU_CTRL_ALT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU | 1 << Atspi.ModifierType.CONTROL | 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 | 1 << Atspi.ModifierType.SHIFT) SHIFT_MODIFIER_MASK = 1 << Atspi.ModifierType.SHIFT @@ -171,6 +174,8 @@ def getModifierNames(mods): text += _("Shift") + "+" return text +get_modifier_names = getModifierNames + def get_click_countString(count): """Returns a human-consumable string representing the number of clicks, such as 'double click' and 'triple click'.""" @@ -189,7 +194,7 @@ def get_click_countString(count): return _("triple click") 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.""" ret = [] if modifiers & CTHULHU_MODIFIER_MASK: @@ -197,7 +202,7 @@ def create_key_definitions(keycode, keyval, modifiers): other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK from . import input_event_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) if mod_keycode == 0 and key == "Shift_Lock": mod_keyval, mod_keycode = get_keycodes("Caps_Lock") @@ -224,14 +229,22 @@ class KeyBinding: and the InputEventHandler. """ - def __init__(self, keysymstring, modifier_mask, modifiers, handler, - click_count = 1, enabled=True): + def __init__( + self, + keysymstring, + modifier_mask_or_modifiers, + *args, + click_count=1, + enabled=True, + ): """Creates a new key binding. Arguments: - keysymstring: the keysymstring - this is typically a string from /usr/include/X11/keysymdef.h with the preceding 'XK_' 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 we care about (see Atspi.ModifierType.*) - 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 """ + 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.modifier_mask = modifier_mask self.modifiers = modifiers @@ -296,14 +327,21 @@ class KeyBinding: """Returns the grab IDs for this KeyBinding.""" 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): """Returns True if there are existing grabs associated with this KeyBinding.""" return bool(self._grab_ids) - def add_grabs(self): + def add_grabs(self, cthulhu_modifiers=None): """Adds key grabs for this KeyBinding.""" 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): """Removes key grabs for this KeyBinding.""" @@ -311,21 +349,35 @@ class KeyBinding: input_event_manager.get_manager().remove_grabs_for_keybinding(self) 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.""" ret = [] if not self.keycode: 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: upper_keyval = Gdk.keyval_to_upper(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 - def keyDefs(self): + def keyDefs(self, cthulhu_modifiers=None): """Legacy wrapper. Use key_definitions() instead.""" - return self.key_definitions() + return self.key_definitions(cthulhu_modifiers) class KeyBindings: """Structure that maintains a set of KeyBinding instances. diff --git a/src/cthulhu/learn_mode_presenter.py b/src/cthulhu/learn_mode_presenter.py index c8a32f3..d7ad2d9 100644 --- a/src/cthulhu/learn_mode_presenter.py +++ b/src/cthulhu/learn_mode_presenter.py @@ -64,6 +64,7 @@ class LearnModePresenter: self.app = app self._handlers = self._setup_handlers() self._bindings = self._setup_bindings() + self._initialized = False self._is_active = False self._gui = None @@ -82,6 +83,27 @@ class LearnModePresenter: 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): """Sets up and returns the learn-mode-presenter input event handlers.""" @@ -405,3 +427,5 @@ def getPresenter(): _presenter = LearnModePresenter(cthulhu.cthulhuApp) return _presenter + +get_presenter = getPresenter diff --git a/src/cthulhu/mako_notification_monitor.py b/src/cthulhu/mako_notification_monitor.py index 066b102..a9bb344 100644 --- a/src/cthulhu/mako_notification_monitor.py +++ b/src/cthulhu/mako_notification_monitor.py @@ -24,6 +24,7 @@ from __future__ import annotations import html import re +import sys from typing import Any, Optional from gi.repository import Gio, GLib @@ -188,7 +189,8 @@ class MakoNotificationMonitor: return self._generation 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: self._presenter = presenter diff --git a/src/cthulhu/mouse_review.py b/src/cthulhu/mouse_review.py index 569c8fe..98a515c 100644 --- a/src/cthulhu/mouse_review.py +++ b/src/cthulhu/mouse_review.py @@ -364,6 +364,7 @@ class MouseReviewer: self._useAtspi = False self._handlers = self._setup_handlers() self._bindings = self._setup_bindings() + self._initialized = False atspiVersion = Atspi.get_version() capabilityEnum = getattr(Atspi, "DeviceCapability", None) @@ -434,6 +435,24 @@ class MouseReviewer: 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): """Sets up and returns the mouse-review input event handlers.""" @@ -831,3 +850,6 @@ def getReviewer(): from . import cthulhu _reviewer = MouseReviewer(cthulhu.cthulhuApp) return _reviewer + + +get_reviewer = getReviewer diff --git a/src/cthulhu/notification_presenter.py b/src/cthulhu/notification_presenter.py index 3aac39e..9ce080a 100644 --- a/src/cthulhu/notification_presenter.py +++ b/src/cthulhu/notification_presenter.py @@ -30,6 +30,7 @@ from __future__ import annotations import time +from dataclasses import dataclass, field from typing import TYPE_CHECKING import gi @@ -54,6 +55,28 @@ if TYPE_CHECKING: 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: """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 # python list, i.e. self._notifications[-3] would return the third-to-last # notification message. - self._notifications: list[tuple[str, float]] = [] + self._notifications: list[NotificationEntry] = [] self._current_index: int = -1 self._initialized: bool = False + self._mako_monitor = None msg = "NOTIFICATION PRESENTER: Registering D-Bus commands." debug.print_message(debug.LEVEL_INFO, msg, True) @@ -122,14 +146,42 @@ class NotificationPresenter: msg = "NOTIFICATION PRESENTER: Commands set up." 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.""" tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."] 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.append((message, time.time())) + return entry def clear_list(self) -> None: """Clears the notifications list.""" @@ -155,6 +207,101 @@ class NotificationPresenter: days = round(diff / 86400) 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 def present_last_notification( self, @@ -316,8 +463,8 @@ class NotificationPresenter: return True rows = [ - (message, self._timestamp_to_string(timestamp)) - for message, timestamp in reversed(self._notifications) + (entry.message, self._timestamp_to_string(entry.timestamp), entry) + for entry in reversed(self._notifications) ] title = guilabels.notifications_count(len(self._notifications)) column_headers = [ @@ -330,6 +477,7 @@ class NotificationPresenter: column_headers, rows, self.on_dialog_destroyed, + self, ) self._gui.show_gui() return True @@ -348,11 +496,17 @@ class NotificationListGUI: script: default.Script, title: str, column_headers: list[str], - rows: list[tuple[str, str]], + rows: list[tuple[str, str, NotificationEntry]], destroyed_callback: Callable[[Gtk.Dialog], None], + presenter: NotificationPresenter | None = None, ): self._script: default.Script = script + self._presenter: NotificationPresenter = presenter or get_presenter() 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.connect("destroy", destroyed_callback) @@ -360,7 +514,7 @@ class NotificationListGUI: self, title: str, column_headers: list[str], - rows: list[tuple[str, str]], + rows: list[tuple[str, str, NotificationEntry]], ) -> Gtk.Dialog: dialog = Gtk.Dialog( title, @@ -381,8 +535,9 @@ class NotificationListGUI: tree.set_hexpand(True) tree.set_vexpand(True) 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): cell = Gtk.CellRendererText() column = Gtk.TreeViewColumn(header, cell, text=i) @@ -400,6 +555,93 @@ class NotificationListGUI: dialog.connect("response", self.on_response) 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: """The handler for the 'response' signal.""" @@ -428,3 +670,6 @@ def get_presenter() -> NotificationPresenter: """Returns the Notification Presenter""" return _presenter + + +getPresenter = get_presenter diff --git a/src/cthulhu/script.py b/src/cthulhu/script.py index d499897..8bad272 100644 --- a/src/cthulhu/script.py +++ b/src/cthulhu/script.py @@ -31,6 +31,7 @@ from . import ( braille_generator, chat_presenter, debug, + formatting, script_utilities, sound_generator, speech_generator, @@ -62,6 +63,7 @@ class Script: self.listeners = self.get_listeners() self.utilities = self.get_utilities() + self.formatting = self.get_formatting() self._braille_generator = self._create_braille_generator() self._sound_generator = self._create_sound_generator() @@ -137,6 +139,18 @@ class Script: 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: """Creates and returns the sound generator for this script.""" diff --git a/src/cthulhu/sleep_mode_manager.py b/src/cthulhu/sleep_mode_manager.py index 75c3f45..2768493 100644 --- a/src/cthulhu/sleep_mode_manager.py +++ b/src/cthulhu/sleep_mode_manager.py @@ -49,6 +49,7 @@ class SleepModeManager: def __init__(self): self._handlers = self.getHandlers(True) self._bindings = keybindings.KeyBindings() + self._initialized = False self._apps = set() self._disabledAutoSleepApps = set() self._autoSleepAppNames = set() @@ -80,6 +81,31 @@ class SleepModeManager: 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): """Returns True if sleep mode is active for app.""" @@ -96,6 +122,11 @@ class SleepModeManager: debug.printTokens(debug.LEVEL_INFO, tokens, True) 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): prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu") try: @@ -301,3 +332,6 @@ _manager = SleepModeManager() def getManager(): """Returns the Sleep Mode Manager singleton.""" return _manager + + +get_manager = getManager diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index e874645..93c16d2 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -76,6 +76,9 @@ _timestamp: float = 0.0 # Optional callback for live monitoring of spoken text. _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: if not moduleName: @@ -310,6 +313,10 @@ def getSpeechServer() -> Optional[SpeechServer]: """Returns the speech server instance.""" return _speechserver +def get_speech_server() -> Optional[SpeechServer]: + """Returns the speech server instance.""" + return getSpeechServer() + def setSpeechServer(speechServer: SpeechServer) -> None: """Sets the speech server to be used. @@ -320,10 +327,30 @@ def setSpeechServer(speechServer: SpeechServer) -> None: _speechserver = speechServer _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.""" 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: """Writes text to the active speech monitor callback if set.""" diff --git a/src/cthulhu/speechserver.py b/src/cthulhu/speechserver.py index a814161..bcf6f12 100644 --- a/src/cthulhu/speechserver.py +++ b/src/cthulhu/speechserver.py @@ -207,6 +207,11 @@ class SpeechServer(object): """Returns a localized name describing this factory.""" pass + @classmethod + def get_factory_name(cls): + """Returns a localized name describing this factory.""" + return cls.getFactoryName() + @staticmethod def getSpeechServers(): """Gets available speech servers as a list. The caller @@ -215,6 +220,11 @@ class SpeechServer(object): """ pass + @classmethod + def get_speech_servers(cls): + """Gets available speech servers as a list.""" + return cls.getSpeechServers() + @staticmethod def getSpeechServer(info): """Gets a given SpeechServer based upon the info. @@ -222,12 +232,22 @@ class SpeechServer(object): """ pass + @classmethod + def get_speech_server(cls, info=None): + """Gets a given SpeechServer based upon the info.""" + return cls.getSpeechServer(info) + @staticmethod def shutdownActiveServers(): """Cleans up and shuts down this factory. """ pass + @classmethod + def shutdown_active_servers(cls): + """Cleans up and shuts down this factory.""" + return cls.shutdownActiveServers() + def __init__(self): pass @@ -236,11 +256,72 @@ class SpeechServer(object): """ pass + def get_info(self): + """Returns [name, id].""" + return self.getInfo() + def getVoiceFamilies(self): """Returns a list of VoiceFamily instances representing all voice families known by the speech server.""" 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): """Speaks a single character immediately. @@ -254,6 +335,10 @@ class SpeechServer(object): """ pass + def speak_character(self, character, acss=None): + """Speaks a single character immediately.""" + return self.speakCharacter(character, acss) + def speakKeyEvent(self, event, acss=None): """Speaks a key event immediately. @@ -262,6 +347,10 @@ class SpeechServer(object): """ 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): """Speaks all queued text immediately. If text is not None, it is added to the queue before speaking. @@ -294,34 +383,84 @@ class SpeechServer(object): """ 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): """Increases the speech rate. """ pass + def increase_speech_rate(self, step=5): + """Increases the speech rate.""" + return self.increaseSpeechRate(step) + def decreaseSpeechRate(self, step=5): """Decreases the speech rate. """ pass + def decrease_speech_rate(self, step=5): + """Decreases the speech rate.""" + return self.decreaseSpeechRate(step) + def increaseSpeechPitch(self, step=0.5): """Increases the speech pitch. """ pass + def increase_speech_pitch(self, step=0.5): + """Increases the speech pitch.""" + return self.increaseSpeechPitch(step) + def decreaseSpeechPitch(self, step=0.5): """Decreases the speech pitch. """ 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): """Updates the capitalization style used by the speech server.""" 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): """Punctuation level changed, inform this speechServer.""" 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): """Stops ongoing speech and flushes the queue.""" pass @@ -333,3 +472,21 @@ class SpeechServer(object): def reset(self, text=None, acss=None): """Resets the speech engine.""" 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 () diff --git a/tests/test_braille_brlapi_constants.py b/tests/test_braille_brlapi_constants.py new file mode 100644 index 0000000..350a529 --- /dev/null +++ b/tests/test_braille_brlapi_constants.py @@ -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 diff --git a/tests/test_braille_compat.py b/tests/test_braille_compat.py new file mode 100644 index 0000000..28bd79c --- /dev/null +++ b/tests/test_braille_compat.py @@ -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] diff --git a/tests/test_cmdnames_exports.py b/tests/test_cmdnames_exports.py new file mode 100644 index 0000000..85aa566 --- /dev/null +++ b/tests/test_cmdnames_exports.py @@ -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) diff --git a/tests/test_focus_manager_compat.py b/tests/test_focus_manager_compat.py new file mode 100644 index 0000000..845c3e1 --- /dev/null +++ b/tests/test_focus_manager_compat.py @@ -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 diff --git a/tests/test_input_event_manager.py b/tests/test_input_event_manager.py index bc92625..5b13ede 100644 --- a/tests/test_input_event_manager.py +++ b/tests/test_input_event_manager.py @@ -348,6 +348,29 @@ class TestInputEventManager: if case["expects_debug_call"]: 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( "case", [ diff --git a/tests/test_keybindings_compat.py b/tests/test_keybindings_compat.py new file mode 100644 index 0000000..b6582a4 --- /dev/null +++ b/tests/test_keybindings_compat.py @@ -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] diff --git a/tests/test_learn_mode_regressions.py b/tests/test_learn_mode_regressions.py index 69336e7..d959906 100644 --- a/tests/test_learn_mode_regressions.py +++ b/tests/test_learn_mode_regressions.py @@ -17,6 +17,12 @@ from cthulhu import learn_mode_presenter 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): presenter = learn_mode_presenter.LearnModePresenter(mock.Mock()) presenter._is_active = True diff --git a/tests/test_mouse_review_pointer_monitor_regressions.py b/tests/test_mouse_review_pointer_monitor_regressions.py index 4104fd5..00db692 100644 --- a/tests/test_mouse_review_pointer_monitor_regressions.py +++ b/tests/test_mouse_review_pointer_monitor_regressions.py @@ -59,6 +59,12 @@ class InputEventManagerPointerMonitorTests(unittest.TestCase): def setUp(self): 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): device = FakeDevice() deviceFactory = FakeDeviceFactory(device) diff --git a/tests/test_notification_presenter_compat.py b/tests/test_notification_presenter_compat.py new file mode 100644 index 0000000..1ce18e5 --- /dev/null +++ b/tests/test_notification_presenter_compat.py @@ -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() diff --git a/tests/test_script_initialization.py b/tests/test_script_initialization.py new file mode 100644 index 0000000..dad55a7 --- /dev/null +++ b/tests/test_script_initialization.py @@ -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" diff --git a/tests/test_sleep_mode_manager_compat.py b/tests/test_sleep_mode_manager_compat.py new file mode 100644 index 0000000..4d2e555 --- /dev/null +++ b/tests/test_sleep_mode_manager_compat.py @@ -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) diff --git a/tests/test_speech_monitor_callbacks.py b/tests/test_speech_monitor_callbacks.py new file mode 100644 index 0000000..3e2a1b5 --- /dev/null +++ b/tests/test_speech_monitor_callbacks.py @@ -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) diff --git a/tests/test_speechserver_compat.py b/tests/test_speechserver_compat.py new file mode 100644 index 0000000..1cc6e7d --- /dev/null +++ b/tests/test_speechserver_compat.py @@ -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