Extract XCUI text editing core and window seam headers

This commit is contained in:
2026-04-05 06:23:49 +08:00
parent da85109a31
commit b4c95e4085
9 changed files with 446 additions and 197 deletions

View File

@@ -15,7 +15,7 @@ Old `editor` replacement is explicitly out of scope for this phase.
- engine runtime coverage was tightened again around `UISystem` and concrete document-host rendering
- `LayoutLab` continues as the editor widget proving ground for tree/list/property-section style controls
- the demo sandbox and editor bridge APIs were tightened again without touching the old editor replacement scope
- `new_editor` now has an explicit window-level compositor seam through `IEditorHostCompositor` / `ImGuiHostCompositor`
- `new_editor` now has an explicit two-step window compositor seam through `IWindowUICompositor` / `ImGuiWindowUICompositor`, layered on top of `IEditorHostCompositor` / `ImGuiHostCompositor`
- Old `editor` replacement remains deferred; all active execution still stays inside XCUI shared code and `new_editor`.
## Three-Layer Status
@@ -29,13 +29,14 @@ Old `editor` replacement is explicitly out of scope for this phase.
- Build-system hardening for MSVC/PDB output paths has started in root CMake, `engine/CMakeLists.txt`, `new_editor/CMakeLists.txt`, and `tests/NewEditor/CMakeLists.txt`.
- Shared engine-side XCUI runtime scaffolding is now present under `engine/include/XCEngine/UI/Runtime` and `engine/src/UI/Runtime`.
- Shared engine-side `UIDocumentScreenHost` now compiles `.xcui` / `.xctheme` screen documents into a runtime-facing document host path instead of leaving all document ownership in `new_editor`.
- Shared text-editing primitives now live under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so UTF-8 caret movement, line splitting, and multiline navigation are no longer trapped inside `XCUI Demo`.
- Core regression coverage now includes `UIContext`, layout, style, runtime screen player/system, and real document-host tests through `core_ui_tests`.
Current gap:
- Minimal schema self-definition support is landed, including consistency checks for enum/document-only schema metadata, but schema-driven validation for `.xcui` / `.xctheme` instances is still not implemented.
- Shared widget/runtime instantiation is still thin and mostly editor-side.
- Common widget primitives are still incomplete: multiline text editing, tree/list virtualization, property-grid composition, and native image/source-rect level APIs.
- Common widget primitives are still incomplete: reusable text-input state/view composition on top of the new text helpers, tree/list virtualization, property-grid composition, and native image/source-rect level APIs.
### 2. Runtime/Game Layer
@@ -60,7 +61,7 @@ Current gap:
- `LayoutLab` now also covers editor-facing widget prototypes: `TreeView`, `TreeItem`, `ListView`, `ListItem`, `PropertySection`, and `FieldRow`.
- Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths.
- The editor bridge layer now has smoke coverage for swapchain after-UI rendering hooks and SRV-backed ImGui texture descriptor registration.
- `Application` no longer owns the ImGui backend directly; window presentation now routes through `IEditorHostCompositor` with an `ImGuiHostCompositor` implementation.
- `Application` no longer owns the ImGui backend directly; window presentation now routes through `IWindowUICompositor` with an `ImGuiWindowUICompositor` implementation, which currently delegates to `IEditorHostCompositor` / `ImGuiHostCompositor`.
- `XCNewEditor` builds successfully to `build/new_editor/bin/Debug/XCNewEditor.exe`.
Current gap:
@@ -75,7 +76,7 @@ Current gap:
- `new_editor_xcui_rhi_command_compiler_tests`: `6/6`
- `new_editor_xcui_rhi_render_backend_tests`: `5/5`
- `XCNewEditor` Debug target builds successfully
- `core_ui_tests`: `16/16`
- `core_ui_tests`: `21/21`
- `core_ui_style_tests`: `5/5`
- `ui_resource_tests`: `11/11`
- `editor_tests` targeted bridge smoke: `3/3`
@@ -84,6 +85,7 @@ Current gap:
- Demo runtime `TextField` with UTF-8 text insertion, caret state, and backspace.
- Demo runtime multiline `TextArea` path in the sandbox and test coverage for caret movement / multiline input.
- Common-core `UITextEditing` extraction now owns UTF-8 offset stepping, codepoint counting, line splitting, and vertical caret motion with dedicated `core_ui_tests` coverage.
- Demo runtime text editing was extended with:
- click-to-place caret
- `Delete` support
@@ -119,6 +121,8 @@ Current gap:
- SRV-view based texture descriptor registration in `ImGuiBackendBridge`
- smoke tests for window renderer, ImGui backend bridge, and console sink registration
- `new_editor` host presentation now has a first-class compositor seam:
- `IWindowUICompositor`
- `ImGuiWindowUICompositor`
- `IEditorHostCompositor`
- `ImGuiHostCompositor`
- `Application` frame/present flow routed through the compositor instead of direct `m_imguiBackend` ownership

View File

@@ -516,6 +516,8 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputRouter.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIShortcutRegistry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputDispatcher.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Text/UITextEditing.h
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenPlayer.h

View File

@@ -0,0 +1,29 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace XCEngine {
namespace UI {
namespace Text {
std::size_t CountUtf8Codepoints(const std::string& text);
std::size_t AdvanceUtf8Offset(const std::string& text, std::size_t offset);
std::size_t RetreatUtf8Offset(const std::string& text, std::size_t offset);
void AppendUtf8Codepoint(std::string& text, std::uint32_t codepoint);
std::vector<std::string> SplitLines(const std::string& text);
std::size_t CountTextLines(const std::string& text);
std::size_t CountUtf8CodepointsInRange(
const std::string& text,
std::size_t beginOffset,
std::size_t endOffset);
std::size_t FindLineStartOffset(const std::string& text, std::size_t caret);
std::size_t FindLineEndOffset(const std::string& text, std::size_t caret);
std::size_t MoveCaretVertically(const std::string& text, std::size_t caret, int lineDelta);
} // namespace Text
} // namespace UI
} // namespace XCEngine

View File

@@ -0,0 +1,179 @@
#include <XCEngine/UI/Text/UITextEditing.h>
#include <algorithm>
namespace XCEngine {
namespace UI {
namespace Text {
namespace {
bool IsUtf8ContinuationByte(unsigned char value) {
return (value & 0xC0u) == 0x80u;
}
} // namespace
std::size_t CountUtf8Codepoints(const std::string& text) {
std::size_t count = 0u;
for (unsigned char ch : text) {
if (!IsUtf8ContinuationByte(ch)) {
++count;
}
}
return count;
}
std::size_t AdvanceUtf8Offset(const std::string& text, std::size_t offset) {
if (offset >= text.size()) {
return text.size();
}
++offset;
while (offset < text.size() && IsUtf8ContinuationByte(static_cast<unsigned char>(text[offset]))) {
++offset;
}
return offset;
}
std::size_t RetreatUtf8Offset(const std::string& text, std::size_t offset) {
if (offset == 0u || text.empty()) {
return 0u;
}
offset = (std::min)(offset, text.size());
do {
--offset;
} while (offset > 0u && IsUtf8ContinuationByte(static_cast<unsigned char>(text[offset])));
return offset;
}
void AppendUtf8Codepoint(std::string& text, std::uint32_t codepoint) {
if (codepoint <= 0x7Fu) {
text.push_back(static_cast<char>(codepoint));
return;
}
if (codepoint <= 0x7FFu) {
text.push_back(static_cast<char>(0xC0u | ((codepoint >> 6u) & 0x1Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
return;
}
if (codepoint >= 0xD800u && codepoint <= 0xDFFFu) {
return;
}
if (codepoint <= 0xFFFFu) {
text.push_back(static_cast<char>(0xE0u | ((codepoint >> 12u) & 0x0Fu)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 6u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
return;
}
if (codepoint <= 0x10FFFFu) {
text.push_back(static_cast<char>(0xF0u | ((codepoint >> 18u) & 0x07u)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 12u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 6u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
}
}
std::vector<std::string> SplitLines(const std::string& text) {
std::vector<std::string> lines = {};
std::size_t lineStart = 0u;
for (std::size_t index = 0u; index < text.size(); ++index) {
if (text[index] != '\n') {
continue;
}
lines.push_back(text.substr(lineStart, index - lineStart));
lineStart = index + 1u;
}
lines.push_back(text.substr(lineStart));
return lines;
}
std::size_t CountTextLines(const std::string& text) {
return SplitLines(text).size();
}
std::size_t CountUtf8CodepointsInRange(
const std::string& text,
std::size_t beginOffset,
std::size_t endOffset) {
beginOffset = (std::min)(beginOffset, text.size());
endOffset = (std::min)(endOffset, text.size());
if (endOffset <= beginOffset) {
return 0u;
}
std::size_t count = 0u;
std::size_t offset = beginOffset;
while (offset < endOffset) {
++count;
offset = AdvanceUtf8Offset(text, offset);
}
return count;
}
std::size_t FindLineStartOffset(const std::string& text, std::size_t caret) {
caret = (std::min)(caret, text.size());
while (caret > 0u && text[caret - 1u] != '\n') {
--caret;
}
return caret;
}
std::size_t FindLineEndOffset(const std::string& text, std::size_t caret) {
caret = (std::min)(caret, text.size());
while (caret < text.size() && text[caret] != '\n') {
++caret;
}
return caret;
}
std::size_t MoveCaretVertically(const std::string& text, std::size_t caret, int lineDelta) {
if (lineDelta == 0 || text.empty()) {
return (std::min)(caret, text.size());
}
const std::size_t currentLineStart = FindLineStartOffset(text, caret);
const std::size_t currentLineEnd = FindLineEndOffset(text, caret);
const std::size_t column = CountUtf8CodepointsInRange(text, currentLineStart, caret);
if (lineDelta < 0) {
if (currentLineStart == 0u) {
return caret;
}
const std::size_t previousLineEnd = currentLineStart - 1u;
const std::size_t previousLineStart = FindLineStartOffset(text, previousLineEnd);
const std::size_t previousLineLength =
CountUtf8CodepointsInRange(text, previousLineStart, previousLineEnd);
std::size_t offset = previousLineStart;
for (std::size_t step = 0u; step < (std::min)(column, previousLineLength); ++step) {
offset = AdvanceUtf8Offset(text, offset);
}
return offset;
}
if (currentLineEnd >= text.size()) {
return caret;
}
const std::size_t nextLineStart = currentLineEnd + 1u;
const std::size_t nextLineEnd = FindLineEndOffset(text, nextLineStart);
const std::size_t nextLineLength =
CountUtf8CodepointsInRange(text, nextLineStart, nextLineEnd);
std::size_t offset = nextLineStart;
for (std::size_t step = 0u; step < (std::min)(column, nextLineLength); ++step) {
offset = AdvanceUtf8Offset(text, offset);
}
return offset;
}
} // namespace Text
} // namespace UI
} // namespace XCEngine

View File

@@ -0,0 +1,52 @@
#pragma once
#include "Platform/D3D12WindowRenderer.h"
#include <XCEngine/RHI/RHIDevice.h>
#include <XCEngine/RHI/RHITexture.h>
#include <imgui.h>
#include <functional>
#include <memory>
#include <windows.h>
namespace XCEngine {
namespace Editor {
namespace XCUIBackend {
class IWindowUICompositor {
public:
using ConfigureFontsCallback = std::function<void()>;
using UiRenderCallback = std::function<void()>;
using RenderCallback = ::XCEngine::Editor::Platform::D3D12WindowRenderer::RenderCallback;
virtual ~IWindowUICompositor() = default;
virtual bool Initialize(
HWND hwnd,
::XCEngine::Editor::Platform::D3D12WindowRenderer& windowRenderer,
const ConfigureFontsCallback& configureFonts) = 0;
virtual void Shutdown() = 0;
virtual bool HandleWindowMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) = 0;
virtual void RenderFrame(
const float clearColor[4],
const UiRenderCallback& renderUi,
const RenderCallback& beforeUiRender = {},
const RenderCallback& afterUiRender = {}) = 0;
virtual bool CreateTextureDescriptor(
::XCEngine::RHI::RHIDevice* device,
::XCEngine::RHI::RHITexture* texture,
D3D12_CPU_DESCRIPTOR_HANDLE* outCpuHandle,
D3D12_GPU_DESCRIPTOR_HANDLE* outGpuHandle,
ImTextureID* outTextureId) = 0;
virtual void FreeTextureDescriptor(
D3D12_CPU_DESCRIPTOR_HANDLE cpuHandle,
D3D12_GPU_DESCRIPTOR_HANDLE gpuHandle) = 0;
};
std::unique_ptr<IWindowUICompositor> CreateImGuiWindowUICompositor();
} // namespace XCUIBackend
} // namespace Editor
} // namespace XCEngine

View File

@@ -0,0 +1,94 @@
#pragma once
#include "XCUIBackend/IEditorHostCompositor.h"
#include "XCUIBackend/IWindowUICompositor.h"
#include <memory>
#include <utility>
namespace XCEngine {
namespace Editor {
namespace XCUIBackend {
class ImGuiWindowUICompositor final : public IWindowUICompositor {
public:
explicit ImGuiWindowUICompositor(
std::unique_ptr<IEditorHostCompositor> hostCompositor = CreateImGuiHostCompositor())
: m_hostCompositor(std::move(hostCompositor)) {
}
bool Initialize(
HWND hwnd,
::XCEngine::Editor::Platform::D3D12WindowRenderer& windowRenderer,
const ConfigureFontsCallback& configureFonts) override {
if (m_hostCompositor == nullptr) {
return false;
}
m_windowRenderer = &windowRenderer;
return m_hostCompositor->Initialize(hwnd, windowRenderer, configureFonts);
}
void Shutdown() override {
if (m_hostCompositor != nullptr) {
m_hostCompositor->Shutdown();
}
m_windowRenderer = nullptr;
}
bool HandleWindowMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) override {
return m_hostCompositor != nullptr &&
m_hostCompositor->HandleWindowMessage(hwnd, message, wParam, lParam);
}
void RenderFrame(
const float clearColor[4],
const UiRenderCallback& renderUi,
const RenderCallback& beforeUiRender,
const RenderCallback& afterUiRender) override {
if (m_hostCompositor == nullptr || m_windowRenderer == nullptr) {
return;
}
m_hostCompositor->BeginFrame();
if (renderUi) {
renderUi();
}
m_hostCompositor->EndFrameAndPresent(*m_windowRenderer, clearColor, beforeUiRender, afterUiRender);
}
bool CreateTextureDescriptor(
::XCEngine::RHI::RHIDevice* device,
::XCEngine::RHI::RHITexture* texture,
D3D12_CPU_DESCRIPTOR_HANDLE* outCpuHandle,
D3D12_GPU_DESCRIPTOR_HANDLE* outGpuHandle,
ImTextureID* outTextureId) override {
return m_hostCompositor != nullptr &&
m_hostCompositor->CreateTextureDescriptor(
device,
texture,
outCpuHandle,
outGpuHandle,
outTextureId);
}
void FreeTextureDescriptor(
D3D12_CPU_DESCRIPTOR_HANDLE cpuHandle,
D3D12_GPU_DESCRIPTOR_HANDLE gpuHandle) override {
if (m_hostCompositor != nullptr) {
m_hostCompositor->FreeTextureDescriptor(cpuHandle, gpuHandle);
}
}
private:
::XCEngine::Editor::Platform::D3D12WindowRenderer* m_windowRenderer = nullptr;
std::unique_ptr<IEditorHostCompositor> m_hostCompositor;
};
inline std::unique_ptr<IWindowUICompositor> CreateImGuiWindowUICompositor() {
return std::make_unique<ImGuiWindowUICompositor>();
}
} // namespace XCUIBackend
} // namespace Editor
} // namespace XCEngine

View File

@@ -11,6 +11,7 @@
#include <XCEngine/UI/Layout/LayoutEngine.h>
#include <XCEngine/UI/Style/StyleResolver.h>
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Text/UITextEditing.h>
#include <algorithm>
#include <chrono>
@@ -58,6 +59,7 @@ using XCEngine::UI::UIRect;
using XCEngine::UI::UISize;
namespace Layout = XCEngine::UI::Layout;
namespace Style = XCEngine::UI::Style;
namespace UIText = XCEngine::UI::Text;
constexpr std::size_t kInvalidIndex = static_cast<std::size_t>(-1);
constexpr char kViewRelativePath[] = "new_editor/resources/xcui_demo_view.xcui";
@@ -147,77 +149,8 @@ float Clamp01(float value) {
return (std::min)(1.0f, (std::max)(0.0f, value));
}
bool IsUtf8ContinuationByte(unsigned char value) {
return (value & 0xC0u) == 0x80u;
}
std::size_t CountUtf8Codepoints(const std::string& text) {
std::size_t count = 0u;
for (unsigned char ch : text) {
if (!IsUtf8ContinuationByte(ch)) {
++count;
}
}
return count;
}
float MeasureGlyphRunWidth(const std::string& text, float fontSize) {
return fontSize * kApproximateTextWidthFactor * static_cast<float>(CountUtf8Codepoints(text));
}
std::size_t AdvanceUtf8Offset(const std::string& text, std::size_t offset) {
if (offset >= text.size()) {
return text.size();
}
++offset;
while (offset < text.size() && IsUtf8ContinuationByte(static_cast<unsigned char>(text[offset]))) {
++offset;
}
return offset;
}
std::size_t RetreatUtf8Offset(const std::string& text, std::size_t offset) {
if (offset == 0u || text.empty()) {
return 0u;
}
offset = (std::min)(offset, text.size());
do {
--offset;
} while (offset > 0u && IsUtf8ContinuationByte(static_cast<unsigned char>(text[offset])));
return offset;
}
void AppendUtf8Codepoint(std::string& text, std::uint32_t codepoint) {
if (codepoint <= 0x7Fu) {
text.push_back(static_cast<char>(codepoint));
return;
}
if (codepoint <= 0x7FFu) {
text.push_back(static_cast<char>(0xC0u | ((codepoint >> 6u) & 0x1Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
return;
}
if (codepoint >= 0xD800u && codepoint <= 0xDFFFu) {
return;
}
if (codepoint <= 0xFFFFu) {
text.push_back(static_cast<char>(0xE0u | ((codepoint >> 12u) & 0x0Fu)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 6u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
return;
}
if (codepoint <= 0x10FFFFu) {
text.push_back(static_cast<char>(0xF0u | ((codepoint >> 18u) & 0x07u)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 12u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | ((codepoint >> 6u) & 0x3Fu)));
text.push_back(static_cast<char>(0x80u | (codepoint & 0x3Fu)));
}
return fontSize * kApproximateTextWidthFactor * static_cast<float>(UIText::CountUtf8Codepoints(text));
}
bool ContainsPoint(const UIRect& rect, const UIPoint& point) {
@@ -467,26 +400,6 @@ bool IsTextInputNode(const DemoNode& node) {
return IsTextFieldNode(node) || IsTextAreaNode(node);
}
std::vector<std::string> SplitLines(const std::string& text) {
std::vector<std::string> lines = {};
std::size_t lineStart = 0u;
for (std::size_t index = 0u; index < text.size(); ++index) {
if (text[index] != '\n') {
continue;
}
lines.push_back(text.substr(lineStart, index - lineStart));
lineStart = index + 1u;
}
lines.push_back(text.substr(lineStart));
return lines;
}
std::size_t CountTextLines(const std::string& text) {
return SplitLines(text).size();
}
std::size_t CountDecimalDigits(std::size_t value) {
std::size_t digits = 1u;
while (value >= 10u) {
@@ -496,90 +409,6 @@ std::size_t CountDecimalDigits(std::size_t value) {
return digits;
}
std::size_t CountUtf8CodepointsInRange(
const std::string& text,
std::size_t beginOffset,
std::size_t endOffset) {
beginOffset = (std::min)(beginOffset, text.size());
endOffset = (std::min)(endOffset, text.size());
if (endOffset <= beginOffset) {
return 0u;
}
std::size_t count = 0u;
for (std::size_t index = beginOffset; index < endOffset; ++index) {
if (!IsUtf8ContinuationByte(static_cast<unsigned char>(text[index]))) {
++count;
}
}
return count;
}
std::size_t AdvanceUtf8Codepoints(
const std::string& text,
std::size_t offset,
std::size_t codepointCount) {
offset = (std::min)(offset, text.size());
for (std::size_t index = 0u; index < codepointCount && offset < text.size(); ++index) {
offset = AdvanceUtf8Offset(text, offset);
}
return offset;
}
std::size_t FindLineStartOffset(const std::string& text, std::size_t caret) {
caret = (std::min)(caret, text.size());
while (caret > 0u && text[caret - 1u] != '\n') {
--caret;
}
return caret;
}
std::size_t FindLineEndOffset(const std::string& text, std::size_t caret) {
caret = (std::min)(caret, text.size());
while (caret < text.size() && text[caret] != '\n') {
++caret;
}
return caret;
}
std::size_t MoveCaretVertically(
const std::string& text,
std::size_t caret,
int direction) {
caret = (std::min)(caret, text.size());
const std::size_t currentLineStart = FindLineStartOffset(text, caret);
const std::size_t currentLineEnd = FindLineEndOffset(text, caret);
const std::size_t column = CountUtf8CodepointsInRange(text, currentLineStart, caret);
if (direction < 0) {
if (currentLineStart == 0u) {
return caret;
}
const std::size_t previousLineEnd = currentLineStart - 1u;
const std::size_t previousLineStart = FindLineStartOffset(text, previousLineEnd);
const std::size_t previousLineLength =
CountUtf8CodepointsInRange(text, previousLineStart, previousLineEnd);
return AdvanceUtf8Codepoints(
text,
previousLineStart,
(std::min)(column, previousLineLength));
}
if (currentLineEnd >= text.size()) {
return caret;
}
const std::size_t nextLineStart = currentLineEnd + 1u;
const std::size_t nextLineEnd = FindLineEndOffset(text, nextLineStart);
const std::size_t nextLineLength =
CountUtf8CodepointsInRange(text, nextLineStart, nextLineEnd);
return AdvanceUtf8Codepoints(
text,
nextLineStart,
(std::min)(column, nextLineLength));
}
bool ShouldShowTextAreaLineNumbers(const DemoNode& node) {
bool showLineNumbers = false;
return IsTextAreaNode(node) &&
@@ -612,7 +441,7 @@ std::size_t FindCaretOffsetForHorizontalPosition(
std::size_t caret = lineStart;
while (caret < lineEnd) {
const std::size_t nextCaret = AdvanceUtf8Offset(text, caret);
const std::size_t nextCaret = UIText::AdvanceUtf8Offset(text, caret);
const float currentWidth = MeasureGlyphRunWidth(text.substr(lineStart, caret - lineStart), fontSize);
const float nextWidth = MeasureGlyphRunWidth(text.substr(lineStart, nextCaret - lineStart), fontSize);
if (targetX < (currentWidth + nextWidth) * 0.5f) {
@@ -955,7 +784,7 @@ std::string BuildNodeDisplayText(
const std::string& promptValue =
promptIt != state.textFieldValues.end() ? promptIt->second : node.staticText;
return "Single-line input, Enter submits, " +
std::to_string(static_cast<unsigned long long>(CountUtf8Codepoints(promptValue))) +
std::to_string(static_cast<unsigned long long>(UIText::CountUtf8Codepoints(promptValue))) +
" chars";
}
if (node.elementKey == "notesMeta") {
@@ -963,7 +792,7 @@ std::string BuildNodeDisplayText(
const std::string& notesValue =
notesIt != state.textFieldValues.end() ? notesIt->second : node.staticText;
return "Multiline input, click caret, Tab indent, " +
std::to_string(static_cast<unsigned long long>(CountTextLines(notesValue))) +
std::to_string(static_cast<unsigned long long>(UIText::CountTextLines(notesValue))) +
" lines";
}
if (node.elementKey == "subtitle" && stats.accentEnabled) {
@@ -1177,7 +1006,7 @@ std::size_t FindCaretOffsetFromPoint(
}
const float lineHeight = MeasureTextHeight(fontSize);
const std::size_t lineCount = (std::max)(std::size_t(1u), CountTextLines(value));
const std::size_t lineCount = (std::max)(std::size_t(1u), UIText::CountTextLines(value));
const float gutterWidth = ResolveTextAreaGutterWidth(node, lineCount, fontSize);
const float localY = point.y - (contentRect.y + kTextInputTextInset);
const float clampedY = (std::max)(0.0f, localY);
@@ -1187,13 +1016,13 @@ std::size_t FindCaretOffsetFromPoint(
std::size_t lineStart = 0u;
for (std::size_t currentLine = 0u; currentLine < lineIndex && lineStart < value.size(); ++currentLine) {
lineStart = FindLineEndOffset(value, lineStart);
lineStart = UIText::FindLineEndOffset(value, lineStart);
if (lineStart < value.size()) {
++lineStart;
}
}
const std::size_t lineEnd = FindLineEndOffset(value, lineStart);
const std::size_t lineEnd = UIText::FindLineEndOffset(value, lineStart);
const float targetX = point.x - (contentRect.x + kTextInputTextInset + gutterWidth);
return FindCaretOffsetForHorizontalPosition(value, lineStart, lineEnd, targetX, fontSize);
}
@@ -1231,7 +1060,7 @@ bool HandleTextInputCharacterInput(
caret = (std::min)(caret, value.size());
std::string encoded = {};
AppendUtf8Codepoint(encoded, character);
UIText::AppendUtf8Codepoint(encoded, character);
if (encoded.empty()) {
return false;
}
@@ -1260,7 +1089,7 @@ bool HandleTextInputKeyDown(
if (keyCode == static_cast<std::int32_t>(KeyCode::Backspace)) {
if (caret > 0u) {
const std::size_t previousCaret = RetreatUtf8Offset(value, caret);
const std::size_t previousCaret = UIText::RetreatUtf8Offset(value, caret);
value.erase(previousCaret, caret - previousCaret);
caret = previousCaret;
state.lastCommandId = "demo.text.edit." + stateKey;
@@ -1270,7 +1099,7 @@ bool HandleTextInputKeyDown(
if (keyCode == static_cast<std::int32_t>(KeyCode::Delete)) {
if (caret < value.size()) {
const std::size_t nextCaret = AdvanceUtf8Offset(value, caret);
const std::size_t nextCaret = UIText::AdvanceUtf8Offset(value, caret);
value.erase(caret, nextCaret - caret);
state.lastCommandId = "demo.text.edit." + stateKey;
}
@@ -1278,35 +1107,35 @@ bool HandleTextInputKeyDown(
}
if (keyCode == static_cast<std::int32_t>(KeyCode::Left)) {
caret = RetreatUtf8Offset(value, caret);
caret = UIText::RetreatUtf8Offset(value, caret);
return true;
}
if (keyCode == static_cast<std::int32_t>(KeyCode::Right)) {
caret = AdvanceUtf8Offset(value, caret);
caret = UIText::AdvanceUtf8Offset(value, caret);
return true;
}
if (keyCode == static_cast<std::int32_t>(KeyCode::Up) && IsTextAreaNode(*node)) {
caret = MoveCaretVertically(value, caret, -1);
caret = UIText::MoveCaretVertically(value, caret, -1);
return true;
}
if (keyCode == static_cast<std::int32_t>(KeyCode::Down) && IsTextAreaNode(*node)) {
caret = MoveCaretVertically(value, caret, 1);
caret = UIText::MoveCaretVertically(value, caret, 1);
return true;
}
if (keyCode == static_cast<std::int32_t>(KeyCode::Home)) {
caret = IsTextAreaNode(*node)
? FindLineStartOffset(value, caret)
? UIText::FindLineStartOffset(value, caret)
: 0u;
return true;
}
if (keyCode == static_cast<std::int32_t>(KeyCode::End)) {
caret = IsTextAreaNode(*node)
? FindLineEndOffset(value, caret)
? UIText::FindLineEndOffset(value, caret)
: value.size();
return true;
}
@@ -1329,7 +1158,7 @@ bool HandleTextInputKeyDown(
!inputModifiers.alt &&
!inputModifiers.super) {
if (inputModifiers.shift) {
const std::size_t lineStart = FindLineStartOffset(value, caret);
const std::size_t lineStart = UIText::FindLineStartOffset(value, caret);
std::size_t removed = 0u;
while (removed < kTextAreaTabWidth &&
lineStart + removed < value.size() &&
@@ -1539,7 +1368,7 @@ UISize MeasureNode(RuntimeBuildContext& state, std::size_t index) {
float minWidth = 220.0f;
TryParseFloat(GetNodeAttribute(node, "min-width"), minWidth);
const std::string probeText = value.empty() ? placeholder : value;
const std::vector<std::string> lines = SplitLines(probeText);
const std::vector<std::string> lines = UIText::SplitLines(probeText);
float widestLine = 0.0f;
for (const std::string& line : lines) {
widestLine = (std::max)(
@@ -1881,7 +1710,7 @@ void DrawTextAreaNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawLi
const std::string value = ResolveTextInputValue(state, node);
const std::string placeholder = GetNodeAttribute(node, "placeholder");
const bool showingPlaceholder = value.empty() && !placeholder.empty();
const std::vector<std::string> lines = SplitLines(showingPlaceholder ? placeholder : value);
const std::vector<std::string> lines = UIText::SplitLines(showingPlaceholder ? placeholder : value);
const float gutterWidth = ResolveTextAreaGutterWidth(node, (std::max)(std::size_t(1u), lines.size()), fontSize);
const Color textColor = showingPlaceholder
? ResolveColorToken(state.activeTheme, "color.text.placeholder", Color(0.49f, 0.56f, 0.64f, 1.0f))
@@ -1931,7 +1760,7 @@ void DrawTextAreaNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawLi
}
const std::size_t caret = ResolveTextInputCaret(state, node);
const std::size_t lineStart = FindLineStartOffset(value, caret);
const std::size_t lineStart = UIText::FindLineStartOffset(value, caret);
std::size_t lineIndex = 0u;
for (std::size_t scan = 0u; scan < lineStart && scan < value.size(); ++scan) {
if (value[scan] == '\n') {

View File

@@ -6,6 +6,7 @@ set(UI_TEST_SOURCES
test_ui_core.cpp
test_layout_engine.cpp
test_ui_runtime.cpp
test_ui_text_editing.cpp
)
add_executable(core_ui_tests ${UI_TEST_SOURCES})

View File

@@ -0,0 +1,59 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Text/UITextEditing.h>
#include <string>
namespace {
namespace UIText = XCEngine::UI::Text;
TEST(UITextEditingTest, Utf8CountingAndCaretOffsetsRespectCodepointBoundaries) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "B";
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 0u), 1u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 1u), 4u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 4u), 5u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, text.size()), 4u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 4u), 1u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 1u), 0u);
}
TEST(UITextEditingTest, AppendUtf8CodepointEncodesCharactersAndSkipsInvalidSurrogates) {
std::string text = {};
UIText::AppendUtf8Codepoint(text, 'A');
UIText::AppendUtf8Codepoint(text, 0x4F60u);
UIText::AppendUtf8Codepoint(text, 0x1F642u);
UIText::AppendUtf8Codepoint(text, 0xD800u);
EXPECT_EQ(text, std::string("A") + "\xE4\xBD\xA0" + "\xF0\x9F\x99\x82");
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
}
TEST(UITextEditingTest, SplitLinesAndLineHelpersTrackMultilineRanges) {
const std::string text = "alpha\nbeta\n";
const auto lines = UIText::SplitLines(text);
ASSERT_EQ(lines.size(), 3u);
EXPECT_EQ(lines[0], "alpha");
EXPECT_EQ(lines[1], "beta");
EXPECT_EQ(lines[2], "");
EXPECT_EQ(UIText::CountTextLines(text), 3u);
EXPECT_EQ(UIText::CountUtf8CodepointsInRange(text, 6u, 10u), 4u);
EXPECT_EQ(UIText::FindLineStartOffset(text, 7u), 6u);
EXPECT_EQ(UIText::FindLineEndOffset(text, 7u), 10u);
}
TEST(UITextEditingTest, MoveCaretVerticallyPreservesUtf8ColumnWhenPossible) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n";
const std::size_t secondColumnCaret = UIText::AdvanceUtf8Offset(text, 1u);
const std::size_t movedDown = UIText::MoveCaretVertically(text, secondColumnCaret, 1);
const std::size_t movedBackUp = UIText::MoveCaretVertically(text, movedDown, -1);
EXPECT_EQ(movedDown, 8u);
EXPECT_EQ(movedBackUp, secondColumnCaret);
}
} // namespace