terminal/src/renderer/vt/XtermEngine.cpp
Michael Niksa 2c2f4f9be2
[FHL] Make VTApiRoutines, which does VT translation for output (#11264)
Make a VTApiRoutines servicer that does minimal translations instead of
environmental simulation for some output methods. Remaining methods are
backed on the existing console host infrastructure (primarily input
related methods).

## PR Checklist
* [x] I work here
* [x] It's Fix-Hack-Learn quality so it's behind a feature gate so we
  can keep refining it. But it's a start!

To turn this on, you will have to be in the Dev or Preview rings
(feature staged).  Then add `experimental.connection.passthroughMode:
true` to a profile and on the next launch, the flags will propagate down
through the `ConptyConnection` into the underlying `Openconsole.exe`
startup and tell it to use the passthrough mode instead of the full
simulation mode.

## Validation Steps Performed
- Played with it manually in CMD.exe, it seems to work mostly.
- Played with it manually in Ubuntu WSL, it seems to work.
- Played with it manually in Powershell and it's mostly sad. It'll get
  there.

Starts #1173
2022-03-30 23:22:42 +00:00

565 lines
23 KiB
C++

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "precomp.h"
#include "XtermEngine.hpp"
#include "../../types/inc/convert.hpp"
#pragma hdrstop
using namespace Microsoft::Console;
using namespace Microsoft::Console::Render;
using namespace Microsoft::Console::Types;
XtermEngine::XtermEngine(_In_ wil::unique_hfile hPipe,
const Viewport initialViewport,
const bool fUseAsciiOnly) :
VtEngine(std::move(hPipe), initialViewport),
_fUseAsciiOnly(fUseAsciiOnly),
_needToDisableCursor(false),
// GH#12401: Ensure a DECTCEM cursor show/hide sequence
// is emitted on the first frame no matter what.
_lastCursorIsVisible(Tribool::Invalid),
_nextCursorIsVisible(true)
{
// Set out initial cursor position to -1, -1. This will force our initial
// paint to manually move the cursor to 0, 0, not just ignore it.
_lastText = VtEngine::INVALID_COORDS;
}
// Method Description:
// - Prepares internal structures for a painting operation. Turns the cursor
// off, so we don't see it flashing all over the client's screen as we
// paint the new contents.
// Arguments:
// - <none>
// Return Value:
// - S_OK if we started to paint. S_FALSE if we didn't need to paint. HRESULT
// error code if painting didn't start successfully, or we failed to write
// the pipe.
[[nodiscard]] HRESULT XtermEngine::StartPaint() noexcept
{
RETURN_IF_FAILED(VtEngine::StartPaint());
_trace.TraceLastText(til::point{ _lastText });
// Prep us to think that the cursor is not visible this frame. If it _is_
// visible, then PaintCursor will be called, and we'll set this to true
// during the frame.
_nextCursorIsVisible = false;
// Do not perform synchronization clearing in passthrough mode.
// In passthrough, the terminal leads and we follow what it is
// handling from the client application.
// (This is in contrast to full PTY mode where WE, the ConPTY, lead and
// it follows our state.)
if (_passthrough)
{
_firstPaint = false;
}
if (_firstPaint)
{
// MSFT:17815688
// If the caller requested to inherit the cursor, we shouldn't
// clear the screen on the first paint. Otherwise, we'll clear
// the screen on the first paint, just to make sure that the
// terminal's state is consistent with what we'll be rendering.
RETURN_IF_FAILED(_ClearScreen());
_clearedAllThisFrame = true;
_firstPaint = false;
}
else
{
gsl::span<const til::rect> dirty;
RETURN_IF_FAILED(GetDirtyArea(dirty));
// If we have 0 or 1 dirty pieces in the area, set as appropriate.
Viewport dirtyView = dirty.empty() ? Viewport::Empty() : Viewport::FromInclusive(til::at(dirty, 0).to_small_rect());
// If there's more than 1, union them all up with the 1 we already have.
for (size_t i = 1; i < dirty.size(); ++i)
{
dirtyView = Viewport::Union(dirtyView, Viewport::FromInclusive(til::at(dirty, i).to_small_rect()));
}
}
if (!_quickReturn)
{
if (_WillWriteSingleChar())
{
// Don't re-enable the cursor.
_quickReturn = true;
}
}
return S_OK;
}
// Routine Description:
// - EndPaint helper to perform the final rendering steps. Turn the cursor back
// on.
// Arguments:
// - <none>
// Return Value:
// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write.
[[nodiscard]] HRESULT XtermEngine::EndPaint() noexcept
{
// If during the frame we determined that the cursor needed to be disabled,
// then insert a cursor off at the start of the buffer, and re-enable
// the cursor here.
if (_needToDisableCursor)
{
// If the cursor was previously visible, let's hide it for this frame,
// by prepending a cursor off.
if (_lastCursorIsVisible != Tribool::False)
{
_buffer.insert(0, "\x1b[?25l");
_lastCursorIsVisible = Tribool::False;
}
// If the cursor was NOT previously visible, then that's fine! we don't
// need to worry, it's already off.
}
if (_lastCursorIsVisible != static_cast<Tribool>(_nextCursorIsVisible))
{
RETURN_IF_FAILED(_nextCursorIsVisible ? _ShowCursor() : _HideCursor());
_lastCursorIsVisible = static_cast<Tribool>(_nextCursorIsVisible);
}
RETURN_IF_FAILED(VtEngine::EndPaint());
_needToDisableCursor = false;
return S_OK;
}
// Routine Description:
// - Write a VT sequence to change the current colors of text. Only writes
// 16-color attributes.
// Arguments:
// - textAttributes - Text attributes to use for the colors and character rendition
// - renderSettings - The color table and modes required for rendering
// - pData - The interface to console data structures required for rendering
// - usingSoftFont - Whether we're rendering characters from a soft font
// - isSettingDefaultBrushes: indicates if we should change the background color of
// the window. Unused for VT
// Return Value:
// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write.
[[nodiscard]] HRESULT XtermEngine::UpdateDrawingBrushes(const TextAttribute& textAttributes,
const RenderSettings& /*renderSettings*/,
const gsl::not_null<IRenderData*> /*pData*/,
const bool /*usingSoftFont*/,
const bool /*isSettingDefaultBrushes*/) noexcept
{
// The base xterm mode only knows about 16 colors
RETURN_IF_FAILED(VtEngine::_16ColorUpdateDrawingBrushes(textAttributes));
// And the only supported meta attributes are reverse video and underline
if (textAttributes.IsReverseVideo() != _lastTextAttributes.IsReverseVideo())
{
RETURN_IF_FAILED(_SetReverseVideo(textAttributes.IsReverseVideo()));
_lastTextAttributes.SetReverseVideo(textAttributes.IsReverseVideo());
}
if (textAttributes.IsUnderlined() != _lastTextAttributes.IsUnderlined())
{
RETURN_IF_FAILED(_SetUnderlined(textAttributes.IsUnderlined()));
_lastTextAttributes.SetUnderlined(textAttributes.IsUnderlined());
}
return S_OK;
}
// Routine Description:
// - Draws the cursor on the screen
// Arguments:
// - options - Options that affect the presentation of the cursor
// Return Value:
// - S_OK or suitable HRESULT error from writing pipe.
[[nodiscard]] HRESULT XtermEngine::PaintCursor(const CursorOptions& options) noexcept
{
// PaintCursor is only called when the cursor is in fact visible in a single
// frame. When this is called, mark _nextCursorIsVisible as true. At the end
// of the frame, we'll decide to either turn the cursor on or not, based
// upon the previous state.
// When this method is not called during a frame, it's because the cursor
// was not visible. In that case, at the end of the frame,
// _nextCursorIsVisible will still be false (from when we set it during
// StartPaint)
_nextCursorIsVisible = true;
// If we did a delayed EOL wrap because we actually wrapped the line here,
// then don't PaintCursor. When we're at the EOL because we've wrapped, our
// internal _lastText thinks the cursor is on the cell just past the right
// of the viewport (ex { 120, 0 }). However, conhost thinks the cursor is
// actually on the last cell of the row. So it'll tell us to paint the
// cursor at { 119, 0 }. If we do that movement, then we'll break line
// wrapping.
// See GH#5113, GH#1245, GH#357
const auto nextCursorPosition = options.coordCursor;
// Only skip this paint when we think the cursor is in the cell
// immediately off the edge of the terminal, and the actual cursor is in
// the last cell of the row. We're in a deferred wrap, but the host
// thinks the cursor is actually in-frame.
// See ConptyRoundtripTests::DontWrapMoveCursorInSingleFrame
const bool cursorIsInDeferredWrap = (nextCursorPosition.X == _lastText.X - 1) && (nextCursorPosition.Y == _lastText.Y);
// If all three of these conditions are true, then:
// * cursorIsInDeferredWrap: The cursor is in a position where the line
// filled the last cell of the row, but the host tried to paint it in
// the last cell anyways
// - GH#5691 - If we're painting the frame because we circled the
// buffer, then the cursor might still be in the position it was
// before the text was written to the buffer to cause the buffer to
// circle. In that case, then we DON'T want to paint the cursor here
// either, because it'll cause us to manually break this line. That's
// okay though, the frame will be painted again, after the circling
// is complete.
// * _delayedEolWrap && _wrappedRow.has_value(): We think we've deferred
// the wrap of a line.
// If they're all true, DON'T manually paint the cursor this frame.
if (!((cursorIsInDeferredWrap || _circled) && _delayedEolWrap && _wrappedRow.has_value()))
{
return VtEngine::PaintCursor(options);
}
return S_OK;
}
// Routine Description:
// - Write a VT sequence to move the cursor to the specified coordinates. We
// also store the last place we left the cursor for future optimizations.
// If the cursor only needs to go to the origin, only write the home sequence.
// If the new cursor is only down one line from the current, only write a newline
// If the new cursor is only down one line and at the start of the line, write
// a carriage return.
// Otherwise just write the whole sequence for moving it.
// Arguments:
// - coord: location to move the cursor to.
// Return Value:
// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write.
[[nodiscard]] HRESULT XtermEngine::_MoveCursor(COORD const coord) noexcept
{
HRESULT hr = S_OK;
const auto originalPos = _lastText;
_trace.TraceMoveCursor(til::point{ _lastText }, til::point{ coord });
bool performedSoftWrap = false;
if (coord.X != _lastText.X || coord.Y != _lastText.Y)
{
if (coord.X == 0 && coord.Y == 0)
{
_needToDisableCursor = true;
hr = _CursorHome();
}
else if (_resized && _resizeQuirk)
{
hr = _CursorPosition(coord);
}
else if (coord.X == 0 && coord.Y == (_lastText.Y + 1))
{
// Down one line, at the start of the line.
// If the previous line wrapped, then the cursor is already at this
// position, we just don't know it yet. Don't emit anything.
bool previousLineWrapped = false;
if (_wrappedRow.has_value())
{
previousLineWrapped = coord.Y == _wrappedRow.value() + 1;
}
if (previousLineWrapped)
{
performedSoftWrap = true;
_trace.TraceWrapped();
hr = S_OK;
}
else
{
std::string seq = "\r\n";
hr = _Write(seq);
}
}
else if (_delayedEolWrap)
{
// GH#1245, GH#357 - If we were in the delayed EOL wrap state, make
// sure to _manually_ position the cursor now, with a full CUP
// sequence, don't try and be clever with \b or \r or other control
// sequences. Different terminals (conhost, gnome-terminal, wt) all
// behave differently with how the cursor behaves at an end of line.
// This is the only solution that works in all of them, and also
// works wrapped lines emitted by conpty.
//
// Make sure to do this _after_ the possible \r\n branch above,
// otherwise we might accidentally break wrapped lines (GH#405)
hr = _CursorPosition(coord);
}
else if (coord.X == 0 && coord.Y == _lastText.Y)
{
// Start of this line
std::string seq = "\r";
hr = _Write(seq);
}
else if (coord.X == _lastText.X && coord.Y == (_lastText.Y + 1))
{
// Down one line, same X position
std::string seq = "\n";
hr = _Write(seq);
}
else if (coord.X == (_lastText.X - 1) && coord.Y == (_lastText.Y))
{
// Back one char, same Y position
std::string seq = "\b";
hr = _Write(seq);
}
else if (coord.Y == _lastText.Y && coord.X > _lastText.X)
{
// Same line, forward some distance
short distance = coord.X - _lastText.X;
hr = _CursorForward(distance);
}
else
{
_needToDisableCursor = true;
hr = _CursorPosition(coord);
}
if (SUCCEEDED(hr))
{
_lastText = coord;
}
}
_deferredCursorPos = INVALID_COORDS;
_wrappedRow = std::nullopt;
_delayedEolWrap = false;
return hr;
}
// Routine Description:
// - Scrolls the existing data on the in-memory frame by the scroll region
// deltas we have collectively received through the Invalidate methods
// since the last time this was called.
// Move the cursor to the origin, and insert or delete rows as appropriate.
// The inserted rows will be blank, but marked invalid by InvalidateScroll,
// so they will later be written by PaintBufferLine.
// Arguments:
// - <none>
// Return Value:
// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write.
[[nodiscard]] HRESULT XtermEngine::ScrollFrame() noexcept
try
{
_trace.TraceScrollFrame(_scrollDelta);
if (_scrollDelta.x != 0)
{
// No easy way to shift left-right. Everything needs repainting.
return InvalidateAll();
}
if (_scrollDelta.y == 0)
{
// There's nothing to do here. Do nothing.
return S_OK;
}
const short dy = _scrollDelta.narrow_y<short>();
const short absDy = static_cast<short>(abs(dy));
// Save the old wrap state here. We're going to clear it so that
// _MoveCursor will definitely move us to the right position. We'll
// restore the state afterwards.
const auto oldWrappedRow = _wrappedRow;
const auto oldDelayedEolWrap = _delayedEolWrap;
_delayedEolWrap = false;
_wrappedRow = std::nullopt;
if (dy < 0)
{
// TODO GH#5228 - We could optimize this by only doing this newline work
// when there's more invalid than just the bottom line. If only the
// bottom line is invalid, then the next thing the Renderer is going to
// tell us to do is print the new line at the bottom of the viewport,
// and _MoveCursor will automatically give us the newline we want.
// When that's implemented, we'll probably want to make sure to add a
// _lastText.Y += dy;
// statement here.
// Move the cursor to the bottom of the current viewport
const short bottom = _lastViewport.BottomInclusive();
RETURN_IF_FAILED(_MoveCursor({ 0, bottom }));
// Emit some number of newlines to create space in the buffer.
RETURN_IF_FAILED(_Write(std::string(absDy, '\n')));
}
else if (dy > 0)
{
// If we've scrolled _down_, then move the cursor to the top of the
// buffer, and insert some newlines using the InsertLines VT sequence
RETURN_IF_FAILED(_MoveCursor({ 0, 0 }));
RETURN_IF_FAILED(_InsertLine(absDy));
}
// Restore our wrap state.
_wrappedRow = oldWrappedRow;
_delayedEolWrap = oldDelayedEolWrap;
// Shift our internal tracker of the last text position according to how
// much we've scrolled. If we manually scroll the buffer right now, by
// moving the cursor to the bottom row of the viewport and emitting a
// newline, we'll cause any wrapped lines to get broken.
//
// Instead, we'll just update our internal tracker of where the buffer
// contents are. On this frame, we'll then still move the cursor correctly
// relative to the new frame contents. To do this, we'll shift our
// coordinates we're tracking, like the row that we wrapped on and the
// position we think we left the cursor.
//
// See GH#5113
_trace.TraceLastText(til::point{ _lastText });
if (_wrappedRow.has_value())
{
_wrappedRow.value() += dy;
_trace.TraceSetWrapped(_wrappedRow.value());
}
if (_delayedEolWrap && _wrappedRow.has_value())
{
// If we wrapped the last line, and we're in the middle of painting it,
// then the newline we did above just manually broke the line. What
// we're doing here is a hack: we're going to manually re-invalidate the
// last character of the wrapped row. When the PaintBufferLine calls
// come back through, we'll paint this last character again, causing us
// to get into the wrapped state once again. This is the only way to
// ensure that if a line was wrapped, and we painted the first line in
// one frame, and the second line in another frame that included other
// changes _above_ the wrapped line, that we maintain the wrap state in
// the Terminal.
const til::rect lastCellOfWrappedRow{
til::point{ _lastViewport.RightInclusive(), _wrappedRow.value() },
til::size{ 1, 1 }
};
_trace.TraceInvalidate(lastCellOfWrappedRow);
_invalidMap.set(lastCellOfWrappedRow);
}
// If the entire viewport was invalidated this frame, don't mark the bottom
// line as new. There are cases where this can cause visual artifacts - see
// GH#5039 and ConptyRoundtripTests::ClearHostTrickeryTest
const bool allInvalidated = _invalidMap.all();
_newBottomLine = !allInvalidated;
// GH#5502 - keep track of the BG color we had when we emitted this new
// bottom line. If the color changes by the time we get to printing that
// line, we'll need to make sure that we don't do any optimizations like
// _removing spaces_, because the background color of the spaces will be
// important information to send to the connected Terminal.
if (_newBottomLine)
{
_newBottomLineBG = _lastTextAttributes.GetBackground();
}
return S_OK;
}
CATCH_RETURN();
// Routine Description:
// - Notifies us that the console is attempting to scroll the existing screen
// area. Add the top or bottom rows to the invalid region, and update the
// total scroll delta accumulated this frame.
// Arguments:
// - pcoordDelta - Pointer to character dimension (COORD) of the distance the
// console would like us to move while scrolling.
// Return Value:
// - S_OK if we succeeded, else an appropriate HRESULT for safemath failure
[[nodiscard]] HRESULT XtermEngine::InvalidateScroll(const COORD* const pcoordDelta) noexcept
try
{
const til::point delta{ *pcoordDelta };
if (delta != til::point{ 0, 0 })
{
_trace.TraceInvalidateScroll(delta);
// Scroll the current offset and invalidate the revealed area
_invalidMap.translate(delta, true);
_scrollDelta += delta;
}
return S_OK;
}
CATCH_RETURN();
// Routine Description:
// - Draws one line of the buffer to the screen. Writes the characters to the
// pipe, encoded in UTF-8 or ASCII only, depending on the VtIoMode.
// (See descriptions of both implementations for details.)
// Arguments:
// - clusters - text and column counts for each piece of text.
// - coord - character coordinate target to render within viewport
// - trimLeft - This specifies whether to trim one character width off the left
// side of the output. Used for drawing the right-half only of a
// double-wide character.
// - lineWrapped: true if this run we're painting is the end of a line that
// wrapped. If we're not painting the last column of a wrapped line, then this
// will be false.
// Return Value:
// - S_OK or suitable HRESULT error from writing pipe.
[[nodiscard]] HRESULT XtermEngine::PaintBufferLine(gsl::span<const Cluster> const clusters,
const COORD coord,
const bool /*trimLeft*/,
const bool lineWrapped) noexcept
{
return _fUseAsciiOnly ?
VtEngine::_PaintAsciiBufferLine(clusters, coord) :
VtEngine::_PaintUtf8BufferLine(clusters, coord, lineWrapped);
}
// Method Description:
// - Wrapper for _Write. Write either an ascii-only, or a
// proper utf-8 string, depending on our mode.
// Arguments:
// - wstr - wstring of text to be written
// Return Value:
// - S_OK or suitable HRESULT error from either conversion or writing pipe.
[[nodiscard]] HRESULT XtermEngine::WriteTerminalW(const std::wstring_view wstr) noexcept
{
RETURN_IF_FAILED(_fUseAsciiOnly ?
VtEngine::_WriteTerminalAscii(wstr) :
VtEngine::_WriteTerminalUtf8(wstr));
// GH#4106, GH#2011 - WriteTerminalW is only ever called by the
// StateMachine, when we've encountered a string we don't understand. When
// this happens, we usually don't actually trigger another frame, but we
// _do_ want this string to immediately be sent to the terminal. Since we
// only flush our buffer on actual frames, this means that strings we've
// decided to pass through would have gotten buffered here until the next
// actual frame is triggered.
//
// To fix this, flush here, so this string is sent to the connected terminal
// application.
return _Flush();
}
// Method Description:
// - Updates the window's title string. Emits the VT sequence to SetWindowTitle.
// Arguments:
// - newTitle: the new string to use for the title of the window
// Return Value:
// - S_OK
[[nodiscard]] HRESULT XtermEngine::_DoUpdateTitle(const std::wstring_view newTitle) noexcept
{
// inbox telnet uses xterm-ascii as its mode. If we're in ascii mode, don't
// do anything, to maintain compatibility.
if (_fUseAsciiOnly)
{
return S_OK;
}
try
{
const auto converted = ConvertToA(CP_UTF8, newTitle);
return VtEngine::_ChangeTitle(converted);
}
CATCH_RETURN();
}