Add controller presets management (#581)

## Link to GitHub Issue or related Pull Request, if one exists
Implements #579.

## Description of change

### Controller Presets

Add a new "Presets" tab to spicecfg that allows users to save, load, and
manage controller binding presets.

#### Save Presets
- Capture all current button, analog, and light bindings as a preset
- "Assign Labels" dialog prompts the user to name each source device
(e.g. "Player 1", "Player 2") before saving
- Device IDs are replaced with these labels, making presets portable
across different machines
- Presets are stored in `%APPDATA%/spice2x/spicetools_presets.xml`

#### Load / Apply Presets
- Preset list shows name, type (Built-in / User), and binding counts
- Apply dialog maps each preset source label to a connected device or
keyboard
- Bindings are applied per-source, supporting multi-device setups (e.g.
two PHOENIXWAN controllers)

#### Edit / Delete Presets
- Rename source labels in saved presets (propagates to all bindings)
- Delete user presets

#### Built-in Presets
- PHOENIXWAN preset for Beatmania IIDX: P1/P2 buttons (1-7, Start,
EFFECT, VEFX), turntable analogs, and button lights

## Testing

* [x] Open spicecfg, go to Buttons tab, verify Presets section loads
* [x] Verify PHOENIXWAN builtin preset appears for Beatmania IIDX
* [x] Save a new preset: confirm "Assign Labels" dialog appears with
device descriptions as defaults
* [x] Apply a preset: confirm source column shows labels, popup does not
shrink
* [x] Edit labels on a saved preset: confirm rename propagates to all
bindings
* [x] Hover over a device source tooltip: confirm vKey shows as number
This commit is contained in:
ichijyo-hotaru
2026-03-21 08:46:17 +09:00
committed by GitHub
parent 458a1495c8
commit b31ddb1ffc
10 changed files with 1778 additions and 1 deletions
+2
View File
@@ -352,6 +352,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
cfg/configurator.cpp
cfg/configurator_wnd.cpp
cfg/screen_resize.cpp
cfg/controller_presets.cpp
# easrv
easrv/easrv.cpp
@@ -565,6 +566,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
overlay/windows/screen_resize.cpp
overlay/windows/sdvx_sub.cpp
overlay/windows/config.cpp
overlay/windows/controller_presets.cpp
overlay/windows/control.cpp
overlay/windows/eadev.cpp
overlay/windows/fps.cpp
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<!-- PHOENIXWAN controller preset for Beatmania IIDX
Button indices 0-6: keys 1-7
Button indices 8-11: E1 (Start for Deluxe Model Style), E2 (Effect), E3 (VEFX), E4 (P1 Start for Lightning Model Style)
P1 Side P2 Side
8 9 10 11 8 9 10 11
2 4 6 2 4 6
TT 1 3 5 7 1 3 5 7 TT -->
<template name="DJ DAO PHOENIXWAN" game="Beatmania IIDX">
<buttons>
<button name="P1 1" vkey="0" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 2" vkey="1" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 3" vkey="2" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 4" vkey="3" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 5" vkey="4" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 6" vkey="5" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 7" vkey="6" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 1" vkey="0" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 2" vkey="1" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 3" vkey="2" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 4" vkey="3" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 5" vkey="4" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 6" vkey="5" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 7" vkey="6" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<!-- P1 Start: E1 primary (index 8), E4 alternative (index 11) -->
<button name="P1 Start" vkey="8" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P1 Start" vkey="11" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<!-- P2 Start: E1 primary (index 8), E4 alternative (index 11) -->
<button name="P2 Start" vkey="8" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="P2 Start" vkey="11" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<!-- EFFECT: E2 (index 9), primary P2 side, alternative P1 side -->
<button name="EFFECT" vkey="9" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="EFFECT" vkey="9" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<!-- VEFX: E3 (index 10), primary P2 side, alternative P1 side -->
<button name="VEFX" vkey="10" analogtype="0" devid="Player 2" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
<button name="VEFX" vkey="10" analogtype="0" devid="Player 1" invert="false" debounce_up="0" debounce_down="0" velocity_threshold="0"/>
</buttons>
<analogs>
<analog name="Turntable P1" devid="Player 1" index="4" sensivity="1" deadzone="0" deadzone_mirror="false" invert="false" smoothing="false" multiplier="1" relative="false" delay="0"/>
<analog name="Turntable P2" devid="Player 2" index="4" sensivity="1" deadzone="0" deadzone_mirror="false" invert="false" smoothing="false" multiplier="1" relative="false" delay="0"/>
</analogs>
<lights>
<light name="P1 1" devid="Player 1" index="0"/>
<light name="P1 2" devid="Player 1" index="1"/>
<light name="P1 3" devid="Player 1" index="2"/>
<light name="P1 4" devid="Player 1" index="3"/>
<light name="P1 5" devid="Player 1" index="4"/>
<light name="P1 6" devid="Player 1" index="5"/>
<light name="P1 7" devid="Player 1" index="6"/>
<light name="P2 1" devid="Player 2" index="0"/>
<light name="P2 2" devid="Player 2" index="1"/>
<light name="P2 3" devid="Player 2" index="2"/>
<light name="P2 4" devid="Player 2" index="3"/>
<light name="P2 5" devid="Player 2" index="4"/>
<light name="P2 6" devid="Player 2" index="5"/>
<light name="P2 7" devid="Player 2" index="6"/>
<!-- Start buttons bind both E1 (index 8) and E4 (index 11), but lights use
Lightning Model style only (one per side) to avoid both lighting up at once -->
<light name="P1 Start" devid="Player 1" index="10"/>
<light name="P2 Start" devid="Player 2" index="7"/>
<light name="VEFX" devid="Player 1" index="8"/>
<light name="VEFX" devid="Player 2" index="8"/>
<light name="Effect" devid="Player 1" index="9"/>
<light name="Effect" devid="Player 2" index="9"/>
</lights>
</template>
</templates>
+1
View File
@@ -13,5 +13,6 @@ IDR_LICENSES RCDATA "../licenses.txt"
IDR_PATCHES RCDATA "../build/patches.json"
IDR_README RCDATA "../readme.txt"
IDR_DSEGFONT RCDATA "../external/dseg/DSEG14Classic-Italic.ttf"
IDR_CONTROLLER_PRESETS_BUILTIN RCDATA "../build/controller_presets.xml"
#endif
+1 -1
View File
@@ -63,10 +63,10 @@ private:
float last_velocity = 0.f;
unsigned short velocity_threshold = 0;
std::string getVKeyString();
std::string getMidiNoteString();
public:
std::string getVKeyString();
// overrides
bool override_enabled = false;
+442
View File
@@ -0,0 +1,442 @@
#include "overlay/windows/controller_presets.h"
#include <filesystem>
#include "cfg/resource.h"
#include "external/tinyxml2/tinyxml2.h"
#include "util/fileutils.h"
#include "util/logging.h"
#include "util/resutils.h"
namespace overlay::windows {
static std::filesystem::path get_templates_path() {
return fileutils::get_config_file_path("ControllerPresets", "spicetools_presets.xml");
}
static void write_button_entry(tinyxml2::XMLDocument &doc, tinyxml2::XMLElement *parent,
const std::string &name, const TemplateButtonEntry &entry) {
auto *el = doc.NewElement("button");
el->SetAttribute("name", name.c_str());
el->SetAttribute("vkey", entry.vKey);
el->SetAttribute("analogtype", (int)entry.analog_type);
el->SetAttribute("devid", entry.device_identifier.c_str());
el->SetAttribute("invert", entry.invert);
el->SetAttribute("debounce_up", entry.debounce_up);
el->SetAttribute("debounce_down", entry.debounce_down);
el->SetAttribute("velocity_threshold", entry.velocity_threshold);
parent->InsertEndChild(el);
}
static TemplateButtonEntry read_button_entry(tinyxml2::XMLElement *el) {
TemplateButtonEntry entry;
int vkey = INVALID_VKEY;
el->QueryIntAttribute("vkey", &vkey);
entry.vKey = (unsigned short)vkey;
int atype = 0;
el->QueryIntAttribute("analogtype", &atype);
entry.analog_type = (ButtonAnalogType)atype;
const char *devid = el->Attribute("devid");
entry.device_identifier = devid ? devid : "";
el->QueryBoolAttribute("invert", &entry.invert);
el->QueryDoubleAttribute("debounce_up", &entry.debounce_up);
el->QueryDoubleAttribute("debounce_down", &entry.debounce_down);
int vel = 0;
el->QueryIntAttribute("velocity_threshold", &vel);
entry.velocity_threshold = (unsigned short)vel;
return entry;
}
static void write_analog(tinyxml2::XMLDocument &doc, tinyxml2::XMLElement *parent,
const TemplateAnalogBinding &analog) {
auto *el = doc.NewElement("analog");
el->SetAttribute("name", analog.name.c_str());
el->SetAttribute("devid", analog.device_identifier.c_str());
el->SetAttribute("index", analog.index);
el->SetAttribute("sensivity", analog.sensitivity); // keep typo for compat
el->SetAttribute("deadzone", analog.deadzone);
el->SetAttribute("deadzone_mirror", analog.deadzone_mirror);
el->SetAttribute("invert", analog.invert);
el->SetAttribute("smoothing", analog.smoothing);
el->SetAttribute("multiplier", analog.multiplier);
el->SetAttribute("relative", analog.relative_mode);
el->SetAttribute("delay", analog.delay_buffer_depth);
parent->InsertEndChild(el);
}
static TemplateAnalogBinding read_analog(tinyxml2::XMLElement *el) {
TemplateAnalogBinding a;
const char *name = el->Attribute("name");
a.name = name ? name : "";
const char *devid = el->Attribute("devid");
a.device_identifier = devid ? devid : "";
int idx = 0xFF;
el->QueryIntAttribute("index", &idx);
a.index = (unsigned short)idx;
el->QueryFloatAttribute("sensivity", &a.sensitivity); // keep typo
el->QueryFloatAttribute("deadzone", &a.deadzone);
el->QueryBoolAttribute("deadzone_mirror", &a.deadzone_mirror);
el->QueryBoolAttribute("invert", &a.invert);
el->QueryBoolAttribute("smoothing", &a.smoothing);
el->QueryIntAttribute("multiplier", &a.multiplier);
el->QueryBoolAttribute("relative", &a.relative_mode);
el->QueryIntAttribute("delay", &a.delay_buffer_depth);
return a;
}
static void write_light_entry(tinyxml2::XMLDocument &doc, tinyxml2::XMLElement *parent,
const std::string &name, const TemplateLightEntry &entry) {
auto *el = doc.NewElement("light");
el->SetAttribute("name", name.c_str());
el->SetAttribute("devid", entry.device_identifier.c_str());
el->SetAttribute("index", entry.index);
parent->InsertEndChild(el);
}
static TemplateLightEntry read_light_entry(tinyxml2::XMLElement *el) {
TemplateLightEntry entry;
const char *devid = el->Attribute("devid");
entry.device_identifier = devid ? devid : "";
int idx = 0;
el->QueryIntAttribute("index", &idx);
entry.index = (unsigned int)idx;
return entry;
}
// Parse all templates from an already-loaded XMLDocument.
// is_builtin controls the is_builtin flag on each returned template.
static std::vector<ControllerTemplate> load_templates_from_doc(
tinyxml2::XMLDocument &doc, bool is_builtin) {
std::vector<ControllerTemplate> templates;
auto *root = doc.RootElement();
if (!root) return templates;
auto *tmpl_el = root->FirstChildElement("template");
while (tmpl_el) {
ControllerTemplate tmpl;
const char *name = tmpl_el->Attribute("name");
const char *game = tmpl_el->Attribute("game");
tmpl.name = name ? name : "";
tmpl.game_name = game ? game : "";
tmpl.is_builtin = is_builtin;
// buttons
auto *buttons_el = tmpl_el->FirstChildElement("buttons");
if (buttons_el) {
auto *btn_el = buttons_el->FirstChildElement("button");
while (btn_el) {
const char *btn_name = btn_el->Attribute("name");
std::string btn_name_str = btn_name ? btn_name : "";
TemplateButtonBinding *binding = nullptr;
for (auto &b : tmpl.buttons) {
if (b.name == btn_name_str) { binding = &b; break; }
}
if (!binding) {
tmpl.buttons.push_back({btn_name_str, read_button_entry(btn_el), {}});
} else {
binding->alternatives.push_back(read_button_entry(btn_el));
}
btn_el = btn_el->NextSiblingElement("button");
}
}
// keypad buttons
auto *kp_el = tmpl_el->FirstChildElement("keypad_buttons");
if (kp_el) {
auto *btn_el = kp_el->FirstChildElement("button");
while (btn_el) {
const char *btn_name = btn_el->Attribute("name");
std::string btn_name_str = btn_name ? btn_name : "";
TemplateButtonBinding *binding = nullptr;
for (auto &b : tmpl.keypad_buttons) {
if (b.name == btn_name_str) { binding = &b; break; }
}
if (!binding) {
tmpl.keypad_buttons.push_back({btn_name_str, read_button_entry(btn_el), {}});
} else {
binding->alternatives.push_back(read_button_entry(btn_el));
}
btn_el = btn_el->NextSiblingElement("button");
}
}
// analogs
auto *analogs_el = tmpl_el->FirstChildElement("analogs");
if (analogs_el) {
auto *analog_el = analogs_el->FirstChildElement("analog");
while (analog_el) {
tmpl.analogs.push_back(read_analog(analog_el));
analog_el = analog_el->NextSiblingElement("analog");
}
}
// lights
auto *lights_el = tmpl_el->FirstChildElement("lights");
if (lights_el) {
auto *light_el = lights_el->FirstChildElement("light");
while (light_el) {
const char *light_name = light_el->Attribute("name");
std::string light_name_str = light_name ? light_name : "";
TemplateLightBinding *binding = nullptr;
for (auto &l : tmpl.lights) {
if (l.name == light_name_str) { binding = &l; break; }
}
if (!binding) {
tmpl.lights.push_back({light_name_str, read_light_entry(light_el), {}});
} else {
binding->alternatives.push_back(read_light_entry(light_el));
}
light_el = light_el->NextSiblingElement("light");
}
}
templates.push_back(std::move(tmpl));
tmpl_el = tmpl_el->NextSiblingElement("template");
}
return templates;
}
static std::vector<ControllerTemplate> load_builtin_templates() {
auto xml = resutil::load_file_string(IDR_CONTROLLER_PRESETS_BUILTIN);
tinyxml2::XMLDocument doc;
auto err = doc.Parse(xml.c_str());
if (err != tinyxml2::XML_SUCCESS) {
log_warning("templates", "failed to parse builtin templates XML: {}", (int)err);
return {};
}
return load_templates_from_doc(doc, true);
}
std::vector<ControllerTemplate> load_user_templates() {
std::vector<ControllerTemplate> templates;
auto path = get_templates_path();
tinyxml2::XMLDocument doc;
if (doc.LoadFile(path.c_str()) != tinyxml2::XML_SUCCESS) {
return templates;
}
return load_templates_from_doc(doc, false);
}
bool save_user_template(const ControllerTemplate &tmpl) {
auto path = get_templates_path();
auto path_tmp = path;
path_tmp.replace_extension(L"tmp");
tinyxml2::XMLDocument doc;
if (doc.LoadFile(path.c_str()) != tinyxml2::XML_SUCCESS) {
auto *decl = doc.NewDeclaration();
doc.InsertFirstChild(decl);
auto *root = doc.NewElement("templates");
doc.InsertEndChild(root);
}
auto *root = doc.RootElement();
if (!root) {
root = doc.NewElement("templates");
doc.InsertEndChild(root);
}
// remove existing template with same name and game
auto *existing = root->FirstChildElement("template");
while (existing) {
auto *next = existing->NextSiblingElement("template");
const char *ename = existing->Attribute("name");
const char *egame = existing->Attribute("game");
if (ename && egame &&
std::string(ename) == tmpl.name &&
std::string(egame) == tmpl.game_name) {
root->DeleteChild(existing);
}
existing = next;
}
auto *tmpl_el = doc.NewElement("template");
tmpl_el->SetAttribute("name", tmpl.name.c_str());
tmpl_el->SetAttribute("game", tmpl.game_name.c_str());
// buttons — skip unbound entries
auto *buttons_el = doc.NewElement("buttons");
for (auto &btn : tmpl.buttons) {
if (!btn.primary.is_unbound()) {
write_button_entry(doc, buttons_el, btn.name, btn.primary);
}
for (auto &alt : btn.alternatives) {
if (!alt.is_unbound()) {
write_button_entry(doc, buttons_el, btn.name, alt);
}
}
}
tmpl_el->InsertEndChild(buttons_el);
// keypad buttons — skip unbound entries
if (!tmpl.keypad_buttons.empty()) {
auto *kp_el = doc.NewElement("keypad_buttons");
for (auto &btn : tmpl.keypad_buttons) {
if (!btn.primary.is_unbound()) {
write_button_entry(doc, kp_el, btn.name, btn.primary);
}
for (auto &alt : btn.alternatives) {
if (!alt.is_unbound()) {
write_button_entry(doc, kp_el, btn.name, alt);
}
}
}
tmpl_el->InsertEndChild(kp_el);
}
// analogs — skip unbound entries
auto *analogs_el = doc.NewElement("analogs");
for (auto &a : tmpl.analogs) {
if (!a.is_unbound()) {
write_analog(doc, analogs_el, a);
}
}
tmpl_el->InsertEndChild(analogs_el);
// lights — skip unbound entries
auto *lights_el = doc.NewElement("lights");
for (auto &l : tmpl.lights) {
if (!l.primary.is_unbound()) {
write_light_entry(doc, lights_el, l.name, l.primary);
}
for (auto &alt : l.alternatives) {
if (!alt.is_unbound()) {
write_light_entry(doc, lights_el, l.name, alt);
}
}
}
tmpl_el->InsertEndChild(lights_el);
root->InsertEndChild(tmpl_el);
// ensure directory exists
if (!path.parent_path().empty() && !std::filesystem::exists(path.parent_path())) {
fileutils::dir_create_recursive(path.parent_path());
}
// save atomically via temp file
auto save_err = doc.SaveFile(path_tmp.c_str());
if (save_err != tinyxml2::XML_SUCCESS) {
log_warning("templates", "failed to save templates file: {}", (int)save_err);
return false;
}
std::error_code ec;
std::filesystem::rename(path_tmp, path, ec);
if (ec) {
log_warning("templates", "failed to rename templates file: {}", ec.message());
return false;
}
return true;
}
bool delete_user_template(const std::string &game_name, const std::string &template_name) {
auto path = get_templates_path();
tinyxml2::XMLDocument doc;
if (doc.LoadFile(path.c_str()) != tinyxml2::XML_SUCCESS) return false;
auto *root = doc.RootElement();
if (!root) return false;
bool found = false;
auto *tmpl_el = root->FirstChildElement("template");
while (tmpl_el) {
auto *next = tmpl_el->NextSiblingElement("template");
const char *ename = tmpl_el->Attribute("name");
const char *egame = tmpl_el->Attribute("game");
if (ename && egame &&
std::string(ename) == template_name &&
std::string(egame) == game_name) {
root->DeleteChild(tmpl_el);
found = true;
}
tmpl_el = next;
}
if (found) {
auto path_tmp = path;
path_tmp.replace_extension(L"tmp");
if (doc.SaveFile(path_tmp.c_str()) == tinyxml2::XML_SUCCESS) {
std::error_code ec;
std::filesystem::rename(path_tmp, path, ec);
}
}
return found;
}
bool rename_user_template(const std::string &game_name,
const std::string &old_name, const std::string &new_name) {
auto path = get_templates_path();
tinyxml2::XMLDocument doc;
if (doc.LoadFile(path.c_str()) != tinyxml2::XML_SUCCESS) return false;
auto *root = doc.RootElement();
if (!root) return false;
bool found = false;
auto *tmpl_el = root->FirstChildElement("template");
while (tmpl_el) {
const char *ename = tmpl_el->Attribute("name");
const char *egame = tmpl_el->Attribute("game");
if (ename && egame &&
std::string(ename) == old_name &&
std::string(egame) == game_name) {
tmpl_el->SetAttribute("name", new_name.c_str());
found = true;
break;
}
tmpl_el = tmpl_el->NextSiblingElement("template");
}
if (found) {
auto path_tmp = path;
path_tmp.replace_extension(L"tmp");
if (doc.SaveFile(path_tmp.c_str()) == tinyxml2::XML_SUCCESS) {
std::error_code ec;
std::filesystem::rename(path_tmp, path, ec);
}
}
return found;
}
std::vector<ControllerTemplate> get_templates_for_game(const std::string &game_name) {
std::vector<ControllerTemplate> result;
for (auto &t : load_builtin_templates()) {
if (t.game_name == game_name) {
result.push_back(std::move(t));
}
}
for (auto &t : load_user_templates()) {
if (t.game_name == game_name) {
result.push_back(std::move(t));
}
}
return result;
}
}
+1
View File
@@ -4,3 +4,4 @@
#define IDR_PATCHES 132
#define IDR_README 133
#define IDR_DSEGFONT 134
#define IDR_CONTROLLER_PRESETS_BUILTIN 135
+842
View File
@@ -257,6 +257,7 @@ namespace overlay::windows {
// selected game changed
if (previous_games_selected != this->games_selected) {
read_card();
templates_cache_dirty = true;
}
// tab selection
@@ -353,6 +354,14 @@ namespace overlay::windows {
ImGui::EndChild();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Presets")) {
tab_selected_new = ConfigTab::CONFIG_TAB_PRESETS;
ImGui::BeginChild("Presets", ImVec2(
0, ImGui::GetWindowContentRegionMax().y - page_offset2), false);
this->build_presets();
ImGui::EndChild();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Cards")) {
tab_selected_new = ConfigTab::CONFIG_TAB_CARDS;
ImGui::BeginChild("Cards", ImVec2(
@@ -573,6 +582,7 @@ namespace overlay::windows {
this->tab_selected = tab_selected_new;
buttons_many_active = false;
buttons_many_index = -1;
templates_cache_dirty = true;
ImGui::CloseCurrentPopup();
}
@@ -4680,6 +4690,838 @@ namespace overlay::windows {
}
}
//////////////////
/// Presets Tab //
//////////////////
ControllerTemplate Config::capture_current_bindings(const std::string &name) {
ControllerTemplate tmpl;
tmpl.name = name;
tmpl.game_name = this->games_selected_name;
tmpl.is_builtin = false;
// capture buttons
auto *buttons = games::get_buttons(this->games_selected_name);
if (buttons) {
for (auto &btn : *buttons) {
tmpl.buttons.emplace_back(btn);
}
}
// capture keypad buttons
auto *keypad_buttons = games::get_buttons_keypads(this->games_selected_name);
if (keypad_buttons) {
for (auto &btn : *keypad_buttons) {
tmpl.keypad_buttons.emplace_back(btn);
}
}
// capture analogs
auto *analogs = games::get_analogs(this->games_selected_name);
if (analogs) {
for (auto &a : *analogs) {
tmpl.analogs.emplace_back(a);
}
}
// capture lights
auto *lights = games::get_lights(this->games_selected_name);
if (lights) {
for (auto &l : *lights) {
TemplateLightBinding tl;
tl.name = l.getName();
tl.primary.device_identifier = l.getDeviceIdentifier();
tl.primary.index = l.getIndex();
for (auto &alt : l.getAlternatives()) {
TemplateLightEntry le;
le.device_identifier = alt.getDeviceIdentifier();
le.index = alt.getIndex();
tl.alternatives.push_back(le);
}
tmpl.lights.push_back(std::move(tl));
}
}
return tmpl;
}
void Config::clear_all_bindings() {
const auto &game = games_list[games_selected];
// clear all game buttons (NOT overlay buttons)
auto *buttons = games::get_buttons(this->games_selected_name);
if (buttons) {
for (auto &btn : *buttons) {
for (int ai = (int)btn.getAlternatives().size() - 1; ai >= 0; ai--) {
clear_button(&btn.getAlternatives()[ai], ai + 1);
}
// clear primary
clear_button(&btn, -1);
}
}
// clear all keypad buttons
auto *keypad_buttons = games::get_buttons_keypads(this->games_selected_name);
if (keypad_buttons) {
for (auto &btn : *keypad_buttons) {
for (int ai = (int)btn.getAlternatives().size() - 1; ai >= 0; ai--) {
clear_button(&btn.getAlternatives()[ai], ai + 1);
}
clear_button(&btn, -1);
}
}
// clear all analogs
auto *analogs = games::get_analogs(this->games_selected_name);
if (analogs) {
for (auto &a : *analogs) {
a.clearBindings();
::Config::getInstance().updateBinding(game, a);
}
}
// clear all lights
auto *lights = games::get_lights(this->games_selected_name);
if (lights) {
for (auto &l : *lights) {
for (int ai = (int)l.getAlternatives().size() - 1; ai >= 0; ai--) {
clear_light(&l.getAlternatives()[ai], ai + 1);
}
clear_light(&l, -1);
}
}
}
// apply bindings from a template filtered by source, remapping to target_device
void Config::apply_template_source(const ControllerTemplate &tmpl,
const std::string &source_filter,
const std::string &target_device) {
const auto &game = games_list[games_selected];
bool source_is_naive = (source_filter == "Naive");
bool target_is_naive = (target_device == "Naive");
// helper: check if a button entry matches the source filter
auto entry_matches = [&](const TemplateButtonEntry &e) -> bool {
if (source_is_naive) {
return e.is_naive();
}
return e.is_device() && e.device_identifier == source_filter;
};
// helper: remap device identifier from source to target
auto remap_devid = [&]() -> std::string {
if (target_is_naive) {
return "";
}
return target_device;
};
// helper: apply template button bindings to a game button vector
auto apply_buttons = [&](const std::vector<TemplateButtonBinding> &tmpl_btns,
std::vector<Button> *game_btns) {
if (!game_btns) {
return;
}
for (auto &tb : tmpl_btns) {
std::vector<const TemplateButtonEntry *> matching_entries;
if (entry_matches(tb.primary)) {
matching_entries.push_back(&tb.primary);
}
for (auto &alt : tb.alternatives) {
if (entry_matches(alt)) {
matching_entries.push_back(&alt);
}
}
if (matching_entries.empty()) {
continue;
}
for (auto &btn : *game_btns) {
if (btn.getName() != tb.name) {
continue;
}
for (auto *entry : matching_entries) {
auto mapped_devid = remap_devid();
if (btn.getVKey() == INVALID_VKEY && btn.getDeviceIdentifier().empty()) {
btn.setVKey(entry->vKey);
btn.setAnalogType(entry->analog_type);
btn.setDeviceIdentifier(mapped_devid);
btn.setInvert(entry->invert);
btn.setDebounceUp(entry->debounce_up);
btn.setDebounceDown(entry->debounce_down);
btn.setVelocityThreshold(entry->velocity_threshold);
::Config::getInstance().updateBinding(game, btn, -1);
} else {
Button alt_btn(btn.getName());
alt_btn.setVKey(entry->vKey);
alt_btn.setAnalogType(entry->analog_type);
alt_btn.setDeviceIdentifier(mapped_devid);
alt_btn.setInvert(entry->invert);
alt_btn.setDebounceUp(entry->debounce_up);
alt_btn.setDebounceDown(entry->debounce_down);
alt_btn.setVelocityThreshold(entry->velocity_threshold);
alt_btn.setTemporary(true);
btn.getAlternatives().push_back(alt_btn);
::Config::getInstance().updateBinding(
game, btn.getAlternatives().back(),
(int)btn.getAlternatives().size() - 1);
btn.getAlternatives().back().setTemporary(false);
}
}
break;
}
}
};
// apply game buttons and keypad buttons
apply_buttons(tmpl.buttons, games::get_buttons(this->games_selected_name));
apply_buttons(tmpl.keypad_buttons, games::get_buttons_keypads(this->games_selected_name));
// apply analog bindings (device source only)
if (!source_is_naive && !target_is_naive) {
auto *analogs = games::get_analogs(this->games_selected_name);
if (analogs) {
for (auto &ta : tmpl.analogs) {
if (!ta.is_device() || ta.device_identifier != source_filter) {
continue;
}
for (auto &a : *analogs) {
if (a.getName() != ta.name) {
continue;
}
a.setDeviceIdentifier(remap_devid());
a.setIndex(ta.index);
a.setSensitivity(ta.sensitivity);
a.setDeadzone(ta.deadzone);
a.setDeadzoneMirror(ta.deadzone_mirror);
a.setInvert(ta.invert);
a.setSmoothing(ta.smoothing);
a.setMultiplier(ta.multiplier);
a.setRelativeMode(ta.relative_mode);
a.setDelayBufferDepth(ta.delay_buffer_depth);
::Config::getInstance().updateBinding(game, a);
break;
}
}
}
}
// apply light bindings (device source only)
if (!source_is_naive && !target_is_naive) {
auto *lights = games::get_lights(this->games_selected_name);
if (lights) {
for (auto &tl : tmpl.lights) {
// collect matching entries
std::vector<const TemplateLightEntry *> matching_entries;
if (tl.primary.is_device() && tl.primary.device_identifier == source_filter) {
matching_entries.push_back(&tl.primary);
}
for (auto &alt : tl.alternatives) {
if (alt.is_device() && alt.device_identifier == source_filter) {
matching_entries.push_back(&alt);
}
}
if (matching_entries.empty()) {
continue;
}
for (auto &l : *lights) {
if (l.getName() != tl.name) {
continue;
}
for (auto *entry : matching_entries) {
auto mapped_devid = remap_devid();
if (l.getDeviceIdentifier().empty()) {
// set as primary
l.setDeviceIdentifier(mapped_devid);
l.setIndex(entry->index);
::Config::getInstance().updateBinding(game, l, -1);
} else {
// add as alternative
Light alt_light(l.getName());
alt_light.setDeviceIdentifier(mapped_devid);
alt_light.setIndex(entry->index);
alt_light.setTemporary(true);
l.getAlternatives().push_back(alt_light);
::Config::getInstance().updateBinding(
game, l.getAlternatives().back(),
(int)l.getAlternatives().size() - 1);
l.getAlternatives().back().setTemporary(false);
}
}
break;
}
}
}
}
}
void Config::build_presets() {
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1), "Controller presets for: %s",
this->games_selected_name.c_str());
ImGui::Spacing();
// refresh cache
if (templates_cache_dirty) {
templates_cache = get_templates_for_game(this->games_selected_name);
templates_cache_dirty = false;
}
// preset list table
if (!templates_cache.empty()) {
if (ImGui::BeginTable("TemplateList", 6,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_None, 1.f);
ImGui::TableSetupColumn("Buttons", ImGuiTableColumnFlags_WidthFixed, overlay::apply_scaling(60.f));
ImGui::TableSetupColumn("Analogs", ImGuiTableColumnFlags_WidthFixed, overlay::apply_scaling(60.f));
ImGui::TableSetupColumn("Lights", ImGuiTableColumnFlags_WidthFixed, overlay::apply_scaling(60.f));
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, overlay::apply_scaling(140.f));
ImGui::TableHeadersRow();
for (int i = 0; i < (int)templates_cache.size(); i++) {
const auto &t = templates_cache[i];
ImGui::PushID(i);
ImGui::TableNextRow();
// name
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.name.c_str());
// type
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.is_builtin ? "Built-in" : "User");
// buttons
ImGui::TableNextColumn();
ImGui::Text("%d", (int)t.buttons.size());
// analogs
ImGui::TableNextColumn();
ImGui::Text("%d", (int)t.analogs.size());
// lights
ImGui::TableNextColumn();
ImGui::Text("%d", (int)t.lights.size());
// actions
ImGui::TableNextColumn();
if (ImGui::Button("Apply")) {
templates_selected = i;
template_target_selection.clear();
template_apply_open = true;
}
if (!t.is_builtin) {
ImGui::SameLine();
if (ImGui::Button("Edit")) {
templates_selected = i;
template_save_sources = t.get_binding_sources();
template_save_labels.resize(template_save_sources.size());
for (int si = 0; si < (int)template_save_sources.size(); si++) {
template_save_labels[si] = template_save_sources[si];
}
template_edit_open = true;
}
ImGui::SameLine();
if (ImGui::Button("Del")) {
templates_selected = i;
template_delete_open = true;
}
}
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::Spacing();
} else {
ImGui::TextDisabled("No presets available for this game.");
}
// deferred popup opens
if (template_apply_open) {
ImGui::OpenPopup("Apply Preset##confirm");
template_apply_open = false;
}
if (template_delete_open) {
ImGui::OpenPopup("Delete Preset##confirm");
template_delete_open = false;
}
// delete preset confirmation popup
ImGui::SetNextWindowSize(ImVec2(360, 120), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Delete Preset##confirm", NULL, 0)) {
if (templates_selected >= 0 && templates_selected < (int)templates_cache.size()) {
const auto &t = templates_cache[templates_selected];
ImGui::TextWrapped("Delete preset \"%s\"?", t.name.c_str());
ImGui::TextColored(ImVec4(1.f, 0.5f, 0.5f, 1.f), "This cannot be undone.");
// buttons at the bottom
ImGui::SetCursorPosY(ImGui::GetWindowHeight()
- ImGui::GetFrameHeightWithSpacing()
- ImGui::GetStyle().WindowPadding.y);
if (ImGui::Button("Delete")) {
delete_user_template(t.game_name, t.name);
templates_cache_dirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
// apply preset popup
ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Apply Preset##confirm", NULL, 0)) {
if (templates_selected >= 0 && templates_selected < (int)templates_cache.size()) {
const auto &t = templates_cache[templates_selected];
ImGui::Text("Apply preset \"%s\"", t.name.c_str());
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1),
"Overlay bindings are not affected.");
ImGui::Spacing();
// clear all button
ImGui::Separator();
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImVec4(1.f, 0.5f, 0.5f, 1.f),
"Step 1: Reset all existing bindings (Caution! This can't be undone.)");
if (ImGui::Button("Clear All Bindings")) {
clear_all_bindings();
}
ImGui::SameLine();
ImGui::HelpMarker(
"Clears all game button, analog, and light bindings.");
ImGui::Spacing();
ImGui::Separator();
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1),
"Step 2: Apply bindings by source (one at a time)");
ImGui::Spacing();
// list binding sources with target selection
auto sources = t.get_binding_sources();
// build target device list: "Naive" + all connected devices
std::vector<std::string> target_options;
target_options.emplace_back("Naive");
for (auto &device : RI_MGR->devices_get()) {
target_options.push_back(device.name);
}
// ensure target selection vector is sized (reset on source count change)
if (template_target_selection.size() != sources.size()) {
template_target_selection.resize(sources.size());
for (int si = 0; si < (int)sources.size(); si++) {
template_target_selection[si] = -1;
for (int ti = 0; ti < (int)target_options.size(); ti++) {
if (target_options[ti] == sources[si]) {
template_target_selection[si] = ti;
break;
}
}
}
}
if (ImGui::BeginTable("TemplateSources", 4,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_None, 2.f);
ImGui::TableSetupColumn("Apply As", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_None, 2.f);
ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_None, 1.f);
ImGui::TableHeadersRow();
// build a set of device names with any button currently pressed
std::set<std::string> active_devices;
for (auto &dev : RI_MGR->devices_get()) {
bool pressed = false;
switch (dev.type) {
case rawinput::HID:
if (dev.hidInfo) {
for (auto &page : dev.hidInfo->button_states) {
for (bool s : page) {
if (s) { pressed = true; break; }
}
if (pressed) { break; }
}
}
break;
case rawinput::KEYBOARD:
if (dev.keyboardInfo) {
for (bool s : dev.keyboardInfo->key_states) {
if (s) { pressed = true; break; }
}
}
break;
case rawinput::MOUSE:
if (dev.mouseInfo) {
for (bool s : dev.mouseInfo->key_states) {
if (s) { pressed = true; break; }
}
}
break;
case rawinput::MIDI:
if (dev.midiInfo) {
for (bool s : dev.midiInfo->states) {
if (s) { pressed = true; break; }
}
}
break;
default:
break;
}
if (pressed) {
active_devices.insert(dev.name);
}
}
for (int si = 0; si < (int)sources.size(); si++) {
ImGui::PushID(si);
ImGui::TableNextRow();
// source (label is the device_identifier)
// highlight if the selected target device has recent input
ImGui::TableNextColumn();
{
int sel = template_target_selection[si];
bool active = sel > 0 && active_devices.count(target_options[sel]) > 0;
if (active) {
ImGui::TextColored(ImVec4(0.f, 1.f, 0.f, 1.f), "%s", sources[si].c_str());
} else {
ImGui::TextUnformatted(sources[si].c_str());
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", t.get_source_summary(sources[si]).c_str());
}
// target device combo
ImGui::TableNextColumn();
ImGui::SetNextItemWidth(-1);
{
int sel = template_target_selection[si];
const char *preview = (sel >= 0) ? target_options[sel].c_str() : "(select target)";
if (ImGui::BeginCombo("##target", preview)) {
for (int ti = 0; ti < (int)target_options.size(); ti++) {
bool is_selected = (sel == ti);
std::string combo_label = target_options[ti];
bool item_active = false;
if (ti > 0) {
auto *dev = RI_MGR->devices_get(target_options[ti]);
if (dev) {
combo_label = fmt::format("{}##{}", dev->desc, dev->name);
item_active = active_devices.count(dev->name) > 0;
}
}
if (item_active) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.f, 1.f, 0.f, 1.f));
}
if (ImGui::Selectable(combo_label.c_str(), is_selected)) {
template_target_selection[si] = ti;
}
if (item_active) {
ImGui::PopStyleColor();
}
if (is_selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
}
}
// connection status of target
ImGui::TableNextColumn();
{
int sel = template_target_selection[si];
if (sel < 0) {
ImGui::TextDisabled("--");
} else if (target_options[sel] == "Naive") {
ImGui::TextColored(ImVec4(0.f, 1.f, 0.f, 1.f), "OK");
} else {
auto *device = RI_MGR->devices_get(target_options[sel]);
if (device) {
ImGui::TextColored(ImVec4(0.f, 1.f, 0.f, 1.f), "OK");
} else {
ImGui::TextColored(ImVec4(1.f, 0.5f, 0.f, 1.f), "Not found");
}
}
}
// apply button
ImGui::TableNextColumn();
{
int sel = template_target_selection[si];
if (sel < 0) {
ImGui::BeginDisabled();
}
if (ImGui::Button("Apply")) {
if (sel >= 0) {
apply_template_source(t, sources[si], target_options[sel]);
}
}
if (sel < 0) {
ImGui::EndDisabled();
}
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight()
- ImGui::GetFrameHeightWithSpacing()
- ImGui::GetStyle().WindowPadding.y);
if (ImGui::Button("Close")) {
templates_selected = -1;
template_target_selection.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// save section
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1), "Save Current Bindings as Preset");
ImGui::Spacing();
ImGui::SetNextItemWidth(300);
ImGui::InputText("Preset Name", template_save_name, sizeof(template_save_name));
ImGui::SameLine();
bool name_empty = strlen(template_save_name) == 0;
if (name_empty) {
ImGui::BeginDisabled();
}
if (ImGui::Button("Save")) {
// capture bindings and open label assignment dialog
template_pending_save = capture_current_bindings(template_save_name);
template_save_sources = template_pending_save.get_binding_sources();
template_save_labels.resize(template_save_sources.size());
for (int si = 0; si < (int)template_save_sources.size(); si++) {
// default label: device description or source id
const auto &src = template_save_sources[si];
if (src == "Naive") {
template_save_labels[si] = "Keyboard";
} else {
auto *dev = RI_MGR->devices_get(src);
template_save_labels[si] = dev ? dev->desc : src;
}
}
template_save_labels_open = true;
}
if (name_empty) {
ImGui::EndDisabled();
}
// open save labels popup (deferred)
if (template_save_labels_open) {
ImGui::OpenPopup("Assign Labels##save");
template_save_labels_open = false;
}
// open edit labels popup (deferred from Edit button inside table)
if (template_edit_open) {
ImGui::OpenPopup("Edit Labels##preset");
template_edit_open = false;
}
// save labels popup: assign labels to sources before saving
ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Assign Labels##save", NULL, 0)) {
ImGui::TextUnformatted("Assign a label to each source device.");
ImGui::TextColored(ImVec4(1.f, 0.7f, 0, 1),
"Labels replace device IDs in the saved preset.");
ImGui::Spacing();
if (ImGui::BeginTable("SaveLabels", 2,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableHeadersRow();
for (int si = 0; si < (int)template_save_sources.size(); si++) {
ImGui::PushID(si + 3000);
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(template_save_sources[si].c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s",
template_pending_save.get_source_summary(
template_save_sources[si]).c_str());
}
ImGui::TableNextColumn();
ImGui::SetNextItemWidth(-1);
char buf[128] = {};
strncpy(buf, template_save_labels[si].c_str(), sizeof(buf) - 1);
if (ImGui::InputText("##label", buf, sizeof(buf))) {
template_save_labels[si] = buf;
}
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::Spacing();
// check all labels are non-empty
bool all_labels_set = true;
for (auto &lbl : template_save_labels) {
if (lbl.empty()) { all_labels_set = false; break; }
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight()
- ImGui::GetFrameHeightWithSpacing()
- ImGui::GetStyle().WindowPadding.y);
if (!all_labels_set) {
ImGui::BeginDisabled();
}
if (ImGui::Button("Save")) {
// replace device IDs with labels in the template
for (int si = 0; si < (int)template_save_sources.size(); si++) {
template_pending_save.rename_source(
template_save_sources[si], template_save_labels[si]);
}
if (save_user_template(template_pending_save)) {
template_save_name[0] = '\0';
templates_cache_dirty = true;
}
template_save_sources.clear();
template_save_labels.clear();
ImGui::CloseCurrentPopup();
}
if (!all_labels_set) {
ImGui::EndDisabled();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
template_save_sources.clear();
template_save_labels.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// edit labels popup: rename sources in an existing preset
ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Edit Labels##preset", NULL, 0)) {
if (templates_selected >= 0 && templates_selected < (int)templates_cache.size()) {
auto &t = templates_cache[templates_selected];
ImGui::Text("Edit labels for \"%s\"", t.name.c_str());
ImGui::Spacing();
if (ImGui::BeginTable("EditLabels", 2,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Current Label", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableSetupColumn("New Label", ImGuiTableColumnFlags_None, 3.f);
ImGui::TableHeadersRow();
for (int si = 0; si < (int)template_save_sources.size(); si++) {
ImGui::PushID(si + 2000);
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(template_save_sources[si].c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s",
t.get_source_summary(template_save_sources[si]).c_str());
}
ImGui::TableNextColumn();
ImGui::SetNextItemWidth(-1);
char buf[128] = {};
strncpy(buf, template_save_labels[si].c_str(), sizeof(buf) - 1);
if (ImGui::InputText("##label", buf, sizeof(buf))) {
template_save_labels[si] = buf;
}
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::Spacing();
// check all labels are non-empty
bool all_labels_set = true;
for (auto &lbl : template_save_labels) {
if (lbl.empty()) { all_labels_set = false; break; }
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight()
- ImGui::GetFrameHeightWithSpacing()
- ImGui::GetStyle().WindowPadding.y);
if (!all_labels_set) {
ImGui::BeginDisabled();
}
if (ImGui::Button("Save Labels")) {
// rename sources in the template
for (int si = 0; si < (int)template_save_sources.size(); si++) {
if (template_save_labels[si] != template_save_sources[si]) {
t.rename_source(template_save_sources[si], template_save_labels[si]);
}
}
save_user_template(t);
templates_cache_dirty = true;
template_save_sources.clear();
template_save_labels.clear();
templates_selected = -1;
ImGui::CloseCurrentPopup();
}
if (!all_labels_set) {
ImGui::EndDisabled();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
template_save_sources.clear();
template_save_labels.clear();
templates_selected = -1;
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
}
void Config::set_alternating_row_colors(const int row_index) {
auto color = ImGuiCol_TableRowBg;
if (row_index % 2 != 0) {
+25
View File
@@ -8,6 +8,7 @@
#include "rawinput/device.h"
#include "external/imgui/imgui_filebrowser.h"
#include "patch_manager.h"
#include "controller_presets.h"
namespace overlay::windows {
@@ -23,6 +24,7 @@ namespace overlay::windows {
CONFIG_TAB_OPTIONS,
CONFIG_TAB_ADVANCED,
CONFIG_TAB_DEV,
CONFIG_TAB_PRESETS,
CONFIG_TAB_SEARCH,
};
@@ -93,6 +95,22 @@ namespace overlay::windows {
ImGui::FileBrowser keypads_card_select_browser[2];
char keypads_card_number[2][18] {};
// presets tab
std::vector<ControllerTemplate> templates_cache;
bool templates_cache_dirty = true;
int templates_selected = -1;
char template_save_name[256] = {};
// per-source target mapping: source index -> selected target device index
std::vector<int> template_target_selection;
// deferred popup flags (to avoid ImGui ID stack mismatch inside tables)
bool template_apply_open = false;
bool template_edit_open = false;
bool template_save_labels_open = false;
bool template_delete_open = false;
ControllerTemplate template_pending_save;
std::vector<std::string> template_save_sources;
std::vector<std::string> template_save_labels;
// patches tab
std::unique_ptr<PatchManager> patch_manager;
@@ -146,6 +164,13 @@ namespace overlay::windows {
std::vector<MatchEntry> &matched,
std::vector<MatchEntry> &unmatched);
void build_presets();
void clear_all_bindings();
void apply_template_source(const ControllerTemplate &tmpl,
const std::string &source_filter,
const std::string &target_device);
ControllerTemplate capture_current_bindings(const std::string &name);
void build_cards();
std::string build_option_value_picker_title(const OptionDefinition& option);
void build_option_value_picker(Option& option);
@@ -0,0 +1,240 @@
#include "controller_presets.h"
#include <algorithm>
#include "cfg/button.h"
namespace overlay::windows {
// helpers - iterate both buttons and keypad_buttons
static void count_buttons_in(const std::vector<TemplateButtonBinding> &btns, int &naive, int &device) {
for (auto &btn : btns) {
if (btn.primary.is_naive()) naive++;
if (btn.primary.is_device()) device++;
for (auto &alt : btn.alternatives) {
if (alt.is_naive()) naive++;
if (alt.is_device()) device++;
}
}
}
int ControllerTemplate::count_naive_buttons() const {
int naive = 0, device = 0;
count_buttons_in(buttons, naive, device);
count_buttons_in(keypad_buttons, naive, device);
return naive;
}
int ControllerTemplate::count_device_buttons() const {
int naive = 0, device = 0;
count_buttons_in(buttons, naive, device);
count_buttons_in(keypad_buttons, naive, device);
return device;
}
int ControllerTemplate::count_device_analogs() const {
int count = 0;
for (auto &a : analogs) {
if (a.is_device()) count++;
}
return count;
}
int ControllerTemplate::count_device_lights() const {
int count = 0;
for (auto &l : lights) {
if (l.primary.is_device()) count++;
for (auto &alt : l.alternatives) {
if (alt.is_device()) count++;
}
}
return count;
}
static void collect_devices_from(const std::vector<TemplateButtonBinding> &btns,
std::set<std::string> &devices) {
for (auto &btn : btns) {
if (btn.primary.is_device()) devices.insert(btn.primary.device_identifier);
for (auto &alt : btn.alternatives) {
if (alt.is_device()) devices.insert(alt.device_identifier);
}
}
}
std::set<std::string> ControllerTemplate::get_used_devices() const {
std::set<std::string> devices;
collect_devices_from(buttons, devices);
collect_devices_from(keypad_buttons, devices);
for (auto &a : analogs) {
if (a.is_device()) devices.insert(a.device_identifier);
}
for (auto &l : lights) {
if (l.primary.is_device()) devices.insert(l.primary.device_identifier);
for (auto &alt : l.alternatives) {
if (alt.is_device()) devices.insert(alt.device_identifier);
}
}
return devices;
}
static void collect_sources_from(const std::vector<TemplateButtonBinding> &btns,
std::set<std::string> &sources_set, bool &has_naive) {
for (auto &btn : btns) {
if (btn.primary.is_naive()) has_naive = true;
if (btn.primary.is_device()) sources_set.insert(btn.primary.device_identifier);
for (auto &alt : btn.alternatives) {
if (alt.is_naive()) has_naive = true;
if (alt.is_device()) sources_set.insert(alt.device_identifier);
}
}
}
std::vector<std::string> ControllerTemplate::get_binding_sources() const {
std::set<std::string> sources_set;
bool has_naive = false;
collect_sources_from(buttons, sources_set, has_naive);
collect_sources_from(keypad_buttons, sources_set, has_naive);
for (auto &a : analogs) {
if (a.is_device()) sources_set.insert(a.device_identifier);
}
for (auto &l : lights) {
if (l.primary.is_device()) sources_set.insert(l.primary.device_identifier);
for (auto &alt : l.alternatives) {
if (alt.is_device()) sources_set.insert(alt.device_identifier);
}
}
std::vector<std::string> result;
if (has_naive) {
result.emplace_back("Naive");
}
for (auto &s : sources_set) {
result.push_back(s);
}
// sort alphabetically
std::sort(result.begin(), result.end());
return result;
}
void ControllerTemplate::rename_source(const std::string &old_id, const std::string &new_id) {
auto rename_in_buttons = [&](std::vector<TemplateButtonBinding> &btns) {
for (auto &btn : btns) {
if (btn.primary.device_identifier == old_id) btn.primary.device_identifier = new_id;
for (auto &alt : btn.alternatives) {
if (alt.device_identifier == old_id) alt.device_identifier = new_id;
}
}
};
rename_in_buttons(buttons);
rename_in_buttons(keypad_buttons);
for (auto &a : analogs) {
if (a.device_identifier == old_id) a.device_identifier = new_id;
}
for (auto &l : lights) {
if (l.primary.device_identifier == old_id) l.primary.device_identifier = new_id;
for (auto &alt : l.alternatives) {
if (alt.device_identifier == old_id) alt.device_identifier = new_id;
}
}
}
std::string ControllerTemplate::get_source_summary(const std::string &source) const {
bool is_naive = (source == "Naive");
std::string result;
// collect button lines from a button vector
auto collect_btn_lines = [&](const std::vector<TemplateButtonBinding> &btns,
std::vector<std::string> &lines) {
for (auto &tb : btns) {
auto check_entry = [&](const TemplateButtonEntry &e) {
if (is_naive && e.is_naive()) {
Button b("");
b.setVKey(e.vKey);
lines.push_back(tb.name + " = " + (e.vKey == INVALID_VKEY ? "None" : b.getVKeyString()));
} else if (!is_naive && e.is_device() && e.device_identifier == source) {
std::string desc = std::to_string(e.vKey);
if (e.analog_type != BAT_NONE) {
desc += " (analog:" + std::to_string((int)e.analog_type) + ")";
}
lines.push_back(tb.name + " = " + desc);
}
};
check_entry(tb.primary);
for (auto &alt : tb.alternatives) {
check_entry(alt);
}
}
};
// buttons
std::vector<std::string> btn_lines;
collect_btn_lines(buttons, btn_lines);
if (!btn_lines.empty()) {
result += "Buttons:\n";
for (auto &line : btn_lines) {
result += " " + line + "\n";
}
}
// keypad buttons
std::vector<std::string> kp_lines;
collect_btn_lines(keypad_buttons, kp_lines);
if (!kp_lines.empty()) {
result += "Keypad:\n";
for (auto &line : kp_lines) {
result += " " + line + "\n";
}
}
// analogs
if (!is_naive) {
std::vector<std::string> analog_lines;
for (auto &a : analogs) {
if (a.is_device() && a.device_identifier == source) {
analog_lines.push_back(a.name + " (idx:" + std::to_string(a.index) + ")");
}
}
if (!analog_lines.empty()) {
result += "Analogs:\n";
for (auto &line : analog_lines) {
result += " " + line + "\n";
}
}
}
// lights
if (!is_naive) {
std::vector<std::string> light_lines;
for (auto &l : lights) {
auto check_light = [&](const TemplateLightEntry &e) {
if (e.is_device() && e.device_identifier == source) {
light_lines.push_back(l.name + " (idx:" + std::to_string(e.index) + ")");
}
};
check_light(l.primary);
for (auto &alt : l.alternatives) {
check_light(alt);
}
}
if (!light_lines.empty()) {
result += "Lights:\n";
for (auto &line : light_lines) {
result += " " + line + "\n";
}
}
}
if (result.empty()) {
result = "(no bindings)";
}
// trim trailing newline
while (!result.empty() && result.back() == '\n') {
result.pop_back();
}
return result;
}
}
@@ -0,0 +1,152 @@
#pragma once
#include <set>
#include <string>
#include <vector>
#include "cfg/analog.h"
#include "cfg/button.h"
namespace overlay::windows {
// single button binding entry (primary or alternative)
struct TemplateButtonEntry {
unsigned short vKey = INVALID_VKEY;
ButtonAnalogType analog_type = BAT_NONE;
std::string device_identifier;
bool invert = false;
double debounce_up = 0.0;
double debounce_down = 0.0;
unsigned short velocity_threshold = 0;
bool is_naive() const { return device_identifier.empty() && vKey != INVALID_VKEY; }
bool is_device() const { return !device_identifier.empty(); }
bool is_unbound() const { return device_identifier.empty() && vKey == INVALID_VKEY; }
static TemplateButtonEntry FromButton(const Button &btn) {
TemplateButtonEntry e;
e.vKey = btn.getVKey();
e.analog_type = btn.getAnalogType();
e.device_identifier = btn.getDeviceIdentifier();
e.invert = btn.getInvert();
e.debounce_up = btn.getDebounceUp();
e.debounce_down = btn.getDebounceDown();
e.velocity_threshold = btn.getVelocityThreshold();
return e;
}
};
// button binding with primary + alternatives
struct TemplateButtonBinding {
std::string name;
TemplateButtonEntry primary;
std::vector<TemplateButtonEntry> alternatives;
TemplateButtonBinding() = default;
TemplateButtonBinding(std::string name, TemplateButtonEntry primary,
std::vector<TemplateButtonEntry> alternatives)
: name(std::move(name)), primary(std::move(primary)),
alternatives(std::move(alternatives)) {}
explicit TemplateButtonBinding(Button &btn) {
name = btn.getName();
primary = TemplateButtonEntry::FromButton(btn);
for (auto &alt : btn.getAlternatives()) {
alternatives.push_back(TemplateButtonEntry::FromButton(alt));
}
}
};
// analog binding
struct TemplateAnalogBinding {
std::string name;
std::string device_identifier;
unsigned short index = 0xFF;
float sensitivity = 1.f;
float deadzone = 0.f;
bool deadzone_mirror = false;
bool invert = false;
bool smoothing = false;
int multiplier = 1;
bool relative_mode = false;
int delay_buffer_depth = 0;
bool is_device() const { return !device_identifier.empty(); }
bool is_unbound() const { return device_identifier.empty() && index == 0xFF; }
TemplateAnalogBinding() = default;
explicit TemplateAnalogBinding(Analog &a) {
name = a.getName();
device_identifier = a.getDeviceIdentifier();
index = a.getIndex();
sensitivity = a.getSensitivity();
deadzone = a.getDeadzone();
deadzone_mirror = a.getDeadzoneMirror();
invert = a.getInvert();
smoothing = a.getSmoothing();
multiplier = a.getMultiplier();
relative_mode = a.isRelativeMode();
delay_buffer_depth = a.getDelayBufferDepth();
}
};
// single light binding entry (primary or alternative)
struct TemplateLightEntry {
std::string device_identifier;
unsigned int index = 0;
bool is_device() const { return !device_identifier.empty(); }
bool is_unbound() const { return device_identifier.empty(); }
};
// light binding with primary + alternatives
struct TemplateLightBinding {
std::string name;
TemplateLightEntry primary;
std::vector<TemplateLightEntry> alternatives;
TemplateLightBinding() = default;
TemplateLightBinding(std::string name, TemplateLightEntry primary,
std::vector<TemplateLightEntry> alternatives)
: name(std::move(name)), primary(std::move(primary)),
alternatives(std::move(alternatives)) {}
};
// controller preset
struct ControllerTemplate {
std::string name;
std::string game_name;
bool is_builtin = false;
std::vector<TemplateButtonBinding> buttons;
std::vector<TemplateButtonBinding> keypad_buttons;
std::vector<TemplateAnalogBinding> analogs;
std::vector<TemplateLightBinding> lights;
// helpers
int count_naive_buttons() const;
int count_device_buttons() const;
int count_device_analogs() const;
int count_device_lights() const;
std::set<std::string> get_used_devices() const;
// get all binding sources (device_identifier strings used in this preset)
std::vector<std::string> get_binding_sources() const;
// get summary of bindings for a specific source (for tooltip display)
std::string get_source_summary(const std::string &source) const;
// replace all occurrences of a source device_identifier with a new one
void rename_source(const std::string &old_id, const std::string &new_id);
};
// template management API
std::vector<ControllerTemplate> get_templates_for_game(const std::string &game_name);
std::vector<ControllerTemplate> load_user_templates();
bool save_user_template(const ControllerTemplate &tmpl);
bool delete_user_template(const std::string &game_name, const std::string &template_name);
bool rename_user_template(const std::string &game_name,
const std::string &old_name, const std::string &new_name);
}