From 4277763df5c1dac8ff389d3bfd138f03acc7f1e2 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Mon, 28 Sep 2020 13:32:34 +0200 Subject: Implement text editing with navigation and multilingual fonts --- README.md | 8 +- TODO | 10 +- images/matrix_logo.png | Bin 2207 -> 1199 bytes include/Body.hpp | 6 +- include/Entry.hpp | 37 +++++ include/ImageViewer.hpp | 7 +- include/SearchBar.hpp | 9 +- include/Text.hpp | 30 ++-- src/Entry.cpp | 72 +++++++++ src/ImageViewer.cpp | 1 + src/QuickMedia.cpp | 95 +++++++---- src/SearchBar.cpp | 3 + src/StringUtils.cpp | 10 +- src/Text.cpp | 418 +++++++++++++++++++++++++----------------------- src/plugins/Matrix.cpp | 6 +- src/plugins/Plugin.cpp | 4 +- 16 files changed, 454 insertions(+), 262 deletions(-) create mode 100644 include/Entry.hpp create mode 100644 src/Entry.cpp diff --git a/README.md b/README.md index 365671f..a320e2f 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ Press `Enter` (aka `Return`) to select the item.\ Press `ESC` to go back to the previous menu.\ Press `Ctrl + F` to switch between window mode and fullscreen mode when watching a video.\ Press `Ctrl + R` to show/hide related videos menu when watching a video.\ -Press `Ctrl + T` when hovering over a manga chapter to start tracking manga after that chapter. This only works if AutoMedia is installed and -accessible in PATH environment variable.\ +Press `Ctrl + T` when hovering over a manga chapter to start tracking manga after that chapter. This only works if AutoMedia is installed and accessible in PATH environment variable.\ Press `Backspace` to return to the preview item when reading replies in image board threads.\ Press `R` to paste the post number of the selected post into the post field (image boards).\ -Press `Ctrl + C` to begin writing a post to a thread (image boards).\ +Press `Ctrl + C` to begin writing a post to a thread (image boards), press `ESC` to cancel.\ +Press `Ctrl + C` to begin writing a message in a matrix room, press `ESC` to cancel.\ Press `1 to 9` or `Numpad 1 to 9` to select google captcha image when posting a comment on 4chan.\ -Press `P` to preview the attached item of the selected row in full screen view. Only works for image boards when browsing a thread.\ +Press `P` to preview the 4chan image of the selected row in full screen view, press `ESC` or `Backspace` to go back.\ Press `I` to switch between single image and scroll image view mode when reading manga.\ Press `Middle mouse button` to "autoscroll" in scrolling image view mode.\ Press `Tab` to autocomplete a search when autocomplete is available (currently only available for youtube).\ diff --git a/TODO b/TODO index a980a66..9db5182 100644 --- a/TODO +++ b/TODO @@ -34,6 +34,12 @@ Also take code from dchat to support gifs (inline in text). Use pixel buffer object for asynchronous texture transfer to gpu? is this necessary? When pressing backspace to delete text, auto search will kick in because the key repeat delay is longer on the first key. SearchBar should instead check of key press/key release state. Set the thumbnail fallback image dimensions to the image dimensions we get from matrix. That means we can know the dimensions of images in matrix before they have finished downloading. This is good for removing popouts in the body. -Add hyphen at end of wrapped text if it wrapped at a character. Add option to edit input in vim (using temporary file). -Scrolling in images still messes up the |current| page sometimes, need a way to fix this. \ No newline at end of file +Scrolling in images still messes up the |current| page sometimes, need a way to fix this. +Add ctrl+i keybind when viewing an image on 4chan to reverse image search it (using google, yandex and saucenao). +Show filename at the bottom when viewing an image/video on 4chan. +Show the last message in a room as the body item description in matrix room view. +Show some kind of indication that there are new messages in a room in matrix room view, and also show another indication if somebody mentioned us (and how many times). +Show the rooms menu on the left side when the window is large in matrix. +Use https://github.com/simdjson/simdjson as a json library. +Sanitize check: do not allow pasting more than 2gb of text. \ No newline at end of file diff --git a/images/matrix_logo.png b/images/matrix_logo.png index ca616e9..055a9c7 100644 Binary files a/images/matrix_logo.png and b/images/matrix_logo.png differ diff --git a/include/Body.hpp b/include/Body.hpp index 35b3f30..a5c346e 100644 --- a/include/Body.hpp +++ b/include/Body.hpp @@ -1,10 +1,8 @@ #pragma once #include "Text.hpp" -#include #include #include -#include #include #include #include "../external/RoundedRectangleShape.hpp" @@ -12,6 +10,10 @@ #include #include +namespace sf { + class RenderWindow; +} + namespace QuickMedia { class Program; diff --git a/include/Entry.hpp b/include/Entry.hpp new file mode 100644 index 0000000..6f96e58 --- /dev/null +++ b/include/Entry.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "Text.hpp" +#include "../external/RoundedRectangleShape.hpp" +#include +#include + +namespace sf { + class Font; + class Event; + class RenderWindow; +} + +namespace QuickMedia { + // Return true to clear the text + using OnEntrySubmit = std::function; + + class Entry { + public: + Entry(const std::string &placeholder_text, sf::Font *font, sf::Font *cjk_font); + void process_event(sf::Event &event); + void draw(sf::RenderWindow &window); + + void set_editable(bool editable); + void set_position(const sf::Vector2f &pos); + void set_max_width(float width); + + float get_height(); + + OnEntrySubmit on_submit_callback; + private: + Text text; + float width; + sf::RoundedRectangleShape background; + sf::Text placeholder; + }; +} \ No newline at end of file diff --git a/include/ImageViewer.hpp b/include/ImageViewer.hpp index fe0b6f3..b8063ee 100644 --- a/include/ImageViewer.hpp +++ b/include/ImageViewer.hpp @@ -3,14 +3,17 @@ #include "Path.hpp" #include #include -#include #include #include -#include #include #include +#include #include +namespace sf { + class RenderWindow; +} + namespace QuickMedia { class Manga; diff --git a/include/SearchBar.hpp b/include/SearchBar.hpp index a90c69d..8a1a8a0 100644 --- a/include/SearchBar.hpp +++ b/include/SearchBar.hpp @@ -1,13 +1,18 @@ #pragma once -#include -#include #include #include #include +#include #include "../external/RoundedRectangleShape.hpp" #include +namespace sf { + class Font; + class RenderWindow; + class Event; +} + namespace QuickMedia { using TextUpdateCallback = std::function; // Return true to consume the search (clear the search field) diff --git a/include/Text.hpp b/include/Text.hpp index bc6f7ab..d34fd47 100644 --- a/include/Text.hpp +++ b/include/Text.hpp @@ -1,15 +1,18 @@ #pragma once #include -#include -#include -#include #include #include #include #include "types.hpp" #include +namespace sf { + class Font; + class Event; + class RenderTarget; +} + namespace QuickMedia { struct StringViewUtf32 { @@ -69,6 +72,7 @@ namespace QuickMedia void setLineSpacing(float lineSpacing); void setCharacterSpacing(float characterSpacing); void setEditable(bool editable); + bool isEditable() const; // Note: won't update until @draw is called float getWidth() const; @@ -92,21 +96,27 @@ namespace QuickMedia HOME, END }; - -#if 0 + + struct VertexRef { + int vertices_index; // index to |vertices| VertexArray + int index; // index within vertices[vertices_index] + int line; + sf::Uint32 codepoint; + }; + void updateCaret(); - bool isCaretAtEnd() const; int getStartOfLine(int startIndex) const; int getEndOfLine(int startIndex) const; - int getRowByPosition(const sf::Vector2f &position) const; int getPreviousLineClosestPosition(int startIndex) const; int getNextLineClosestPosition(int startIndex) const; -#endif void splitTextByFont(); + + float get_text_quad_left_side(const VertexRef &vertex_ref) const; + float get_text_quad_right_side(const VertexRef &vertex_ref) const; private: - sf::String str; + sf::String str; // TODO: Remove this for non-editable text??? const sf::Font *font; const sf::Font *cjk_font; unsigned int characterSize; @@ -131,5 +141,7 @@ namespace QuickMedia sf::Vector2f caretPosition; sf::Clock lastSeenTimer; sf::Vector2u renderTargetSize; + + std::vector vertices_linear; // TODO: Use textElements instead }; } diff --git a/src/Entry.cpp b/src/Entry.cpp new file mode 100644 index 0000000..977feab --- /dev/null +++ b/src/Entry.cpp @@ -0,0 +1,72 @@ +#include "../include/Entry.hpp" +#include +#include +#include +#include + +const float background_margin_horizontal = 15.0f; +const float padding_vertical = 5.0f; +const float background_margin_vertical = 4.0f; + +namespace QuickMedia { + Entry::Entry(const std::string &placeholder_text, sf::Font *font, sf::Font *cjk_font) : + on_submit_callback(nullptr), + text("", font, cjk_font, 18, 0.0f), + width(0.0f), + background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10), + placeholder(placeholder_text, *font, 18) + { + text.setEditable(true); + background.setFillColor(sf::Color(55, 60, 68)); + placeholder.setFillColor(sf::Color(255, 255, 255, 100)); + } + + void Entry::process_event(sf::Event &event) { + text.processEvent(event); + if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Enter && !event.key.shift) { + if(on_submit_callback) { + bool clear_text = on_submit_callback(text.getString()); + if(clear_text) + text.setString(""); + } + } + } + + // TODO: Set the max number of visible lines and use glScissor to cut off the lines outsides + // (and also split text into lines to not draw them at all once they are not inside the scissor box) + void Entry::draw(sf::RenderWindow &window) { + background.setSize(sf::Vector2f(width, get_height())); + window.draw(background); + if(text.getString().isEmpty() && !text.isEditable()) { + window.draw(placeholder); + //sf::Vector2f placeholder_pos = placeholder.getPosition(); + //const float caret_margin = 2.0f; + //const float vspace = placeholder.getFont()->getLineSpacing(18); + //sf::RectangleShape caret_rect(sf::Vector2f(2.0f, floor(vspace - caret_margin * 2.0f))); + //caret_rect.setPosition(floor(placeholder_pos.x), floor(placeholder_pos.y + caret_margin)); + //window.draw(caret_rect); + } else { + text.draw(window); + } + } + + void Entry::set_editable(bool editable) { + text.setEditable(editable); + } + + void Entry::set_position(const sf::Vector2f &pos) { + background.setPosition(pos); + text.setPosition(pos + sf::Vector2f(background_margin_horizontal, background_margin_vertical)); + placeholder.setPosition(pos + sf::Vector2f(background_margin_horizontal, background_margin_vertical + 7.0f)); + } + + void Entry::set_max_width(float width) { + this->width = width; + text.setMaxWidth(this->width - background_margin_horizontal * 2.0f); + } + + float Entry::get_height() { + text.updateGeometry(); + return std::floor(text.getHeight() + background_margin_vertical * 2.0f + padding_vertical *2.0f); + } +} \ No newline at end of file diff --git a/src/ImageViewer.cpp b/src/ImageViewer.cpp index c45e35d..c929086 100644 --- a/src/ImageViewer.cpp +++ b/src/ImageViewer.cpp @@ -4,6 +4,7 @@ #include "../plugins/Manga.hpp" #include #include +#include #include namespace QuickMedia { diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index a0d8508..a854613 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -18,6 +18,7 @@ #include "../include/ImageViewer.hpp" #include "../include/ImageUtils.hpp" #include "../include/base64_url.hpp" +#include "../include/Entry.hpp" #include #include @@ -3107,6 +3108,7 @@ namespace QuickMedia { } else if(navigation_stage == NavigationStage::POSTING_COMMENT) { // TODO: Show "Posting..." when posting comment } else if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { + // TODO: Use image instead of data with string. texture->loadFromMemory creates a temporary image anyways that parses the string. std::string image_data; if(downloading_image && load_image_future.valid() && load_image_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { downloading_image = false; @@ -3341,22 +3343,16 @@ namespace QuickMedia { // get_all_room_messages is not needed here because its done in the loop, where the initial timeout is 0ms - SearchBar chat_input(*font, &plugin_logo, "Send a message..."); - chat_input.set_background_color(sf::Color::Transparent); - chat_input.padding_vertical = 10.0f; - - // TODO: Scroll to bottom when receiving new messages, but only if we are already at the bottom? - - // TODO: Filer for rooms and settings - chat_input.onTextUpdateCallback = nullptr; - Page new_page = Page::CHAT; + bool typing_message = false; - // TODO: Show post message immediately, instead of waiting for sync. Otherwise it can take a while until we receive the message, - // which happens when uploading an image. - chat_input.onTextSubmitCallback = [matrix, &tabs, &selected_tab, ¤t_room_id, &new_page](const std::string &text) -> bool { + sf::Sprite logo_sprite(plugin_logo); + + Entry chat_input("Press ctrl+c to begin writing a message...", font.get(), cjk_font.get()); + chat_input.set_editable(false); + chat_input.on_submit_callback = [matrix, &chat_input, &tabs, &selected_tab, ¤t_room_id, &new_page, &typing_message](const sf::String &text) { if(tabs[selected_tab].type == ChatTabType::MESSAGES) { - if(text.empty()) + if(text.isEmpty()) return false; if(text[0] == '/') { @@ -3364,9 +3360,13 @@ namespace QuickMedia { strip(command); if(command == "/upload") { new_page = Page::FILE_MANAGER; + chat_input.set_editable(false); + typing_message = false; return true; } else if(command == "/logout") { new_page = Page::CHAT_LOGIN; + chat_input.set_editable(false); + typing_message = false; return true; } else { fprintf(stderr, "Error: invalid command: %s, expected /upload\n", command.c_str()); @@ -3375,11 +3375,14 @@ namespace QuickMedia { } // TODO: Make asynchronous - if(matrix->post_message(current_room_id, text) != PluginResult::OK) { + if(matrix->post_message(current_room_id, text) == PluginResult::OK) { + chat_input.set_editable(false); + typing_message = false; + return true; + } else { show_notification("QuickMedia", "Failed to post matrix message", Urgency::CRITICAL); return false; } - return true; } return false; }; @@ -3413,6 +3416,9 @@ namespace QuickMedia { sf::RectangleShape more_messages_below_rect; more_messages_below_rect.setFillColor(sf::Color(128, 50, 50)); + sf::RectangleShape chat_input_shade; + chat_input_shade.setFillColor(sf::Color(33, 38, 44)); + sf::Clock start_typing_timer; const double typing_timeout_seconds = 3.0; bool typing = false; @@ -3430,13 +3436,15 @@ namespace QuickMedia { sf::Clock frame_timer; + float prev_chat_height = chat_input.get_height(); + while (current_page == Page::CHAT) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { base_event_handler(event, Page::EXIT, false, false, false); if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { redraw = true; - } else if(event.type == sf::Event::KeyPressed) { + } else if(event.type == sf::Event::KeyPressed && !typing_message) { if(event.key.code == sf::Keyboard::Up || event.key.code == sf::Keyboard::PageUp || event.key.code == sf::Keyboard::Home) { bool hit_top = false; switch(event.key.code) { @@ -3511,9 +3519,14 @@ namespace QuickMedia { } } - if(tabs[selected_tab].type == ChatTabType::MESSAGES) { + if(!typing_message && tabs[selected_tab].type == ChatTabType::MESSAGES && event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::C && event.key.control) { + chat_input.set_editable(true); + typing_message = true; + } + + if(typing_message && tabs[selected_tab].type == ChatTabType::MESSAGES) { if(event.type == sf::Event::TextEntered) { - chat_input.onTextEntered(event.text.unicode); + //chat_input.onTextEntered(event.text.unicode); // TODO: Also show typing event when ctrl+v pasting? if(event.text.unicode != 13) { // Return key start_typing_timer.restart(); @@ -3523,8 +3536,12 @@ namespace QuickMedia { } typing = true; } + } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { + chat_input.set_editable(false); + typing_message = false; } - chat_input.on_event(event); + //chat_input.on_event(event); + chat_input.process_event(event); } else if(tabs[selected_tab].type == ChatTabType::ROOMS && event.type == sf::Event::KeyReleased && event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item) { @@ -3607,10 +3624,22 @@ namespace QuickMedia { const float tab_shade_height = tab_spacer_height + std::floor(tab_vertical_offset) + tab_height + 10.0f; + const float chat_height = chat_input.get_height(); + if(std::abs(chat_height - prev_chat_height) > 1.0f) { + prev_chat_height = chat_height; + redraw = true; + } if(redraw) { + const float logo_padding_x = 15.0f; + const float chat_input_padding_x = 15.0f; + const float chat_input_padding_y = 15.0f; + + //chat_height += 10.0f; redraw = false; - chat_input.onWindowResize(window_size); - chat_input.set_vertical_position(window_size.y - chat_input.getBottomWithoutShadow()); + chat_input.set_max_width(window_size.x - (logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x * 2.0f)); + chat_input.set_position(sf::Vector2f(logo_padding_x + plugin_logo.getSize().x + chat_input_padding_x, window_size.y - chat_height - chat_input_padding_y)); + //chat_input.onWindowResize(window_size); + //chat_input.set_vertical_position(window_size.y - chat_input.getBottomWithoutShadow()); float body_padding_horizontal = 25.0f; float body_padding_vertical = 5.0f; @@ -3620,15 +3649,17 @@ namespace QuickMedia { body_padding_horizontal = 0.0f; } - float input_bottom = chat_input.getBottomWithoutShadow(); - if(tabs[selected_tab].type != ChatTabType::MESSAGES) - input_bottom = 0.0f; + chat_input_shade.setSize(sf::Vector2f(window_size.x, chat_input.get_height() + chat_input_padding_y * 2.0f)); + chat_input_shade.setPosition(0.0f, window_size.y - chat_input_shade.getSize().y); + body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + tab_shade_height); - body_size = sf::Vector2f(body_width, window_size.y - input_bottom - body_padding_vertical - tab_shade_height); + body_size = sf::Vector2f(body_width, window_size.y - chat_input_shade.getSize().y - body_padding_vertical + tab_shade_height); //get_body_dimensions(window_size, &chat_input, body_pos, body_size, true); more_messages_below_rect.setSize(sf::Vector2f(window_size.x, gradient_height)); - more_messages_below_rect.setPosition(0.0f, std::floor(window_size.y - chat_input.getBottomWithoutShadow() - gradient_height)); + more_messages_below_rect.setPosition(0.0f, std::floor(window_size.y - chat_input_shade.getSize().y - gradient_height)); + + logo_sprite.setPosition(logo_padding_x, window_size.y - chat_input_shade.getSize().y * 0.5f - plugin_logo.getSize().y * 0.5f); } if(!sync_running && sync_timer.getElapsedTime().asMilliseconds() >= sync_min_time_ms) { @@ -3681,16 +3712,12 @@ namespace QuickMedia { fetching_previous_messages_running = false; } - chat_input.update(); + //chat_input.update(); window.clear(back_color); - if(tabs[selected_tab].type == ChatTabType::MESSAGES) - chat_input.draw(window, false); - const float width_per_tab = window_size.x / tabs.size(); tab_background.setSize(sf::Vector2f(std::floor(width_per_tab - tab_margin_x * 2.0f), tab_height)); - tabs[selected_tab].body->draw(window, body_pos, body_size); const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f); @@ -3739,6 +3766,12 @@ namespace QuickMedia { window.draw(more_messages_below_rect); } + if(tabs[selected_tab].type == ChatTabType::MESSAGES) { + window.draw(chat_input_shade); + chat_input.draw(window); //chat_input.draw(window, false); + window.draw(logo_sprite); + } + window.display(); } diff --git a/src/SearchBar.cpp b/src/SearchBar.cpp index 419ca38..fa81c61 100644 --- a/src/SearchBar.cpp +++ b/src/SearchBar.cpp @@ -2,9 +2,12 @@ #include "../include/Scale.hpp" #include #include +#include #include #include +// TODO: Use a seperate placeholder sf::Text instead of switching the text to placeholder text.... + const sf::Color text_placeholder_color(255, 255, 255, 100); const sf::Color front_color(55, 60, 68); const float background_margin_horizontal = 15.0f; diff --git a/src/StringUtils.cpp b/src/StringUtils.cpp index f255971..111822e 100644 --- a/src/StringUtils.cpp +++ b/src/StringUtils.cpp @@ -4,10 +4,10 @@ namespace QuickMedia { void string_split(const std::string &str, char delimiter, StringSplitCallback callback_func) { size_t index = 0; - while(true) { + while(index < str.size()) { size_t new_index = str.find(delimiter, index); if(new_index == std::string::npos) - break; + new_index = str.size(); if(!callback_func(str.data() + index, new_index - index)) break; @@ -19,11 +19,12 @@ namespace QuickMedia { size_t string_replace_all(std::string &str, char old_char, const std::string &new_str) { size_t num_replaced_substrings = 0; size_t index = 0; - while(true) { + while(index < str.size()) { index = str.find(old_char, index); if(index == std::string::npos) break; str.replace(index, 1, new_str); + index += new_str.size(); ++num_replaced_substrings; } return num_replaced_substrings; @@ -32,11 +33,12 @@ namespace QuickMedia { size_t string_replace_all(std::string &str, const std::string &old_str, const std::string &new_str) { size_t num_replaced_substrings = 0; size_t index = 0; - while(true) { + while(index < str.size()) { index = str.find(old_str, index); if(index == std::string::npos) break; str.replace(index, old_str.size(), new_str); + index += new_str.size(); ++num_replaced_substrings; } return num_replaced_substrings; diff --git a/src/Text.cpp b/src/Text.cpp index 8f58e3d..3962374 100644 --- a/src/Text.cpp +++ b/src/Text.cpp @@ -1,6 +1,9 @@ #include "../include/Text.hpp" #include #include +#include +#include +#include #include #include @@ -48,8 +51,8 @@ namespace QuickMedia void Text::setString(sf::String str) { - if(str != this->str) - { + //if(str != this->str) + //{ this->str = std::move(str); dirty = true; dirtyText = true; @@ -58,7 +61,7 @@ namespace QuickMedia caretIndex = this->str.getSize(); dirtyCaret = true; } - } + // } } const sf::String& Text::getString() const @@ -156,6 +159,10 @@ namespace QuickMedia } } + bool Text::isEditable() const { + return editable; + } + float Text::getWidth() const { return boundingBox.width; @@ -214,10 +221,18 @@ namespace QuickMedia index += 1 + offset; } } + + float Text::get_text_quad_left_side(const VertexRef &vertex_ref) const { + return vertices[vertex_ref.vertices_index][vertex_ref.index].position.x; + } + + float Text::get_text_quad_right_side(const VertexRef &vertex_ref) const { + return vertices[vertex_ref.vertices_index][vertex_ref.index + 1].position.x; + } - // Logic loosely based on https://github.com/SFML/SFML/wiki/Source:-CurvedText void Text::updateGeometry(bool update_even_if_not_dirty) { if(dirtyText) { + assert(dirty); dirtyText = false; splitTextByFont(); } @@ -225,10 +240,11 @@ namespace QuickMedia if(!update_even_if_not_dirty && !dirty) return; + vertices_linear.clear(); vertices[0].clear(); vertices[1].clear(); float hspace = font->getGlyph(' ', characterSize, false).advance + characterSpacing; - float vspace = font->getLineSpacing(characterSize); + float vspace = font->getLineSpacing(characterSize); // TODO: What about japanese font??? boundingBox = sf::FloatRect(); @@ -236,27 +252,27 @@ namespace QuickMedia sf::Uint32 prevCodePoint = 0; for(usize textElementIndex = 0; textElementIndex < textElements.size(); ++textElementIndex) { - size_t lastSpacingWordWrapIndex = -1; - float lastSpacingAccumulatedOffset = 0.0f; TextElement &textElement = textElements[textElementIndex]; const sf::Font *ff = font; - size_t vertices_index = 0; + int vertices_index = 0; if(textElement.is_japanese) { ff = cjk_font; vertices_index = 1; } usize vertexOffset = vertices[vertices_index].getVertexCount(); - vertices[vertices_index].resize(vertices[vertices_index].getVertexCount() + 4 * (textElement.text.size + 1)); + vertices[vertices_index].resize(vertices[vertices_index].getVertexCount() + 4 * textElement.text.size); // TODO: Precalculate textElement.position = glyphPos; for(size_t i = 0; i < textElement.text.size; ++i) { sf::Uint32 codePoint = textElement.text[i]; + // TODO: Make this work when combining multiple different fonts (for example latin and japanese). + // For japanese we could use a hack, because all japanese characters are monospace (exception being half-width characters). float kerning = ff->getKerning(prevCodePoint, codePoint, characterSize); prevCodePoint = codePoint; glyphPos.x += kerning; - usize vertexStart = vertexOffset + i * 4; + int vertexStart = vertexOffset + i * 4; switch(codePoint) { @@ -267,67 +283,34 @@ namespace QuickMedia vertices[vertices_index][vertexStart + 2] = { sf::Vector2f(glyphPos.x + hspace, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; vertices[vertices_index][vertexStart + 3] = { sf::Vector2f(glyphPos.x, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; glyphPos.x += hspace; - if(glyphPos.x > maxWidth * 0.5f) - { - lastSpacingWordWrapIndex = i; - lastSpacingAccumulatedOffset = glyphPos.x; - } + vertices_linear.push_back({vertices_index, vertexStart, 0, codePoint}); continue; } case '\t': { + const float char_width = hspace * TAB_WIDTH; vertices[vertices_index][vertexStart + 0] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertexStart + 1] = { sf::Vector2f(glyphPos.x + hspace * TAB_WIDTH, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertexStart + 2] = { sf::Vector2f(glyphPos.x + hspace * TAB_WIDTH, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; + vertices[vertices_index][vertexStart + 1] = { sf::Vector2f(glyphPos.x + char_width, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + vertices[vertices_index][vertexStart + 2] = { sf::Vector2f(glyphPos.x + char_width, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; vertices[vertices_index][vertexStart + 3] = { sf::Vector2f(glyphPos.x, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; - glyphPos.x += (hspace * TAB_WIDTH); - if(glyphPos.x > maxWidth * 0.5f) - { - lastSpacingWordWrapIndex = i; - lastSpacingAccumulatedOffset = glyphPos.x; - } + glyphPos.x += char_width; + vertices_linear.push_back({vertices_index, vertexStart, 0, codePoint}); continue; } case '\n': { - vertices[vertices_index][vertexStart + 0] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertexStart + 1] = { sf::Vector2f(0.0f, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertexStart + 2] = { sf::Vector2f(0.0f, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertexStart + 3] = { sf::Vector2f(0.0f, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; glyphPos.x = 0.0f; glyphPos.y += floor(vspace + lineSpacing); + vertices[vertices_index][vertexStart + 0] = { sf::Vector2f(0.0f, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + vertices[vertices_index][vertexStart + 1] = { sf::Vector2f(0.0f, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + vertices[vertices_index][vertexStart + 2] = { sf::Vector2f(0.0f, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; + vertices[vertices_index][vertexStart + 3] = { sf::Vector2f(0.0f, glyphPos.y), sf::Color::Transparent, sf::Vector2f() }; + vertices_linear.push_back({vertices_index, vertexStart, 0, codePoint}); continue; } } const sf::Glyph &glyph = ff->getGlyph(codePoint, characterSize, false); - // TODO: Fix wrap-around with multiple textElements. Right now it only wrap-arounds within the same textElement, so with mixed latin-japanese it will - // wrap at character size rather than at whitespace - if(glyphPos.x + glyph.advance > maxWidth) - { - // If there was a space in the text and text width is too long, then we need to word wrap at space index instead, - // which means we need to change the position of all vertices after the space to the current vertex - if(lastSpacingWordWrapIndex != (size_t)-1) - { - for(size_t j = lastSpacingWordWrapIndex; j < i; ++j) - { - for(size_t k = 0; k < 4; ++k) - { - sf::Vector2f &vertexPos = vertices[vertices_index][vertexOffset + j * 4 + k].position; - vertexPos.x -= lastSpacingAccumulatedOffset; - vertexPos.y += floor(vspace + lineSpacing); - } - } - - glyphPos.x -= lastSpacingAccumulatedOffset; - lastSpacingWordWrapIndex = -1; - lastSpacingAccumulatedOffset = 0.0f; - } - else - glyphPos.x = 0.0f; - - glyphPos.y += floor(vspace + lineSpacing); - } sf::Vector2f vertexTopLeft(glyphPos.x + glyph.bounds.left, glyphPos.y + glyph.bounds.top); sf::Vector2f vertexTopRight(glyphPos.x + glyph.bounds.left + glyph.bounds.width, glyphPos.y + glyph.bounds.top); @@ -347,43 +330,110 @@ namespace QuickMedia vertices[vertices_index][vertexStart + 3] = { vertexBottomLeft, fontColor, textureBottomLeft }; glyphPos.x += glyph.advance + characterSpacing; + vertices_linear.push_back({vertices_index, vertexStart, 0, codePoint}); } - vertices[vertices_index][vertices[vertices_index].getVertexCount() - 4] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertices[vertices_index].getVertexCount() - 3] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertices[vertices_index].getVertexCount() - 2] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; - vertices[vertices_index][vertices[vertices_index].getVertexCount() - 1] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + //vertices[vertices_index][vertices[vertices_index].getVertexCount() - 4] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + //vertices[vertices_index][vertices[vertices_index].getVertexCount() - 3] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + //vertices[vertices_index][vertices[vertices_index].getVertexCount() - 2] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; + //vertices[vertices_index][vertices[vertices_index].getVertexCount() - 1] = { sf::Vector2f(glyphPos.x, glyphPos.y - vspace), sf::Color::Transparent, sf::Vector2f() }; prevCodePoint = 0; } - - boundingBox.height = glyphPos.y + lineSpacing; - boundingBox.height += vspace; - for(size_t vertices_index = 0; vertices_index < 2; ++vertices_index) { - usize numVertices = vertices[vertices_index].getVertexCount(); - for(usize i = 0; i < numVertices; i += 4) - { - const sf::Vertex &bottomRight = vertices[vertices_index][i + 2]; - boundingBox.width = std::max(boundingBox.width, bottomRight.position.x); + const float line_height = floor(vspace + lineSpacing); + float text_wrap_offset = 0.0f; + float text_offset_y = 0.0f; + int last_space_index = -1; + int num_lines = 1; + // TODO: Binary search? + for(int i = 0; i < (int)vertices_linear.size(); ++i) { + VertexRef &vertex_ref = vertices_linear[i]; + switch(vertex_ref.codepoint) { + case ' ': + case '\t': + last_space_index = i; + break; + case '\n': + text_wrap_offset = 0.0f; + last_space_index = -1; + ++num_lines; + break; + default: + break; + } + + sf::Vertex *vertex = &vertices[vertex_ref.vertices_index][vertex_ref.index]; + vertex[0].position.x -= text_wrap_offset; + vertex[1].position.x -= text_wrap_offset; + vertex[2].position.x -= text_wrap_offset; + vertex[3].position.x -= text_wrap_offset; + + vertex[0].position.y += text_offset_y; + vertex[1].position.y += text_offset_y; + vertex[2].position.y += text_offset_y; + vertex[3].position.y += text_offset_y; + vertex_ref.line = num_lines - 1; + + float vertex_right_side = get_text_quad_right_side(vertex_ref); + if(vertex_right_side > maxWidth) { + ++num_lines; + // TODO: Ignore line wrap on space + if(last_space_index != -1 && last_space_index != i) { + float vertex_left_side = get_text_quad_left_side(vertices_linear[last_space_index + 1]); + for(int j = last_space_index + 1; j <= i; ++j) { + VertexRef &vertex_ref_wrap = vertices_linear[j]; + sf::Vertex *vertex = &vertices[vertex_ref_wrap.vertices_index][vertex_ref_wrap.index]; + vertex[0].position.x -= vertex_left_side; + vertex[1].position.x -= vertex_left_side; + vertex[2].position.x -= vertex_left_side; + vertex[3].position.x -= vertex_left_side; + + vertex[0].position.y += line_height; + vertex[1].position.y += line_height; + vertex[2].position.y += line_height; + vertex[3].position.y += line_height; + + vertex_ref_wrap.line = num_lines - 1; + } + last_space_index = -1; + text_wrap_offset += vertex_left_side; + } else { + float vertex_left_side = get_text_quad_left_side(vertex_ref); + vertex[0].position.x -= vertex_left_side; + vertex[1].position.x -= vertex_left_side; + vertex[2].position.x -= vertex_left_side; + vertex[3].position.x -= vertex_left_side; + + vertex[0].position.y += line_height; + vertex[1].position.y += line_height; + vertex[2].position.y += line_height; + vertex[3].position.y += line_height; + + text_wrap_offset += vertex_left_side; + vertex_ref.line = num_lines - 1; + } + text_offset_y += line_height; } } + boundingBox.width = 0.0f; + for(VertexRef &vertex_ref : vertices_linear) { + boundingBox.width = std::max(boundingBox.width, get_text_quad_right_side(vertex_ref)); + } + boundingBox.height = num_lines * line_height; dirty = false; } - -#if 0 + + // TODO: Fix caret up/down navigation! its broken because of newlines void Text::updateCaret() { assert(!dirty && !dirtyText); - if(textElements.size() == 0) - { - float vspace = font->getLineSpacing(characterSize); + if(vertices_linear.empty()) { caretIndex = 0; - caretPosition = sf::Vector2f(0.0f, -vspace); + caretPosition = sf::Vector2f(0.0f, floor(font->getLineSpacing(characterSize))); return; } - switch(caretMoveDirection) { @@ -407,49 +457,41 @@ namespace QuickMedia caretIndex = getEndOfLine(caretIndex); break; } - default: + case CaretMoveDirection::NONE: // Ignore... break; } - - caretIndex = std::min(std::max(0, caretIndex), (int)textElements[0].text.size); - - usize vertexIndex = caretIndex * 4; - if(vertexIndex == 0) - { - float vspace = font->getLineSpacing(characterSize); - caretPosition = sf::Vector2f(0.0f, -vspace); - } - else - { - const sf::Vertex &topLeftVertex = vertices[vertexIndex]; - caretPosition = topLeftVertex.position; - } - } - bool Text::isCaretAtEnd() const - { - assert(!dirty && !dirtyText); - return textElements[0].text.size == 0 || caretIndex == (int)textElements[0].text.size; + if(caretIndex == (int)vertices_linear.size()) { + caretPosition.x = get_text_quad_right_side(vertices_linear.back()); + caretPosition.y = (1 + vertices_linear.back().line) * floor(font->getLineSpacing(characterSize) + lineSpacing); + } else if(caretIndex == 0) { + caretPosition = sf::Vector2f(0.0f, floor(font->getLineSpacing(characterSize))); + } else { + if(vertices_linear[caretIndex].codepoint == '\n') { + caretPosition.x = get_text_quad_right_side(vertices_linear[caretIndex - 1]); + caretPosition.y = (1 + vertices_linear[caretIndex - 1].line) * floor(font->getLineSpacing(characterSize) + lineSpacing); + } else { + caretPosition.x = get_text_quad_left_side(vertices_linear[caretIndex]); + caretPosition.y = (1 + vertices_linear[caretIndex].line) * floor(font->getLineSpacing(characterSize) + lineSpacing); + } + } } // TODO: This can be optimized by using binary search int Text::getStartOfLine(int startIndex) const { assert(!dirty && !dirtyText); - int numVertices = vertices.getVertexCount(); - if(numVertices < 4) return 0; - - usize vertexIndex = startIndex * 4; - const sf::Vertex &startTopLeftVertex = vertices[vertexIndex]; - int startRow = getRowByPosition(startTopLeftVertex.position); - for(int i = startIndex * 4; i > 0; i -= 4) - { - const sf::Vertex &topLeftVertex = vertices[i]; - int row = getRowByPosition(topLeftVertex.position); - if(row != startRow) - { - return std::max(0, i / 4 + 1); + const int num_vertices = vertices_linear.size(); + const int start_index_wrap = startIndex < num_vertices ? startIndex : num_vertices - 1; + int start_line = vertices_linear[start_index_wrap].line; + if(vertices_linear[start_index_wrap].codepoint == '\n') + --start_line; + for(int i = startIndex - 1; i >= 0; --i) { + if(vertices_linear[i].line != start_line) { + if(i + 2 <= num_vertices && vertices_linear[i + 1].codepoint == '\n') + return i + 2; + return i + 1; } } return 0; @@ -459,55 +501,46 @@ namespace QuickMedia int Text::getEndOfLine(int startIndex) const { assert(!dirty && !dirtyText); - int numVertices = vertices.getVertexCount(); - if(numVertices < 4) return 0; - - usize vertexIndex = startIndex * 4; - const sf::Vertex &startTopLeftVertex = vertices[vertexIndex]; - int startRow = getRowByPosition(startTopLeftVertex.position); - for(int i = startIndex * 4; i < numVertices; i += 4) - { - const sf::Vertex &topLeftVertex = vertices[i]; - int row = getRowByPosition(topLeftVertex.position); - if(row != startRow) - { - return std::max(0, i / 4 - 1); + const int num_vertices = vertices_linear.size(); + const int start_index_wrap = startIndex < num_vertices ? startIndex : num_vertices - 1; + int start_line = vertices_linear[start_index_wrap].line; + if(vertices_linear[start_index_wrap].codepoint == '\n') + return startIndex; + for(int i = startIndex + 1; i < (int)vertices_linear.size(); ++i) { + if(vertices_linear[i].line != start_line) { + if(vertices_linear[i].codepoint == '\n') + return i; + return i - 1; } } - return numVertices / 4; + return (int)vertices_linear.size(); } // TODO: This can be optimized by using binary search int Text::getPreviousLineClosestPosition(int startIndex) const { assert(!dirty && !dirtyText); - int numVertices = vertices.getVertexCount(); - if(numVertices < 4) return 0; - - usize vertexIndex = startIndex * 4; - const sf::Vertex &startTopLeftVertex = vertices[vertexIndex]; - int startRow = getRowByPosition(startTopLeftVertex.position); - int closestIndex = -1; - float closestAbsoluteDiffX = 0.0f; - for(int i = startIndex * 4; i >= 0; i -= 4) - { - const sf::Vertex &topLeftVertex = vertices[i]; - int row = getRowByPosition(topLeftVertex.position); - float absoluteDiffX = fabs(topLeftVertex.position.x - startTopLeftVertex.position.x); - int rowDiff = abs(row - startRow); - if(rowDiff > 1) - break; - - if(rowDiff == 1 && (closestIndex == -1 || absoluteDiffX < closestAbsoluteDiffX)) - { - closestIndex = i; - closestAbsoluteDiffX = absoluteDiffX; - } + const int num_vertices = vertices_linear.size(); + float start_left_pos; + if(startIndex == num_vertices) { + start_left_pos = get_text_quad_right_side(vertices_linear.back()); + if(vertices_linear.back().codepoint == '\n') + return getStartOfLine(startIndex - 1); + } else { + start_left_pos = get_text_quad_left_side(vertices_linear[startIndex]); + if(vertices_linear[startIndex].codepoint == '\n') + return getStartOfLine(startIndex - 1); + } + float closest_char = 999999.9f; + for(int i = getStartOfLine(startIndex) - 1; i >= 0; --i) { + //if(vertices_linear[i].codepoint == '\n') + // continue; + const float left_pos = get_text_quad_left_side(vertices_linear[i]); + const float pos_diff = std::abs(start_left_pos - left_pos); + if(pos_diff > closest_char) + return i + 1; + closest_char = pos_diff; } - - if(closestIndex != -1) - return closestIndex / 4; - return 0; } @@ -515,43 +548,31 @@ namespace QuickMedia int Text::getNextLineClosestPosition(int startIndex) const { assert(!dirty && !dirtyText); - int numVertices = vertices.getVertexCount(); - if(numVertices < 4) return 0; - - usize vertexIndex = startIndex * 4; - const sf::Vertex &startTopLeftVertex = vertices[vertexIndex]; - int startRow = getRowByPosition(startTopLeftVertex.position); - int closestIndex = -1; - float closestAbsoluteDiffX = 0.0f; - for(int i = startIndex * 4; i < numVertices; i += 4) - { - const sf::Vertex &topLeftVertex = vertices[i]; - int row = getRowByPosition(topLeftVertex.position); - float absoluteDiffX = fabs(topLeftVertex.position.x - startTopLeftVertex.position.x); - int rowDiff = abs(row - startRow); - if(rowDiff > 1) - break; - - if(rowDiff == 1 && (closestIndex == -1 || absoluteDiffX < closestAbsoluteDiffX)) - { - closestIndex = i; - closestAbsoluteDiffX = absoluteDiffX; - } + const int num_vertices = vertices_linear.size(); + float start_left_pos; + if(startIndex == num_vertices) { + return startIndex; + } else { + start_left_pos = get_text_quad_left_side(vertices_linear[startIndex]); + if(vertices_linear[startIndex].codepoint == '\n') + return startIndex + 1; } - - if(closestIndex != -1) - return closestIndex / 4; - - return numVertices / 4; - } - - int Text::getRowByPosition(const sf::Vector2f &position) const - { - assert(!dirty && !dirtyText); - const float vspace = font->getLineSpacing(characterSize); - return static_cast(1.0f + position.y / (vspace + lineSpacing)); + float closest_char = 999999.9f; + for(int i = getEndOfLine(startIndex) + 1; i < (int)vertices_linear.size(); ++i) { + //if(vertices_linear[i].codepoint == '\n') + // continue; + const float left_pos = get_text_quad_left_side(vertices_linear[i]); + const float pos_diff = std::abs(start_left_pos - left_pos); + if(pos_diff > closest_char) + return i - 1; + closest_char = pos_diff; + } + return (int)vertices_linear.size(); } - + + // TODO: Optimize text editing by only processing the changed parts in updateGeometry. + // TODO: Split text into lines and add to vertices list so the lines that are cut off are not visible. This is good when using the text and as text input + // where there are a max number of rows shown at a time. void Text::processEvent(const sf::Event &event) { if(!editable) return; @@ -602,7 +623,7 @@ namespace QuickMedia } else if(event.key.code == sf::Keyboard::Return) { - if(sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) || sf::Keyboard::isKeyPressed(sf::Keyboard::RShift)) + if(event.key.shift) { if(caretAtEnd) str += '\n'; @@ -650,24 +671,19 @@ namespace QuickMedia dirtyCaret = true; } } -#endif + bool Text::draw(sf::RenderTarget &target) { updateGeometry(); - -#if 0 + if(dirtyCaret || caretMoveDirection != CaretMoveDirection::NONE) { updateCaret(); dirtyCaret = false; caretMoveDirection = CaretMoveDirection::NONE; } -#endif - - float vspace = font->getLineSpacing(characterSize); sf::Vector2f pos = position; - pos.y += floor(vspace); // Origin is at bottom left, we want it to be at top left // TODO: Do not use maxWidth here. Max width might be set to 99999 and actual text width might be 200. Text width should be calculated instead //sf::FloatRect targetRect(0.0f, 0.0f, maxWidth, target.getSize().y); @@ -685,6 +701,9 @@ namespace QuickMedia } return false; } + + const float vspace = font->getLineSpacing(characterSize); + pos.y += floor(vspace); // Origin is at bottom left, we want it to be at top left if(!visible) { visible = true; @@ -700,21 +719,14 @@ namespace QuickMedia } lastSeenTimer.restart(); - pos.y -= floor(vspace); - -#if 0 if(!editable) return true; - - //float rows = floor(totalHeight / (vspace + lineSpacing)); - const float caretRow = getRowByPosition(caretPosition); - - sf::RectangleShape caretRect(sf::Vector2f(2.0f, floor(vspace))); - caretRect.setFillColor(sf::Color::White); - caretRect.setPosition(sf::Vector2f(floor(pos.x + caretPosition.x), floor(pos.y + caretRow * (vspace + lineSpacing)))); + pos.y -= floor(vspace * 2.0f); + + const float caret_margin = 2.0f; + + sf::RectangleShape caretRect(sf::Vector2f(2.0f, floor(vspace - caret_margin * 2.0f))); + caretRect.setPosition(floor(pos.x + caretPosition.x), floor(pos.y + caretPosition.y + caret_margin + 4.0f)); target.draw(caretRect); return true; -#else - return true; -#endif } } diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index d364b16..513a9fb 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -666,7 +666,10 @@ namespace QuickMedia { std::string formatted_body; bool contains_formatted_text = false; if(msgtype == MessageType::TEXT) { - string_split(body, '\n', [&formatted_body, &contains_formatted_text](const char *str, size_t size){ + int line = 0; + string_split(body, '\n', [&formatted_body, &contains_formatted_text, &line](const char *str, size_t size){ + if(line > 0) + formatted_body += "
"; if(size > 0 && str[0] == '>') { std::string line(str, size); html_escape_sequences(line); @@ -677,6 +680,7 @@ namespace QuickMedia { } else { formatted_body.append(str, size); } + ++line; return true; }); } diff --git a/src/plugins/Plugin.cpp b/src/plugins/Plugin.cpp index f23175c..20c4b0a 100644 --- a/src/plugins/Plugin.cpp +++ b/src/plugins/Plugin.cpp @@ -35,11 +35,11 @@ namespace QuickMedia { void html_escape_sequences(std::string &str) { const std::array escape_sequences = { + HtmlEscapeSequence { '&', "&" }, // This should be first, to not accidentally replace a new sequence caused by replacing this HtmlEscapeSequence { '"', """ }, HtmlEscapeSequence { '\'', "'" }, HtmlEscapeSequence { '<', "<" }, - HtmlEscapeSequence { '>', ">" }, - HtmlEscapeSequence { '&', "&" } // This should be last, to not accidentally replace a new sequence caused by replacing this + HtmlEscapeSequence { '>', ">" } }; for(const HtmlEscapeSequence &escape_sequence : escape_sequences) { -- cgit v1.2.3