Chunk Selection Expansion for Double/Triple Click Selection (#2184)

Double/Triple click create a selection expanding beyond one cell. This PR makes it so that when you're dragging your mouse to expand the selection, you expand to the next delimiter defined by double/triple click.

So, double click expands by doubleClickDelimiter ranges. Triple click expands by line.

When you double/triple click, a word/line is selected. When you drag, that word/line will remain selected after the expansion occurs.

Closes #1933 

## Details
Rather than resizing the selection when the mouse event occurs, I figured I'd do what I did with wide glyph selection: expand at render time.

We needed an enum `multiClickSelectionMode` to keep track of which expansion mode we're in.

Minor modifications to `_ExpandDoubleClickSelection*(COORD)` had to be made so that we can re-use them. 

Actual expansion occurs in `_GetSelectionRects()`

## Validation Steps Performed
- generic double click test
  - `dir` or `ls`
  - double click a word
  - drag up
  - Works! ✔
- double click on delimiter test
  - `dir` or `ls`
  - double click a word delimiter (i.e.: space between words)
  - drag up
  - Works! ✔
- generic triple click test
  - `dir` or `ls`
  - triple click a line
  - drag up
  - Works! ✔
- ALT + double click test
  - `dir` or `ls`
  - hold ALT
  - double click a word
  - drag up
  - Works! ✔

repeat above tests in following scenarios:
- when at top of scrollback
- drag down instead of up
This commit is contained in:
Carlos Zamora 2019-08-14 16:41:43 -07:00 committed by Dustin L. Howett (MSFT)
parent 82de43bce9
commit 1f41fd35cf
6 changed files with 320 additions and 85 deletions

View file

@ -164,7 +164,13 @@ private:
bool _snapOnInput;
// Text Selection
#pragma region Text Selection
enum SelectionExpansionMode
{
Cell,
Word,
Line
};
COORD _selectionAnchor;
COORD _endSelectionPosition;
bool _boxSelection;
@ -172,6 +178,8 @@ private:
SHORT _selectionAnchor_YOffset;
SHORT _endSelectionPosition_YOffset;
std::wstring _wordDelimiters;
SelectionExpansionMode _multiClickSelectionMode;
#pragma endregion
std::shared_mutex _readWriteLock;
@ -214,8 +222,8 @@ private:
std::vector<SMALL_RECT> _GetSelectionRects() const;
const SHORT _ExpandWideGlyphSelectionLeft(const SHORT xPos, const SHORT yPos) const;
const SHORT _ExpandWideGlyphSelectionRight(const SHORT xPos, const SHORT yPos) const;
void _ExpandDoubleClickSelectionLeft(const COORD position);
void _ExpandDoubleClickSelectionRight(const COORD position);
COORD _ExpandDoubleClickSelectionLeft(const COORD position) const;
COORD _ExpandDoubleClickSelectionRight(const COORD position) const;
const bool _isWordDelimiter(std::wstring_view cellChar) const;
const COORD _ConvertToBufferCell(const COORD viewportPos) const;
#pragma endregion

View file

@ -63,6 +63,27 @@ std::vector<SMALL_RECT> Terminal::_GetSelectionRects() const
selectionRow.Right = (row == lowerCoord.Y) ? lowerCoord.X : bufferSize.RightInclusive();
}
// expand selection for Double/Triple Click
if (_multiClickSelectionMode == SelectionExpansionMode::Word)
{
const auto cellChar = _buffer->GetCellDataAt(selectionAnchorWithOffset)->Chars();
if (_selectionAnchor == _endSelectionPosition && _isWordDelimiter(cellChar))
{
// only highlight the cell if you double click a delimiter
}
else
{
selectionRow.Left = _ExpandDoubleClickSelectionLeft({ selectionRow.Left, row }).X;
selectionRow.Right = _ExpandDoubleClickSelectionRight({ selectionRow.Right, row }).X;
}
}
else if (_multiClickSelectionMode == SelectionExpansionMode::Line)
{
selectionRow.Left = 0;
selectionRow.Right = bufferSize.RightInclusive();
}
// expand selection for Wide Glyphs
selectionRow.Left = _ExpandWideGlyphSelectionLeft(selectionRow.Left, row);
selectionRow.Right = _ExpandWideGlyphSelectionRight(selectionRow.Right, row);
@ -142,16 +163,24 @@ void Terminal::DoubleClickSelection(const COORD position)
if (_isWordDelimiter(cellChar))
{
SetSelectionAnchor(position);
_multiClickSelectionMode = SelectionExpansionMode::Word;
return;
}
// scan leftwards until delimiter is found and
// set selection anchor to one right of that spot
_ExpandDoubleClickSelectionLeft(position);
_selectionAnchor = _ExpandDoubleClickSelectionLeft(positionWithOffsets);
THROW_IF_FAILED(ShortSub(_selectionAnchor.Y, gsl::narrow<SHORT>(_ViewStartIndex()), &_selectionAnchor.Y));
_selectionAnchor_YOffset = gsl::narrow<SHORT>(_ViewStartIndex());
// scan rightwards until delimiter is found and
// set endSelectionPosition to one left of that spot
_ExpandDoubleClickSelectionRight(position);
_endSelectionPosition = _ExpandDoubleClickSelectionRight(positionWithOffsets);
THROW_IF_FAILED(ShortSub(_endSelectionPosition.Y, gsl::narrow<SHORT>(_ViewStartIndex()), &_endSelectionPosition.Y));
_endSelectionPosition_YOffset = gsl::narrow<SHORT>(_ViewStartIndex());
_selectionActive = true;
_multiClickSelectionMode = SelectionExpansionMode::Word;
}
// Method Description:
@ -162,6 +191,8 @@ void Terminal::TripleClickSelection(const COORD position)
{
SetSelectionAnchor({ 0, position.Y });
SetEndSelectionPosition({ _buffer->GetSize().RightInclusive(), position.Y });
_multiClickSelectionMode = SelectionExpansionMode::Line;
}
// Method Description:
@ -181,6 +212,8 @@ void Terminal::SetSelectionAnchor(const COORD position)
_selectionActive = true;
SetEndSelectionPosition(position);
_multiClickSelectionMode = SelectionExpansionMode::Cell;
}
// Method Description:
@ -253,16 +286,10 @@ const std::wstring Terminal::RetrieveSelectedTextFromBuffer(bool trimTrailingWhi
// Arguments:
// - position: viewport coordinate for selection
// Return Value:
// - update _selectionAnchor to new expanded location
void Terminal::_ExpandDoubleClickSelectionLeft(const COORD position)
// - updated copy of "position" to new expanded location (with vertical offset)
COORD Terminal::_ExpandDoubleClickSelectionLeft(const COORD position) const
{
// don't change the value if at/outside the boundary
if (position.X <= 0 || position.X >= _buffer->GetSize().RightInclusive())
{
return;
}
COORD positionWithOffsets = _ConvertToBufferCell(position);
COORD positionWithOffsets = position;
const auto bufferViewport = _buffer->GetSize();
auto cellChar = _buffer->GetCellDataAt(positionWithOffsets)->Chars();
while (positionWithOffsets.X != 0 && !_isWordDelimiter(cellChar))
@ -271,16 +298,13 @@ void Terminal::_ExpandDoubleClickSelectionLeft(const COORD position)
cellChar = _buffer->GetCellDataAt(positionWithOffsets)->Chars();
}
if (positionWithOffsets.X != 0 || _isWordDelimiter(cellChar))
if (positionWithOffsets.X != 0 && _isWordDelimiter(cellChar))
{
// move off of delimiter to highlight properly
bufferViewport.IncrementInBounds(positionWithOffsets);
}
THROW_IF_FAILED(ShortSub(positionWithOffsets.Y, gsl::narrow<SHORT>(_ViewStartIndex()), &positionWithOffsets.Y));
_selectionAnchor = positionWithOffsets;
_selectionAnchor_YOffset = gsl::narrow<SHORT>(_ViewStartIndex());
_selectionActive = true;
return positionWithOffsets;
}
// Method Description:
@ -288,16 +312,10 @@ void Terminal::_ExpandDoubleClickSelectionLeft(const COORD position)
// Arguments:
// - position: viewport coordinate for selection
// Return Value:
// - update _endSelectionPosition to new expanded location
void Terminal::_ExpandDoubleClickSelectionRight(const COORD position)
// - updated copy of "position" to new expanded location (with vertical offset)
COORD Terminal::_ExpandDoubleClickSelectionRight(const COORD position) const
{
// don't change the value if at/outside the boundary
if (position.X <= 0 || position.X >= _buffer->GetSize().RightInclusive())
{
return;
}
COORD positionWithOffsets = _ConvertToBufferCell(position);
COORD positionWithOffsets = position;
const auto bufferViewport = _buffer->GetSize();
auto cellChar = _buffer->GetCellDataAt(positionWithOffsets)->Chars();
while (positionWithOffsets.X != _buffer->GetSize().RightInclusive() && !_isWordDelimiter(cellChar))
@ -306,15 +324,13 @@ void Terminal::_ExpandDoubleClickSelectionRight(const COORD position)
cellChar = _buffer->GetCellDataAt(positionWithOffsets)->Chars();
}
if (positionWithOffsets.X != bufferViewport.RightInclusive() || _isWordDelimiter(cellChar))
if (positionWithOffsets.X != bufferViewport.RightInclusive() && _isWordDelimiter(cellChar))
{
// move off of delimiter to highlight properly
bufferViewport.DecrementInBounds(positionWithOffsets);
}
THROW_IF_FAILED(ShortSub(positionWithOffsets.Y, gsl::narrow<SHORT>(_ViewStartIndex()), &positionWithOffsets.Y));
_endSelectionPosition = positionWithOffsets;
_endSelectionPosition_YOffset = gsl::narrow<SHORT>(_ViewStartIndex());
return positionWithOffsets;
}
// Method Description:
@ -336,7 +352,11 @@ const bool Terminal::_isWordDelimiter(std::wstring_view cellChar) const
// - the corresponding location on the buffer
const COORD Terminal::_ConvertToBufferCell(const COORD viewportPos) const
{
// Force position to be valid
COORD positionWithOffsets = viewportPos;
positionWithOffsets.X = std::clamp(viewportPos.X, static_cast<SHORT>(0), _buffer->GetSize().RightInclusive());
positionWithOffsets.Y = std::clamp(viewportPos.Y, static_cast<SHORT>(0), _buffer->GetSize().BottomInclusive());
THROW_IF_FAILED(ShortSub(viewportPos.Y, gsl::narrow<SHORT>(_scrollOffset), &positionWithOffsets.Y));
THROW_IF_FAILED(ShortAdd(positionWithOffsets.Y, gsl::narrow<SHORT>(_ViewStartIndex()), &positionWithOffsets.Y));
return positionWithOffsets;

View file

@ -0,0 +1,59 @@
#pragma once
#include "precomp.h"
#include <WexTestClass.h>
#include "DefaultSettings.h"
#include "winrt/Microsoft.Terminal.Settings.h"
using namespace winrt::Microsoft::Terminal::Settings;
namespace TerminalCoreUnitTests
{
class MockTermSettings : public winrt::implements<MockTermSettings, ICoreSettings>
{
public:
MockTermSettings(int32_t historySize, int32_t initialRows, int32_t initialCols) :
_historySize(historySize),
_initialRows(initialRows),
_initialCols(initialCols)
{
}
// property getters - all implemented
int32_t HistorySize() { return _historySize; }
int32_t InitialRows() { return _initialRows; }
int32_t InitialCols() { return _initialCols; }
uint32_t DefaultForeground() { return COLOR_WHITE; }
uint32_t DefaultBackground() { return COLOR_BLACK; }
bool SnapOnInput() { return false; }
uint32_t CursorColor() { return COLOR_WHITE; }
CursorStyle CursorShape() const noexcept { return CursorStyle::Vintage; }
uint32_t CursorHeight() { return 42UL; }
winrt::hstring WordDelimiters() { return winrt::to_hstring(DEFAULT_WORD_DELIMITERS.c_str()); }
// other implemented methods
uint32_t GetColorTableEntry(int32_t) const { return 123; }
// property setters - all unimplemented
void HistorySize(int32_t) {}
void InitialRows(int32_t) {}
void InitialCols(int32_t) {}
void DefaultForeground(uint32_t) {}
void DefaultBackground(uint32_t) {}
void SnapOnInput(bool) {}
void CursorColor(uint32_t) {}
void CursorShape(CursorStyle const&) noexcept {}
void CursorHeight(uint32_t) {}
void WordDelimiters(winrt::hstring) {}
// other unimplemented methods
void SetColorTableEntry(int32_t /* index */, uint32_t /* value */) {}
private:
int32_t _historySize;
int32_t _initialRows;
int32_t _initialCols;
};
}

View file

@ -4,64 +4,16 @@
#include "precomp.h"
#include <WexTestClass.h>
#include "DefaultSettings.h"
#include "../cascadia/TerminalCore/Terminal.hpp"
#include "MockTermSettings.h"
#include "../renderer/inc/DummyRenderTarget.hpp"
#include "consoletaeftemplates.hpp"
#include "winrt/Microsoft.Terminal.Settings.h"
using namespace winrt::Microsoft::Terminal::Settings;
using namespace Microsoft::Terminal::Core;
namespace TerminalCoreUnitTests
{
class MockTermSettings : public winrt::implements<MockTermSettings, ICoreSettings>
{
public:
MockTermSettings(int32_t historySize, int32_t initialRows, int32_t initialCols) :
_historySize(historySize),
_initialRows(initialRows),
_initialCols(initialCols)
{
}
// property getters - all implemented
int32_t HistorySize() { return _historySize; }
int32_t InitialRows() { return _initialRows; }
int32_t InitialCols() { return _initialCols; }
uint32_t DefaultForeground() { return COLOR_WHITE; }
uint32_t DefaultBackground() { return COLOR_BLACK; }
bool SnapOnInput() { return false; }
uint32_t CursorColor() { return COLOR_WHITE; }
CursorStyle CursorShape() const noexcept { return CursorStyle::Vintage; }
uint32_t CursorHeight() { return 42UL; }
winrt::hstring WordDelimiters() { return winrt::to_hstring(DEFAULT_WORD_DELIMITERS.c_str()); }
// other implemented methods
uint32_t GetColorTableEntry(int32_t) const { return 123; }
// property setters - all unimplemented
void HistorySize(int32_t) {}
void InitialRows(int32_t) {}
void InitialCols(int32_t) {}
void DefaultForeground(uint32_t) {}
void DefaultBackground(uint32_t) {}
void SnapOnInput(bool) {}
void CursorColor(uint32_t) {}
void CursorShape(CursorStyle const&) noexcept {}
void CursorHeight(uint32_t) {}
void WordDelimiters(winrt::hstring) {}
// other unimplemented methods
void SetColorTableEntry(int32_t /* index */, uint32_t /* value */) {}
private:
int32_t _historySize;
int32_t _initialRows;
int32_t _initialCols;
};
#define WCS(x) WCSHELPER(x)
#define WCSHELPER(x) L#x

View file

@ -9,6 +9,7 @@
#include <WexTestClass.h>
#include "../cascadia/TerminalCore/Terminal.hpp"
#include "../cascadia/UnitTests_TerminalCore/MockTermSettings.h"
#include "../renderer/inc/DummyRenderTarget.hpp"
#include "consoletaeftemplates.hpp"
@ -16,7 +17,7 @@ using namespace WEX::Logging;
using namespace WEX::TestExecution;
using namespace Microsoft::Terminal::Core;
using namespace Microsoft::Console::Render;
using namespace winrt::Microsoft::Terminal::Settings;
namespace TerminalCoreUnitTests
{
@ -183,7 +184,7 @@ namespace TerminalCoreUnitTests
{
#ifdef _X86_
Log::Comment(L"This test is unreliable on x86 but is fine elsewhere. Disabled on x86.");
Log::Result(WEX::Logging::TestResults::Skipped);
Log::Result(TestResults::Skipped);
return;
#else
Terminal term;
@ -218,7 +219,7 @@ namespace TerminalCoreUnitTests
{
#ifdef _X86_
Log::Comment(L"This test is unreliable on x86 but is fine elsewhere. Disabled on x86.");
Log::Result(WEX::Logging::TestResults::Skipped);
Log::Result(TestResults::Skipped);
return;
#else
Terminal term;
@ -253,7 +254,7 @@ namespace TerminalCoreUnitTests
{
#ifdef _X86_
Log::Comment(L"This test is unreliable on x86 but is fine elsewhere. Disabled on x86.");
Log::Result(WEX::Logging::TestResults::Skipped);
Log::Result(TestResults::Skipped);
return;
#else
Terminal term;
@ -309,5 +310,199 @@ namespace TerminalCoreUnitTests
}
#endif
}
TEST_METHOD(DoubleClick_GeneralCase)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// set word delimiters for terminal
auto settings = winrt::make<MockTermSettings>(0, 100, 100);
term.UpdateSettings(settings);
// Insert text at position (4,10)
const std::wstring_view text = L"doubleClickMe";
term.SetCursorPosition(4, 10);
term.Write(text);
// Simulate double click at (x,y) = (5,10)
auto clickPos = COORD{ 5, 10 };
term.DoubleClickSelection(clickPos);
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 4, 10, (4 + gsl::narrow<SHORT>(text.size()) - 1), 10 }));
}
TEST_METHOD(DoubleClick_Delimiter)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// set word delimiters for terminal
auto settings = winrt::make<MockTermSettings>(0, 100, 100);
term.UpdateSettings(settings);
// Simulate click at (x,y) = (5,10)
auto clickPos = COORD{ 5, 10 };
term.DoubleClickSelection(clickPos);
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 5, 10, 5, 10 }));
}
TEST_METHOD(DoubleClickDrag_Right)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// set word delimiters for terminal
auto settings = winrt::make<MockTermSettings>(0, 100, 100);
term.UpdateSettings(settings);
// Insert text at position (4,10)
const std::wstring_view text = L"doubleClickMe dragThroughHere";
term.SetCursorPosition(4, 10);
term.Write(text);
// Simulate double click at (x,y) = (5,10)
term.DoubleClickSelection({ 5, 10 });
// Simulate move to (x,y) = (21,10)
//
// buffer: doubleClickMe dragThroughHere
// ^ ^
// start finish
term.SetEndSelectionPosition({ 21, 10 });
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 4, 10, 32, 10 }));
}
TEST_METHOD(DoubleClickDrag_Left)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// set word delimiters for terminal
auto settings = winrt::make<MockTermSettings>(0, 100, 100);
term.UpdateSettings(settings);
// Insert text at position (21,10)
const std::wstring_view text = L"doubleClickMe dragThroughHere";
term.SetCursorPosition(4, 10);
term.Write(text);
// Simulate double click at (x,y) = (21,10)
term.DoubleClickSelection({ 21, 10 });
// Simulate move to (x,y) = (5,10)
//
// buffer: doubleClickMe dragThroughHere
// ^ ^
// finish start
term.SetEndSelectionPosition({ 5, 10 });
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 4, 10, 32, 10 }));
}
TEST_METHOD(TripleClick_GeneralCase)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// Simulate click at (x,y) = (5,10)
auto clickPos = COORD{ 5, 10 };
term.TripleClickSelection(clickPos);
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 0, 10, 99, 10 }));
}
TEST_METHOD(TripleClickDrag_Horizontal)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// Simulate click at (x,y) = (5,10)
auto clickPos = COORD{ 5, 10 };
term.TripleClickSelection(clickPos);
// Simulate move to (x,y) = (7,10)
term.SetEndSelectionPosition({ 7, 10 });
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(1));
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 0, 10, 99, 10 }));
}
TEST_METHOD(TripleClickDrag_Vertical)
{
Terminal term;
DummyRenderTarget emptyRT;
term.Create({ 100, 100 }, 0, emptyRT);
// Simulate click at (x,y) = (5,10)
auto clickPos = COORD{ 5, 10 };
term.TripleClickSelection(clickPos);
// Simulate move to (x,y) = (5,11)
term.SetEndSelectionPosition({ 5, 11 });
// Simulate renderer calling TriggerSelection and acquiring selection area
auto selectionRects = term.GetSelectionRects();
// Validate selection area
VERIFY_ARE_EQUAL(selectionRects.size(), static_cast<size_t>(2));
// verify first selection rect
auto selection = term.GetViewport().ConvertToOrigin(selectionRects.at(0)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 0, 10, 99, 10 }));
// verify second selection rect
selection = term.GetViewport().ConvertToOrigin(selectionRects.at(1)).ToInclusive();
VERIFY_ARE_EQUAL(selection, SMALL_RECT({ 0, 11, 99, 11 }));
}
};
}

View file

@ -28,6 +28,7 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<ClInclude Include="MockTermSettings.h" />
<ClInclude Include="precomp.h" />
</ItemGroup>
<PropertyGroup>