feat(focus): 完成阶段 2 通用焦点系统

This commit is contained in:
Codex
2026-10-08 18:37:34 +08:00
parent 81341fab2c
commit 255aba688f
25 changed files with 1533 additions and 5 deletions
+378
View File
@@ -25,6 +25,38 @@ namespace
bool model() const override { return false; }
};
class FocusTestControl final : public Control
{
bool focusEnabled = true;
std::function<void(bool, StellarX::FocusReason)> focusCallback;
public:
explicit FocusTestControl(int tabOrder = 0) : Control(0, 0, 10, 10)
{
id = "FocusTest";
focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
tabIndex = tabOrder;
}
void draw() override { dirty = false; }
bool handleEvent(const ExMessage&) override { return false; }
bool model() const override { return false; }
void setFocusEnabledForTest(bool value)
{
focusEnabled = value;
notifyFocusSubtreeStateChanged();
}
void setFocusCallback(std::function<void(bool, StellarX::FocusReason)> callback)
{
focusCallback = std::move(callback);
}
protected:
bool isFocusEnabled() const override { return focusEnabled; }
void onLogicalFocusChanged(bool focused, StellarX::FocusReason reason) override
{
if (focusCallback) focusCallback(focused, reason);
}
};
std::filesystem::path MakeTempLogPath(const char* name)
{
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
@@ -169,6 +201,352 @@ namespace StellarXNativeTests
}
};
TEST_CLASS(FocusManagerTests)
{
public:
TEST_METHOD(NestedControlResolvesByFocusTargetId)
{
Window window(200, 120, 1);
auto canvas = std::make_unique<Canvas>(0, 0, 100, 100);
auto child = std::make_unique<FocusTestControl>();
FocusTestControl* childRaw = child.get();
const auto childHandle = canvas->addControl(std::move(child));
window.addControl(std::move(canvas));
const auto result = window.getFocusManager().requestFocus({ childHandle, 0 });
Assert::AreEqual(static_cast<int>(StellarX::FocusError::None),
static_cast<int>(result.error));
Assert::IsTrue(childRaw->hasLogicalFocus());
Assert::AreEqual(childHandle.value,
window.getFocusManager().focusedTarget().owner.value);
const auto stops = window.getFocusManager().collectTabStops();
Assert::AreEqual<size_t>(1, stops.size());
Assert::AreEqual(childHandle.value, stops.front().id.owner.value);
Assert::AreEqual<std::uint32_t>(0, stops.front().id.subNode);
}
TEST_METHOD(PointerDownMovesLogicalFocusWithoutKeyboardRouting)
{
Window window(240, 120, 1);
auto button = std::make_unique<Button>(10, 10, 80, 30, "button");
Button* buttonRaw = button.get();
window.addControl(std::move(button));
auto text = std::make_unique<TextBox>(110, 10, 100, 30, "text");
TextBox* textRaw = text.get();
window.addControl(std::move(text));
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
Assert::IsTrue(buttonRaw->handleEvent(down));
Assert::IsTrue(buttonRaw->hasLogicalFocus());
down.x = 120;
Assert::IsTrue(textRaw->handleEvent(down));
Assert::IsFalse(buttonRaw->hasLogicalFocus());
Assert::IsTrue(textRaw->hasLogicalFocus());
}
TEST_METHOD(FocusLostCallbackCanDeleteNewTarget)
{
Window window(200, 120, 1);
auto oldControl = std::make_unique<FocusTestControl>();
FocusTestControl* oldRaw = oldControl.get();
const auto oldHandle = window.addControl(std::move(oldControl));
auto newControl = std::make_unique<FocusTestControl>();
const auto newHandle = window.addControl(std::move(newControl));
window.getFocusManager().requestFocus({ oldHandle, 0 });
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (!focused) Assert::IsTrue(window.removeControl(newHandle));
});
const auto result = window.getFocusManager().requestFocus({ newHandle, 0 });
Assert::AreEqual(static_cast<int>(StellarX::FocusError::InvalidTarget),
static_cast<int>(result.error));
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
Assert::IsNull(window.findControl(newHandle));
Assert::IsFalse(oldRaw->hasLogicalFocus());
}
TEST_METHOD(FocusLostCallbackForcesFreshEnabledSnapshot)
{
Window window(200, 120, 1);
auto oldControl = std::make_unique<FocusTestControl>();
FocusTestControl* oldRaw = oldControl.get();
const auto oldHandle = window.addControl(std::move(oldControl));
auto newControl = std::make_unique<FocusTestControl>();
FocusTestControl* newRaw = newControl.get();
const auto newHandle = window.addControl(std::move(newControl));
window.getFocusManager().requestFocus({ oldHandle, 0 });
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (!focused) newRaw->setFocusEnabledForTest(false);
});
const auto result = window.getFocusManager().requestFocus({ newHandle, 0 });
Assert::AreEqual(static_cast<int>(StellarX::FocusError::Disabled),
static_cast<int>(result.error));
Assert::IsFalse(newRaw->hasLogicalFocus());
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
}
TEST_METHOD(FocusGainedCallbackCanDeleteItselfAndFallsBack)
{
Window window(200, 120, 1);
auto fallback = std::make_unique<FocusTestControl>();
FocusTestControl* fallbackRaw = fallback.get();
const auto fallbackHandle = window.addControl(std::move(fallback));
auto selfDeleting = std::make_unique<FocusTestControl>();
FocusTestControl* deletingRaw = selfDeleting.get();
const auto deletingHandle = window.addControl(std::move(selfDeleting));
window.getFocusManager().requestFocus({ fallbackHandle, 0 });
deletingRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (focused) Assert::IsTrue(window.removeControl(deletingHandle));
});
const auto result = window.getFocusManager().requestFocus({ deletingHandle, 0 });
Assert::AreEqual(static_cast<int>(StellarX::FocusError::None),
static_cast<int>(result.error));
Assert::AreEqual(fallbackHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsTrue(fallbackRaw->hasLogicalFocus());
Assert::IsNull(window.findControl(deletingHandle));
}
TEST_METHOD(ReentrantPendingIsSingleSlotAndDoesNotReviveOlderRequest)
{
Window window(300, 120, 1);
auto oldControl = std::make_unique<FocusTestControl>();
FocusTestControl* oldRaw = oldControl.get();
const auto oldHandle = window.addControl(std::move(oldControl));
auto trigger = std::make_unique<FocusTestControl>();
const auto triggerHandle = window.addControl(std::move(trigger));
auto olderPending = std::make_unique<FocusTestControl>();
FocusTestControl* olderRaw = olderPending.get();
const auto olderHandle = window.addControl(std::move(olderPending));
auto latestPending = std::make_unique<FocusTestControl>();
const auto latestHandle = window.addControl(std::move(latestPending));
window.getFocusManager().requestFocus({ oldHandle, 0 });
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (focused) return;
window.getFocusManager().requestFocus({ olderHandle, 0 });
window.getFocusManager().requestFocus({ latestHandle, 0 });
Assert::IsTrue(window.removeControl(latestHandle));
});
window.getFocusManager().requestFocus({ triggerHandle, 0 });
Assert::AreEqual(triggerHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsFalse(olderRaw->hasLogicalFocus());
Assert::IsNull(window.findControl(latestHandle));
}
TEST_METHOD(FocusCallbackCanPushScopeAndPopRestoresOuterFocus)
{
Window window(300, 160, 1);
auto scopeRoot = std::make_unique<Canvas>(0, 40, 200, 100);
const auto scopeRootHandle = scopeRoot->getHandle();
auto scopedChild = std::make_unique<FocusTestControl>();
FocusTestControl* childRaw = scopedChild.get();
const auto childHandle = scopeRoot->addControl(std::move(scopedChild));
window.addControl(std::move(scopeRoot));
auto outer = std::make_unique<FocusTestControl>();
FocusTestControl* outerRaw = outer.get();
const auto outerHandle = window.addControl(std::move(outer));
StellarX::FocusScopeId pushed{};
bool didPush = false;
outerRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (focused && !didPush)
{
didPush = true;
pushed = window.getFocusManager().pushScope(
{ scopeRootHandle, 0 }, { childHandle, 0 });
}
});
window.getFocusManager().requestFocus({ outerHandle, 0 });
Assert::IsTrue(static_cast<bool>(pushed));
Assert::AreEqual(childHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsTrue(childRaw->hasLogicalFocus());
Assert::IsTrue(window.getFocusManager().popScope(pushed));
Assert::AreEqual(outerHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsTrue(outerRaw->hasLogicalFocus());
}
TEST_METHOD(FocusLostCallbackScopeRestoresTheActualOldTarget)
{
Window window(360, 180, 1);
auto unrelated = std::make_unique<FocusTestControl>();
const auto unrelatedHandle = window.addControl(std::move(unrelated));
auto oldControl = std::make_unique<FocusTestControl>();
FocusTestControl* oldRaw = oldControl.get();
const auto oldHandle = window.addControl(std::move(oldControl));
auto requested = std::make_unique<FocusTestControl>();
const auto requestedHandle = window.addControl(std::move(requested));
auto scopeRoot = std::make_unique<Canvas>(0, 40, 200, 100);
const auto scopeRootHandle = scopeRoot->getHandle();
auto scopedChild = std::make_unique<FocusTestControl>();
const auto childHandle = scopeRoot->addControl(std::move(scopedChild));
window.addControl(std::move(scopeRoot));
StellarX::FocusScopeId pushed{};
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (!focused && !pushed)
pushed = window.getFocusManager().pushScope(
{ scopeRootHandle, 0 }, { childHandle, 0 });
});
window.getFocusManager().requestFocus({ oldHandle, 0 });
window.getFocusManager().requestFocus({ requestedHandle, 0 });
Assert::IsTrue(static_cast<bool>(pushed));
Assert::AreEqual(childHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsTrue(window.getFocusManager().popScope(pushed));
Assert::AreEqual(oldHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::AreNotEqual(unrelatedHandle.value,
window.getFocusManager().focusedTarget().owner.value);
}
TEST_METHOD(ReentrantFocusCycleStopsAtExplicitLimit)
{
Window window(200, 120, 1);
auto first = std::make_unique<FocusTestControl>();
FocusTestControl* firstRaw = first.get();
const auto firstHandle = window.addControl(std::move(first));
auto second = std::make_unique<FocusTestControl>();
FocusTestControl* secondRaw = second.get();
const auto secondHandle = window.addControl(std::move(second));
firstRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (focused) window.getFocusManager().requestFocus({ secondHandle, 0 });
});
secondRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
{
if (focused) window.getFocusManager().requestFocus({ firstHandle, 0 });
});
const auto result = window.getFocusManager().requestFocus({ firstHandle, 0 });
Assert::AreEqual(static_cast<int>(StellarX::FocusError::ReentrancyLimit),
static_cast<int>(result.error));
Assert::AreEqual(static_cast<int>(StellarX::FocusError::ReentrancyLimit),
static_cast<int>(window.getFocusManager().getLastError()));
Assert::IsTrue(static_cast<bool>(window.getFocusManager().focusedTarget()));
}
TEST_METHOD(ActiveRemovalUsesNextTabStopFallback)
{
Window window(240, 120, 1);
auto first = std::make_unique<FocusTestControl>(0);
const auto firstHandle = window.addControl(std::move(first));
auto second = std::make_unique<FocusTestControl>(1);
const auto secondHandle = window.addControl(std::move(second));
auto third = std::make_unique<FocusTestControl>(2);
FocusTestControl* thirdRaw = third.get();
const auto thirdHandle = window.addControl(std::move(third));
window.getFocusManager().requestFocus({ secondHandle, 0 });
Assert::IsTrue(window.removeControl(secondHandle));
Assert::AreEqual(thirdHandle.value,
window.getFocusManager().focusedTarget().owner.value);
Assert::IsTrue(thirdRaw->hasLogicalFocus());
Assert::IsNotNull(window.findControl(firstHandle));
}
TEST_METHOD(ClearInvalidatesWholeBatchBeforeChoosingFallback)
{
Window window(240, 120, 1);
auto first = std::make_unique<FocusTestControl>();
const auto firstHandle = window.addControl(std::move(first));
int unexpectedGains = 0;
auto second = std::make_unique<FocusTestControl>();
second->setFocusCallback([&](bool focused, StellarX::FocusReason)
{ if (focused) ++unexpectedGains; });
window.addControl(std::move(second));
auto third = std::make_unique<FocusTestControl>();
third->setFocusCallback([&](bool focused, StellarX::FocusReason)
{ if (focused) ++unexpectedGains; });
window.addControl(std::move(third));
window.getFocusManager().requestFocus({ firstHandle, 0 });
window.clearControls();
Assert::AreEqual(0, unexpectedGains);
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
}
TEST_METHOD(TabControlExposesRealButtonHandleWithoutSyntheticSubNode)
{
Window window(300, 200, 1);
auto tabs = std::make_unique<TabControl>(0, 0, 300, 200);
auto tabButton = std::make_unique<Button>(0, 0, 80, 24, "tab");
const auto tabHandle = tabButton->getHandle();
tabs->addTab(std::move(tabButton), std::make_unique<Canvas>(0, 24, 300, 176));
window.addControl(std::move(tabs));
const auto stops = window.getFocusManager().collectTabStops();
auto found = std::find_if(stops.begin(), stops.end(), [&](const auto& item)
{ return item.id.owner == tabHandle; });
Assert::IsTrue(found != stops.end());
Assert::AreEqual<std::uint32_t>(0, found->id.subNode);
}
TEST_METHOD(TabControlExcludesInactivePageFromFocusTree)
{
Window window(320, 220, 1);
auto tabs = std::make_unique<TabControl>(0, 0, 300, 200);
auto firstPage = std::make_unique<Canvas>(0, 24, 300, 176);
Canvas* firstPageRaw = firstPage.get();
auto firstChild = std::make_unique<FocusTestControl>();
const auto firstChildHandle = firstPage->addControl(std::move(firstChild));
tabs->addTab(std::make_unique<Button>(0, 0, 80, 24, "first"),
std::move(firstPage));
auto secondPage = std::make_unique<Canvas>(0, 24, 300, 176);
Canvas* secondPageRaw = secondPage.get();
auto secondChild = std::make_unique<FocusTestControl>();
const auto secondChildHandle = secondPage->addControl(std::move(secondChild));
tabs->addTab(std::make_unique<Button>(80, 0, 80, 24, "second"),
std::move(secondPage));
window.addControl(std::move(tabs));
auto contains = [&](StellarX::ControlHandle handle)
{
const auto stops = window.getFocusManager().collectTabStops();
return std::any_of(stops.begin(), stops.end(), [&](const auto& item)
{ return item.id.owner == handle; });
};
// addTab keeps pages hidden until the first real draw. Drive visibility directly here so
// this non-GDI unit isolates the focus-tree filter rather than TabControl rendering.
firstPageRaw->setIsVisible(true);
Assert::IsTrue(contains(firstChildHandle));
Assert::IsFalse(contains(secondChildHandle));
window.getFocusManager().requestFocus({ firstChildHandle, 0 });
firstPageRaw->setIsVisible(false);
secondPageRaw->setIsVisible(true);
Assert::IsFalse(contains(firstChildHandle));
Assert::IsTrue(contains(secondChildHandle));
Assert::AreNotEqual(firstChildHandle.value,
window.getFocusManager().focusedTarget().owner.value);
}
};
// 验证 MBCS 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
TEST_CLASS(TextAndTabTests)
{
+59
View File
@@ -251,6 +251,65 @@ int main(int argc, char** argv)
});
window.addControl(std::move(resize));
}
else if (scenario == "focus")
{
// Stage 2 integration: real focus visuals, Table child eligibility, and active Tab page filtering.
auto button = MakeButton(20, 20, 120, 40, "focus button");
Button* buttonRaw = button.get();
bool buttonFocusObserved = false;
button->setOnClickListener([&]() { buttonFocusObserved = buttonRaw->hasLogicalFocus(); });
window.addControl(std::move(button));
auto textBox = std::make_unique<TextBox>(20, 80, 160, 36, "focus text");
TextBox* textBoxRaw = textBox.get();
window.addControl(std::move(textBox));
auto table = std::make_unique<Table>(210, 20);
const auto tableHandle = table->getHandle();
table->setHeaders({ "value" });
table->setRowsPerPage(1);
table->setData({ { "one" }, { "two" } });
window.addControl(std::move(table));
auto tabs = std::make_unique<TabControl>(20, 145, 300, 140);
tabs->setActiveIndex(0);
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto firstPageButton = MakeButton(10, 40, 110, 30, "active child");
const auto firstPageChildHandle = firstPageButton->getHandle();
firstPage->addControl(std::move(firstPageButton));
tabs->addTab(MakeButton(0, 0, 100, 30, "first"), std::move(firstPage));
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto secondPageButton = MakeButton(10, 40, 110, 30, "inactive child");
const auto secondPageChildHandle = secondPageButton->getHandle();
secondPage->addControl(std::move(secondPageButton));
tabs->addTab(MakeButton(100, 0, 100, 30, "second"), std::move(secondPage));
window.addControl(std::move(tabs));
auto verify = MakeButton(350, 260, 110, 36, "verify");
verify->setOnClickListener([&, buttonRaw]() {
const auto stops = window.getFocusManager().collectTabStops();
std::size_t tableTabStops = 0;
bool activePageChildPresent = false;
bool inactivePageChildPresent = false;
bool allSubNodesZero = true;
for (const auto& stop : stops)
{
if (stop.logicalParent.owner == tableHandle) ++tableTabStops;
if (stop.id.owner == firstPageChildHandle) activePageChildPresent = true;
if (stop.id.owner == secondPageChildHandle) inactivePageChildPresent = true;
if (stop.id.subNode != 0) allSubNodesZero = false;
}
WriteResult(resultDir, scenario,
std::string("{\"buttonFocusObserved\":") + (buttonFocusObserved ? "true" : "false") +
",\"tableTabStops\":" + std::to_string(tableTabStops) +
",\"activePageChildPresent\":" + (activePageChildPresent ? "true" : "false") +
",\"inactivePageChildPresent\":" + (inactivePageChildPresent ? "true" : "false") +
",\"allSubNodesZero\":" + (allSubNodesZero ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
textBoxRaw->requestFocus();
}
else if (scenario == "dialog")
{
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。