keybinds: fix subchord matching for release binds (#15904)

This commit is contained in:
Vaxry
2026-08-20 17:24:01 +02:00
committed by GitHub
parent 52b368f1d7
commit 8d50be06aa
6 changed files with 128 additions and 44 deletions
+80
View File
@@ -832,6 +832,86 @@ TEST_CASE(keybinds) {
CALL_SUBTEST(unbind);
}
TEST_CASE(unorderedSubChordDeferral) {
constexpr uint32_t X = KEY_X + 8;
constexpr uint32_t D = KEY_D + 8;
constexpr uint32_t F = KEY_F + 8;
const auto counts = [] { return getFromSocket("/repl return _G.hyprtester_overlap_x .. ':' .. _G.hyprtester_overlap_d .. ':' .. _G.hyprtester_overlap_long"); };
OK(getFromSocket("/eval do "
"_G.hyprtester_overlap_x = 0; "
"_G.hyprtester_overlap_d = 0; "
"_G.hyprtester_overlap_long = 0; "
"hl.bind('SUPER + X', function() _G.hyprtester_overlap_x = _G.hyprtester_overlap_x + 1 end); "
"hl.bind('SUPER + D', function() _G.hyprtester_overlap_d = _G.hyprtester_overlap_d + 1 end); "
"hl.bind('SUPER + X + D + F', function() _G.hyprtester_overlap_long = _G.hyprtester_overlap_long + 1 end) "
"end"));
OK(getFromSocket(pluginKeybindCmd(true, MOD_META, X)));
EXPECT(counts(), "0:0:0");
OK(getFromSocket(pluginKeybindCmd(false, 0, X)));
EXPECT(counts(), "1:0:0");
OK(getFromSocket(pluginKeybindCmd(true, MOD_META, D)));
EXPECT(counts(), "1:0:0");
OK(getFromSocket(pluginKeybindCmd(false, 0, D)));
EXPECT(counts(), "1:1:0");
OK(getFromSocket(pluginKeybindCmd(true, MOD_META, X)));
EXPECT(counts(), "1:1:0");
OK(getFromSocket(pluginKeybindCmd(true, MOD_META, D)));
EXPECT(counts(), "1:1:0");
OK(getFromSocket(pluginKeybindCmd(true, MOD_META, F)));
EXPECT(counts(), "1:1:1");
OK(getFromSocket(pluginKeybindCmd(false, MOD_META, F)));
OK(getFromSocket(pluginKeybindCmd(false, MOD_META, D)));
OK(getFromSocket(pluginKeybindCmd(false, 0, X)));
EXPECT(counts(), "1:1:1");
OK(getFromSocket("/eval do "
"hl.unbind('SUPER + X'); "
"hl.unbind('SUPER + D'); "
"hl.unbind('SUPER + X + D + F'); "
"_G.hyprtester_overlap_x = nil; "
"_G.hyprtester_overlap_d = nil; "
"_G.hyprtester_overlap_long = nil "
"end"));
}
TEST_CASE(modifierReleaseBindShadowing) {
constexpr uint32_t ALT_L = KEY_LEFTALT + 8;
constexpr uint32_t P = KEY_P + 8;
const auto counts = [] { return getFromSocket("/repl return _G.hyprtester_alt_release .. ':' .. _G.hyprtester_alt_p"); };
OK(getFromSocket("/eval do "
"_G.hyprtester_alt_release = 0; "
"_G.hyprtester_alt_p = 0; "
"hl.bind('ALT + ALT_L', function() _G.hyprtester_alt_release = _G.hyprtester_alt_release + 1 end, { release = true }); "
"hl.bind('ALT + P', function() _G.hyprtester_alt_p = _G.hyprtester_alt_p + 1 end) "
"end"));
OK(getFromSocket(pluginKeybindCmd(true, 0, ALT_L)));
EXPECT(counts(), "0:0");
OK(getFromSocket(pluginKeybindCmd(true, MOD_ALT, P)));
EXPECT(counts(), "0:1");
OK(getFromSocket(pluginKeybindCmd(false, MOD_ALT, P)));
OK(getFromSocket(pluginKeybindCmd(false, 0, ALT_L)));
EXPECT(counts(), "0:1");
OK(getFromSocket(pluginKeybindCmd(true, 0, ALT_L)));
OK(getFromSocket(pluginKeybindCmd(false, 0, ALT_L)));
EXPECT(counts(), "1:1");
OK(getFromSocket("/eval do "
"hl.unbind('ALT + ALT_L'); "
"hl.unbind('ALT + P'); "
"_G.hyprtester_alt_release = nil; "
"_G.hyprtester_alt_p = nil "
"end"));
}
TEST_CASE(luaDispatcherStrings) {
OK(getFromSocket("/eval B = hl.bind('SUPER + F24', hl.dsp.exec_cmd('true'))"));
EXPECT(getFromSocket("/repl return B.handler"), "HL.Dispatcher(exec_cmd)");
-29
View File
@@ -315,35 +315,6 @@ bool CBind::isSubChordOf(const CBind& other, const SBindEventContext& ctx) const
return true;
}
bool CBind::isOrderedPrefixOf(const CBind& other, const SBindEventContext& ctx) const {
const auto CHORD_SIZE = chordSize();
if (CHORD_SIZE == 0 || CHORD_SIZE >= other.chordSize() || !ctx.trigger || !m_keys.back().matches(*ctx.trigger))
return false;
size_t keyIdx = 0;
size_t otherKeyIdx = 0;
size_t relevant = 0;
for (const auto& held : ctx.heldKeys) {
if (held.modifier || std::ranges::none_of(other.m_keys, [&held](const auto& pattern) { return !pattern.isMod() && pattern.matches(held); }))
continue;
if (relevant == CHORD_SIZE)
return false;
while (m_keys[keyIdx].isMod())
++keyIdx;
while (other.m_keys[otherKeyIdx].isMod())
++otherKeyIdx;
if (!m_keys[keyIdx].matches(held) || !other.m_keys[otherKeyIdx].matches(held))
return false;
++keyIdx;
++otherKeyIdx;
++relevant;
}
return relevant == CHORD_SIZE;
}
std::span<const CKey> CBind::keys() const {
return m_keys;
}
-1
View File
@@ -101,7 +101,6 @@ namespace Keybinds {
bool containsKey(const SResolvedKey& key) const;
bool isFullyHeld(const SBindEventContext& ctx) const;
bool isSubChordOf(const CBind& other, const SBindEventContext& ctx) const;
bool isOrderedPrefixOf(const CBind& other, const SBindEventContext& ctx) const;
std::span<const CKey> keys() const;
std::span<const std::string> keyNames() const;
const std::unordered_set<std::string>& devices() const;
+15 -9
View File
@@ -765,6 +765,10 @@ void CKeybindManager::shadowBinds(const std::optional<SResolvedKey>& excluded, c
[&bind](const auto& input) { return std::ranges::any_of(input.deferredBinds, [&bind](const auto& weak) { return weak.lock() == bind; }); }))
continue;
// Multi-key release binds retain their armed state as earlier chord keys are released.
// Their overlap suppression is tracked on the armed input instead of through m_shadowed.
const bool MATCH_RELEASE = bind->hasFlag(BIND_FLAG_RELEASE) && bind->chordSize() <= 1;
for (const auto& input : m_inputState.pressed()) {
const auto device = input.device.lock();
if (!device)
@@ -773,15 +777,17 @@ void CKeybindManager::shadowBinds(const std::optional<SResolvedKey>& excluded, c
if (excluded && device == excludedDevice && input.key.code == excluded->code && input.key.event == excluded->event)
continue;
if (bind->matches({
.heldKeys = m_inputState.heldKeys(),
.trigger = input.key,
.modifiersNow = MODIFIERS,
.modifiersAtPress = input.modifiersAtPress,
.pressed = true,
.device = device,
.submap = input.submapAtPress,
}) == BIND_MATCH_FULL) {
const SBindEventContext CONTEXT{
.heldKeys = m_inputState.heldKeys(),
.trigger = input.key,
.modifiersNow = MODIFIERS,
.modifiersAtPress = input.modifiersAtPress,
.pressed = !MATCH_RELEASE,
.device = device,
.submap = input.submapAtPress,
};
if (bind->matches(CONTEXT) == BIND_MATCH_FULL) {
m_shadowed.emplace(bind);
break;
}
+1 -1
View File
@@ -22,7 +22,7 @@ SChordMatchResolution Keybinds::resolveChordMatches(std::span<const SBindMatchCa
continue;
const bool EXTENDABLE = context.pressed &&
std::ranges::any_of(candidates, [&](const auto& other) { return other.match == BIND_MATCH_PARTIAL && candidate.bind->isOrderedPrefixOf(*other.bind, context); });
std::ranges::any_of(candidates, [&](const auto& other) { return other.match == BIND_MATCH_PARTIAL && candidate.bind->isSubChordOf(*other.bind, context); });
if (EXTENDABLE)
result.deferred.emplace_back(candidate.bind);
else
+32 -4
View File
@@ -63,7 +63,7 @@ TEST(KeybindsMatchResolver, FullPrefixWaitsForLongerChord) {
EXPECT_EQ(RESOLUTION.deferred.front(), SHORT);
}
TEST(KeybindsMatchResolver, ReverseOrderDoesNotDeferShortChord) {
TEST(KeybindsMatchResolver, NonPrefixSubChordWaitsForLongerChord) {
const auto SHORT = makeResolverBind({"SUPER", "Q"});
const auto LONG = makeResolverBind({"SUPER", "K", "Q"});
const auto Q = resolverKey("Q", 24);
@@ -79,9 +79,37 @@ TEST(KeybindsMatchResolver, ReverseOrderDoesNotDeferShortChord) {
};
const auto RESOLUTION = resolveChordMatches(CANDIDATES, CONTEXT);
ASSERT_EQ(RESOLUTION.immediate.size(), 1);
EXPECT_EQ(RESOLUTION.immediate.front(), SHORT);
EXPECT_TRUE(RESOLUTION.deferred.empty());
EXPECT_TRUE(RESOLUTION.immediate.empty());
ASSERT_EQ(RESOLUTION.deferred.size(), 1);
EXPECT_EQ(RESOLUTION.deferred.front(), SHORT);
}
TEST(KeybindsMatchResolver, MiddleSubChordWaitsForLongerChord) {
const auto SHORT = makeResolverBind({"SUPER", "D"});
const auto LONG = makeResolverBind({"SUPER", "X", "D", "F"});
const auto X = resolverKey("X", 53);
const auto D = resolverKey("D", 40);
const std::array HELD = {X, D};
const SBindEventContext CONTEXT{
.heldKeys = HELD,
.trigger = D,
.modifiersNow = HL_MODIFIER_META,
};
const std::array CANDIDATES = {
SBindMatchCandidate{SHORT, SHORT->matches(CONTEXT)},
SBindMatchCandidate{LONG, LONG->matches(CONTEXT)},
};
const std::array REVERSED_CANDIDATES = {
SBindMatchCandidate{LONG, LONG->matches(CONTEXT)},
SBindMatchCandidate{SHORT, SHORT->matches(CONTEXT)},
};
for (const auto& candidates : {CANDIDATES, REVERSED_CANDIDATES}) {
const auto RESOLUTION = resolveChordMatches(candidates, CONTEXT);
EXPECT_TRUE(RESOLUTION.immediate.empty());
ASSERT_EQ(RESOLUTION.deferred.size(), 1);
EXPECT_EQ(RESOLUTION.deferred.front(), SHORT);
}
}
TEST(KeybindsMatchResolver, SidedModifierDoesNotIncreaseChordLength) {