diff --git a/CMakeLists.txt b/CMakeLists.txt index 40434e3..89b7fe6 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,8 @@ list(APPEND OSTD_SOURCE_FILES # string ${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/String.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/TextStyleParser.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/utf8.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/TextBuffer.cpp # utils ${CMAKE_CURRENT_LIST_DIR}/src/ostd/utils/Logic.cpp @@ -103,6 +105,7 @@ list(APPEND OGFX_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/Slider.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/ComboBox.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/TreeView.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/Text.cpp # render ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/render/BasicRenderer.cpp @@ -119,6 +122,7 @@ list(APPEND OGFX_SOURCE_FILES list(APPEND TEST_SOURCE_FILES # ${CMAKE_CURRENT_LIST_DIR}/src/test/GraphicsWindowTest.cpp ${CMAKE_CURRENT_LIST_DIR}/src/test/GuiTest.cpp + # ${CMAKE_CURRENT_LIST_DIR}/src/test/TextBufferUnitTest.cpp ) #----------------------------------------------------------------------------------------- diff --git a/other/TODO.txt b/other/TODO.txt index b38a4e2..23c7840 100644 --- a/other/TODO.txt +++ b/other/TODO.txt @@ -60,6 +60,15 @@ Add modifiers to Event.keyboard path +Text: + Check if Text Entered events are enabled via SDL + Add utf-8 codepoint walking functions to String + Change the stored text in KeyEventData from a single char to the full utf-8 string + Implement a font-fallback chain in FontGlyphAtlas + + + + Implement following widgets: ***Label ***Checkbox diff --git a/other/build.nr b/other/build.nr index 16e30f7..ab7d2ad 100644 --- a/other/build.nr +++ b/other/build.nr @@ -1 +1 @@ -2075 +2076 diff --git a/src/ogfx/gui/widgets/Text.cpp b/src/ogfx/gui/widgets/Text.cpp new file mode 100644 index 0000000..b93f7f0 --- /dev/null +++ b/src/ogfx/gui/widgets/Text.cpp @@ -0,0 +1,29 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +#include "Text.hpp" + +namespace ostd +{ + namespace gui + { + + } +} diff --git a/src/ogfx/gui/widgets/Text.hpp b/src/ogfx/gui/widgets/Text.hpp new file mode 100644 index 0000000..6043cc9 --- /dev/null +++ b/src/ogfx/gui/widgets/Text.hpp @@ -0,0 +1,30 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +#pragma once + +#include "Widget.hpp" + +namespace ostd +{ + namespace gui + { + } +} diff --git a/src/ostd/string/String.cpp b/src/ostd/string/String.cpp index 901a87f..6bb07d4 100644 --- a/src/ostd/string/String.cpp +++ b/src/ostd/string/String.cpp @@ -680,55 +680,6 @@ namespace ostd return str; } - stdvec String::decodeUTF8(const String& s) - { - stdvec out; - const char* p = s.m_data.data(); - const char* end = p + s.m_data.size(); - - while (p < end) - out.push_back(utf8_next(p, end)); - - return out; - } - - - u32 String::utf8_next(const char*& p, const char* end) - { - u8 c = cast(*p++); - - // ASCII fast path - if (c < 0x80) - return c; - - // 2-byte sequence - if ((c >> 5) == 0x6) { - u32 cp = (c & 0x1F) << 6; - cp |= (cast(*p++) & 0x3F); - return cp; - } - - // 3-byte sequence - if ((c >> 4) == 0xE) { - u32 cp = (c & 0x0F) << 12; - cp |= (cast(*p++) & 0x3F) << 6; - cp |= (cast(*p++) & 0x3F); - return cp; - } - - // 4-byte sequence - if ((c >> 3) == 0x1E) { - u32 cp = (c & 0x07) << 18; - cp |= (cast(*p++) & 0x3F) << 12; - cp |= (cast(*p++) & 0x3F) << 6; - cp |= (cast(*p++) & 0x3F); - return cp; - } - - // Invalid byte → return replacement character - return 0xFFFD; - } - String operator+(const cpp_string& str1, const String& str) { return String(str1) + str; diff --git a/src/ostd/string/String.hpp b/src/ostd/string/String.hpp index 5397457..aa06f61 100644 --- a/src/ostd/string/String.hpp +++ b/src/ostd/string/String.hpp @@ -72,8 +72,8 @@ namespace ostd inline char at(u32 index) const { return m_data[index]; } inline u32 len(void) const { return m_data.length(); } inline char operator[](u32 index) const { return m_data[index]; } - inline bool operator== (const char* str2) const { return std::strcmp(c_str(), str2) == 0; } - inline bool operator!= (const char* str2) const { return std::strcmp(c_str(), str2) != 0; } + inline bool operator==(const char* str2) const { return std::strcmp(c_str(), str2) == 0; } + inline bool operator!=(const char* str2) const { return std::strcmp(c_str(), str2) != 0; } inline String operator+(const String& str2) const { return m_data + str2.cpp_str(); } friend String operator+(const cpp_string& str1, const String& str); inline String& operator+=(const String& str2) { m_data += str2.cpp_str(); return *this; } @@ -83,7 +83,7 @@ namespace ostd inline operator std::filesystem::path() const { return cpp_str(); } inline String& clr(void) { m_data = ""; return *this; } inline String& set(const cpp_string& str) { m_data = str; return *this; } - inline stdvec getUTF8Codepoints(void) const { return decodeUTF8(m_data); } + inline stdvec getUTF8Codepoints(void) const { return utf8::decode(m_data); } inline bool empty(void) const { return m_data.empty(); } inline auto begin(void) { return m_data.begin(); } @@ -179,13 +179,30 @@ namespace ostd static String getHexStr(u64 value, bool prefix = true, u8 nbytes = 1); static String getBinStr(u64 value, bool prefix = true, u8 nbytes = 1); static String duplicateChar(uchar c, u16 count); - static stdvec decodeUTF8(const String& s); + + struct utf8 + { + utf8() = delete; // Pure namespace — never instantiated. + + // Navigation + static u32 align_to_codepoint(const cpp_string& s, u32 byte_pos); + static u32 prev_codepoint_start(const cpp_string& s, u32 byte_pos); + static u32 next_codepoint_start(const cpp_string& s, u32 byte_pos); + static u8 codepoint_length(const cpp_string& s, u32 byte_pos); + static u8 encode_length(u32 codepoint); + + // Encoding/decoding + static String encode(u32 codepoint); + static String encode(const stdvec& codepoints); + static stdvec decode(const cpp_string& s); + + // Utilities + static u32 count_codepoints(const cpp_string& s); + static bool is_valid(const cpp_string& s); + }; friend std::ostream& operator<<(std::ostream& out, const String& val); - private: - static u32 utf8_next(const char*& p, const char* end); - private: ostd::cpp_string m_data; }; diff --git a/src/ostd/string/TextBuffer.cpp b/src/ostd/string/TextBuffer.cpp new file mode 100644 index 0000000..c043fba --- /dev/null +++ b/src/ostd/string/TextBuffer.cpp @@ -0,0 +1,674 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +#include "TextBuffer.hpp" + +namespace ostd +{ + // ============================================================ + // Construction + // ============================================================ + + TextBuffer::TextBuffer(void) + { + setTypeName("ostd::TextBuffer"); + validate(); + m_lineStarts.push_back(0); + m_lineStartsDirty = false; + } + + TextBuffer::TextBuffer(const String& initial, bool multiline) + { + setTypeName("ostd::TextBuffer"); + validate(); + m_multiline = multiline; + m_buffer = initial; + if (!m_multiline) strip_newlines_from_buffer(); + m_cursor = m_anchor = cast(m_buffer.len()); + m_lineStartsDirty = true; + } + + + // ============================================================ + // Content access + // ============================================================ + + u32 TextBuffer::codepointCount(void) const + { + return String::utf8::count_codepoints(m_buffer.cpp_str()); + } + + u32 TextBuffer::lineCount(void) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + return cast(m_lineStarts.size()); + } + + String TextBuffer::getLine(u32 lineIdx) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + if (lineIdx >= m_lineStarts.size()) return String(""); + const u32 start = m_lineStarts[lineIdx]; + const u32 end = (lineIdx + 1 < m_lineStarts.size()) + ? m_lineStarts[lineIdx + 1] - 1 // -1 to exclude the '\n' + : cast(m_buffer.len()); + return m_buffer.new_substr(start, cast(end)); + } + + u32 TextBuffer::lineByteStart(u32 lineIdx) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + if (lineIdx >= m_lineStarts.size()) return cast(m_buffer.len()); + return m_lineStarts[lineIdx]; + } + + u32 TextBuffer::lineByteLength(u32 lineIdx) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + if (lineIdx >= m_lineStarts.size()) return 0; + const u32 start = m_lineStarts[lineIdx]; + const u32 end = (lineIdx + 1 < m_lineStarts.size()) + ? m_lineStarts[lineIdx + 1] - 1 + : cast(m_buffer.len()); + return end - start; + } + + + // ============================================================ + // Mode + // ============================================================ + + void TextBuffer::setMultiline(bool ml) + { + if (m_multiline == ml) return; + m_multiline = ml; + if (!m_multiline) + { + strip_newlines_from_buffer(); + m_cursor = clamp_to_codepoint_boundary(m_cursor); + m_anchor = clamp_to_codepoint_boundary(m_anchor); + m_lineStartsDirty = true; + fire_changed(); + fire_cursor_moved(); + } + } + + + // ============================================================ + // Replace contents + // ============================================================ + + void TextBuffer::setText(const String& text) + { + m_buffer = text; + if (!m_multiline) strip_newlines_from_buffer(); + m_cursor = m_anchor = cast(m_buffer.len()); + m_desiredColumn = -1; + m_lineStartsDirty = true; + fire_changed(); + fire_cursor_moved(); + } + + void TextBuffer::clear(void) + { + if (m_buffer.empty() && m_cursor == 0 && m_anchor == 0) return; + m_buffer.clr(); + m_cursor = m_anchor = 0; + m_desiredColumn = -1; + m_lineStartsDirty = true; + fire_changed(); + fire_cursor_moved(); + } + + + // ============================================================ + // Cursor / selection + // ============================================================ + + IPoint TextBuffer::cursorLineCol(void) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + const u32 col = codepoint_column_in_line(m_cursor); + return IPoint(cast(col), cast(line)); + } + + void TextBuffer::setCursorByteOffset(u32 byte_offset) + { + const u32 clamped = clamp_to_codepoint_boundary(byte_offset); + move_cursor_to(clamped, false); + m_desiredColumn = -1; + } + + void TextBuffer::setCursorLineCol(IPoint pos) + { + if (m_lineStartsDirty) rebuild_line_starts(); + const i32 line_count = cast(m_lineStarts.size()); + i32 line = pos.y; + if (line < 0) line = 0; + if (line >= line_count) line = line_count - 1; + const u32 col = pos.x < 0 ? 0 : cast(pos.x); + const u32 byte = byte_offset_for_line_col(cast(line), col); + move_cursor_to(byte, false); + m_desiredColumn = -1; + } + + String TextBuffer::selectedText(void) const + { + if (!hasSelection()) return String(""); + return m_buffer.new_substr(selectionStart(), cast(selectionEnd())); + } + + void TextBuffer::clearSelection(void) + { + if (!hasSelection()) return; + m_anchor = m_cursor; + fire_cursor_moved(); + } + + void TextBuffer::selectAll(void) + { + m_anchor = 0; + m_cursor = cast(m_buffer.len()); + m_desiredColumn = -1; + fire_cursor_moved(); + } + + void TextBuffer::selectRange(u32 start_byte, u32 end_byte) + { + const u32 a = clamp_to_codepoint_boundary(start_byte); + const u32 b = clamp_to_codepoint_boundary(end_byte); + m_anchor = a; + m_cursor = b; + m_desiredColumn = -1; + fire_cursor_moved(); + } + + + // ============================================================ + // Cursor movement + // ============================================================ + + void TextBuffer::moveLeft(bool extend) + { + m_desiredColumn = -1; + if (!extend && hasSelection()) + { + // Collapse selection to its left edge instead of moving past it — + // matches the behavior of every text editor. + move_cursor_to(selectionStart(), false); + return; + } + if (m_cursor == 0) + { + if (!extend) clearSelection(); + return; + } + const u32 prev = String::utf8::prev_codepoint_start(m_buffer.cpp_str(), m_cursor); + move_cursor_to(prev, extend); + } + + void TextBuffer::moveRight(bool extend) + { + m_desiredColumn = -1; + if (!extend && hasSelection()) + { + move_cursor_to(selectionEnd(), false); + return; + } + if (m_cursor >= m_buffer.len()) + { + if (!extend) clearSelection(); + return; + } + const u32 next = String::utf8::next_codepoint_start(m_buffer.cpp_str(), m_cursor); + move_cursor_to(next, extend); + } + + void TextBuffer::moveUp(bool extend) + { + if (!m_multiline) { moveLineStart(extend); return; } + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + if (line == 0) + { + move_cursor_to(0, extend); + m_desiredColumn = -1; + return; + } + const u32 col = (m_desiredColumn >= 0) + ? cast(m_desiredColumn) + : codepoint_column_in_line(m_cursor); + if (m_desiredColumn < 0) m_desiredColumn = cast(col); + const u32 target = byte_offset_for_line_col(line - 1, col); + move_cursor_to(target, extend); + } + + void TextBuffer::moveDown(bool extend) + { + if (!m_multiline) { moveLineEnd(extend); return; } + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + if (line + 1 >= m_lineStarts.size()) + { + move_cursor_to(cast(m_buffer.len()), extend); + m_desiredColumn = -1; + return; + } + const u32 col = (m_desiredColumn >= 0) + ? cast(m_desiredColumn) + : codepoint_column_in_line(m_cursor); + if (m_desiredColumn < 0) m_desiredColumn = cast(col); + const u32 target = byte_offset_for_line_col(line + 1, col); + move_cursor_to(target, extend); + } + + void TextBuffer::moveWordLeft(bool extend) + { + m_desiredColumn = -1; + const u32 target = prev_word_boundary(m_cursor); + move_cursor_to(target, extend); + } + + void TextBuffer::moveWordRight(bool extend) + { + m_desiredColumn = -1; + const u32 target = next_word_boundary(m_cursor); + move_cursor_to(target, extend); + } + + void TextBuffer::moveLineStart(bool extend) + { + m_desiredColumn = -1; + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + move_cursor_to(m_lineStarts[line], extend); + } + + void TextBuffer::moveLineEnd(bool extend) + { + m_desiredColumn = -1; + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + const u32 end = (line + 1 < m_lineStarts.size()) + ? m_lineStarts[line + 1] - 1 + : cast(m_buffer.len()); + move_cursor_to(end, extend); + } + + void TextBuffer::moveDocumentStart(bool extend) + { + m_desiredColumn = -1; + move_cursor_to(0, extend); + } + + void TextBuffer::moveDocumentEnd(bool extend) + { + m_desiredColumn = -1; + move_cursor_to(cast(m_buffer.len()), extend); + } + + + // ============================================================ + // Mutation + // ============================================================ + + void TextBuffer::insertText(const String& text) + { + String to_insert = text; + if (!m_multiline) + { + // Strip newlines from the input rather than rejecting wholesale. + // This matches what users expect when pasting multi-line text + // into a single-line field. + to_insert.replaceAll("\r\n", " "); + to_insert.replaceAll("\n", " "); + to_insert.replaceAll("\r", " "); + } + else + { + // Normalize line endings to '\n' on the way in. This is the only + // place we need to think about CRLF vs LF — once inside the buffer, + // everything is LF. + to_insert.replaceAll("\r\n", "\n"); + to_insert.replaceAll("\r", "\n"); + } + replace_range(selectionStart(), selectionEnd(), to_insert); + m_desiredColumn = -1; + } + + void TextBuffer::insertCodepoint(u32 codepoint) + { + if (!m_multiline && codepoint == '\n') return; + const String encoded = String::utf8::encode(codepoint); + if (encoded.empty()) return; // Invalid codepoint — silently drop. + replace_range(selectionStart(), selectionEnd(), encoded); + m_desiredColumn = -1; + } + + void TextBuffer::backspace(void) + { + m_desiredColumn = -1; + if (hasSelection()) + { + replace_range(selectionStart(), selectionEnd(), String("")); + return; + } + if (m_cursor == 0) return; + const u32 prev = String::utf8::prev_codepoint_start(m_buffer.cpp_str(), m_cursor); + replace_range(prev, m_cursor, String("")); + } + + void TextBuffer::deleteForward(void) + { + m_desiredColumn = -1; + if (hasSelection()) + { + replace_range(selectionStart(), selectionEnd(), String("")); + return; + } + if (m_cursor >= m_buffer.len()) return; + const u32 next = String::utf8::next_codepoint_start(m_buffer.cpp_str(), m_cursor); + replace_range(m_cursor, next, String("")); + } + + void TextBuffer::backspaceWord(void) + { + m_desiredColumn = -1; + if (hasSelection()) + { + replace_range(selectionStart(), selectionEnd(), String("")); + return; + } + if (m_cursor == 0) return; + const u32 target = prev_word_boundary(m_cursor); + replace_range(target, m_cursor, String("")); + } + + void TextBuffer::deleteWord(void) + { + m_desiredColumn = -1; + if (hasSelection()) + { + replace_range(selectionStart(), selectionEnd(), String("")); + return; + } + if (m_cursor >= m_buffer.len()) return; + const u32 target = next_word_boundary(m_cursor); + replace_range(m_cursor, target, String("")); + } + + void TextBuffer::deleteCurrentLine(void) + { + m_desiredColumn = -1; + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(m_cursor); + const u32 start = m_lineStarts[line]; + // Delete through the trailing newline (if there is one) so the + // next line moves up. On the last line, just delete to end. + const u32 end = (line + 1 < m_lineStarts.size()) + ? m_lineStarts[line + 1] + : cast(m_buffer.len()); + replace_range(start, end, String("")); + } + + + // ============================================================ + // toString + // ============================================================ + + String TextBuffer::toString(void) const + { + String s = getObjectHeaderString(); + s.add(" { bytes="); + s.add(cast(m_buffer.len())); + s.add(", lines="); + s.add(lineCount()); + s.add(", cursor@"); + s.add(m_cursor); + s.add(m_multiline ? ", multiline }" : ", single-line }"); + return s; + } + + + // ============================================================ + // Private — chokepoints + // ============================================================ + + void TextBuffer::replace_range(u32 start_byte, u32 end_byte, const String& with) + { + // Defensive clamping. In normal usage the public methods already + // produce well-formed boundaries, but we want the chokepoint itself + // to be unconditionally safe. + if (start_byte > m_buffer.len()) start_byte = cast(m_buffer.len()); + if (end_byte > m_buffer.len()) end_byte = cast(m_buffer.len()); + if (start_byte > end_byte) std::swap(start_byte, end_byte); + + // Skip no-op edits to avoid spurious notifications. + if (start_byte == end_byte && with.empty()) return; + + // Build the new buffer. + String head = m_buffer.new_substr(0, cast(start_byte)); + String tail = m_buffer.new_substr(end_byte, cast(m_buffer.len())); + m_buffer = head + with + tail; + + // Cursor lands at the end of the inserted text. Selection collapses. + const u32 new_cursor = start_byte + cast(with.len()); + m_cursor = m_anchor = new_cursor; + + m_lineStartsDirty = true; + fire_changed(); + fire_cursor_moved(); + } + + void TextBuffer::move_cursor_to(u32 byte_offset, bool extend) + { + if (byte_offset > m_buffer.len()) byte_offset = cast(m_buffer.len()); + // We trust the caller produced a codepoint-aligned offset; the public + // movement primitives all do. This is the "internal" entrypoint. + const u32 old_cursor = m_cursor; + const u32 old_anchor = m_anchor; + m_cursor = byte_offset; + if (!extend) m_anchor = byte_offset; + if (m_cursor != old_cursor || m_anchor != old_anchor) + fire_cursor_moved(); + } + + + // ============================================================ + // Private — line tracking + // ============================================================ + + void TextBuffer::rebuild_line_starts(void) const + { + m_lineStarts.clear(); + m_lineStarts.push_back(0); + const cpp_string& s = m_buffer.cpp_str(); // Note: returns by value; + // since cpp_str() returns a copy, this scan is over a temporary, which + // is fine for a one-off rebuild but worth being aware of. If you ever + // add cpp_str_ref() const, prefer that here. + for (u32 i = 0; i < s.size(); i++) + { + if (s[i] == '\n') + m_lineStarts.push_back(i + 1); + } + m_lineStartsDirty = false; + } + + u32 TextBuffer::line_index_for_byte(u32 byte_offset) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + // Binary search: find largest line whose start <= byte_offset. + u32 lo = 0, hi = cast(m_lineStarts.size()); + while (lo + 1 < hi) + { + const u32 mid = (lo + hi) / 2; + if (m_lineStarts[mid] <= byte_offset) lo = mid; + else hi = mid; + } + return lo; + } + + + // ============================================================ + // Private — column / line+col conversions + // ============================================================ + + u32 TextBuffer::codepoint_column_in_line(u32 byte_offset) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + const u32 line = line_index_for_byte(byte_offset); + const u32 line_start = m_lineStarts[line]; + // Count codepoints between line_start and byte_offset. + const cpp_string& s = m_buffer.cpp_str(); + u32 col = 0; + u32 i = line_start; + while (i < byte_offset && i < s.size()) + { + if ((cast(s[i]) & 0xC0) != 0x80) col++; + i++; + } + return col; + } + + u32 TextBuffer::byte_offset_for_line_col(u32 line, u32 codepoint_col) const + { + if (m_lineStartsDirty) rebuild_line_starts(); + if (m_lineStarts.empty()) return 0; + if (line >= m_lineStarts.size()) line = cast(m_lineStarts.size()) - 1; + const u32 line_start = m_lineStarts[line]; + const u32 line_end = (line + 1 < m_lineStarts.size()) + ? m_lineStarts[line + 1] - 1 + : cast(m_buffer.len()); + + // Walk forward codepoint by codepoint from line_start, stopping when + // we hit the requested column or the end of the line. + u32 i = line_start; + u32 col = 0; + const cpp_string s = m_buffer.cpp_str(); + while (i < line_end && col < codepoint_col) + { + i = String::utf8::next_codepoint_start(s, i); + col++; + } + return i; + } + + + // ============================================================ + // Private — word boundaries + // ============================================================ + + // Word-class categorization. We use a simple tri-state: + // - Word: ASCII letters/digits/underscore, plus any non-ASCII + // byte (treats all non-ASCII as "word" — good enough + // default for European/CJK text without a Unicode DB). + // - Whitespace: ASCII space/tab/newline/CR. + // - Punctuation: everything else printable ASCII. + // + // More sophisticated word segmentation (UAX #29) is out of scope; this + // rule is what most editors implement and it matches user intuition for + // the languages our font is likely to support. + namespace { + enum class WordClass { Word, Whitespace, Punctuation }; + + WordClass classify_byte(u8 b) + { + if (b == ' ' || b == '\t' || b == '\n' || b == '\r') + return WordClass::Whitespace; + if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '_') + return WordClass::Word; + if (b >= 0x80) // Treat all non-ASCII as word. + return WordClass::Word; + return WordClass::Punctuation; + } + } + + u32 TextBuffer::prev_word_boundary(u32 byte_offset) const + { + const cpp_string& s = m_buffer.cpp_str(); + if (byte_offset == 0) return 0; + u32 i = byte_offset; + // Step 1: skip any whitespace immediately to the left. + while (i > 0) + { + const u32 prev = String::utf8::prev_codepoint_start(s, i); + if (classify_byte(cast(s[prev])) != WordClass::Whitespace) break; + i = prev; + } + // Step 2: skip the run of same-class characters to the left. + if (i == 0) return 0; + const u32 first_prev = String::utf8::prev_codepoint_start(s, i); + const WordClass cls = classify_byte(cast(s[first_prev])); + while (i > 0) + { + const u32 prev = String::utf8::prev_codepoint_start(s, i); + if (classify_byte(cast(s[prev])) != cls) break; + i = prev; + } + return i; + } + + u32 TextBuffer::next_word_boundary(u32 byte_offset) const + { + const cpp_string& s = m_buffer.cpp_str(); + const u32 size = cast(s.size()); + if (byte_offset >= size) return size; + u32 i = byte_offset; + // Step 1: skip the run of same-class characters at the cursor. + const WordClass cls = classify_byte(cast(s[i])); + while (i < size && classify_byte(cast(s[i])) == cls) + i = String::utf8::next_codepoint_start(s, i); + // Step 2: skip any whitespace following. + while (i < size && classify_byte(cast(s[i])) == WordClass::Whitespace) + i = String::utf8::next_codepoint_start(s, i); + return i; + } + + + // ============================================================ + // Private — utility + // ============================================================ + + u32 TextBuffer::clamp_to_codepoint_boundary(u32 byte_offset) const + { + if (byte_offset >= m_buffer.len()) return cast(m_buffer.len()); + // If we're on a continuation byte, walk back to the lead byte. + return String::utf8::align_to_codepoint(m_buffer.cpp_str(), byte_offset); + } + + void TextBuffer::strip_newlines_from_buffer(void) + { + m_buffer.replaceAll("\r\n", " "); + m_buffer.replaceAll("\n", " "); + m_buffer.replaceAll("\r", " "); + } + + void TextBuffer::fire_changed(void) + { + if (m_onChanged) m_onChanged(*this); + } + + void TextBuffer::fire_cursor_moved(void) + { + if (m_onCursorMoved) m_onCursorMoved(*this); + } +} diff --git a/src/ostd/string/TextBuffer.hpp b/src/ostd/string/TextBuffer.hpp new file mode 100644 index 0000000..968b60c --- /dev/null +++ b/src/ostd/string/TextBuffer.hpp @@ -0,0 +1,171 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +#pragma once + +#include +#include +#include +#include + +namespace ostd +{ + /** + * TextBuffer — owns a UTF-8 text document and manages cursor/selection + * state along with all higher-level editing operations. + * + * Design invariants (these hold AT ALL TIMES outside private methods): + * 1. m_buffer contains valid UTF-8. + * 2. m_cursor and m_anchor are byte offsets in [0, m_buffer.size()]. + * 3. m_cursor and m_anchor always sit on a codepoint boundary. + * 4. When m_multiline is false, m_buffer contains no '\n' bytes. + * + * Coordinate spaces: + * - The canonical cursor representation is a single byte offset. + * - IPoint line/col is a derived view, computed on demand from a + * cached line-starts table that's lazily rebuilt after edits. + * - "col" in IPoint is measured in CODEPOINTS, not bytes — this is + * what callers (renderers, click hit-testing) actually want. + * + * Notifications: + * - onChanged fires whenever buffer content is mutated. + * - onCursorMoved fires whenever the cursor or selection changes + * without a content change. + * - Both fire on operations that do both (the cursor-move callback + * fires after the change callback, so observers can scroll the + * view to follow the cursor *after* layout has been updated). + */ + class TextBuffer : public BaseObject + { + public: using ChangeCallback = std::function; + + public: + TextBuffer(void); + explicit TextBuffer(const String& initial, bool multiline = false); + + // ===================== Content access ===================== + inline const String& text(void) const { return m_buffer; } + inline u32 byteSize(void) const { return cast(m_buffer.len()); } + inline bool empty(void) const { return m_buffer.empty(); } + u32 codepointCount(void) const; + u32 lineCount(void) const; + String getLine(u32 lineIdx) const; + u32 lineByteStart(u32 lineIdx) const; + u32 lineByteLength(u32 lineIdx) const; // Excludes the trailing '\n'. + + // ====================== Mode toggle ======================= + // When switching to single-line, any existing newlines are stripped. + void setMultiline(bool ml); + inline bool isMultiline(void) const { return m_multiline; } + + // ===================== Replace contents =================== + // Replaces the entire buffer atomically. Cursor is clamped/reset. + void setText(const String& text); + void clear(void); + + // ====================== Cursor state ====================== + inline u32 cursorByteOffset(void) const { return m_cursor; } + IPoint cursorLineCol(void) const; + void setCursorByteOffset(u32 byte_offset); + void setCursorLineCol(IPoint pos); + + // ====================== Selection ========================= + inline bool hasSelection(void) const { return m_cursor != m_anchor; } + inline u32 selectionStart(void) const { return m_cursor < m_anchor ? m_cursor : m_anchor; } + inline u32 selectionEnd(void) const { return m_cursor < m_anchor ? m_anchor : m_cursor; } + inline u32 anchorByteOffset(void) const { return m_anchor; } + String selectedText(void) const; + void clearSelection(void); + void selectAll(void); + void selectRange(u32 start_byte, u32 end_byte); + + // ===================== Cursor movement ==================== + // Each of these collapses the selection unless extend == true. + void moveLeft(bool extend = false); + void moveRight(bool extend = false); + void moveUp(bool extend = false); + void moveDown(bool extend = false); + void moveWordLeft(bool extend = false); + void moveWordRight(bool extend = false); + void moveLineStart(bool extend = false); + void moveLineEnd(bool extend = false); + void moveDocumentStart(bool extend = false); + void moveDocumentEnd(bool extend = false); + + // ======================== Mutation ======================== + // All mutators replace the current selection (if any) before + // performing their action. + void insertText(const String& text); + void insertCodepoint(u32 codepoint); + void backspace(void); + void deleteForward(void); + void backspaceWord(void); + void deleteWord(void); + void deleteCurrentLine(void); + + // ===================== Notifications ====================== + inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); } + inline void setOnCursorMoved(ChangeCallback cb) { m_onCursorMoved = std::move(cb); } + + // ==================== BaseObject hooks ==================== + String toString(void) const override; + + private: + // The two chokepoints — every public method funnels through these. + // replace_range is the only place buffer content changes. + // move_cursor_to is the only place m_cursor/m_anchor change. + void replace_range(u32 start_byte, u32 end_byte, const String& with); + void move_cursor_to(u32 byte_offset, bool extend); + + // Helpers + void rebuild_line_starts(void) const; + u32 line_index_for_byte(u32 byte_offset) const; + u32 prev_word_boundary(u32 byte_offset) const; + u32 next_word_boundary(u32 byte_offset) const; + u32 codepoint_column_in_line(u32 byte_offset) const; + u32 byte_offset_for_line_col(u32 line, u32 codepoint_col) const; + u32 clamp_to_codepoint_boundary(u32 byte_offset) const; + void strip_newlines_from_buffer(void); + + void fire_changed(void); + void fire_cursor_moved(void); + + private: + String m_buffer; + u32 m_cursor { 0 }; + u32 m_anchor { 0 }; + + // Sticky desired column for vertical movement: when you move up + // from col 30 onto a 5-char line, the cursor visually goes to + // col 5 but remembers 30, so moving up again lands at col 30 of + // that line. Reset to -1 by any horizontal movement or edit. + i32 m_desiredColumn { -1 }; + + bool m_multiline { false }; + + // Line-starts cache: byte offsets where each line begins. + // m_lineStarts[0] is always 0. Rebuilt lazily on access. + mutable stdvec m_lineStarts; + mutable bool m_lineStartsDirty { true }; + + ChangeCallback m_onChanged { nullptr }; + ChangeCallback m_onCursorMoved { nullptr }; + }; +} diff --git a/src/ostd/string/utf8.cpp b/src/ostd/string/utf8.cpp new file mode 100644 index 0000000..3355382 --- /dev/null +++ b/src/ostd/string/utf8.cpp @@ -0,0 +1,282 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +#include "String.hpp" + +namespace ostd +{ + stdvec String::utf8::decode(const cpp_string& s) + { + stdvec out; + out.reserve(s.size()); // Upper bound: 1 codepoint per byte (pure ASCII). + + const u8* p = reinterpret_cast(s.data()); + const u8* end = p + s.size(); + + while (p < end) + { + u8 c = *p; + + // ASCII fast path — handles the vast majority of typical input. + if (c < 0x80) { + out.push_back(c); + p++; + continue; + } + + // Decode multibyte sequence. Determine length from lead byte, + // then read continuation bytes. Any malformed input emits U+FFFD + // and advances by one byte to recover. + u32 codepoint = 0; + u32 expected = 0; + + if ((c >> 5) == 0x6) { expected = 1; codepoint = c & 0x1F; } + else if ((c >> 4) == 0xE) { expected = 2; codepoint = c & 0x0F; } + else if ((c >> 3) == 0x1E) { expected = 3; codepoint = c & 0x07; } + else { + // Continuation byte where a lead was expected, or invalid lead. + out.push_back(0xFFFD); + p++; + continue; + } + + p++; + bool ok = true; + for (u32 i = 0; i < expected; i++) { + if (p >= end || (*p & 0xC0) != 0x80) { + ok = false; + break; + } + codepoint = (codepoint << 6) | (*p & 0x3F); + p++; + } + + out.push_back(ok ? codepoint : 0xFFFD); + } + + return out; + } + + String String::utf8::encode(const stdvec& codepoints) + { + // Pre-compute exact byte size to allocate once. This walks the vector + // twice but avoids any reallocation in the underlying string buffer. + u32 total_bytes = 0; + for (u32 cp : codepoints) + total_bytes += encode_length(cp); // Returns 0 for invalid → skipped. + + String out; + out.cpp_str_ref().reserve(total_bytes); + + // Append-by-byte. Mirrors the single-codepoint utf8_encode logic but + // writes directly into the output buffer with no intermediate Strings. + for (u32 cp : codepoints) + { + if (cp < 0x80) { + out.addChar(cast(cp)); + } + else if (cp < 0x800) { + out.addChar(cast(0xC0 | (cp >> 6))); + out.addChar(cast(0x80 | (cp & 0x3F))); + } + else if (cp < 0x10000) { + // Reject UTF-16 surrogates: they are not valid standalone codepoints. + if (cp >= 0xD800 && cp <= 0xDFFF) continue; + out.addChar(cast(0xE0 | (cp >> 12))); + out.addChar(cast(0x80 | ((cp >> 6) & 0x3F))); + out.addChar(cast(0x80 | (cp & 0x3F))); + } + else if (cp <= 0x10FFFF) { + out.addChar(cast(0xF0 | (cp >> 18))); + out.addChar(cast(0x80 | ((cp >> 12) & 0x3F))); + out.addChar(cast(0x80 | ((cp >> 6) & 0x3F))); + out.addChar(cast(0x80 | (cp & 0x3F))); + } + // else: codepoint beyond Unicode range — skipped silently. + } + + return out; + } + + u32 String::utf8::align_to_codepoint(const cpp_string& s, u32 byte_pos) + { + if (byte_pos >= s.size()) return cast(s.size()); + // Already on a lead byte (or ASCII): nothing to do. + if ((cast(s[byte_pos]) & 0xC0) != 0x80) return byte_pos; + // We're on a continuation byte. Walk backward to the lead byte. + u32 i = byte_pos; + while (i > 0 && (cast(s[i]) & 0xC0) == 0x80) + i--; + return i; + } + + u32 String::utf8::prev_codepoint_start(const cpp_string& s, u32 byte_pos) + { + if (byte_pos == 0 || s.empty()) return 0; + // Clamp to a valid index. If we're past end, start from the last byte. + u32 i = (byte_pos > s.size()) ? cast(s.size()) : byte_pos; + if (i == 0) return 0; + i--; + // A continuation byte has the bit pattern 10xxxxxx (top two bits == 10). + // Walk backward over continuation bytes until we find a lead byte + // (anything that is NOT a continuation byte: ASCII or multibyte lead). + while (i > 0 && (cast(s[i]) & 0xC0) == 0x80) + i--; + return i; + } + + u32 String::utf8::next_codepoint_start(const cpp_string& s, u32 byte_pos) + { + if (byte_pos >= s.size()) return cast(s.size()); + u8 c = cast(s[byte_pos]); + + // ASCII fast path: advance by 1. + if (c < 0x80) return byte_pos + 1; + + // Determine sequence length from the lead byte. + u32 advance = 1; + if ((c >> 5) == 0x6) advance = 2; // 110xxxxx + else if ((c >> 4) == 0xE) advance = 3; // 1110xxxx + else if ((c >> 3) == 0x1E) advance = 4; // 11110xxx + // If c is a continuation byte (10xxxxxx) or invalid lead, fall back + // to advancing by 1 — gracefully recover from malformed UTF-8. + + u32 result = byte_pos + advance; + if (result > s.size()) result = cast(s.size()); + return result; + } + + u8 String::utf8::codepoint_length(const cpp_string& s, u32 byte_pos) + { + if (byte_pos >= s.size()) return 0; + u8 c = cast(s[byte_pos]); + if (c < 0x80) return 1; // ASCII + if ((c >> 5) == 0x6) return 2; // 110xxxxx + if ((c >> 4) == 0xE) return 3; // 1110xxxx + if ((c >> 3) == 0x1E) return 4; // 11110xxx + // Continuation byte or invalid lead — caller is misaligned. + // Return 1 so the caller still makes forward progress. + return 1; + } + + u8 String::utf8::encode_length(u32 codepoint) + { + if (codepoint < 0x80) return 1; + if (codepoint < 0x800) return 2; + if (codepoint < 0x10000) return 3; + if (codepoint <= 0x10FFFF) return 4; + return 0; // Beyond Unicode's defined range. + } + + String String::utf8::encode(u32 codepoint) + { + String out; + if (codepoint < 0x80) { + out.addChar(cast(codepoint)); + } + else if (codepoint < 0x800) { + out.addChar(cast(0xC0 | (codepoint >> 6))); + out.addChar(cast(0x80 | (codepoint & 0x3F))); + } + else if (codepoint < 0x10000) { + out.addChar(cast(0xE0 | (codepoint >> 12))); + out.addChar(cast(0x80 | ((codepoint >> 6) & 0x3F))); + out.addChar(cast(0x80 | (codepoint & 0x3F))); + } + else if (codepoint <= 0x10FFFF) { + out.addChar(cast(0xF0 | (codepoint >> 18))); + out.addChar(cast(0x80 | ((codepoint >> 12) & 0x3F))); + out.addChar(cast(0x80 | ((codepoint >> 6) & 0x3F))); + out.addChar(cast(0x80 | (codepoint & 0x3F))); + } + // else: invalid codepoint — return empty String. + return out; + } + + u32 String::utf8::count_codepoints(const cpp_string& s) + { + u32 count = 0; + const char* p = s.data(); + const char* end = p + s.size(); + while (p < end) { + // Every byte that is NOT a continuation byte starts a new codepoint. + if ((cast(*p) & 0xC0) != 0x80) + count++; + p++; + } + return count; + } + + bool String::utf8::is_valid(const cpp_string& s) + { + const u8* p = reinterpret_cast(s.data()); + const u8* end = p + s.size(); + + while (p < end) + { + u8 c = *p; + u32 expected_continuations = 0; + u32 codepoint = 0; + u32 min_codepoint = 0; // Used to reject overlong encodings. + + if (c < 0x80) { + p++; + continue; + } + else if ((c >> 5) == 0x6) { + expected_continuations = 1; + codepoint = c & 0x1F; + min_codepoint = 0x80; + } + else if ((c >> 4) == 0xE) { + expected_continuations = 2; + codepoint = c & 0x0F; + min_codepoint = 0x800; + } + else if ((c >> 3) == 0x1E) { + expected_continuations = 3; + codepoint = c & 0x07; + min_codepoint = 0x10000; + } + else { + // Continuation byte where a lead byte was expected, or a 5/6-byte + // sequence (which is illegal in modern UTF-8). + return false; + } + + p++; + for (u32 i = 0; i < expected_continuations; i++) { + if (p >= end) return false; + if ((*p & 0xC0) != 0x80) return false; // Not a continuation byte. + codepoint = (codepoint << 6) | (*p & 0x3F); + p++; + } + + // Reject overlong encodings (e.g., encoding U+0041 in 2 bytes). + if (codepoint < min_codepoint) return false; + // Reject codepoints outside Unicode's defined range. + if (codepoint > 0x10FFFF) return false; + // Reject UTF-16 surrogates (they're not valid standalone codepoints). + if (codepoint >= 0xD800 && codepoint <= 0xDFFF) return false; + } + + return true; + } +} diff --git a/src/test/TextBufferUnitTest.cpp b/src/test/TextBufferUnitTest.cpp new file mode 100644 index 0000000..4da0fd7 --- /dev/null +++ b/src/test/TextBufferUnitTest.cpp @@ -0,0 +1,730 @@ +/* + OmniaFramework - A collection of useful functionality + Copyright (C) 2026 OmniaX-Dev + + This file is part of OmniaFramework. + + OmniaFramework is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OmniaFramework is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OmniaFramework. If not, see . +*/ + +// Standalone tests for ostd::TextBuffer. +// Exercises invariants, edge cases, UTF-8 handling, multiline behavior, +// word boundaries, selection, and the desired-column sticky behavior. +// +// Run as a separate executable. No external test framework — just a +// minimal in-house assertion macro that records pass/fail counts. + +#include +#include +#include + +namespace { + +// ============================================================ +// Tiny test harness +// ============================================================ + +struct TestStats { + u32 total { 0 }; + u32 passed { 0 }; + u32 failed { 0 }; + String currentTest; + stdvec failures; +}; + +static TestStats g_stats; +static ostd::ConsoleOutputHandler g_out; + +static void __record_pass(void) +{ + g_stats.total++; + g_stats.passed++; +} + +static void __record_fail(const String& test, const String& detail, i32 line) +{ + g_stats.total++; + g_stats.failed++; + String msg; + msg.add(" [").add(test).add(":").add(line).add("] ").add(detail); + g_stats.failures.push_back(msg); + g_out.fg(ostd::ConsoleColors::BrightRed).p("F").reset().flush(); +} + +#define CHECK(cond) \ + do { \ + if (cond) { __record_pass(); \ + g_out.fg(ostd::ConsoleColors::BrightGreen).p(".").reset().flush(); \ + } \ + else { \ + __record_fail(g_stats.currentTest, String("CHECK failed: ") + #cond, __LINE__); \ + } \ + } while(0) + +#define CHECK_EQ(actual, expected) \ + do { \ + auto __a = (actual); \ + auto __e = (expected); \ + if (__a == __e) { __record_pass(); \ + g_out.fg(ostd::ConsoleColors::BrightGreen).p(".").reset().flush(); \ + } \ + else { \ + String __msg; \ + __msg.add("CHECK_EQ failed: ").add(#actual).add(" == ").add(#expected); \ + __msg.add(" (got ").add(String("").add(__a)).add(", expected ").add(String("").add(__e)).add(")"); \ + __record_fail(g_stats.currentTest, __msg, __LINE__); \ + } \ + } while(0) + +// String comparison overload — String::add doesn't accept String, so handle separately. +#define CHECK_STR_EQ(actual, expected) \ + do { \ + String __a = (actual); \ + String __e = (expected); \ + if (__a == __e.c_str()) { __record_pass(); \ + g_out.fg(ostd::ConsoleColors::BrightGreen).p(".").reset().flush(); \ + } \ + else { \ + String __msg; \ + __msg.add("CHECK_STR_EQ failed: got \"").add(__a).add("\", expected \"").add(__e).add("\""); \ + __record_fail(g_stats.currentTest, __msg, __LINE__); \ + } \ + } while(0) + +#define TEST(name) \ + g_stats.currentTest = name; \ + g_out.nl().fg(ostd::ConsoleColors::Cyan).p("[").p(name).p("] ").reset().flush(); + + +// ============================================================ +// String literals for the UTF-8 test corpus +// ============================================================ +// +// "café" = 'c' 'a' 'f' (0xC3 0xA9) → 5 bytes, 4 codepoints +// "日本" = (0xE6 0x97 0xA5) (0xE6 0x9C 0xAC) → 6 bytes, 2 codepoints +// "😀" = (0xF0 0x9F 0x98 0x80) → 4 bytes, 1 codepoint +// "héllo" = 'h' (0xC3 0xA9) 'l' 'l' 'o' → 6 bytes, 5 codepoints + +// ============================================================ +// Tests +// ============================================================ + +void test_construction_and_basic(void) +{ + TEST("construction_and_basic"); + + ostd::TextBuffer empty; + CHECK(empty.empty()); + CHECK_EQ(empty.byteSize(), 0u); + CHECK_EQ(empty.cursorByteOffset(), 0u); + CHECK_EQ(empty.lineCount(), 1u); // Always at least one (empty) line. + CHECK(!empty.hasSelection()); + + ostd::TextBuffer hello("hello"); + CHECK_EQ(hello.byteSize(), 5u); + CHECK_EQ(hello.codepointCount(), 5u); + CHECK_EQ(hello.cursorByteOffset(), 5u); // Constructor parks cursor at end. + CHECK_STR_EQ(hello.text(), "hello"); +} + +void test_setText_and_clear(void) +{ + TEST("setText_and_clear"); + + ostd::TextBuffer buf; + buf.setText("hello"); + CHECK_STR_EQ(buf.text(), "hello"); + CHECK_EQ(buf.cursorByteOffset(), 5u); + + buf.clear(); + CHECK(buf.empty()); + CHECK_EQ(buf.cursorByteOffset(), 0u); + CHECK_EQ(buf.lineCount(), 1u); +} + +void test_simple_insert_and_backspace(void) +{ + TEST("simple_insert_and_backspace"); + + ostd::TextBuffer buf; + buf.insertCodepoint('h'); + buf.insertCodepoint('i'); + CHECK_STR_EQ(buf.text(), "hi"); + CHECK_EQ(buf.cursorByteOffset(), 2u); + + buf.backspace(); + CHECK_STR_EQ(buf.text(), "h"); + CHECK_EQ(buf.cursorByteOffset(), 1u); + + buf.backspace(); + CHECK(buf.empty()); + + // Backspace on empty must be a no-op. + buf.backspace(); + CHECK(buf.empty()); + CHECK_EQ(buf.cursorByteOffset(), 0u); +} + +void test_insert_text_at_position(void) +{ + TEST("insert_text_at_position"); + + ostd::TextBuffer buf("hello world"); + buf.setCursorByteOffset(5); + buf.insertText(","); + CHECK_STR_EQ(buf.text(), "hello, world"); + CHECK_EQ(buf.cursorByteOffset(), 6u); + + buf.setCursorByteOffset(0); + buf.insertText("Why "); + CHECK_STR_EQ(buf.text(), "Why hello, world"); + CHECK_EQ(buf.cursorByteOffset(), 4u); +} + +void test_utf8_backspace(void) +{ + TEST("utf8_backspace"); + + // "café" — 5 bytes, 4 codepoints. Backspacing once removes 'é' (2 bytes). + ostd::TextBuffer buf("café"); + CHECK_EQ(buf.byteSize(), 5u); + CHECK_EQ(buf.codepointCount(), 4u); + CHECK_EQ(buf.cursorByteOffset(), 5u); + + buf.backspace(); + CHECK_STR_EQ(buf.text(), "caf"); + CHECK_EQ(buf.cursorByteOffset(), 3u); + CHECK_EQ(buf.codepointCount(), 3u); +} + +void test_utf8_movement(void) +{ + TEST("utf8_movement"); + + // "héllo" = 6 bytes: 'h'(1) é(2) 'l'(1) 'l'(1) 'o'(1) + ostd::TextBuffer buf("héllo"); + CHECK_EQ(buf.byteSize(), 6u); + CHECK_EQ(buf.cursorByteOffset(), 6u); + + buf.moveLeft(); // before 'o' + CHECK_EQ(buf.cursorByteOffset(), 5u); + buf.moveLeft(); // before 'l' + CHECK_EQ(buf.cursorByteOffset(), 4u); + buf.moveLeft(); // before 'l' + CHECK_EQ(buf.cursorByteOffset(), 3u); + buf.moveLeft(); // before 'é' — must skip both bytes. + CHECK_EQ(buf.cursorByteOffset(), 1u); + buf.moveLeft(); // before 'h' + CHECK_EQ(buf.cursorByteOffset(), 0u); + buf.moveLeft(); // no-op at start + CHECK_EQ(buf.cursorByteOffset(), 0u); + + // And forward. + buf.moveRight(); + CHECK_EQ(buf.cursorByteOffset(), 1u); + buf.moveRight(); + CHECK_EQ(buf.cursorByteOffset(), 3u); // skipped past 2-byte 'é' +} + +void test_utf8_4byte_codepoint(void) +{ + TEST("utf8_4byte_codepoint"); + + // "a😀b" — 'a' (1) + 😀 (4) + 'b' (1) = 6 bytes, 3 codepoints + ostd::TextBuffer buf("a😀b"); + CHECK_EQ(buf.byteSize(), 6u); + CHECK_EQ(buf.codepointCount(), 3u); + + buf.setCursorByteOffset(6); + buf.moveLeft(); // before 'b' + CHECK_EQ(buf.cursorByteOffset(), 5u); + buf.moveLeft(); // before 😀 — must skip 4 bytes + CHECK_EQ(buf.cursorByteOffset(), 1u); + buf.moveLeft(); // before 'a' + CHECK_EQ(buf.cursorByteOffset(), 0u); + + // Backspace from end deletes 'b' (1 byte), then 😀 (4 bytes), then 'a'. + buf.setCursorByteOffset(6); + buf.backspace(); + CHECK_STR_EQ(buf.text(), "a😀"); + CHECK_EQ(buf.cursorByteOffset(), 5u); + buf.backspace(); + CHECK_STR_EQ(buf.text(), "a"); + CHECK_EQ(buf.cursorByteOffset(), 1u); + buf.backspace(); + CHECK(buf.empty()); +} + +void test_clamp_to_codepoint_boundary(void) +{ + TEST("clamp_to_codepoint_boundary"); + + // "café" — setting cursor to byte 4 (middle of 'é') should clamp to 3. + ostd::TextBuffer buf("café"); + buf.setCursorByteOffset(4); // middle of multibyte sequence + CHECK_EQ(buf.cursorByteOffset(), 3u); +} + +void test_selection_basic(void) +{ + TEST("selection_basic"); + + ostd::TextBuffer buf("hello world"); + buf.selectAll(); + CHECK(buf.hasSelection()); + CHECK_EQ(buf.selectionStart(), 0u); + CHECK_EQ(buf.selectionEnd(), 11u); + CHECK_STR_EQ(buf.selectedText(), "hello world"); + + buf.clearSelection(); + CHECK(!buf.hasSelection()); + + buf.selectRange(6, 11); + CHECK_STR_EQ(buf.selectedText(), "world"); + + // Inverted range: anchor > cursor still selects same span. + buf.selectRange(11, 6); + CHECK(buf.hasSelection()); + CHECK_EQ(buf.selectionStart(), 6u); + CHECK_EQ(buf.selectionEnd(), 11u); + CHECK_STR_EQ(buf.selectedText(), "world"); +} + +void test_selection_replace_on_insert(void) +{ + TEST("selection_replace_on_insert"); + + ostd::TextBuffer buf("hello world"); + buf.selectRange(6, 11); + buf.insertText("there"); + CHECK_STR_EQ(buf.text(), "hello there"); + CHECK(!buf.hasSelection()); + CHECK_EQ(buf.cursorByteOffset(), 11u); // end of inserted text +} + +void test_selection_replace_on_backspace(void) +{ + TEST("selection_replace_on_backspace"); + + ostd::TextBuffer buf("hello world"); + buf.selectRange(5, 11); // ", world" + buf.backspace(); + CHECK_STR_EQ(buf.text(), "hello"); + CHECK(!buf.hasSelection()); + CHECK_EQ(buf.cursorByteOffset(), 5u); +} + +void test_extend_selection(void) +{ + TEST("extend_selection"); + + ostd::TextBuffer buf("hello"); + buf.setCursorByteOffset(0); + buf.moveRight(true); + buf.moveRight(true); + buf.moveRight(true); + CHECK(buf.hasSelection()); + CHECK_EQ(buf.cursorByteOffset(), 3u); + CHECK_EQ(buf.anchorByteOffset(), 0u); + CHECK_STR_EQ(buf.selectedText(), "hel"); + + // Move-without-extend collapses to the right edge of the selection + // (matches macOS / modern Windows / Qt / GTK convention). + buf.moveRight(false); + CHECK(!buf.hasSelection()); + CHECK_EQ(buf.cursorByteOffset(), 3u); // was 4u — collapsed, did not advance further + + // And from a selection, plain moveLeft collapses to the LEFT edge. + buf.setCursorByteOffset(0); + buf.moveRight(true); + buf.moveRight(true); + CHECK_EQ(buf.cursorByteOffset(), 2u); + buf.moveLeft(false); + CHECK(!buf.hasSelection()); + CHECK_EQ(buf.cursorByteOffset(), 0u); // collapsed to selection start +} + +void test_multiline_basic(void) +{ + TEST("multiline_basic"); + + ostd::TextBuffer buf("line1\nline2\nline3", true); + CHECK(buf.isMultiline()); + CHECK_EQ(buf.lineCount(), 3u); + CHECK_STR_EQ(buf.getLine(0), "line1"); + CHECK_STR_EQ(buf.getLine(1), "line2"); + CHECK_STR_EQ(buf.getLine(2), "line3"); + CHECK_EQ(buf.lineByteStart(0), 0u); + CHECK_EQ(buf.lineByteStart(1), 6u); // after "line1\n" + CHECK_EQ(buf.lineByteStart(2), 12u); + CHECK_EQ(buf.lineByteLength(0), 5u); // "line1" without the '\n' +} + +void test_multiline_insertion(void) +{ + TEST("multiline_insertion"); + + ostd::TextBuffer buf("hello", true); + buf.setCursorByteOffset(5); + buf.insertCodepoint('\n'); + buf.insertText("world"); + CHECK_STR_EQ(buf.text(), "hello\nworld"); + CHECK_EQ(buf.lineCount(), 2u); + + // Inserting CRLF should normalize to LF. + buf.clear(); + buf.insertText("a\r\nb\r\nc"); + CHECK_STR_EQ(buf.text(), "a\nb\nc"); + CHECK_EQ(buf.lineCount(), 3u); +} + +void test_singleline_strips_newlines_on_insert(void) +{ + TEST("singleline_strips_newlines_on_insert"); + + ostd::TextBuffer buf; + CHECK(!buf.isMultiline()); + buf.insertText("a\nb\r\nc\rd"); + CHECK_STR_EQ(buf.text(), "a b c d"); + + // And insertCodepoint('\n') is rejected. + buf.clear(); + buf.insertCodepoint('h'); + buf.insertCodepoint('\n'); + buf.insertCodepoint('i'); + CHECK_STR_EQ(buf.text(), "hi"); +} + +void test_setMultiline_strip(void) +{ + TEST("setMultiline_strip"); + + // Constructed multiline, switching to single-line strips newlines. + ostd::TextBuffer buf("a\nb\nc", true); + CHECK_EQ(buf.lineCount(), 3u); + buf.setMultiline(false); + CHECK_STR_EQ(buf.text(), "a b c"); + CHECK_EQ(buf.lineCount(), 1u); +} + +void test_vertical_movement(void) +{ + TEST("vertical_movement"); + + // Three lines, each 5 chars: "abcde\nfghij\nklmno" + ostd::TextBuffer buf("abcde\nfghij\nklmno", true); + buf.setCursorByteOffset(0); + CHECK_EQ(buf.cursorLineCol().y, 0); + CHECK_EQ(buf.cursorLineCol().x, 0); + + buf.moveDown(); + CHECK_EQ(buf.cursorLineCol().y, 1); + CHECK_EQ(buf.cursorLineCol().x, 0); + CHECK_EQ(buf.cursorByteOffset(), 6u); // 'f' + + buf.moveDown(); + CHECK_EQ(buf.cursorLineCol().y, 2); + + // At the last line, moveDown jumps to end of document. + buf.moveDown(); + CHECK_EQ(buf.cursorByteOffset(), buf.byteSize()); +} + +void test_desired_column_sticky(void) +{ + TEST("desired_column_sticky"); + + // Long, short, long: cursor on column 4 of line 0, move down to line 1 + // (only 2 chars long, lands at end), then back up — should remember col 4. + ostd::TextBuffer buf("abcdef\nxy\nabcdef", true); + buf.setCursorLineCol({ 4, 0 }); + CHECK_EQ(buf.cursorLineCol().x, 4); + + buf.moveDown(); + // Line 1 is "xy" (2 codepoints), so we land at col 2 visually but remember 4. + CHECK_EQ(buf.cursorLineCol().y, 1); + CHECK_EQ(buf.cursorLineCol().x, 2); + + buf.moveDown(); + // Back on a long line: should restore column 4. + CHECK_EQ(buf.cursorLineCol().y, 2); + CHECK_EQ(buf.cursorLineCol().x, 4); + + // Horizontal movement resets the desired column. + buf.moveLeft(); + CHECK_EQ(buf.cursorLineCol().x, 3); + buf.moveUp(); + // Now from col 3 on a 2-char line, we land at col 2. But on next moveUp, + // we should NOT remember col 4 anymore — should remember col 3. + CHECK_EQ(buf.cursorLineCol().y, 1); + buf.moveUp(); + CHECK_EQ(buf.cursorLineCol().y, 0); + CHECK_EQ(buf.cursorLineCol().x, 3); +} + +void test_line_start_end(void) +{ + TEST("line_start_end"); + + ostd::TextBuffer buf("hello\nworld", true); + buf.setCursorByteOffset(8); // middle of "world" + buf.moveLineStart(); + CHECK_EQ(buf.cursorByteOffset(), 6u); + buf.moveLineEnd(); + CHECK_EQ(buf.cursorByteOffset(), 11u); + + buf.setCursorByteOffset(2); // middle of "hello" + buf.moveLineEnd(); + CHECK_EQ(buf.cursorByteOffset(), 5u); // before the '\n' + buf.moveLineStart(); + CHECK_EQ(buf.cursorByteOffset(), 0u); +} + +void test_word_movement(void) +{ + TEST("word_movement"); + + ostd::TextBuffer buf("hello, world foo"); + // Indices: h=0 ... o=4, ','=5, ' '=6, w=7 ... d=11, ' '=12, ' '=13, f=14 ... o=16 + buf.setCursorByteOffset(0); + buf.moveWordRight(); + // Skip "hello", then skip ", " whitespace+punct run... actually let's + // check what the implementation does: it skips the run of "Word" then + // any following whitespace. ',' is Punctuation, not Word, so the first + // run is "hello" (5 chars), then we skip Whitespace (the comma is NOT + // whitespace, so we stop). Result: cursor at 5 (on the comma). + CHECK_EQ(buf.cursorByteOffset(), 5u); + + buf.moveWordRight(); + // From the comma: classify(',') == Punctuation. Skip Punct run = just the + // comma. Then skip Whitespace run = the space. Land on 'w'. + CHECK_EQ(buf.cursorByteOffset(), 7u); + + buf.moveWordRight(); + // Skip "world", then skip the two spaces. Land on 'f'. + CHECK_EQ(buf.cursorByteOffset(), 14u); + + // Reverse direction. + buf.setCursorByteOffset(17); // end of buffer + buf.moveWordLeft(); + // Skip whitespace-to-the-left first (none — we're after 'o'), then skip + // the Word run "foo". Land on 'f' (byte 14). + CHECK_EQ(buf.cursorByteOffset(), 14u); + + buf.moveWordLeft(); + // Skip the two spaces (whitespace run), then skip the Word run "world". + CHECK_EQ(buf.cursorByteOffset(), 7u); +} + +void test_word_backspace_and_delete(void) +{ + TEST("word_backspace_and_delete"); + + ostd::TextBuffer buf("hello world foo"); + buf.setCursorByteOffset(15); + buf.backspaceWord(); + CHECK_STR_EQ(buf.text(), "hello world "); + CHECK_EQ(buf.cursorByteOffset(), 12u); + + buf.backspaceWord(); + CHECK_STR_EQ(buf.text(), "hello "); + CHECK_EQ(buf.cursorByteOffset(), 6u); + + buf.setCursorByteOffset(0); + buf.deleteWord(); + CHECK_STR_EQ(buf.text(), ""); +} + +void test_delete_current_line(void) +{ + TEST("delete_current_line"); + + ostd::TextBuffer buf("a\nb\nc\nd", true); + buf.setCursorLineCol({ 0, 1 }); + buf.deleteCurrentLine(); + CHECK_STR_EQ(buf.text(), "a\nc\nd"); + CHECK_EQ(buf.lineCount(), 3u); + + // Last line: deletes through end of buffer (no trailing newline to consume). + buf.setCursorLineCol({ 0, 2 }); + buf.deleteCurrentLine(); + CHECK_STR_EQ(buf.text(), "a\nc\n"); +} + +void test_callbacks_fire(void) +{ + TEST("callbacks_fire"); + + ostd::TextBuffer buf; + u32 changed = 0; + u32 cursor_moved = 0; + buf.setOnChanged([&](const ostd::TextBuffer&) { changed++; }); + buf.setOnCursorMoved([&](const ostd::TextBuffer&) { cursor_moved++; }); + + buf.insertText("hello"); + CHECK_EQ(changed, 1u); + CHECK(cursor_moved >= 1u); // exact count is impl-detail; just verify it fired + + const u32 cm_before = cursor_moved; + buf.setCursorByteOffset(0); + CHECK_EQ(changed, 1u); // movement does NOT count as change + CHECK(cursor_moved > cm_before); + + // No-op moves should not fire. + const u32 cm_pre = cursor_moved; + buf.setCursorByteOffset(0); + CHECK_EQ(cursor_moved, cm_pre); +} + +void test_cursor_clamp_after_setText(void) +{ + TEST("cursor_clamp_after_setText"); + + ostd::TextBuffer buf("hello world"); + buf.setCursorByteOffset(11); + + buf.setText("hi"); + CHECK_EQ(buf.cursorByteOffset(), 2u); // setText parks cursor at end + CHECK(!buf.hasSelection()); +} + +void test_replace_range_via_selection_across_lines(void) +{ + TEST("replace_range_via_selection_across_lines"); + + ostd::TextBuffer buf("aaa\nbbb\nccc", true); + // Select from middle of line 0 to middle of line 2. + buf.selectRange(2, 9); + buf.insertText("X"); + CHECK_STR_EQ(buf.text(), "aaXcc"); + CHECK_EQ(buf.lineCount(), 1u); + CHECK_EQ(buf.cursorByteOffset(), 3u); +} + +void test_no_op_edits_dont_fire_changed(void) +{ + TEST("no_op_edits_dont_fire_changed"); + + ostd::TextBuffer buf("hello"); + u32 changed = 0; + buf.setOnChanged([&](const ostd::TextBuffer&) { changed++; }); + + // Backspace at start of buffer is a no-op. + buf.setCursorByteOffset(0); + buf.backspace(); + CHECK_EQ(changed, 0u); + + // Delete-forward at end is a no-op. + buf.setCursorByteOffset(buf.byteSize()); + buf.deleteForward(); + CHECK_EQ(changed, 0u); +} + +void test_utf8_word_movement(void) +{ + TEST("utf8_word_movement"); + + // "héllo wörld" — non-ASCII letters classified as Word, so the buffer + // should treat "héllo" and "wörld" as single word runs. + // Bytes: h(1) é(2) l(1) l(1) o(1) ' '(1) w(1) ö(2) r(1) l(1) d(1) = 13 bytes + ostd::TextBuffer buf("héllo wörld"); + CHECK_EQ(buf.byteSize(), 13u); + + buf.setCursorByteOffset(0); + buf.moveWordRight(); + CHECK_EQ(buf.cursorByteOffset(), 7u); // end of "héllo", on the space + buf.moveWordRight(); + // Skip space, skip "wörld" → end of buffer + CHECK_EQ(buf.cursorByteOffset(), 13u); +} + +void test_select_all_on_empty(void) +{ + TEST("select_all_on_empty"); + + ostd::TextBuffer buf; + buf.selectAll(); + CHECK(!buf.hasSelection()); // 0..0 is no selection +} + +void test_line_count_with_trailing_newline(void) +{ + TEST("line_count_with_trailing_newline"); + + // "a\n" — is this 1 line or 2? Convention varies. Our impl counts the + // position after the trailing '\n' as a new (empty) line, so this is 2. + ostd::TextBuffer buf("a\n", true); + CHECK_EQ(buf.lineCount(), 2u); + CHECK_STR_EQ(buf.getLine(0), "a"); + CHECK_STR_EQ(buf.getLine(1), ""); +} + +} // anonymous namespace + + +// ============================================================ +// Driver +// ============================================================ + +int main(void) +{ + g_out.fg(ostd::ConsoleColors::BrightCyan) + .p("Running TextBuffer tests...").nl().reset(); + + test_construction_and_basic(); + test_setText_and_clear(); + test_simple_insert_and_backspace(); + test_insert_text_at_position(); + test_utf8_backspace(); + test_utf8_movement(); + test_utf8_4byte_codepoint(); + test_clamp_to_codepoint_boundary(); + test_selection_basic(); + test_selection_replace_on_insert(); + test_selection_replace_on_backspace(); + test_extend_selection(); + test_multiline_basic(); + test_multiline_insertion(); + test_singleline_strips_newlines_on_insert(); + test_setMultiline_strip(); + test_vertical_movement(); + test_desired_column_sticky(); + test_line_start_end(); + test_word_movement(); + test_word_backspace_and_delete(); + test_delete_current_line(); + test_callbacks_fire(); + test_cursor_clamp_after_setText(); + test_replace_range_via_selection_across_lines(); + test_no_op_edits_dont_fire_changed(); + test_utf8_word_movement(); + test_select_all_on_empty(); + test_line_count_with_trailing_newline(); + + g_out.nl().nl(); + + if (g_stats.failed == 0) { + g_out.fg(ostd::ConsoleColors::BrightGreen) + .p("ALL ").p(g_stats.passed).p(" CHECKS PASSED").reset().nl(); + return 0; + } + else { + g_out.fg(ostd::ConsoleColors::BrightRed) + .p(g_stats.failed).p(" FAILURES out of ").p(g_stats.total).p(" checks:") + .reset().nl(); + for (const auto& f : g_stats.failures) + g_out.p(f).nl(); + return 1; + } +}