Files
terminal/src/terminal/adapter/SixelParser.cpp
Leonard Hecker c0debf8e26 Improve sixel performance (#19639)
Together with #19640 this bumps the performance on my HW by around 60%.
You can now stream 4K images at >60 FPS on a high-end CPU.

## Validation Steps Performed
* Used imagemagick to dump large images ✅
2026-01-12 14:05:33 +01:00

965 lines
39 KiB
C++

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "precomp.h"
#include "SixelParser.hpp"
#include "adaptDispatch.hpp"
#include "../buffer/out/ImageSlice.hpp"
#include "../parser/ascii.hpp"
#include "../renderer/base/renderer.hpp"
#include "../types/inc/colorTable.hpp"
#include "../types/inc/utils.hpp"
using namespace Microsoft::Console::Types;
using namespace Microsoft::Console::Utils;
using namespace Microsoft::Console::VirtualTerminal;
using namespace std::chrono;
using namespace std::chrono_literals;
til::size SixelParser::CellSizeForLevel(const VTInt conformanceLevel) noexcept
{
switch (conformanceLevel)
{
case 1: // Compatible with the VT125
return { 9, 20 };
default: // Compatible with the VT240 and VT340
return { 10, 20 };
}
}
size_t SixelParser::MaxColorsForLevel(const VTInt conformanceLevel) noexcept
{
switch (conformanceLevel)
{
case 1:
case 2: // Compatible with the 4-color VT125 and VT240
return 4;
case 3: // Compatible with the 16-color VT340
return 16;
default: // Modern sixel apps often require 256 colors.
return MAX_COLORS;
}
}
SixelParser::SixelParser(AdaptDispatch& dispatcher, const StateMachine& stateMachine, const VTInt conformanceLevel) noexcept :
_dispatcher{ dispatcher },
_stateMachine{ stateMachine },
_conformanceLevel{ conformanceLevel },
_cellSize{ CellSizeForLevel(conformanceLevel) },
_maxColors{ MaxColorsForLevel(conformanceLevel) }
{
// We initialize the first 16 color entries with the VT340 palette, which is
// also compatible with the 4-color VT125 and VT240. The remaining entries
// are initialized with the XTerm extended colors.
Microsoft::Console::Utils::InitializeVT340ColorTable(_colorTable);
Microsoft::Console::Utils::InitializeExtendedColorTable(_colorTable);
}
void SixelParser::SoftReset()
{
// The VT240 is the only terminal known to reset colors with DECSTR.
// We only reset the first 16, since it only needs 4 of them anyway.
if (_conformanceLevel == 2)
{
Microsoft::Console::Utils::InitializeVT340ColorTable(_colorTable);
_updateTextColors();
}
}
void SixelParser::SetDisplayMode(const bool enabled) noexcept
{
// The display mode determines whether images are clamped at the bottom of
// the screen (the set state), or scroll when they reach the bottom of the
// margin area (the reset state). Clamping was the only mode of operation
// supported prior to the VT340, so we don't allow the mode to be reset on
// levels 1 and 2.
if (_conformanceLevel >= 3)
{
_displayMode = enabled;
}
}
std::function<bool(wchar_t)> SixelParser::DefineImage(const VTInt macroParameter, const DispatchTypes::SixelBackground backgroundSelect, const VTParameter backgroundColor)
{
if (_initTextBufferBoundaries())
{
_initRasterAttributes(macroParameter, backgroundSelect);
_initColorMap(backgroundColor);
_initImageBuffer();
_state = States::Normal;
_parameters.clear();
return [&](const auto ch) {
_parseCommandChar(ch);
return true;
};
}
else
{
return nullptr;
}
}
void SixelParser::_parseCommandChar(const wchar_t ch)
{
// Characters in the range `?` to `~` encode a sixel value, which is a group
// of six vertical pixels. After subtracting `?` from the character, you've
// got a six bit binary value which represents the six pixels.
if (ch >= '?' && ch <= '~') [[likely]]
{
// When preceded by a repeat command, the repeat parameter value denotes
// the number of times that the following sixel should be repeated.
const auto repeatCount = _applyPendingCommand();
_writeToImageBuffer(ch - L'?', repeatCount);
}
// Characters `0` to `9` and `;` are used to represent parameter values for
// commands that require them.
else if ((ch >= '0' && ch <= '9') || ch == ';')
{
_parseParameterChar(ch);
}
// The remaining characters represent commands, some of which will execute
// immediately, but some requiring additional parameter values. In the
// latter case, the command will only be applied once the next command
// character is received.
else
{
switch (ch)
{
case '#': // DECGCI - Color Introducer
_applyPendingCommand();
_state = States::Color;
_parameters.clear();
break;
case '!': // DECGRI - Repeat Introducer
_applyPendingCommand();
_state = States::Repeat;
_parameters.clear();
break;
case '$': // DECGCR - Graphics Carriage Return
_applyPendingCommand();
_executeCarriageReturn();
break;
case '-': // DECGNL - Graphics Next Line
_applyPendingCommand();
_executeNextLine();
break;
case '+': // Undocumented home command (VT240 only)
if (_conformanceLevel == 2)
{
_applyPendingCommand();
_executeMoveToHome();
}
break;
case '"': // DECGRA - Set Raster Attributes
if (_conformanceLevel >= 3)
{
_applyPendingCommand();
_state = States::Attributes;
_parameters.clear();
}
break;
case AsciiChars::ESC: // End of image sequence
// At this point we only care about pending color changes. Raster
// attributes have no effect at the end of a sequence, and a repeat
// command is only applicable when followed by a sixel value.
if (_state == States::Color)
{
_applyPendingCommand();
}
_fillImageBackground();
_executeCarriageReturn();
_maybeFlushImageBuffer(true);
break;
default:
break;
}
}
}
void SixelParser::_parseParameterChar(const wchar_t ch)
{
// The most any command requires is 5 parameters (for the color command),
// so anything after that can be ignored.
if (_parameters.size() <= 5)
{
if (_parameters.empty())
{
_parameters.push_back({});
}
if (ch == ';')
{
_parameters.push_back({});
}
else
{
const VTInt digit = ch - L'0';
auto currentValue = _parameters.back().value_or(0);
currentValue = currentValue * 10 + digit;
_parameters.back() = std::min(currentValue, MAX_PARAMETER_VALUE);
}
}
}
int SixelParser::_applyPendingCommand()
{
if (_state != States::Normal) [[unlikely]]
{
const auto previousState = _state;
_state = States::Normal;
switch (previousState)
{
case States::Color:
_defineColor({ _parameters.data(), _parameters.size() });
return 1;
case States::Repeat:
return VTParameters{ _parameters.data(), _parameters.size() }.at(0);
case States::Attributes:
_updateRasterAttributes({ _parameters.data(), _parameters.size() });
return 1;
}
}
return 1;
}
void SixelParser::_executeCarriageReturn() noexcept
{
_imageWidth = std::max(_imageWidth, _imageCursor.x);
_imageCursor.x = 0;
}
void SixelParser::_executeNextLine()
{
_executeCarriageReturn();
_imageLineCount++;
_maybeFlushImageBuffer();
_imageCursor.y += _sixelHeight;
_availablePixelHeight -= _sixelHeight;
_resizeImageBuffer(_sixelHeight);
_fillImageBackgroundWhenScrolled();
}
void SixelParser::_executeMoveToHome()
{
_executeCarriageReturn();
_maybeFlushImageBuffer();
_imageCursor.y = 0;
_availablePixelHeight = _textMargins.height() * _cellSize.height;
}
bool SixelParser::_initTextBufferBoundaries()
{
const auto page = _dispatcher._pages.ActivePage();
auto validOrigin = true;
if (_displayMode)
{
// When display mode is set, we can write to the full extent of the page
// and the starting cursor position is the top left of the page.
_textMargins = { 0, page.Top(), page.Width(), page.Bottom() };
_textCursor = _textMargins.origin();
_availablePixelWidth = page.Width() * _cellSize.width;
_availablePixelHeight = page.Height() * _cellSize.height;
}
else
{
// When display mode is reset, we're constrained by the text margins,
// and the starting position is the current cursor position. This must
// be inside the horizontal margins and above the bottom margin, else
// nothing will be rendered.
const auto [topMargin, bottomMargin] = _dispatcher._GetVerticalMargins(page, true);
const auto [leftMargin, rightMargin] = _dispatcher._GetHorizontalMargins(page.Width());
_textMargins = til::rect{ leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 };
_textCursor = page.Cursor().GetPosition();
_availablePixelWidth = (_textMargins.right - _textCursor.x) * _cellSize.width;
_availablePixelHeight = (_textMargins.bottom - _textCursor.y) * _cellSize.height;
validOrigin = _textCursor.x >= leftMargin && _textCursor.x <= rightMargin && _textCursor.y <= bottomMargin;
}
_pendingTextScrollCount = 0;
// The pixel aspect ratio can't be so large that it would prevent a sixel
// row from fitting within the margin height, so we need to have a limit.
_maxPixelAspectRatio = _textMargins.height() * _cellSize.height / 6;
// If the cursor is visible, we need to hide it while the sixel data is
// being processed. It will be made visible again when we're done.
_textCursorWasVisible = page.Cursor().IsVisible();
if (_textCursorWasVisible && validOrigin)
{
page.Cursor().SetIsVisible(false);
}
return validOrigin;
}
void SixelParser::_initRasterAttributes(const VTInt macroParameter, const DispatchTypes::SixelBackground backgroundSelect) noexcept
{
if (_conformanceLevel < 3)
{
// Prior to the VT340, the pixel aspect ratio was fixed at 2:1.
_pixelAspectRatio = 2;
}
else
{
// The macro parameter was originally used on printers to define the
// pixel aspect ratio and the grid size (the distance between pixels).
// On graphic terminals, though, it's only used for the aspect ratio,
// and then only a limited set of ratios are supported.
switch (macroParameter)
{
case 0:
case 1:
case 5:
case 6:
_pixelAspectRatio = 2;
break;
case 2:
_pixelAspectRatio = 5;
break;
case 3:
case 4:
_pixelAspectRatio = 3;
break;
case 7:
case 8:
case 9:
default:
// While the default aspect ratio is defined as 2:1, macro parameter
// values outside the defined range of 0 to 9 should map to 1:1.
_pixelAspectRatio = 1;
break;
}
}
// The height of a sixel row is 6 virtual pixels, but if the aspect ratio is
// greater than one, the height in device pixels is a multiple of that.
_sixelHeight = 6 * _pixelAspectRatio;
_segmentHeight = _sixelHeight;
// On the VT125, the background was always drawn, but for other terminals it
// depends on the value of the background select parameter.
const auto transparent = (backgroundSelect == DispatchTypes::SixelBackground::Transparent);
_backgroundFillRequired = (_conformanceLevel == 1 || !transparent);
// By default, the filled area will cover the maximum extent allowed.
_backgroundSize = { til::CoordTypeMax, til::CoordTypeMax };
// If the image ends up extending beyond the bottom of the page, we may need
// to perform additional background filling as the screen is scrolled, which
// requires us to track the area filled so far. This will be initialized, if
// necessary, in the _fillImageBackground method below.
_filledBackgroundHeight = std::nullopt;
}
void SixelParser::_updateRasterAttributes(const VTParameters& rasterAttributes)
{
// The documentation says default values should be interpreted as 1, but
// the original VT340 hardware interprets omitted parameters as 0, and if
// the x aspect is 0 (implying division by zero), the update is ignored.
const auto yAspect = rasterAttributes.at(0).value_or(0);
const auto xAspect = rasterAttributes.at(1).value_or(0);
if (xAspect > 0)
{
// The documentation suggests the aspect ratio is rounded to the nearest
// integer, but on the original VT340 hardware it was rounded up.
_pixelAspectRatio = std::clamp(static_cast<int>(std::ceil(yAspect * 1.0 / xAspect)), 1, _maxPixelAspectRatio);
_sixelHeight = 6 * _pixelAspectRatio;
// When the sixel height is changed multiple times in a row, the segment
// height has to track the maximum of all the sixel heights used.
_segmentHeight = std::max(_segmentHeight, _sixelHeight);
_resizeImageBuffer(_sixelHeight);
}
// Although it's not clear from the documentation, we know from testing on
// a VT340 that the background dimensions are measured in device pixels, so
// the given height does not need to be scaled by the pixel aspect ratio.
const auto width = rasterAttributes.at(2).value_or(0);
const auto height = rasterAttributes.at(3).value_or(0);
// If these values are omitted or 0, they default to what they were before,
// which typically would mean filling the whole screen, but could also fall
// back to the dimensions from an earlier raster attributes command.
_backgroundSize.width = width > 0 ? width : _backgroundSize.width;
_backgroundSize.height = height > 0 ? height : _backgroundSize.height;
// If the aspect ratio has changed, the image height may increase, and that
// could potentially trigger a scroll requiring the background to be filled.
_fillImageBackgroundWhenScrolled();
// And while not documented, we know from testing on a VT330 that the raster
// attributes command should also trigger a carriage return. This applies
// regardless of whether the requested aspect ratio is valid or not.
_executeCarriageReturn();
}
void SixelParser::_scrollTextBuffer(Page& page, const int scrollAmount)
{
// We scroll the text buffer by moving the cursor to the bottom of the
// margin area and executing an appropriate number of line feeds.
if (_textCursor.y != _textMargins.bottom - 1)
{
_textCursor = { _textCursor.x, _textMargins.bottom - 1 };
page.Cursor().SetPosition(_textCursor);
}
auto panAmount = 0;
for (auto i = 0; i < scrollAmount; i++)
{
if (_dispatcher._DoLineFeed(page, false, false))
{
page.MoveViewportDown();
panAmount++;
}
}
// If the line feeds panned the viewport down, we need to adjust our margins
// and text cursor coordinates to align with that movement.
_textCursor.y += panAmount;
_textMargins += til::point{ 0, panAmount };
// And if it wasn't all panning, we need to move the image origin up to
// match the number of rows that were actually scrolled.
if (scrollAmount > panAmount)
{
auto expectedMovement = scrollAmount - panAmount;
// If constrained by margins, we can only move as far as the top margin.
if (_textMargins.top > page.Top() || _textMargins.left > 0 || _textMargins.right < page.Width() - 1)
{
const auto availableSpace = std::max(_imageOriginCell.y - _textMargins.top, 0);
if (expectedMovement > availableSpace)
{
// Anything more than that will need to be erased from the
// image. And if the origin was already above the top margin,
// this erased segment will be partway through the image.
const auto eraseRowCount = expectedMovement - availableSpace;
const auto eraseOffset = std::max(_textMargins.top - _imageOriginCell.y, 0);
_eraseImageBufferRows(eraseRowCount, eraseOffset);
// But if there was any available space, we still then need to
// move the origin up as far as it can go.
expectedMovement = availableSpace;
}
}
_imageOriginCell.y -= expectedMovement;
}
}
void SixelParser::_updateTextCursor(Cursor& cursor) noexcept
{
// Unless the sixel display mode is set, we need to update the text cursor
// position to align with the final image cursor position. This should be
// the cell which is intersected by the top of the final sixel row.
if (!_displayMode)
{
const auto finalRow = _imageOriginCell.y + _imageCursor.y / _cellSize.height;
if (finalRow != _textCursor.y)
{
cursor.SetPosition({ _textCursor.x, finalRow });
}
}
// And if the cursor was visible when we started, we need to restore it.
if (_textCursorWasVisible)
{
cursor.SetIsVisible(true);
}
}
void SixelParser::_initColorMap(const VTParameter backgroundColor)
{
_colorsUsed = 0;
_colorsAvailable = _maxColors;
_colorTableChanged = false;
// The color numbers in a sixel image don't necessarily map directly to
// entries in the color table. That mapping is determined by the order in
// which the colors are defined. If they aren't defined, though, the default
// mapping is just the color number modulo the color table size.
for (size_t colorNumber = 0; colorNumber < _colorMap.size(); colorNumber++)
{
_colorMap.at(colorNumber) = gsl::narrow_cast<IndexType>(colorNumber % _maxColors);
}
// The _colorMapUsed field keeps track of the color numbers that have been
// explicitly mapped to a color table entry, since that locks in the mapping
// for the duration of the image. Additional definitions for that color
// number will update the existing mapped table entry - they won't generate
// new mappings for the number.
std::fill(_colorMapUsed.begin(), _colorMapUsed.end(), false);
// The VT240 has an extra feature, whereby the P3 parameter defines the
// color number to be used for the background (i.e. it's preassigned to
// table entry 0). If you specify a value larger than the maximum color
// table index, the number of available colors is reduced by 1, which
// effectively protects the background color from modification.
if (_conformanceLevel == 2 && backgroundColor.has_value()) [[unlikely]]
{
const size_t colorNumber = backgroundColor.value();
if (colorNumber < _maxColors)
{
til::at(_colorMap, colorNumber) = 0;
til::at(_colorMapUsed, colorNumber) = true;
}
else
{
_colorsAvailable = _maxColors - 1;
}
}
// On the original hardware terminals, the default color index would have
// been the last entry in the color table. But on modern terminals, it is
// typically capped at 15 for compatibility with the 16-color VT340. This
// is the color used if no color commands are received.
const auto defaultColorIndex = std::min<size_t>(_maxColors - 1, 15);
_foregroundPixel = { .colorIndex = gsl::narrow_cast<IndexType>(defaultColorIndex) };
}
void SixelParser::_defineColor(const VTParameters& colorParameters)
{
// The first parameter selects the color number to use. If it's greater than
// the color map size, we just mod the value into range.
const auto colorNumber = colorParameters.at(0).value_or(0) % _colorMap.size();
// If there are additional parameters, then this command will also redefine
// the color palette associated with the selected color number. This is not
// supported on the VT125 though.
if (colorParameters.size() > 1 && _conformanceLevel > 1) [[unlikely]]
{
const auto model = DispatchTypes::ColorModel{ colorParameters.at(1) };
const auto x = colorParameters.at(2).value_or(0);
const auto y = colorParameters.at(3).value_or(0);
const auto z = colorParameters.at(4).value_or(0);
switch (model)
{
case DispatchTypes::ColorModel::HLS:
_defineColor(colorNumber, Utils::ColorFromHLS(x, y, z));
break;
case DispatchTypes::ColorModel::RGB:
_defineColor(colorNumber, Utils::ColorFromRGB100(x, y, z));
break;
}
}
// The actual color table index we use is derived from the color number via
// the color map. This is initially defined in _initColorMap above, but may
// be altered when colors are set in the _defineColor method below.
const auto colorIndex = _colorMap.at(colorNumber);
_foregroundPixel = { .colorIndex = colorIndex };
}
void SixelParser::_defineColor(const size_t colorNumber, const COLORREF color)
{
if (til::at(_colorMapUsed, colorNumber))
{
// If the color is already assigned, we update the mapped table entry.
const auto tableIndex = til::at(_colorMap, colorNumber);
til::at(_colorTable, tableIndex) = color;
_colorTableChanged = true;
// If some image content has already been defined at this point, and
// we're processing the last character in the packet, this is likely an
// attempt to animate the palette, so we should flush the image.
if (_imageWidth > 0 && _stateMachine.IsProcessingLastCharacter())
{
_maybeFlushImageBuffer();
}
}
else
{
// Otherwise assign it to the next available color table entry.
if (_colorsUsed < _colorsAvailable)
{
// Since table entry 0 is the background color, which you typically
// want to leave unchanged, the original hardware terminals would
// skip that and start with table entry 1, and only wrap back to 0
// when all others had been used.
const auto tableIndex = ++_colorsUsed % _maxColors;
til::at(_colorMap, colorNumber) = gsl::narrow_cast<IndexType>(tableIndex);
til::at(_colorTable, tableIndex) = color;
_colorTableChanged = true;
}
else if (_conformanceLevel == 2)
{
// If we've used up all the available color table entries, we have
// to assign this color number to one of the previously used ones.
// The VT240 uses the closest match from the existing color entries,
// but the VT340 just uses the default mapping assigned at the start
// (i.e. the color number modulo the color table size).
size_t tableIndex = 0;
int bestDiff = std::numeric_limits<int>::max();
for (size_t i = 0; i < _maxColors; i++)
{
const auto existingColor = til::at(_colorTable, i);
const auto diff = [](const auto c1, const auto c2) noexcept {
return static_cast<int>(c1) - static_cast<int>(c2);
};
const auto redDiff = diff(GetRValue(existingColor), GetRValue(color));
const auto greenDiff = diff(GetGValue(existingColor), GetGValue(color));
const auto blueDiff = diff(GetBValue(existingColor), GetBValue(color));
const auto totalDiff = redDiff * redDiff + greenDiff * greenDiff + blueDiff * blueDiff;
if (totalDiff <= bestDiff)
{
bestDiff = totalDiff;
tableIndex = i;
}
}
til::at(_colorMap, colorNumber) = gsl::narrow_cast<IndexType>(tableIndex);
}
til::at(_colorMapUsed, colorNumber) = true;
}
}
COLORREF SixelParser::_colorFromIndex(const IndexType tableIndex) const noexcept
{
return til::at(_colorTable, tableIndex);
}
constexpr RGBQUAD SixelParser::_makeRGBQUAD(const COLORREF color) noexcept
{
return RGBQUAD{
.rgbBlue = GetBValue(color),
.rgbGreen = GetGValue(color),
.rgbRed = GetRValue(color),
.rgbReserved = 255
};
}
void SixelParser::_updateTextColors()
{
// On the original hardware terminals, text and images shared the same
// color table, so palette changes made in an image would be reflected in
// the text output as well.
if (_conformanceLevel <= 3 && _maxColors > 2 && _colorTableChanged) [[unlikely]]
{
for (IndexType tableIndex = 0; _maxColors <= 16 && tableIndex < _maxColors; tableIndex++)
{
_dispatcher.SetColorTableEntry(tableIndex, _colorFromIndex(tableIndex));
}
_colorTableChanged = false;
}
}
void SixelParser::_initImageBuffer()
{
_imageBuffer.clear();
_imageOriginCell = _textCursor;
_imageCursor = {};
_imageWidth = 0;
_imageMaxWidth = _availablePixelWidth;
_imageLineCount = 0;
_resizeImageBuffer(_sixelHeight);
_lastFlushLine = 0;
_lastFlushTime = steady_clock::now();
// Prior to the VT340, the background was filled as soon as the sixel
// definition was started, because the initial raster attributes could
// not be altered.
if (_conformanceLevel < 3)
{
_fillImageBackground();
}
}
void SixelParser::_resizeImageBuffer(const til::CoordType requiredHeight)
{
const auto requiredSize = (_imageCursor.y + requiredHeight) * _imageMaxWidth;
if (static_cast<size_t>(requiredSize) > _imageBuffer.size())
{
static constexpr auto transparentPixel = IndexedPixel{ .transparent = true };
_imageBuffer.resize(requiredSize, transparentPixel);
}
}
void SixelParser::_fillImageBackground()
{
if (_backgroundFillRequired) [[unlikely]]
{
_backgroundFillRequired = false;
// When a background fill is requested, we prefill the buffer with the 0
// color index, up to the boundaries set by the raster attributes (or if
// none were given, up to the page boundaries). The actual image output
// isn't limited by the background dimensions though.
const auto backgroundHeight = std::min(_backgroundSize.height, _availablePixelHeight);
_resizeImageBuffer(backgroundHeight);
_fillImageBackground(backgroundHeight);
// When the image extends beyond the page boundaries, and the screen is
// scrolled, we also need to fill the newly exposed lines, so we keep a
// record of the area filled so far. Initially this is considered to be
// the available height, even if it wasn't all filled to start with.
_filledBackgroundHeight = _imageCursor.y + _availablePixelHeight;
_fillImageBackgroundWhenScrolled();
}
}
void SixelParser::_fillImageBackground(const int backgroundHeight)
{
static constexpr auto backgroundPixel = IndexedPixel{};
const auto backgroundWidth = std::min(_backgroundSize.width, _availablePixelWidth);
const auto backgroundOffset = _imageCursor.y * _imageMaxWidth;
auto dst = std::next(_imageBuffer.begin(), backgroundOffset);
for (auto i = 0; i < backgroundHeight; i++)
{
std::fill_n(dst, backgroundWidth, backgroundPixel);
std::advance(dst, _imageMaxWidth);
}
_imageWidth = std::max(_imageWidth, backgroundWidth);
}
void SixelParser::_fillImageBackgroundWhenScrolled()
{
// If _filledBackgroundHeight is set, that means a background fill has been
// requested, and we need to extend that area whenever the image is about to
// overrun it. The newly filled area is a multiple of the cell height (this
// is to match the behavior of the original hardware terminals).
const auto imageHeight = _imageCursor.y + _sixelHeight;
if (_filledBackgroundHeight && imageHeight > _filledBackgroundHeight) [[unlikely]]
{
_filledBackgroundHeight = (imageHeight + _cellSize.height - 1) / _cellSize.height * _cellSize.height;
const auto additionalFillHeight = *_filledBackgroundHeight - _imageCursor.y;
_resizeImageBuffer(additionalFillHeight);
_fillImageBackground(additionalFillHeight);
}
}
void SixelParser::_decreaseFilledBackgroundHeight(const int decreasedHeight) noexcept
{
// Sometimes the top of the image buffer may be clipped (e.g. when the image
// scrolls off the top of a margin area). When that occurs, our record of
// the filled height will need to be decreased to account for the new start.
if (_filledBackgroundHeight) [[unlikely]]
{
_filledBackgroundHeight = *_filledBackgroundHeight - decreasedHeight;
}
}
#pragma warning(push)
#pragma warning(disable : 26429) // Symbol 'imageBufferPtr' is never tested for nullness, it can be marked as not_null (f.23).
#pragma warning(disable : 26481) // Don't use pointer arithmetic. Use span instead (bounds.1).
#pragma warning(disable : 26490) // Don't use reinterpret_cast (type.1).
void SixelParser::_writeToImageBuffer(int sixelValue, int repeatCount)
{
// On terminals that support the raster attributes command (which sets the
// background size), the background is only drawn when the first sixel value
// is received. So if we haven't filled it yet, we need to do so now.
_fillImageBackground();
repeatCount = std::min(repeatCount, _imageMaxWidth - _imageCursor.x);
if (repeatCount <= 0)
{
return;
}
if (sixelValue == 0)
{
_imageCursor.x += repeatCount;
return;
}
// This allows us to unsafely cast _imageBuffer to uint16_t
// and benefit from compiler/STL optimizations.
static_assert(sizeof(IndexedPixel) == sizeof(int16_t));
static_assert(alignof(IndexedPixel) == alignof(int16_t));
// Then we need to render the 6 vertical pixels that are represented by the
// bits in the sixel value. Although note that each of these sixel pixels
// may cover more than one device pixel, depending on the aspect ratio.
const auto targetOffset = _imageCursor.y * _imageMaxWidth + _imageCursor.x;
const auto foreground = std::bit_cast<int16_t>(_foregroundPixel);
auto imageBufferPtr = reinterpret_cast<int16_t*>(_imageBuffer.data() + targetOffset);
// A aspect ratio of 1:1 is the most common and worth optimizing.
if (_pixelAspectRatio == 1)
{
do
{
// This gets unrolled by MSVC. It's written this way to use CMOV instructions.
// Modern CPUs have fat caches and deep pipelines. It's better to do pointless reads
// from memory than causing branch misprediction, as sixelValue is highly random.
for (int i = 0; i < 6; i++)
{
const auto test = sixelValue & (1 << i);
// Possibly pointless read from memory, but...
const auto before = imageBufferPtr[i * _imageMaxWidth];
// ...it allows the compiler to turn this into a CMOV (= no branch misprediction).
const auto after = test ? foreground : before;
imageBufferPtr[i * _imageMaxWidth] = after;
}
_imageCursor.x += 1;
imageBufferPtr += 1;
repeatCount -= 1;
} while (repeatCount > 0);
}
else
{
for (auto i = 0; i < 6; i++)
{
if (sixelValue & 1)
{
auto repeatAspectRatio = _pixelAspectRatio;
do
{
// If this used std::fill_n or just a primitive loop, MSVC would compile
// it to a `rep stosw` instruction, which has a high startup cost.
// This is not ideal when our repeatCount is almost always small.
// The way this does ptr++ and len-- is also ideal for optimization.
auto ptr = imageBufferPtr;
auto remaining = repeatCount;
do
{
__iso_volatile_store16(ptr++, foreground);
} while (--remaining != 0);
imageBufferPtr += _imageMaxWidth;
} while (--repeatAspectRatio > 0);
}
else
{
std::advance(imageBufferPtr, _imageMaxWidth * _pixelAspectRatio);
}
sixelValue >>= 1;
}
_imageCursor.x += repeatCount;
}
}
#pragma warning(pop)
void SixelParser::_eraseImageBufferRows(const int rowCount, const til::CoordType rowOffset) noexcept
{
const auto pixelCount = rowCount * _cellSize.height;
const auto bufferOffset = rowOffset * _cellSize.height * _imageMaxWidth;
const auto bufferOffsetEnd = bufferOffset + pixelCount * _imageMaxWidth;
if (static_cast<size_t>(bufferOffsetEnd) >= _imageBuffer.size()) [[unlikely]]
{
_decreaseFilledBackgroundHeight(_imageCursor.y);
_imageBuffer.clear();
_imageCursor.y = 0;
}
else
{
_decreaseFilledBackgroundHeight(pixelCount);
_imageBuffer.erase(_imageBuffer.begin() + bufferOffset, _imageBuffer.begin() + bufferOffsetEnd);
_imageCursor.y -= pixelCount;
}
}
void SixelParser::_maybeFlushImageBuffer(const bool endOfSequence)
{
// Regardless of whether we flush the image or not, we always calculate how
// much we need to scroll in advance. This algorithm is a bit odd. If there
// isn't enough space for the current segment, it'll scroll until it can fit
// the segment with a pixel to spare. So in the case that it's an exact fit,
// it's expected that we'd scroll an additional line. Although this is not
// common, since it only occurs for pixel aspect ratios of 4:1 or more. Also
// note that we never scroll more than the margin height, since that would
// result in the top of the segment being pushed offscreen.
if (_segmentHeight > _availablePixelHeight && !_displayMode) [[unlikely]]
{
const auto marginPixelHeight = _textMargins.height() * _cellSize.height;
while (_availablePixelHeight < marginPixelHeight && _segmentHeight >= _availablePixelHeight)
{
_pendingTextScrollCount += 1;
_availablePixelHeight += _cellSize.height;
}
}
// Once we've calculated how much scrolling was necessary for the existing
// segment height, we don't need to track that any longer. The next segment
// will start with the active sixel height.
_segmentHeight = _sixelHeight;
// This method is called after every newline (DECGNL), but we don't want to
// render partial output for high speed image sequences like video, so we
// only flush if it has been more than 500ms since the last flush, or it
// appears that the output is intentionally streamed. If the current buffer
// has ended with a newline, and we've received no more than one line since
// the last flush, that suggest it's an intentional break in the stream.
const auto currentTime = steady_clock::now();
const auto timeSinceLastFlush = duration_cast<milliseconds>(currentTime - _lastFlushTime);
const auto linesSinceLastFlush = _imageLineCount - _lastFlushLine;
if (endOfSequence || timeSinceLastFlush > 500ms || (linesSinceLastFlush <= 1 && _stateMachine.IsProcessingLastCharacter()))
{
_lastFlushTime = currentTime;
_lastFlushLine = _imageLineCount;
// Before we output anything, we need to scroll the text buffer to make
// space for the image, using the precalculated scroll count from above.
auto page = _dispatcher._pages.ActivePage();
if (_pendingTextScrollCount > 0) [[unlikely]]
{
_scrollTextBuffer(page, _pendingTextScrollCount);
_pendingTextScrollCount = 0;
}
// If there's no image width, there's nothing to render at this point,
// so the only visible change will be the scrolling.
if (_imageWidth > 0)
{
const auto columnBegin = _imageOriginCell.x;
const auto columnEnd = _imageOriginCell.x + (_imageWidth + _cellSize.width - 1) / _cellSize.width;
auto rowOffset = _imageOriginCell.y;
auto srcIterator = _imageBuffer.begin();
while (srcIterator < _imageBuffer.end() && rowOffset < page.Bottom())
{
if (rowOffset >= 0)
{
auto& dstRow = page.Buffer().GetMutableRowByOffset(rowOffset);
auto dstSlice = dstRow.GetMutableImageSlice();
if (!dstSlice)
{
dstSlice = dstRow.SetImageSlice(std::make_unique<ImageSlice>(_cellSize));
__assume(dstSlice != nullptr);
}
auto dstIterator = dstSlice->MutablePixels(columnBegin, columnEnd);
for (auto pixelRow = 0; pixelRow < _cellSize.height; pixelRow++)
{
for (auto pixelColumn = 0; pixelColumn < _imageWidth; pixelColumn++)
{
const auto srcPixel = til::at(srcIterator, pixelColumn);
if (!srcPixel.transparent)
{
const auto srcColor = _colorFromIndex(srcPixel.colorIndex);
til::at(dstIterator, pixelColumn) = _makeRGBQUAD(srcColor);
}
}
std::advance(srcIterator, _imageMaxWidth);
if (srcIterator >= _imageBuffer.end())
{
break;
}
std::advance(dstIterator, dstSlice->PixelWidth());
}
}
else
{
std::advance(srcIterator, _imageMaxWidth * _cellSize.height);
}
rowOffset++;
}
// Trigger a redraw of the affected rows in the renderer.
const auto topRowOffset = std::max(_imageOriginCell.y, 0);
const auto dirtyView = Viewport::FromExclusive({ 0, topRowOffset, page.Width(), rowOffset });
page.Buffer().TriggerRedraw(dirtyView);
// If the start of the image is now above the top of the page, we
// won't be making any further updates to that content, so we can
// erase it from our local buffer
if (_imageOriginCell.y < page.Top())
{
const auto rowsToDelete = page.Top() - _imageOriginCell.y;
_eraseImageBufferRows(rowsToDelete);
_imageOriginCell.y += rowsToDelete;
}
}
// On lower conformance levels, we also update the text colors.
_updateTextColors();
// And at the end of the sequence, we update the text cursor position.
if (endOfSequence)
{
_updateTextCursor(page.Cursor());
}
}
}