rawinput: modifiers (key combinations) (#813)

## Link to GitHub Issue or related Pull Request, if one exists
Fixes #635

## Description of change
Adds optional **button modifiers**, allowing a binding to require one or
more "modifier" buttons to be held before it activates.

- New "Modifiers" controller page in the overlay config to bind the
Modifier 1–4 source buttons.
- Each binding gains a 4-bit modifier_mask (Modifier 1-4). It is
persisted as an optional modifiers XML attribute on button nodes and
controller-preset entries. The attribute is optional and defaults to 0,
so existing config files and presets load unchanged and the feature is
off unless the user opts in.
- The button Edit properties popup gains a "Modifiers" dropdown to pick
which modifiers a binding requires. Doesn't apply to MIDI though.
- Input evaluation skips a binding whose required modifiers are not
held, falling through to its alternatives; velocity reporting uses the
same gated path.
- Controller-preset templates gain a "Modifiers" group for importing and
exporting.

## Testing
This commit is contained in:
bicarus
2026-07-21 00:26:36 -07:00
committed by GitHub
parent 623e1e3998
commit ed7318c270
11 changed files with 393 additions and 48 deletions
+168 -34
View File
@@ -85,6 +85,7 @@ namespace overlay::windows {
static const std::vector<std::pair<const char *, ControllerPage>> CONTROLLER_PAGE_GROUPS = {
{ "Buttons", ControllerPage::CONTROLLER_PAGE_BUTTONS },
{ "Keypads", ControllerPage::CONTROLLER_PAGE_KEYPADS },
{ "Modifiers", ControllerPage::CONTROLLER_PAGE_MODIFIERS },
{ "Analogs", ControllerPage::CONTROLLER_PAGE_ANALOGS },
{ "Overlay", ControllerPage::CONTROLLER_PAGE_OVERLAY },
{ "Lights", ControllerPage::CONTROLLER_PAGE_LIGHTS },
@@ -616,6 +617,21 @@ namespace overlay::windows {
}
break;
}
case ControllerPage::CONTROLLER_PAGE_MODIFIERS: {
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1), "Button Modifiers");
ImGui::Spacing();
ImGui::TextWrapped(
"Bind modifier buttons below, then open Edit properties on any button binding "
"(except MIDI) to select the required modifiers. This allows you to configure "
"button combinations.\n\n"
"For example, if you bind the Start button as Modifier 1, and then bind "
"the A button for Service Coin and set it to require Modifier 1, you press "
"Start + A to trigger Service Coin.");
ImGui::TextUnformatted("");
this->build_buttons(
"Modifier", games::get_buttons_modifiers(this->games_selected_name));
break;
}
case ControllerPage::CONTROLLER_PAGE_ANALOGS: {
// help text for binding analog, if the game has one
@@ -1256,7 +1272,8 @@ namespace overlay::windows {
if (open_edit) {
ImGui::OpenPopup(edit_name.c_str());
}
edit_button_popup(edit_name, button_display, button, button_velocity, alt_index);
edit_button_popup(
edit_name, button_display, button, button_velocity, alt_index, name != "Modifier");
// clean up
ImGui::PopID();
@@ -1275,6 +1292,7 @@ namespace overlay::windows {
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setInvert(false);
button->setModifierMask(0);
button->setLastState(GameAPI::Buttons::BUTTON_NOT_PRESSED);
button->setLastVelocity(0);
button->setTemporary(false);
@@ -1370,6 +1388,7 @@ namespace overlay::windows {
button->setDeviceIdentifier(device->name);
button->setVKey(static_cast<unsigned short>(i));
button->setAnalogType(BAT_NONE);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button, alt_index - 1);
ImGui::CloseCurrentPopup();
@@ -1402,6 +1421,7 @@ namespace overlay::windows {
button->setDeviceIdentifier(device->name);
button->setVKey(vkey);
button->setAnalogType(BAT_NONE);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button, alt_index - 1);
ImGui::CloseCurrentPopup();
@@ -1537,6 +1557,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1582,6 +1603,7 @@ namespace overlay::windows {
button->setDebounceUp(0.0);
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setModifierMask(0);
// same idea as setMidiVKey - keep velocity threshold consistent
button->setVelocityThreshold(
device->midiInfo->v2_velocity_threshold[button->getVKey()]);
@@ -1609,6 +1631,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1636,6 +1659,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1663,6 +1687,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1689,6 +1714,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1710,6 +1736,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1740,6 +1767,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1816,6 +1844,7 @@ namespace overlay::windows {
if (RI_MGR->XINPUT_MGR->get_any_button_pressed(xinput)) {
button->setDeviceIdentifier(xinput::get_device_desc(xinput.player));
button->setVKey(static_cast<uint16_t>(xinput.button));
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button, alt_index - 1);
inc_buttons_many_index(button_it_max);
@@ -1872,6 +1901,7 @@ namespace overlay::windows {
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setModifierMask(0);
::Config::getInstance().updateBinding(
games_list[games_selected], *button,
alt_index - 1);
@@ -1890,27 +1920,103 @@ namespace overlay::windows {
}
}
bool Config::build_modifier_picker(Button &button) {
bool changed = false;
auto modifier_mask = button.getModifierMask();
// collect live states and build the closed dropdown summary
uint8_t pressed_modifier_mask = 0;
std::string modifier_preview;
auto *modifier_buttons = games::get_buttons_modifiers(this->games_selected_name);
for (uint8_t index = 0; index < games::ModifierButtons::Size; index++) {
const uint8_t modifier_bit = UINT8_C(1) << index;
if (modifier_buttons && index < modifier_buttons->size() &&
GameAPI::Buttons::getState(RI_MGR, modifier_buttons->at(index))) {
pressed_modifier_mask |= modifier_bit;
}
if ((modifier_mask & modifier_bit) != 0) {
modifier_preview += modifier_preview.empty() ? "Mod " : "+";
modifier_preview += std::to_string(index + 1);
}
}
if (modifier_preview.empty()) {
modifier_preview = "None";
}
// color the combo when all requirements are held, but keep its label unchanged
const bool all_modifiers_pressed = modifier_mask != 0 &&
(pressed_modifier_mask & modifier_mask) == modifier_mask;
if (all_modifiers_pressed) {
ImGui::PushStyleColor(ImGuiCol_Text, TEXT_COLOR_GREEN);
}
const bool combo_open = ImGui::BeginCombo("##Modifiers", modifier_preview.c_str());
if (all_modifiers_pressed) {
ImGui::PopStyleColor();
}
// update requirements while highlighting modifier sources that are currently pressed
if (combo_open) {
for (uint8_t index = 0; index < games::ModifierButtons::Size; index++) {
const uint8_t modifier_bit = UINT8_C(1) << index;
bool required = (modifier_mask & modifier_bit) != 0;
const auto label = fmt::format("Modifier {}", index + 1);
const bool modifier_pressed = (pressed_modifier_mask & modifier_bit) != 0;
if (modifier_pressed) {
ImGui::PushStyleColor(ImGuiCol_Text, TEXT_COLOR_GREEN);
}
const bool requirement_changed = ImGui::Checkbox(label.c_str(), &required);
if (modifier_pressed) {
ImGui::PopStyleColor();
}
if (requirement_changed) {
modifier_mask ^= modifier_bit;
button.setModifierMask(modifier_mask);
changed = true;
}
}
ImGui::EndCombo();
}
ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
ImGui::TextUnformatted("Modifiers");
return changed;
}
void Config::edit_button_popup(
const std::string &edit_name,
const std::string &button_display,
Button *button,
const float button_velocity,
const int alt_index) {
const int alt_index,
const bool allow_modifiers) {
const auto button_state = GameAPI::Buttons::getState(RI_MGR, *button, false);
if (ImGui::BeginPopupModal(edit_name.c_str(), NULL, ImGuiWindowFlags_AlwaysAutoResize)) {
bool dirty = false;
auto device = RI_MGR->devices_get(button->getDeviceIdentifier());
auto switch_device = [&](const std::string &device_identifier) {
button->setDeviceIdentifier(device_identifier);
button->setAnalogType(ButtonAnalogType::BAT_NONE);
button->setDebounceUp(0.0);
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setVKey(0);
button->setInvert(false);
button->setModifierMask(0);
dirty = true;
};
// binding
ImGui::Text("Binding");
ImGui::Separator();
// combo for devices
std::string device_desc = (device != nullptr) ? device->desc : "Empty (Naive)";
if (ImGui::BeginCombo("Device Identifier", device_desc.c_str())) {
if (ImGui::Selectable("Empty (Naive)", button->isNaive())) {
button->setDeviceIdentifier("");
dirty = true;
if (ImGui::Selectable("Empty (Naive)", button->isNaive()) && !button->isNaive()) {
switch_device("");
}
if (button->isNaive()) {
ImGui::SetItemDefaultFocus();
@@ -1918,17 +2024,8 @@ namespace overlay::windows {
for (auto &device : RI_MGR->devices_get()) {
bool selected = button->getDeviceIdentifier() == device.name.c_str();
const auto device_desc = fmt::format("{}##{}", device.desc, device.name);
if (ImGui::Selectable(device_desc.c_str(), selected)) {
button->setDeviceIdentifier(device.name);
// reset controls when switching devices
button->setAnalogType(ButtonAnalogType::BAT_NONE);
button->setDebounceUp(0.0);
button->setDebounceDown(0.0);
button->setBatThreshold(0);
button->setVelocityThreshold(0);
button->setVKey(0);
button->setInvert(false);
dirty = true;
if (ImGui::Selectable(device_desc.c_str(), selected) && !selected) {
switch_device(device.name);
}
if (selected) {
ImGui::SetItemDefaultFocus();
@@ -1936,6 +2033,7 @@ namespace overlay::windows {
}
ImGui::EndCombo();
}
device = RI_MGR->devices_get(button->getDeviceIdentifier());
// analog type (only for HID)
const auto bat = button->getAnalogType();
@@ -2227,21 +2325,22 @@ namespace overlay::windows {
}
}
// modifiers
{
const bool is_midi = device != nullptr && device->type == rawinput::MIDI;
if (allow_modifiers && button->isValid() && !is_midi) {
dirty |= build_modifier_picker(*button);
}
}
// preview
if (!button_display.empty()) {
ImGui::TextUnformatted("\nPreview");
ImGui::Separator();
if (button_state == GameAPI::Buttons::State::BUTTON_PRESSED) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.7f, 0.f, 1.f));
}
// this doesn't account for utf-8 but whatever
if (button_display.size() >= 40) {
ImGui::Text("%.37s...", button_display.c_str());
ImGui::SameLine();
ImGui::HelpMarker(button_display.c_str());
} else {
ImGui::TextUnformatted(button_display.c_str());
ImGui::PushStyleColor(ImGuiCol_Text, TEXT_COLOR_GREEN);
}
ImGui::TextTruncated(button_display, ImGui::GetContentRegionAvail().x);
ImGui::TextUnformatted("\n");
if (button_state == GameAPI::Buttons::State::BUTTON_PRESSED) {
ImGui::PopStyleColor();
@@ -2250,8 +2349,10 @@ namespace overlay::windows {
ImGui::TextUnformatted("");
}
// options
ImGui::Text("Options");
ImGui::TextUnformatted("Options");
ImGui::Separator();
// check for debounce
if (button->getDebounceUp() || button->getDebounceDown()
@@ -2370,6 +2471,7 @@ namespace overlay::windows {
ImGui::ProgressBar(button_velocity);
} else {
ImGui::Text("State");
ImGui::Separator();
ImGui::ProgressBar(button_velocity);
}
@@ -5490,6 +5592,12 @@ namespace overlay::windows {
tmpl.buttons.emplace_back(btn);
}
}
auto *modifier_buttons = games::get_buttons_modifiers(this->games_selected_name);
if (modifier_buttons) {
for (auto &modifier : *modifier_buttons) {
tmpl.modifier_buttons.emplace_back(modifier);
}
}
// capture keypad buttons
auto *keypad_buttons = games::get_buttons_keypads(this->games_selected_name);
@@ -5545,6 +5653,17 @@ namespace overlay::windows {
}
}
// clear all modifier buttons
auto *modifier_buttons = games::get_buttons_modifiers(this->games_selected_name);
if (modifier_buttons) {
for (auto &modifier : *modifier_buttons) {
for (int ai = (int)modifier.getAlternatives().size() - 1; ai >= 0; ai--) {
clear_button(&modifier.getAlternatives()[ai], ai + 1);
}
clear_button(&modifier, -1);
}
}
// clear all keypad buttons
auto *keypad_buttons = games::get_buttons_keypads(this->games_selected_name);
if (keypad_buttons) {
@@ -5638,6 +5757,7 @@ namespace overlay::windows {
btn.setDebounceDown(entry->debounce_down);
btn.setBatThreshold(entry->bat_threshold);
btn.setVelocityThreshold(entry->velocity_threshold);
btn.setModifierMask(entry->modifier_mask);
::Config::getInstance().updateBinding(game, btn, -1);
} else {
Button alt_btn(btn.getName());
@@ -5649,6 +5769,7 @@ namespace overlay::windows {
alt_btn.setDebounceDown(entry->debounce_down);
alt_btn.setBatThreshold(entry->bat_threshold);
alt_btn.setVelocityThreshold(entry->velocity_threshold);
alt_btn.setModifierMask(entry->modifier_mask);
alt_btn.setTemporary(true);
btn.getAlternatives().push_back(alt_btn);
::Config::getInstance().updateBinding(
@@ -5667,6 +5788,11 @@ namespace overlay::windows {
if (this->apply_buttons) {
apply_buttons(tmpl.buttons, games::get_buttons(this->games_selected_name));
}
if (this->apply_modifiers) {
apply_buttons(
tmpl.modifier_buttons,
games::get_buttons_modifiers(this->games_selected_name));
}
if (this->apply_keypads) {
apply_buttons(tmpl.keypad_buttons, games::get_buttons_keypads(this->games_selected_name));
}
@@ -5908,7 +6034,7 @@ namespace overlay::windows {
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::HelpMarker(
"Clears all game button, analog, and light bindings.");
"Clears all game button, modifier, keypad, analog, and light bindings.");
if (this->all_cleared) {
ImGui::SameLine();
ImGui::TextUnformatted("Done.");
@@ -5923,6 +6049,8 @@ namespace overlay::windows {
bool selection_changed = false;
selection_changed |= ImGui::Checkbox("Buttons", &this->apply_buttons);
ImGui::SameLine();
selection_changed |= ImGui::Checkbox("Modifiers", &this->apply_modifiers);
ImGui::SameLine();
selection_changed |= ImGui::Checkbox("Keypads", &this->apply_keypads);
ImGui::SameLine();
selection_changed |= ImGui::Checkbox("Analogs", &this->apply_analogs);
@@ -5932,7 +6060,7 @@ namespace overlay::windows {
std::fill(this->template_is_applied.begin(), this->template_is_applied.end(), false);
}
if (!this->apply_buttons && !this->apply_keypads &&
if (!this->apply_buttons && !this->apply_modifiers && !this->apply_keypads &&
!this->apply_analogs && !this->apply_lights) {
ImGui::TextUnformatted("\nYou must select at least one group to apply.\n\n");
}
@@ -5972,7 +6100,8 @@ namespace overlay::windows {
}
}
if (!apply_buttons && !apply_keypads && !apply_analogs && !apply_lights) {
if (!apply_buttons && !apply_modifiers && !apply_keypads &&
!apply_analogs && !apply_lights) {
ImGui::BeginDisabled();
}
@@ -6141,7 +6270,8 @@ namespace overlay::windows {
ImGui::EndTable();
}
if (!apply_buttons && !apply_keypads && !apply_analogs && !apply_lights) {
if (!apply_buttons && !apply_modifiers && !apply_keypads &&
!apply_analogs && !apply_lights) {
ImGui::EndDisabled();
}
}
@@ -6220,12 +6350,14 @@ namespace overlay::windows {
ImGui::TextUnformatted("Pick which groups to save:");
ImGui::Checkbox("Buttons", &this->save_buttons);
ImGui::SameLine();
ImGui::Checkbox("Modifiers", &this->save_modifiers);
ImGui::SameLine();
ImGui::Checkbox("Keypads", &this->save_keypads);
ImGui::SameLine();
ImGui::Checkbox("Analogs", &this->save_analogs);
ImGui::SameLine();
ImGui::Checkbox("Lights", &this->save_lights);
if (!this->save_analogs && !this->save_keypads &&
if (!this->save_buttons && !this->save_modifiers && !this->save_keypads &&
!this->save_analogs && !this->save_lights) {
ImGui::TextUnformatted("\nYou must select at least one group to save.\n\n");
}
@@ -6277,7 +6409,8 @@ namespace overlay::windows {
ImGui::BeginDisabled(
!all_labels_set ||
(!this->save_buttons && !this->save_keypads && !this->save_analogs && !this->save_lights));
(!this->save_buttons && !this->save_modifiers && !this->save_keypads &&
!this->save_analogs && !this->save_lights));
if (ImGui::Button("Save")) {
// replace device IDs with labels in the template
@@ -6286,7 +6419,8 @@ namespace overlay::windows {
template_save_sources[si], template_save_labels[si]);
}
if (save_user_template(template_pending_save,
this->save_buttons, this->save_keypads, this->save_analogs, this->save_lights)) {
this->save_buttons, this->save_modifiers, this->save_keypads,
this->save_analogs, this->save_lights)) {
template_save_name[0] = '\0';
templates_cache_dirty = true;
}
@@ -6368,7 +6502,7 @@ namespace overlay::windows {
t.rename_source(template_save_sources[si], template_save_labels[si]);
}
}
save_user_template(t, true, true, true, true);
save_user_template(t, true, true, true, true, true);
templates_cache_dirty = true;
template_save_sources.clear();
template_save_labels.clear();