terminal/src/cascadia/TerminalCore/TerminalSelection.cpp
James Holderness 4c53c595e7
Add support for double-width/double-height lines in conhost (#8664)
This PR adds support for the VT line rendition attributes, which allow
for double-width and double-height line renditions. These renditions are
enabled with the `DECDWL` (double-width line) and `DECDHL`
(double-height line) escape sequences. Both reset to the default
rendition with the `DECSWL` (single-width line) escape sequence. For now
this functionality is only supported by the GDI renderer in conhost.

There are a lot of changes, so this is just a general overview of the
main areas affected.

Previously it was safe to assume that the screen had a fixed width, at
least for a given point in time. But now we need to deal with the
possibility of different lines have different widths, so all the
functions that are constrained by the right border (text wrapping,
cursor movement operations, and sequences like `EL` and `ICH`) now need
to lookup the width of the active line in order to behave correctly.

Similarly it used to be safe to assume that buffer and screen
coordinates were the same thing, but that is no longer true. Lots of
places now need to translate back and forth between coordinate systems
dependent on the line rendition. This includes clipboard handling, the
conhost color selection and search, accessibility location tracking and
screen reading, IME editor positioning, "snapping" the viewport, and of
course all the rendering calculations.

For the rendering itself, I've had to introduce a new
`PrepareLineTransform` method that the render engines can use to setup
the necessary transform matrix for a given line rendition. This is also
now used to handle the horizontal viewport offset, since that could no
longer be achieved just by changing the target coordinates (on a double
width line, the viewport offset may be halfway through a character).

I've also had to change the renderer's existing `InvalidateCursor`
method to take a `SMALL_RECT` rather than a `COORD`, to allow for the
cursor being a variable width. Technically this was already a problem,
because the cursor could occupy two screen cells when over a
double-width character, but now it can be anything between one and four
screen cells (e.g. a double-width character on the double-width line).

In terms of architectural changes, there is now a new `lineRendition`
field in the `ROW` class that keeps track of the line rendition for each
row, and several new methods in the `ROW` and `TextBuffer` classes for
manipulating that state. This includes a few helper methods for handling
the various issues discussed above, e.g. position clamping and
translating between coordinate systems.

## Validation Steps Performed

I've manually confirmed all the double-width and double-height tests in
_Vttest_ are now working as expected, and the _VT100 Torture Test_ now
renders correctly (at least the line rendition aspects). I've also got
my own test scripts that check many of the line rendition boundary cases
and have confirmed that those are now passing.

I've manually tested as many areas of the conhost UI that I could think
of, that might be affected by line rendition, including things like
searching, selection, copying, and color highlighting. For
accessibility, I've confirmed that the _Magnifier_ and _Narrator_
correctly handle double-width lines. And I've also tested the Japanese
IME, which while not perfect, is at least useable.

Closes #7865
2021-02-18 05:44:50 +00:00

294 lines
9.9 KiB
C++

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "pch.h"
#include "Terminal.hpp"
#include "unicode.hpp"
using namespace Microsoft::Terminal::Core;
/* Selection Pivot Description:
* The pivot helps properly update the selection when a user moves a selection over itself
* See SelectionTest::DoubleClickDrag_Left for an example of the functionality mentioned here
* As an example, consider the following scenario...
* 1. Perform a word selection (double-click) on a word
*
* |-position where we double-clicked
* _|_
* |word|
* |--|
* start & pivot-| |-end
*
* 2. Drag your mouse down a line
*
*
* start & pivot-|__________
* __|word_______|
* |______|
* |
* |-end & mouse position
*
* 3. Drag your mouse up two lines
*
* |-start & mouse position
* |________
* ____| ______|
* |___w|ord
* |-end & pivot
*
* The pivot never moves until a new selection is created. It ensures that that cell will always be selected.
*/
// Method Description:
// - Helper to determine the selected region of the buffer. Used for rendering.
// Return Value:
// - A vector of rectangles representing the regions to select, line by line. They are absolute coordinates relative to the buffer origin.
std::vector<SMALL_RECT> Terminal::_GetSelectionRects() const noexcept
{
std::vector<SMALL_RECT> result;
if (!IsSelectionActive())
{
return result;
}
try
{
return _buffer->GetTextRects(_selection->start, _selection->end, _blockSelection, false);
}
CATCH_LOG();
return result;
}
// Method Description:
// - Get the current anchor position relative to the whole text buffer
// Arguments:
// - None
// Return Value:
// - None
const COORD Terminal::GetSelectionAnchor() const noexcept
{
return _selection->start;
}
// Method Description:
// - Get the current end anchor position relative to the whole text buffer
// Arguments:
// - None
// Return Value:
// - None
const COORD Terminal::GetSelectionEnd() const noexcept
{
return _selection->end;
}
// Method Description:
// - Checks if selection is active
// Return Value:
// - bool representing if selection is active. Used to decide copy/paste on right click
const bool Terminal::IsSelectionActive() const noexcept
{
return _selection.has_value();
}
const bool Terminal::IsBlockSelection() const noexcept
{
return _blockSelection;
}
// Method Description:
// - Perform a multi-click selection at viewportPos expanding according to the expansionMode
// Arguments:
// - viewportPos: the (x,y) coordinate on the visible viewport
// - expansionMode: the SelectionExpansionMode to dictate the boundaries of the selection anchors
void Terminal::MultiClickSelection(const COORD viewportPos, SelectionExpansionMode expansionMode)
{
// set the selection pivot to expand the selection using SetSelectionEnd()
_selection = SelectionAnchors{};
_selection->pivot = _ConvertToBufferCell(viewportPos);
_multiClickSelectionMode = expansionMode;
SetSelectionEnd(viewportPos);
// we need to set the _selectionPivot again
// for future shift+clicks
_selection->pivot = _selection->start;
}
// Method Description:
// - Record the position of the beginning of a selection
// Arguments:
// - position: the (x,y) coordinate on the visible viewport
void Terminal::SetSelectionAnchor(const COORD viewportPos)
{
_selection = SelectionAnchors{};
_selection->pivot = _ConvertToBufferCell(viewportPos);
_multiClickSelectionMode = SelectionExpansionMode::Cell;
SetSelectionEnd(viewportPos);
_selection->start = _selection->pivot;
}
// Method Description:
// - Update selection anchors when dragging to a position
// - based on the selection expansion mode
// Arguments:
// - viewportPos: the (x,y) coordinate on the visible viewport
// - newExpansionMode: overwrites the _multiClickSelectionMode for this function call. Used for ShiftClick
void Terminal::SetSelectionEnd(const COORD viewportPos, std::optional<SelectionExpansionMode> newExpansionMode)
{
if (!_selection.has_value())
{
// capture a log for spurious endpoint sets without an active selection
LOG_HR(E_ILLEGAL_STATE_CHANGE);
return;
}
const auto textBufferPos = _ConvertToBufferCell(viewportPos);
// if this is a shiftClick action, we need to overwrite the _multiClickSelectionMode value (even if it's the same)
// Otherwise, we may accidentally expand during other selection-based actions
_multiClickSelectionMode = newExpansionMode.has_value() ? *newExpansionMode : _multiClickSelectionMode;
bool targetStart = false;
const auto anchors = _PivotSelection(textBufferPos, targetStart);
const auto expandedAnchors = _ExpandSelectionAnchors(anchors);
if (newExpansionMode.has_value())
{
// shift-click operations only expand the target side
auto& anchorToExpand = targetStart ? _selection->start : _selection->end;
anchorToExpand = targetStart ? expandedAnchors.first : expandedAnchors.second;
// the other anchor should then become the pivot (we don't expand it)
auto& anchorToPivot = targetStart ? _selection->end : _selection->start;
anchorToPivot = _selection->pivot;
}
else
{
// expand both anchors
std::tie(_selection->start, _selection->end) = expandedAnchors;
}
}
// Method Description:
// - returns a new pair of selection anchors for selecting around the pivot
// - This ensures start < end when compared
// Arguments:
// - targetPos: the (x,y) coordinate we are moving to on the text buffer
// - targetStart: if true, target will be the new start. Otherwise, target will be the new end.
// Return Value:
// - the new start/end for a selection
std::pair<COORD, COORD> Terminal::_PivotSelection(const COORD targetPos, bool& targetStart) const
{
if (targetStart = _buffer->GetSize().CompareInBounds(targetPos, _selection->pivot) <= 0)
{
// target is before pivot
// treat target as start
return std::make_pair(targetPos, _selection->pivot);
}
else
{
// target is after pivot
// treat pivot as start
return std::make_pair(_selection->pivot, targetPos);
}
}
// Method Description:
// - Update the selection anchors to expand according to the expansion mode
// Arguments:
// - anchors: a pair of selection anchors representing a desired selection
// Return Value:
// - the new start/end for a selection
std::pair<COORD, COORD> Terminal::_ExpandSelectionAnchors(std::pair<COORD, COORD> anchors) const
{
COORD start = anchors.first;
COORD end = anchors.second;
const auto bufferSize = _buffer->GetSize();
switch (_multiClickSelectionMode)
{
case SelectionExpansionMode::Line:
start = { bufferSize.Left(), start.Y };
end = { bufferSize.RightInclusive(), end.Y };
break;
case SelectionExpansionMode::Word:
start = _buffer->GetWordStart(start, _wordDelimiters);
end = _buffer->GetWordEnd(end, _wordDelimiters);
break;
case SelectionExpansionMode::Cell:
default:
// no expansion is necessary
break;
}
return std::make_pair(start, end);
}
// Method Description:
// - enable/disable block selection (ALT + selection)
// Arguments:
// - isEnabled: new value for _blockSelection
void Terminal::SetBlockSelection(const bool isEnabled) noexcept
{
_blockSelection = isEnabled;
}
// Method Description:
// - clear selection data and disable rendering it
#pragma warning(disable : 26440) // changing this to noexcept would require a change to ConHost's selection model
void Terminal::ClearSelection()
{
_selection = std::nullopt;
}
// Method Description:
// - get wstring text from highlighted portion of text buffer
// Arguments:
// - singleLine: collapse all of the text to one line
// Return Value:
// - wstring text from buffer. If extended to multiple lines, each line is separated by \r\n
const TextBuffer::TextAndColor Terminal::RetrieveSelectedTextFromBuffer(bool singleLine) const
{
const auto selectionRects = _GetSelectionRects();
const auto GetAttributeColors = std::bind(&Terminal::GetAttributeColors, this, std::placeholders::_1);
// GH#6740: Block selection should preserve the text block as is:
// - No trailing white-spaces should be removed.
// - CRLFs need to be added - so the lines structure is preserved
// - We should apply formatting above to wrapped rows as well (newline should be added).
return _buffer->GetText(!singleLine || _blockSelection,
!singleLine && !_blockSelection,
selectionRects,
GetAttributeColors,
_blockSelection);
}
// Method Description:
// - convert viewport position to the corresponding location on the buffer
// Arguments:
// - viewportPos: a coordinate on the viewport
// Return Value:
// - the corresponding location on the buffer
COORD Terminal::_ConvertToBufferCell(const COORD viewportPos) const
{
const auto yPos = base::ClampedNumeric<short>(_VisibleStartIndex()) + viewportPos.Y;
COORD bufferPos = { viewportPos.X, yPos };
_buffer->GetSize().Clamp(bufferPos);
return bufferPos;
}
// Method Description:
// - This method won't be used. We just throw and do nothing. For now we
// need this method to implement UiaData interface
// Arguments:
// - coordSelectionStart - Not used
// - coordSelectionEnd - Not used
// - attr - Not used.
void Terminal::ColorSelection(const COORD, const COORD, const TextAttribute)
{
THROW_HR(E_NOTIMPL);
}