Started work on text widgets

This commit is contained in:
OmniaX-Dev 2026-05-05 06:39:46 +02:00
parent 424574a2e6
commit 655d7b1f98
11 changed files with 1954 additions and 57 deletions

View file

@ -69,6 +69,8 @@ list(APPEND OSTD_SOURCE_FILES
# string # string
${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/String.cpp ${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/TextStyleParser.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/utf8.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ostd/string/TextBuffer.cpp
# utils # utils
${CMAKE_CURRENT_LIST_DIR}/src/ostd/utils/Logic.cpp ${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/Slider.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/ComboBox.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/TreeView.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/Text.cpp
# render # render
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/render/BasicRenderer.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ogfx/render/BasicRenderer.cpp
@ -119,6 +122,7 @@ list(APPEND OGFX_SOURCE_FILES
list(APPEND TEST_SOURCE_FILES list(APPEND TEST_SOURCE_FILES
# ${CMAKE_CURRENT_LIST_DIR}/src/test/GraphicsWindowTest.cpp # ${CMAKE_CURRENT_LIST_DIR}/src/test/GraphicsWindowTest.cpp
${CMAKE_CURRENT_LIST_DIR}/src/test/GuiTest.cpp ${CMAKE_CURRENT_LIST_DIR}/src/test/GuiTest.cpp
# ${CMAKE_CURRENT_LIST_DIR}/src/test/TextBufferUnitTest.cpp
) )
#----------------------------------------------------------------------------------------- #-----------------------------------------------------------------------------------------

View file

@ -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: Implement following widgets:
***Label ***Label
***Checkbox ***Checkbox

View file

@ -1 +1 @@
2075 2076

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "Text.hpp"
namespace ostd
{
namespace gui
{
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include "Widget.hpp"
namespace ostd
{
namespace gui
{
}
}

View file

@ -680,55 +680,6 @@ namespace ostd
return str; return str;
} }
stdvec<u32> String::decodeUTF8(const String& s)
{
stdvec<u32> 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<u8>(*p++);
// ASCII fast path
if (c < 0x80)
return c;
// 2-byte sequence
if ((c >> 5) == 0x6) {
u32 cp = (c & 0x1F) << 6;
cp |= (cast<u8>(*p++) & 0x3F);
return cp;
}
// 3-byte sequence
if ((c >> 4) == 0xE) {
u32 cp = (c & 0x0F) << 12;
cp |= (cast<u8>(*p++) & 0x3F) << 6;
cp |= (cast<u8>(*p++) & 0x3F);
return cp;
}
// 4-byte sequence
if ((c >> 3) == 0x1E) {
u32 cp = (c & 0x07) << 18;
cp |= (cast<u8>(*p++) & 0x3F) << 12;
cp |= (cast<u8>(*p++) & 0x3F) << 6;
cp |= (cast<u8>(*p++) & 0x3F);
return cp;
}
// Invalid byte → return replacement character
return 0xFFFD;
}
String operator+(const cpp_string& str1, const String& str) String operator+(const cpp_string& str1, const String& str)
{ {
return String(str1) + str; return String(str1) + str;

View file

@ -72,8 +72,8 @@ namespace ostd
inline char at(u32 index) const { return m_data[index]; } inline char at(u32 index) const { return m_data[index]; }
inline u32 len(void) const { return m_data.length(); } inline u32 len(void) const { return m_data.length(); }
inline char operator[](u32 index) const { return m_data[index]; } 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(); } inline String operator+(const String& str2) const { return m_data + str2.cpp_str(); }
friend String operator+(const cpp_string& str1, const String& str); friend String operator+(const cpp_string& str1, const String& str);
inline String& operator+=(const String& str2) { m_data += str2.cpp_str(); return *this; } 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 operator std::filesystem::path() const { return cpp_str(); }
inline String& clr(void) { m_data = ""; return *this; } inline String& clr(void) { m_data = ""; return *this; }
inline String& set(const cpp_string& str) { m_data = str; return *this; } inline String& set(const cpp_string& str) { m_data = str; return *this; }
inline stdvec<u32> getUTF8Codepoints(void) const { return decodeUTF8(m_data); } inline stdvec<u32> getUTF8Codepoints(void) const { return utf8::decode(m_data); }
inline bool empty(void) const { return m_data.empty(); } inline bool empty(void) const { return m_data.empty(); }
inline auto begin(void) { return m_data.begin(); } 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 getHexStr(u64 value, bool prefix = true, u8 nbytes = 1);
static String getBinStr(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 String duplicateChar(uchar c, u16 count);
static stdvec<u32> 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<u32>& codepoints);
static stdvec<u32> 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); friend std::ostream& operator<<(std::ostream& out, const String& val);
private:
static u32 utf8_next(const char*& p, const char* end);
private: private:
ostd::cpp_string m_data; ostd::cpp_string m_data;
}; };

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
#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<u32>(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<u32>(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<u32>(m_buffer.len());
return m_buffer.new_substr(start, cast<i32>(end));
}
u32 TextBuffer::lineByteStart(u32 lineIdx) const
{
if (m_lineStartsDirty) rebuild_line_starts();
if (lineIdx >= m_lineStarts.size()) return cast<u32>(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<u32>(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<u32>(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<i32>(col), cast<i32>(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<i32>(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<u32>(pos.x);
const u32 byte = byte_offset_for_line_col(cast<u32>(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<i32>(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<u32>(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<u32>(m_desiredColumn)
: codepoint_column_in_line(m_cursor);
if (m_desiredColumn < 0) m_desiredColumn = cast<i32>(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<u32>(m_buffer.len()), extend);
m_desiredColumn = -1;
return;
}
const u32 col = (m_desiredColumn >= 0)
? cast<u32>(m_desiredColumn)
: codepoint_column_in_line(m_cursor);
if (m_desiredColumn < 0) m_desiredColumn = cast<i32>(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<u32>(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<u32>(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<u32>(m_buffer.len());
replace_range(start, end, String(""));
}
// ============================================================
// toString
// ============================================================
String TextBuffer::toString(void) const
{
String s = getObjectHeaderString();
s.add(" { bytes=");
s.add(cast<u32>(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<u32>(m_buffer.len());
if (end_byte > m_buffer.len()) end_byte = cast<u32>(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<i32>(start_byte));
String tail = m_buffer.new_substr(end_byte, cast<i32>(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<u32>(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<u32>(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<u32>(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<u8>(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<u32>(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<u32>(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<u8>(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<u8>(s[first_prev]));
while (i > 0)
{
const u32 prev = String::utf8::prev_codepoint_start(s, i);
if (classify_byte(cast<u8>(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<u32>(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<u8>(s[i]));
while (i < size && classify_byte(cast<u8>(s[i])) == cls)
i = String::utf8::next_codepoint_start(s, i);
// Step 2: skip any whitespace following.
while (i < size && classify_byte(cast<u8>(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<u32>(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);
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <functional>
#include <ostd/data/BaseObject.hpp>
#include <ostd/string/String.hpp>
#include <ostd/math/Geometry.hpp>
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<void(const TextBuffer&)>;
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<u32>(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<u32> m_lineStarts;
mutable bool m_lineStartsDirty { true };
ChangeCallback m_onChanged { nullptr };
ChangeCallback m_onCursorMoved { nullptr };
};
}

282
src/ostd/string/utf8.cpp Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "String.hpp"
namespace ostd
{
stdvec<u32> String::utf8::decode(const cpp_string& s)
{
stdvec<u32> out;
out.reserve(s.size()); // Upper bound: 1 codepoint per byte (pure ASCII).
const u8* p = reinterpret_cast<const u8*>(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<u32>& 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<char>(cp));
}
else if (cp < 0x800) {
out.addChar(cast<char>(0xC0 | (cp >> 6)));
out.addChar(cast<char>(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<char>(0xE0 | (cp >> 12)));
out.addChar(cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.addChar(cast<char>(0x80 | (cp & 0x3F)));
}
else if (cp <= 0x10FFFF) {
out.addChar(cast<char>(0xF0 | (cp >> 18)));
out.addChar(cast<char>(0x80 | ((cp >> 12) & 0x3F)));
out.addChar(cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.addChar(cast<char>(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<u32>(s.size());
// Already on a lead byte (or ASCII): nothing to do.
if ((cast<u8>(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<u8>(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<u32>(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<u8>(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<u32>(s.size());
u8 c = cast<u8>(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<u32>(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<u8>(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<char>(codepoint));
}
else if (codepoint < 0x800) {
out.addChar(cast<char>(0xC0 | (codepoint >> 6)));
out.addChar(cast<char>(0x80 | (codepoint & 0x3F)));
}
else if (codepoint < 0x10000) {
out.addChar(cast<char>(0xE0 | (codepoint >> 12)));
out.addChar(cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
out.addChar(cast<char>(0x80 | (codepoint & 0x3F)));
}
else if (codepoint <= 0x10FFFF) {
out.addChar(cast<char>(0xF0 | (codepoint >> 18)));
out.addChar(cast<char>(0x80 | ((codepoint >> 12) & 0x3F)));
out.addChar(cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
out.addChar(cast<char>(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<u8>(*p) & 0xC0) != 0x80)
count++;
p++;
}
return count;
}
bool String::utf8::is_valid(const cpp_string& s)
{
const u8* p = reinterpret_cast<const u8*>(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;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
// 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 <ostd/io/IOHandlers.hpp>
#include <ostd/string/String.hpp>
#include <ostd/string/TextBuffer.hpp>
namespace {
// ============================================================
// Tiny test harness
// ============================================================
struct TestStats {
u32 total { 0 };
u32 passed { 0 };
u32 failed { 0 };
String currentTest;
stdvec<String> 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;
}
}