diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e88800e35..d948b81ac1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,6 +57,11 @@ option(CONTOUR_COVERAGE "Builds with codecov [default: OFF]" OFF) option(CONTOUR_SANITIZE "Builds with Address sanitizer enabled [default: OFF]" "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_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..2c423cee21 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,104 @@ 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 = [&](string_view _data) { + for (auto ch: _data) + buf.push_back(ch); + }; + auto const flush = [&]() { + cout.write(buf.data(), 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 +418,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/TerminalWidget.cpp b/src/contour/opengl/TerminalWidget.cpp index 1f6335e273..1ae3c02f35 100644 --- a/src/contour/opengl/TerminalWidget.cpp +++ b/src/contour/opengl/TerminalWidget.cpp @@ -1219,4 +1219,40 @@ void TerminalWidget::discardImage(terminal::Image const& _image) } // }}} +optional TerminalWidget::decodeImage(crispy::span _imageData) +{ + QImage image; + image.loadFromData(_imageData.begin(), _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 terminal::Image::Id nextImageId = 0; + + terminal::Image::Data pixels; + auto* p = &pixels[0]; + pixels.resize(image.bytesPerLine() * image.height()); + for (int i = 0; i < image.height(); ++i) + { + memcpy(p, image.constScanLine(i), 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; + } + crispy::Size size { image.width(), image.height() }; + + auto img = terminal::Image(nextImageId++, format, std::move(pixels), size); + 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 f0fb67bbc6..205bf2c403 100644 --- a/src/terminal/CMakeLists.txt +++ b/src/terminal/CMakeLists.txt @@ -31,6 +31,7 @@ set(terminal_HEADERS InputGenerator.h Line.h MatchModes.h + MessageParser.h MockTerm.h Parser.h Process.h @@ -71,6 +72,7 @@ set(terminal_SOURCES InputGenerator.cpp Line.cpp MatchModes.cpp + MessageParser.cpp MockTerm.cpp Parser.cpp Process${PLATFORM_SUFFIX}.cpp @@ -130,6 +132,7 @@ if(LIBTERMINAL_TESTING) Selector_test.cpp Functions_test.cpp Grid_test.cpp + MessageParser_test.cpp Parser_test.cpp Screen_test.cpp Terminal_test.cpp diff --git a/src/terminal/Functions.h b/src/terminal/Functions.h index 410d54d0f8..b08000b2ae 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/MessageParser.cpp b/src/terminal/MessageParser.cpp new file mode 100644 index 0000000000..39526db4ef --- /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(_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_), &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..eb80de24a2 --- /dev/null +++ b/src/terminal/MessageParser.h @@ -0,0 +1,146 @@ +/** + * 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(char32_t const* _begin, char32_t const* _end) + { + crispy::for_each(crispy::span(_begin, _end), [&](auto ch) { pass(ch); }); + } + void parseFragment(std::u32string_view _range) + { + crispy::for_each(_range, [&](auto ch) { pass(ch); }); + } + void parseFragment(std::string_view _range) + { + crispy::for_each(_range, [&](auto ch) { 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..7b6ba73f49 --- /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::ParserEvents +{ + 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 00721d794b..ee0066b2e1 100644 --- a/src/terminal/Screen.cpp +++ b/src/terminal/Screen.cpp @@ -2251,6 +2251,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; @@ -2298,6 +2299,71 @@ void Screen::renderImage(shared_ptr _image, moveCursorToColumn(_topLeft.column + _gridSize.columns.as()); } +#if defined(GOOD_IMAGE_PROTOCOL) // {{{ +template +void Screen::uploadImage(string const& _name, + ImageFormat _format, + Size _imageSize, + Image::Data&& _pixmap) +{ + imagePool_.link(_name, uploadImage(_format, _imageSize, move(_pixmap))); +} + +template +void Screen::renderImage(std::string const& _name, + Size _gridSize, + Coordinate _imageOffset, + Size _imageSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll, + bool _requestStatus) +{ + auto const imageRef = imagePool_.findImageByName(_name); + auto const topLeft = cursorPosition(); + + if (imageRef) + renderImage(imageRef, + topLeft, + _gridSize, + _imageOffset, + _imageSize, + _alignmentPolicy, + _resizePolicy, + _autoScroll); + + if (_requestStatus) + reply("\033P{}r\033\\", imageRef != nullptr ? 1 : 0); +} + +template +void Screen::releaseImage(std::string const& _name) +{ + imagePool_.unlink(_name); +} + +template +void Screen::renderImage(ImageFormat _format, + Size _imageSize, + Image::Data&& _pixmap, + Size _gridSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll) +{ + auto constexpr imageOffset = CellLocation {}; + auto constexpr imageSize = Size {}; + + auto const topLeft = cursorPosition(); + 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 // }}} + template void Screen::setWindowTitle(std::string const& _title) { diff --git a/src/terminal/Screen.h b/src/terminal/Screen.h index 32ab569f4f..c38c685233 100644 --- a/src/terminal/Screen.h +++ b/src/terminal/Screen.h @@ -251,6 +251,29 @@ class Screen: public capabilities::StaticDatabase ImageResize _resizePolicy, bool _autoScroll); +#if defined(GOOD_IMAGE_PROTOCOL) + void uploadImage(std::string const& _name, + ImageFormat _format, + crispy::Size _imageSize, + Image::Data&& _pixmap); + void renderImage(std::string const& _name, + crispy::Size _gridSize, + CellLocation _imageOffset, + crispy::Size _imageSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll, + bool _requestStatus); + void releaseImage(std::string const& _name); + void renderImage(ImageFormat _format, + crispy::Size _imageSize, + Image::Data&& _pixmap, + crispy::Size _gridSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll); +#endif + void inspect(std::string const& _message, std::ostream& _os) const; // reset screen diff --git a/src/terminal/Sequencer.cpp b/src/terminal/Sequencer.cpp index af9223fdec..17f9033ea7 100644 --- a/src/terminal/Sequencer.cpp +++ b/src/terminal/Sequencer.cpp @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +#include #include #include #include @@ -33,6 +34,7 @@ using std::make_unique; using std::nullopt; using std::optional; using std::pair; +using std::string; using std::string_view; using std::unique_ptr; @@ -188,6 +190,12 @@ void Sequencer::hook(char _finalChar) case STP: hookedParser_ = hookSTP(sequence_); break; case DECRQSS: hookedParser_ = hookDECRQSS(sequence_); break; case XTGETTCAP: hookedParser_ = hookXTGETTCAP(sequence_); break; +#if defined(GOOD_IMAGE_PROTOCOL) + case GIUPLOAD: hookedParser_ = hookGoodImageUpload(sequence_); break; + case GIRENDER: hookedParser_ = hookGoodImageRender(sequence_); break; + case GIDELETE: hookedParser_ = hookGoodImageRelease(sequence_); break; + case GIONESHOT: hookedParser_ = hookGoodImageOneshot(sequence_); break; +#endif } } } @@ -292,6 +300,179 @@ unique_ptr Sequencer::hookXTGETTCAP(Sequence const& /*_seq*/) }); } +#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 + +unique_ptr Sequencer::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 = toNumber(_message.header("w"), 0); + auto const height = toNumber(_message.header("h"), 0); + auto const size = Size { 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) + { + screen_.uploadImage(*name, imageFormat.value(), size, _message.takeBody()); + } + }); +} + +unique_ptr Sequencer::hookGoodImageRender(Sequence const&) +{ + return make_unique([this](Message&& _message) { + auto const screenRows = toNumber(_message.header("r"), 0); + auto const screenCols = toNumber(_message.header("c"), 0); + auto const name = _message.header("n"); + auto const x = toNumber(_message.header("x"), 0); // XXX grid x offset + auto const y = toNumber(_message.header("y"), 0); // XXX grid y offset + auto const imageWidth = toNumber(_message.header("w"), 0); // XXX image width in grid coords + auto const imageHeight = toNumber(_message.header("h"), 0); // XXX image height 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 = Coordinate { y, x }; + auto const imageSize = Size { imageWidth, imageHeight }; + auto const screenExtent = Size { screenCols, screenRows }; + + screen_.renderImage(name ? *name : "", + screenExtent, + imageOffset, + imageSize, + *alignmentPolicy, + *resizePolicy, + autoScroll, + requestStatus); + }); +} + +unique_ptr Sequencer::hookGoodImageRelease(Sequence const&) +{ + return make_unique([this](Message&& _message) { + if (auto const name = _message.header("n"); name) + screen_.releaseImage(*name); + }); +} + +unique_ptr Sequencer::hookGoodImageOneshot(Sequence const&) +{ + return make_unique([this](Message&& _message) { + auto const screenRows = toNumber(_message.header("r"), 0); + auto const screenCols = 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 = toNumber(_message.header("w"), 0); + auto const imageHeight = toNumber(_message.header("h"), 0); + auto const imageFormat = toImageFormat(_message.header("f")); + + auto const imageSize = Size { imageWidth, imageHeight }; + auto const screenExtent = Size { screenCols, screenRows }; + + screen_.renderImage(*imageFormat, + imageSize, + _message.takeBody(), + screenExtent, + *alignmentPolicy, + *resizePolicy, + autoScroll); + }); +} +#endif // }}} + unique_ptr Sequencer::hookDECRQSS(Sequence const& /*_seq*/) { return make_unique([this](string_view const& _data) { diff --git a/src/terminal/Sequencer.h b/src/terminal/Sequencer.h index dd2cf4db30..00b0f95596 100644 --- a/src/terminal/Sequencer.h +++ b/src/terminal/Sequencer.h @@ -165,6 +165,13 @@ class Sequencer [[nodiscard]] std::unique_ptr hookDECRQSS(Sequence const& _ctx); [[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 + // private data // Terminal& terminal_;