Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ae70597
Webview browseable message prototype
LeonarddeR Sep 8, 2025
426fc6f
Fixings
LeonarddeR May 28, 2026
5ceab04
Rework browseableMessage to use wx.html2.WebView via MessageDialog
LeonarddeR May 28, 2026
5b36a82
Fix
LeonarddeR May 29, 2026
d957f44
Refactor browseable message into an HtmlMessageDialog subclass
LeonarddeR May 29, 2026
b13909b
Align browseable message copy with the About dialog
LeonarddeR May 29, 2026
d19af95
Pre-commit auto-fix
pre-commit-ci[bot] May 29, 2026
926c251
Add change log entries for the browseable message rework
LeonarddeR May 29, 2026
c4d27ec
Fixup
LeonarddeR May 30, 2026
376ceac
Add system tests
LeonarddeR May 30, 2026
6d0f08d
Pre-commit auto-fix
pre-commit-ci[bot] May 30, 2026
84b83d2
HtmlMessageDialog: allow showing without buttons; fix Escape
LeonarddeR May 30, 2026
2ed6d5b
Pre-commit auto-fix
pre-commit-ci[bot] May 30, 2026
3ef6466
Add note about edge and wx python limitaitons
LeonarddeR May 30, 2026
837c4b4
Ensure IE11 emu
LeonarddeR May 30, 2026
811ca7a
Pre-commit auto-fix
pre-commit-ci[bot] May 30, 2026
9515b03
Remove not supported IE emulation level option
LeonarddeR Jun 1, 2026
b5758eb
Merge remote-tracking branch 'origin/master' into browsableWebview
LeonarddeR Jun 1, 2026
d26c8e8
Merge remote-tracking branch 'origin/master' into browsableWebview
LeonarddeR Jun 2, 2026
4a0b336
Add deprecation strategy
LeonarddeR Jun 2, 2026
2d7fd1d
Review actions
LeonarddeR Jun 2, 2026
cdf0fe6
Remove Show override
LeonarddeR Jun 8, 2026
4b4897e
Ensure that _onNavigating supports an override to Edge in future
LeonarddeR Jun 10, 2026
46664f7
Merge remote-tracking branch 'origin/master' into browsableWebview
LeonarddeR Jun 17, 2026
6596d76
Specify deprecated values inline
LeonarddeR Jun 17, 2026
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
5 changes: 4 additions & 1 deletion .github/workflows/testAndPublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ jobs:
- installer
- startupShutdown
- symbols
- browseableMessage
# - vscode
- chrome_annotations
- chrome_list
Expand All @@ -446,10 +447,12 @@ jobs:
arch: ${{ fromJson(needs.matrix.outputs.supportedArchitectures) }}
pythonVersion: ${{ fromJson(needs.matrix.outputs.supportedPythonVersions) }}
# A bug exists with Windows 2022 notepad that prevents NVDA from focusing it.
# This causes our symbol pronunciation tests to fail on this runner.
# This causes our symbol pronunciation and browseable message tests to fail on this runner.
exclude:
- runner: windows-2022
testSuite: symbols
- runner: windows-2022
testSuite: browseableMessage
env:
uv-arch: ${{ matrix.arch == 'x64' && 'x86_64' || 'x86' }}
steps:
Expand Down
31 changes: 9 additions & 22 deletions source/NVDAObjects/IAccessible/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2025 NV Access Limited, Babbage B.V., Cyrille Bougot
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Cyrille Bougot, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

import typing
from typing import (
Expand Down Expand Up @@ -2339,26 +2339,13 @@ def _get_container(self):


class ShellDocObjectView(IAccessible):
def event_gainFocus(self):
# Sometimes Shell DocObject View gets focus, when really the document inside it should
# Adobe Reader 9 licence agreement
if eventHandler.isPendingEvents("gainFocus") or self.childCount != 1:
return super(ShellDocObjectView, self).event_gainFocus()
def _get_focusRedirect(self):
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated
# Sometimes Shell DocObject View gets focus, when really the document inside it should.
# E.g. Adobe Reader 9 licence agreement, WX Web View
child = self.firstChild
if (
not child
or child.windowClassName != "Internet Explorer_Server"
or child.role != controlTypes.Role.PANE
):
return super(ShellDocObjectView, self).event_gainFocus()
child = child.firstChild
if (
not child
or child.windowClassName != "Internet Explorer_Server"
or child.role != controlTypes.Role.DOCUMENT
):
return super(ShellDocObjectView, self).event_gainFocus()
eventHandler.queueEvent("gainFocus", child)
if not child or child.windowClassName != "Internet Explorer_Server":
return None
return child
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated


class JavaVMRoot(IAccessible):
Expand Down
12 changes: 8 additions & 4 deletions source/NVDAObjects/IAccessible/wx.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2025 NV Access Limited, Leonard de Ruijter
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2025-2026 NV Access Limited, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

"""Improvements for wxWidgets objects."""

Expand All @@ -16,11 +16,15 @@


def findExtraOverlayClasses(obj: IAccessible, clsList: list[NVDAObject]):
if obj.name == "wxWebView":
if obj.name == "wxWebView" and obj.event_objectID == winUser.OBJID_CLIENT:
clsList.insert(0, WxWebView)


class WxWebView(IAccessible):
def reportFocus(self):
# Reporting the wxWebView control gaining focus is redundant since it redirects focus to its inner content.
pass

def event_gainFocus(self) -> None:
super().event_gainFocus()
firstChild = self.firstChild
Expand Down
142 changes: 136 additions & 6 deletions source/gui/message.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2025 NV Access Limited, Peter V谩gner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee,
# Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2006-2026 NV Access Limited, Peter V谩gner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee,
# Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

from dataclasses import dataclass
import threading
Expand All @@ -18,6 +18,7 @@
import core
import extensionPoints
import wx
from wx.html2 import WebView
from .contextHelp import ContextHelpMixin
from logHandler import log

Expand Down Expand Up @@ -448,7 +449,7 @@ def __init__(
# Scafold the dialog.
mainSizer = self._mainSizer = wx.BoxSizer(wx.VERTICAL)
contentsSizer = self._contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL)
messageControl = self._messageControl = wx.StaticText(self)
messageControl = self._messageControl = self._createMessageControl()
contentsSizer.addItem(messageControl)
buttonHelper = self._buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL)
mainSizer.Add(
Expand Down Expand Up @@ -657,6 +658,20 @@ def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) ->
)
return self

def _createMessageControl(self) -> wx.Window:
"""Create the control used to display the dialog's message.

Override to render the message with a different control (see :class:`HtmlMessageDialog`).
"""
return wx.StaticText(self)

def _wrapMessageControl(self) -> None:
"""Wrap the message control's text to the dialog width, as part of laying out the dialog.

Override when the message control lays out its own content and needs no wrapping.
"""
self._messageControl.Wrap(self.scaleSize(self.GetSize().Width))

def setMessage(self, message: str) -> Self:
"""Set the textual message to display in the dialog.

Expand Down Expand Up @@ -954,7 +969,7 @@ def _realizeLayout(self) -> None:
if gui._isDebug():
startTime = time.time()
log.debug("Laying out message dialog")
self._messageControl.Wrap(self.scaleSize(self.GetSize().Width))
self._wrapMessageControl()
self._mainSizer.Fit(self)
if self.Parent == gui.mainFrame:
# NVDA's main frame is not visible on screen, so centre on screen rather than on `mainFrame` to avoid the dialog appearing at the top left of the screen.
Expand Down Expand Up @@ -1172,6 +1187,121 @@ def _executeCommand(
# endregion


class HtmlMessageDialog(MessageDialog):
"""A :class:`MessageDialog` that renders its message as HTML in a WebView.

The message passed to the dialog must be a full HTML document.
Because the WebView captures keyboard focus, key presses are routed from JavaScript to NVDA via
``nvda-action://<action>`` URLs. The ``close`` action is handled internally; register handlers for
any other actions with :meth:`registerAction`.
"""

_ACTION_URL_PREFIX = "nvda-action://"

_FAIL_ON_NO_BUTTONS = False
"""HtmlMessageDialog can be shown without buttons; the HTML content handles its own close action."""

_webViewBackend: str = wx.html2.WebViewBackendIE
"""Identifier of the WebView backend to render the message with. Override in a subclass to use another.

.. note:: The Edge backend (wx.html2.WebViewBackendEdge) is preferred over IE for modern HTML support,
but incurs a ~4 second cold start on each new WebView instance because wxPython 4.2 does not expose
wx.html2.WebViewConfiguration, preventing reuse of the underlying CoreWebView2Environment across
instances. Once NVDA upgrades to wxPython 4.3.0, WebViewConfiguration can be created once, held
alive, and passed to each WebView.New() call to eliminate the cold start. Switch this backend to
wx.html2.WebViewBackendEdge at that point.
"""

def __init__(self, *args, **kwargs):
# Initialised before super().__init__() because it creates the WebView (binding its events) and sets
# its initial content, both of which can fire those events.
self._actionHandlers: dict[str, Callable[[], None]] = {}
self._isContentLoaded = False
self._deferShowUntilLoaded = False
super().__init__(*args, **kwargs)
# The WebView (IE backend) consumes Escape natively before JavaScript keydown fires.
# Use a wx accelerator table, which is translated before the message reaches the focused
# child window, so Escape reliably triggers Close() regardless of what IE does with it.
escapeId = wx.NewIdRef()
self.Bind(wx.EVT_MENU, lambda evt: self.Close(), id=escapeId)
self.SetAcceleratorTable(
wx.AcceleratorTable(
[
wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_ESCAPE, escapeId),
],
),
)

def registerAction(self, action: str, handler: Callable[[], None]) -> Self:
"""Register a handler for an ``nvda-action://<action>`` URL triggered from the HTML message.

:param action: The action name, i.e. the part of the URL after ``nvda-action://``.
:param handler: Called when the message navigates to the action's URL.
:return: Updated instance for chaining.
"""
self._actionHandlers[action] = handler
return self

def Show(self, show: bool = True) -> bool:
"""Show the dialog, deferring until the WebView content has loaded.

Some backends (e.g. Edge) load content asynchronously; showing the dialog before the content
is ready would present a blank WebView to the user. If content is not yet loaded, the show is
deferred until :meth:`_onLoaded` fires.
"""
if show and not self._isContentLoaded:
self._deferShowUntilLoaded = True
return False
return super().Show(show)

def _createMessageControl(self) -> WebView:
control = WebView.New(self, backend=self._webViewBackend)
control.EnableContextMenu(False)
control.EnableHistory(False)
# Bind before MessageDialog.__init__ sets the initial content, so the first load and navigation are observed.
control.Bind(wx.html2.EVT_WEBVIEW_NAVIGATING, self._onNavigating)
control.Bind(wx.html2.EVT_WEBVIEW_LOADED, self._onLoaded)
return control

def _wrapMessageControl(self) -> None:
# A WebView lays out its own content, so there is nothing to wrap.
pass

def setMessage(self, message: str) -> Self:
self._messageControl.SetPage(message, "")
self._isLayoutFullyRealized = False
return self

def _onLoaded(self, evt: wx.html2.WebViewEvent) -> None:
self._isContentLoaded = True
if self._deferShowUntilLoaded:
self._deferShowUntilLoaded = False
self.Show()
evt.Skip()

def _onNavigating(self, evt: wx.html2.WebViewEvent) -> None:
url = evt.GetURL()
if not url.startswith(self._ACTION_URL_PREFIX):
return
evt.Veto()
action = url[len(self._ACTION_URL_PREFIX) :]
if action == "close":
self.Close()
elif handler := self._actionHandlers.get(action):
handler()
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated

def _getFallbackAction(self) -> _Command | None:
"""Return a fallback close action even when no buttons are registered.

HtmlMessageDialog may be shown without buttons; in that case escape and the title bar close
button must still work.
"""
action = super()._getFallbackAction()
if action is None and not self._commands:
return _Command(callback=None, closesDialog=True, returnCode=ReturnCode.CLOSE)
return action


def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | None):
"""Display a message box with the given message, caption, style, and parent window.

Expand Down
Loading
Loading