Skip to content

Commit d452399

Browse files
carlos-zamoraDHowett
authored andcommitted
Use UIA notifications for text output (#12358)
This change makes Windows Terminal raise a `RaiseNotificationEvent()` ([docs](https://docs.microsoft.com/en-us/uwp/api/windows.ui.xaml.automation.peers.automationpeer.raisenotificationevent?view=winrt-22000)) for new text output to the buffer. This is intended to help Narrator identify what new output appears and reduce the workload of diffing the buffer when a `TextChanged` event occurs. The flow of the event occurs as follows: - `Terminal::_WriteBuffer()` - New text is output to the text buffer. Notify the renderer that we have new text (and what that text is). - `Renderer::TriggerNewTextNotification()` - Cycle through all the rendering engines and tell them to notify handle the new text output. - None of the rendering engines _except_ `UiaEngine` has it implemented, so really we're just notifying UIA. - `UiaEngine::NotifyNewText()` - Concatenate any new output into a string. - When we're done painting, tell the notification system to actually notify of new events occurring and clear any stored output text. That way, we're ready for the next renderer frame. - `InteractivityAutomationPeer::NotifyNewOutput()` --> `TermControlAutomationPeer::NotifyNewOutput` - NOTE: these are split because of the in-proc and out-of-proc separation of the buffer. - Actually `RaiseNotificationEvent()` for the new text output. Additionally, we had to handle the "local echo" problem: when a key is pressed, the character is said twice (once for the keyboard event, and again for the character being written to the buffer). To accomplish this, we did the following: - `TermControl`: - here, we already handle keyboard events, so I added a line saying "if we have an automation peer attached, record the keyboard event in the automation peer". - `TermControlAutomationPeer`: - just before the notification is dispatched, check if the string of recent keyboard events match the beginning of the string of new output. If that's the case, we can assume that the common prefix was the "local echo". This is a fairly naive heuristic, but it's been working. Closes the following ADO bugs: - https://dev.azure.com/microsoft/OS/_workitems/edit/36506838 - (Probably) https://dev.azure.com/microsoft/OS/_workitems/edit/38011453 - [x] Base case: "echo hello" - [x] Partial line change - [x] Scrolling (should be unaffected) - [x] Large output - [x] "local echo": keyboard events read input character twice (cherry picked from commit f9be172)
1 parent fa21425 commit d452399

21 files changed

+227
-15
lines changed

.github/actions/spelling/expect/expect.txt

+1
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,7 @@ rxvt
20832083
safearray
20842084
SAFECAST
20852085
safemath
2086+
sapi
20862087
sba
20872088
SBCS
20882089
SBCSDBCS

src/cascadia/TerminalControl/InteractivityAutomationPeer.cpp

+5
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
9393
_CursorChangedHandlers(*this, nullptr);
9494
}
9595

96+
void InteractivityAutomationPeer::NotifyNewOutput(std::wstring_view newOutput)
97+
{
98+
_NewOutputHandlers(*this, hstring{ newOutput });
99+
}
100+
96101
#pragma region ITextProvider
97102
com_array<XamlAutomation::ITextRangeProvider> InteractivityAutomationPeer::GetSelection()
98103
{

src/cascadia/TerminalControl/InteractivityAutomationPeer.h

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
4949
void SignalSelectionChanged() override;
5050
void SignalTextChanged() override;
5151
void SignalCursorChanged() override;
52+
void NotifyNewOutput(std::wstring_view newOutput) override;
5253
#pragma endregion
5354

5455
#pragma region ITextProvider Pattern
@@ -73,6 +74,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
7374
TYPED_EVENT(SelectionChanged, IInspectable, IInspectable);
7475
TYPED_EVENT(TextChanged, IInspectable, IInspectable);
7576
TYPED_EVENT(CursorChanged, IInspectable, IInspectable);
77+
TYPED_EVENT(NewOutput, IInspectable, hstring);
7678

7779
private:
7880
Windows::UI::Xaml::Automation::Provider::ITextRangeProvider _CreateXamlUiaTextRange(::ITextRangeProvider* returnVal) const;

src/cascadia/TerminalControl/InteractivityAutomationPeer.idl

+1
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ namespace Microsoft.Terminal.Control
1414
event Windows.Foundation.TypedEventHandler<Object, Object> SelectionChanged;
1515
event Windows.Foundation.TypedEventHandler<Object, Object> TextChanged;
1616
event Windows.Foundation.TypedEventHandler<Object, Object> CursorChanged;
17+
event Windows.Foundation.TypedEventHandler<Object, String> NewOutput;
1718
}
1819
}

src/cascadia/TerminalControl/TermControl.cpp

+5
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
10301030
keyDown) :
10311031
true;
10321032

1033+
if (vkey && keyDown && _automationPeer)
1034+
{
1035+
get_self<TermControlAutomationPeer>(_automationPeer)->RecordKeyEvent(vkey);
1036+
}
1037+
10331038
if (_cursorTimer)
10341039
{
10351040
// Manually show the cursor when a key is pressed. Restarting

src/cascadia/TerminalControl/TermControlAutomationPeer.cpp

+107
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,43 @@ namespace XamlAutomation
2828
using winrt::Windows::UI::Xaml::Automation::Provider::ITextRangeProvider;
2929
}
3030

31+
static constexpr wchar_t UNICODE_NEWLINE{ L'\n' };
32+
33+
// Method Description:
34+
// - creates a copy of the provided text with all of the control characters removed
35+
// Arguments:
36+
// - text: the string we're sanitizing
37+
// Return Value:
38+
// - a copy of "sanitized" with all of the control characters removed
39+
static std::wstring Sanitize(std::wstring_view text)
40+
{
41+
std::wstring sanitized{ text };
42+
sanitized.erase(std::remove_if(sanitized.begin(), sanitized.end(), [](wchar_t c) {
43+
return (c < UNICODE_SPACE && c != UNICODE_NEWLINE) || c == 0x7F /*DEL*/;
44+
}),
45+
sanitized.end());
46+
return sanitized;
47+
}
48+
49+
// Method Description:
50+
// - verifies if a given string has text that would be read by a screen reader.
51+
// - a string of control characters, for example, would not be read.
52+
// Arguments:
53+
// - text: the string we're validating
54+
// Return Value:
55+
// - true, if the text is readable. false, otherwise.
56+
static constexpr bool IsReadable(std::wstring_view text)
57+
{
58+
for (const auto c : text)
59+
{
60+
if (c > UNICODE_SPACE)
61+
{
62+
return true;
63+
}
64+
}
65+
return false;
66+
}
67+
3168
namespace winrt::Microsoft::Terminal::Control::implementation
3269
{
3370
TermControlAutomationPeer::TermControlAutomationPeer(TermControl* owner,
@@ -45,6 +82,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
4582
_contentAutomationPeer.SelectionChanged([this](auto&&, auto&&) { SignalSelectionChanged(); });
4683
_contentAutomationPeer.TextChanged([this](auto&&, auto&&) { SignalTextChanged(); });
4784
_contentAutomationPeer.CursorChanged([this](auto&&, auto&&) { SignalCursorChanged(); });
85+
_contentAutomationPeer.NewOutput([this](auto&&, hstring newOutput) { NotifyNewOutput(newOutput); });
4886
_contentAutomationPeer.ParentProvider(*this);
4987
};
5088

@@ -68,6 +106,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation
68106
_contentAutomationPeer.SetControlPadding(padding);
69107
}
70108

109+
void TermControlAutomationPeer::RecordKeyEvent(const WORD vkey)
110+
{
111+
if (const auto charCode{ MapVirtualKey(vkey, MAPVK_VK_TO_CHAR) })
112+
{
113+
if (const auto keyEventChar{ gsl::narrow_cast<wchar_t>(charCode) }; IsReadable({ &keyEventChar, 1 }))
114+
{
115+
_keyEvents.emplace_back(keyEventChar);
116+
}
117+
}
118+
}
119+
71120
// Method Description:
72121
// - Signals the ui automation client that the terminal's selection has changed and should be updated
73122
// Arguments:
@@ -142,8 +191,66 @@ namespace winrt::Microsoft::Terminal::Control::implementation
142191
});
143192
}
144193

194+
void TermControlAutomationPeer::NotifyNewOutput(std::wstring_view newOutput)
195+
{
196+
// Try to suppress any events (or event data)
197+
// that is just the keypress the user made
198+
auto sanitized{ Sanitize(newOutput) };
199+
while (!_keyEvents.empty() && IsReadable(sanitized))
200+
{
201+
if (til::toupper_ascii(sanitized.front()) == _keyEvents.front())
202+
{
203+
// the key event's character (i.e. the "A" key) matches
204+
// the output character (i.e. "a" or "A" text).
205+
// We can assume that the output character resulted from
206+
// the pressed key, so we can ignore it.
207+
sanitized = sanitized.substr(1);
208+
_keyEvents.pop_front();
209+
}
210+
else
211+
{
212+
// The output doesn't match,
213+
// so clear the input stack and
214+
// move on to fire the event.
215+
_keyEvents.clear();
216+
break;
217+
}
218+
}
219+
220+
// Suppress event if the remaining text is not readable
221+
if (!IsReadable(sanitized))
222+
{
223+
return;
224+
}
225+
226+
auto dispatcher{ Dispatcher() };
227+
if (!dispatcher)
228+
{
229+
return;
230+
}
231+
232+
// IMPORTANT:
233+
// [1] make sure the scope returns a copy of "sanitized" so that it isn't accidentally deleted
234+
// [2] AutomationNotificationProcessing::All --> ensures it can be interrupted by keyboard events
235+
// [3] Do not "RunAsync(...).get()". For whatever reason, this causes NVDA to just not receive "SignalTextChanged()"'s events.
236+
dispatcher.RunAsync(Windows::UI::Core::CoreDispatcherPriority::Normal, [weakThis{ get_weak() }, sanitizedCopy{ hstring{ sanitized } }]() {
237+
if (auto strongThis{ weakThis.get() })
238+
{
239+
try
240+
{
241+
strongThis->RaiseNotificationEvent(AutomationNotificationKind::ActionCompleted,
242+
AutomationNotificationProcessing::All,
243+
sanitizedCopy,
244+
L"TerminalTextOutput");
245+
}
246+
CATCH_LOG();
247+
}
248+
});
249+
}
250+
145251
hstring TermControlAutomationPeer::GetClassNameCore() const
146252
{
253+
// IMPORTANT: Do NOT change the name. Screen readers like JAWS may be dependent on this being "TermControl".
147254
return L"TermControl";
148255
}
149256

src/cascadia/TerminalControl/TermControlAutomationPeer.h

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
4848

4949
void UpdateControlBounds();
5050
void SetControlPadding(const Core::Padding padding);
51+
void RecordKeyEvent(const WORD vkey);
5152

5253
#pragma region FrameworkElementAutomationPeer
5354
hstring GetClassNameCore() const;
@@ -64,6 +65,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
6465
void SignalSelectionChanged() override;
6566
void SignalTextChanged() override;
6667
void SignalCursorChanged() override;
68+
void NotifyNewOutput(std::wstring_view newOutput) override;
6769
#pragma endregion
6870

6971
#pragma region ITextProvider Pattern
@@ -78,5 +80,6 @@ namespace winrt::Microsoft::Terminal::Control::implementation
7880
private:
7981
winrt::Microsoft::Terminal::Control::implementation::TermControl* _termControl;
8082
Control::InteractivityAutomationPeer _contentAutomationPeer;
83+
std::deque<wchar_t> _keyEvents;
8184
};
8285
}

src/cascadia/TerminalCore/Terminal.cpp

+5
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,11 @@ void Terminal::_WriteBuffer(const std::wstring_view& stringView)
996996
_AdjustCursorPosition(proposedCursorPosition);
997997
}
998998

999+
// Notify UIA of new text.
1000+
// It's important to do this here instead of in TextBuffer, because here you have access to the entire line of text,
1001+
// whereas TextBuffer writes it one character at a time via the OutputCellIterator.
1002+
_buffer->GetRenderTarget().TriggerNewTextNotification(stringView);
1003+
9991004
cursor.EndDeferDrawing();
10001005
}
10011006

src/cascadia/UnitTests_TerminalCore/ScrollTest.cpp

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ namespace
5454
};
5555
virtual void TriggerCircling(){};
5656
void TriggerTitleChange(){};
57+
void TriggerNewTextNotification(const std::wstring_view){};
5758

5859
private:
5960
std::optional<COORD> _triggerScrollDelta;

src/host/ScreenBufferRenderTarget.cpp

+10
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,13 @@ void ScreenBufferRenderTarget::TriggerTitleChange()
110110
pRenderer->TriggerTitleChange();
111111
}
112112
}
113+
114+
void ScreenBufferRenderTarget::TriggerNewTextNotification(const std::wstring_view newText)
115+
{
116+
auto* pRenderer = ServiceLocator::LocateGlobals().pRender;
117+
const auto* pActive = &ServiceLocator::LocateGlobals().getConsoleInformation().GetActiveOutputBuffer().GetActiveBuffer();
118+
if (pRenderer != nullptr && pActive == &_owner)
119+
{
120+
pRenderer->TriggerNewTextNotification(newText);
121+
}
122+
}

src/host/ScreenBufferRenderTarget.hpp

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class ScreenBufferRenderTarget final : public Microsoft::Console::Render::IRende
3939
void TriggerScroll(const COORD* const pcoordDelta) override;
4040
void TriggerCircling() override;
4141
void TriggerTitleChange() override;
42+
void TriggerNewTextNotification(const std::wstring_view newText) override;
4243

4344
private:
4445
SCREEN_INFORMATION& _owner;

src/renderer/base/RenderEngineBase.cpp

+5
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ HRESULT RenderEngineBase::UpdateTitle(const std::wstring_view newTitle) noexcept
3636
return hr;
3737
}
3838

39+
HRESULT RenderEngineBase::NotifyNewText(const std::wstring_view /*newText*/) noexcept
40+
{
41+
return S_FALSE;
42+
}
43+
3944
HRESULT RenderEngineBase::UpdateSoftFont(const gsl::span<const uint16_t> /*bitPattern*/,
4045
const SIZE /*cellSize*/,
4146
const size_t /*centeringHint*/) noexcept

src/renderer/base/renderer.cpp

+8
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,14 @@ void Renderer::TriggerTitleChange()
492492
_NotifyPaintFrame();
493493
}
494494

495+
void Renderer::TriggerNewTextNotification(const std::wstring_view newText)
496+
{
497+
FOREACH_ENGINE(pEngine)
498+
{
499+
LOG_IF_FAILED(pEngine->NotifyNewText(newText));
500+
}
501+
}
502+
495503
// Routine Description:
496504
// - Update the title for a particular engine.
497505
// Arguments:

src/renderer/base/renderer.hpp

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ namespace Microsoft::Console::Render
6161
void TriggerCircling() override;
6262
void TriggerTitleChange() override;
6363

64+
void TriggerNewTextNotification(const std::wstring_view newText) override;
65+
6466
void TriggerFontChange(const int iDpi,
6567
const FontInfoDesired& FontInfoDesired,
6668
_Out_ FontInfo& FontInfo) override;

src/renderer/inc/DummyRenderTarget.hpp

+1
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ class DummyRenderTarget final : public Microsoft::Console::Render::IRenderTarget
3131
void TriggerScroll(const COORD* const /*pcoordDelta*/) override {}
3232
void TriggerCircling() override {}
3333
void TriggerTitleChange() override {}
34+
void TriggerNewTextNotification(const std::wstring_view) override {}
3435
};

src/renderer/inc/IRenderEngine.hpp

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ namespace Microsoft::Console::Render
7575

7676
[[nodiscard]] virtual HRESULT InvalidateTitle(const std::wstring_view proposedTitle) noexcept = 0;
7777

78+
[[nodiscard]] virtual HRESULT NotifyNewText(const std::wstring_view newText) noexcept = 0;
79+
7880
[[nodiscard]] virtual HRESULT PrepareRenderInfo(const RenderFrameInfo& info) noexcept = 0;
7981

8082
[[nodiscard]] virtual HRESULT ResetLineTransform() noexcept = 0;

src/renderer/inc/IRenderTarget.hpp

+2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ namespace Microsoft::Console::Render
4545
virtual void TriggerScroll(const COORD* const pcoordDelta) = 0;
4646
virtual void TriggerCircling() = 0;
4747
virtual void TriggerTitleChange() = 0;
48+
49+
virtual void TriggerNewTextNotification(const std::wstring_view newText) = 0;
4850
};
4951

5052
inline Microsoft::Console::Render::IRenderTarget::~IRenderTarget() {}

src/renderer/inc/RenderEngineBase.hpp

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ namespace Microsoft::Console::Render
3838

3939
[[nodiscard]] HRESULT UpdateTitle(const std::wstring_view newTitle) noexcept override;
4040

41+
[[nodiscard]] HRESULT NotifyNewText(const std::wstring_view newText) noexcept override;
42+
4143
[[nodiscard]] HRESULT UpdateSoftFont(const gsl::span<const uint16_t> bitPattern,
4244
const SIZE cellSize,
4345
const size_t centeringHint) noexcept override;

0 commit comments

Comments
 (0)