From b4c95e40856fcf09e1004a4ee225bc97bfc61043 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 06:23:49 +0800 Subject: [PATCH] Extract XCUI text editing core and window seam headers --- docs/plan/XCUI_Phase_Status_2026-04-05.md | 12 +- engine/CMakeLists.txt | 2 + .../include/XCEngine/UI/Text/UITextEditing.h | 29 +++ engine/src/UI/Text/UITextEditing.cpp | 179 +++++++++++++++ .../src/XCUIBackend/IWindowUICompositor.h | 52 +++++ .../src/XCUIBackend/ImGuiWindowUICompositor.h | 94 ++++++++ .../src/XCUIBackend/XCUIDemoRuntime.cpp | 215 ++---------------- tests/Core/UI/CMakeLists.txt | 1 + tests/Core/UI/test_ui_text_editing.cpp | 59 +++++ 9 files changed, 446 insertions(+), 197 deletions(-) create mode 100644 engine/include/XCEngine/UI/Text/UITextEditing.h create mode 100644 engine/src/UI/Text/UITextEditing.cpp create mode 100644 new_editor/src/XCUIBackend/IWindowUICompositor.h create mode 100644 new_editor/src/XCUIBackend/ImGuiWindowUICompositor.h create mode 100644 tests/Core/UI/test_ui_text_editing.cpp diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index e569f009..4892ee02 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -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 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index c39626b4..1a1740f0 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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 diff --git a/engine/include/XCEngine/UI/Text/UITextEditing.h b/engine/include/XCEngine/UI/Text/UITextEditing.h new file mode 100644 index 00000000..cb357b33 --- /dev/null +++ b/engine/include/XCEngine/UI/Text/UITextEditing.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +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 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 diff --git a/engine/src/UI/Text/UITextEditing.cpp b/engine/src/UI/Text/UITextEditing.cpp new file mode 100644 index 00000000..d732003a --- /dev/null +++ b/engine/src/UI/Text/UITextEditing.cpp @@ -0,0 +1,179 @@ +#include + +#include + +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(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(text[offset]))); + return offset; +} + +void AppendUtf8Codepoint(std::string& text, std::uint32_t codepoint) { + if (codepoint <= 0x7Fu) { + text.push_back(static_cast(codepoint)); + return; + } + + if (codepoint <= 0x7FFu) { + text.push_back(static_cast(0xC0u | ((codepoint >> 6u) & 0x1Fu))); + text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); + return; + } + + if (codepoint >= 0xD800u && codepoint <= 0xDFFFu) { + return; + } + + if (codepoint <= 0xFFFFu) { + text.push_back(static_cast(0xE0u | ((codepoint >> 12u) & 0x0Fu))); + text.push_back(static_cast(0x80u | ((codepoint >> 6u) & 0x3Fu))); + text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); + return; + } + + if (codepoint <= 0x10FFFFu) { + text.push_back(static_cast(0xF0u | ((codepoint >> 18u) & 0x07u))); + text.push_back(static_cast(0x80u | ((codepoint >> 12u) & 0x3Fu))); + text.push_back(static_cast(0x80u | ((codepoint >> 6u) & 0x3Fu))); + text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); + } +} + +std::vector SplitLines(const std::string& text) { + std::vector 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 diff --git a/new_editor/src/XCUIBackend/IWindowUICompositor.h b/new_editor/src/XCUIBackend/IWindowUICompositor.h new file mode 100644 index 00000000..8172882e --- /dev/null +++ b/new_editor/src/XCUIBackend/IWindowUICompositor.h @@ -0,0 +1,52 @@ +#pragma once + +#include "Platform/D3D12WindowRenderer.h" + +#include +#include + +#include + +#include +#include +#include + +namespace XCEngine { +namespace Editor { +namespace XCUIBackend { + +class IWindowUICompositor { +public: + using ConfigureFontsCallback = std::function; + using UiRenderCallback = std::function; + 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 CreateImGuiWindowUICompositor(); + +} // namespace XCUIBackend +} // namespace Editor +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/ImGuiWindowUICompositor.h b/new_editor/src/XCUIBackend/ImGuiWindowUICompositor.h new file mode 100644 index 00000000..d89fdec9 --- /dev/null +++ b/new_editor/src/XCUIBackend/ImGuiWindowUICompositor.h @@ -0,0 +1,94 @@ +#pragma once + +#include "XCUIBackend/IEditorHostCompositor.h" +#include "XCUIBackend/IWindowUICompositor.h" + +#include +#include + +namespace XCEngine { +namespace Editor { +namespace XCUIBackend { + +class ImGuiWindowUICompositor final : public IWindowUICompositor { +public: + explicit ImGuiWindowUICompositor( + std::unique_ptr 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 m_hostCompositor; +}; + +inline std::unique_ptr CreateImGuiWindowUICompositor() { + return std::make_unique(); +} + +} // namespace XCUIBackend +} // namespace Editor +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp index 2777199f..4aed40a8 100644 --- a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp +++ b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -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(-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(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(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(text[offset]))); - return offset; -} - -void AppendUtf8Codepoint(std::string& text, std::uint32_t codepoint) { - if (codepoint <= 0x7Fu) { - text.push_back(static_cast(codepoint)); - return; - } - - if (codepoint <= 0x7FFu) { - text.push_back(static_cast(0xC0u | ((codepoint >> 6u) & 0x1Fu))); - text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); - return; - } - - if (codepoint >= 0xD800u && codepoint <= 0xDFFFu) { - return; - } - - if (codepoint <= 0xFFFFu) { - text.push_back(static_cast(0xE0u | ((codepoint >> 12u) & 0x0Fu))); - text.push_back(static_cast(0x80u | ((codepoint >> 6u) & 0x3Fu))); - text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); - return; - } - - if (codepoint <= 0x10FFFFu) { - text.push_back(static_cast(0xF0u | ((codepoint >> 18u) & 0x07u))); - text.push_back(static_cast(0x80u | ((codepoint >> 12u) & 0x3Fu))); - text.push_back(static_cast(0x80u | ((codepoint >> 6u) & 0x3Fu))); - text.push_back(static_cast(0x80u | (codepoint & 0x3Fu))); - } + return fontSize * kApproximateTextWidthFactor * static_cast(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 SplitLines(const std::string& text) { - std::vector 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(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(CountUtf8Codepoints(promptValue))) + + std::to_string(static_cast(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(CountTextLines(notesValue))) + + std::to_string(static_cast(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(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(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(KeyCode::Left)) { - caret = RetreatUtf8Offset(value, caret); + caret = UIText::RetreatUtf8Offset(value, caret); return true; } if (keyCode == static_cast(KeyCode::Right)) { - caret = AdvanceUtf8Offset(value, caret); + caret = UIText::AdvanceUtf8Offset(value, caret); return true; } if (keyCode == static_cast(KeyCode::Up) && IsTextAreaNode(*node)) { - caret = MoveCaretVertically(value, caret, -1); + caret = UIText::MoveCaretVertically(value, caret, -1); return true; } if (keyCode == static_cast(KeyCode::Down) && IsTextAreaNode(*node)) { - caret = MoveCaretVertically(value, caret, 1); + caret = UIText::MoveCaretVertically(value, caret, 1); return true; } if (keyCode == static_cast(KeyCode::Home)) { caret = IsTextAreaNode(*node) - ? FindLineStartOffset(value, caret) + ? UIText::FindLineStartOffset(value, caret) : 0u; return true; } if (keyCode == static_cast(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 lines = SplitLines(probeText); + const std::vector 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 lines = SplitLines(showingPlaceholder ? placeholder : value); + const std::vector 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') { diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index eb75aa62..28dc207a 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -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}) diff --git a/tests/Core/UI/test_ui_text_editing.cpp b/tests/Core/UI/test_ui_text_editing.cpp new file mode 100644 index 00000000..4c84fb64 --- /dev/null +++ b/tests/Core/UI/test_ui_text_editing.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include + +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