diff --git a/src/App.cpp b/src/App.cpp index a38e357..3981389 100644 --- a/src/App.cpp +++ b/src/App.cpp @@ -189,23 +189,57 @@ auto App::init_wayland() -> void bool ctrl = app->m_kbd.mod_active("Control"); bool alt = app->m_kbd.mod_active("Mod1"); bool meta = app->m_kbd.mod_active("Mod4"); - if (!(ctrl || alt || meta)) { - if (sym == XKB_KEY_Left) { - app->m_kbd.typing.push_back(1); - } else if (sym == XKB_KEY_Down) { - app->m_kbd.typing.push_back(2); - } else if (sym == XKB_KEY_Up) { - app->m_kbd.typing.push_back(3); - } else if (sym == XKB_KEY_Right) { - app->m_kbd.typing.push_back(4); - } else { - u32 cp = xkb_keysym_to_utf32(sym); - if (cp >= 0x20) { - char buf[8]; - int n = xkb_keysym_to_utf8(sym, buf, sizeof(buf)); - if (n > 0) - app->m_kbd.typing.push_utf8(buf); - } + + bool handled = false; + switch (sym) { + case XKB_KEY_Left: + app->m_kbd.typing.push_back(1); + handled = true; + break; + case XKB_KEY_Down: + app->m_kbd.typing.push_back(2); + handled = true; + break; + case XKB_KEY_Up: + app->m_kbd.typing.push_back(3); + handled = true; + break; + case XKB_KEY_Right: + app->m_kbd.typing.push_back(4); + handled = true; + break; + case XKB_KEY_BackSpace: + app->m_kbd.typing.push_back(8); + handled = true; + break; + case XKB_KEY_Delete: + case XKB_KEY_KP_Delete: + app->m_kbd.typing.push_back(0x7F); + handled = true; + break; + case XKB_KEY_Return: + case XKB_KEY_KP_Enter: + app->m_kbd.typing.push_back('\n'); + handled = true; + break; + case XKB_KEY_w: + case XKB_KEY_W: + if (ctrl) { + app->m_kbd.typing.push_back(8); + handled = true; + } + break; + default: + break; + } + + if (!handled && !(ctrl || alt || meta)) { + u32 cp = xkb_keysym_to_utf32(sym); + if (cp >= 0x20) { + char buf[8]; + int n = xkb_keysym_to_utf8(sym, buf, sizeof(buf)); + if (n > 0) + app->m_kbd.typing.push_utf8(buf); } } } else { @@ -346,6 +380,7 @@ auto App::init_egl() -> void auto const font_handle = m_tr->load_font(*font, std::span(fallback_paths)); assert(font_handle && "Could not load font"); m_font = *font_handle; + m_gui->set_font(m_font); } auto App::init_signal() -> void diff --git a/src/ImGui.cpp b/src/ImGui.cpp index 6ad0a62..27ea53a 100644 --- a/src/ImGui.cpp +++ b/src/ImGui.cpp @@ -1,9 +1,124 @@ #include "ImGui.hpp" +#include #include +#include +#include +#include +#include +#include +#include #include +namespace { + +struct CodepointSpan { + u32 codepoint {}; + std::size_t start {}; + std::size_t end {}; +}; + +auto decode_utf8(std::string_view text) -> std::vector +{ + std::vector spans; + std::size_t i = 0; + spans.reserve(text.size()); + + while (i < text.size()) { + u8 const byte = static_cast(text[i]); + std::size_t const start = i; + std::size_t length = 1; + u32 cp = 0xFFFD; + + if (byte < 0x80) { + cp = byte; + } else if ((byte & 0xE0) == 0xC0) { + if (i + 1 < text.size()) { + u8 const b1 = static_cast(text[i + 1]); + if ((b1 & 0xC0) == 0x80) { + u32 const t = ((static_cast(byte) & 0x1F) << 6) + | (static_cast(b1) & 0x3F); + if (t >= 0x80) { + cp = t; + length = 2; + } + } + } + } else if ((byte & 0xF0) == 0xE0) { + if (i + 2 < text.size()) { + u8 const b1 = static_cast(text[i + 1]); + u8 const b2 = static_cast(text[i + 2]); + if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80) { + u32 const t = ((static_cast(byte) & 0x0F) << 12) + | ((static_cast(b1) & 0x3F) << 6) + | (static_cast(b2) & 0x3F); + if (t >= 0x800 && (t < 0xD800 || t > 0xDFFF)) { + cp = t; + length = 3; + } + } + } + } else if ((byte & 0xF8) == 0xF0) { + if (i + 3 < text.size()) { + u8 const b1 = static_cast(text[i + 1]); + u8 const b2 = static_cast(text[i + 2]); + u8 const b3 = static_cast(text[i + 3]); + if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80 + && (b3 & 0xC0) == 0x80) { + u32 const t = ((static_cast(byte) & 0x07) << 18) + | ((static_cast(b1) & 0x3F) << 12) + | ((static_cast(b2) & 0x3F) << 6) + | (static_cast(b3) & 0x3F); + if (t >= 0x10000 && t <= 0x10FFFF) { + cp = t; + length = 4; + } + } + } + } + + spans.push_back(CodepointSpan { cp, start, start + length }); + i += length; + } + + return spans; +} + +auto encode_utf8(u32 cp) -> std::string +{ + char buf[5] = { 0, 0, 0, 0, 0 }; + int len = 0; + if (cp <= 0x7F) { + buf[len++] = static_cast(cp); + } else if (cp <= 0x7FF) { + buf[len++] = static_cast(0xC0 | (cp >> 6)); + buf[len++] = static_cast(0x80 | (cp & 0x3F)); + } else if (cp <= 0xFFFF) { + if (cp >= 0xD800 && cp <= 0xDFFF) + return {}; + buf[len++] = static_cast(0xE0 | (cp >> 12)); + buf[len++] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + buf[len++] = static_cast(0x80 | (cp & 0x3F)); + } else if (cp <= 0x10FFFF) { + buf[len++] = static_cast(0xF0 | (cp >> 18)); + buf[len++] = static_cast(0x80 | ((cp >> 12) & 0x3F)); + buf[len++] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + buf[len++] = static_cast(0x80 | (cp & 0x3F)); + } else { + return {}; + } + return std::string(buf, len); +} + +constexpr float HORIZONTAL_PADDING = 6.0f; +constexpr float VERTICAL_PADDING = 4.0f; +constexpr float CARET_WIDTH = 2.0f; +constexpr float CARET_INSET = 1.0f; +constexpr double CARET_BLINK_INTERVAL = 0.5; + +} // namespace + ImGui::ImGui(std::shared_ptr text_renderer) : m_text_renderer(text_renderer) { @@ -18,20 +133,25 @@ void ImGui::begin(u32 const rune, bool ctrl, bool shift) void ImGui::end() { } +void ImGui::set_font(FontHandle font) { m_font = font; } + auto ImGui::text_input(std::size_t id, std::pmr::string &str, Rectangle rec, TextInputOptions options) -> std::bitset<2> { assert(id != 0); + assert( + m_font.has_value() && "ImGui font must be set before using text input"); bool submitted { false }; bool changed { false }; - if (!m_ti_states.contains(id)) { - m_ti_states[id] = {}; - } + auto &state = m_ti_states[id]; assert(!options.multiline && "Multiline not yet implemented."); + if (m_focused_id == 0) + m_focused_id = id; + if (options.font_size > rec.height) { TraceLog(LOG_WARNING, std::format("Text size for text input {} is bigger than height ({} " @@ -40,8 +160,289 @@ auto ImGui::text_input(std::size_t id, std::pmr::string &str, Rectangle rec, .c_str()); } + std::string_view str_view(str.data(), str.size()); + auto spans = decode_utf8(str_view); + + auto is_space = [](u32 cp) -> bool { + if (cp == '\n' || cp == '\r' || cp == '\t' || cp == '\v' || cp == '\f') + return true; + if (cp <= 0x7F) + return std::isspace(static_cast(cp)) != 0; + return false; + }; + + auto clamp_cursor = [&]() -> std::size_t { + int const max_idx = static_cast(spans.size()); + state.current_rune_idx = std::clamp(state.current_rune_idx, 0, max_idx); + if (state.current_rune_idx == max_idx) + return str.size(); + return spans[state.current_rune_idx].start; + }; + + std::size_t caret_byte = clamp_cursor(); + + auto refresh_spans = [&]() { + str_view = std::string_view(str.data(), str.size()); + spans = decode_utf8(str_view); + caret_byte = clamp_cursor(); + }; + + auto erase_range = [&](std::size_t byte_begin, std::size_t byte_end) { + if (byte_end > byte_begin && byte_begin < str.size()) { + str.erase(byte_begin, byte_end - byte_begin); + changed = true; + } + }; + + bool caret_activity = false; + + if (m_focused_id == id && m_rune != 0) { + bool request_refresh = false; + auto handle_backspace = [&]() { + if (state.current_rune_idx <= 0 + || state.current_rune_idx > static_cast(spans.size())) + return; + + if (m_ctrl) { + int idx = state.current_rune_idx; + int scan = idx - 1; + while (scan >= 0 + && is_space( + spans[static_cast(scan)].codepoint)) + scan--; + while (scan >= 0 + && !is_space( + spans[static_cast(scan)].codepoint)) + scan--; + int start_idx = std::max(scan + 1, 0); + std::size_t byte_begin + = spans[static_cast(start_idx)].start; + std::size_t byte_end = (idx >= static_cast(spans.size())) + ? str.size() + : spans[static_cast(idx)].start; + erase_range(byte_begin, byte_end); + state.current_rune_idx = start_idx; + } else { + auto const &prev = spans[static_cast( + state.current_rune_idx - 1)]; + erase_range(prev.start, prev.end); + state.current_rune_idx--; + } + request_refresh = true; + }; + + auto handle_delete = [&]() { + if (state.current_rune_idx < 0 + || state.current_rune_idx >= static_cast(spans.size())) { + if (!m_ctrl) + return; + } + + int idx = state.current_rune_idx; + if (m_ctrl) { + int scan = idx; + while (scan < static_cast(spans.size()) + && is_space( + spans[static_cast(scan)].codepoint)) + scan++; + while (scan < static_cast(spans.size()) + && !is_space( + spans[static_cast(scan)].codepoint)) + scan++; + std::size_t byte_begin = (idx < static_cast(spans.size())) + ? spans[static_cast(idx)].start + : str.size(); + std::size_t byte_end = (scan < static_cast(spans.size())) + ? spans[static_cast(scan)].start + : str.size(); + erase_range(byte_begin, byte_end); + } else if (idx < static_cast(spans.size())) { + auto const &curr = spans[static_cast(idx)]; + erase_range(curr.start, curr.end); + } + request_refresh = true; + }; + + switch (m_rune) { + case 1: // Left (H) + if (state.current_rune_idx > 0) { + state.current_rune_idx--; + if (m_ctrl) { + while (state.current_rune_idx > 0 + && is_space(spans[static_cast( + state.current_rune_idx)] + .codepoint)) + state.current_rune_idx--; + while (state.current_rune_idx > 0 + && !is_space(spans[static_cast( + state.current_rune_idx)] + .codepoint)) + state.current_rune_idx--; + } + caret_byte = clamp_cursor(); + } + break; + case 4: // Right (L) + if (state.current_rune_idx < static_cast(spans.size())) { + state.current_rune_idx++; + if (m_ctrl) { + while ( + state.current_rune_idx < static_cast(spans.size()) + && is_space(spans[static_cast( + state.current_rune_idx - 1)] + .codepoint)) + state.current_rune_idx++; + while ( + state.current_rune_idx < static_cast(spans.size()) + && !is_space(spans[static_cast( + state.current_rune_idx - 1)] + .codepoint)) + state.current_rune_idx++; + } + caret_byte = clamp_cursor(); + } + break; + case 3: // Up (K) + state.current_rune_idx = 0; + caret_byte = clamp_cursor(); + break; + case 2: // Down (J) + state.current_rune_idx = static_cast(spans.size()); + caret_byte = clamp_cursor(); + break; + case 8: // Backspace + handle_backspace(); + break; + case 0x7F: // Delete + handle_delete(); + break; + case '\r': + case '\n': + if (options.multiline) { + auto encoded = encode_utf8('\n'); + if (!encoded.empty()) { + str.insert(caret_byte, encoded); + state.current_rune_idx++; + changed = true; + request_refresh = true; + } + } else { + submitted = true; + } + break; + default: + if (m_rune >= 0x20) { + auto encoded = encode_utf8(m_rune); + if (!encoded.empty()) { + str.insert(caret_byte, encoded); + state.current_rune_idx++; + changed = true; + request_refresh = true; + } + } + break; + } + + if (request_refresh) { + refresh_spans(); + } else { + caret_byte = clamp_cursor(); + } + caret_activity = true; + } + + double const dt = static_cast(GetFrameTime()); + if (m_focused_id == id) { + if (caret_activity) { + state.caret_timer = 0.0; + state.caret_visible = true; + } else { + if (state.caret_timer == 0.0) + state.caret_visible = true; + state.caret_timer += dt; + if (state.caret_timer >= CARET_BLINK_INTERVAL) { + int toggles = static_cast(state.caret_timer / CARET_BLINK_INTERVAL); + state.caret_timer + = std::fmod(state.caret_timer, CARET_BLINK_INTERVAL); + if (toggles % 2 == 1) + state.caret_visible = !state.caret_visible; + } + } + } else { + state.caret_visible = false; + state.caret_timer = 0.0; + } + + Vector2 prefix_metrics { 0.0f, 0.0f }; + Vector2 full_metrics { 0.0f, 0.0f }; + if (m_font.has_value() && m_text_renderer) { + std::string_view const prefix_view(str.data(), caret_byte); + prefix_metrics = m_text_renderer->measure_text( + *m_font, prefix_view, static_cast(options.font_size)); + full_metrics = m_text_renderer->measure_text( + *m_font, str_view, static_cast(options.font_size)); + } + + state.cursor_position.x = prefix_metrics.x; + + float const available_width + = std::max(0.0f, rec.width - 2.0f * HORIZONTAL_PADDING); + if (full_metrics.x <= available_width) { + state.scroll_offset.x = 0.0f; + } else { + float &scroll = state.scroll_offset.x; + float caret_local = state.cursor_position.x - scroll; + if (caret_local > available_width) { + scroll = state.cursor_position.x - available_width; + } else if (caret_local < 0.0f) { + scroll = state.cursor_position.x; + } + scroll = std::clamp( + scroll, 0.0f, std::max(0.0f, full_metrics.x - available_width)); + } + state.scroll_offset.y = 0.0f; + + Color const bg_col { 16, 16, 16, 100 }; + Color const border_col { 220, 220, 220, 180 }; + DrawRectangleRec(rec, bg_col); + DrawRectangleLinesEx(rec, 1.0f, border_col); + + float const text_top = rec.y + VERTICAL_PADDING; + float const baseline_y = text_top + options.font_size; + float caret_height = std::min(options.font_size, + std::max(0.0f, rec.height - 2.0f * VERTICAL_PADDING)); + if (caret_height <= 0.0f) + caret_height = options.font_size; + float caret_top = baseline_y - caret_height; + caret_top = std::clamp(caret_top, rec.y + VERTICAL_PADDING, + rec.y + rec.height - VERTICAL_PADDING - caret_height); + state.cursor_position.y = caret_top; + BeginScissorMode(rec.x, rec.y, rec.width, rec.height); { + if (m_font.has_value() && m_text_renderer) { + Vector2 const text_pos { + rec.x + HORIZONTAL_PADDING - state.scroll_offset.x, + baseline_y, + }; + Color const text_color { 255, 255, 255, 255 }; + m_text_renderer->draw_text(*m_font, str_view, text_pos, + static_cast(options.font_size), text_color); + + if (m_focused_id == id && state.caret_visible) { + float const caret_x = std::round(rec.x + HORIZONTAL_PADDING + + state.cursor_position.x - state.scroll_offset.x + + CARET_INSET); + Rectangle caret_rect { + caret_x, + caret_top, + CARET_WIDTH, + caret_height, + }; + Color const caret_color { 255, 255, 255, 200 }; + DrawRectangleRec(caret_rect, caret_color); + } + } } EndScissorMode(); diff --git a/src/ImGui.hpp b/src/ImGui.hpp index d6bcb91..ba62326 100644 --- a/src/ImGui.hpp +++ b/src/ImGui.hpp @@ -2,12 +2,13 @@ #include #include +#include #include #include "TextRenderer.hpp" -constexpr float DEFAULT_FONT_SIZE { 16 }; +constexpr float DEFAULT_FONT_SIZE { 24 }; struct TextInputOptions { bool multiline { false }; @@ -30,6 +31,8 @@ struct ImGui { auto text_input(std::size_t id, std::pmr::string &str, Rectangle rec, TextInputOptions options = {}) -> std::bitset<2>; + void set_font(FontHandle font); + [[nodiscard]] inline auto id(std::string_view const str) -> std::size_t { std::hash hasher; @@ -41,6 +44,8 @@ private: int current_rune_idx { 0 }; Vector2 scroll_offset; // y not used if multiline == false Vector2 cursor_position; // y not used if multiline == false + bool caret_visible { true }; + double caret_timer { 0.0 }; }; std::unordered_map m_ti_states; @@ -49,6 +54,7 @@ private: bool m_ctrl {}; bool m_shift {}; + std::optional m_font {}; std::shared_ptr m_text_renderer {}; };