diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 8bf2847b07..8d4faa1764 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -14,6 +14,7 @@ CDEFGH crtdbg CRTDBG circleval +COLSx ctrled dcx DDDDD @@ -23,6 +24,7 @@ DECRST DECSET Decset ecmascript +eeeh ellips emantic emtpy @@ -30,6 +32,10 @@ FAILCRITICALERRORS FFFFF FFFFFFC GGGGG +GIDELETE +GIONESHOT +GIRENDER +GIUPLOAD gitbranch gitgraph HHHHH @@ -46,6 +52,8 @@ Ptd Pwe precomposed pseudoconsole +Pxa +qmljsdebugger rbong REPORTFAULT SBQUERY @@ -54,4 +62,5 @@ ULval unscroll URval vectorizable +WXYZ xad diff --git a/CMakeLists.txt b/CMakeLists.txt index 222ca464ba..1990c585eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,8 +101,6 @@ option(CONTOUR_WITH_UTEMPTER "Build with utempter support [default: ON]" ON) option(CONTOUR_USE_CPM "Use CPM to fetch dependencies [default: ON]" ON) option(CONTOUR_BUILD_STATIC "Link to static libraries [default: OFF]" OFF) option(CONTOUR_BUILD_NATIVE "Build for native architecture [default: OFF]" OFF) - - if(CONTOUR_BUILD_STATIC) set(BUILD_SHARED_LIBS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) diff --git a/metainfo.xml b/metainfo.xml index 96e5a3ff6a..4fa0b7c01a 100644 --- a/metainfo.xml +++ b/metainfo.xml @@ -109,6 +109,8 @@ diff --git a/src/contour/CMakeLists.txt b/src/contour/CMakeLists.txt index 00852a9706..8efcbe08aa 100644 --- a/src/contour/CMakeLists.txt +++ b/src/contour/CMakeLists.txt @@ -101,10 +101,9 @@ set_target_properties(contour PROPERTIES AUTORCC ON) target_include_directories(contour PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") # {{{ declare compiler definitions -# target_compile_definitions(contour PRIVATE $<$,$>:QT_QML_DEBUG>) -target_compile_definitions(contour PRIVATE $<$:QT_QML_DEBUG>) -target_compile_definitions(contour PRIVATE $<$:QMLJSDEBUGGER>) -target_compile_definitions(contour PRIVATE $<$:QT_DECLARATIVE_DEBUG>) +# QML debugging can be enabled at runtime via -qmljsdebugger=... or QT_QML_DEBUG env var. +# Avoid compile-time defines that force the "QML debugging is enabled" message on every +# invocation, including headless CLI commands like `contour cat`. target_compile_definitions(contour PRIVATE CONTOUR_VERSION_MAJOR=${PROJECT_VERSION_MAJOR} CONTOUR_VERSION_MINOR=${PROJECT_VERSION_MINOR} diff --git a/src/contour/ContourApp.cpp b/src/contour/ContourApp.cpp index 90491cc034..df908ff218 100644 --- a/src/contour/ContourApp.cpp +++ b/src/contour/ContourApp.cpp @@ -11,10 +11,12 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -43,6 +45,7 @@ using std::string_view; using std::unique_ptr; using namespace std::string_literals; +using namespace std::string_view_literals; namespace CLI = crispy::cli; @@ -166,6 +169,7 @@ ContourApp::ContourApp(): app("contour", "Contour Terminal Emulator", CONTOUR_VE link("contour.documentation.keys", bind(&ContourApp::documentationKeyMapping, this)); link("contour.documentation.configuration.global", bind(&ContourApp::documentationGlobalConfig, this)); link("contour.documentation.configuration.profile", bind(&ContourApp::documentationProfileConfig, this)); + link("contour.cat", bind(&ContourApp::catAction, this)); } template @@ -466,6 +470,180 @@ int ContourApp::captureAction() return EXIT_FAILURE; } +namespace +{ + /// Parses a size string in the format "WxH" (e.g. "80x24"). + /// Returns {0,0} if the format is invalid. + crispy::size parseSize(string_view text) + { + auto const pos = text.find('x'); + if (pos == string_view::npos || pos == 0 || pos == text.size() - 1) + return crispy::size {}; + + auto const widthStr = text.substr(0, pos); + auto const heightStr = text.substr(pos + 1); + + int width = 0; + int height = 0; + auto [pW, ecW] = std::from_chars(widthStr.data(), widthStr.data() + widthStr.size(), width); + auto [pH, ecH] = std::from_chars(heightStr.data(), heightStr.data() + heightStr.size(), height); + + if (ecW != std::errc {} || ecH != std::errc {}) + return crispy::size {}; + + return crispy::size { .width = width, .height = height }; + } + + /// Parses an image alignment string. + vtbackend::ImageAlignment parseImageAlignment(string_view text) + { + if (text == "top-start") + return vtbackend::ImageAlignment::TopStart; + if (text == "top-center") + return vtbackend::ImageAlignment::TopCenter; + if (text == "top-end") + return vtbackend::ImageAlignment::TopEnd; + if (text == "middle-start") + return vtbackend::ImageAlignment::MiddleStart; + if (text == "middle-center" || text == "center") + return vtbackend::ImageAlignment::MiddleCenter; + if (text == "middle-end") + return vtbackend::ImageAlignment::MiddleEnd; + if (text == "bottom-start") + return vtbackend::ImageAlignment::BottomStart; + if (text == "bottom-center") + return vtbackend::ImageAlignment::BottomCenter; + if (text == "bottom-end") + return vtbackend::ImageAlignment::BottomEnd; + return vtbackend::ImageAlignment::MiddleCenter; + } + + /// Parses an image resize policy string. + vtbackend::ImageResize parseImageResize(string_view text) + { + if (text == "no" || text == "none") + return vtbackend::ImageResize::NoResize; + if (text == "fit") + return vtbackend::ImageResize::ResizeToFit; + if (text == "fill") + return vtbackend::ImageResize::ResizeToFill; + if (text == "stretch") + return vtbackend::ImageResize::StretchToFill; + return vtbackend::ImageResize::ResizeToFit; + } + + /// Parses an image layer value string (0/1/2). + int parseImageLayer(string_view text) + { + if (text == "0" || text == "below") + return 0; + if (text == "1" || text == "replace") + return 1; + if (text == "2" || text == "above") + return 2; + return 1; // default: replace + } + + /// Reads a file in binary mode, returning its raw bytes. + std::vector readFile(std::filesystem::path const& path) + { + auto ifs = std::ifstream(path.string(), std::ios::binary); + if (!ifs.good()) + return {}; + + auto const fileSize = std::filesystem::file_size(path); + auto data = std::vector(); + data.resize(fileSize); + ifs.read(reinterpret_cast(data.data()), static_cast(fileSize)); + if (!ifs || static_cast(ifs.gcount()) != fileSize) + return {}; + return data; + } + + void displayImage(vtbackend::ImageResize resizePolicy, + vtbackend::ImageAlignment alignmentPolicy, + crispy::size screenSize, + int layer, + string_view fileName) + { + auto constexpr ST = "\033\\"sv; + + // Emit GIP oneshot DCS sequence. + // Format 3 = PNG (let the terminal decode it). + cout << std::format("{}f=3,c={},r={},a={},z={},L={};!", + "\033Ps"sv, + screenSize.width, + screenSize.height, + static_cast(alignmentPolicy) + 1, + static_cast(resizePolicy), + layer); + + auto const data = readFile(std::filesystem::path(string(fileName))); + if (data.empty()) + { + cerr << std::format("Error: Failed to read file '{}'\n", fileName); + return; + } + + auto encoderState = crispy::base64::encoder_state {}; + std::vector buf; + auto const writer = [&](char a, char b, char c, char d) { + buf.push_back(a); + buf.push_back(b); + buf.push_back(c); + buf.push_back(d); + }; + auto const flush = [&]() { + cout.write(buf.data(), static_cast(buf.size())); + buf.clear(); + }; + + for (uint8_t const byte: data) + { + crispy::base64::encode(byte, encoderState, writer); + if (buf.size() >= 4096) + flush(); + } + crispy::base64::finish(encoderState, writer); + flush(); + + cout << ST; + } +} // namespace + +int ContourApp::catAction() +{ + if (parameters().verbatim.empty()) + { + cerr << "Error: No image file specified.\n"; + return EXIT_FAILURE; + } + + auto const resizePolicy = parseImageResize(parameters().get("contour.cat.resize")); + auto const alignmentPolicy = parseImageAlignment(parameters().get("contour.cat.align")); + auto const size = parseSize(parameters().get("contour.cat.size")); + auto const layer = parseImageLayer(parameters().get("contour.cat.layer")); + auto const fileName = parameters().verbatim.front(); + + auto const filePath = std::filesystem::path(string(fileName)); + if (!std::filesystem::exists(filePath)) + { + cerr << std::format("Error: File not found: '{}'\n", fileName); + return EXIT_FAILURE; + } + + // GIP protocol limits the body to 16 MB. + auto constexpr MaxImageFileSize = std::uintmax_t { 16 * 1024 * 1024 }; + if (std::filesystem::file_size(filePath) > MaxImageFileSize) + { + cerr << std::format("Error: File '{}' exceeds the maximum supported size of 16 MB.\n", fileName); + return EXIT_FAILURE; + } + + displayImage(resizePolicy, alignmentPolicy, size, layer, fileName); + return EXIT_SUCCESS; +} + int ContourApp::parserTableAction() { vtparser::parserTableDot(std::cout); @@ -575,6 +753,39 @@ crispy::cli::command ContourApp::parameterDefinition() const "FILE", CLI::presence::Required }, } } } }, + CLI::command { + "cat", + "Displays an image in the terminal using the Good Image Protocol.", + CLI::option_list { + CLI::option { "resize", + CLI::value { "fit"s }, + "Sets the image resize policy.\n" + "Policies available are:\n" + " - no/none (no resize),\n" + " - fit (resize to fit),\n" + " - fill (resize to fill),\n" + " - stretch (stretch to fill)." }, + CLI::option { "align", + CLI::value { "center"s }, + "Sets the image alignment policy.\n" + "Possible values: top-start, top-center, top-end, " + "middle-start, middle-center/center, middle-end, " + "bottom-start, bottom-center, bottom-end." }, + CLI::option { "size", + CLI::value { "0x0"s }, + "Sets the amount of columns and rows to place the image onto " + "(format: COLSxROWS, e.g. 80x24). " + "The top-left of the area is the current cursor position." }, + CLI::option { "layer", + CLI::value { "1"s }, + "Sets the image layer relative to text.\n" + "Values: 0/below (below text), 1/replace (replace text, default), " + "2/above (above text)." } }, + CLI::command_list {}, + CLI::command_select::Explicit, + CLI::verbatim { + "IMAGE_FILE", + "Path to image to be displayed. Image formats supported are at least PNG, JPG." } }, CLI::command { "capture", "Captures the screen buffer of the currently running terminal.", diff --git a/src/contour/ContourApp.h b/src/contour/ContourApp.h index d269ed6cf7..cf51f9b5d3 100644 --- a/src/contour/ContourApp.h +++ b/src/contour/ContourApp.h @@ -29,6 +29,7 @@ class ContourApp: public crispy::app int documentationKeyMapping(); int documentationGlobalConfig(); int documentationProfileConfig(); + int catAction(); }; } // namespace contour diff --git a/src/contour/display/OpenGLRenderer.cpp b/src/contour/display/OpenGLRenderer.cpp index 4384462cd7..e16248a483 100644 --- a/src/contour/display/OpenGLRenderer.cpp +++ b/src/contour/display/OpenGLRenderer.cpp @@ -118,6 +118,7 @@ namespace { case vtbackend::ImageFormat::RGB: return GL_RGB; case vtbackend::ImageFormat::RGBA: return GL_RGBA; + case vtbackend::ImageFormat::PNG: Require(false); } Guarantee(false); crispy::unreachable(); diff --git a/src/contour/display/TerminalDisplay.cpp b/src/contour/display/TerminalDisplay.cpp index d3399c9643..63d57a7dbf 100644 --- a/src/contour/display/TerminalDisplay.cpp +++ b/src/contour/display/TerminalDisplay.cpp @@ -346,6 +346,34 @@ void TerminalDisplay::setSession(TerminalSession* newSession) _session->attachDisplay(*this); // NB: Requires Renderer to be instanciated to retrieve grid metrics. + _session->terminal().setImageDecoder( + [](vtbackend::ImageFormat format, + std::span data, + vtbackend::ImageSize& size) -> std::optional { + if (format != vtbackend::ImageFormat::PNG) + return std::nullopt; + + QImage image; + image.loadFromData(static_cast(data.data()), static_cast(data.size())); + if (image.isNull()) + return std::nullopt; + + image = image.convertToFormat(QImage::Format_RGBA8888); + + size = vtbackend::ImageSize { vtbackend::Width::cast_from(image.width()), + vtbackend::Height::cast_from(image.height()) }; + + vtbackend::Image::Data pixels; + pixels.resize(static_cast(image.width() * image.height() * 4)); + auto* p = pixels.data(); + for (int i = 0; i < image.height(); ++i) + { + memcpy(p, image.constScanLine(i), static_cast(image.bytesPerLine())); + p += image.bytesPerLine(); + } + return pixels; + }); + emit sessionChanged(newSession); } @@ -1624,6 +1652,7 @@ void TerminalDisplay::discardImage(vtbackend::Image const& image) { _renderer->discardImage(image); } + // }}} } // namespace contour::display diff --git a/src/vtbackend/CMakeLists.txt b/src/vtbackend/CMakeLists.txt index a65f6700ff..fd62e8ede6 100644 --- a/src/vtbackend/CMakeLists.txt +++ b/src/vtbackend/CMakeLists.txt @@ -32,6 +32,7 @@ set(vtbackend_HEADERS LineFlags.h LineSoA.h MatchModes.h + MessageParser.h MockTerm.h RenderBuffer.h RenderBufferBuilder.h @@ -68,6 +69,7 @@ set(vtbackend_SOURCES Line.cpp LineSoA.cpp MatchModes.cpp + MessageParser.cpp MockTerm.cpp RenderBuffer.cpp RenderBufferBuilder.cpp @@ -132,12 +134,14 @@ if(LIBTERMINAL_TESTING) Capabilities_test.cpp DesktopNotification_test.cpp Color_test.cpp + GoodImageProtocol_test.cpp InputGenerator_test.cpp Selector_test.cpp Functions_test.cpp Grid_test.cpp HintModeHandler_test.cpp Line_test.cpp + MessageParser_test.cpp Screen_test.cpp Image_test.cpp Sequence_test.cpp diff --git a/src/vtbackend/CellProxy.h b/src/vtbackend/CellProxy.h index 2aee6b8682..610a6ebd16 100644 --- a/src/vtbackend/CellProxy.h +++ b/src/vtbackend/CellProxy.h @@ -196,6 +196,18 @@ class BasicCellProxy _line->widths[_col] = static_cast(std::max(1u, unicode::width(ch))); else _line->widths[_col] = 1; + + // Writing text into a cell destroys Replace-layer images (Sixel-compatible behavior). + // Below and Above layer images coexist with text per the GIP spec. + if (_line->imageFragments) + { + auto const key = static_cast(_col); + if (auto const it = _line->imageFragments->find(key); it != _line->imageFragments->end()) + { + if (!shouldPreserveImageOnTextWrite(it->second)) + _line->imageFragments->erase(it); + } + } } void setWidth(uint8_t w) noexcept diff --git a/src/vtbackend/Functions.cpp b/src/vtbackend/Functions.cpp index 25ec09ac44..b229c8cd41 100644 --- a/src/vtbackend/Functions.cpp +++ b/src/vtbackend/Functions.cpp @@ -40,8 +40,6 @@ Function const* select(FunctionSelector const& selector, auto const i = (a + b) / 2; auto const& fui = availableDefinitions[i]; auto const rel = compare(selector, fui); - // std::cout << std::format(" - a:{:>2} b:{:>2} i:{} rel:{} I: {}\n", a, b, i, rel < 0 ? '<' : rel > 0 - // ? '>' : '=', I); if (rel > 0) a = i + 1; else if (rel < 0) @@ -51,7 +49,21 @@ Function const* select(FunctionSelector const& selector, b = i - 1; } else - return &fui; + { + // Found a match. Scan left to find the most specific (leftmost) match. + // Multiple definitions can match the same selector (e.g. DCS q with 0 args + // matches both GIQUERY [max=0] and DECSIXEL [max=3]). The leftmost match + // in the sorted array has the tightest parameter range. + auto result = &fui; + for (auto j = i; j > 0; --j) + { + if (compare(selector, availableDefinitions[j - 1]) == 0) + result = &availableDefinitions[j - 1]; + else + break; + } + return result; + } } return nullptr; } diff --git a/src/vtbackend/Functions.h b/src/vtbackend/Functions.h index dc3109ab42..878cd192de 100644 --- a/src/vtbackend/Functions.h +++ b/src/vtbackend/Functions.h @@ -135,6 +135,13 @@ constexpr inline auto DECSIXEL = FunctionDocumentation { .mnemonic = "DECSIXEL", constexpr inline auto STP = FunctionDocumentation { .mnemonic = "STP", .comment = "Set Terminal Profile" }; constexpr inline auto XTGETTCAP = FunctionDocumentation { .mnemonic = "XTGETTCAP", .comment = "Request Termcap/Terminfo String" }; +// GIP (Good Image Protocol) +constexpr inline auto GIUPLOAD = FunctionDocumentation { .mnemonic = "GIUPLOAD", .comment = "Uploads an image." }; +constexpr inline auto GIRENDER = FunctionDocumentation { .mnemonic = "GIRENDER", .comment = "Renders an image." }; +constexpr inline auto GIDELETE = FunctionDocumentation { .mnemonic = "GIDELETE", .comment = "Deletes an image." }; +constexpr inline auto GIONESHOT = FunctionDocumentation { .mnemonic = "GIONESHOT", .comment = "Uploads and renders an unnamed image." }; +constexpr inline auto GIQUERY = FunctionDocumentation { .mnemonic = "GIQUERY", .comment = "Queries image resource limits." }; + // OSC constexpr inline auto CLIPBOARD = FunctionDocumentation { .mnemonic = "CLIPBOARD", .comment = "Clipboard management." }; constexpr inline auto COLORBG = FunctionDocumentation { .mnemonic = "COLORBG", .comment = "Change or request text background color." }; @@ -677,6 +684,18 @@ constexpr inline auto DESKTOPNOTIFY = detail::OSC(99, VTExtension::Unknown, d // NOLINTEND(readability-identifier-naming) // clang-format on +// DCS: Good Image Protocol +constexpr inline auto GIUPLOAD = + detail::DCS(std::nullopt, 0, 0, std::nullopt, 'u', VTType::VT525, documentation::GIUPLOAD); +constexpr inline auto GIRENDER = + detail::DCS(std::nullopt, 0, 0, std::nullopt, 'r', VTType::VT525, documentation::GIRENDER); +constexpr inline auto GIDELETE = + detail::DCS(std::nullopt, 0, 0, std::nullopt, 'd', VTType::VT525, documentation::GIDELETE); +constexpr inline auto GIONESHOT = + detail::DCS(std::nullopt, 0, 0, std::nullopt, 's', VTType::VT525, documentation::GIONESHOT); +constexpr inline auto GIQUERY = + detail::DCS(std::nullopt, 0, 0, std::nullopt, 'q', VTType::VT525, documentation::GIQUERY); + constexpr inline auto CaptureBufferCode = 314; // HACK to get older compiler work (GCC 9.4) @@ -816,6 +835,11 @@ constexpr static auto allFunctionsArray() noexcept DECRQDE, // DCS + GIUPLOAD, + GIRENDER, + GIDELETE, + GIONESHOT, + GIQUERY, STP, DECRQSS, DECSIXEL, diff --git a/src/vtbackend/GoodImageProtocol_test.cpp b/src/vtbackend/GoodImageProtocol_test.cpp new file mode 100644 index 0000000000..a323bf8248 --- /dev/null +++ b/src/vtbackend/GoodImageProtocol_test.cpp @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include + +using namespace vtbackend; +using namespace std::string_view_literals; + +namespace +{ + +/// Helper: creates raw RGBA pixel data of the given size filled with the given color. +std::vector makeRGBA(int width, int height, uint8_t r, uint8_t g, uint8_t b, uint8_t a) +{ + auto data = std::vector(static_cast(width * height * 4)); + for (int i = 0; i < width * height; ++i) + { + data[static_cast(i * 4 + 0)] = r; + data[static_cast(i * 4 + 1)] = g; + data[static_cast(i * 4 + 2)] = b; + data[static_cast(i * 4 + 3)] = a; + } + return data; +} + +/// Helper: wraps data as a GIP DCS upload sequence string. +/// DCS u ; ST +std::string gipUpload(std::string_view headers, std::span body) +{ + auto const encoded = + crispy::base64::encode(std::string_view(reinterpret_cast(body.data()), body.size())); + return std::format("\033Pu{};!{}\033\\", headers, encoded); +} + +/// Helper: wraps a GIP DCS render sequence string. +std::string gipRender(std::string_view headers) +{ + return std::format("\033Pr{}\033\\", headers); +} + +/// Helper: wraps a GIP DCS oneshot sequence string. +std::string gipOneshot(std::string_view headers, std::span body) +{ + auto const encoded = + crispy::base64::encode(std::string_view(reinterpret_cast(body.data()), body.size())); + return std::format("\033Ps{};!{}\033\\", headers, encoded); +} + +/// Helper: wraps a GIP DCS release sequence string. +std::string gipRelease(std::string_view headers) +{ + return std::format("\033Pd{}\033\\", headers); +} + +} // namespace + +// ==================== Upload Tests ==================== + +TEST_CASE("GoodImageProtocol.Upload.RGB", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + mock.writeToScreen(gipUpload("n=test,f=2,w=2,h=2", pixels)); + + auto const imageRef = mock.terminal.imagePool().findImageByName("test"); + REQUIRE(imageRef != nullptr); + CHECK(imageRef->width() == Width(2)); + CHECK(imageRef->height() == Height(2)); +} + +TEST_CASE("GoodImageProtocol.Upload.RGBA", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(3, 3, 0x00, 0xFF, 0x00, 0xFF); + mock.writeToScreen(gipUpload("n=rgba,f=2,w=3,h=3", pixels)); + + auto const imageRef = mock.terminal.imagePool().findImageByName("rgba"); + REQUIRE(imageRef != nullptr); + CHECK(imageRef->width() == Width(3)); + CHECK(imageRef->height() == Height(3)); +} + +TEST_CASE("GoodImageProtocol.Upload.WithoutName", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0xFF, 0xFF, 0xFF); + // Upload without name should be silently ignored. + mock.writeToScreen(gipUpload("f=2,w=2,h=2", pixels)); + // No crash, no named image in pool. +} + +TEST_CASE("GoodImageProtocol.Upload.InvalidFormat", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(1, 1, 0xFF, 0xFF, 0xFF, 0xFF); + // Format 9 is invalid. + mock.writeToScreen(gipUpload("n=invalid,f=9,w=1,h=1", pixels)); + + auto const imageRef = mock.terminal.imagePool().findImageByName("invalid"); + CHECK(imageRef == nullptr); +} + +// ==================== Render Tests ==================== + +TEST_CASE("GoodImageProtocol.Render.ByName", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Upload first + mock.writeToScreen(gipUpload("n=red,f=2,w=2,h=2", pixels)); + REQUIRE(mock.terminal.imagePool().findImageByName("red") != nullptr); + + // Render: 4 columns, 2 rows + mock.writeToScreen(gipRender("n=red,c=4,r=2")); + + // Verify image fragments are placed in grid cells. + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().image().width() == Width(2)); +} + +TEST_CASE("GoodImageProtocol.Render.NonexistentName", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + + // Render a name that was never uploaded — should be a no-op, no crash. + mock.writeToScreen(gipRender("n=nonexistent,c=4,r=2")); + + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + CHECK(fragment == nullptr); +} + +TEST_CASE("GoodImageProtocol.Render.StatusSuccess", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=img,f=2,w=2,h=2", pixels)); + mock.resetReplyData(); + mock.writeToScreen(gipRender("n=img,c=4,r=2,s")); + + // CSI > 0 i = success + CHECK(mock.replyData().find("\033[>0i") != std::string::npos); +} + +TEST_CASE("GoodImageProtocol.Render.StatusFailure", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + mock.resetReplyData(); + mock.writeToScreen(gipRender("n=missing,c=4,r=2,s")); + + // CSI > 1 i = failure + CHECK(mock.replyData().find("\033[>1i") != std::string::npos); +} + +// ==================== Oneshot Tests ==================== + +TEST_CASE("GoodImageProtocol.Oneshot.Render", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0x00, 0x00, 0xFF, 0xFF); + + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2", pixels)); + + // Verify image fragment in cell (0,0) + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); +} + +// ==================== Release Tests ==================== + +TEST_CASE("GoodImageProtocol.Release.ByName", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0xFF, 0xFF, 0xFF); + + mock.writeToScreen(gipUpload("n=tmp,f=2,w=2,h=2", pixels)); + REQUIRE(mock.terminal.imagePool().findImageByName("tmp") != nullptr); + + mock.writeToScreen(gipRelease("n=tmp")); + CHECK(mock.terminal.imagePool().findImageByName("tmp") == nullptr); +} + +TEST_CASE("GoodImageProtocol.Release.Nonexistent", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + // Releasing a nonexistent name should be a no-op, no crash. + mock.writeToScreen(gipRelease("n=nope")); +} + +// ==================== DA1 Test ==================== + +TEST_CASE("GoodImageProtocol.DA1.IncludesGIPCode", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + mock.resetReplyData(); + // Send DA1 query + mock.writeToScreen("\033[c"); + mock.terminal.flushInput(); + + // Response should contain ;11 (the GIP DA1 code) + CHECK(mock.replyData().find(";11") != std::string::npos); +} + +// ==================== Screen Layer Tests ==================== + +TEST_CASE("GoodImageProtocol.Layer.Below", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=below,f=2,w=2,h=2", pixels)); + mock.writeToScreen(gipRender("n=below,c=4,r=2,L=0")); + + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Below); +} + +TEST_CASE("GoodImageProtocol.Layer.Replace", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=replace,f=2,w=2,h=2", pixels)); + // Default layer (no L parameter) should be Replace. + mock.writeToScreen(gipRender("n=replace,c=4,r=2")); + + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Replace); +} + +TEST_CASE("GoodImageProtocol.Layer.Above", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=above,f=2,w=2,h=2", pixels)); + mock.writeToScreen(gipRender("n=above,c=4,r=2,L=2")); + + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Above); +} + +// ==================== Edge Cases ==================== + +TEST_CASE("GoodImageProtocol.EdgeCase.ZeroGridSize", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0xFF, 0xFF, 0xFF); + + // Render with r=0, c=0 should not crash. + mock.writeToScreen(gipUpload("n=zero,f=2,w=2,h=2", pixels)); + mock.writeToScreen(gipRender("n=zero,c=0,r=0")); + + // Cell should not have an image fragment. + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + CHECK(cell.imageFragment() == nullptr); +} + +TEST_CASE("GoodImageProtocol.Oneshot.WithLayer", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0x00, 0xFF, 0x00, 0xFF); + + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,L=2", pixels)); + + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + auto const fragment = cell.imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Above); +} + +// ==================== MessageParser MaxBodyLength ==================== + +TEST_CASE("GoodImageProtocol.MaxBodyLength", "[GIP]") +{ + CHECK(MessageParser::MaxBodyLength == 16 * 1024 * 1024); +} + +// ==================== Layer Text-Write Interaction Tests ==================== + +TEST_CASE("GoodImageProtocol.Layer.Below.SurvivesTextWrite", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Place a Below-layer image at cursor position (top-left). + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,L=0", pixels)); + + // Verify fragment is placed. + auto fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Below); + + // Move cursor back to top-left and write text over the image area. + mock.writeToScreen("\033[H"); // CUP to (1,1) + mock.writeToScreen("ABCD"); + + // Below-layer image should survive the text write. + fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + CHECK(fragment != nullptr); + + // Text should also be present. + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + CHECK(cell.codepoint(0) == U'A'); +} + +TEST_CASE("GoodImageProtocol.Layer.Replace.DestroyedByTextWrite", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Place a Replace-layer image (default layer). + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2", pixels)); + + // Verify fragment is placed with Replace layer. + auto fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Replace); + + // Move cursor back and write text. + mock.writeToScreen("\033[H"); + mock.writeToScreen("ABCD"); + + // Replace-layer image should be destroyed by text write. + fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + CHECK(fragment == nullptr); + + // Text should be present. + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + CHECK(cell.codepoint(0) == U'A'); +} + +TEST_CASE("GoodImageProtocol.Layer.Above.SurvivesTextWrite", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Place an Above-layer image. + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,L=2", pixels)); + + // Verify fragment is placed. + auto fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + REQUIRE(fragment != nullptr); + CHECK(fragment->rasterizedImage().layer() == ImageLayer::Above); + + // Move cursor back and write text. + mock.writeToScreen("\033[H"); + mock.writeToScreen("ABCD"); + + // Above-layer image should survive the text write. + fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + CHECK(fragment != nullptr); + + // Text should also be present. + auto const& cell = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)); + CHECK(cell.codepoint(0) == U'A'); +} + +TEST_CASE("GoodImageProtocol.Layer.Below.SurvivesCursorMoveAndTextWrite", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Place a Below-layer image spanning 4 columns x 2 rows at top-left. + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,L=0", pixels)); + + // Write some text elsewhere (after the image area). + mock.writeToScreen("extra text"); + + // Move cursor back to top-left and overwrite all 4 image columns. + mock.writeToScreen("\033[H"); // CUP to (1,1) + mock.writeToScreen("WXYZ"); + + // All 4 cells on the first row should retain their image fragments. + for (auto col: { 0, 1, 2, 3 }) + { + auto const fragment = + mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(col)).imageFragment(); + CHECK(fragment != nullptr); + } + + // Text should also be present in those cells. + CHECK(mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).codepoint(0) == U'W'); + CHECK(mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(1)).codepoint(0) == U'X'); + CHECK(mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(2)).codepoint(0) == U'Y'); + CHECK(mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(3)).codepoint(0) == U'Z'); +} + +TEST_CASE("GoodImageProtocol.Layer.Below.ClearedByErase", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Place a Below-layer image. + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,L=0", pixels)); + + // Verify fragment is placed. + auto fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + REQUIRE(fragment != nullptr); + + // Erase display (ED 2 = clear entire screen). Reset operations should clear everything. + mock.writeToScreen("\033[2J"); + + // Below-layer image should be destroyed by erase. + fragment = mock.terminal.primaryScreen().at(LineOffset(0), ColumnOffset(0)).imageFragment(); + CHECK(fragment == nullptr); +} + +// ==================== Update-Cursor Tests ==================== + +TEST_CASE("GoodImageProtocol.Render.UpdateCursorFalse", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=uc,f=2,w=2,h=2", pixels)); + + // Move cursor to a known position. + mock.writeToScreen("\033[3;5H"); // CUP to (3, 5) — 1-based + auto const beforePos = mock.terminal.primaryScreen().realCursorPosition(); + + // Render without u flag — cursor should NOT move. + mock.writeToScreen(gipRender("n=uc,c=4,r=2")); + + auto const afterPos = mock.terminal.primaryScreen().realCursorPosition(); + CHECK(afterPos.line == beforePos.line); + CHECK(afterPos.column == beforePos.column); +} + +TEST_CASE("GoodImageProtocol.Render.UpdateCursorTrue", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + mock.writeToScreen(gipUpload("n=uc2,f=2,w=2,h=2", pixels)); + + // Move cursor to top-left. + mock.writeToScreen("\033[1;1H"); // CUP to (1, 1) — 1-based + // Render with u flag — cursor should move below the image. + mock.writeToScreen(gipRender("n=uc2,c=4,r=3,u")); + + auto const afterPos = mock.terminal.primaryScreen().realCursorPosition(); + // Cursor should be at column 0 (left margin), line 2 (below 3 rows of image, 0-based). + CHECK(afterPos.column == ColumnOffset(0)); + CHECK(afterPos.line == LineOffset(2)); +} + +// ==================== Upload Status Response Tests ==================== + +TEST_CASE("GoodImageProtocol.Upload.StatusSuccess", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + mock.resetReplyData(); + mock.writeToScreen(gipUpload("n=upstat,f=2,w=2,h=2,s", pixels)); + + // CSI > 0 i = success + CHECK(mock.replyData().find("\033[>0i") != std::string::npos); + + // Verify image was actually uploaded. + auto const imageRef = mock.terminal.imagePool().findImageByName("upstat"); + CHECK(imageRef != nullptr); +} + +TEST_CASE("GoodImageProtocol.Upload.StatusInvalidData", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + // Create data that doesn't match the declared dimensions (2x2 RGBA = 16 bytes, but we send 4). + auto const wrongPixels = std::vector { 0xFF, 0x00, 0x00, 0xFF }; + mock.resetReplyData(); + mock.writeToScreen(gipUpload("n=bad,f=2,w=2,h=2,s", wrongPixels)); + + // CSI > 2 i = invalid image data + CHECK(mock.replyData().find("\033[>2i") != std::string::npos); + + // Image should NOT be in pool. + CHECK(mock.terminal.imagePool().findImageByName("bad") == nullptr); +} + +// ==================== Name Validation Tests ==================== + +TEST_CASE("GoodImageProtocol.Upload.InvalidNameCharacters", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(1, 1, 0xFF, 0xFF, 0xFF, 0xFF); + + // Name with spaces should be rejected. + mock.writeToScreen(gipUpload("n=bad name,f=2,w=1,h=1", pixels)); + CHECK(mock.terminal.imagePool().findImageByName("bad name") == nullptr); + + // Name with special characters should be rejected. + mock.writeToScreen(gipUpload("n=bad!name,f=2,w=1,h=1", pixels)); + CHECK(mock.terminal.imagePool().findImageByName("bad!name") == nullptr); + + // Valid name with underscore should be accepted. + mock.writeToScreen(gipUpload("n=good_name_123,f=2,w=1,h=1", pixels)); + CHECK(mock.terminal.imagePool().findImageByName("good_name_123") != nullptr); +} + +// ==================== Query Tests ==================== + +TEST_CASE("GoodImageProtocol.Query.ResourceLimits", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + mock.resetReplyData(); + + // Send DCS q ST + mock.writeToScreen("\033Pq\033\\"); + + // Response should be CSI > 8 ; Pm ; Pb ; Pw ; Ph i + auto const& reply = mock.replyData(); + CHECK(reply.find("\033[>8;") != std::string::npos); + CHECK(reply.find("i") != std::string::npos); +} + +// ==================== Oneshot Update-Cursor Tests ==================== + +TEST_CASE("GoodImageProtocol.Oneshot.UpdateCursorFalse", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Move cursor to known position. + mock.writeToScreen("\033[3;5H"); + auto const beforePos = mock.terminal.primaryScreen().realCursorPosition(); + + // Oneshot without u flag — cursor should NOT move. + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2", pixels)); + + auto const afterPos = mock.terminal.primaryScreen().realCursorPosition(); + CHECK(afterPos.line == beforePos.line); + CHECK(afterPos.column == beforePos.column); +} + +TEST_CASE("GoodImageProtocol.Oneshot.UpdateCursorTrue", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + + // Move cursor to top-left. + mock.writeToScreen("\033[1;1H"); + // Oneshot with u flag — cursor should move below the image. + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,u", pixels)); + + auto const afterPos = mock.terminal.primaryScreen().realCursorPosition(); + // Cursor should be at column 0, line 1 (below 2 rows, 0-based). + CHECK(afterPos.column == ColumnOffset(0)); + CHECK(afterPos.line == LineOffset(1)); +} + +// ==================== Oneshot Status Response Tests ==================== + +TEST_CASE("GoodImageProtocol.Oneshot.StatusResponse", "[GIP]") +{ + auto mock = MockTerm { PageSize { LineCount(10), ColumnCount(20) } }; + auto const pixels = makeRGBA(2, 2, 0xFF, 0x00, 0x00, 0xFF); + mock.resetReplyData(); + + mock.writeToScreen(gipOneshot("f=2,w=2,h=2,c=4,r=2,s", pixels)); + + // CSI > 0 i = success + CHECK(mock.replyData().find("\033[>0i") != std::string::npos); +} diff --git a/src/vtbackend/Image.cpp b/src/vtbackend/Image.cpp index 1adfd8c568..1f0ce9a456 100644 --- a/src/vtbackend/Image.cpp +++ b/src/vtbackend/Image.cpp @@ -150,6 +150,10 @@ namespace int paramHeight; int imageWidth; int imageHeight; + int subOffsetX; + int subOffsetY; + int subWidth; + int subHeight; std::vector const& imageData; uint32_t defaultColor; }; @@ -175,13 +179,16 @@ namespace if (simd::all_of(xInBounds)) { // Fully in bounds - auto const sourceXVec = simd::static_simd_cast( - (simd::static_simd_cast(globalXVec) - static_cast(context.xOffset)) - * static_cast(context.imageWidth) / static_cast(context.paramWidth)); + auto const sourceXVec = + context.subOffsetX + + simd::static_simd_cast( + (simd::static_simd_cast(globalXVec) - static_cast(context.xOffset)) + * static_cast(context.subWidth) / static_cast(context.paramWidth)); - auto const sourceY = static_cast((globalY - context.yOffset) - * static_cast(context.imageHeight) - / static_cast(context.paramHeight)); + auto const sourceY = context.subOffsetY + + static_cast((globalY - context.yOffset) + * static_cast(context.subHeight) + / static_cast(context.paramHeight)); for (int i = 0; i < SimdWidth; ++i) { @@ -213,12 +220,16 @@ namespace if (xInBounds[i]) { auto const globalX = context.cellX + x + i; - auto const sourceX = static_cast((globalX - context.xOffset) - * static_cast(context.imageWidth) - / static_cast(context.paramWidth)); - auto const sourceY = static_cast((globalY - context.yOffset) - * static_cast(context.imageHeight) - / static_cast(context.paramHeight)); + auto const sourceX = + context.subOffsetX + + static_cast((globalX - context.xOffset) + * static_cast(context.subWidth) + / static_cast(context.paramWidth)); + auto const sourceY = + context.subOffsetY + + static_cast((globalY - context.yOffset) + * static_cast(context.subHeight) + / static_cast(context.paramHeight)); auto const sourceIndex = (static_cast(sourceY) * static_cast(context.imageWidth) + static_cast(sourceX)) @@ -256,9 +267,17 @@ Image::Data RasterizedImage::fragment(CellLocation pos, ImageSize targetCellSize .height = Height::cast_from(unbox(_cellSpan.lines) * unbox(cellSize.height)), }; + // Use sub-region dimensions if specified, otherwise use full image. + auto const subWidth = + _imageSubSize.width.value > 0 ? unbox(_imageSubSize.width) : unbox(_image->width()); + auto const subHeight = + _imageSubSize.height.value > 0 ? unbox(_imageSubSize.height) : unbox(_image->height()); + auto const subOffsetX = static_cast(_imageOffset.x.value); + auto const subOffsetY = static_cast(_imageOffset.y.value); auto const imageWidth = unbox(_image->width()); auto const imageHeight = unbox(_image->height()); - auto const targetSize = computeTargetSize(_resizePolicy, _image->size(), gridSize); + auto const effectiveImageSize = ImageSize { Width(subWidth), Height(subHeight) }; + auto const targetSize = computeTargetSize(_resizePolicy, effectiveImageSize, gridSize); auto const paramWidth = unbox(targetSize.width); auto const paramHeight = unbox(targetSize.height); auto const [xOffset, yOffset] = computeTargetTopLeftOffset(_alignmentPolicy, targetSize, gridSize); @@ -281,6 +300,10 @@ Image::Data RasterizedImage::fragment(CellLocation pos, ImageSize targetCellSize .paramHeight = paramHeight, .imageWidth = imageWidth, .imageHeight = imageHeight, + .subOffsetX = subOffsetX, + .subOffsetY = subOffsetY, + .subWidth = subWidth, + .subHeight = subHeight, .imageData = _image->data(), .defaultColor = _defaultColor.value, }; @@ -303,10 +326,12 @@ Image::Data RasterizedImage::fragment(CellLocation pos, ImageSize targetCellSize auto const globalX = cellX + x; if (globalX >= xOffset && globalX < xOffset + paramWidth && yInBounds) { - auto const sourceX = static_cast((globalX - xOffset) * static_cast(imageWidth) - / static_cast(paramWidth)); - auto const sourceY = static_cast((globalY - yOffset) * static_cast(imageHeight) - / static_cast(paramHeight)); + auto const sourceX = subOffsetX + + static_cast((globalX - xOffset) * static_cast(subWidth) + / static_cast(paramWidth)); + auto const sourceY = subOffsetY + + static_cast((globalY - yOffset) * static_cast(subHeight) + / static_cast(paramHeight)); auto const sourceIndex = (static_cast(sourceY) * static_cast(imageWidth) + static_cast(sourceX)) * 4; @@ -337,15 +362,16 @@ shared_ptr rasterize(shared_ptr image, ImageResize resizePolicy, RGBAColor defaultColor, GridSize cellSpan, - ImageSize cellSize) + ImageSize cellSize, + ImageLayer layer) { return make_shared( - std::move(image), alignmentPolicy, resizePolicy, defaultColor, cellSpan, cellSize); + std::move(image), alignmentPolicy, resizePolicy, defaultColor, cellSpan, cellSize, layer); } -void ImagePool::link(string const& name, shared_ptr imageRef) +void ImagePool::link(string name, shared_ptr imageRef) { - _imageNameToImageCache.emplace(name, std::move(imageRef)); + _imageNameToImageCache.emplace(std::move(name), std::move(imageRef)); } shared_ptr ImagePool::findImageByName(string const& name) const noexcept diff --git a/src/vtbackend/Image.h b/src/vtbackend/Image.h index 2535a95d46..5199b58f06 100644 --- a/src/vtbackend/Image.h +++ b/src/vtbackend/Image.h @@ -26,6 +26,7 @@ enum class ImageFormat : uint8_t { RGB, RGBA, + PNG, }; // clang-format off @@ -86,6 +87,16 @@ class Image: public std::enable_shared_from_this OnImageRemove _onImageRemove; }; +/// Image layer determines the z-ordering of the image relative to text. +/// +/// @see GoodImageProtocol spec, parameter `L`. +enum class ImageLayer : uint8_t +{ + Below = 0, ///< Render below text (watermark-like). + Replace = 1, ///< Replace text cells (default, like Sixel). + Above = 2, ///< Render above text (overlay). +}; + /// Image resize hints are used to properly fit/fill the area to place the image onto. enum class ImageResize : uint8_t { @@ -122,13 +133,19 @@ class RasterizedImage: public std::enable_shared_from_this ImageResize resizePolicy, RGBAColor defaultColor, GridSize cellSpan, - ImageSize cellSize): + ImageSize cellSize, + ImageLayer layer = ImageLayer::Replace, + PixelCoordinate imageOffset = {}, + ImageSize imageSubSize = {}): _image { std::move(image) }, _alignmentPolicy { alignmentPolicy }, _resizePolicy { resizePolicy }, _defaultColor { defaultColor }, _cellSpan { cellSpan }, - _cellSize { cellSize } + _cellSize { cellSize }, + _layer { layer }, + _imageOffset { imageOffset }, + _imageSubSize { imageSubSize } { ++ImageStats::get().rasterized; } @@ -149,6 +166,7 @@ class RasterizedImage: public std::enable_shared_from_this RGBAColor defaultColor() const noexcept { return _defaultColor; } GridSize cellSpan() const noexcept { return _cellSpan; } ImageSize cellSize() const noexcept { return _cellSize; } + ImageLayer layer() const noexcept { return _layer; } /// @returns an RGBA buffer for a grid cell at given coordinate @p pos of the rasterized image. /// @@ -164,6 +182,9 @@ class RasterizedImage: public std::enable_shared_from_this RGBAColor _defaultColor; //!< Default color to be applied at corners when needed. GridSize _cellSpan; //!< Number of grid cells to span the pixel image onto. ImageSize _cellSize; //!< number of pixels in X and Y dimension one grid cell has to fill. + ImageLayer _layer; //!< Layer for z-ordering relative to text. + PixelCoordinate _imageOffset; //!< Pixel offset into the source image for sub-rectangle rendering. + ImageSize _imageSubSize; //!< Sub-region pixel size (zero means full image). }; std::shared_ptr rasterize(std::shared_ptr image, @@ -171,7 +192,8 @@ std::shared_ptr rasterize(std::shared_ptr image, ImageResize resizePolicy, RGBAColor defaultColor, GridSize cellSpan, - ImageSize cellSize); + ImageSize cellSize, + ImageLayer layer = ImageLayer::Replace); /// An ImageFragment holds a graphical image that ocupies one full grid cell. class ImageFragment @@ -214,6 +236,17 @@ namespace detail } using ImageFragmentId = boxed::boxed; +/// Returns true if the image fragment should be preserved when text is written to a cell. +/// +/// Below and Above layer images coexist with text; only Replace layer images are destroyed. +[[nodiscard]] inline bool shouldPreserveImageOnTextWrite( + std::shared_ptr const& fragment) noexcept +{ + if (!fragment) + return false; + return fragment->rasterizedImage().layer() != ImageLayer::Replace; +} + inline bool operator==(ImageFragment const& a, ImageFragment const& b) noexcept { return a.rasterizedImage().image().id() == b.rasterizedImage().image().id() && a.offset() == b.offset(); @@ -246,7 +279,7 @@ class ImagePool // named image access // - void link(std::string const& name, std::shared_ptr imageRef); + void link(std::string name, std::shared_ptr imageRef); [[nodiscard]] std::shared_ptr findImageByName(std::string const& name) const noexcept; void unlink(std::string const& name); @@ -279,6 +312,7 @@ struct std::formatter: formatter { case vtbackend::ImageFormat::RGB: name = "RGB"; break; case vtbackend::ImageFormat::RGBA: name = "RGBA"; break; + case vtbackend::ImageFormat::PNG: name = "PNG"; break; } return formatter::format(name, ctx); } @@ -317,6 +351,22 @@ struct std::formatter>: std::formatter +struct std::formatter: formatter +{ + auto format(vtbackend::ImageLayer value, auto& ctx) const + { + string_view name; + switch (value) + { + case vtbackend::ImageLayer::Below: name = "Below"; break; + case vtbackend::ImageLayer::Replace: name = "Replace"; break; + case vtbackend::ImageLayer::Above: name = "Above"; break; + } + return formatter::format(name, ctx); + } +}; + template <> struct std::formatter: formatter { diff --git a/src/vtbackend/MessageParser.cpp b/src/vtbackend/MessageParser.cpp new file mode 100644 index 0000000000..bc4d611fa4 --- /dev/null +++ b/src/vtbackend/MessageParser.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +namespace vtbackend +{ + +// XXX prominent use case: +// +// Good Image Protocol +// =================== +// +// DCS u format=N width=N height=N id=S pixmap=D +// DCS r id=S rows=N cols=N align=N? resize=N? [x=N y=N w=N h=N] reqStatus? +// DCS s rows=N cols=N align=N? resize=N? pixmap=D +// DCS d id=S + +void MessageParser::pass(char ch) +{ + switch (_state) + { + case State::ParamKey: + if (ch == ',') + flushHeader(); + else if (ch == ';') + _state = State::BodyStart; + else if (ch == '=') + _state = State::ParamValue; + else if (_parsedKey.size() < MaxKeyLength) + _parsedKey.push_back(ch); + break; + case State::ParamValue: + if (ch == ',') + { + flushHeader(); + _state = State::ParamKey; + } + else if (ch == ';') + _state = State::BodyStart; + else if (_parsedValue.size() < MaxValueLength) + _parsedValue.push_back(ch); + break; + case State::BodyStart: + flushHeader(); + // TODO: check if a transport-encoding header was specified and make use of that, + // so that the body directly contains decoded raw data. + _state = State::Body; + [[fallthrough]]; + case State::Body: + if (_body.size() < MaxBodyLength) + _body.push_back(static_cast(ch)); + // TODO: In order to avoid needless copies, I could pass the body incrementally back to the + // caller. + break; + } +} + +void MessageParser::flushHeader() +{ + bool const hasSpaceAvailable = _headers.size() < MaxParamCount || _headers.count(_parsedKey); + bool const isValidParameter = !_parsedKey.empty(); + + if (_parsedValue.size() > 1 && _parsedValue[0] == '!') + { + auto decoded = std::string {}; + decoded.resize(crispy::base64::decodeLength(next(begin(_parsedValue)), end(_parsedValue))); + auto const actualSize = + crispy::base64::decode(next(begin(_parsedValue)), end(_parsedValue), decoded.data()); + decoded.resize(actualSize); + _parsedValue = std::move(decoded); + } + + if (hasSpaceAvailable && isValidParameter) + _headers[std::move(_parsedKey)] = std::move(_parsedValue); + + _parsedKey.clear(); + _parsedValue.clear(); +} + +void MessageParser::finalize() +{ + switch (_state) + { + case State::ParamKey: + case State::ParamValue: flushHeader(); break; + case State::BodyStart: break; + case State::Body: + if (_body.size() > 1 && _body[0] == '!') + { + auto decoded = std::vector {}; + decoded.resize(crispy::base64::decodeLength(next(begin(_body)), end(_body))); + auto const actualSize = crispy::base64::decode( + next(begin(_body)), end(_body), reinterpret_cast(decoded.data())); + decoded.resize(actualSize); + _body = std::move(decoded); + } + break; + } + _finalizer(Message(std::move(_headers), std::move(_body))); +} + +Message MessageParser::parse(std::string_view range) +{ + Message m; + auto mp = MessageParser([&](Message&& message) { m = std::move(message); }); + mp.parseFragment(range); + mp.finalize(); + return m; +} + +} // namespace vtbackend diff --git a/src/vtbackend/MessageParser.h b/src/vtbackend/MessageParser.h new file mode 100644 index 0000000000..196f69cb54 --- /dev/null +++ b/src/vtbackend/MessageParser.h @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include + +namespace vtbackend +{ + +/** + * HTTP-like simple parametrized message object. + * + * A Message provides a zero or more unique header/value pairs and an optional message body. + */ +class Message +{ + public: + using HeaderMap = std::unordered_map; + using Data = std::vector; + + Message() = default; + Message(Message const&) = default; + Message(Message&&) = default; + Message& operator=(Message const&) = default; + Message& operator=(Message&&) = default; + + Message(HeaderMap headers, Data body): _headers { std::move(headers) }, _body { std::move(body) } {} + + HeaderMap const& headers() const noexcept { return _headers; } + HeaderMap& headers() noexcept { return _headers; } + + std::string const* header(std::string const& key) const noexcept + { + if (auto const i = _headers.find(key); i != _headers.end()) + return &i->second; + else + return nullptr; + } + + Data const& body() const noexcept { return _body; } + Data takeBody() noexcept { return std::move(_body); } + + private: + HeaderMap _headers; + Data _body; +}; + +/** + * MessageParser provides an API for parsing simple parametrized messages. + * + * The format is more simple than HTTP messages. + * You have a set of headers (key/value pairs)) and an optional body. + * + * Duplicate header names will override the previously declared ones. + * + * - Headers and body are separated by ';' + * - Header entries are separated by ',' + * - Header name and value is separated by '=' + * + * Therefore the header name must not contain any ';', ',', '=', + * and the parameter value must not contain any ';', ',', '!'. + * + * In order to allow arbitrary header values or body contents, + * it may be encoded using Base64. + * Base64-encoding is introduced with a leading exclamation mark (!). + * + * Examples: + * + * - "first=Foo,second=Bar;some body here" + * - ",first=Foo,second,,,another=value,also=;some body here" + * - "message=!SGVsbG8gV29ybGQ=" (no body, only one Base64 encoded header) + * - ";!SGVsbG8gV29ybGQ=" (no headers, only one Base64 encoded body) + */ +class MessageParser: public ParserExtension +{ + public: + constexpr static inline size_t MaxKeyLength = 64; + constexpr static inline size_t MaxValueLength = 512; + constexpr static inline size_t MaxParamCount = 32; + constexpr static inline size_t MaxBodyLength = 16 * 1024 * 1024; // 16 MB + + using OnFinalize = std::function; + + explicit MessageParser(OnFinalize finalizer = {}): _finalizer { std::move(finalizer) } {} + + void parseFragment(std::string_view chars) + { + for (char const ch: chars) + pass(ch); + } + + static Message parse(std::string_view range); + + // ParserExtension overrides + // + void pass(char ch) override; + void finalize() override; + + private: + void flushHeader(); + + enum class State : std::uint8_t + { + ParamKey, + ParamValue, + BodyStart, + Body, + }; + + State _state = State::ParamKey; + std::string _parsedKey; + std::string _parsedValue; + + OnFinalize _finalizer; + + Message::HeaderMap _headers; + Message::Data _body; +}; + +} // namespace vtbackend diff --git a/src/vtbackend/MessageParser_test.cpp b/src/vtbackend/MessageParser_test.cpp new file mode 100644 index 0000000000..cb8daa1359 --- /dev/null +++ b/src/vtbackend/MessageParser_test.cpp @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include + +#include +#include + +using vtbackend::MessageParser; +using namespace std::string_view_literals; + +TEST_CASE("MessageParser.empty", "[MessageParser]") +{ + auto const m = MessageParser::parse(""); + CHECK(m.body().size() == 0); + CHECK(m.headers().size() == 0); +} + +TEST_CASE("MessageParser.headers.one", "[MessageParser]") +{ + SECTION("without value") + { + auto const m = MessageParser::parse("name="); + REQUIRE(!!m.header("name")); + CHECK(*m.header("name") == ""); + } + SECTION("with value") + { + auto const m = MessageParser::parse("name=value"); + CHECK(m.header("name")); + CHECK(*m.header("name") == "value"); + } +} + +TEST_CASE("MessageParser.header.base64") +{ + auto const m = MessageParser::parse(std::format("name=!{}", crispy::base64::encode("\033\0\x07"sv))); + CHECK(m.header("name")); + CHECK(*m.header("name") == "\033\0\x07"sv); +} + +TEST_CASE("MessageParser.headers.many", "[MessageParser]") +{ + SECTION("without value") + { + auto const m = MessageParser::parse("name=,name2="); + CHECK(m.body().size() == 0); + REQUIRE(!!m.header("name")); + REQUIRE(!!m.header("name2")); + CHECK(m.header("name")->empty()); + CHECK(m.header("name2")->empty()); + } + SECTION("with value") + { + auto const m = MessageParser::parse("name=value,name2=other"); + CHECK(m.body().size() == 0); + REQUIRE(!!m.header("name")); + REQUIRE(!!m.header("name2")); + CHECK(*m.header("name") == "value"); + CHECK(*m.header("name2") == "other"); + } + SECTION("mixed value 1") + { + auto const m = MessageParser::parse("name=,name2=other"); + CHECK(m.body().size() == 0); + REQUIRE(!!m.header("name")); + REQUIRE(!!m.header("name2")); + CHECK(*m.header("name") == ""); + CHECK(*m.header("name2") == "other"); + } + SECTION("mixed value 2") + { + auto const m = MessageParser::parse("name=some,name2="); + CHECK(m.body().size() == 0); + REQUIRE(!!m.header("name")); + REQUIRE(!!m.header("name2")); + CHECK(*m.header("name") == "some"); + CHECK(*m.header("name2") == ""); + } + + SECTION("superfluous comma 1") + { + auto const m = MessageParser::parse(",foo=text,,,bar=other,"); + CHECK(m.headers().size() == 2); + REQUIRE(!!m.header("foo")); + REQUIRE(!!m.header("bar")); + CHECK(*m.header("foo") == "text"); + CHECK(*m.header("bar") == "other"); + } + + SECTION("superfluous comma many") + { + auto const m = MessageParser::parse(",,,foo=text,,,bar=other,,,"); + CHECK(m.headers().size() == 2); + REQUIRE(m.header("foo")); + REQUIRE(m.header("bar")); + CHECK(*m.header("foo") == "text"); + CHECK(*m.header("bar") == "other"); + } +} + +TEST_CASE("MessageParser.body", "[MessageParser]") +{ + SECTION("empty body") + { + auto const m = MessageParser::parse(";"); + CHECK(m.headers().size() == 0); + CHECK(m.body().size() == 0); + } + + SECTION("simple body") + { + auto const m = MessageParser::parse(";foo"); + CHECK(m.headers().size() == 0); + CHECK(m.body() == std::vector { 'f', 'o', 'o' }); + } + + SECTION("headers and body") + { + auto const m = MessageParser::parse("a=A,bee=eeeh;foo"); + CHECK(m.body() == std::vector { 'f', 'o', 'o' }); + REQUIRE(m.header("a")); + REQUIRE(m.header("bee")); + CHECK(*m.header("a") == "A"); + CHECK(*m.header("bee") == "eeeh"); + } + + SECTION("binary body") + { // ESC \x1b \033 + auto const m = MessageParser::parse("a=A,bee=eeeh;\0\x1b\xff"sv); + CHECK(m.body() == std::vector { 0x00, 0x1b, 0xff }); + REQUIRE(!!m.header("a")); + REQUIRE(m.header("bee")); + CHECK(*m.header("a") == "A"); + CHECK(*m.header("bee") == "eeeh"); + } +} + +class MessageParserTest: public vtparser::NullParserEvents +{ + private: + std::unique_ptr _parserExtension; + + public: + vtbackend::Message message; + + void hook(char) override + { + _parserExtension = + std::make_unique([&](vtbackend::Message&& msg) { message = std::move(msg); }); + } + + void put(char ch) override + { + if (_parserExtension) + _parserExtension->pass(ch); + } + + void unhook() override + { + if (_parserExtension) + { + _parserExtension->finalize(); + _parserExtension.reset(); + } + } +}; + +TEST_CASE("MessageParser.VT_embedded") +{ + auto vtEvents = MessageParserTest {}; + auto vtParser = vtparser::Parser { vtEvents }; + + vtParser.parseFragment(std::format("\033Pxa=foo,b=bar;!{}\033\\", crispy::base64::encode("abc"))); + + REQUIRE(!!vtEvents.message.header("a")); + REQUIRE(!!vtEvents.message.header("b")); + CHECK(*vtEvents.message.header("a") == "foo"); + CHECK(*vtEvents.message.header("b") == "bar"); + CHECK(vtEvents.message.body() == std::vector { 'a', 'b', 'c' }); +} diff --git a/src/vtbackend/Screen.cpp b/src/vtbackend/Screen.cpp index 3777531044..c4b18caea3 100644 --- a/src/vtbackend/Screen.cpp +++ b/src/vtbackend/Screen.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -268,7 +270,107 @@ namespace // {{{ helper // return nullopt; // } // } + + /// Parses a non-negative decimal integer from a string pointer. + /// Returns std::nullopt if the pointer is null, the string contains non-digit characters, + /// or the value would overflow an int. + std::optional toNumber(string const* value) + { + if (!value || value->empty()) + return std::nullopt; + + int result = 0; + for (char const ch: *value) + { + if (ch < '0' || ch > '9') + return std::nullopt; + if (result > (std::numeric_limits::max() - (ch - '0')) / 10) + return std::nullopt; // overflow + result = result * 10 + (ch - '0'); + } + + return result; + } + + optional toImageAlignmentPolicy(string const* value, ImageAlignment defaultValue) + { + if (!value) + return defaultValue; + + if (value->size() != 1) + return nullopt; + + switch (value->at(0)) + { + case '1': return ImageAlignment::TopStart; + case '2': return ImageAlignment::TopCenter; + case '3': return ImageAlignment::TopEnd; + case '4': return ImageAlignment::MiddleStart; + case '5': return ImageAlignment::MiddleCenter; + case '6': return ImageAlignment::MiddleEnd; + case '7': return ImageAlignment::BottomStart; + case '8': return ImageAlignment::BottomCenter; + case '9': return ImageAlignment::BottomEnd; + default: return nullopt; + } + } + + optional toImageResizePolicy(string const* value, ImageResize defaultValue) + { + if (!value) + return defaultValue; + + if (value->size() != 1) + return nullopt; + + switch (value->at(0)) + { + case '0': return ImageResize::NoResize; + case '1': return ImageResize::ResizeToFit; + case '2': return ImageResize::ResizeToFill; + case '3': return ImageResize::StretchToFill; + default: return nullopt; + } + } + + optional toImageFormat(string const* value) + { + auto constexpr DefaultFormat = ImageFormat::RGB; + + if (value) + { + if (value->size() == 1) + { + switch (value->at(0)) + { + case '1': return ImageFormat::RGB; + case '2': return ImageFormat::RGBA; + case '3': return ImageFormat::PNG; + default: return nullopt; + } + } + else + return nullopt; + } + else + return DefaultFormat; + } + + ImageLayer toImageLayer(string const* value) + { + if (!value || value->empty()) + return ImageLayer::Replace; + + switch (value->at(0)) + { + case '0': return ImageLayer::Below; + case '1': return ImageLayer::Replace; + case '2': return ImageLayer::Above; + default: return ImageLayer::Replace; + } + } } // namespace + // }}} Screen::~Screen() = default; @@ -1054,7 +1156,8 @@ void Screen::sendDeviceAttributes() // TODO: DeviceAttributes::SelectiveErase | DeviceAttributes::SixelGraphics | // TODO: DeviceAttributes::TechnicalCharacters | - DeviceAttributes::UserDefinedKeys | DeviceAttributes::ClipboardExtension); + DeviceAttributes::UserDefinedKeys | DeviceAttributes::ClipboardExtension + | DeviceAttributes::GoodImageProtocol); reply("\033[?{};{}c", id, attrs); } @@ -2022,51 +2125,68 @@ void Screen::renderImage(shared_ptr image, ImageSize imageSize, ImageAlignment alignmentPolicy, ImageResize resizePolicy, - bool autoScroll) + bool autoScroll, + bool updateCursor, + ImageLayer layer) { - // TODO: make use of imageOffset - (void) imageOffset; - auto const linesAvailable = pageSize().lines - topLeft.line.as(); auto const linesToBeRendered = std::min(gridSize.lines, linesAvailable); auto const columnsAvailable = pageSize().columns - topLeft.column; auto const columnsToBeRendered = ColumnCount(std::min(columnsAvailable, gridSize.columns)); - auto const gapColor = RGBAColor {}; // TODO: _cursor.graphicsRendition.backgroundColor; - - // TODO: make use of imageOffset and imageSize - auto const rasterizedImage = make_shared( - std::move(image), alignmentPolicy, resizePolicy, gapColor, gridSize, _terminal->cellPixelSize()); - const auto lastSixelBand = unbox(imageSize.height) % 6; - const LineOffset offset = [&]() { - auto offset = LineOffset::cast_from(std::ceil((imageSize.height - lastSixelBand).as() - / _terminal->cellPixelSize().height.as())) - - 1 * (lastSixelBand == 0); - auto const h = unbox(imageSize.height) - 1; - // VT340 has this behavior where for some heights it text cursor is placed not - // at the final sixel line but a line above it. - // See - // https://github.com/hackerb9/vt340test/blob/main/glitches.md#text-cursor-is-left-one-row-too-high-for-certain-sixel-heights - if (h % 6 > h % unbox(_terminal->cellPixelSize().height)) - return offset - 1; - return offset; + auto const gapColor = RGBAColor { vtbackend::apply(_terminal->colorPalette(), + _cursor.graphicsRendition.backgroundColor, + ColorTarget::Background, + ColorMode::Normal) }; + + auto const rasterizedImage = make_shared(std::move(image), + alignmentPolicy, + resizePolicy, + gapColor, + gridSize, + _terminal->cellPixelSize(), + layer, + imageOffset, + imageSize); + + // Compute the cursor line offset after rendering. + // For Sixel images (identified by non-zero pixel imageSize), use VT340-compatible sixel band math. + // For GIP images, simply place the cursor at the last row of the rendered area. + auto const isSixelImage = imageSize.width.value > 0 && imageSize.height.value > 0; + auto const cursorLineOffset = [&]() -> LineOffset { + if (isSixelImage) + { + auto const lastSixelBand = unbox(imageSize.height) % 6; + auto lineOffset = + LineOffset::cast_from(std::ceil((imageSize.height - lastSixelBand).as() + / _terminal->cellPixelSize().height.as())) + - 1 * (lastSixelBand == 0); + auto const h = unbox(imageSize.height) - 1; + // VT340 quirk: for some heights the text cursor is placed one row too high. + // See: https://github.com/hackerb9/vt340test/blob/main/glitches.md + if (h % 6 > h % unbox(_terminal->cellPixelSize().height)) + lineOffset = lineOffset - 1; + return lineOffset; + } + // GIP: cursor goes to the last row of the rendered image area. + return boxed_cast(linesToBeRendered) - 1; }(); if (unbox(linesToBeRendered)) { - for (GridSize::Offset const offset: + for (GridSize::Offset const gridOffset: GridSize { .lines = linesToBeRendered, .columns = columnsToBeRendered }) { - auto cell = at(topLeft + offset); + auto cell = at(topLeft + gridOffset); cell.setImageFragment(rasterizedImage, - CellLocation { .line = offset.line, .column = offset.column }); + CellLocation { .line = gridOffset.line, .column = gridOffset.column }); cell.setHyperlink(_cursor.hyperlink); }; - moveCursorTo(topLeft.line + offset, topLeft.column); + if (updateCursor) + moveCursorTo(topLeft.line + cursorLineOffset, topLeft.column); } - // If there're lines to be rendered missing (because it didn't fit onto the screen just yet) - // AND iff sixel !sixelScrolling is enabled, then scroll as much as needed to render the remaining - // lines. + // If there are lines remaining (image didn't fit on screen) and autoScroll is enabled, + // scroll content up and render the remaining lines. if (linesToBeRendered != gridSize.lines && autoScroll) { auto const remainingLineCount = gridSize.lines - linesToBeRendered; @@ -2075,17 +2195,19 @@ void Screen::renderImage(shared_ptr image, linefeed(topLeft.column); for (auto const columnOffset: crispy::views::iota_as(*columnsToBeRendered)) { - auto const offset = + auto const fragOffset = CellLocation { .line = boxed_cast(linesToBeRendered) + lineOffset, .column = columnOffset }; auto cell = at(boxed_cast(pageSize().lines) - 1, topLeft.column + columnOffset); - cell.setImageFragment(rasterizedImage, offset); + cell.setImageFragment(rasterizedImage, fragOffset); cell.setHyperlink(_cursor.hyperlink); }; } } - // move ansi text cursor to position of the sixel cursor - moveCursorToColumn(topLeft.column); + + // Move ANSI text cursor to the correct column after image placement. + if (updateCursor) + moveCursorToColumn(topLeft.column); } void Screen::requestDynamicColor(DynamicColorName name) @@ -4383,6 +4505,11 @@ ApplyResult Screen::apply(Function const& function, Sequence const& seq) case STP: _terminal->hookParser(hookSTP(seq)); break; case DECRQSS: _terminal->hookParser(hookDECRQSS(seq)); break; case XTGETTCAP: _terminal->hookParser(hookXTGETTCAP(seq)); break; + case GIUPLOAD: _terminal->hookParser(hookGoodImageUpload(seq)); break; + case GIRENDER: _terminal->hookParser(hookGoodImageRender(seq)); break; + case GIDELETE: _terminal->hookParser(hookGoodImageRelease(seq)); break; + case GIONESHOT: _terminal->hookParser(hookGoodImageOneshot(seq)); break; + case GIQUERY: _terminal->hookParser(hookGoodImageQuery(seq)); break; default: return ApplyResult::Unsupported; } @@ -4593,4 +4720,278 @@ bool Screen::isCursorInsideMargins() const noexcept return insideVerticalMargin && insideHorizontalMargin; } +unique_ptr Screen::hookGoodImageUpload(Sequence const&) +{ + return make_unique([this](Message message) { + auto const* const name = message.header("n"); + auto const imageFormat = toImageFormat(message.header("f")); + auto const width = Width::cast_from(toNumber(message.header("w")).value_or(0)); + auto const height = Height::cast_from(toNumber(message.header("h")).value_or(0)); + auto const size = ImageSize { width, height }; + auto const requestStatus = message.header("s") != nullptr; + + // Validate name: ASCII alphanumeric + underscore, 1-512 chars. + auto const validName = [&]() -> bool { + if (!name || name->empty() || name->size() > 512) + return false; + return std::ranges::all_of(*name, [](char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') + || ch == '_'; + }); + }(); + + bool const validImage = imageFormat.has_value() + && ((*imageFormat == ImageFormat::PNG && !*size.width && !*size.height) + || (*imageFormat != ImageFormat::PNG && *size.width && *size.height)); + + // Validate RGB/RGBA data size: body must equal width * height * bytes-per-pixel. + auto const validDataSize = [&]() -> bool { + if (!validImage || !imageFormat.has_value()) + return false; + if (*imageFormat == ImageFormat::PNG) + return true; // PNG is self-describing + auto const bpp = (*imageFormat == ImageFormat::RGBA) ? 4u : 3u; + auto const expectedSize = static_cast(*size.width) * *size.height * bpp; + return message.body().size() == expectedSize; + }(); + + if (validName && validImage && validDataSize) + { + uploadImage(*name, imageFormat.value(), size, message.takeBody()); + if (requestStatus) + { + _terminal->reply("\033[>0i"); + _terminal->flushInput(); + } + } + else if (requestStatus) + { + // Error code 2: invalid image data (bad format, corrupt data, dimension mismatch) + _terminal->reply("\033[>2i"); + _terminal->flushInput(); + } + }); +} + +unique_ptr Screen::hookGoodImageRender(Sequence const&) +{ + return make_unique([this](Message const& message) { + auto const* const name = message.header("n"); + auto const x = PixelCoordinate::X { toNumber(message.header("x")).value_or(0) }; // XXX grid x offset + auto const y = PixelCoordinate::Y { toNumber(message.header("y")).value_or(0) }; // XXX grid y offset + auto const screenRows = LineCount::cast_from(toNumber(message.header("r")).value_or(0)); + auto const screenCols = ColumnCount::cast_from(toNumber(message.header("c")).value_or(0)); + auto const imageWidth = + Width::cast_from(toNumber(message.header("w")).value_or(0)); // XXX in grid coords + auto const imageHeight = + Height::cast_from(toNumber(message.header("h")).value_or(0)); // XXX in grid coords + auto const alignmentPolicy = + toImageAlignmentPolicy(message.header("a"), ImageAlignment::MiddleCenter); + auto const resizePolicy = toImageResizePolicy(message.header("z"), ImageResize::NoResize); + auto const requestStatus = message.header("s") != nullptr; + auto const autoScroll = message.header("l") != nullptr; + auto const updateCursor = message.header("u") != nullptr; + auto const layer = toImageLayer(message.header("L")); + + auto const imageOffset = PixelCoordinate { .x = x, .y = y }; + auto const imageSize = ImageSize { imageWidth, imageHeight }; + auto const screenExtent = GridSize { .lines = screenRows, .columns = screenCols }; + + renderImageByName(name ? *name : "", + screenExtent, + imageOffset, + imageSize, + alignmentPolicy.value_or(ImageAlignment::MiddleCenter), + resizePolicy.value_or(ImageResize::NoResize), + autoScroll, + requestStatus, + updateCursor, + layer); + }); +} + +unique_ptr Screen::hookGoodImageRelease(Sequence const&) +{ + return make_unique([this](Message const& message) { + if (auto const* const name = message.header("n"); name) + releaseImage(*name); + }); +} + +unique_ptr Screen::hookGoodImageQuery(Sequence const&) +{ + // DCS q ST -> respond with CSI > 8 ; Pm ; Pb ; Pw ; Ph i + return make_unique([this](Message const& /*message*/) { + auto constexpr MaxImages = 100; // matches ImagePool LRU capacity + auto const maxSize = _terminal->maxImageSize(); + auto const maxBytes = + static_cast(*maxSize.width) * *maxSize.height * 4; // RGBA = 4 bytes per pixel + _terminal->reply("\033[>8;{};{};{};{}i", MaxImages, maxBytes, *maxSize.width, *maxSize.height); + _terminal->flushInput(); + }); +} + +unique_ptr Screen::hookGoodImageOneshot(Sequence const&) +{ + return make_unique([this](Message message) { + auto const screenRows = LineCount::cast_from(toNumber(message.header("r")).value_or(0)); + auto const screenCols = ColumnCount::cast_from(toNumber(message.header("c")).value_or(0)); + auto const autoScroll = message.header("l") != nullptr; + auto const updateCursor = message.header("u") != nullptr; + auto const requestStatus = message.header("s") != nullptr; + auto const layer = toImageLayer(message.header("L")); + auto const alignmentPolicy = + toImageAlignmentPolicy(message.header("a"), ImageAlignment::MiddleCenter); + auto const resizePolicy = toImageResizePolicy(message.header("z"), ImageResize::NoResize); + auto const imageWidth = Width::cast_from(toNumber(message.header("w")).value_or(0)); + auto const imageHeight = Height::cast_from(toNumber(message.header("h")).value_or(0)); + auto const imageFormat = toImageFormat(message.header("f")); + + auto const imageSize = ImageSize { imageWidth, imageHeight }; + auto const screenExtent = GridSize { .lines = screenRows, .columns = screenCols }; + + renderImage(imageFormat.value_or(ImageFormat::RGB), + imageSize, + message.takeBody(), + screenExtent, + alignmentPolicy.value_or(ImageAlignment::MiddleCenter), + resizePolicy.value_or(ImageResize::NoResize), + autoScroll, + updateCursor, + layer); + + if (requestStatus) + { + _terminal->reply("\033[>0i"); + _terminal->flushInput(); + } + }); +} + +void Screen::uploadImage(string name, ImageFormat format, ImageSize imageSize, Image::Data&& pixmap) +{ + // For PNG format, decode to RGBA using the image decoder callback. + if (format == ImageFormat::PNG && _terminal->imageDecoder()) + { + auto decodedSize = imageSize; + auto decodedData = _terminal->imageDecoder()(format, std::span(pixmap), decodedSize); + if (decodedData) + { + _terminal->imagePool().link(std::move(name), + uploadImage(ImageFormat::RGBA, decodedSize, std::move(*decodedData))); + } + else + errorLog()("Failed to decode PNG image for upload."); + return; + } + + _terminal->imagePool().link(std::move(name), uploadImage(format, imageSize, std::move(pixmap))); +} + +void Screen::renderImageByName(std::string const& name, + GridSize gridSize, + PixelCoordinate imageOffset, + ImageSize imageSize, + ImageAlignment alignmentPolicy, + ImageResize resizePolicy, + bool autoScroll, + bool requestStatus, + bool updateCursor, + ImageLayer layer) +{ + auto const imageRef = _terminal->imagePool().findImageByName(name); + auto const topLeft = _cursor.position; + + if (imageRef) + renderImage(imageRef, + topLeft, + gridSize, + imageOffset, + imageSize, + alignmentPolicy, + resizePolicy, + autoScroll, + updateCursor, + layer); + + if (requestStatus) + { + _terminal->reply("\033[>{}i", imageRef ? 0 : 1); + _terminal->flushInput(); + } +} + +void Screen::renderImage(ImageFormat format, + ImageSize imageSize, + Image::Data&& pixmap, + GridSize gridSize, + ImageAlignment alignmentPolicy, + ImageResize resizePolicy, + bool autoScroll, + bool updateCursor, + ImageLayer layer) +{ + auto constexpr PixelOffset = PixelCoordinate {}; + auto constexpr PixelSize = ImageSize {}; + + auto const topLeft = _cursor.position; + + // Helper to compute grid size from pixel dimensions when the sender specified {0, 0}. + auto const computeGridSize = [&](ImageSize decodedPixelSize) -> GridSize { + if (*gridSize.lines && *gridSize.columns) + return gridSize; + auto const cellSize = _terminal->cellPixelSize(); + auto const columns = ColumnCount::cast_from( + std::ceil(decodedPixelSize.width.as() / cellSize.width.as())); + auto const lines = LineCount::cast_from( + std::ceil(decodedPixelSize.height.as() / cellSize.height.as())); + return GridSize { .lines = *gridSize.lines ? gridSize.lines : lines, + .columns = *gridSize.columns ? gridSize.columns : columns }; + }; + + // For PNG format, decode to RGBA using the image decoder callback. + if (format == ImageFormat::PNG && _terminal->imageDecoder()) + { + auto decodedSize = imageSize; + auto decodedData = _terminal->imageDecoder()(format, std::span(pixmap), decodedSize); + if (decodedData) + { + auto const effectiveGridSize = computeGridSize(decodedSize); + auto const imageRef = uploadImage(ImageFormat::RGBA, decodedSize, std::move(*decodedData)); + renderImage(imageRef, + topLeft, + effectiveGridSize, + PixelOffset, + PixelSize, + alignmentPolicy, + resizePolicy, + autoScroll, + updateCursor, + layer); + } + else + errorLog()("Failed to decode PNG image for oneshot render."); + return; + } + + auto const effectiveGridSize = computeGridSize(imageSize); + auto const imageRef = uploadImage(format, imageSize, std::move(pixmap)); + + renderImage(imageRef, + topLeft, + effectiveGridSize, + PixelOffset, + PixelSize, + alignmentPolicy, + resizePolicy, + autoScroll, + updateCursor, + layer); +} + +void Screen::releaseImage(std::string const& name) +{ + _terminal->imagePool().unlink(name); +} + } // namespace vtbackend diff --git a/src/vtbackend/Screen.h b/src/vtbackend/Screen.h index 5228eee46a..22b86c7674 100644 --- a/src/vtbackend/Screen.h +++ b/src/vtbackend/Screen.h @@ -312,6 +312,8 @@ class Screen final: public ScreenBase, public capabilities::StaticDatabase std::shared_ptr uploadImage(ImageFormat format, ImageSize imageSize, Image::Data&& pixmap); + void uploadImage(std::string name, ImageFormat format, ImageSize imageSize, Image::Data&& pixmap); + /** * Renders an image onto the screen. * @@ -332,7 +334,32 @@ class Screen final: public ScreenBase, public capabilities::StaticDatabase ImageSize imageSize, ImageAlignment alignmentPolicy, ImageResize resizePolicy, - bool autoScroll); + bool autoScroll, + bool updateCursor = true, + ImageLayer layer = ImageLayer::Replace); + + void renderImageByName(std::string const& name, + GridSize gridSize, + PixelCoordinate imageOffset, + ImageSize imageSize, + ImageAlignment alignmentPolicy, + ImageResize resizePolicy, + bool autoScroll, + bool requestStatus, + bool updateCursor = false, + ImageLayer layer = ImageLayer::Replace); + + void renderImage(ImageFormat format, + ImageSize imageSize, + Image::Data&& pixmap, + GridSize gridSize, + ImageAlignment alignmentPolicy, + ImageResize resizePolicy, + bool autoScroll, + bool updateCursor = false, + ImageLayer layer = ImageLayer::Replace); + + void releaseImage(std::string const& name); void inspect(std::string const& message, std::ostream& os) const override; @@ -664,6 +691,12 @@ class Screen final: public ScreenBase, public capabilities::StaticDatabase [[nodiscard]] std::unique_ptr hookDECRQSS(Sequence const& seq); [[nodiscard]] std::unique_ptr hookXTGETTCAP(Sequence const& seq); + [[nodiscard]] std::unique_ptr hookGoodImageUpload(Sequence const& seq); + [[nodiscard]] std::unique_ptr hookGoodImageRender(Sequence const& seq); + [[nodiscard]] std::unique_ptr hookGoodImageRelease(Sequence const& seq); + [[nodiscard]] std::unique_ptr hookGoodImageOneshot(Sequence const& seq); + [[nodiscard]] std::unique_ptr hookGoodImageQuery(Sequence const& seq); + void processShellIntegration(Sequence const& seq); void handleSemanticBlockQuery(Sequence const& seq); void handleInProgressQuery(SemanticBlockTracker const& tracker); diff --git a/src/vtbackend/SoAClusterWriter.cpp b/src/vtbackend/SoAClusterWriter.cpp index 9e2090aa56..e386417ece 100644 --- a/src/vtbackend/SoAClusterWriter.cpp +++ b/src/vtbackend/SoAClusterWriter.cpp @@ -54,6 +54,21 @@ namespace std::fill_n(line.hyperlinks.data() + startCol, count, hyperlink); } + // Clear Replace-layer image fragments in the overwritten range. + // Below and Above layer images coexist with text per the GIP spec. + if (line.imageFragments) + { + for (size_t i = 0; i < count; ++i) + { + auto const key = static_cast(startCol + i); + if (auto const it = line.imageFragments->find(key); it != line.imageFragments->end()) + { + if (!shouldPreserveImageOnTextWrite(it->second)) + line.imageFragments->erase(it); + } + } + } + // Trivial flag: all cells got the same SGR. Check once against cell 0. if (line.trivial && startCol > 0 && (attrs != line.sgr[0] || hyperlink != line.hyperlinks[0])) { diff --git a/src/vtbackend/SoAClusterWriter.h b/src/vtbackend/SoAClusterWriter.h index 0bfeaed75f..dbe97c0a73 100644 --- a/src/vtbackend/SoAClusterWriter.h +++ b/src/vtbackend/SoAClusterWriter.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -63,6 +64,18 @@ inline void writeCellToSoA(LineSoA& line, if (oldClusterSize > 1) clearClusterExtras(line, col); + // Writing text into a cell destroys Replace-layer images (Sixel-compatible behavior). + // Below and Above layer images coexist with text per the GIP spec. + if (line.imageFragments) + { + auto const key = static_cast(col); + if (auto const it = line.imageFragments->find(key); it != line.imageFragments->end()) + { + if (!shouldPreserveImageOnTextWrite(it->second)) + line.imageFragments->erase(it); + } + } + // Invalidate trivial flag if this cell's SGR or hyperlink differs from the first cell's. if (line.trivial && col > 0 && (line.sgr[col] != line.sgr[0] || line.hyperlinks[col] != line.hyperlinks[0])) diff --git a/src/vtbackend/Terminal.h b/src/vtbackend/Terminal.h index e4abebdf5d..d8d189f554 100644 --- a/src/vtbackend/Terminal.h +++ b/src/vtbackend/Terminal.h @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -1174,6 +1175,19 @@ class Terminal ImagePool& imagePool() noexcept { return _imagePool; } ImagePool const& imagePool() const noexcept { return _imagePool; } + /// Callback for decoding encoded images (e.g. PNG) to RGBA pixel data. + /// + /// @param format The source image format. + /// @param data The raw encoded image data. + /// @param size [in/out] For PNG: the size is extracted during decoding. + /// For RGB/RGBA: the size is already known. + /// @returns Decoded RGBA pixel data, or std::nullopt on failure. + using ImageDecoderCallback = std::function( + ImageFormat format, std::span data, ImageSize& size)>; + + void setImageDecoder(ImageDecoderCallback decoder) noexcept { _imageDecoder = std::move(decoder); } + ImageDecoderCallback const& imageDecoder() const noexcept { return _imageDecoder; } + bool syncWindowTitleWithHostWritableStatusDisplay() const noexcept { return _syncWindowTitleWithHostWritableStatusDisplay; @@ -1472,6 +1486,7 @@ class Terminal ImageSize _effectiveImageCanvasSize; std::shared_ptr _sixelColorPalette; ImagePool _imagePool; + ImageDecoderCallback _imageDecoder; std::vector _tabs; diff --git a/src/vtbackend/VTType.cpp b/src/vtbackend/VTType.cpp index f7dce8656c..d00f5f8de2 100644 --- a/src/vtbackend/VTType.cpp +++ b/src/vtbackend/VTType.cpp @@ -32,6 +32,7 @@ string to_string(DeviceAttributes v) pair { DeviceAttributes::UserDefinedKeys, "UserDefinedKeys" }, pair { DeviceAttributes::Windowing, "Windowing" }, pair { DeviceAttributes::ClipboardExtension, "ClipboardExtension" }, + pair { DeviceAttributes::GoodImageProtocol, "GoodImageProtocol" }, }; for (auto const& mapping: Mappings) @@ -65,6 +66,7 @@ string to_params(DeviceAttributes v) pair { DeviceAttributes::UserDefinedKeys, "8" }, pair { DeviceAttributes::Windowing, "18" }, pair { DeviceAttributes::ClipboardExtension, "52" }, + pair { DeviceAttributes::GoodImageProtocol, "11" }, }; for (auto const& mapping: Mappings) diff --git a/src/vtbackend/VTType.h b/src/vtbackend/VTType.h index 0fae79c255..ba8a401727 100644 --- a/src/vtbackend/VTType.h +++ b/src/vtbackend/VTType.h @@ -58,6 +58,7 @@ enum class DeviceAttributes : uint16_t Windowing = (1 << 10), CaptureScreenBuffer = (1 << 11), ClipboardExtension = (1 << 12), + GoodImageProtocol = (1 << 13), }; constexpr DeviceAttributes operator|(DeviceAttributes a, DeviceAttributes b) diff --git a/src/vtrasterizer/ImageRenderer.cpp b/src/vtrasterizer/ImageRenderer.cpp index 0c1f3fde73..d41b2f7dd0 100644 --- a/src/vtrasterizer/ImageRenderer.cpp +++ b/src/vtrasterizer/ImageRenderer.cpp @@ -36,22 +36,29 @@ void ImageRenderer::setCellSize(ImageSize cellSize) void ImageRenderer::renderImage(crispy::point pos, vtbackend::ImageFragment const& fragment) { - // std::cout << std::format("ImageRenderer.renderImage: {}\n", fragment); - AtlasTileAttributes const* tileAttributes = getOrCreateCachedTileAttributes(fragment); if (!tileAttributes) return; - // clang-format off - _pendingRenderTilesAboveText.emplace_back(createRenderTile(atlas::RenderTile::X { pos.x }, - atlas::RenderTile::Y { pos.y }, - vtbackend::RGBAColor::White, *tileAttributes)); - // clang-format on + auto const tile = createRenderTile(atlas::RenderTile::X { pos.x }, + atlas::RenderTile::Y { pos.y }, + vtbackend::RGBAColor::White, + *tileAttributes); + + // Route to below-text or above-text queue based on the image layer. + auto const layer = fragment.rasterizedImage().layer(); + if (layer == vtbackend::ImageLayer::Below) + _pendingRenderTilesBelowText.emplace_back(tile); + else + _pendingRenderTilesAboveText.emplace_back(tile); } void ImageRenderer::onBeforeRenderingText() { - // We could render here the images that should go below text. + // Render images that should go below text (ImageLayer::Below). + for (auto const& tile: _pendingRenderTilesBelowText) + textureScheduler().renderTile(tile); + _pendingRenderTilesBelowText.clear(); } void ImageRenderer::onAfterRenderingText() @@ -66,17 +73,24 @@ void ImageRenderer::onAfterRenderingText() void ImageRenderer::beginFrame() { + if (!SoftRequire(_pendingRenderTilesBelowText.empty())) + _pendingRenderTilesBelowText.clear(); if (!SoftRequire(_pendingRenderTilesAboveText.empty())) _pendingRenderTilesAboveText.clear(); } void ImageRenderer::endFrame() { + // Flush any remaining tiles that were not rendered during the text pass. + if (!_pendingRenderTilesBelowText.empty()) + { + for (auto const& tile: _pendingRenderTilesBelowText) + textureScheduler().renderTile(tile); + _pendingRenderTilesBelowText.clear(); + } if (!_pendingRenderTilesAboveText.empty()) { - // In case some image tiles are still pending but no text had to be rendered. - - for (auto& tile: _pendingRenderTilesAboveText) + for (auto const& tile: _pendingRenderTilesAboveText) textureScheduler().renderTile(tile); _pendingRenderTilesAboveText.clear(); } diff --git a/src/vtrasterizer/ImageRenderer.h b/src/vtrasterizer/ImageRenderer.h index acbe4fcf52..2ce84c074e 100644 --- a/src/vtrasterizer/ImageRenderer.h +++ b/src/vtrasterizer/ImageRenderer.h @@ -65,6 +65,7 @@ class ImageRenderer: public Renderable, public TextRendererEvents private: AtlasTileAttributes const* getOrCreateCachedTileAttributes(vtbackend::ImageFragment const& fragment); + std::vector _pendingRenderTilesBelowText; std::vector _pendingRenderTilesAboveText; // private data