From 5825bef4b547720692cc6c841fd31e28b4a0c6f7 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Fri, 18 Dec 2020 12:52:38 +0100 Subject: [PATCH] [terminal] WIP: Good Image Protocol (PoC) --- TODO.md | 4 + src/contour/ContourApp.cpp | 131 +++++++++++++++++ src/contour/ContourApp.h | 1 + src/contour/opengl/TerminalWidget.cpp | 44 ++++++ src/contour/opengl/TerminalWidget.h | 6 + src/terminal/CMakeLists.txt | 7 + src/terminal/Functions.h | 15 ++ src/terminal/MessageParser.cpp | 138 ++++++++++++++++++ src/terminal/MessageParser.h | 138 ++++++++++++++++++ src/terminal/MessageParser_test.cpp | 184 ++++++++++++++++++++++++ src/terminal/Parser.h | 5 + src/terminal/Parser_test.cpp | 5 +- src/terminal/Screen.cpp | 56 ++++++++ src/terminal/Screen.h | 19 +++ src/terminal/Sequencer.cpp | 200 +++++++++++++++++++++++++- src/terminal/Sequencer.h | 7 + 16 files changed, 958 insertions(+), 2 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/TODO.md b/TODO.md index 8fd8af3906..2249837d68 100644 --- a/TODO.md +++ b/TODO.md @@ -93,6 +93,10 @@ - [ ] contour: provide `--mono` (or alike) CLI flag to "just" provide a QOpenGLWindow for best performance, lacking UI features as compromise. +### Good Image Protocol + +- [ ] Make sure Screen::Image does not need to know about the underlying image format. (only the frontend needs to know about the actual format in use, so it can *render* the pixmaps) + ### Usability Improvements - ? Images: copy action should uxe U+FFFC (object replacement) on grid cells that contain an image for text-based clipboard action diff --git a/src/contour/ContourApp.cpp b/src/contour/ContourApp.cpp index 16a56af5bd..4d50da44a1 100644 --- a/src/contour/ContourApp.cpp +++ b/src/contour/ContourApp.cpp @@ -19,9 +19,12 @@ #include #include +#include #include +#include #include +#include #include #include @@ -35,16 +38,21 @@ #include #endif +#define GOOD_IMAGE_PROTOCOL // TODO use cmake here instead + using std::bind; using std::cerr; using std::cout; +using std::ifstream; using std::make_unique; using std::ofstream; using std::string; using std::string_view; using std::unique_ptr; +using std::vector; using namespace std::string_literals; +using namespace std::string_view_literals; namespace CLI = crispy::cli; @@ -202,6 +210,76 @@ namespace // {{{ helper #endif } // }}} +#if defined(GOOD_IMAGE_PROTOCOL) // {{{ +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::Coordinate 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 = ifstream(_path.string()); + if (!ifs.good()) + return {}; + + auto const size = FileSystem::file_size(_path); + auto text = vector(); + text.resize(size); + ifs.read((char*) &text[0], 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(_fileName);// TODO: incremental buffered read + auto encoderState = crispy::base64::EncoderState{}; + + 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; +} +#endif // }}} + ContourApp::ContourApp() : App("contour", "Contour Terminal Emulator", CONTOUR_VERSION_STRING) { @@ -301,6 +379,28 @@ int ContourApp::profileAction() return EXIT_SUCCESS; } +#if defined(GOOD_IMAGE_PROTOCOL) +crispy::Size parseSize(string_view _text) +{ + (void) _text; + return crispy::Size{};//TODO +} + +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 + crispy::cli::Command ContourApp::parameterDefinition() const { return CLI::Command{ @@ -365,6 +465,37 @@ crispy::cli::Command ContourApp::parameterDefinition() const } } }, +#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 f01de1d653..875613085b 100644 --- a/src/contour/ContourApp.h +++ b/src/contour/ContourApp.h @@ -35,6 +35,7 @@ class ContourApp : public crispy::App int terminfoAction(); int configAction(); int integrationAction(); + int imageAction(); }; } diff --git a/src/contour/opengl/TerminalWidget.cpp b/src/contour/opengl/TerminalWidget.cpp index 92fc9f539a..87d5c684dc 100644 --- a/src/contour/opengl/TerminalWidget.cpp +++ b/src/contour/opengl/TerminalWidget.cpp @@ -1117,6 +1117,50 @@ void TerminalWidget::updateMinimumSize() }; setMinimumSize(minSize.width.as(), minSize.height.as()); } + +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; + } + auto const size = ImageSize{Width(image.width()), Height(image.height())}; + + auto img = terminal::Image(nextImageId++, format, std::move(pixels), size); + return {std::move(img)}; +} // }}} } // namespace contour diff --git a/src/contour/opengl/TerminalWidget.h b/src/contour/opengl/TerminalWidget.h index f7a4d36248..38580bead8 100644 --- a/src/contour/opengl/TerminalWidget.h +++ b/src/contour/opengl/TerminalWidget.h @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,11 @@ class TerminalWidget : static QSurfaceFormat surfaceFormat(); QSize minimumSizeHint() const override; QSize sizeHint() const override; + + std::optional decodeImage(crispy::span _imageData); + + int pointsToPixels(text::font_size _fontSize) const noexcept; + void initializeGL() override; void resizeGL(int _width, int _height) override; void paintGL() override; diff --git a/src/terminal/CMakeLists.txt b/src/terminal/CMakeLists.txt index f8c6577241..52e7c89ea6 100644 --- a/src/terminal/CMakeLists.txt +++ b/src/terminal/CMakeLists.txt @@ -17,6 +17,7 @@ option(LIBTERMINAL_PASSIVE_RENDER_BUFFER_UPDATE "Updates the render buffer withi # Compile-time terminal features option(LIBTERMINAL_IMAGES "Enables image support [default: ON]" ON) option(LIBTERMINAL_HYPERLINKS "Enables hyperlink support [default: ON]" ON) +option(GOOD_IMAGE_PROTOCOL "Enables building with Good Image Protocol support [default: OFF]" OFF) if(MSVC) add_definitions(-DNOMINMAX) @@ -33,6 +34,7 @@ set(terminal_HEADERS InputBinding.h InputGenerator.h MatchModes.h + MessageParser.h Parser.h Process.h pty/Pty.h @@ -63,6 +65,7 @@ set(terminal_SOURCES InputBinding.cpp InputGenerator.cpp MatchModes.cpp + MessageParser.cpp Parser.cpp Process.cpp RenderBuffer.cpp @@ -104,6 +107,9 @@ endif() if(LIBTERMINAL_LOG_TRACE) target_compile_definitions(terminal PRIVATE LIBTERMINAL_LOG_TRACE=1) endif() +if(GOOD_IMAGE_PROTOCOL) + target_compile_definitions(terminal PUBLIC GOOD_IMAGE_PROTOCOL=1) +endif() if(LIBTERMINAL_IMAGES) target_compile_definitions(terminal PUBLIC LIBTERMINAL_IMAGES=1) @@ -126,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 36ae1b37c6..7ca75d0c82 100644 --- a/src/terminal/Functions.h +++ b/src/terminal/Functions.h @@ -366,6 +366,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 + inline auto const& functions() noexcept { static auto const funcs = []() constexpr { // {{{ @@ -465,6 +474,12 @@ inline auto const& functions() noexcept XTVERSION, // DCS +#if defined(GOOD_IMAGE_PROTOCOL) + GIUPLOAD, + GIRENDER, + GIDELETE, + GIONESHOT, +#endif STP, DECRQSS, DECSIXEL, diff --git a/src/terminal/MessageParser.cpp b/src/terminal/MessageParser.cpp new file mode 100644 index 0000000000..e7095dbed8 --- /dev/null +++ b/src/terminal/MessageParser.cpp @@ -0,0 +1,138 @@ +/** + * 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::start() +{ + state_ = State::ParamKey; + parsedKey_.clear(); + parsedValue_.clear(); + + headers_.clear(); + body_.clear(); +} + +void MessageParser::pass(char32_t _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.start(); + mp.parseFragment(_range); + mp.finalize(); + return m; +} + +} // end namespace diff --git a/src/terminal/MessageParser.h b/src/terminal/MessageParser.h new file mode 100644 index 0000000000..fe0a6799ce --- /dev/null +++ b/src/terminal/MessageParser.h @@ -0,0 +1,138 @@ +/** + * 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 start() override; + void pass(char32_t _char) override; + void finalize() override; + + private: + void flushHeader(); + + private: + 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_; +}; + +} // end namespace diff --git a/src/terminal/MessageParser_test.cpp b/src/terminal/MessageParser_test.cpp new file mode 100644 index 0000000000..ccff46c362 --- /dev/null +++ b/src/terminal/MessageParser_test.cpp @@ -0,0 +1,184 @@ +/** + * 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 + +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(char32_t _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/Parser.h b/src/terminal/Parser.h index 1197103d9e..2b36e99480 100644 --- a/src/terminal/Parser.h +++ b/src/terminal/Parser.h @@ -741,6 +741,11 @@ class Parser { } void parseFragment(std::string_view const& s) + { + parseFragment((uint8_t const*) &s[0], (uint8_t const*) &s[0] + s.size()); + } + + void parseFragment(std::string const& s) { parseFragment(iterator(s.data()), iterator(s.data() + s.size())); } diff --git a/src/terminal/Parser_test.cpp b/src/terminal/Parser_test.cpp index 4d33a663f2..cea3997b76 100644 --- a/src/terminal/Parser_test.cpp +++ b/src/terminal/Parser_test.cpp @@ -12,9 +12,12 @@ * limitations under the License. */ #include +#include + #include using namespace std; +using namespace std::string_view_literals; using namespace terminal; class MockParserEvents : public terminal::BasicParserEvents { @@ -30,7 +33,7 @@ TEST_CASE("Parser.utf8_single", "[Parser]") MockParserEvents textListener; auto p = parser::Parser(textListener); - p.parseFragment("\xC3\xB6"); // ö + p.parseFragment("\xC3\xB6"sv); // ö REQUIRE(textListener.text.size() == 1); CHECK(0xF6 == static_cast(textListener.text.at(0))); diff --git a/src/terminal/Screen.cpp b/src/terminal/Screen.cpp index 2ff41c1ef4..ee81589f0b 100644 --- a/src/terminal/Screen.cpp +++ b/src/terminal/Screen.cpp @@ -2083,6 +2083,7 @@ void Screen::renderImage(std::shared_ptr const& _imageRef, auto const gapColor = RGBAColor{}; // TODO: cursor_.graphicsRendition.backgroundColor; // TODO: make use of _imageOffset and _imageSize + // TODO: OPTIMIZATION: if the exact same image has been rasterized already, reuse that. auto const rasterizedImage = imagePool_.rasterize( _imageRef, _alignmentPolicy, @@ -2147,6 +2148,61 @@ void Screen::renderImage(std::shared_ptr const& _imageRef, #endif } +#if defined(GOOD_IMAGE_PROTOCOL) // {{{ +void Screen::uploadImage(string const& _name, ImageFormat _format, Size _imageSize, Image::Data&& _pixmap) +{ + imagePool_.link(_name, uploadImage(_format, _imageSize, move(_pixmap))); +} + +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); +} + +void Screen::releaseImage(std::string const& _name) +{ + imagePool_.unlink(_name); +} + +void Screen::renderImage(ImageFormat _format, + Size _imageSize, + Image::Data&& _pixmap, + Size _gridSize, + ImageAlignment _alignmentPolicy, + ImageResize _resizePolicy, + bool _autoScroll) +{ + auto constexpr imageOffset = Coordinate{0, 0}; + auto constexpr imageSize = Size{0, 0}; + + auto const topLeft = cursorPosition(); + auto const imageRef = uploadImage(_format, _imageSize, std::move(_pixmap)); + + renderImage(imageRef, topLeft, _gridSize, + imageOffset, imageSize, + _alignmentPolicy, + _resizePolicy, + _autoScroll); +} +#endif // }}} + void Screen::setWindowTitle(std::string const& _title) { windowTitle_ = _title; diff --git a/src/terminal/Screen.h b/src/terminal/Screen.h index 1ac97fc80b..e039d9eff5 100644 --- a/src/terminal/Screen.h +++ b/src/terminal/Screen.h @@ -308,6 +308,25 @@ class Screen : public capabilities::StaticDatabase { void requestPixelSize(RequestPixelSize _area); void requestCharacterSize(RequestPixelSize _area); void sixelImage(ImageSize _pixelSize, Image::Data&& _rgba); +#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, + Coordinate _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 requestStatusString(RequestStatusString _value); void requestTabStops(); void resetDynamicColor(DynamicColorName _name); diff --git a/src/terminal/Sequencer.cpp b/src/terminal/Sequencer.cpp index e944246fbf..8c9f68ddf0 100644 --- a/src/terminal/Sequencer.cpp +++ b/src/terminal/Sequencer.cpp @@ -15,8 +15,9 @@ #include #include -#include +#include #include +#include #include #include @@ -1129,6 +1130,20 @@ void Sequencer::hook(char _finalChar) 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 } if (hookedParser_) @@ -1253,6 +1268,189 @@ 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; + } +} + +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( diff --git a/src/terminal/Sequencer.h b/src/terminal/Sequencer.h index e7621b4f62..608da1d1dc 100644 --- a/src/terminal/Sequencer.h +++ b/src/terminal/Sequencer.h @@ -631,6 +631,13 @@ class Sequencer : public ParserEvents { [[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 + void flushBatchedSequences(); void applyAndLog(FunctionDefinition const& _function, Sequence const& _context);