diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 3de1bb2..0f19032 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -3559,4 +3559,13 @@ target_link_libraries(step519_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step520_test tests/step520_test.cpp) +target_include_directories(step520_test PRIVATE src) +target_link_libraries(step520_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/CompletionOverlayUXHardening.h b/editor/src/CompletionOverlayUXHardening.h new file mode 100644 index 0000000..9a7ab10 --- /dev/null +++ b/editor/src/CompletionOverlayUXHardening.h @@ -0,0 +1,87 @@ +#pragma once +// Step 520: Completion Overlay UX Hardening + +#include +#include + +struct OverlayRect { + float x = 0.0f; + float y = 0.0f; + float w = 0.0f; + float h = 0.0f; +}; + +struct CompletionOverlayState { + bool visible = false; + bool dismissed = false; + int selectedIndex = 0; + std::string lastTriggerToken; + OverlayRect rect; +}; + +class CompletionOverlayUXHardening { +public: + static OverlayRect placeOverlayNearCursor(float cursorX, + float cursorY, + float lineHeight, + float viewportW, + float viewportH, + float overlayW, + float overlayH) { + OverlayRect r; + r.w = overlayW; + r.h = overlayH; + r.x = cursorX; + r.y = cursorY + lineHeight; + + if (r.x + r.w > viewportW) r.x = std::max(0.0f, viewportW - r.w); + if (r.y + r.h > viewportH) r.y = std::max(0.0f, cursorY - r.h - 4.0f); + if (r.y < 0.0f) r.y = 0.0f; + return r; + } + + static CompletionOverlayState onDismiss(const CompletionOverlayState& in) { + CompletionOverlayState out = in; + out.visible = false; + out.dismissed = true; + return out; + } + + static CompletionOverlayState onTrigger(const CompletionOverlayState& in, + const std::string& token, + bool semanticTriggerChanged) { + CompletionOverlayState out = in; + if (semanticTriggerChanged || token != in.lastTriggerToken) { + out.dismissed = false; + } + out.lastTriggerToken = token; + out.visible = !out.dismissed; + return out; + } + + static CompletionOverlayState onPointerSelect(const CompletionOverlayState& in, + int index, + int itemCount) { + CompletionOverlayState out = in; + out.selectedIndex = std::max(0, std::min(itemCount - 1, index)); + out.visible = itemCount > 0; + return out; + } + + static CompletionOverlayState onKeyNav(const CompletionOverlayState& in, + int delta, + int itemCount) { + CompletionOverlayState out = in; + if (itemCount <= 0) { + out.selectedIndex = 0; + out.visible = false; + return out; + } + int idx = in.selectedIndex + delta; + while (idx < 0) idx += itemCount; + while (idx >= itemCount) idx -= itemCount; + out.selectedIndex = idx; + out.visible = true; + return out; + } +}; diff --git a/editor/tests/step520_test.cpp b/editor/tests/step520_test.cpp new file mode 100644 index 0000000..91ea9a9 --- /dev/null +++ b/editor/tests/step520_test.cpp @@ -0,0 +1,123 @@ +// Step 520: Completion Overlay UX Hardening (12 tests) + +#include "CompletionOverlayUXHardening.h" + +#include + +static int passed = 0, failed = 0; +#define TEST(name) { std::cout << " " << #name << "... "; } +#define PASS() { std::cout << "PASS\n"; ++passed; } +#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; } +#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {} + +void test_overlay_defaults_below_cursor() { + TEST(overlay_defaults_below_cursor); + auto r = CompletionOverlayUXHardening::placeOverlayNearCursor(100, 100, 18, 800, 600, 200, 120); + CHECK(r.y >= 118, "overlay should appear below cursor"); + PASS(); +} + +void test_overlay_flips_above_when_bottom_overflow() { + TEST(overlay_flips_above_when_bottom_overflow); + auto r = CompletionOverlayUXHardening::placeOverlayNearCursor(100, 560, 18, 800, 600, 220, 120); + CHECK(r.y < 560, "overlay should flip above"); + PASS(); +} + +void test_overlay_clamps_right_edge() { + TEST(overlay_clamps_right_edge); + auto r = CompletionOverlayUXHardening::placeOverlayNearCursor(760, 100, 18, 800, 600, 120, 100); + CHECK(r.x + r.w <= 800, "overlay should clamp to right edge"); + PASS(); +} + +void test_overlay_never_negative_y() { + TEST(overlay_never_negative_y); + auto r = CompletionOverlayUXHardening::placeOverlayNearCursor(10, 5, 18, 200, 60, 190, 100); + CHECK(r.y >= 0, "overlay y must remain non-negative"); + PASS(); +} + +void test_dismiss_hides_overlay_and_sets_flag() { + TEST(dismiss_hides_overlay_and_sets_flag); + CompletionOverlayState s{true, false, 0, "foo", {}}; + auto d = CompletionOverlayUXHardening::onDismiss(s); + CHECK(!d.visible && d.dismissed, "dismiss state mismatch"); + PASS(); +} + +void test_trigger_with_same_token_keeps_dismissed() { + TEST(trigger_with_same_token_keeps_dismissed); + CompletionOverlayState s{false, true, 0, "foo", {}}; + auto n = CompletionOverlayUXHardening::onTrigger(s, "foo", false); + CHECK(!n.visible, "overlay should stay hidden"); + PASS(); +} + +void test_trigger_with_new_token_reopens_overlay() { + TEST(trigger_with_new_token_reopens_overlay); + CompletionOverlayState s{false, true, 0, "foo", {}}; + auto n = CompletionOverlayUXHardening::onTrigger(s, "bar", false); + CHECK(n.visible && !n.dismissed, "overlay should reopen on new token"); + PASS(); +} + +void test_trigger_with_semantic_change_reopens_overlay() { + TEST(trigger_with_semantic_change_reopens_overlay); + CompletionOverlayState s{false, true, 0, "foo", {}}; + auto n = CompletionOverlayUXHardening::onTrigger(s, "foo", true); + CHECK(n.visible && !n.dismissed, "semantic change should reopen overlay"); + PASS(); +} + +void test_pointer_selection_clamps_index() { + TEST(pointer_selection_clamps_index); + CompletionOverlayState s{true, false, 0, "", {}}; + auto n = CompletionOverlayUXHardening::onPointerSelect(s, 99, 5); + CHECK(n.selectedIndex == 4, "index should clamp to last item"); + PASS(); +} + +void test_pointer_selection_hides_when_no_items() { + TEST(pointer_selection_hides_when_no_items); + CompletionOverlayState s{true, false, 0, "", {}}; + auto n = CompletionOverlayUXHardening::onPointerSelect(s, 0, 0); + CHECK(!n.visible, "overlay should hide with zero items"); + PASS(); +} + +void test_keyboard_nav_wraps_forward() { + TEST(keyboard_nav_wraps_forward); + CompletionOverlayState s{true, false, 4, "", {}}; + auto n = CompletionOverlayUXHardening::onKeyNav(s, 1, 5); + CHECK(n.selectedIndex == 0, "forward nav should wrap"); + PASS(); +} + +void test_keyboard_nav_wraps_backward() { + TEST(keyboard_nav_wraps_backward); + CompletionOverlayState s{true, false, 0, "", {}}; + auto n = CompletionOverlayUXHardening::onKeyNav(s, -1, 5); + CHECK(n.selectedIndex == 4, "backward nav should wrap"); + PASS(); +} + +int main() { + std::cout << "Step 520: Completion Overlay UX Hardening\n"; + + test_overlay_defaults_below_cursor(); // 1 + test_overlay_flips_above_when_bottom_overflow(); // 2 + test_overlay_clamps_right_edge(); // 3 + test_overlay_never_negative_y(); // 4 + test_dismiss_hides_overlay_and_sets_flag(); // 5 + test_trigger_with_same_token_keeps_dismissed(); // 6 + test_trigger_with_new_token_reopens_overlay(); // 7 + test_trigger_with_semantic_change_reopens_overlay(); // 8 + test_pointer_selection_clamps_index(); // 9 + test_pointer_selection_hides_when_no_items(); // 10 + test_keyboard_nav_wraps_forward(); // 11 + test_keyboard_nav_wraps_backward(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 179e1ce..89a9480 100644 --- a/progress.md +++ b/progress.md @@ -8386,3 +8386,36 @@ modifier support, and stable text fallback labels for non-glyph environments. - `editor/src/ModifierEdgeNotation.h` within header-size limit (`76` <= `600`) - `editor/tests/step519_test.cpp` within test-file size guidance (`116` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 520: Completion Overlay UX Hardening +**Status:** PASS (12/12 tests) + +Implements completion-overlay hardening for predictable non-intrusive behavior: +viewport-safe placement, dismissal intent persistence, semantic-trigger reopen +logic, and stable pointer/keyboard navigation semantics. + +**Files added:** +- `editor/src/CompletionOverlayUXHardening.h` - overlay UX module: + - cursor-relative placement with overflow-aware flip/clamp logic + - dismissal-state persistence and semantic-trigger reopen policy + - pointer selection clamping and empty-list handling + - keyboard navigation with wrap behavior +- `editor/tests/step520_test.cpp` - 12 tests covering: + - placement under normal and overflow conditions + - dismissal/reopen behavior across trigger states + - pointer selection bounds and zero-item handling + - keyboard navigation wrap semantics + +**Files modified:** +- `editor/CMakeLists.txt` - `step520_test` target + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step520_test` - PASS +- `./editor/build-native/step520_test` - PASS (12/12) +- `./editor/build-native/step519_test` - PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/CompletionOverlayUXHardening.h` within header-size limit (`87` <= `600`) +- `editor/tests/step520_test.cpp` within test-file size guidance (`123` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`