From e6b821448295c2f69b29a7ba86762b93af357b07 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Sun, 20 Mar 2022 22:37:30 +0100 Subject: [PATCH] [terminal] WIP: Good Image Protocol (PoC) --- CMakeLists.txt | 5 + src/contour/ContourApp.cpp | 131 +++++++++++++ src/contour/ContourApp.h | 1 + src/contour/opengl/OpenGLRenderer.cpp | 1 + src/contour/opengl/TerminalWidget.cpp | 37 ++++ src/contour/opengl/TerminalWidget.h | 2 + src/terminal/CMakeLists.txt | 3 + src/terminal/Functions.h | 21 ++- src/terminal/Image.cpp | 4 +- src/terminal/Image.h | 4 +- src/terminal/MessageParser.cpp | 125 +++++++++++++ src/terminal/MessageParser.h | 139 ++++++++++++++ src/terminal/MessageParser_test.cpp | 196 ++++++++++++++++++++ src/terminal/Screen.cpp | 256 +++++++++++++++++++++++++- src/terminal/Screen.h | 35 +++- 15 files changed, 951 insertions(+), 9 deletions(-) create mode 100644 src/terminal/MessageParser.cpp create mode 100644 src/terminal/MessageParser.h create mode 100644 src/terminal/MessageParser_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dbecaf488a..cd9515f311 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,11 @@ option(CONTOUR_SANITIZE "Builds with Address sanitizer enabled [default: OFF]" " option(CONTOUR_STACKTRACE_ADDR2LINE "Uses addr2line to pretty-print SEGV stacktrace." ${ADDR2LINE_DEFAULT}) option(CONTOUR_BUILD_WITH_MIMALLOC "Builds with mimalloc [default: OFF]" OFF) option(CONTOUR_INSTALL_TOOLS "Installs tools, if built [default: OFF]" OFF) +option(CONTOUR_GOOD_IMAGE_PROTOCOL "Enables Good Image Protocol support [default: ON]" ON) + +if(CONTOUR_GOOD_IMAGE_PROTOCOL) + add_definitions(-DGOOD_IMAGE_PROTOCOL=1) +endif() if(NOT WIN32 AND NOT CONTOUR_SANITIZE AND NOT CMAKE_CONFIGURATION_TYPES) set(CONTOUR_SANITIZE "OFF" CACHE STRING "Choose the sanitizer mode." FORCE) diff --git a/src/contour/ContourApp.cpp b/src/contour/ContourApp.cpp index 1692227315..57d7369fc5 100644 --- a/src/contour/ContourApp.cpp +++ b/src/contour/ContourApp.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -54,6 +55,7 @@ using std::string_view; using std::unique_ptr; using namespace std::string_literals; +using namespace std::string_view_literals; namespace CLI = crispy::cli; @@ -239,6 +241,106 @@ int ContourApp::captureAction() return EXIT_FAILURE; } +#if defined(GOOD_IMAGE_PROTOCOL) +namespace +{ + crispy::Size parseSize(string_view _text) + { + (void) _text; + return crispy::Size {}; // TODO + } + + terminal::ImageAlignment parseImageAlignment(string_view _text) + { + (void) _text; + return terminal::ImageAlignment::TopStart; // TODO + } + + terminal::ImageResize parseImageResize(string_view _text) + { + (void) _text; + return terminal::ImageResize::NoResize; // TODO + } + + // terminal::CellLocation parsePosition(string_view _text) + // { + // (void) _text; + // return {}; // TODO + // } + + // TODO: chunkedFileReader(path) to return iterator over spans of data chunks. + std::vector readFile(FileSystem::path const& _path) + { + auto ifs = std::ifstream(_path.string()); + if (!ifs.good()) + return {}; + + auto const size = FileSystem::file_size(_path); + auto text = std::vector(); + text.resize(size); + ifs.read((char*) &text[0], static_cast(size)); + return text; + } + + void displayImage(terminal::ImageResize _resizePolicy, + terminal::ImageAlignment _alignmentPolicy, + crispy::Size _screenSize, + string_view _fileName) + { + auto constexpr ST = "\033\\"sv; + + cout << fmt::format("{}f={},c={},l={},a={},z={};", + "\033Ps"sv, // GIONESHOT + '0', // image format: 0 = auto detect + _screenSize.width, + _screenSize.height, + int(_alignmentPolicy), + int(_resizePolicy)); + + #if 1 + auto const data = readFile(FileSystem::path(string(_fileName))); // TODO: incremental buffered read + auto encoderState = crispy::base64::EncoderState {}; + + 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(); + } + flush(); + #endif + + cout << ST; + } +} // namespace + +int ContourApp::imageAction() +{ + auto const resizePolicy = parseImageResize(parameters().get("contour.image.resize")); + auto const alignmentPolicy = parseImageAlignment(parameters().get("contour.image.align")); + auto const size = parseSize(parameters().get("contour.image.size")); + auto const fileName = parameters().verbatim.front(); + // TODO: how do we wanna handle more than one verbatim arg (image)? + // => report error and EXIT_FAILURE as only one verbatim arg is allowed. + // FIXME: What if parameter `size` is given as `_size` instead, it should cause an + // invalid-argument error above already! + displayImage(resizePolicy, alignmentPolicy, size, fileName); + return EXIT_SUCCESS; +} +#endif + int ContourApp::parserTableAction() { terminal::parser::dot(std::cout, terminal::parser::ParserTable::get()); @@ -318,6 +420,35 @@ crispy::cli::Command ContourApp::parameterDefinition() const "FILE", CLI::Presence::Required }, } } } }, +#if defined(GOOD_IMAGE_PROTOCOL) + CLI::Command { + "image", + "Sends an image to the terminal emulator for display.", + CLI::OptionList { + CLI::Option { "resize", + CLI::Value { "fit"s }, + "Sets the image resize policy.\n" + "Policies available are:\n" + " - no (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 policies are: TopLeft, TopCenter, TopRight, MiddleLeft, " + "MiddleCenter, MiddleRight, BottomLeft, BottomCenter, BottomRight." }, + CLI::Option { "size", + CLI::Value { ""s }, + "Sets the amount of columns and rows to place the image onto. " + "The top-left of the this area is the current cursor position, " + "and it will be scrolled automatically if not enough rows are present." } }, + CLI::CommandList {}, + CLI::CommandSelect::Explicit, + CLI::Verbatim { + "IMAGE_FILE", + "Path to image to be displayed. Image formats supported are at least PNG, JPG." } }, +#endif 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 3c118da21c..43cb651020 100644 --- a/src/contour/ContourApp.h +++ b/src/contour/ContourApp.h @@ -36,6 +36,7 @@ class ContourApp: public crispy::App int terminfoAction(); int configAction(); int integrationAction(); + int imageAction(); }; } // namespace contour diff --git a/src/contour/opengl/OpenGLRenderer.cpp b/src/contour/opengl/OpenGLRenderer.cpp index 61ecd20c3c..e9c97de4f7 100644 --- a/src/contour/opengl/OpenGLRenderer.cpp +++ b/src/contour/opengl/OpenGLRenderer.cpp @@ -155,6 +155,7 @@ namespace { case terminal::ImageFormat::RGB: return GL_RGB; case terminal::ImageFormat::RGBA: return GL_RGBA; + case terminal::ImageFormat::PNG: Require(false); } Guarantee(false); crispy::unreachable(); diff --git a/src/contour/opengl/TerminalWidget.cpp b/src/contour/opengl/TerminalWidget.cpp index 5b803ab980..f7349a4b17 100644 --- a/src/contour/opengl/TerminalWidget.cpp +++ b/src/contour/opengl/TerminalWidget.cpp @@ -1227,4 +1227,41 @@ void TerminalWidget::discardImage(terminal::Image const& _image) } // }}} +optional TerminalWidget::decodeImage(crispy::span _imageData) +{ + QImage image; + image.loadFromData(_imageData.begin(), static_cast(_imageData.size())); + + qDebug() << "decodeImage()" << image.format(); + if (image.hasAlphaChannel() && image.format() != QImage::Format_ARGB32) + image = image.convertToFormat(QImage::Format_ARGB32); + else + image = image.convertToFormat(QImage::Format_RGB888); + qDebug() << "|> decodeImage()" << image.format() << image.sizeInBytes() << image.size(); + + static auto nextImageId = terminal::ImageId(0); + + terminal::Image::Data pixels; + auto* p = &pixels[0]; + pixels.resize(static_cast(image.bytesPerLine() * image.height())); + for (int i = 0; i < image.height(); ++i) + { + memcpy(p, image.constScanLine(i), static_cast(image.bytesPerLine())); + p += image.bytesPerLine(); + } + + terminal::ImageFormat format = terminal::ImageFormat::RGBA; + switch (image.format()) + { + case QImage::Format_RGBA8888: format = terminal::ImageFormat::RGBA; break; + case QImage::Format_RGB888: format = terminal::ImageFormat::RGB; break; + default: return nullopt; + } + ImageSize size { Width::cast_from(image.width()), Height::cast_from(image.height()) }; + auto onRemove = terminal::Image::OnImageRemove {}; + + auto img = terminal::Image(nextImageId++, format, std::move(pixels), size, onRemove); + return { std::move(img) }; +} + } // namespace contour::opengl diff --git a/src/contour/opengl/TerminalWidget.h b/src/contour/opengl/TerminalWidget.h index f82bb6f7d9..5bb6ab3c88 100644 --- a/src/contour/opengl/TerminalWidget.h +++ b/src/contour/opengl/TerminalWidget.h @@ -132,6 +132,8 @@ class TerminalWidget: public QOpenGLWidget, public TerminalDisplay, private QOpe void discardImage(terminal::Image const&) override; // }}} + std::optional decodeImage(crispy::span _imageData); + public Q_SLOTS: void onFrameSwapped(); void onScrollBarValueChanged(int _value); diff --git a/src/terminal/CMakeLists.txt b/src/terminal/CMakeLists.txt index db7c7b3af0..29934d8474 100644 --- a/src/terminal/CMakeLists.txt +++ b/src/terminal/CMakeLists.txt @@ -35,6 +35,7 @@ set(terminal_HEADERS InputGenerator.h Line.h MatchModes.h + MessageParser.h MockTerm.h Parser.h Process.h @@ -77,6 +78,7 @@ set(terminal_SOURCES InputGenerator.cpp Line.cpp MatchModes.cpp + MessageParser.cpp MockTerm.cpp Parser.cpp Process${PLATFORM_SUFFIX}.cpp @@ -145,6 +147,7 @@ if(LIBTERMINAL_TESTING) Functions_test.cpp Grid_test.cpp Line_test.cpp + MessageParser_test.cpp Parser_test.cpp Screen_test.cpp Sequence_test.cpp diff --git a/src/terminal/Functions.h b/src/terminal/Functions.h index fb4caaa09e..32e40ee1e5 100644 --- a/src/terminal/Functions.h +++ b/src/terminal/Functions.h @@ -386,6 +386,15 @@ constexpr inline auto RCOLORHIGHLIGHTBG = detail::OSC(117, "RCOLORHIGHLIGHTBG", constexpr inline auto NOTIFY = detail::OSC(777, "NOTIFY", "Send Notification."); constexpr inline auto DUMPSTATE = detail::OSC(888, "DUMPSTATE", "Dumps internal state to debug stream."); +// DCS: Good Image Protocol +#if defined(GOOD_IMAGE_PROTOCOL) +// TODO: use OSC instead of DCS? +constexpr inline auto GIUPLOAD = detail::DCS(std::nullopt, 0, 0, std::nullopt, 'u', VTType::VT525, "GIUPLOAD", "Uploads an image."); +constexpr inline auto GIRENDER = detail::DCS(std::nullopt, 0, 0, std::nullopt, 'r', VTType::VT525, "GIRENDER", "Renders an image."); +constexpr inline auto GIDELETE = detail::DCS(std::nullopt, 0, 0, std::nullopt, 'd', VTType::VT525, "GIDELETE", "Deletes an image."); +constexpr inline auto GIONESHOT = detail::DCS(std::nullopt, 0, 0, std::nullopt, 's', VTType::VT525, "GIONESHOT", "Uploads and renders an unnamed image."); +#endif + constexpr inline auto CaptureBufferCode = 314; // clang-format on @@ -393,7 +402,8 @@ constexpr inline auto CaptureBufferCode = 314; inline auto const& functions() noexcept { static auto const funcs = []() constexpr - { // {{{ + { + // clang-format off auto f = std::array { // C0 EOT, @@ -491,6 +501,12 @@ inline auto const& functions() noexcept XTVERSION, // DCS +#if defined(GOOD_IMAGE_PROTOCOL) + GIUPLOAD, + GIRENDER, + GIDELETE, + GIONESHOT, +#endif STP, DECRQSS, DECSIXEL, @@ -524,12 +540,13 @@ inline auto const& functions() noexcept NOTIFY, DUMPSTATE, }; + // clang-format off crispy::sort( f, [](FunctionDefinition const& a, FunctionDefinition const& b) constexpr { return compare(a, b); }); return f; } - (); // }}} + (); #if 0 for (auto [a, b] : crispy::indexed(funcs)) diff --git a/src/terminal/Image.cpp b/src/terminal/Image.cpp index 649fed646d..f70fd15b97 100644 --- a/src/terminal/Image.cpp +++ b/src/terminal/Image.cpp @@ -150,9 +150,9 @@ shared_ptr ImagePool::rasterize(shared_ptr _image, move(_image), _alignmentPolicy, _resizePolicy, _defaultColor, _cellSpan, _cellSize); } -void ImagePool::link(string const& _name, shared_ptr _imageRef) +void ImagePool::link(string _name, shared_ptr _imageRef) { - imageNameToImageCache_.emplace(_name, move(_imageRef)); + imageNameToImageCache_.emplace(move(_name), move(_imageRef)); } shared_ptr ImagePool::findImageByName(string const& _name) const noexcept diff --git a/src/terminal/Image.h b/src/terminal/Image.h index f054328566..f5521c65e1 100644 --- a/src/terminal/Image.h +++ b/src/terminal/Image.h @@ -41,6 +41,7 @@ enum class ImageFormat { RGB, RGBA, + PNG, }; // clang-format off @@ -259,7 +260,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); @@ -300,6 +301,7 @@ struct formatter { case terminal::ImageFormat::RGB: return format_to(ctx.out(), "RGB"); case terminal::ImageFormat::RGBA: return format_to(ctx.out(), "RGBA"); + case terminal::ImageFormat::PNG: return format_to(ctx.out(), "PNG"); } return format_to(ctx.out(), "{}", unsigned(value)); } diff --git a/src/terminal/MessageParser.cpp b/src/terminal/MessageParser.cpp new file mode 100644 index 0000000000..b221e1aba0 --- /dev/null +++ b/src/terminal/MessageParser.cpp @@ -0,0 +1,125 @@ +/** + * This file is part of the "libterminal" project + * Copyright (c) 2019-2020 Christian Parpart + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include + +#include +#include + +#include + +#include +#include + +namespace terminal +{ + +// XXX prominent usecase: +// +// 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 _char) +{ + switch (state_) + { + case State::ParamKey: + if (_char == ',') + flushHeader(); + else if (_char == ';') + state_ = State::BodyStart; + else if (_char == '=') + state_ = State::ParamValue; + else if (parsedKey_.size() < MaxKeyLength) + parsedKey_.push_back(_char); + break; + case State::ParamValue: + if (_char == ',') + { + flushHeader(); + state_ = State::ParamKey; + } + else if (_char == ';') + state_ = State::BodyStart; + else if (parsedValue_.size() < MaxValueLength) + parsedValue_.push_back(_char); + 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(_char)); + // 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_.empty() && parsedValue_[0] == '!') + { + auto decoded = std::string {}; + decoded.resize(crispy::base64::decodeLength(next(begin(parsedValue_)), end(parsedValue_))); + crispy::base64::decode(next(begin(parsedValue_)), end(parsedValue_), &decoded[0]); + parsedValue_ = 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_.empty() && body_[0] == '!') + { + auto decoded = std::vector {}; + decoded.resize(crispy::base64::decodeLength(next(begin(body_)), end(body_))); + crispy::base64::decode(next(begin(body_)), end(body_), (char*) &decoded[0]); + body_ = move(decoded); + } + break; + } + finalizer_(Message(move(headers_), 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 terminal diff --git a/src/terminal/MessageParser.h b/src/terminal/MessageParser.h new file mode 100644 index 0000000000..e7765e9b28 --- /dev/null +++ b/src/terminal/MessageParser.h @@ -0,0 +1,139 @@ +/** + * This file is part of the "libterminal" project + * Copyright (c) 2019-2020 Christian Parpart + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include + +namespace terminal +{ + +/** + * 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 previousely declared ones. + * + * - Headers and body are seperated by ';' + * - Header entries are seperated by ',' + * - Header name and value is seperated 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 = 8 * 1024 * 1024; // 8 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 _char) override; + void finalize() override; + + private: + void flushHeader(); + + enum class State + { + ParamKey, + ParamValue, + BodyStart, + Body, + }; + + State state_ = State::ParamKey; + std::string parsedKey_; + std::string parsedValue_; + + OnFinalize finalizer_; + + Message::HeaderMap headers_; + Message::Data body_; +}; + +} // namespace terminal diff --git a/src/terminal/MessageParser_test.cpp b/src/terminal/MessageParser_test.cpp new file mode 100644 index 0000000000..01bf123196 --- /dev/null +++ b/src/terminal/MessageParser_test.cpp @@ -0,0 +1,196 @@ +/** + * This file is part of the "libterminal" project + * Copyright (c) 2019-2020 Christian Parpart + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include +#include +#include + +#include + +#include + +#include + +using terminal::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(fmt::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 terminal::BasicParserEvents +{ + private: + std::unique_ptr parserExtension_; + + public: + terminal::Message message; + + void hook(char) override + { + parserExtension_ = std::make_unique( + [&](terminal::Message&& _message) { message = std::move(_message); }); + } + + void put(char _char) override + { + if (parserExtension_) + parserExtension_->pass(_char); + } + + void unhook() override + { + if (parserExtension_) + { + parserExtension_->finalize(); + parserExtension_.reset(); + } + } +}; + +TEST_CASE("MessageParser.VT_embedded") +{ + auto vtEvents = MessageParserTest {}; + auto vtParser = terminal::parser::Parser { vtEvents }; + + vtParser.parseFragment(fmt::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/terminal/Screen.cpp b/src/terminal/Screen.cpp index eeb3bb1556..826d767d4b 100644 --- a/src/terminal/Screen.cpp +++ b/src/terminal/Screen.cpp @@ -12,6 +12,7 @@ * limitations under the License. */ #include +#include #include #include #include @@ -1549,7 +1550,7 @@ void Screen::sixelImage(ImageSize _pixelSize, Image::Data&& template shared_ptr Screen::uploadImage(ImageFormat _format, ImageSize _imageSize, - Image::Data&& _pixmap) + Image::Data _pixmap) { return _state.imagePool.create(_format, _imageSize, move(_pixmap)); } @@ -1565,6 +1566,7 @@ void Screen::renderImage(shared_ptr _image, bool _autoScroll) { // TODO: make use of _imageOffset and _imageSize + // TODO: OPTIMIZATION: if the exact same image has been rasterized already, reuse that. (void) _imageOffset; (void) _imageSize; @@ -3257,6 +3259,12 @@ ApplyResult Screen::apply(FunctionDefinition const& functio case STP: _state.sequencer.hookParser(hookSTP(seq)); break; case DECRQSS: _state.sequencer.hookParser(hookDECRQSS(seq)); break; case XTGETTCAP: _state.sequencer.hookParser(hookXTGETTCAP(seq)); break; +#if defined(GOOD_IMAGE_PROTOCOL) + case GIUPLOAD: _state.sequencer.hookParser(hookGoodImageUpload(seq)); break; + case GIRENDER: _state.sequencer.hookParser(hookGoodImageRender(seq)); break; + case GIDELETE: _state.sequencer.hookParser(hookGoodImageRelease(seq)); break; + case GIONESHOT: _state.sequencer.hookParser(hookGoodImageOneshot(seq)); break; +#endif default: return ApplyResult::Unsupported; } @@ -3376,6 +3384,252 @@ unique_ptr Screen::hookDECRQSS(Sequence co }); } +#if defined(GOOD_IMAGE_PROTOCOL) // {{{ +namespace +{ + int toNumber(string const* _value, int _default) + { + if (!_value) + return _default; + + int result = 0; + for (char const ch: *_value) + { + if (ch >= '0' && ch <= '9') + result = result * 10 + (ch - '0'); + else + return _default; + } + + return result; + } + + optional toImageAlignmentPolicy(string const* _value, ImageAlignment _default) + { + if (!_value) + return _default; + + 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; + } + + return nullopt; + } + + optional toImageResizePolicy(string const* _value, ImageResize _default) + { + if (!_value) + return _default; + + 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; + } + + return nullopt; // TODO + } + + 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; + } +} // namespace + +template +unique_ptr Screen::hookGoodImageUpload(Sequence const&) +{ + return make_unique([this](Message&& _message) { + auto const name = _message.header("n"); + auto const imageFormat = toImageFormat(_message.header("f")); + auto const width = Width::cast_from(toNumber(_message.header("w"), 0)); + auto const height = Height::cast_from(toNumber(_message.header("h"), 0)); + auto const size = ImageSize { width, height }; + + bool const validImage = imageFormat.has_value() + && ((*imageFormat == ImageFormat::PNG && !*size.width && !*size.height) + || (*imageFormat != ImageFormat::PNG && *size.width && *size.height)); + + if (name && validImage) + { + uploadImage(*name, imageFormat.value(), size, _message.takeBody()); + } + }); +} + +template +unique_ptr Screen::hookGoodImageRender(Sequence const&) +{ + return make_unique([this](Message&& _message) { + auto const name = _message.header("n"); + auto const x = PixelCoordinate::X { toNumber(_message.header("x"), 0) }; // XXX grid x offset + auto const y = PixelCoordinate::Y { toNumber(_message.header("y"), 0) }; // XXX grid y offset + auto const screenRows = LineCount::cast_from(toNumber(_message.header("r"), 0)); + auto const screenCols = ColumnCount::cast_from(toNumber(_message.header("c"), 0)); + auto const imageWidth = Width::cast_from(toNumber(_message.header("w"), 0)); // XXX in grid coords + auto const imageHeight = Height::cast_from(toNumber(_message.header("h"), 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 imageOffset = PixelCoordinate { x, y }; + auto const imageSize = ImageSize { imageWidth, imageHeight }; + auto const screenExtent = GridSize { screenRows, screenCols }; + + renderImage(name ? *name : "", + screenExtent, + imageOffset, + imageSize, + *alignmentPolicy, + *resizePolicy, + autoScroll, + requestStatus); + }); +} + +template +unique_ptr Screen::hookGoodImageRelease(Sequence const&) +{ + return make_unique([this](Message&& _message) { + if (auto const name = _message.header("n"); name) + releaseImage(*name); + }); +} + +template +unique_ptr Screen::hookGoodImageOneshot(Sequence const&) +{ + return make_unique([this](Message&& _message) { + auto const screenRows = LineCount::cast_from(toNumber(_message.header("r"), 0)); + auto const screenCols = ColumnCount::cast_from(toNumber(_message.header("c"), 0)); + auto const autoScroll = _message.header("l") != nullptr; + 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"), 0)); + auto const imageHeight = Height::cast_from(toNumber(_message.header("h"), 0)); + auto const imageFormat = toImageFormat(_message.header("f")); + + auto const imageSize = ImageSize { imageWidth, imageHeight }; + auto const screenExtent = GridSize { screenRows, screenCols }; + + renderImage(*imageFormat, + imageSize, + _message.takeBody(), + screenExtent, + *alignmentPolicy, + *resizePolicy, + autoScroll); + }); +} + +template +void Screen::uploadImage(string _name, + ImageFormat _format, + ImageSize _imageSize, + Image::Data _pixmap) +{ + _state.imagePool.link(move(_name), uploadImage(_format, _imageSize, move(_pixmap))); +} + +template +void Screen::renderImage(std::string const& _name, + GridSize _gridSize, + PixelCoordinate _imageOffset, + ImageSize _imageSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll, + bool _requestStatus) +{ + auto const imageRef = _state.imagePool.findImageByName(_name); + auto const topLeft = _state.cursor.position; + + if (imageRef) + renderImage(imageRef, + topLeft, + _gridSize, + _imageOffset, + _imageSize, + _alignmentPolicy, + _resizePolicy, + _autoScroll); + + if (_requestStatus) + _terminal.reply("\033P{}r\033\\", imageRef != nullptr ? 1 : 0); +} + +template +void Screen::releaseImage(std::string const& _name) +{ + _state.imagePool.unlink(_name); +} + +template +void Screen::renderImage(ImageFormat _format, + ImageSize _imageSize, + Image::Data _pixmap, + GridSize _gridSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll) +{ + auto constexpr imageOffset = PixelCoordinate {}; + auto constexpr imageSize = ImageSize {}; + + auto const topLeft = _state.cursor.position; + auto const imageRef = uploadImage(_format, _imageSize, std::move(_pixmap)); + + // clang-format off + renderImage(imageRef, + topLeft, + _gridSize, + imageOffset, + imageSize, + _alignmentPolicy, + _resizePolicy, + _autoScroll); + // clang-format on +} +#endif // }}} + } // namespace terminal #include diff --git a/src/terminal/Screen.h b/src/terminal/Screen.h index 6b026eb5fe..460446c3b6 100644 --- a/src/terminal/Screen.h +++ b/src/terminal/Screen.h @@ -218,9 +218,7 @@ class Screen: public ScreenBase, public capabilities::StaticDatabase [[nodiscard]] ImageSize maxImageSize() const noexcept { return _state.maxImageSize; } [[nodiscard]] ImageSize maxImageSizeLimit() const noexcept { return _state.maxImageSizeLimit; } - std::shared_ptr uploadImage(ImageFormat _format, - ImageSize _imageSize, - Image::Data&& _pixmap); + std::shared_ptr uploadImage(ImageFormat _format, ImageSize _imageSize, Image::Data _pixmap); /** * Renders an image onto the screen. @@ -246,6 +244,30 @@ class Screen: public ScreenBase, public capabilities::StaticDatabase void inspect(std::string const& _message, std::ostream& _os) const override; +#if defined(GOOD_IMAGE_PROTOCOL) + void uploadImage(std::string _name, ImageFormat _format, ImageSize _imageSize, Image::Data _pixmap); + void renderImage(std::string const& _name, + GridSize _gridSize, + PixelCoordinate _imageOffset, + ImageSize _imageSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll, + bool _requestStatus); + void releaseImage(std::string const& _name); + void renderImage(ImageFormat _format, + ImageSize _imageSize, + Image::Data _pixmap, + GridSize _gridSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll); +#endif + + // reset screen + void resetSoft(); + void resetHard(); + // for DECSC and DECRC void saveModes(std::vector const& _modes); void restoreModes(std::vector const& _modes); @@ -494,6 +516,13 @@ class Screen: public ScreenBase, public capabilities::StaticDatabase [[nodiscard]] std::unique_ptr hookDECRQSS(Sequence const& seq); [[nodiscard]] std::unique_ptr hookXTGETTCAP(Sequence const& seq); +#if defined(GOOD_IMAGE_PROTOCOL) + [[nodiscard]] std::unique_ptr hookGoodImageUpload(Sequence const& _ctx); + [[nodiscard]] std::unique_ptr hookGoodImageRender(Sequence const& _ctx); + [[nodiscard]] std::unique_ptr hookGoodImageRelease(Sequence const& _ctx); + [[nodiscard]] std::unique_ptr hookGoodImageOneshot(Sequence const& _ctx); +#endif + Terminal& _terminal; TerminalState& _state; ScreenType const _screenType;