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
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
+22
View File
@@ -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")
+5
View File
@@ -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."""
+1 -1
View File
@@ -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."""
+63 -11
View File
@@ -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.
+24
View File
@@ -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
+3 -1
View File
@@ -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
+22
View File
@@ -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
+254 -9
View File
@@ -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
+14
View File
@@ -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."""
+34
View File
@@ -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
+29 -2
View File
@@ -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."""
+157
View File
@@ -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 ()
+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"]:
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",
[
+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):
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
@@ -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)
@@ -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