From a9e118ea8fba5e954e4bb1899c87616af71d31fb Mon Sep 17 00:00:00 2001 From: dec05eba Date: Wed, 6 Aug 2025 02:03:48 +0200 Subject: [PATCH] Improve entry with cutting off text, vertical scroll, text selection, caret movement, copy, etc --- TODO | 1 + depends/mglpp | 2 +- include/gui/Entry.hpp | 34 +++- include/gui/Utils.hpp | 2 + src/gui/Entry.cpp | 352 +++++++++++++++++++++++++++++++++++++----- src/gui/Utils.cpp | 20 +++ 6 files changed, 369 insertions(+), 42 deletions(-) diff --git a/TODO b/TODO index eab96a0..a8ceb19 100644 --- a/TODO +++ b/TODO @@ -87,6 +87,7 @@ Dont put widget position to int position when scrolling. This makes the UI jitte Show warning if another instance of gpu screen recorder is already running when starting recording? Keyboard leds get turned off when stopping gsr-global-hotkeys (for example numlock). The numlock key has to be pressed twice again to make it look correct to match its state. + Fix this by writing 0 or 1 to /sys/class/leds/input2::numlock/brightness. Make gsr-ui flatpak systemd work nicely with non-flatpak gsr-ui. Maybe change ExecStart to do flatpak run ... || gsr-ui, but make it run as a shell command first with /bin/sh -c "". diff --git a/depends/mglpp b/depends/mglpp index a784fdb..0a3fe76 160000 --- a/depends/mglpp +++ b/depends/mglpp @@ -1 +1 @@ -Subproject commit a784fdb62b1ddfc8c38733c3a16cd1f39e5d4150 +Subproject commit 0a3fe766419d812c335a61d262c183ddef517f25 diff --git a/include/gui/Entry.hpp b/include/gui/Entry.hpp index 449a87c..fd683e2 100644 --- a/include/gui/Entry.hpp +++ b/include/gui/Entry.hpp @@ -5,9 +5,17 @@ #include #include +#include namespace gsr { - using EntryValidateHandler = std::function; + class Entry; + + enum class EntryValidateHandlerResult { + DENY, + ALLOW, + REPLACED + }; + using EntryValidateHandler = std::function; class Entry : public Widget { public: @@ -20,19 +28,39 @@ namespace gsr { mgl::vec2f get_size() override; - void set_text(std::string str); + EntryValidateHandlerResult set_text(std::string str); const std::string& get_text() const; + // Also updates the cursor position + void replace_text(size_t index, size_t size, const std::string &replacement); + // Return false to specify that the string should not be accepted. This reverts the string back to its previous value. // The input can be changed by changing the input parameter and returning true. EntryValidateHandler validate_handler; std::function on_changed; private: + EntryValidateHandlerResult set_text_internal(std::string str); + void draw_caret(mgl::Window &window, mgl::vec2f draw_pos, mgl::vec2f caret_size); + void draw_caret_selection(mgl::Window &window, mgl::vec2f draw_pos, mgl::vec2f caret_size); + mgl_index_codepoint_pair find_closest_caret_index_by_position(mgl::vec2f position); + private: + struct Caret { + float offset_x = 0.0f; + int utf8_index = 0; + int byte_index = 0; + }; + + mgl::Rectangle background; mgl::Text text; float max_width; bool selected = false; - float caret_offset_x = 0.0f; + bool selecting_text = false; + bool selecting_with_keyboard = false; + bool show_selection = false; + Caret caret; + Caret selection_start_caret; + float text_overflow = 0.0f; }; EntryValidateHandler create_entry_validator_integer_in_range(int min, int max); diff --git a/include/gui/Utils.hpp b/include/gui/Utils.hpp index 542e4ea..576648c 100644 --- a/include/gui/Utils.hpp +++ b/include/gui/Utils.hpp @@ -2,6 +2,7 @@ #include #include +#include namespace mgl { class Window; @@ -14,4 +15,5 @@ namespace gsr { void set_frame_delta_seconds(double frame_delta); mgl::vec2f scale_keep_aspect_ratio(mgl::vec2f from, mgl::vec2f to); mgl::vec2f clamp_keep_aspect_ratio(mgl::vec2f from, mgl::vec2f to); + mgl::Scissor scissor_get_sub_area(mgl::Scissor parent, mgl::Scissor child); } \ No newline at end of file diff --git a/src/gui/Entry.cpp b/src/gui/Entry.cpp index 9a3fccf..a0243c4 100644 --- a/src/gui/Entry.cpp +++ b/src/gui/Entry.cpp @@ -1,7 +1,6 @@ #include "../../include/gui/Entry.hpp" #include "../../include/gui/Utils.hpp" #include "../../include/Theme.hpp" -#include #include #include #include @@ -16,6 +15,13 @@ namespace gsr { static const float border_scale = 0.0015f; static const float caret_width_scale = 0.001f; + static void string_replace_all(std::string &str, char old_char, char new_char) { + for(char &c : str) { + if(c == old_char) + c = new_char; + } + } + Entry::Entry(mgl::Font *font, const char *text, float max_width) : text("", *font), max_width(max_width) { this->text.set_color(get_color_theme().text_color); set_text(text); @@ -26,24 +32,137 @@ namespace gsr { return true; if(event.type == mgl::Event::MouseButtonPressed && event.mouse_button.button == mgl::Mouse::Left) { - selected = mgl::FloatRect(position + offset, get_size()).contains({ (float)event.mouse_button.x, (float)event.mouse_button.y }); - } else if(event.type == mgl::Event::KeyPressed && selected) { - if(event.key.code == mgl::Keyboard::Backspace && !text.get_string().empty()) { - std::string str = text.get_string(); - const size_t prev_index = mgl::utf8_get_start_of_codepoint((const unsigned char*)str.c_str(), str.size(), str.size()); - str.erase(prev_index, std::string::npos); - set_text(std::move(str)); - } else if(event.key.code == mgl::Keyboard::V && event.key.control) { - std::string clipboard_text = window.get_clipboard_string(); - std::string str = text.get_string(); - str += clipboard_text; - set_text(std::move(str)); + const mgl::vec2f mouse_pos = { (float)event.mouse_button.x, (float)event.mouse_button.y }; + selected = mgl::FloatRect(position + offset, get_size()).contains(mouse_pos); + if(selected) { + selecting_text = true; + + const auto caret_index_mouse = find_closest_caret_index_by_position(mouse_pos); + caret.byte_index = caret_index_mouse.byte_index; + caret.utf8_index = caret_index_mouse.utf8_index; + caret.offset_x = caret_index_mouse.pos.x - this->text.get_position().x; + selection_start_caret = caret; + show_selection = true; } - } else if(event.type == mgl::Event::TextEntered && selected && event.text.codepoint >= 32) { - std::string str = text.get_string(); - str.append(event.text.str, event.text.size); - set_text(std::move(str)); + } else if(event.type == mgl::Event::MouseButtonReleased && event.mouse_button.button == mgl::Mouse::Left) { + selecting_text = false; + if(caret.byte_index == selection_start_caret.byte_index) + show_selection = false; + } else if(event.type == mgl::Event::KeyPressed && selected) { + int selection_start_byte = caret.byte_index; + int selection_end_byte = caret.byte_index; + if(show_selection) { + selection_start_byte = std::min(caret.byte_index, selection_start_caret.byte_index); + selection_end_byte = std::max(caret.byte_index, selection_start_caret.byte_index); + } + + if(event.key.code == mgl::Keyboard::Backspace) { + if(selection_start_byte == selection_end_byte && caret.byte_index > 0) + selection_start_byte = mgl::utf8_get_start_of_codepoint((const unsigned char*)text.get_string().c_str(), text.get_string().size(), caret.byte_index - 1); + + replace_text(selection_start_byte, selection_end_byte - selection_start_byte, ""); + } else if(event.key.code == mgl::Keyboard::Delete) { + if(selection_start_byte == selection_end_byte && caret.byte_index < (int)text.get_string().size()) { + size_t codepoint_length = 1; + mgl::utf8_get_codepoint_length(((const unsigned char*)text.get_string().c_str())[caret.byte_index], &codepoint_length); + selection_end_byte = selection_start_byte + codepoint_length; + } + + replace_text(selection_start_byte, selection_end_byte - selection_start_byte, ""); + } else if(event.key.code == mgl::Keyboard::C && event.key.control) { + const size_t selection_num_bytes = selection_end_byte - selection_start_byte; + if(selection_num_bytes > 0) + window.set_clipboard(text.get_string().substr(selection_start_byte, selection_num_bytes)); + } else if(event.key.code == mgl::Keyboard::V && event.key.control) { + std::string clipboard_string = window.get_clipboard_string(); + string_replace_all(clipboard_string, '\n', ' '); + replace_text(selection_start_byte, selection_end_byte - selection_start_byte, std::move(clipboard_string)); + } else if(event.key.code == mgl::Keyboard::A && event.key.control) { + selection_start_caret.byte_index = 0; + selection_start_caret.utf8_index = 0; + selection_start_caret.offset_x = 0.0f; + + caret.byte_index = text.get_string().size(); + caret.utf8_index = mgl::utf8_get_character_count((const unsigned char*)text.get_string().data(), text.get_string().size()); + // TODO: Optimize + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + + show_selection = true; + } else if(event.key.code == mgl::Keyboard::Left && caret.byte_index > 0) { + if(!selecting_with_keyboard && show_selection) { + show_selection = false; + } else { + caret.byte_index = mgl::utf8_get_start_of_codepoint((const unsigned char*)text.get_string().data(), text.get_string().size(), caret.byte_index - 1); + caret.utf8_index -= 1; + // TODO: Move left by one character instead of calculating every character to caret index + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + } + + if(!selecting_with_keyboard) { + selection_start_caret = caret; + show_selection = false; + } + } else if(event.key.code == mgl::Keyboard::Right) { + if(!selecting_with_keyboard && show_selection) { + show_selection = false; + } else { + const int caret_byte_index_before = caret.byte_index; + caret.byte_index = mgl::utf8_index_to_byte_index((const unsigned char*)text.get_string().data(), text.get_string().size(), caret.utf8_index + 1); + if(caret.byte_index != caret_byte_index_before) + caret.utf8_index += 1; + // TODO: Move right by one character instead of calculating every character to caret index + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + } + + if(!selecting_with_keyboard) { + selection_start_caret = caret; + show_selection = false; + } + } else if(event.key.code == mgl::Keyboard::Home) { + caret.byte_index = 0; + caret.utf8_index = 0; + caret.offset_x = 0.0f; + + if(!selecting_with_keyboard) { + selection_start_caret = caret; + show_selection = false; + } + } else if(event.key.code == mgl::Keyboard::End) { + caret.byte_index = text.get_string().size(); + caret.utf8_index = mgl::utf8_get_character_count((const unsigned char*)text.get_string().data(), text.get_string().size()); + // TODO: Optimize + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + + if(!selecting_with_keyboard) { + selection_start_caret = caret; + show_selection = false; + } + } else if(event.key.code == mgl::Keyboard::LShift || event.key.code == mgl::Keyboard::RShift) { + if(!show_selection) + selection_start_caret = caret; + selecting_with_keyboard = true; + show_selection = true; + } + + return false; + } else if(event.type == mgl::Event::KeyReleased && selected) { + if(event.key.code == mgl::Keyboard::LShift || event.key.code == mgl::Keyboard::RShift) { + selecting_with_keyboard = false; + } + + return false; + } else if(event.type == mgl::Event::TextEntered && selected && event.text.codepoint >= 32 && event.text.codepoint != 127) { + int selection_start_byte = caret.byte_index; + int selection_end_byte = caret.byte_index; + if(show_selection) { + selection_start_byte = std::min(caret.byte_index, selection_start_caret.byte_index); + selection_end_byte = std::max(caret.byte_index, selection_start_caret.byte_index); + } + + replace_text(selection_start_byte, selection_end_byte - selection_start_byte, std::string(event.text.str, event.text.size)); + return false; } + return true; } @@ -54,26 +173,91 @@ namespace gsr { const mgl::vec2f draw_pos = position + offset; const int padding_top = padding_top_scale * get_theme().window_height; + const int padding_bottom = padding_bottom_scale * get_theme().window_height; const int padding_left = padding_left_scale * get_theme().window_height; + const int padding_right = padding_right_scale * get_theme().window_height; - mgl::Rectangle background(get_size()); + background.set_size(get_size()); background.set_position(draw_pos.floor()); background.set_color(selected ? mgl::Color(0, 0, 0, 255) : mgl::Color(0, 0, 0, 120)); window.draw(background); + const int caret_width = std::max(1.0f, caret_width_scale * get_theme().window_height); + const mgl::vec2f caret_size = mgl::vec2f(caret_width, text.get_bounds().size.y).floor(); + + const float overflow_left = (caret.offset_x + padding_left) - (padding_left + text_overflow); + if(overflow_left < 0.0f) + text_overflow += overflow_left; + + const float overflow_right = (caret.offset_x + padding_left) - (background.get_size().x - padding_right); + if(overflow_right - text_overflow > 0.0f) + text_overflow = overflow_right; + + text.set_position((draw_pos + mgl::vec2f(padding_left, get_size().y * 0.5f - text.get_bounds().size.y * 0.5f) - mgl::vec2f(text_overflow, 0.0f)).floor()); + + const auto text_bounds = text.get_bounds(); + const bool text_larger_than_background = text_bounds.size.x > (background.get_size().x - padding_left - padding_right); + const float text_overflow_right = (text_bounds.position.x + text_bounds.size.x) - (background.get_position().x + background.get_size().x - padding_right); + if(text_larger_than_background) { + if(text_overflow_right < 0.0f) { + text_overflow += text_overflow_right; + text.set_position(text.get_position() + mgl::vec2f(-text_overflow_right, 0.0f)); + } + } else { + text.set_position(text.get_position() + mgl::vec2f(-text_overflow, 0.0f)); + text_overflow = 0.0f; + } + if(selected) { const int border_size = std::max(1.0f, border_scale * get_theme().window_height); draw_rectangle_outline(window, draw_pos.floor(), get_size().floor(), get_color_theme().tint_color, border_size); - - const int caret_width = std::max(1.0f, caret_width_scale * get_theme().window_height); - mgl::Rectangle caret({(float)caret_width, text.get_bounds().size.y}); - caret.set_position((draw_pos + mgl::vec2f(padding_left + caret_offset_x, padding_top)).floor()); - caret.set_color(mgl::Color(255, 255, 255)); - window.draw(caret); + draw_caret(window, draw_pos, caret_size); } - text.set_position((draw_pos + mgl::vec2f(padding_left, get_size().y * 0.5f - text.get_bounds().size.y * 0.5f)).floor()); + const mgl::Scissor parent_scissor = window.get_scissor(); + const mgl::Scissor scissor = scissor_get_sub_area(parent_scissor, + mgl::Scissor{ + (background.get_position() + mgl::vec2f(padding_left, padding_top)).to_vec2i(), + (background.get_size() - mgl::vec2f(padding_left + padding_right, padding_top + padding_bottom)).to_vec2i() + }); + window.set_scissor(scissor); + window.draw(text); + + if(selecting_text) { + const auto caret_index_mouse = find_closest_caret_index_by_position(window.get_mouse_position().to_vec2f()); + caret.byte_index = caret_index_mouse.byte_index; + caret.utf8_index = caret_index_mouse.utf8_index; + caret.offset_x = caret_index_mouse.pos.x - this->text.get_position().x; + } + + if(show_selection) + draw_caret_selection(window, draw_pos, caret_size); + + window.set_scissor(parent_scissor); + } + + void Entry::draw_caret(mgl::Window &window, mgl::vec2f draw_pos, mgl::vec2f caret_size) { + const int padding_top = padding_top_scale * get_theme().window_height; + const int padding_left = padding_left_scale * get_theme().window_height; + + mgl::Rectangle caret_rect(caret_size); + mgl::vec2f caret_draw_pos = draw_pos + mgl::vec2f(padding_left + caret.offset_x - text_overflow, padding_top); + caret_rect.set_position(caret_draw_pos.floor()); + caret_rect.set_color(mgl::Color(255, 255, 255)); + window.draw(caret_rect); + } + + void Entry::draw_caret_selection(mgl::Window &window, mgl::vec2f draw_pos, mgl::vec2f caret_size) { + const int padding_top = padding_top_scale * get_theme().window_height; + const int padding_left = padding_left_scale * get_theme().window_height; + + mgl::Rectangle caret_selection_rect(mgl::vec2f(std::abs(selection_start_caret.offset_x - caret.offset_x), caret_size.y).floor()); + caret_selection_rect.set_position((draw_pos + mgl::vec2f(padding_left + std::min(caret.offset_x, selection_start_caret.offset_x) - text_overflow, padding_top)).floor()); + mgl::Color caret_select_color = get_color_theme().tint_color; + caret_select_color.a = 100; + caret_selection_rect.set_color(caret_select_color); + window.draw(caret_selection_rect); } mgl::vec2f Entry::get_size() { @@ -85,19 +269,107 @@ namespace gsr { return { max_width, text.get_bounds().size.y + padding_top + padding_bottom }; } - void Entry::set_text(std::string str) { - if(!validate_handler || validate_handler(str)) { + EntryValidateHandlerResult Entry::set_text(std::string str) { + EntryValidateHandlerResult validate_result = set_text_internal(std::move(str)); + if(validate_result == EntryValidateHandlerResult::ALLOW) { + caret.byte_index = text.get_string().size(); + caret.utf8_index = mgl::utf8_get_character_count((const unsigned char*)text.get_string().data(), text.get_string().size()); + // TODO: Optimize + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + selection_start_caret = caret; + + selecting_text = false; + selecting_with_keyboard = false; + show_selection = false; + } + return validate_result; + } + + EntryValidateHandlerResult Entry::set_text_internal(std::string str) { + EntryValidateHandlerResult validate_result = EntryValidateHandlerResult::ALLOW; + if(validate_handler) + validate_result = validate_handler(*this, str); + + if(validate_result == EntryValidateHandlerResult::ALLOW) { text.set_string(std::move(str)); - caret_offset_x = text.find_character_pos(99999).x - this->text.get_position().x; if(on_changed) on_changed(text.get_string()); } + + return validate_result; } const std::string& Entry::get_text() const { return text.get_string(); } + void Entry::replace_text(size_t index, size_t size, const std::string &replacement) { + if(index + size > text.get_string().size()) + return; + + const auto prev_caret = caret; + + if((int)index >= caret.byte_index) { + caret.utf8_index += mgl::utf8_get_character_count((const unsigned char*)replacement.c_str(), replacement.size()); + caret.byte_index += replacement.size(); + } else { + caret.utf8_index -= mgl::utf8_get_character_count((const unsigned char*)(text.get_string().c_str() + caret.byte_index - size), size); + caret.utf8_index += mgl::utf8_get_character_count((const unsigned char*)replacement.c_str(), replacement.size()); + caret.byte_index = caret.byte_index - size + replacement.size(); + } + + std::string str = text.get_string(); + str.replace(index, size, replacement); + const EntryValidateHandlerResult validate_result = set_text_internal(std::move(str)); + if(validate_result == EntryValidateHandlerResult::DENY) { + caret = prev_caret; + return; + } else if(validate_result == EntryValidateHandlerResult::REPLACED) { + return; + } + + // TODO: Optimize + caret.offset_x = text.find_character_pos(caret.utf8_index).x - this->text.get_position().x; + selection_start_caret = caret; + + selecting_text = false; + selecting_with_keyboard = false; + show_selection = false; + } + + mgl_index_codepoint_pair Entry::find_closest_caret_index_by_position(mgl::vec2f position) { + const std::string &str = text.get_string(); + mgl::Font *font = text.get_font(); + + mgl_index_codepoint_pair result = {0, 0, {text.get_position().x, text.get_position().y}}; + + for(; result.byte_index < str.size();) { + uint32_t codepoint = ' '; + size_t clen = 1; + if(!mgl::utf8_decode((const unsigned char*)&str[result.byte_index], str.size() - result.byte_index, &codepoint, &clen)) + clen = 1; + + float glyph_width = 0.0f; + if(codepoint == '\t') { + const auto glyph = font->get_glyph(' '); + const int tab_width = 4; + glyph_width = glyph.advance * tab_width; + } else { + const auto glyph = font->get_glyph(codepoint); + glyph_width = glyph.advance; + } + + if(result.pos.x + glyph_width * 0.5f >= position.x) + break; + + result.pos.x += glyph_width; + result.byte_index += clen; + result.utf8_index += 1; + } + + return result; + } + static bool is_number(uint8_t c) { return c >= '0' && c <= '9'; } @@ -114,7 +386,7 @@ namespace gsr { int number = 0; for(; i < str.size(); ++i) { if(!is_number(str[i])) - return false; + return std::nullopt; const int new_number = number * 10 + (str[i] - '0'); if(new_number < number) @@ -129,19 +401,23 @@ namespace gsr { } EntryValidateHandler create_entry_validator_integer_in_range(int min, int max) { - return [min, max](std::string &str) { + return [min, max](Entry &entry, const std::string &str) { if(str.empty()) - return true; + return EntryValidateHandlerResult::ALLOW; - std::optional number = to_integer(str); + const std::optional number = to_integer(str); if(!number) - return false; + return EntryValidateHandlerResult::DENY; - if(number.value() < min) - str = std::to_string(min); - else if(number.value() > max) - str = std::to_string(max); - return true; + if(number.value() < min) { + entry.set_text(std::to_string(min)); + return EntryValidateHandlerResult::REPLACED; + } else if(number.value() > max) { + entry.set_text(std::to_string(max)); + return EntryValidateHandlerResult::REPLACED; + } + + return EntryValidateHandlerResult::ALLOW; }; } } \ No newline at end of file diff --git a/src/gui/Utils.cpp b/src/gui/Utils.cpp index 8f77f17..e427ff2 100644 --- a/src/gui/Utils.cpp +++ b/src/gui/Utils.cpp @@ -5,6 +5,18 @@ namespace gsr { static double frame_delta_seconds = 1.0; + static mgl::vec2i min_vec2i(mgl::vec2i a, mgl::vec2i b) { + return { std::min(a.x, b.x), std::min(a.y, b.y) }; + } + + static mgl::vec2i max_vec2i(mgl::vec2i a, mgl::vec2i b) { + return { std::max(a.x, b.x), std::max(a.y, b.y) }; + } + + static mgl::vec2i clamp_vec2i(mgl::vec2i value, mgl::vec2i min, mgl::vec2i max) { + return min_vec2i(max, max_vec2i(value, min)); + } + // TODO: Use vertices to make it one draw call void draw_rectangle_outline(mgl::Window &window, mgl::vec2f pos, mgl::vec2f size, mgl::Color color, float border_size) { pos = pos.floor(); @@ -74,4 +86,12 @@ namespace gsr { else return from; } + + mgl::Scissor scissor_get_sub_area(mgl::Scissor parent, mgl::Scissor child) { + const mgl::vec2i pos = clamp_vec2i(child.position, parent.position, parent.position + parent.size); + return mgl::Scissor{ + pos, + min_vec2i(child.size, parent.position + parent.size - pos) + }; + } } \ No newline at end of file