Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 1 addition & 39 deletions source/_remoteClient/localMachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import api
import braille
from config.registry import RegistryKey
import inputCore
import nvwave
import speech
import tones
Expand Down Expand Up @@ -126,9 +125,6 @@ def __init__(self) -> None:

self.receivingBraille = False

self._cachedSizes: list[int] | None = None
"""Cached braille display sizes from remote machines"""

self._showingLocalUiMessage: bool = False
"""Whether we're currently showing a `ui.message` while showing remote braille."""

Expand Down Expand Up @@ -257,41 +253,7 @@ def brailleInput(self, **kwargs: dict[str, Any]) -> None:
:param kwargs: Gesture parameters passed to BrailleInputGesture
:note: Silently ignores gestures that have no associated action.
"""
try:
inputCore.manager.executeGesture(input.BrailleInputGesture(**kwargs))
except inputCore.NoInputGestureAction:
pass

def setBrailleDisplaySize(self, sizes: list[int]) -> None:
"""Cache remote braille display sizes for size negotiation.

:param sizes: List of display sizes (cells) from remote machines
"""
self._cachedSizes = sizes

def _handleFilterDisplayDimensions(self, value: braille.DisplayDimensions) -> braille.DisplayDimensions:
"""Filter the local display dimensions based on remote display dimensions.

Determines the optimal display dimensions when sharing braille output by
finding the smallest positive width among local and remote displays.

.. note::
We can currently only support a single line of braille,
as sending display dimensions would require changing the Remote Access protocol.

:param value: Local display dimensions
:return: The negotiated display dimensions to use.
"""
if not self._cachedSizes:
# We cannot support multiline displays without breaking the Remote Access protocol,
# so always force numRows to 1.
return value._replace(numRows=1)
# There is no point storing the number of rows if we are always going to set it to 1.
sizes = self._cachedSizes + [value.numCols]
try:
return braille.DisplayDimensions(numRows=1, numCols=min(i for i in sizes if i > 0))
except ValueError:
return value._replace(numRows=1)
braille.injectGesture(input.BrailleInputGesture(**kwargs))

def handleDecideEnabled(self) -> bool:
"""Determine if the local braille display should be enabled.
Expand Down
49 changes: 34 additions & 15 deletions source/_remoteClient/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,26 @@ def connectedClientsCount(self) -> int:
return self.connectedLeadersCount + self.connectedFollowersCount


class _FollowerBrailleMirror(braille.BrailleMirror):
"""BrailleMirror that forwards display updates to connected leader machines.

Registered while at least one leader with a braille display is connected.
:meth:`numCells` returns the smallest positive leader display size so NVDA
negotiates a compatible display width.
"""

def __init__(self, session: "FollowerSession") -> None:
self._session = session

def display(self, cells: list[int]) -> None:
if self._session.hasBrailleLeaders():
self._session.transport.send(type=RemoteMessageType.DISPLAY, cells=cells)

def numCells(self) -> int:
sizes = [s for s in self._session.leaderDisplaySizes if s > 0]
return min(sizes) if sizes else 0


class FollowerSession(RemoteSession):
"""Session that runs on the controlled (follower) NVDA instance.

Expand Down Expand Up @@ -302,6 +322,9 @@ def __init__(
self.leaders = defaultdict(dict)
self.leaderDisplaySizes = []
self.followers = set()
self._brailleMirror = _FollowerBrailleMirror(self)
# The remote protocol only supports single-row braille; force numRows to 1.
braille.filter_displayDimensions.register(self._filterNumRowsToOne)
self.transport.transportClosing.register(self.handleTransportClosing)
self.transport.registerInbound(
RemoteMessageType.CHANNEL_JOINED,
Expand All @@ -315,9 +338,6 @@ def __init__(
RemoteMessageType.SET_DISPLAY_SIZE,
self.setDisplaySize,
)
braille.filter_displayDimensions.register(
self.localMachine._handleFilterDisplayDimensions,
)
self.transport.registerInbound(
RemoteMessageType.BRAILLE_INPUT,
self.localMachine.brailleInput,
Expand All @@ -340,7 +360,7 @@ def registerCallbacks(self) -> None:
)
self.transport.registerOutbound(decide_playWaveFile, RemoteMessageType.WAVE)
self.transport.registerOutbound(post_speechPaused, RemoteMessageType.PAUSE_SPEECH)
braille.pre_writeCells.register(self.display)
braille.registerMirror(self._brailleMirror)
pre_speechQueued.register(self.sendSpeech)
self.callbacksAdded = True

Expand All @@ -351,7 +371,7 @@ def unregisterCallbacks(self) -> None:
self.transport.unregisterOutbound(RemoteMessageType.CANCEL)
self.transport.unregisterOutbound(RemoteMessageType.WAVE)
self.transport.unregisterOutbound(RemoteMessageType.PAUSE_SPEECH)
braille.pre_writeCells.unregister(self.display)
braille.unregisterMirror(self._brailleMirror)
pre_speechQueued.unregister(self.sendSpeech)
self.callbacksAdded = False

Expand Down Expand Up @@ -382,6 +402,7 @@ def handleTransportClosing(self) -> None:
to ensure clean shutdown of remote features.
"""
self.unregisterCallbacks()
braille.filter_displayDimensions.unregister(self._filterNumRowsToOne)

def handleTransportDisconnected(self) -> None:
"""Handle disconnection from the transport layer.
Expand All @@ -408,7 +429,14 @@ def setDisplaySize(self, sizes: list[int] | None = None) -> None:
sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()]
)
log.debug(f"Setting follower display size to: {self.leaderDisplaySizes!r}")
self.localMachine.setBrailleDisplaySize(self.leaderDisplaySizes)

def _filterNumRowsToOne(self, value: braille.DisplayDimensions) -> braille.DisplayDimensions:
"""Force single-row braille output.

The remote protocol does not support multi-row displays,
so we always constrain numRows to 1.
"""
return value._replace(numRows=1)

def handleBrailleInfo(
self,
Expand Down Expand Up @@ -451,15 +479,6 @@ def pauseSpeech(self, switch: bool) -> None:
"""Toggle speech pause state on leader instances."""
self.transport.send(type=RemoteMessageType.PAUSE_SPEECH, switch=switch)

def display(self, cells: list[int]) -> None:
"""Forward braille display content to leader instances.

Only sends braille data if there are connected leaders with braille displays.
"""
# Only send braille data when there are controlling machines with a braille display
if self.hasBrailleLeaders():
self.transport.send(type=RemoteMessageType.DISPLAY, cells=cells)

def hasBrailleLeaders(self) -> bool:
"""Check if any connected leaders have braille displays.

Expand Down
182 changes: 180 additions & 2 deletions source/braille.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2008-2025 NV Access Limited, Joseph Lee, Babbage B.V., Davy Kager, Bram Duvigneau,
# Leonard de Ruijter, Burman's Computer and Education Ltd., Julien Cochuyt
# Copyright (C) 2008-2026 NV Access Limited, Joseph Lee, Babbage B.V., Davy Kager, Bram Duvigneau,
# Leonard de Ruijter, Burman's Computer and Education Ltd., Julien Cochuyt, Pneuma Solutions

from enum import StrEnum
import itertools
Expand Down Expand Up @@ -67,6 +67,7 @@
import hwPortUtils
import bdDetect
import queueHandler
import winUser
import brailleViewer
from autoSettingsUtils.driverSetting import BooleanDriverSetting, NumericDriverSetting
from utils.security import objectBelowLockScreenAndWindowsIsLocked, post_sessionLockStateChanged
Expand Down Expand Up @@ -3434,6 +3435,9 @@ def initialize():
handler = BrailleHandler()
handler.handlePostConfigProfileSwitch()
config.post_configProfileSwitch.register(handler.handlePostConfigProfileSwitch)
import braillePipeServer

braillePipeServer.initialize()


def pumpAll():
Expand All @@ -3443,6 +3447,9 @@ def pumpAll():

def terminate():
global handler
import braillePipeServer

braillePipeServer.terminate()
handler.terminate()
handler = None

Expand Down Expand Up @@ -3969,6 +3976,177 @@ def getDisplayTextForIdentifier(cls, identifier):
inputCore.registerGestureSource("br", BrailleDisplayGesture)


class BrailleMirror:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should be avoiding adding substantial new code to source/braille.py (See #12772)
Could you please move the added classes and functions to a new file (e.g. source/_brailleMirror.py)?

"""Abstract base class for a braille mirror.

A mirror intercepts every braille display update and can optionally influence the negotiated display width.
Both the physical display and all registered mirrors receive the same cells simultaneously; the mirror does **not** suppress the local display.
Register an instance with :func:`registerMirror` and remove it with :func:`unregisterMirror`. To inject a gesture back into NVDA (e.g. a routing key received over a remote channel) use :func:`injectGesture`.
"""

def display(self, cells: List[int]) -> None:
"""Called with the full cell array on every display update.

:param cells: The braille cells written to the display.
"""

def numCells(self) -> int:
"""Return the number of cells this mirror can show.

Return 0 (the default) to have no effect on the negotiated display
width. A positive value caps the display width used by
:data:`filter_displayDimensions` to the smallest value across all registered mirrors and the physical display.
"""
return 0


_registeredMirrors: List["BrailleMirror"] = []


def _mirrorPreWriteCells(cells: List[int], **kwargs) -> None:
for mirror in _registeredMirrors:
mirror.display(cells)
Comment on lines +4006 to +4008
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

_mirrorPreWriteCells forwards the mutable cells list directly to each mirror. Because this hook runs before the physical display write, any mirror that mutates the list (even accidentally) will change what NVDA outputs to the real display, which contradicts the expectation that a mirror is passive. Consider passing an immutable snapshot (e.g., a copy) to mirrors so they can’t affect the main output.

Copilot uses AI. Check for mistakes.


def _mirrorFilterDisplayDimensions(value: DisplayDimensions) -> DisplayDimensions:
sizes = [m.numCells() for m in _registeredMirrors if m.numCells() > 0]
if not sizes:
return value
cap = min(sizes)
if cap >= value.numCols:
return value
return value._replace(numCols=cap)


def registerMirror(mirror: BrailleMirror) -> None:
"""Register *mirror* to receive braille display updates.

:meth:`BrailleMirror.display` will be called on the main thread for every subsequent :meth:`BrailleHandler._writeCells` call. If *mirror* returns a positive value from :meth:`BrailleMirror.numCells`, it will also participate in display-width negotiation via :data:`filter_displayDimensions`.
"""
if not _registeredMirrors:
pre_writeCells.register(_mirrorPreWriteCells)
filter_displayDimensions.register(_mirrorFilterDisplayDimensions)
_registeredMirrors.append(mirror)
if handler:
handler._refreshEnabled(block=True)


def unregisterMirror(mirror: BrailleMirror) -> None:
"""Remove a previously registered mirror.

Safe to call even if *mirror* is not currently registered.
"""
try:
_registeredMirrors.remove(mirror)
except ValueError:
return
if not _registeredMirrors:
pre_writeCells.unregister(_mirrorPreWriteCells)
filter_displayDimensions.unregister(_mirrorFilterDisplayDimensions)
if handler:
handler._refreshEnabled(block=True)


def injectGesture(gesture: BrailleDisplayGesture) -> None:
"""Inject *gesture* into NVDA's input pipeline.

This is a thin wrapper around :func:`inputCore.manager.executeGesture` that silently swallows :class:`inputCore.NoInputGestureAction` so callers do not need to handle the common case where no script is bound.

Thread safety: must be called on the main thread, or scheduled via ``wx.CallAfter`` from a background thread.
"""
try:
inputCore.manager.executeGesture(gesture)
except inputCore.NoInputGestureAction:
pass


class DirectBrailleWindow:
"""Take over braille output and input while a specific window has focus.

When the window identified by *hwnd* is the foreground window, NVDA's own braille rendering is suppressed. The application drives what appears on the physical display by calling :meth:`display`, and all braille gestures are forwarded to :meth:`onGesture` instead of being processed by NVDA.
When the window loses focus, normal NVDA rendering resumes automatically.

:param hwnd: The HWND of the window that triggers direct braille mode.
:param numCells: Advertised display width; 0 means use whatever NVDA provides. A positive value caps the negotiated display width via :data:`filter_displayDimensions`.
"""

def __init__(self, hwnd: int, numCells: int = 0) -> None:
self._hwnd = hwnd
self._numCells = numCells
self._active = False

def _isForeground(self) -> bool:
"""Return True if our registered window is currently in the foreground."""
fg = winUser.getForegroundWindow()
return fg == self._hwnd or winUser.isDescendantWindow(self._hwnd, fg)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

DirectBrailleWindow._isForeground passes arguments to winUser.isDescendantWindow in the wrong order. isDescendantWindow expects (parentHwnd, childHwnd), and the common pattern elsewhere is isDescendantWindow(fg, windowHandle). As written, descendant windows of the foreground window won’t be recognized, so direct braille mode may not activate correctly when the target window is inside the foreground root. Swap the arguments so the foreground window is treated as the parent.

Suggested change
return fg == self._hwnd or winUser.isDescendantWindow(self._hwnd, fg)
return fg == self._hwnd or winUser.isDescendantWindow(fg, self._hwnd)

Copilot uses AI. Check for mistakes.

def display(self, cells: List[int]) -> None:
"""Push *cells* to the physical braille display.

Has no effect if the registered window is not currently foreground or if no braille display is connected.

Thread safety: safe to call from any thread; the actual write is dispatched to the main thread via ``wx.CallAfter``.
"""
if handler and self._isForeground():
wx.CallAfter(handler._writeCells, cells)

def onGesture(self, gesture: BrailleDisplayGesture) -> None:
"""Called when a braille gesture arrives while this window is active.

The default implementation does nothing, so gestures are suppressed. Subclass and override to handle them.

:param gesture: The braille gesture that was intercepted.
"""

def _handleDecideEnabled(self) -> bool:
return not self._isForeground()

def _handleDecideExecuteGesture(self, gesture: inputCore.InputGesture) -> bool:
if not self._isForeground():
return True
if isinstance(gesture, BrailleDisplayGesture):
self.onGesture(gesture)
return False
return True

def _handleFilterDisplayDimensions(self, value: DisplayDimensions) -> DisplayDimensions:
if self._numCells <= 0 or not self._isForeground():
return value
if self._numCells >= value.numCols:
return value
return value._replace(numCols=self._numCells)

def activate(self) -> None:
"""Start intercepting braille output and input for the registered window.

Safe to call even if already active (subsequent calls are no-ops).
"""
if self._active:
return
self._active = True
decide_enabled.register(self._handleDecideEnabled)
inputCore.decide_executeGesture.register(self._handleDecideExecuteGesture)
if self._numCells > 0:
filter_displayDimensions.register(self._handleFilterDisplayDimensions)
if handler:
handler._refreshEnabled(block=True)

def deactivate(self) -> None:
"""Stop intercepting braille, restoring NVDA's normal rendering.

Safe to call even if not currently active.
"""
if not self._active:
return
self._active = False
decide_enabled.unregister(self._handleDecideEnabled)
inputCore.decide_executeGesture.unregister(self._handleDecideExecuteGesture)
if self._numCells > 0:
filter_displayDimensions.unregister(self._handleFilterDisplayDimensions)
if handler:
handler._refreshEnabled(block=True)


def getSerialPorts(filterFunc=None) -> typing.Iterator[typing.Tuple[str, str]]:
"""Get available serial ports in a format suitable for L{BrailleDisplayDriver.getManualPorts}.
@param filterFunc: a function executed on every dictionary retrieved using L{hwPortUtils.listComPorts}.
Expand Down
Loading
Loading