terminal/src/cascadia/UnitTests_Control/ControlInteractivityTests.cpp
Carlos Zamora 64d4fbab17
Make selection an exclusive range (#18106)
Selection is generally stored as an inclusive start and end. This PR
makes the end exclusive which now allows degenerate selections, namely
in mark mode. This also modifies mouse selection to round to the nearest
cell boundary (see #5099) and improves word boundaries to be a bit more
modern and make sense for degenerate selections (similar to #15787).

Closes #5099
Closes #13447
Closes #17892

## Detailed Description of the Pull Request / Additional comments
- Buffer, Viewport, and Point
- Introduced a few new functions here to find word boundaries, delimiter
class runs, and glyph boundaries.
- 📝These new functions should be able to replace a few other functions
(i.e. `GetWordStart` --> `GetWordStart2`). That migration is going to be
a part of #4423 to reduce the risk of breaking UIA.
- Viewport: added a few functions to handle navigating the _exclusive_
bounds (namely allowing RightExclusive as a position for buffer
coordinates). This is important for selection to be able to highlight
the entire line.
- 📝`BottomInclusiveRightExclusive()` will replace `EndExclusive` in the
UIA code
- Point: `iterate_rows_exclusive` is similar to `iterate_rows`, except
it has handling for RightExclusive
- Renderer
- Use `iterate_rows_exclusive` for proper handling (this actually fixed
a lot of our issues)
- Remove some workarounds in `_drawHighlighted` (this is a boundary
where we got inclusive coords and made them exclusive, but now we don't
need that!)
- Terminal
   - fix selection marker rendering
- `_ConvertToBufferCell()`: add a param to allow for RightExclusive or
clamp it to RightInclusive (original behavior). Both are useful!
- Use new `GetWordStart2` and `GetWordEnd2` to improve word boundaries
and make them feel right now that the selection an exclusive range.
- Convert a few `IsInBounds` --> `IsInExclusiveBounds` for safety and
correctness
   - Add `TriggerSelection` to `SelectNewRegion`
- 📝 We normally called `TriggerSelection` in a different layer, but it
turns out, UIA's `Select` function wouldn't actually update the
renderer. Whoops! This fixes that.
- TermControl
- `_getTerminalPosition` now has a new param to round to the nearest
cell (see #5099)
- UIA
- `TermControlUIAProvider::GetSelectionRange` no need to convert from
inclusive range to exclusive range anymore!
- `TextBuffer::GetPlainText` now works on an exclusive range, so no need
to convert the range anymore!

## Validation Steps Performed
This fundamental change impacts a lot of scenarios:
- Rendering selections
- Selection markers
- Copy text
- Session restore
- Mark mode navigation (i.e. character, word, line, buffer)
- Mouse selection (i.e. click+drag, shift+click, multi-click,
alt+click)
- Hyperlinks (interaction and rendering)
- Accessibility (i.e. get selection, movement, text extraction,
selecting text)
- [ ] Prev/Next Command/Output (untested)
- Unit tests

## Follow-ups
- Refs #4423
- Now that selection and UIA are both exclusive ranges, it should be a
lot easier to deduplicate code between selection and UIA. We should be
able to remove `EndExclusive` as well when we do that. This'll also be
an opportunity to modernize that code and use more `til` classes.
2025-01-28 16:54:49 -06:00

1049 lines
49 KiB
C++

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "pch.h"
#include "../TerminalControl/EventArgs.h"
#include "../TerminalControl/ControlInteractivity.h"
#include "../../inc/TestUtils.h"
#include "MockControlSettings.h"
#include "MockConnection.h"
using namespace ::Microsoft::Console;
using namespace WEX::Logging;
using namespace WEX::TestExecution;
using namespace WEX::Common;
using namespace winrt;
using namespace winrt::Microsoft::Terminal;
using namespace ::Microsoft::Terminal::Core;
using namespace ::Microsoft::Console::VirtualTerminal;
namespace ControlUnitTests
{
class ControlInteractivityTests
{
BEGIN_TEST_CLASS(ControlInteractivityTests)
TEST_CLASS_PROPERTY(L"TestTimeout", L"0:0:10") // 10s timeout
END_TEST_CLASS()
TEST_METHOD(TestAdjustAcrylic);
TEST_METHOD(TestScrollWithMouse);
TEST_METHOD(CreateSubsequentSelectionWithDragging);
TEST_METHOD(ScrollWithSelection);
TEST_METHOD(TestScrollWithTrackpad);
TEST_METHOD(TestQuickDragOnSelect);
TEST_METHOD(TestDragSelectOutsideBounds);
TEST_METHOD(PointerClickOutsideActiveRegion);
TEST_METHOD(IncrementCircularBufferWithSelection);
TEST_METHOD(GetMouseEventsInTest);
TEST_METHOD(AltBufferClampMouse);
TEST_CLASS_SETUP(ClassSetup)
{
winrt::init_apartment(winrt::apartment_type::single_threaded);
return true;
}
TEST_CLASS_CLEANUP(ClassCleanup)
{
winrt::uninit_apartment();
return true;
}
std::tuple<winrt::com_ptr<MockControlSettings>,
winrt::com_ptr<MockConnection>>
_createSettingsAndConnection()
{
Log::Comment(L"Create settings object");
auto settings = winrt::make_self<MockControlSettings>();
VERIFY_IS_NOT_NULL(settings);
Log::Comment(L"Create connection object");
auto conn = winrt::make_self<MockConnection>();
VERIFY_IS_NOT_NULL(conn);
return { settings, conn };
}
std::tuple<winrt::com_ptr<Control::implementation::ControlCore>,
winrt::com_ptr<Control::implementation::ControlInteractivity>>
_createCoreAndInteractivity(Control::IControlSettings settings,
TerminalConnection::ITerminalConnection conn)
{
Log::Comment(L"Create ControlInteractivity object");
auto interactivity = winrt::make_self<Control::implementation::ControlInteractivity>(settings, settings, conn);
VERIFY_IS_NOT_NULL(interactivity);
auto core = interactivity->_core;
core->_inUnitTests = true;
VERIFY_IS_NOT_NULL(core);
return { core, interactivity };
}
void _standardInit(winrt::com_ptr<Control::implementation::ControlCore> core,
winrt::com_ptr<Control::implementation::ControlInteractivity> interactivity)
{
// "Consolas" ends up with an actual size of 9x19 at 96DPI. So
// let's just arbitrarily start with a 270x380px (30x20 chars) window
core->Initialize(270, 380, 1.0);
#ifndef NDEBUG
core->_terminal->_suppressLockChecks = true;
#endif
VERIFY_IS_TRUE(core->_initializedTerminal);
VERIFY_ARE_EQUAL(20, core->_terminal->GetViewport().Height());
interactivity->Initialize();
}
// Returns a scope_exit callback that should be used to ensure all
// output is drained.
auto _addInputCallback(const winrt::com_ptr<MockConnection>& conn,
std::deque<std::wstring>& expectedOutput)
{
conn->TerminalOutput([&](const hstring& hstr) {
VERIFY_IS_GREATER_THAN(expectedOutput.size(), 0u);
const auto expected = expectedOutput.front();
expectedOutput.pop_front();
Log::Comment(fmt::format(L"Received: \"{}\"", TerminalCoreUnitTests::TestUtils::ReplaceEscapes(hstr.c_str())).c_str());
Log::Comment(fmt::format(L"Expected: \"{}\"", TerminalCoreUnitTests::TestUtils::ReplaceEscapes(expected)).c_str());
VERIFY_ARE_EQUAL(expected, hstr);
});
return std::move(wil::scope_exit([&]() {
VERIFY_ARE_EQUAL(0u, expectedOutput.size(), L"Validate we drained all the expected output");
}));
}
};
void ControlInteractivityTests::TestAdjustAcrylic()
{
Log::Comment(L"Test that scrolling the mouse wheel with Ctrl+Shift changes opacity");
Log::Comment(L"(This test won't log as it goes, because it does some 200 verifications.)");
WEX::TestExecution::SetVerifyOutput verifyOutputScope{ WEX::TestExecution::VerifyOutputSettings::LogOnlyFailures };
BEGIN_TEST_METHOD_PROPERTIES()
TEST_METHOD_PROPERTY(L"Data:useAcrylic", L"{true, false}")
END_TEST_METHOD_PROPERTIES()
bool useAcrylic;
VERIFY_SUCCEEDED(TestData::TryGetValue(L"useAcrylic", useAcrylic), L"whether or not we should enable acrylic");
auto [settings, conn] = _createSettingsAndConnection();
settings->UseAcrylic(useAcrylic);
settings->Opacity(0.5f);
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
// A callback to make sure that we're raising TransparencyChanged events
auto expectedOpacity = 0.5f;
auto opacityCallback = [&](auto&&, Control::TransparencyChangedEventArgs args) mutable {
VERIFY_ARE_EQUAL(expectedOpacity, args.Opacity());
VERIFY_ARE_EQUAL(expectedOpacity, core->Opacity());
// The Settings object's opacity shouldn't be changed
VERIFY_ARE_EQUAL(0.5f, settings->Opacity());
auto expectedUseAcrylic = expectedOpacity < 1.0f &&
(useAcrylic);
VERIFY_ARE_EQUAL(useAcrylic, settings->UseAcrylic());
VERIFY_ARE_EQUAL(expectedUseAcrylic, core->UseAcrylic());
};
core->TransparencyChanged(opacityCallback);
const auto modifiers = ControlKeyStates(ControlKeyStates::RightCtrlPressed | ControlKeyStates::ShiftPressed);
const Control::MouseButtonState buttonState{};
Log::Comment(L"Scroll in the positive direction, increasing opacity");
// Scroll more than enough times to get to 1.0 from .5.
for (auto i = 0; i < 55; i++)
{
// each mouse wheel only adjusts opacity by .01
expectedOpacity += 0.01f;
if (expectedOpacity >= 1.0f)
{
expectedOpacity = 1.0f;
}
// The mouse location and buttons don't matter here.
interactivity->MouseWheel(modifiers,
30,
Core::Point{ 0, 0 },
buttonState);
}
Log::Comment(L"Scroll in the negative direction, decreasing opacity");
// Scroll more than enough times to get to 0.0 from 1.0
for (auto i = 0; i < 105; i++)
{
// each mouse wheel only adjusts opacity by .01
expectedOpacity -= 0.01f;
if (expectedOpacity <= 0.0f)
{
expectedOpacity = 0.0f;
}
// The mouse location and buttons don't matter here.
interactivity->MouseWheel(modifiers,
-30,
Core::Point{ 0, 0 },
buttonState);
}
}
void ControlInteractivityTests::TestScrollWithMouse()
{
BEGIN_TEST_METHOD_PROPERTIES()
TEST_METHOD_PROPERTY(L"IsolationLevel", L"Method")
END_TEST_METHOD_PROPERTIES()
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For the sake of this test, scroll one line at a time
interactivity->_rowsToScroll = 1;
auto expectedTop = 0;
auto expectedViewHeight = 20;
auto expectedBufferHeight = 20;
auto scrollChangedHandler = [&](auto&&, const Control::ScrollPositionChangedArgs& args) mutable {
VERIFY_ARE_EQUAL(expectedTop, args.ViewTop());
VERIFY_ARE_EQUAL(expectedViewHeight, args.ViewHeight());
VERIFY_ARE_EQUAL(expectedBufferHeight, args.BufferSize());
};
core->ScrollPositionChanged(scrollChangedHandler);
interactivity->ScrollPositionChanged(scrollChangedHandler);
for (auto i = 0; i < 40; ++i)
{
Log::Comment(NoThrowString().Format(L"Writing line #%d", i));
// The \r\n in the 19th loop will cause the view to start moving
if (i >= 19)
{
expectedTop++;
expectedBufferHeight++;
}
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// We printed that 40 times, but the final \r\n bumped the view down one MORE row.
VERIFY_ARE_EQUAL(20, core->_terminal->GetViewport().Height());
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
VERIFY_ARE_EQUAL(20, core->ViewHeight());
VERIFY_ARE_EQUAL(41, core->BufferHeight());
Log::Comment(L"Scroll up a line");
const Control::MouseButtonState buttonState{};
const auto modifiers = ControlKeyStates();
expectedBufferHeight = 41;
expectedTop = 20;
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
Log::Comment(L"Scroll up 19 more times, to the top");
for (auto i = 0; i < 20; ++i)
{
expectedTop--;
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
}
Log::Comment(L"Scrolling up more should do nothing");
expectedTop = 0;
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
Log::Comment(L"Scroll down 21 more times, to the bottom");
for (auto i = 0; i < 21; ++i)
{
Log::Comment(NoThrowString().Format(L"---scroll down #%d---", i));
expectedTop++;
interactivity->MouseWheel(modifiers,
-WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
Log::Comment(NoThrowString().Format(L"internal scrollbar pos:%f", interactivity->_internalScrollbarPosition));
}
Log::Comment(L"Scrolling down more should do nothing");
expectedTop = 21;
interactivity->MouseWheel(modifiers,
-WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
interactivity->MouseWheel(modifiers,
-WHEEL_DELTA,
Core::Point{ 0, 0 },
buttonState);
}
void ControlInteractivityTests::CreateSubsequentSelectionWithDragging()
{
BEGIN_TEST_METHOD_PROPERTIES()
TEST_METHOD_PROPERTY(L"IsolationLevel", L"Method")
END_TEST_METHOD_PROPERTIES()
// This is a test for GH#9725
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const Control::MouseButtonState noMouseDown{};
const til::size fontSize{ 9, 21 };
Log::Comment(L"Click on the terminal");
const til::point terminalPosition0{ 0, 0 };
const auto cursorPosition0 = terminalPosition0 * fontSize;
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
Log::Comment(L"Drag the mouse just a little");
// move not quite a whole cell, but enough to start a selection
const til::point terminalPosition1{ 0, 0 };
const til::point cursorPosition1{ 6, 0 };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's one selection");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Drag the mouse down a whole row");
const til::point terminalPosition2{ 1, 1 };
const auto cursorPosition2 = terminalPosition2 * fontSize;
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition2.to_core_point(),
true);
Log::Comment(L"Verify that there's now two selections (one on each row)");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Release the mouse");
interactivity->PointerReleased(noMouseDown,
WM_LBUTTONUP, //pointerUpdateKind
modifiers,
cursorPosition2.to_core_point());
Log::Comment(L"Verify that there's still two selections");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"click outside the current selection");
const til::point terminalPosition3{ 2, 2 };
const auto cursorPosition3 = terminalPosition3 * fontSize;
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition3.to_core_point());
Log::Comment(L"Verify that there's now no selection");
VERIFY_IS_FALSE(core->HasSelection());
Log::Comment(L"Drag the mouse");
const til::point terminalPosition4{ 3, 2 };
const auto cursorPosition4 = terminalPosition4 * fontSize;
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition4.to_core_point(),
true);
Log::Comment(L"Verify that there's now one selection");
VERIFY_IS_TRUE(core->HasSelection());
}
void ControlInteractivityTests::ScrollWithSelection()
{
// This is a test for GH#9955.a
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For the sake of this test, scroll one line at a time
interactivity->_rowsToScroll = 1;
Log::Comment(L"Add some test to the terminal so we can scroll");
for (auto i = 0; i < 40; ++i)
{
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// We printed that 40 times, but the final \r\n bumped the view down one MORE row.
VERIFY_ARE_EQUAL(20, core->_terminal->GetViewport().Height());
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
VERIFY_ARE_EQUAL(20, core->ViewHeight());
VERIFY_ARE_EQUAL(41, core->BufferHeight());
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const til::size fontSize{ 9, 21 };
Log::Comment(L"Click on the terminal");
const til::point terminalPosition0{ 5, 5 };
const auto cursorPosition0{ terminalPosition0 * fontSize };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
VERIFY_IS_TRUE(interactivity->_singleClickTouchdownPos.has_value());
VERIFY_ARE_EQUAL(cursorPosition0.to_core_point(), interactivity->_singleClickTouchdownPos.value());
Log::Comment(L"Drag the mouse just a little");
// move not quite a whole cell, but enough to start a selection
const auto cursorPosition1{ cursorPosition0 + til::point{ 6, 0 } };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's one selection");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Verify the location of the selection");
// The viewport is on row 21, so the selection will be on:
// {(5, 5)+(0, 21)} to {(5, 5)+(0, 21)}
til::point expectedAnchor{ 5, 26 };
til::point expectedEnd{ 6, 26 }; // add 1 to x-coordinate because end is exclusive
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
VERIFY_ARE_EQUAL(expectedEnd, core->_terminal->GetSelectionEnd());
Log::Comment(L"Scroll up a line, with the left mouse button selected");
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
cursorPosition1.to_core_point(),
leftMouseDown);
Log::Comment(L"Verify the location of the selection");
// The viewport is now on row 20, so the selection will be on:
// {(5 + 1, 5)+(0, 20)} to {(5, 5)+(0, 21)}
// NOTE: newExpectedAnchor should be expectedEnd moved up one row
til::point newExpectedAnchor{ 6, 25 };
// Remember, the anchor is always before the end in the buffer. So yes,
// we started the selection on 5,26, but now that's the end.
VERIFY_ARE_EQUAL(newExpectedAnchor, core->_terminal->GetSelectionAnchor());
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionEnd());
}
void ControlInteractivityTests::TestScrollWithTrackpad()
{
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For the sake of this test, scroll one line at a time
interactivity->_rowsToScroll = 1;
for (auto i = 0; i < 40; ++i)
{
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// We printed that 40 times, but the final \r\n bumped the view down one MORE row.
VERIFY_ARE_EQUAL(20, core->_terminal->GetViewport().Height());
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
VERIFY_ARE_EQUAL(20, core->ViewHeight());
VERIFY_ARE_EQUAL(41, core->BufferHeight());
Log::Comment(L"Scroll up a line");
const auto modifiers = ControlKeyStates();
// Deltas that I saw while scrolling with the surface laptop trackpad
// were on the range [-22, 7], though I'm sure they could be greater in
// magnitude.
//
// WHEEL_DELTA is 120, so we'll use 24 for now as the delta, just so the tests don't take forever.
const auto delta = WHEEL_DELTA / 5;
const Core::Point mousePos{ 0, 0 };
Control::MouseButtonState state{};
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 1/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
Log::Comment(L"Scroll up 4 more times. Once we're at 3/5 scrolls, "
L"we'll round the internal scrollbar position to scrolling to the next row.");
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 2/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 3/5
VERIFY_ARE_EQUAL(20, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 4/5
VERIFY_ARE_EQUAL(20, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 5/5
VERIFY_ARE_EQUAL(20, core->ScrollOffset());
Log::Comment(L"Jump to line 5, so we can scroll down from there.");
interactivity->UpdateScrollbar(5);
VERIFY_ARE_EQUAL(5, core->ScrollOffset());
Log::Comment(L"Scroll down 5 times, at which point we should accumulate a whole row of delta.");
interactivity->MouseWheel(modifiers, -delta, mousePos, state); // 1/5
VERIFY_ARE_EQUAL(5, core->ScrollOffset());
interactivity->MouseWheel(modifiers, -delta, mousePos, state); // 2/5
VERIFY_ARE_EQUAL(5, core->ScrollOffset());
interactivity->MouseWheel(modifiers, -delta, mousePos, state); // 3/5
VERIFY_ARE_EQUAL(6, core->ScrollOffset());
interactivity->MouseWheel(modifiers, -delta, mousePos, state); // 4/5
VERIFY_ARE_EQUAL(6, core->ScrollOffset());
interactivity->MouseWheel(modifiers, -delta, mousePos, state); // 5/5
VERIFY_ARE_EQUAL(6, core->ScrollOffset());
Log::Comment(L"Jump to the bottom.");
interactivity->UpdateScrollbar(21);
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
Log::Comment(L"Scroll a bit, then emit a line of text. We should reset our internal scroll position.");
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 1/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 2/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
VERIFY_ARE_EQUAL(22, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 1/5
VERIFY_ARE_EQUAL(22, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 2/5
VERIFY_ARE_EQUAL(22, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 3/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 4/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
interactivity->MouseWheel(modifiers, delta, mousePos, state); // 5/5
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
}
void ControlInteractivityTests::TestQuickDragOnSelect()
{
// This is a test for GH#9955.c
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const til::size fontSize{ 9, 21 };
Log::Comment(L"Click on the terminal");
const til::point cursorPosition0{ 6, 0 };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
VERIFY_IS_TRUE(interactivity->_singleClickTouchdownPos.has_value());
VERIFY_ARE_EQUAL(cursorPosition0.to_core_point(), interactivity->_singleClickTouchdownPos.value());
Log::Comment(L"Drag the mouse a lot. This simulates dragging the mouse real fast.");
const til::point cursorPosition1{ 6 + fontSize.width * 2, 0 };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's one selection");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Verify that it started on the first cell we clicked on, not the one we dragged to");
til::point expectedAnchor{ 0, 0 };
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
}
void ControlInteractivityTests::TestDragSelectOutsideBounds()
{
// This is a test for GH#4603
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const Control::MouseButtonState noMouseDown{};
const til::size fontSize{ 9, 21 };
Log::Comment(L"Click on the terminal");
const til::point cursorPosition0{ 6, 0 };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
VERIFY_IS_TRUE(interactivity->_singleClickTouchdownPos.has_value());
VERIFY_ARE_EQUAL(cursorPosition0.to_core_point(), interactivity->_singleClickTouchdownPos.value());
Log::Comment(L"Drag the mouse a lot. This simulates dragging the mouse real fast.");
const til::point cursorPosition1{ 6 + fontSize.width * 2, 0 };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's one selection");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Verify that it started on the first cell we clicked on, not the one we dragged to");
til::point expectedAnchor{ 0, 0 };
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
til::point expectedEnd{ 3, 0 }; // add 1 to x-coordinate because end is exclusive
VERIFY_ARE_EQUAL(expectedEnd, core->_terminal->GetSelectionEnd());
interactivity->PointerReleased(noMouseDown,
WM_LBUTTONUP,
modifiers,
cursorPosition1.to_core_point());
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
VERIFY_ARE_EQUAL(expectedEnd, core->_terminal->GetSelectionEnd());
Log::Comment(L"Simulate dragging the mouse into the control, without first clicking into the control");
const til::point cursorPosition2{ fontSize.width * 10, 0 };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition2.to_core_point(),
false);
Log::Comment(L"The selection should be unchanged.");
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
VERIFY_ARE_EQUAL(expectedEnd, core->_terminal->GetSelectionEnd());
}
void ControlInteractivityTests::PointerClickOutsideActiveRegion()
{
// This is a test for GH#10642
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const Control::MouseButtonState noMouseDown{};
const til::size fontSize{ 9, 21 };
interactivity->_rowsToScroll = 1;
auto expectedTop = 0;
auto expectedViewHeight = 20;
auto expectedBufferHeight = 20;
auto scrollChangedHandler = [&](auto&&, const Control::ScrollPositionChangedArgs& args) mutable {
VERIFY_ARE_EQUAL(expectedTop, args.ViewTop());
VERIFY_ARE_EQUAL(expectedViewHeight, args.ViewHeight());
VERIFY_ARE_EQUAL(expectedBufferHeight, args.BufferSize());
};
core->ScrollPositionChanged(scrollChangedHandler);
interactivity->ScrollPositionChanged(scrollChangedHandler);
for (auto i = 0; i < 40; ++i)
{
Log::Comment(NoThrowString().Format(L"Writing line #%d", i));
// The \r\n in the 19th loop will cause the view to start moving
if (i >= 19)
{
expectedTop++;
expectedBufferHeight++;
}
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// We printed that 40 times, but the final \r\n bumped the view down one MORE row.
VERIFY_ARE_EQUAL(20, core->_terminal->GetViewport().Height());
VERIFY_ARE_EQUAL(21, core->ScrollOffset());
VERIFY_ARE_EQUAL(20, core->ViewHeight());
VERIFY_ARE_EQUAL(41, core->BufferHeight());
expectedBufferHeight = 41;
expectedTop = 21;
Log::Comment(L"Scroll up 10 times");
for (auto i = 0; i < 11; ++i)
{
expectedTop--;
interactivity->MouseWheel(modifiers,
WHEEL_DELTA,
Core::Point{ 0, 0 },
noMouseDown);
}
// Enable VT mouse event tracking
conn->WriteInput(winrt_wstring_to_array_view(L"\x1b[?1003;1006h"));
// Mouse clicks in the inactive region (i.e. the top 10 rows in this case) should not register
Log::Comment(L"Click on the terminal");
const til::point terminalPosition0{ 4, 4 };
const auto cursorPosition0 = terminalPosition0 * fontSize;
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
Log::Comment(L"Drag the mouse");
// move the mouse as if to make a selection
const til::point terminalPosition1{ 10, 4 };
const auto cursorPosition1 = terminalPosition1 * fontSize;
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's still no selection");
VERIFY_IS_FALSE(core->HasSelection());
}
void ControlInteractivityTests::IncrementCircularBufferWithSelection()
{
// This is a test for GH#10749
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
Log::Comment(L"Fill up the history buffer");
const auto scrollbackLength = settings->HistorySize();
// Output lines equal to history size + viewport height to make sure we're
// at the point where outputting more lines causes circular incrementing
for (auto i = 0; i < settings->HistorySize() + core->ViewHeight(); ++i)
{
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
VERIFY_ARE_EQUAL(scrollbackLength, core->_terminal->GetScrollOffset());
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const til::size fontSize{ 9, 21 };
Log::Comment(L"Click on the terminal");
const til::point terminalPosition0{ 5, 5 };
const auto cursorPosition0{ terminalPosition0 * fontSize };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
Log::Comment(L"Verify that there's not yet a selection");
VERIFY_IS_FALSE(core->HasSelection());
VERIFY_IS_TRUE(interactivity->_singleClickTouchdownPos.has_value());
VERIFY_ARE_EQUAL(cursorPosition0.to_core_point(), interactivity->_singleClickTouchdownPos.value());
Log::Comment(L"Drag the mouse just a little");
// move not quite a whole cell, but enough to start a selection
const auto cursorPosition1{ cursorPosition0 + til::point{ 6, 0 } };
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
Log::Comment(L"Verify that there's one selection");
VERIFY_IS_TRUE(core->HasSelection());
Log::Comment(L"Verify the location of the selection");
// The viewport is on row (historySize + 5), so the selection will be on:
// {(5, (historySize+5))+(0, 21)} to {(5, (historySize+5))+(0, 21)}
til::point expectedAnchor{ 5, settings->HistorySize() + 5 };
til::point expectedEnd{ 6, expectedAnchor.y }; // add 1 to x-coordinate because end is exclusive
VERIFY_ARE_EQUAL(expectedAnchor, core->_terminal->GetSelectionAnchor());
VERIFY_ARE_EQUAL(expectedEnd, core->_terminal->GetSelectionEnd());
Log::Comment(L"Output a line of text");
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
Log::Comment(L"Verify the location of the selection");
// The selection should now be 1 row lower
expectedAnchor.y -= 1;
expectedEnd.y -= 1;
{
const auto anchor{ core->_terminal->GetSelectionAnchor() };
const auto end{ core->_terminal->GetSelectionEnd() };
Log::Comment(fmt::format(L"expectedAnchor:({},{})", expectedAnchor.x, expectedAnchor.y).c_str());
Log::Comment(fmt::format(L"anchor:({},{})", anchor.x, anchor.y).c_str());
Log::Comment(fmt::format(L"end:({},{})", end.x, end.y).c_str());
VERIFY_ARE_EQUAL(expectedAnchor, anchor);
VERIFY_ARE_EQUAL(expectedEnd, end);
}
VERIFY_ARE_EQUAL(scrollbackLength - 1, core->_terminal->GetScrollOffset());
Log::Comment(L"Output a line of text");
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
Log::Comment(L"Verify the location of the selection");
// The selection should now be 1 row lower
expectedAnchor.y -= 1;
expectedEnd.y -= 1;
{
const auto anchor{ core->_terminal->GetSelectionAnchor() };
const auto end{ core->_terminal->GetSelectionEnd() };
Log::Comment(fmt::format(L"expectedAnchor:({},{})", expectedAnchor.x, expectedAnchor.y).c_str());
Log::Comment(fmt::format(L"anchor:({},{})", anchor.x, anchor.y).c_str());
Log::Comment(fmt::format(L"end:({},{})", end.x, end.y).c_str());
VERIFY_ARE_EQUAL(expectedAnchor, anchor);
VERIFY_ARE_EQUAL(expectedEnd, end);
}
VERIFY_ARE_EQUAL(scrollbackLength - 2, core->_terminal->GetScrollOffset());
Log::Comment(L"Move the mouse a little, to update the selection");
// At this point, there should only be one selection region! The
// viewport moved up to keep the selection at the same relative spot. So
// wiggling the cursor should continue to select only the same
// character in the buffer (if, albeit in a new location).
//
// This helps test GH #14462, a regression from #10749.
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition0.to_core_point(),
true);
VERIFY_IS_TRUE(core->HasSelection());
{
const auto anchor{ core->_terminal->GetSelectionAnchor() };
const auto end{ core->_terminal->GetSelectionEnd() };
Log::Comment(fmt::format(L"expectedAnchor:({},{})", expectedAnchor.x, expectedAnchor.y).c_str());
Log::Comment(fmt::format(L"anchor:({},{})", anchor.x, anchor.y).c_str());
Log::Comment(fmt::format(L"end:({},{})", end.x, end.y).c_str());
// Selection was updated, but we didn't highlight a full cell
VERIFY_ARE_EQUAL(expectedAnchor, anchor);
VERIFY_ARE_EQUAL(expectedAnchor, end);
}
Log::Comment(L"Output a line and move the mouse a little to update the selection, all at once");
// Same as above. The viewport has moved, so the mouse is still over the
// same character, even though it's at a new offset.
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
expectedAnchor.y -= 1;
expectedEnd.y -= 1;
VERIFY_ARE_EQUAL(scrollbackLength - 3, core->_terminal->GetScrollOffset());
interactivity->PointerMoved(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
modifiers,
true, // focused,
cursorPosition1.to_core_point(),
true);
VERIFY_IS_TRUE(core->HasSelection());
{
const auto anchor{ core->_terminal->GetSelectionAnchor() };
const auto end{ core->_terminal->GetSelectionEnd() };
Log::Comment(fmt::format(L"expectedAnchor:({},{})", expectedAnchor.x, expectedAnchor.y).c_str());
Log::Comment(fmt::format(L"anchor:({},{})", anchor.x, anchor.y).c_str());
Log::Comment(fmt::format(L"end:({},{})", end.x, end.y).c_str());
VERIFY_ARE_EQUAL(expectedAnchor, anchor);
VERIFY_ARE_EQUAL(expectedEnd, end);
}
// Output enough text for the selection to get pushed off the buffer
for (auto i = 0; i < settings->HistorySize() + core->ViewHeight(); ++i)
{
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// Verify that the selection got reset
VERIFY_IS_FALSE(core->HasSelection());
}
void ControlInteractivityTests::GetMouseEventsInTest()
{
// This is just a simple case that proves you can test mouse events
// generated by the terminal
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
std::deque<std::wstring> expectedOutput{};
auto validateDrained = _addInputCallback(conn, expectedOutput);
Log::Comment(L"Enable mouse mode");
auto& term{ *core->_terminal };
term.Write(L"\x1b[?1000h");
Log::Comment(L"Click on the terminal");
expectedOutput.push_back(L"\x1b[M &&");
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const til::size fontSize{ 9, 21 };
const til::point terminalPosition0{ 5, 5 };
const auto cursorPosition0{ terminalPosition0 * fontSize };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
}
void ControlInteractivityTests::AltBufferClampMouse()
{
// This is a test for
// * GH#10642
// * a comment in GH#12719
WEX::TestExecution::DisableVerifyExceptions disableVerifyExceptions{};
auto [settings, conn] = _createSettingsAndConnection();
auto [core, interactivity] = _createCoreAndInteractivity(*settings, *conn);
_standardInit(core, interactivity);
auto& term{ *core->_terminal };
// Output enough text for view to start scrolling
for (auto i = 0; i < core->ViewHeight() * 2; ++i)
{
conn->WriteInput(winrt_wstring_to_array_view(L"Foo\r\n"));
}
// Start checking output
std::deque<std::wstring> expectedOutput{};
auto validateDrained = _addInputCallback(conn, expectedOutput);
const auto originalViewport{ term.GetViewport() };
VERIFY_ARE_EQUAL(originalViewport.Width(), 30);
Log::Comment(L" --- Enable mouse mode ---");
term.Write(L"\x1b[?1000h");
Log::Comment(L" --- Click on the terminal ---");
// Recall:
//
// > ! specifies the value 1. The upper left character position on
// > the terminal is denoted as 1,1
//
// So 5 in our buffer is 32+5+1 = '&'
expectedOutput.push_back(L"\x1b[M &&");
// For this test, don't use any modifiers
const auto modifiers = ControlKeyStates();
const auto leftMouseDown{ Control::MouseButtonState::IsLeftButtonDown };
const til::size fontSize{ 9, 21 };
const til::point terminalPosition0{ 5, 5 };
const auto cursorPosition0{ terminalPosition0 * fontSize };
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
VERIFY_ARE_EQUAL(0u, expectedOutput.size(), L"Validate we drained all the expected output");
// These first two bits are a test for GH#10642
Log::Comment(L" --- Click on the terminal outside the width of the mutable viewport, see that it's clamped to the viewport ---");
// Not actually possible, but for validation.
const til::point terminalPosition1{ originalViewport.Width() + 5, 5 };
const auto cursorPosition1{ terminalPosition1 * fontSize };
// The viewport is only 30 wide, so clamping 35 to the buffer size gets
// us 29, which converted is (32 + 29 + 1) = 62 = '>'
expectedOutput.push_back(L"\x1b[M >&");
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition1.to_core_point());
VERIFY_ARE_EQUAL(0u, expectedOutput.size(), L"Validate we drained all the expected output");
Log::Comment(L" --- Scroll up, click the terminal. We shouldn't get any event. ---");
core->UserScrollViewport(10);
VERIFY_IS_GREATER_THAN(core->ScrollOffset(), 0);
// Viewport is now above the mutable viewport, so the mouse event
// will be clamped to the top line.
expectedOutput.push_back(L"\x1b[M &!"); // 5, 1
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition0.to_core_point());
// Flush it out.
VERIFY_ARE_EQUAL(0u, expectedOutput.size(), L"Validate we drained all the expected output");
// This is the part as mentioned in GH#12719
Log::Comment(L" --- Switch to alt buffer ---");
term.Write(L"\x1b[?1049h");
auto returnToMain = wil::scope_exit([&]() { term.Write(L"\x1b[?1049h"); });
VERIFY_ARE_EQUAL(0, core->ScrollOffset());
Log::Comment(L" --- Click on a spot that's still outside the buffer ---");
expectedOutput.push_back(L"\x1b[M >&");
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition1.to_core_point());
Log::Comment(L" --- Resize the terminal to be 10 columns wider ---");
const auto newWidth = 40.0f * fontSize.width;
const auto newHeight = 20.0f * fontSize.height;
core->SizeChanged(newWidth, newHeight);
Log::Comment(L" --- Click on a spot that's NOW INSIDE the buffer ---");
// (32 + 35 + 1) = 68 = 'D'
expectedOutput.push_back(L"\x1b[M D&");
interactivity->PointerPressed(leftMouseDown,
WM_LBUTTONDOWN, //pointerUpdateKind
0, // timestamp
modifiers,
cursorPosition1.to_core_point());
VERIFY_ARE_EQUAL(0u, expectedOutput.size(), L"Validate we drained all the expected output");
}
}