Started work on text widgets
This commit is contained in:
parent
424574a2e6
commit
655d7b1f98
11 changed files with 1954 additions and 57 deletions
|
|
@ -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
|
||||
)
|
||||
#-----------------------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2075
|
||||
2076
|
||||
|
|
|
|||
29
src/ogfx/gui/widgets/Text.cpp
Normal file
29
src/ogfx/gui/widgets/Text.cpp
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
30
src/ogfx/gui/widgets/Text.hpp
Normal file
30
src/ogfx/gui/widgets/Text.hpp
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -680,55 +680,6 @@ namespace ostd
|
|||
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)
|
||||
{
|
||||
return String(str1) + str;
|
||||
|
|
|
|||
|
|
@ -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<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 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<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);
|
||||
|
||||
private:
|
||||
static u32 utf8_next(const char*& p, const char* end);
|
||||
|
||||
private:
|
||||
ostd::cpp_string m_data;
|
||||
};
|
||||
|
|
|
|||
674
src/ostd/string/TextBuffer.cpp
Normal file
674
src/ostd/string/TextBuffer.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
171
src/ostd/string/TextBuffer.hpp
Normal file
171
src/ostd/string/TextBuffer.hpp
Normal 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
282
src/ostd/string/utf8.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
730
src/test/TextBufferUnitTest.cpp
Normal file
730
src/test/TextBufferUnitTest.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue