Step 520: harden completion overlay placement and dismissal
This commit is contained in:
@@ -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)
|
||||
|
||||
87
editor/src/CompletionOverlayUXHardening.h
Normal file
87
editor/src/CompletionOverlayUXHardening.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
// Step 520: Completion Overlay UX Hardening
|
||||
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
123
editor/tests/step520_test.cpp
Normal file
123
editor/tests/step520_test.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
// Step 520: Completion Overlay UX Hardening (12 tests)
|
||||
|
||||
#include "CompletionOverlayUXHardening.h"
|
||||
|
||||
#include <iostream>
|
||||
|
||||
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;
|
||||
}
|
||||
33
progress.md
33
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`
|
||||
|
||||
Reference in New Issue
Block a user