Skip to content

Commit

Permalink
Add support for double-width/double-height lines in conhost (#8664)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
j4james authored Feb 18, 2021
1 parent 72cbe59 commit 4c53c59
Show file tree
Hide file tree
Showing 50 changed files with 660 additions and 107 deletions.
4 changes: 4 additions & 0 deletions .github/actions/spelling/expect/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ DECAUPSS
DECAWM
DECCKM
DECCOLM
DECDHL
DECDWL
DECEKBD
DECID
DECKPAM
Expand Down Expand Up @@ -557,6 +559,7 @@ DECSR
decstandar
DECSTBM
DECSTR
DECSWL
DECTCEM
Dedupe
deduplicated
Expand Down Expand Up @@ -2825,6 +2828,7 @@ Xes
XES
xff
XFile
XFORM
XManifest
XMath
XMFLOAT
Expand Down
36 changes: 36 additions & 0 deletions src/buffer/out/LineRendition.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*++
Copyright (c) Microsoft Corporation
Licensed under the MIT license.
Module Name:
- LineRendition.hpp
Abstract:
- Enumerated type for the VT line rendition attribute. This determines the
width and height scaling with which each line is rendered.
--*/

#pragma once

enum class LineRendition
{
SingleWidth,
DoubleWidth,
DoubleHeightTop,
DoubleHeightBottom
};

constexpr SMALL_RECT ScreenToBufferLine(const SMALL_RECT& line, const LineRendition lineRendition)
{
// Use shift right to quickly divide the Left and Right by 2 for double width lines.
const auto scale = lineRendition == LineRendition::SingleWidth ? 0 : 1;
return { line.Left >> scale, line.Top, line.Right >> scale, line.Bottom };
}

constexpr SMALL_RECT BufferToScreenLine(const SMALL_RECT& line, const LineRendition lineRendition)
{
// Use shift left to quickly multiply the Left and Right by 2 for double width lines.
const SHORT scale = lineRendition == LineRendition::SingleWidth ? 0 : 1;
return { line.Left << scale, line.Top, (line.Right << scale) + scale, line.Bottom };
}
2 changes: 2 additions & 0 deletions src/buffer/out/Row.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ROW::ROW(const SHORT rowId, const unsigned short rowWidth, const TextAttribute f
_rowWidth{ rowWidth },
_charRow{ rowWidth, this },
_attrRow{ rowWidth, fillAttribute },
_lineRendition{ LineRendition::SingleWidth },
_wrapForced{ false },
_doubleBytePadded{ false },
_pParent{ pParent }
Expand All @@ -35,6 +36,7 @@ ROW::ROW(const SHORT rowId, const unsigned short rowWidth, const TextAttribute f
// - <none>
bool ROW::Reset(const TextAttribute Attr)
{
_lineRendition = LineRendition::SingleWidth;
_wrapForced = false;
_doubleBytePadded = false;
_charRow.Reset();
Expand Down
5 changes: 5 additions & 0 deletions src/buffer/out/Row.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Revision History:
#pragma once

#include "AttrRow.hpp"
#include "LineRendition.hpp"
#include "OutputCell.hpp"
#include "OutputCellIterator.hpp"
#include "CharRow.hpp"
Expand Down Expand Up @@ -48,6 +49,9 @@ class ROW final
const ATTR_ROW& GetAttrRow() const noexcept { return _attrRow; }
ATTR_ROW& GetAttrRow() noexcept { return _attrRow; }

LineRendition GetLineRendition() const noexcept { return _lineRendition; }
void SetLineRendition(const LineRendition lineRendition) noexcept { _lineRendition = lineRendition; }

SHORT GetId() const noexcept { return _id; }
void SetId(const SHORT id) noexcept { _id = id; }

Expand All @@ -70,6 +74,7 @@ class ROW final
private:
CharRow _charRow;
ATTR_ROW _attrRow;
LineRendition _lineRendition;
SHORT _id;
unsigned short _rowWidth;
// Occurs when the user runs out of text in a given row and we're forced to wrap the cursor to the next line
Expand Down
1 change: 1 addition & 0 deletions src/buffer/out/lib/bufferout.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<ClInclude Include="..\cursor.h" />
<ClInclude Include="..\DbcsAttribute.hpp" />
<ClInclude Include="..\ICharRow.hpp" />
<ClInclude Include="..\LineRendition.hpp" />
<ClInclude Include="..\OutputCell.hpp" />
<ClInclude Include="..\OutputCellIterator.hpp" />
<ClInclude Include="..\OutputCellRect.hpp" />
Expand Down
12 changes: 10 additions & 2 deletions src/buffer/out/search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ bool Search::FindNext()
// - Takes the found word and selects it in the screen buffer
void Search::Select() const
{
_uiaData.SelectNewRegion(_coordSelStart, _coordSelEnd);
// Convert buffer selection offsets into the equivalent screen coordinates
// required by SelectNewRegion, taking line renditions into account.
const auto& textBuffer = _uiaData.GetTextBuffer();
const auto selStart = textBuffer.BufferToScreenPosition(_coordSelStart);
const auto selEnd = textBuffer.BufferToScreenPosition(_coordSelEnd);
_uiaData.SelectNewRegion(selStart, selEnd);
}

// Routine Description:
Expand Down Expand Up @@ -141,7 +146,10 @@ COORD Search::s_GetInitialAnchor(IUiaData& uiaData, const Direction direction)
const COORD textBufferEndPosition = uiaData.GetTextBufferEndPosition();
if (uiaData.IsSelectionActive())
{
auto anchor = uiaData.GetSelectionAnchor();
// Convert the screen position of the selection anchor into an equivalent
// buffer position to start searching, taking line rendition into account.
auto anchor = textBuffer.ScreenToBufferPosition(uiaData.GetSelectionAnchor());

if (direction == Direction::Forward)
{
textBuffer.GetSize().IncrementInBoundsCircular(anchor);
Expand Down
113 changes: 102 additions & 11 deletions src/buffer/out/textBuffer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,14 @@ bool TextBuffer::_PrepareForDoubleByteSequence(const DbcsAttribute dbcsAttribute
// We only need to compensate for leading bytes
if (dbcsAttribute.IsLeading())
{
short const sBufferWidth = GetSize().Width();
const auto cursorPosition = GetCursor().GetPosition();
const auto lineWidth = GetLineWidth(cursorPosition.Y);

// If we're about to lead on the last column in the row, we need to add a padding space
if (GetCursor().GetPosition().X == sBufferWidth - 1)
if (cursorPosition.X == lineWidth - 1)
{
// set that we're wrapping for double byte reasons
auto& row = GetRowByOffset(GetCursor().GetPosition().Y);
auto& row = GetRowByOffset(cursorPosition.Y);
row.SetDoubleBytePadded(true);

// then move the cursor forward and onto the next row
Expand Down Expand Up @@ -496,7 +497,7 @@ bool TextBuffer::IncrementCursor()
// Cursor position is stored as logical array indices (starts at 0) for the window
// Buffer Size is specified as the "length" of the array. It would say 80 for valid values of 0-79.
// So subtract 1 from buffer size in each direction to find the index of the final column in the buffer
const short iFinalColumnIndex = GetSize().RightInclusive();
const short iFinalColumnIndex = GetLineWidth(GetCursor().GetPosition().Y) - 1;

// Move the cursor one position to the right
GetCursor().IncrementXPosition(1);
Expand Down Expand Up @@ -635,7 +636,7 @@ COORD TextBuffer::GetLastNonSpaceCharacter(std::optional<const Microsoft::Consol
// Return Value:
// - Coordinate position in screen coordinates of the character just before the cursor.
// - NOTE: Will return 0,0 if already in the top left corner
COORD TextBuffer::_GetPreviousFromCursor() const noexcept
COORD TextBuffer::_GetPreviousFromCursor() const
{
COORD coordPosition = GetCursor().GetPosition();

Expand All @@ -649,11 +650,11 @@ COORD TextBuffer::_GetPreviousFromCursor() const noexcept
// Otherwise, only if we're not on the top row (e.g. we don't move anywhere in the top left corner. there is no previous)
if (coordPosition.Y > 0)
{
// move the cursor to the right edge
coordPosition.X = GetSize().RightInclusive();

// and up one line
// move the cursor up one line
coordPosition.Y--;

// and to the right edge
coordPosition.X = GetLineWidth(coordPosition.Y) - 1;
}
}

Expand Down Expand Up @@ -801,6 +802,78 @@ void TextBuffer::SetCurrentAttributes(const TextAttribute& currentAttributes) no
_currentAttributes = currentAttributes;
}

void TextBuffer::SetCurrentLineRendition(const LineRendition lineRendition)
{
const auto cursorPosition = GetCursor().GetPosition();
const auto rowIndex = cursorPosition.Y;
auto& row = GetRowByOffset(rowIndex);
if (row.GetLineRendition() != lineRendition)
{
row.SetLineRendition(lineRendition);
// If the line rendition has changed, the row can no longer be wrapped.
row.SetWrapForced(false);
// And if it's no longer single width, the right half of the row should be erased.
if (lineRendition != LineRendition::SingleWidth)
{
const auto fillChar = L' ';
auto fillAttrs = GetCurrentAttributes();
fillAttrs.SetStandardErase();
const size_t fillOffset = GetLineWidth(rowIndex);
const size_t fillLength = GetSize().Width() - fillOffset;
const auto fillData = OutputCellIterator{ fillChar, fillAttrs, fillLength };
row.WriteCells(fillData, fillOffset, false);
// We also need to make sure the cursor is clamped within the new width.
GetCursor().SetPosition(ClampPositionWithinLine(cursorPosition));
}
_NotifyPaint(Viewport::FromDimensions({ 0, rowIndex }, { GetSize().Width(), 1 }));
}
}

void TextBuffer::ResetLineRenditionRange(const size_t startRow, const size_t endRow)
{
for (auto row = startRow; row < endRow; row++)
{
GetRowByOffset(row).SetLineRendition(LineRendition::SingleWidth);
}
}

LineRendition TextBuffer::GetLineRendition(const size_t row) const
{
return GetRowByOffset(row).GetLineRendition();
}

bool TextBuffer::IsDoubleWidthLine(const size_t row) const
{
return GetLineRendition(row) != LineRendition::SingleWidth;
}

SHORT TextBuffer::GetLineWidth(const size_t row) const
{
// Use shift right to quickly divide the width by 2 for double width lines.
const auto scale = IsDoubleWidthLine(row) ? 1 : 0;
return GetSize().Width() >> scale;
}

COORD TextBuffer::ClampPositionWithinLine(const COORD position) const
{
const SHORT rightmostColumn = GetLineWidth(position.Y) - 1;
return { std::min(position.X, rightmostColumn), position.Y };
}

COORD TextBuffer::ScreenToBufferPosition(const COORD position) const
{
// Use shift right to quickly divide the X pos by 2 for double width lines.
const auto scale = IsDoubleWidthLine(position.Y) ? 1 : 0;
return { position.X >> scale, position.Y };
}

COORD TextBuffer::BufferToScreenPosition(const COORD position) const
{
// Use shift left to quickly multiply the X pos by 2 for double width lines.
const auto scale = IsDoubleWidthLine(position.Y) ? 1 : 0;
return { position.X << scale, position.Y };
}

// Routine Description:
// - Resets the text contents of this buffer with the default character
// and the default current color attributes
Expand Down Expand Up @@ -1425,9 +1498,11 @@ bool TextBuffer::MoveToPreviousGlyph(til::point& pos) const
// - blockSelection: when enabled, only get the rectangular text region,
// as opposed to the text extending to the left/right
// buffer margins
// - bufferCoordinates: when enabled, treat the coordinates as relative to
// the buffer rather than the screen.
// Return Value:
// - the delimiter class for the given char
const std::vector<SMALL_RECT> TextBuffer::GetTextRects(COORD start, COORD end, bool blockSelection) const
const std::vector<SMALL_RECT> TextBuffer::GetTextRects(COORD start, COORD end, bool blockSelection, bool bufferCoordinates) const
{
std::vector<SMALL_RECT> textRects;

Expand Down Expand Up @@ -1461,6 +1536,13 @@ const std::vector<SMALL_RECT> TextBuffer::GetTextRects(COORD start, COORD end, b
textRow.Right = (row == lowerCoord.Y) ? lowerCoord.X : bufferSize.RightInclusive();
}

// If we were passed screen coordinates, convert the given range into
// equivalent buffer offsets, taking line rendition into account.
if (!bufferCoordinates)
{
textRow = ScreenToBufferLine(textRow, GetLineRendition(row));
}

_ExpandTextRow(textRow);
textRects.emplace_back(textRow);
}
Expand Down Expand Up @@ -2044,7 +2126,6 @@ HRESULT TextBuffer::Reflow(TextBuffer& oldBuffer,
const COORD cOldLastChar = oldBuffer.GetLastNonSpaceCharacter(lastCharacterViewport);

const short cOldRowsTotal = cOldLastChar.Y + 1;
const short cOldColsTotal = oldBuffer.GetSize().Width();

COORD cNewCursorPos = { 0 };
bool fFoundCursorPos = false;
Expand All @@ -2056,9 +2137,19 @@ HRESULT TextBuffer::Reflow(TextBuffer& oldBuffer,
{
// Fetch the row and its "right" which is the last printable character.
const ROW& row = oldBuffer.GetRowByOffset(iOldRow);
const short cOldColsTotal = oldBuffer.GetLineWidth(iOldRow);
const CharRow& charRow = row.GetCharRow();
short iRight = gsl::narrow_cast<short>(charRow.MeasureRight());

// If we're starting a new row, try and preserve the line rendition
// from the row in the original buffer.
const auto newBufferPos = newBuffer.GetCursor().GetPosition();
if (newBufferPos.X == 0)
{
auto& newRow = newBuffer.GetRowByOffset(newBufferPos.Y);
newRow.SetLineRendition(row.GetLineRendition());
}

// There is a special case here. If the row has a "wrap"
// flag on it, but the right isn't equal to the width (one
// index past the final valid index in the row) then there
Expand Down
14 changes: 12 additions & 2 deletions src/buffer/out/textBuffer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ class TextBuffer final

void SetCurrentAttributes(const TextAttribute& currentAttributes) noexcept;

void SetCurrentLineRendition(const LineRendition lineRendition);
void ResetLineRenditionRange(const size_t startRow, const size_t endRow);
LineRendition GetLineRendition(const size_t row) const;
bool IsDoubleWidthLine(const size_t row) const;

SHORT GetLineWidth(const size_t row) const;
COORD ClampPositionWithinLine(const COORD position) const;
COORD ScreenToBufferPosition(const COORD position) const;
COORD BufferToScreenPosition(const COORD position) const;

void Reset();

[[nodiscard]] HRESULT ResizeTraditional(const COORD newSize) noexcept;
Expand All @@ -141,7 +151,7 @@ class TextBuffer final
bool MoveToNextGlyph(til::point& pos, bool allowBottomExclusive = false) const;
bool MoveToPreviousGlyph(til::point& pos) const;

const std::vector<SMALL_RECT> GetTextRects(COORD start, COORD end, bool blockSelection = false) const;
const std::vector<SMALL_RECT> GetTextRects(COORD start, COORD end, bool blockSelection, bool bufferCoordinates) const;

void AddHyperlinkToMap(std::wstring_view uri, uint16_t id);
std::wstring GetHyperlinkUriFromId(uint16_t id) const;
Expand Down Expand Up @@ -212,7 +222,7 @@ class TextBuffer final

void _SetFirstRowIndex(const SHORT FirstRowIndex) noexcept;

COORD _GetPreviousFromCursor() const noexcept;
COORD _GetPreviousFromCursor() const;

void _SetWrapOnCurrentRow();
void _AdjustWrapOnCurrentRow(const bool fSet);
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalCore/TerminalSelection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ std::vector<SMALL_RECT> Terminal::_GetSelectionRects() const noexcept

try
{
return _buffer->GetTextRects(_selection->start, _selection->end, _blockSelection);
return _buffer->GetTextRects(_selection->start, _selection->end, _blockSelection, false);
}
CATCH_LOG();
return result;
Expand Down
7 changes: 5 additions & 2 deletions src/host/CursorBlinker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ void CursorBlinker::FocusStart()
// - <none>
void CursorBlinker::TimerRoutine(SCREEN_INFORMATION& ScreenInfo)
{
Cursor& cursor = ScreenInfo.GetTextBuffer().GetCursor();
auto& buffer = ScreenInfo.GetTextBuffer();
auto& cursor = buffer.GetCursor();
const CONSOLE_INFORMATION& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
auto* const _pAccessibilityNotifier = ServiceLocator::LocateAccessibilityNotifier();

Expand All @@ -79,7 +80,9 @@ void CursorBlinker::TimerRoutine(SCREEN_INFORMATION& ScreenInfo)
// Update the cursor pos in USER so accessibility will work.
if (cursor.HasMoved())
{
const auto position = cursor.GetPosition();
// Convert the buffer position to the equivalent screen coordinates
// required by the notifier, taking line rendition into account.
const auto position = buffer.BufferToScreenPosition(cursor.GetPosition());
const auto viewport = ScreenInfo.GetViewport();
const auto fontSize = ScreenInfo.GetScreenFontSize();
cursor.SetHasMoved(false);
Expand Down
Loading

0 comments on commit 4c53c59

Please sign in to comment.