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;
+ }
+}