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 @@
- Fixes Unicode characters sometimes being wrongly decoded during high-bandwidth output
- Improves PTY throughput performance determinism by rewriting the internal grid cell storage
+ - Adds Good Image Protocol (GIP) implementation — a DCS-based protocol for displaying raster images with named image pools, LRU eviction, three compositing layers (below/replace/above), configurable resize and alignment policies, PNG/RGB/RGBA format support, and DA1 capability advertisement via code 11 (#100)
+ - Adds contour cat CLI command for displaying images in the terminal via GIP oneshot sequences
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