diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index 4892ee02..15bf2550 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -30,13 +30,14 @@ Old `editor` replacement is explicitly out of scope for this phase. - 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`. +- Shared text-input controller/state now also lives under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so character insertion, backspace/delete, submit, and multiline key handling no longer need to be reimplemented per host. - 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: 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. +- Common widget primitives are still incomplete: shared text-input presentation/composition on top of the new text controller, tree/list virtualization, property-grid composition, and native image/source-rect level APIs. ### 2. Runtime/Game Layer @@ -57,6 +58,7 @@ Current gap: - Native hosted preview is working as `RHI offscreen surface -> ImGui shell texture embed`. - `XCUI Demo` remains the long-lived effect and behavior testbed. - `XCUI Demo` now covers both single-line and multiline text authoring behavior, including click caret placement, delete/backspace, tab indentation, and optional text-area line numbers. +- `XCUI Demo` now consumes the shared `UITextInputController` path for text editing instead of carrying a private key-handling state machine. - `LayoutLab` now includes a `ScrollView` prototype and a more editor-like three-column authored layout. - `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. @@ -76,7 +78,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`: `21/21` +- `core_ui_tests`: `26/26` - `core_ui_style_tests`: `5/5` - `ui_resource_tests`: `11/11` - `editor_tests` targeted bridge smoke: `3/3` @@ -86,6 +88,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. +- Common-core `UITextInputController` extraction now owns per-field text state, character insertion, enter-submit, and multiline keyboard editing behavior with dedicated `core_ui_tests` coverage. - Demo runtime text editing was extended with: - click-to-place caret - `Delete` support diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 1a1740f0..fa8d01b0 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -517,7 +517,9 @@ add_library(XCEngine STATIC ${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}/include/XCEngine/UI/Text/UITextInputController.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.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/UITextInputController.h b/engine/include/XCEngine/UI/Text/UITextInputController.h new file mode 100644 index 00000000..686059e5 --- /dev/null +++ b/engine/include/XCEngine/UI/Text/UITextInputController.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Text { + +struct UITextInputState { + std::string value = {}; + std::size_t caret = 0u; +}; + +struct UITextInputOptions { + bool multiline = false; + std::size_t tabWidth = 4u; +}; + +struct UITextInputEditResult { + bool handled = false; + bool valueChanged = false; + bool submitRequested = false; +}; + +void ClampCaret(UITextInputState& state); +bool InsertCharacter(UITextInputState& state, std::uint32_t character); +UITextInputEditResult HandleKeyDown( + UITextInputState& state, + std::int32_t keyCode, + const UIInputModifiers& modifiers, + const UITextInputOptions& options = {}); + +} // namespace Text +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Text/UITextInputController.cpp b/engine/src/UI/Text/UITextInputController.cpp new file mode 100644 index 00000000..ddb55d5a --- /dev/null +++ b/engine/src/UI/Text/UITextInputController.cpp @@ -0,0 +1,156 @@ +#include + +#include +#include + +#include + +namespace XCEngine { +namespace UI { +namespace Text { + +namespace { + +using XCEngine::Input::KeyCode; + +} // namespace + +void ClampCaret(UITextInputState& state) { + state.caret = (std::min)(state.caret, state.value.size()); +} + +bool InsertCharacter(UITextInputState& state, std::uint32_t character) { + if (character < 32u || character == 127u) { + return false; + } + + std::string encoded = {}; + AppendUtf8Codepoint(encoded, character); + if (encoded.empty()) { + return false; + } + + ClampCaret(state); + state.value.insert(state.caret, encoded); + state.caret += encoded.size(); + return true; +} + +UITextInputEditResult HandleKeyDown( + UITextInputState& state, + std::int32_t keyCode, + const UIInputModifiers& modifiers, + const UITextInputOptions& options) { + ClampCaret(state); + + UITextInputEditResult result = {}; + + if (keyCode == static_cast(KeyCode::Backspace)) { + result.handled = true; + if (state.caret > 0u) { + const std::size_t previousCaret = RetreatUtf8Offset(state.value, state.caret); + state.value.erase(previousCaret, state.caret - previousCaret); + state.caret = previousCaret; + result.valueChanged = true; + } + return result; + } + + if (keyCode == static_cast(KeyCode::Delete)) { + result.handled = true; + if (state.caret < state.value.size()) { + const std::size_t nextCaret = AdvanceUtf8Offset(state.value, state.caret); + state.value.erase(state.caret, nextCaret - state.caret); + result.valueChanged = true; + } + return result; + } + + if (keyCode == static_cast(KeyCode::Left)) { + result.handled = true; + state.caret = RetreatUtf8Offset(state.value, state.caret); + return result; + } + + if (keyCode == static_cast(KeyCode::Right)) { + result.handled = true; + state.caret = AdvanceUtf8Offset(state.value, state.caret); + return result; + } + + if (keyCode == static_cast(KeyCode::Up) && options.multiline) { + result.handled = true; + state.caret = MoveCaretVertically(state.value, state.caret, -1); + return result; + } + + if (keyCode == static_cast(KeyCode::Down) && options.multiline) { + result.handled = true; + state.caret = MoveCaretVertically(state.value, state.caret, 1); + return result; + } + + if (keyCode == static_cast(KeyCode::Home)) { + result.handled = true; + state.caret = options.multiline + ? FindLineStartOffset(state.value, state.caret) + : 0u; + return result; + } + + if (keyCode == static_cast(KeyCode::End)) { + result.handled = true; + state.caret = options.multiline + ? FindLineEndOffset(state.value, state.caret) + : state.value.size(); + return result; + } + + if (keyCode == static_cast(KeyCode::Enter)) { + result.handled = true; + if (options.multiline) { + state.value.insert(state.caret, "\n"); + ++state.caret; + result.valueChanged = true; + } else { + result.submitRequested = true; + } + return result; + } + + if (keyCode == static_cast(KeyCode::Tab) && + options.multiline && + !modifiers.control && + !modifiers.alt && + !modifiers.super) { + result.handled = true; + + if (modifiers.shift) { + const std::size_t lineStart = FindLineStartOffset(state.value, state.caret); + std::size_t removed = 0u; + while (removed < options.tabWidth && + lineStart + removed < state.value.size() && + state.value[lineStart + removed] == ' ') { + ++removed; + } + + if (removed > 0u) { + state.value.erase(lineStart, removed); + state.caret = state.caret >= lineStart + removed ? state.caret - removed : lineStart; + result.valueChanged = true; + } + } else { + state.value.insert(state.caret, std::string(options.tabWidth, ' ')); + state.caret += options.tabWidth; + result.valueChanged = true; + } + + return result; + } + + return result; +} + +} // namespace Text +} // namespace UI +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp index 4aed40a8..815f8a8b 100644 --- a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp +++ b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -107,8 +108,7 @@ struct RuntimeBuildContext { std::unordered_map nodeIndexByKey = {}; std::unordered_map nodeIndexById = {}; std::unordered_map toggleStates = {}; - std::unordered_map textFieldValues = {}; - std::unordered_map textFieldCarets = {}; + std::unordered_map textInputStates = {}; UIElementId toggleButtonId = 0; UIElementId registeredShortcutOwnerId = 0; UIElementId armedElementId = 0; @@ -780,17 +780,17 @@ std::string BuildNodeDisplayText( std::string(diagnosticsEnabled ? "on" : "off"); } if (node.elementKey == "promptMeta") { - const auto promptIt = state.textFieldValues.find("agentPrompt"); + const auto promptIt = state.textInputStates.find("agentPrompt"); const std::string& promptValue = - promptIt != state.textFieldValues.end() ? promptIt->second : node.staticText; + promptIt != state.textInputStates.end() ? promptIt->second.value : node.staticText; return "Single-line input, Enter submits, " + std::to_string(static_cast(UIText::CountUtf8Codepoints(promptValue))) + " chars"; } if (node.elementKey == "notesMeta") { - const auto notesIt = state.textFieldValues.find("sessionNotes"); + const auto notesIt = state.textInputStates.find("sessionNotes"); const std::string& notesValue = - notesIt != state.textFieldValues.end() ? notesIt->second : node.staticText; + notesIt != state.textInputStates.end() ? notesIt->second.value : node.staticText; return "Multiline input, click caret, Tab indent, " + std::to_string(static_cast(UIText::CountTextLines(notesValue))) + " lines"; @@ -951,32 +951,30 @@ void EnsureTextInputStateInitialized(RuntimeBuildContext& state, const DemoNode& } const std::string stateKey = ResolveTextInputStateKey(node); - auto valueIt = state.textFieldValues.find(stateKey); - if (valueIt == state.textFieldValues.end()) { - valueIt = state.textFieldValues - .emplace(stateKey, GetNodeAttribute(node, "value")) + auto textInputIt = state.textInputStates.find(stateKey); + if (textInputIt == state.textInputStates.end()) { + UIText::UITextInputState textInput = {}; + textInput.value = GetNodeAttribute(node, "value"); + textInput.caret = textInput.value.size(); + textInputIt = state.textInputStates + .emplace(stateKey, std::move(textInput)) .first; } - auto caretIt = state.textFieldCarets.find(stateKey); - if (caretIt == state.textFieldCarets.end()) { - caretIt = state.textFieldCarets.emplace(stateKey, valueIt->second.size()).first; - } - - caretIt->second = (std::min)(caretIt->second, valueIt->second.size()); + UIText::ClampCaret(textInputIt->second); } std::string ResolveTextInputValue(RuntimeBuildContext& state, const DemoNode& node) { EnsureTextInputStateInitialized(state, node); - return state.textFieldValues[ResolveTextInputStateKey(node)]; + return state.textInputStates[ResolveTextInputStateKey(node)].value; } std::size_t ResolveTextInputCaret(RuntimeBuildContext& state, const DemoNode& node) { EnsureTextInputStateInitialized(state, node); const std::string stateKey = ResolveTextInputStateKey(node); - std::size_t& caret = state.textFieldCarets[stateKey]; - caret = (std::min)(caret, state.textFieldValues[stateKey].size()); - return caret; + UIText::UITextInputState& textInput = state.textInputStates[stateKey]; + UIText::ClampCaret(textInput); + return textInput.caret; } DemoNode* TryGetNodeByElementId(RuntimeBuildContext& state, UIElementId elementId) { @@ -1036,8 +1034,11 @@ void SetTextInputCaretFromPoint( return; } + EnsureTextInputStateInitialized(state, *node); const std::string stateKey = ResolveTextInputStateKey(*node); - state.textFieldCarets[stateKey] = FindCaretOffsetFromPoint(state, *node, point); + UIText::UITextInputState& textInput = state.textInputStates[stateKey]; + textInput.caret = FindCaretOffsetFromPoint(state, *node, point); + UIText::ClampCaret(textInput); } bool HandleTextInputCharacterInput( @@ -1055,18 +1056,11 @@ bool HandleTextInputCharacterInput( EnsureTextInputStateInitialized(state, *node); const std::string stateKey = ResolveTextInputStateKey(*node); - std::string& value = state.textFieldValues[stateKey]; - std::size_t& caret = state.textFieldCarets[stateKey]; - caret = (std::min)(caret, value.size()); - - std::string encoded = {}; - UIText::AppendUtf8Codepoint(encoded, character); - if (encoded.empty()) { + UIText::UITextInputState& textInput = state.textInputStates[stateKey]; + if (!UIText::InsertCharacter(textInput, character)) { return false; } - value.insert(caret, encoded); - caret += encoded.size(); state.lastCommandId = "demo.text.edit." + stateKey; return true; } @@ -1083,103 +1077,24 @@ bool HandleTextInputKeyDown( EnsureTextInputStateInitialized(state, *node); const std::string stateKey = ResolveTextInputStateKey(*node); - std::string& value = state.textFieldValues[stateKey]; - std::size_t& caret = state.textFieldCarets[stateKey]; - caret = (std::min)(caret, value.size()); + UIText::UITextInputState& textInput = state.textInputStates[stateKey]; + UIText::UITextInputOptions options = {}; + options.multiline = IsTextAreaNode(*node); + options.tabWidth = kTextAreaTabWidth; - if (keyCode == static_cast(KeyCode::Backspace)) { - if (caret > 0u) { - const std::size_t previousCaret = UIText::RetreatUtf8Offset(value, caret); - value.erase(previousCaret, caret - previousCaret); - caret = previousCaret; - state.lastCommandId = "demo.text.edit." + stateKey; - } - return true; + const UIText::UITextInputEditResult result = + UIText::HandleKeyDown(textInput, keyCode, inputModifiers, options); + if (!result.handled) { + return false; } - if (keyCode == static_cast(KeyCode::Delete)) { - if (caret < value.size()) { - const std::size_t nextCaret = UIText::AdvanceUtf8Offset(value, caret); - value.erase(caret, nextCaret - caret); - state.lastCommandId = "demo.text.edit." + stateKey; - } - return true; - } - - if (keyCode == static_cast(KeyCode::Left)) { - caret = UIText::RetreatUtf8Offset(value, caret); - return true; - } - - if (keyCode == static_cast(KeyCode::Right)) { - caret = UIText::AdvanceUtf8Offset(value, caret); - return true; - } - - if (keyCode == static_cast(KeyCode::Up) && IsTextAreaNode(*node)) { - caret = UIText::MoveCaretVertically(value, caret, -1); - return true; - } - - if (keyCode == static_cast(KeyCode::Down) && IsTextAreaNode(*node)) { - caret = UIText::MoveCaretVertically(value, caret, 1); - return true; - } - - if (keyCode == static_cast(KeyCode::Home)) { - caret = IsTextAreaNode(*node) - ? UIText::FindLineStartOffset(value, caret) - : 0u; - return true; - } - - if (keyCode == static_cast(KeyCode::End)) { - caret = IsTextAreaNode(*node) - ? UIText::FindLineEndOffset(value, caret) - : value.size(); - return true; - } - - if (keyCode == static_cast(KeyCode::Enter)) { - if (IsTextAreaNode(*node)) { - value.insert(caret, "\n"); - ++caret; - state.lastCommandId = "demo.text.edit." + stateKey; - return true; - } - + if (result.submitRequested) { state.lastCommandId = "demo.text.submit." + stateKey; - return true; + } else if (result.valueChanged) { + state.lastCommandId = "demo.text.edit." + stateKey; } - if (keyCode == static_cast(KeyCode::Tab) && - IsTextAreaNode(*node) && - !inputModifiers.control && - !inputModifiers.alt && - !inputModifiers.super) { - if (inputModifiers.shift) { - const std::size_t lineStart = UIText::FindLineStartOffset(value, caret); - std::size_t removed = 0u; - while (removed < kTextAreaTabWidth && - lineStart + removed < value.size() && - value[lineStart + removed] == ' ') { - ++removed; - } - - if (removed > 0u) { - value.erase(lineStart, removed); - caret = caret >= lineStart + removed ? caret - removed : lineStart; - state.lastCommandId = "demo.text.edit." + stateKey; - } - } else { - value.insert(caret, std::string(kTextAreaTabWidth, ' ')); - caret += kTextAreaTabWidth; - state.lastCommandId = "demo.text.edit." + stateKey; - } - return true; - } - - return false; + return true; } std::string BuildActivationCommandId(const DemoNode& node) { diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index 28dc207a..9d52a339 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -7,6 +7,7 @@ set(UI_TEST_SOURCES test_layout_engine.cpp test_ui_runtime.cpp test_ui_text_editing.cpp + test_ui_text_input_controller.cpp ) add_executable(core_ui_tests ${UI_TEST_SOURCES}) diff --git a/tests/Core/UI/test_ui_text_input_controller.cpp b/tests/Core/UI/test_ui_text_input_controller.cpp new file mode 100644 index 00000000..9b0e541d --- /dev/null +++ b/tests/Core/UI/test_ui_text_input_controller.cpp @@ -0,0 +1,115 @@ +#include + +#include +#include + +namespace { + +namespace UIText = XCEngine::UI::Text; +using XCEngine::Input::KeyCode; + +TEST(UITextInputControllerTest, InsertCharacterTracksUtf8CaretMovement) { + UIText::UITextInputState state = {}; + + EXPECT_TRUE(UIText::InsertCharacter(state, 'A')); + EXPECT_TRUE(UIText::InsertCharacter(state, 0x4F60u)); + EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0"); + EXPECT_EQ(state.caret, state.value.size()); +} + +TEST(UITextInputControllerTest, BackspaceAndArrowKeysUseUtf8Boundaries) { + UIText::UITextInputState state = {}; + state.value = std::string("A") + "\xE4\xBD\xA0" + "B"; + state.caret = state.value.size(); + + const auto moveLeft = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Left), + {}, + {}); + EXPECT_TRUE(moveLeft.handled); + EXPECT_EQ(state.caret, 4u); + + const auto backspace = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Backspace), + {}, + {}); + EXPECT_TRUE(backspace.handled); + EXPECT_TRUE(backspace.valueChanged); + EXPECT_EQ(state.value, "AB"); + EXPECT_EQ(state.caret, 1u); +} + +TEST(UITextInputControllerTest, SingleLineEnterRequestsSubmitWithoutMutatingValue) { + UIText::UITextInputState state = {}; + state.value = "prompt"; + state.caret = state.value.size(); + + const auto result = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Enter), + {}, + {}); + EXPECT_TRUE(result.handled); + EXPECT_FALSE(result.valueChanged); + EXPECT_TRUE(result.submitRequested); + EXPECT_EQ(state.value, "prompt"); +} + +TEST(UITextInputControllerTest, MultilineEnterAndVerticalMovementStayInController) { + UIText::UITextInputState state = {}; + state.value = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC"; + state.caret = 4u; + + const UIText::UITextInputOptions options = { true, 4u }; + + const auto moveDown = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Down), + {}, + options); + EXPECT_TRUE(moveDown.handled); + EXPECT_EQ(state.caret, 8u); + + const auto enter = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Enter), + {}, + options); + EXPECT_TRUE(enter.handled); + EXPECT_TRUE(enter.valueChanged); + EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n"); +} + +TEST(UITextInputControllerTest, MultilineTabAndShiftTabIndentAndOutdentCurrentLine) { + UIText::UITextInputState state = {}; + state.value = "root\nnode"; + state.caret = 5u; + + const UIText::UITextInputOptions options = { true, 4u }; + + const auto indent = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Tab), + {}, + options); + EXPECT_TRUE(indent.handled); + EXPECT_TRUE(indent.valueChanged); + EXPECT_EQ(state.value, "root\n node"); + EXPECT_EQ(state.caret, 9u); + + XCEngine::UI::UIInputModifiers modifiers = {}; + modifiers.shift = true; + const auto outdent = UIText::HandleKeyDown( + state, + static_cast(KeyCode::Tab), + modifiers, + options); + EXPECT_TRUE(outdent.handled); + EXPECT_TRUE(outdent.valueChanged); + EXPECT_EQ(state.value, "root\nnode"); + EXPECT_EQ(state.caret, 5u); +} + +} // namespace