Skip to content

Commit d10eb25

Browse files
committed
Key binding in UI
1 parent 693c034 commit d10eb25

11 files changed

Lines changed: 1087 additions & 0 deletions

runtime/lvgljs/input.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,42 @@ function resolveKeyName(name: string): number | null {
8282
return null;
8383
}
8484

85+
// Inverse of resolveKeyName: DPF key code → preferred symbolic name. Used
86+
// by the in-app bindings editor so that the user pressing e.g. KEY_ENTER
87+
// in capture mode lands "Enter" in the JSON (canonical) rather than
88+
// "Return" (synonym). Returns null for keys we don't know how to name.
89+
//
90+
// Multiple entries in KEY_NAME_TO_DPF collide on the same code (Enter /
91+
// Return both = 0x0D); the preferred-name table below picks one.
92+
const DPF_TO_KEY_NAME: Record<number, string> = {
93+
[KEY_BACKSPACE]: "Backspace",
94+
[KEY_TAB]: "Tab",
95+
[KEY_ENTER]: "Enter",
96+
[KEY_ESCAPE]: "Escape",
97+
[KEY_LEFT]: "Left",
98+
[KEY_UP]: "Up",
99+
[KEY_RIGHT]: "Right",
100+
[KEY_DOWN]: "Down",
101+
[KEY_SHIFT_L]: "ShiftL",
102+
[KEY_SHIFT_R]: "ShiftR",
103+
};
104+
105+
export function dpfKeyToName(code: number): string | null {
106+
if (code in DPF_TO_KEY_NAME) return DPF_TO_KEY_NAME[code];
107+
// Printable ASCII: keep case (so "Z" and "z" remain distinguishable).
108+
if (code >= 0x20 && code <= 0x7E) return String.fromCharCode(code);
109+
return null;
110+
}
111+
112+
// List of every symbolic key name the loader understands, plus a single
113+
// representative of the printable-ASCII range. Useful for the editor's
114+
// "unbind / clear" affordance when the underlying file is multi-bind.
115+
export const KNOWN_KEY_NAMES: readonly string[] = [
116+
"Backspace", "Tab", "Enter", "Escape",
117+
"Left", "Up", "Right", "Down",
118+
"ShiftL", "ShiftR",
119+
];
120+
85121
// Runtime maps populated either from the hardcoded defaults below (initial
86122
// state) or from a user JSON profile (after installBindings runs).
87123
let keyMap_: Map<number, GameboyButton> = new Map();

src/PluginRpcRegistration.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ void registerPluginRpcMethods(Server& server) {
5050
server.template addMethod<&PluginRpcService::getUserConfig>();
5151
server.template addMethod<&PluginRpcService::setActiveKeyboardBindings>();
5252
server.template addMethod<&PluginRpcService::setActiveGamepadBindings>();
53+
server.template addMethod<&PluginRpcService::getBindingProfile>();
54+
server.template addMethod<&PluginRpcService::saveBindingProfile>();
55+
server.template addMethod<&PluginRpcService::renameBindingProfile>();
56+
server.template addMethod<&PluginRpcService::deleteBindingProfile>();
5357
server.template addMethod<&PluginRpcService::openSettingsFolder>();
5458
server.template addMethod<&PluginRpcService::saveSram>();
5559
server.template addMethod<&PluginRpcService::openSaveSramBrowser>();

src/PluginRpcService.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,26 @@ bool PluginRpcService::setActiveGamepadBindings(std::string name) {
887887
return userConfig_->setActiveGamepadBindings(std::move(name));
888888
}
889889

890+
std::optional<BindingMapJson> PluginRpcService::getBindingProfile(std::string name) {
891+
if (!userConfig_) return std::nullopt;
892+
return userConfig_->loadProfile(name);
893+
}
894+
895+
bool PluginRpcService::saveBindingProfile(std::string name, BindingMapJson bindings) {
896+
if (!userConfig_) return false;
897+
return userConfig_->saveProfile(std::move(name), std::move(bindings));
898+
}
899+
900+
bool PluginRpcService::renameBindingProfile(std::string oldName, std::string newName) {
901+
if (!userConfig_) return false;
902+
return userConfig_->renameProfile(std::move(oldName), std::move(newName));
903+
}
904+
905+
bool PluginRpcService::deleteBindingProfile(std::string name) {
906+
if (!userConfig_) return false;
907+
return userConfig_->deleteProfile(std::move(name));
908+
}
909+
890910
namespace {
891911
std::string defaultSavePath(const SystemBase& sys, const char* ext) {
892912
const std::string& romPath = sys.romPath();

src/PluginRpcService.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,13 @@ class PluginRpcService {
250250
UserConfigDto getUserConfig();
251251
bool setActiveKeyboardBindings(std::string name);
252252
bool setActiveGamepadBindings(std::string name);
253+
// In-app bindings editor surface. Validation rules live in
254+
// UserConfig::isValidProfileName; all four return false / nullopt when
255+
// userConfig_ is null (LV2-UI / rpc-schema-dump).
256+
std::optional<BindingMapJson> getBindingProfile(std::string name);
257+
bool saveBindingProfile (std::string name, BindingMapJson bindings);
258+
bool renameBindingProfile(std::string oldName, std::string newName);
259+
bool deleteBindingProfile(std::string name);
253260
// Launch the platform file manager on the user config directory. False if
254261
// we have no UserConfig wired or the shell-out call fails.
255262
bool openSettingsFolder();

src/config/UserConfig.cpp

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,118 @@ bool UserConfig::setActiveGamepadBindings(std::string name) {
143143
return true;
144144
}
145145

146+
std::optional<BindingMapJson> UserConfig::loadProfile(std::string_view name) const {
147+
if (!isValidProfileName(name)) return std::nullopt;
148+
const fs::path p = bindingsDir_ / (std::string(name) + ".json");
149+
auto text = slurp(p);
150+
if (text.empty()) return std::nullopt;
151+
return bindingMapFromJson(text);
152+
}
153+
154+
bool UserConfig::isValidProfileName(std::string_view name) {
155+
if (name.empty()) return false;
156+
if (name == "config") return false; // would collide with config.json
157+
for (char c : name) {
158+
const bool ok = (c >= 'a' && c <= 'z')
159+
|| (c >= 'A' && c <= 'Z')
160+
|| (c >= '0' && c <= '9')
161+
|| c == '_' || c == '-';
162+
if (!ok) return false;
163+
}
164+
return true;
165+
}
166+
167+
bool UserConfig::saveProfile(std::string name, BindingMapJson bindings) {
168+
if (!started_.load()) return false;
169+
if (!isValidProfileName(name)) return false;
170+
bindings.schemaVersion = 1;
171+
bindings.name = name;
172+
const fs::path target = bindingsDir_ / (name + ".json");
173+
if (!atomicWrite(target, bindingMapToJson(bindings))) {
174+
std::fprintf(stderr, "[user-config] failed to write %s\n",
175+
target.string().c_str());
176+
return false;
177+
}
178+
reloadFromDisk();
179+
if (onReload_) onReload_();
180+
return true;
181+
}
182+
183+
bool UserConfig::renameProfile(std::string oldName, std::string newName) {
184+
if (!started_.load()) return false;
185+
if (!isValidProfileName(oldName) || !isValidProfileName(newName)) return false;
186+
if (oldName == newName) return true;
187+
const fs::path src = bindingsDir_ / (oldName + ".json");
188+
const fs::path dst = bindingsDir_ / (newName + ".json");
189+
std::error_code ec;
190+
if (!fs::exists(src, ec)) return false;
191+
if (fs::exists(dst, ec)) return false; // refuse to clobber
192+
193+
// If the source carries an embedded `name` field, rewrite it so the
194+
// file's content matches its new filename (cosmetic — the loader uses
195+
// the filename, not the field). Keeps hand-inspection sane.
196+
if (auto text = slurp(src); !text.empty()) {
197+
if (auto parsed = bindingMapFromJson(text)) {
198+
parsed->name = newName;
199+
const fs::path tmpRename = src.string() + ".renaming";
200+
if (!atomicWrite(tmpRename, bindingMapToJson(*parsed))) return false;
201+
fs::rename(tmpRename, src, ec);
202+
if (ec) { fs::remove(tmpRename, ec); return false; }
203+
}
204+
}
205+
206+
fs::rename(src, dst, ec);
207+
if (ec) return false;
208+
209+
// Repoint active profile references in config.json if needed.
210+
bool rewrote = false;
211+
UserConfigJson cfg;
212+
cfg.schemaVersion = 1;
213+
{
214+
std::lock_guard<std::mutex> lock(mu_);
215+
cfg.activeKeyboardBindings = current_.activeKeyboardBindings;
216+
cfg.activeGamepadBindings = current_.activeGamepadBindings;
217+
cfg.defaultZoom = current_.defaultZoom;
218+
if (cfg.activeKeyboardBindings == oldName) {
219+
cfg.activeKeyboardBindings = newName;
220+
rewrote = true;
221+
}
222+
if (cfg.activeGamepadBindings == oldName) {
223+
cfg.activeGamepadBindings = newName;
224+
rewrote = true;
225+
}
226+
}
227+
if (rewrote) {
228+
if (!atomicWrite(configFile_, userConfigToJson(cfg))) {
229+
std::fprintf(stderr, "[user-config] failed to update %s after rename\n",
230+
configFile_.string().c_str());
231+
return false;
232+
}
233+
}
234+
235+
reloadFromDisk();
236+
if (onReload_) onReload_();
237+
return true;
238+
}
239+
240+
bool UserConfig::deleteProfile(std::string name) {
241+
if (!started_.load()) return false;
242+
if (!isValidProfileName(name)) return false;
243+
{
244+
std::lock_guard<std::mutex> lock(mu_);
245+
if (name == current_.activeKeyboardBindings) return false;
246+
if (name == current_.activeGamepadBindings) return false;
247+
}
248+
const fs::path target = bindingsDir_ / (name + ".json");
249+
std::error_code ec;
250+
if (!fs::exists(target, ec)) return false;
251+
fs::remove(target, ec);
252+
if (ec) return false;
253+
reloadFromDisk();
254+
if (onReload_) onReload_();
255+
return true;
256+
}
257+
146258
void UserConfig::reloadFromDisk() {
147259
UserConfigDto next;
148260
{

src/config/UserConfig.hpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
#include <functional>
66
#include <memory>
77
#include <mutex>
8+
#include <optional>
89
#include <string>
10+
#include <string_view>
911

1012
#include <efsw/efsw.hpp>
1113

@@ -63,6 +65,32 @@ class UserConfig final : public efsw::FileWatchListener {
6365
bool setActiveKeyboardBindings(std::string name);
6466
bool setActiveGamepadBindings(std::string name);
6567

68+
// Read a single profile file from disk. Returns nullopt if the file
69+
// doesn't exist or fails to parse. Used by the in-app bindings editor
70+
// so it can edit one channel (keyboard or gamepad) of a profile
71+
// without clobbering the other when saving.
72+
std::optional<BindingMapJson> loadProfile(std::string_view name) const;
73+
74+
// Profile management used by the in-app bindings editor. All three
75+
// validate `name` (and `newName`) as `[A-Za-z0-9_-]+`, refuse reserved
76+
// stems ("config"), and refresh the in-memory snapshot synchronously
77+
// before returning.
78+
//
79+
// saveProfile — overwrites bindings/<name>.json (creates if missing).
80+
// renameProfile — fs::rename; if `oldName` is the active keyboard or
81+
// gamepad profile, config.json is rewritten to point
82+
// at `newName` so the active reference stays valid.
83+
// deleteProfile — refuses if `name` matches either active profile
84+
// (caller must switch first); otherwise unlinks.
85+
bool saveProfile (std::string name, BindingMapJson bindings);
86+
bool renameProfile(std::string oldName, std::string newName);
87+
bool deleteProfile(std::string name);
88+
89+
// Validate a profile name: non-empty, contains only [A-Za-z0-9_-], and
90+
// not equal to a reserved stem. Exposed for the RPC layer so the UI can
91+
// surface the rejection without round-tripping a write.
92+
static bool isValidProfileName(std::string_view name);
93+
6694
// efsw callback — runs on a background thread. Only flips dirty_.
6795
void handleFileAction(efsw::WatchID,
6896
const std::string& dir,

test/UserConfigTests.cpp

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
#include <catch2/catch_test_macros.hpp>
88

9+
#include <algorithm>
910
#include <chrono>
1011
#include <filesystem>
1112
#include <fstream>
@@ -167,6 +168,109 @@ TEST_CASE("UserConfig live-reloads when bindings file changes on disk", "[user-c
167168
fs::remove_all(dir);
168169
}
169170

171+
TEST_CASE("UserConfig::saveProfile writes a new profile file", "[user-config]") {
172+
auto dir = makeTempDir("save-profile");
173+
UserConfig cfg(dir);
174+
cfg.start();
175+
176+
BindingMapJson b;
177+
b.keyboard["A"] = {"q"};
178+
b.gamepad ["A"] = {"y"};
179+
REQUIRE(cfg.saveProfile("custom", b));
180+
181+
REQUIRE(fs::exists(dir / "bindings" / "custom.json"));
182+
auto loaded = cfg.loadProfile("custom");
183+
REQUIRE(loaded.has_value());
184+
REQUIRE(loaded->keyboard.at("A") == std::vector<std::string>{"q"});
185+
REQUIRE(loaded->gamepad .at("A") == std::vector<std::string>{"y"});
186+
REQUIRE(loaded->name == "custom");
187+
188+
auto snap = cfg.snapshot();
189+
const auto& list = snap.availableProfiles;
190+
REQUIRE(std::find(list.begin(), list.end(), std::string("custom")) != list.end());
191+
192+
fs::remove_all(dir);
193+
}
194+
195+
TEST_CASE("UserConfig::saveProfile rejects invalid names", "[user-config]") {
196+
auto dir = makeTempDir("save-invalid");
197+
UserConfig cfg(dir);
198+
cfg.start();
199+
200+
BindingMapJson b;
201+
REQUIRE_FALSE(cfg.saveProfile("", b));
202+
REQUIRE_FALSE(cfg.saveProfile("config", b)); // reserved
203+
REQUIRE_FALSE(cfg.saveProfile("../etc", b)); // path traversal
204+
REQUIRE_FALSE(cfg.saveProfile("a b", b)); // whitespace
205+
REQUIRE_FALSE(cfg.saveProfile("a.b", b)); // dot
206+
207+
fs::remove_all(dir);
208+
}
209+
210+
TEST_CASE("UserConfig::renameProfile moves the file and updates active references", "[user-config]") {
211+
auto dir = makeTempDir("rename-profile");
212+
UserConfig cfg(dir);
213+
cfg.start();
214+
215+
BindingMapJson b;
216+
b.keyboard["A"] = {"j"};
217+
REQUIRE(cfg.saveProfile("alpha", b));
218+
REQUIRE(cfg.setActiveKeyboardBindings("alpha"));
219+
REQUIRE(cfg.snapshot().activeKeyboardBindings == "alpha");
220+
221+
REQUIRE(cfg.renameProfile("alpha", "beta"));
222+
REQUIRE_FALSE(fs::exists(dir / "bindings" / "alpha.json"));
223+
REQUIRE(fs::exists(dir / "bindings" / "beta.json"));
224+
225+
auto snap = cfg.snapshot();
226+
REQUIRE(snap.activeKeyboardBindings == "beta");
227+
REQUIRE(snap.bindings.keyboard.at("A") == std::vector<std::string>{"j"});
228+
229+
// The renamed file's own `name` field was rewritten to match.
230+
auto loaded = cfg.loadProfile("beta");
231+
REQUIRE(loaded.has_value());
232+
REQUIRE(loaded->name == "beta");
233+
234+
fs::remove_all(dir);
235+
}
236+
237+
TEST_CASE("UserConfig::renameProfile refuses to clobber an existing file", "[user-config]") {
238+
auto dir = makeTempDir("rename-conflict");
239+
UserConfig cfg(dir);
240+
cfg.start();
241+
242+
BindingMapJson b;
243+
REQUIRE(cfg.saveProfile("alpha", b));
244+
REQUIRE(cfg.saveProfile("beta", b));
245+
246+
REQUIRE_FALSE(cfg.renameProfile("alpha", "beta"));
247+
REQUIRE(fs::exists(dir / "bindings" / "alpha.json"));
248+
REQUIRE(fs::exists(dir / "bindings" / "beta.json"));
249+
250+
fs::remove_all(dir);
251+
}
252+
253+
TEST_CASE("UserConfig::deleteProfile refuses the active profile", "[user-config]") {
254+
auto dir = makeTempDir("delete-active");
255+
UserConfig cfg(dir);
256+
cfg.start();
257+
258+
BindingMapJson b;
259+
REQUIRE(cfg.saveProfile("alt", b));
260+
REQUIRE(cfg.setActiveKeyboardBindings("alt"));
261+
262+
// Active keyboard profile — refused.
263+
REQUIRE_FALSE(cfg.deleteProfile("alt"));
264+
REQUIRE(fs::exists(dir / "bindings" / "alt.json"));
265+
266+
// Switch back, then delete succeeds.
267+
REQUIRE(cfg.setActiveKeyboardBindings("default"));
268+
REQUIRE(cfg.deleteProfile("alt"));
269+
REQUIRE_FALSE(fs::exists(dir / "bindings" / "alt.json"));
270+
271+
fs::remove_all(dir);
272+
}
273+
170274
TEST_CASE("UserConfig keeps previous snapshot AND leaves file alone on malformed JSON", "[user-config]") {
171275
auto dir = makeTempDir("malformed");
172276

0 commit comments

Comments
 (0)