From 7bfc0dfa73a2966ec9a0fdb5a296a3f22f51725b Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Sat, 24 Oct 2026 15:51:15 -0300 Subject: [PATCH] Controls: Player 2 on the Gamepad tab (input.player2), and the tabs fit 640x480 The launcher and the in-game menu read and write input.player2 (#101): the Gamepad tab starts with Player 2 (Off, First gamepad, Second gamepad), a line on what the choice does, and the gamepads connected now with the player each is, counted in connection order as the runtime does. It is written once set and kept once in the file (as video.menu_size); a bad value is warned about and reads as "off". The in-game menu applies it live (runtime/live.c already re-applies input). The Keyboard and Gamepad tables did not fit the in-game menu over a 640x480 window (309 and 480 px too wide): their chips now wrap as the Hotkeys table's do, the buttons' column is as wide as its widest name (auto-fitted, it was squeezed below the names once the chips wrap), and the note above a table keeps its Reset button beside it only when both fit (a SameLine after the wrapped note put the next line's start past the edge). _PORT_MENU_SCREEN takes controls: (Screens::show_controls_tab), so tests/hello_test.py --menu takes the Gamepad tab at 640x480 with player 2 set and requires it to fit. The launcher self-test checks the setting's round trips, gives the game's parser player2 "second", and takes a picture of the Gamepad tab with two virtual gamepads connected. Part of #101 (phase 2 of 3). Co-Authored-By: Claude Opus 5.5 --- docs/LAUNCHER.md | 6 +-- docs/RUNTIME.md | 2 +- launcher/README.md | 43 +++++++++------- launcher/src/selftest.cpp | 54 ++++++++++++++++++++ launcher/src/settings.cpp | 10 +++- launcher/src/settings.h | 3 ++ launcher/ui/menu.cpp | 12 +++-- launcher/ui/screens.cpp | 101 ++++++++++++++++++++++++++++++++++---- launcher/ui/screens.h | 4 ++ tests/hello_test.py | 17 ++++++- 10 files changed, 212 insertions(+), 40 deletions(-) diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index a5da06a..889d20f 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -282,8 +282,8 @@ over the generated header). It finds the settings directory, edits `settings.jso edit, unknown ones included), and starts the game. Screens: **Disc** (file dialog, drag-and-drop, or a typed path; only the described discs' SHA-1s are accepted), **Play** (starts the game; on an error shows the exit status, the crash report and the last lines of output, "Crash report"), **Settings** (scale, fullscreen, the rate when the game offers -two, renderer, mute, memory cards, the update check when the game has one: "Updates", Add to Steam: "Steam"), **Controls** (keyboard, gamepad, -hotkeys), **Mods** (an on/off switch per mod and a page generated from its manifest; the data mods in the settings +two, renderer, mute, memory cards, the update check when the game has one: "Updates", Add to Steam: "Steam"), **Controls** (keyboard, gamepad +with the second player's gamepad, hotkeys), **Mods** (an on/off switch per mod and a page generated from its manifest; the data mods in the settings directory's `mods/` in priority order, "Data mods"). Build, run, details and the self-test: `launcher/README.md`. ## Updates @@ -505,7 +505,7 @@ at a time), where leaving the game for the launcher's window is a chore. polls, the hotkey again, the next vsync follows the paused one), and `_PORT_MENU_SHOT=PATH` writes the window's output with the menu up as a PNG (the software renderer; the hardware renderer's present is not read back with the menu in it), on the page `_PORT_MENU_SCREEN=game|settings|controls|mods` names (`mods:`: - with that mod chosen), with the window first sized to `_PORT_MENU_WINDOW=WxH` when it is set (SDL's + with that mod chosen; `controls:keyboard|gamepad|hotkeys`: on that tab), with the window first sized to `_PORT_MENU_WINDOW=WxH` when it is set (SDL's software renderer follows it: `SDL_RENDER_DRIVER=software`). The shot's log line names the windows whose content reaches past their right edge ("nothing wider than its window" when none does). The round trip also re-applies the settings live at its second poll, as the menu does after a save, and with diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 56086d4..26cce2e 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -71,7 +71,7 @@ cmake -S port -B build/port-sdl -G Ninja -DPSXSTACK_SDL=ON -DPSXSTACK_MENU=ON links as C++ (`launcher/ui/`, Dear ImGui from `tools/imgui`); without the option nothing of it is in the binary. A change made in the menu is written to the settings file and applied live where the runtime can (`runtime/live.c`). `_PORT_MENU_SHOT=PATH` writes the window with the menu up as a PNG (the software renderer), on the page -`_PORT_MENU_SCREEN` names (`mods:` with a mod chosen), at the size `_PORT_MENU_WINDOW=WxH` gives +`_PORT_MENU_SCREEN` names (`mods:` with a mod chosen, `controls:` on a tab of Controls), at the size `_PORT_MENU_WINDOW=WxH` gives the window (with `SDL_RENDER_DRIVER=software`). SDL's backends follow the `-dev` headers present when `setup.sh sdl3` runs: missing optional ones are turned off one by one (logged), and with no X11/Wayland headers at all only the `offscreen` and `dummy` video drivers are built (enough diff --git a/launcher/README.md b/launcher/README.md index 5961023..e3450ed 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -123,7 +123,11 @@ half way (Gamepad), or, for a hotkey, any of them or a chord (everything held to most 4 inputs, and 8 triggers a binding: the game's limits). Escape alone, or Cancel, closes it. The prompt takes its events before ImGui, and gamepad navigation pauses until the gamepad is released, so the press that was bound does not also click a button. An input used by two buttons (or a button and a single-input hotkey) is marked, with a -tooltip naming the other use. A mod's own bindings are its options (Mods, below). +tooltip naming the other use. A mod's own bindings are its options (Mods, below). The **Gamepad** tab starts with +**Player 2** (`input.player2`: Off, First gamepad, Second gamepad, by connection order; docs/LAUNCHER.md "Input +bindings"), a line on what the choice does, and the gamepads connected now with the player each is (in the in-game +menu the game's own count; in the launcher the same connection order the game will count). Both players' gamepads use +the tab's map. The tables wrap their chips, so every tab fits the in-game menu over a 640x480 window. ## Mods @@ -193,32 +197,33 @@ drops `SDL_VIDEO_DRIVER` from the program's environment.) No disc and no display needed (CI runs it). It replaces `DIR/launcher-self-test/` and checks: the path helpers (Windows forms too), the JSON writer, the lookup order, the settings file's round trips (the documented example with its unknown -members kept, invalid values, a broken file, a newer schema), the binding grammar and the input prompt (keys, gamepad -buttons, stick directions, chords, Escape), the `.cue` reader and the SHA-1 check (against the described discs), the -launch path with **the launcher itself as the game's stand-in** (`_LAUNCHER_FAKE_GAME=mode`: a game with and -without `--config`, one that rejects the file, one that crashes, one that fails after 250 lines), then opens the -window, walks every screen with injected key events and a virtual gamepad, plays with the stand-in (a file of the first -described disc's size stands for the verified disc), rebinds a key through the prompt and checks the file, drops a -wrong file on the window, renders the mods screen over the fixture manifests (`tests/fixtures/mods/`: the first game's -`fast_forward` as shipped, the documented `skip_dialogues` example, one with every option type, one with presets and a -slider whose toggle makes it a typed number; three unusable ones written at test time) and rebinds a mod's key through -the prompt, and saves a picture of each screen in `DIR/launcher-self-test/screens/`. The update check never touches the -network: `_LAUNCHER_FAKE_CURL` points at **the launcher itself as curl's stand-in** (it answers with a file, in -pieces for a download, or with curl's errors; `_SELFTEST_CURL_REPLY` says which), and every check of the -self-test goes to it. It checks the version rules (only an exact `X.Y.Z` is offered an update), GitHub's answer +members kept, invalid values, a broken file, a newer schema), the binding grammar, `input.player2`, and the input prompt +(keys, gamepad buttons, stick directions, chords, Escape), the `.cue` reader and the SHA-1 check (against the described +discs), the launch path with **the launcher itself as the game's stand-in** (`_LAUNCHER_FAKE_GAME=mode`: a game +with and without `--config`, one that rejects the file, one that crashes, one that fails after 250 lines), then opens +the window, walks every screen with injected key events and a virtual gamepad, plays with the stand-in (a file of the +first described disc's size stands for the verified disc), rebinds a key through the prompt and checks the file, shows +the Gamepad tab with player 2 set and two virtual gamepads connected, drops a wrong file on the window, renders the mods +screen over the fixture manifests (`tests/fixtures/mods/`: the first game's `fast_forward` as shipped, the documented +`skip_dialogues` example, one with every option type, one with presets and a slider whose toggle makes it a typed +number; three unusable ones written at test time) and rebinds a mod's key through the prompt, and saves a picture of +each screen in `DIR/launcher-self-test/screens/`. The update check never touches the network: +`_LAUNCHER_FAKE_CURL` points at **the launcher itself as curl's stand-in** (it answers with a file, in pieces +for a download, or with curl's errors; `_SELFTEST_CURL_REPLY` says which), and every check of the self-test goes +to it. It checks the version rules (only an exact `X.Y.Z` is offered an update), GitHub's answer (`tests/fixtures/updates/`: the tag, the page, this platform's package with its size and SHA-256; a tag that is not a version, a cut answer), `launcher.updates` in the file, `src/http.cpp` (a GET, curl's own errors, curl missing, a download with its progress, a cancel, a wrong size), then in the window: the prompt over Play (a picture), Escape as Later, the Release page, Skip and a newer release after it, the setting off with Check now, `--no-update-check`, the prompt waiting for a running game, curl missing, a cut answer, a development build and a description without a repository (pictures of the Settings screen's part). Then **Update now** in the window, the stand-in "downloading" -fixture packages (`_SELFTEST_CURL_DOWNLOAD` is its answer for a download, the release's JSON being the -check's): the line instead of the button when the plan is not possible (no `$APPIMAGE`), the progress and Cancel on a -slow download (no check started meanwhile, the file removed, the install unchanged), a wrong digest and a missing one +fixture packages (`_SELFTEST_CURL_DOWNLOAD` is its answer for a download, the release's JSON being the check's): +the line instead of the button when the plan is not possible (no `$APPIMAGE`), the progress and Cancel on a slow +download (no check started meanwhile, the file removed, the install unchanged), a wrong digest and a missing one (nothing applied, the message, Release page still works), the whole flow into a fake AppImage install (`$APPIMAGE` at a copy of the launcher; Enter presses Update now) and into a fake Windows folder (a copy of the launcher, the release's -zip made with the self-update's tar), each ending with the new launcher (a marked copy) started with the arguments -given and this one quitting, and the relaunched stamp not prompted again (pictures of the prompt's steps). +zip made with the self-update's tar), each ending with the new launcher (a marked copy) started with the arguments given +and this one quitting, and the relaunched stamp not prompted again (pictures of the prompt's steps). The Steam shortcut (`selftest_steam.cpp`, in `DIR/launcher-self-test/steam/`; never this system's Steam): the fixture `shortcuts.vdf` read and written back byte for byte, the launcher's shortcut added (the bytes of diff --git a/launcher/src/selftest.cpp b/launcher/src/selftest.cpp index fed8ae4..516f612 100644 --- a/launcher/src/selftest.cpp +++ b/launcher/src/selftest.cpp @@ -429,6 +429,28 @@ static void test_input(const std::string &root) { h.values.keyboard["circle"] = { "B" }; check(h.save(&err) && Json::parse(read(h.path()), nullptr).find("input")->find("future") != nullptr, "unknown members of input are kept"); + // input.player2 (the second pad): written once set, read back; kept once in the file, as video.menu_size; a bad + // value is warned about and reads as "off"; a file without it says "off" and gets none. + check(h.values.player2 == "off", "input.player2: off by default"); + h.values.player2 = "second"; + check(h.save(&err) && + *Json::parse(read(h.path()), nullptr).find("input")->find("player2") == Json::string("second"), + "input.player2 is written"); + SettingsFile p2; + p2.load(dir); + check(p2.values.player2 == "second" && p2.messages().empty(), "input.player2 reads back"); + p2.values.player2 = "off"; + check(p2.save(&err) && *Json::parse(read(p2.path()), nullptr).find("input")->find("player2") == Json::string("off"), + "input.player2 set back to off stays in the file"); + write(p2.path(), R"({"schema": 1, "input": {"player2": "third"}})"); + p2.load(dir); + check(p2.values.player2 == "off" && p2.messages().size() == 1 && + p2.messages()[0].find("input.player2") != std::string::npos, + "a bad input.player2: a warning and off"); + write(p2.path(), R"({"schema": 1})"); + p2.load(dir); + check(p2.save(&err) && Json::parse(read(p2.path()), nullptr).find("input") == nullptr, + "no input.player2 written for a file without one"); // The capture, fed events. InputCapture c; @@ -1022,6 +1044,7 @@ static void test_game(const std::string &root) { r.values.gamepad["select"] = {}; r.values.hotkeys["pause"] = { { "pad:guide", "pad:start" }, { "P" } }; r.values.hotkeys["fullscreen"] = {}; + r.values.player2 = "second"; // Every mod beside the game turned on, every option off its default (the game's own checks of mods.). int mods_set = 0, mods_found = 0; ModValues mv(&r.doc); @@ -1254,6 +1277,37 @@ static void test_play(SDL_Window *window, const std::string &root) { push_key(window, SDL_SCANCODE_ESCAPE, SDL_KMOD_NONE, false); pump(app, 2); check(!app.capture().active() && app.settings().values.hotkeys.count("pause") == 0, "Escape leaves the hotkey"); + // The second pad (input.player2): the Gamepad tab with two virtual gamepads connected, the second one player 2's + // (a picture); then back to off and the Keyboard tab. + { + SDL_JoystickID pads[2] = { 0, 0 }; + for (int i = 0; i < 2; i++) { + SDL_VirtualJoystickDesc desc; + SDL_INIT_INTERFACE(&desc); + desc.type = SDL_JOYSTICK_TYPE_GAMEPAD; + desc.nbuttons = SDL_GAMEPAD_BUTTON_DPAD_RIGHT + 1; + desc.naxes = SDL_GAMEPAD_AXIS_COUNT; + desc.name = i == 0 ? "psxstack self-test pad A" : "psxstack self-test pad B"; + pads[i] = SDL_AttachVirtualJoystick(&desc); + check(pads[i] != 0, std::string("a virtual gamepad: ") + SDL_GetError()); + } + app.settings().values.player2 = "second"; + app.screens().show_controls_tab("gamepad"); + pump(app, 4); + int listed = 0; + SDL_free(SDL_GetGamepads(&listed)); + check(listed >= 2, "the two virtual gamepads are connected (" + std::to_string(listed) + ")"); + png = path_join(shots, "8b-Controls-gamepad.png"); + app.frame(png.c_str()); + for (SDL_JoystickID id : pads) { + if (id != 0) { + SDL_DetachVirtualJoystick(id); + } + } + app.settings().values.player2 = "off"; + app.screens().show_controls_tab("keyboard"); + pump(app, 2); + } app.set_screen(Screen::Settings); pump(app, 2); png = path_join(shots, "9-Settings.png"); diff --git a/launcher/src/settings.cpp b/launcher/src/settings.cpp index 9f40026..b62e97a 100644 --- a/launcher/src/settings.cpp +++ b/launcher/src/settings.cpp @@ -179,6 +179,11 @@ static void read_input(const Json &doc, Settings *s, std::vector *w (pad ? s->gamepad : s->keyboard)[b.id] = names; } } + read_string(input, "input", "player2", &s->player2, w); + if (std::find(std::begin(PLAYER2_NAMES), std::end(PLAYER2_NAMES), s->player2) == std::end(PLAYER2_NAMES)) { + warn(w, "input.player2: expected \"off\", \"first\" or \"second\"; using \"off\""); + s->player2 = "off"; + } if (const Json *hotkeys = input->find("hotkeys")) { if (!hotkeys->is_object()) { warn(w, "input.hotkeys: expected an object; using the defaults"); @@ -204,7 +209,7 @@ static void read_input(const Json &doc, Settings *s, std::vector *w // removed (a file never rebound has no input at all). static void write_input(const Settings &s, Json *doc) { Json *input = doc->find("input"); - if (input == nullptr && s.keyboard.empty() && s.gamepad.empty() && s.hotkeys.empty()) { + if (input == nullptr && s.keyboard.empty() && s.gamepad.empty() && s.hotkeys.empty() && s.player2 == "off") { return; } if (input == nullptr || !input->is_object()) { @@ -252,6 +257,9 @@ static void write_input(const Settings &s, Json *doc) { input->erase("hotkeys"); } } + if (s.player2 != "off" || input->find("player2") != nullptr) { // as video.menu_size + input->set("player2", Json::string(s.player2)); + } if (input->members().empty()) { doc->erase("input"); } diff --git a/launcher/src/settings.h b/launcher/src/settings.h index 26c88b8..9201a0e 100644 --- a/launcher/src/settings.h +++ b/launcher/src/settings.h @@ -64,6 +64,8 @@ struct MemoryCard { inline const char *const FILTER_NAMES[] = { "none", "sharp", "scanlines", "crt", "smooth" }; // video.menu_size's values (the in-game menu's text, launcher/ui/menu.cpp), in the Settings screen's order. inline const char *const MENU_SIZE_NAMES[] = { "smaller", "auto", "larger" }; +// input.player2's values (the game's runtime/input.c): none, or the gamepad by connection order that is player 2. +inline const char *const PLAYER2_NAMES[] = { "off", "first", "second" }; // launcher.steam (docs/LAUNCHER.md "Steam"): what Add to Steam did, so Remove and Update shortcut undo exactly that // without looking for Steam again. appid 0: not in Steam. @@ -102,6 +104,7 @@ struct Settings { std::map> keyboard; // input.keyboard: button -> key names std::map> gamepad; // input.gamepad: button -> gamepad input names std::map hotkeys; // input.hotkeys: action -> binding + std::string player2 = "off"; // input.player2: one of PLAYER2_NAMES // The effective values (the file's, else the defaults). std::vector keys_for(const std::string &button) const; diff --git a/launcher/ui/menu.cpp b/launcher/ui/menu.cpp index a1a1055..01b543e 100644 --- a/launcher/ui/menu.cpp +++ b/launcher/ui/menu.cpp @@ -9,7 +9,8 @@ // _PORT_MENU_SHOT=PATH writes the window's output with the menu up, once, as a PNG: the second present after // it is shown (the software renderer: the input self-test's picture; the hardware renderer's present cannot be read // back with the menu in it, logged). _PORT_MENU_SCREEN=game|settings|controls|mods picks the page it opens on -// (the picture of a page; default game). +// (the picture of a page; default game), `mods:` with that mod chosen, `controls:` on that tab (keyboard, +// gamepad, hotkeys). #include "menu.h" #include @@ -83,11 +84,11 @@ bool menu_open_common(SDL_Window *window) { mn.shot = shot; } mn.from_launcher = SDL_getenv(env_name("_LAUNCHER").c_str()) != nullptr; - std::string start_mod; + std::string start_mod, start_tab; if (const char *page = SDL_getenv(env_name("_PORT_MENU_SCREEN").c_str())) { std::string name = page; - if (size_t colon = name.find(':'); colon != std::string::npos) { // mods: - start_mod = name.substr(colon + 1); + if (size_t colon = name.find(':'); colon != std::string::npos) { // mods:, controls: + (SDL_strncasecmp(page, "controls:", 9) == 0 ? start_tab : start_mod) = name.substr(colon + 1); name.resize(colon); } for (int i = 0; i < PAGE_COUNT; i++) { @@ -123,6 +124,9 @@ bool menu_open_common(SDL_Window *window) { if (!start_mod.empty()) { mn.screens.select_mod(start_mod); } + if (!start_tab.empty()) { + mn.screens.show_controls_tab(start_tab); + } if (mn.has_settings) { port_log("menu: settings %s, mods beside %s%s%s", port_settings.file, exe_dir.c_str(), user_mods != nullptr ? ", data mods in " : "", user_mods != nullptr ? user_mods : ""); diff --git a/launcher/ui/screens.cpp b/launcher/ui/screens.cpp index 51ca14b..381d53e 100644 --- a/launcher/ui/screens.cpp +++ b/launcher/ui/screens.cpp @@ -937,32 +937,104 @@ void Screens::draw_controls() { draw_restart_note(); ImGui::BeginDisabled(!settings_.writable()); if (ImGui::BeginTabBar("controls")) { - if (ImGui::BeginTabItem("Keyboard")) { + auto pick = [this](const char *tab) { + return SDL_strcasecmp(controls_tab_.c_str(), tab) == 0 ? ImGuiTabItemFlags_SetSelected : 0; + }; + if (ImGui::BeginTabItem("Keyboard", nullptr, pick("keyboard"))) { draw_names_table(false); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Gamepad")) { + if (ImGui::BeginTabItem("Gamepad", nullptr, pick("gamepad"))) { + draw_players(); + ImGui::Separator(); draw_names_table(true); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Hotkeys")) { + if (ImGui::BeginTabItem("Hotkeys", nullptr, pick("hotkeys"))) { draw_hotkeys(); ImGui::EndTabItem(); } ImGui::EndTabBar(); + controls_tab_.clear(); } ImGui::EndDisabled(); draw_capture_popup(); } +// input.player2 (docs/LAUNCHER.md "Input bindings"): Off, or the gamepad by connection order that drives the second +// pad (the PS1's port 1); a line of help for the choice, then the gamepads connected now with the player each is (in +// the game's menu the game's own count; in the launcher the same connection order the game will count). +void Screens::draw_players() { + static const char *const labels[] = { "Off", "First gamepad", "Second gamepad" }; + static_assert(SDL_arraysize(labels) == SDL_arraysize(PLAYER2_NAMES), "a label per input.player2 value"); + Settings &s = settings_.values; + const float label_w = label_width(); + int p2 = 0; // 0 off, else the gamepad (1-based, by connection order) that is player 2 + ImGui::TextDisabled("Player 2"); + ImGui::SetItemTooltip("The gamepad that drives the second pad, for two-player games"); + ImGui::SameLine(label_w); + for (int i = 0; i < (int)SDL_arraysize(labels); i++) { + if (i > 0) { + ImGui::SameLine(); + wrap_for(radio_width(labels[i]), label_w); + } + if (ImGui::RadioButton(labels[i], s.player2 == PLAYER2_NAMES[i])) { + s.player2 = PLAYER2_NAMES[i]; + dirty_ = true; + } + if (s.player2 == PLAYER2_NAMES[i]) { + p2 = i; + } + } + ImGui::SetCursorPosX(label_w); + ImGui::BeginGroup(); // its wrapped lines start under the choices + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("%s", p2 == 0 ? "The keyboard and every gamepad are player 1; the second pad stays empty." + : "That gamepad drives the second pad and never a hotkey; the keyboard and the " + "other gamepads are player 1. First suits a player 1 on the keyboard."); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + int n = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&n); + if (ids != nullptr) { + std::sort(ids, ids + n); // the instance ids grow with each connection + } + ImGui::TextDisabled("Connected"); + ImGui::SameLine(label_w); + ImGui::BeginGroup(); + if (n == 0) { + ImGui::TextDisabled("no gamepad"); + } + for (int i = 0; i < n; i++) { + const char *name = SDL_GetGamepadNameForID(ids[i]); + ImGui::TextWrapped("%d. %s (player %d)", i + 1, name != nullptr ? name : "Gamepad", i + 1 == p2 ? 2 : 1); + } + ImGui::EndGroup(); + SDL_free(ids); +} + void Screens::draw_names_table(bool pad) { Settings &s = settings_.values; auto &map = pad ? s.gamepad : s.keyboard; - ImGui::TextDisabled(pad ? "Every gamepad drives the PS1 pad (positions: south is the PlayStation cross)." - : "Keys by their place on the keyboard, whatever its layout."); - ImGui::SameLine(); + const char *reset = pad ? "Reset the gamepad" : "Reset the keyboard"; + const char *note = "Keys by their place on the keyboard, whatever its layout."; + if (pad) { + note = s.player2 == "off" ? "Every gamepad drives the PS1 pad (positions: south is the PlayStation cross)." + : "Both players' gamepads use this map (south is the PlayStation cross)."; + } + // the note with its Reset beside it when both fit, else the note wrapped and Reset under it; the chips below wrap + // too (a narrow page: the in-game menu over a 640x480 window) + if (ImGui::CalcTextSize(note).x + ImGui::GetStyle().ItemSpacing.x + button_width(reset) <= + ImGui::GetContentRegionAvail().x) { + ImGui::TextDisabled("%s", note); + ImGui::SameLine(); + } else { + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("%s", note); + ImGui::PopTextWrapPos(); + } ImGui::BeginDisabled(map.empty()); - if (ImGui::SmallButton(pad ? "Reset the gamepad" : "Reset the keyboard")) { + if (ImGui::SmallButton(reset)) { map.clear(); dirty_ = true; } @@ -971,9 +1043,15 @@ void Screens::draw_names_table(bool pad) { return; } const std::vector uses = binding_uses(); - ImGui::TableSetupColumn("Button", ImGuiTableColumnFlags_WidthFixed); + // The buttons' column as wide as its widest name: auto-fitted, it was squeezed below the names once the chips + // wrap (the in-game menu's Gamepad tab at 640x480). + float names_w = 0; + for (const PadButton &b : pad_buttons()) { + names_w = std::max(names_w, ImGui::CalcTextSize(b.label).x); + } + ImGui::TableSetupColumn("Button", ImGuiTableColumnFlags_WidthFixed, names_w); ImGui::TableSetupColumn("Inputs", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Default", ImGuiTableColumnFlags_WidthFixed); + ImGui::TableSetupColumn("Default", ImGuiTableColumnFlags_WidthFixed, button_width("Default")); for (const PadButton &b : pad_buttons()) { ImGui::PushID(b.id); ImGui::TableNextRow(); @@ -984,6 +1062,7 @@ void Screens::draw_names_table(bool pad) { std::vector names = pad ? s.pad_for(b.id) : s.keys_for(b.id); for (size_t i = 0; i < names.size(); i++) { const std::string label = pad ? pad_input_label(names[i]) : names[i]; + wrap_for(button_width(label.c_str()) + 2 + button_width("x")); if (chip(label, uses_of(s, uses, pad, names[i], b.id), std::to_string(i).c_str())) { names.erase(names.begin() + (long)i); map[b.id] = names; @@ -995,6 +1074,7 @@ void Screens::draw_names_table(bool pad) { ImGui::TextDisabled("unbound"); ImGui::SameLine(); } + wrap_for(button_width("+")); if (ImGui::SmallButton("+")) { capture_for(pad ? 1 : 0, b.id); } @@ -1046,7 +1126,8 @@ bool Screens::binding_editor(const std::string &key, Binding *b, const std::vect void Screens::draw_hotkeys() { Settings &s = settings_.values; ImGui::TextWrapped("The port's own actions. A hotkey never reaches the game's pad. A chord: hold its inputs " - "together, then let go. The mods' bindings are on the Mods screen."); + "together, then let go. The mods' bindings are on the Mods screen.%s", + s.player2 == "off" ? "" : " Player 2's gamepad never fires one."); if (!ImGui::BeginTable("hotkeys", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) { return; } diff --git a/launcher/ui/screens.h b/launcher/ui/screens.h index 71afaaa..59e327a 100644 --- a/launcher/ui/screens.h +++ b/launcher/ui/screens.h @@ -61,6 +61,8 @@ class Screens { InputCapture &capture() { return capture_; } void capture_for(int section, const std::string &id); // 0 keyboard, 1 gamepad (button ids), 2 hotkeys (actions) void capture_for_mod(const std::string &mod, const std::string &option); + // The Controls screen's tab ("keyboard", "gamepad" or "hotkeys"), selected at its next frame. + void show_controls_tab(const std::string &tab) { controls_tab_ = tab; } // The mods found: the game's, then the user's data mods. const std::vector &mods() const { return mods_; } const std::string &mods_dir() const { return mods_dir_; } @@ -74,6 +76,7 @@ class Screens { void restart_mark(float indent = 0); // "(restart)" after the last item, in the game's menu void draw_filter(float label_w); void draw_names_table(bool pad); + void draw_players(); void draw_hotkeys(); void draw_capture_popup(); void draw_data_mod(const ModManifest &m, ModValues &values); @@ -94,6 +97,7 @@ class Screens { bool dirty_ = false; bool saved_ = false; bool in_game_ = false; + std::string controls_tab_; // show_controls_tab's, until the Controls screen is drawn std::function settings_extra_; std::string error_; // the last save error diff --git a/tests/hello_test.py b/tests/hello_test.py index 04af07d..a1111cb 100644 --- a/tests/hello_test.py +++ b/tests/hello_test.py @@ -66,7 +66,8 @@ def menu_check(build, env): imgui), its input self-test on SDL's offscreen driver, which ends with the menu's round trip (runtime/input.c), and the picture of the menu up on its Settings page (HELLO_PORT_MENU_SHOT and _SCREEN, launcher/ui/menu.cpp) at build/menu.png; then the Mods page's at three window sizes and one menu size (MENU_SIZES), build/menu-mods-*.png; - then the input self-test again with each input.player2 that gives a second pad (PLAYER2: its players' part).""" + then the input self-test again with each input.player2 that gives a second pad (PLAYER2: its players' part), the + first also with the picture of the Controls screen's Gamepad tab at 640x480, build/menu-controls-gamepad.png.""" for tool in ("cmake", "ninja", "gcc", "g++", "objcopy", "objdump", "nm"): if not shutil.which(tool, path=env["PATH"]): print(f"hello: no {tool} on PATH (scripts/setup.sh cmake; gcc, g++ and binutils from the system)") @@ -159,7 +160,8 @@ def menu_sizes(exe, cfg, env): # input.player2 with a second pad (#101): the self-test's players' part puts one virtual gamepad on each port and -# checks player 2's never fires a hotkey (the default, "off", is the first run's: both on port 0). +# checks player 2's never fires a hotkey (the default, "off", is the first run's: both on port 0). The first run also +# takes the menu's Gamepad tab (its Player 2 row) at the smallest window, which must fit it. PLAYER2 = ["second", "first"] @@ -170,6 +172,11 @@ def players_check(exe, cfg, env): for mode in PLAYER2: (players / "settings.json").write_text(json.dumps({"schema": 1, "input": {"player2": mode}})) run_env = dict(env, SDL_VIDEO_DRIVER="offscreen", SDL_AUDIO_DRIVER="dummy") + shot = exe.parent / "menu-controls-gamepad.png" + if mode == PLAYER2[0]: + shot.unlink(missing_ok=True) + run_env.update(SDL_RENDER_DRIVER="software", HELLO_PORT_MENU_SHOT=str(shot), + HELLO_PORT_MENU_SCREEN="controls:gamepad", HELLO_PORT_MENU_WINDOW="640x480") r = subprocess.run([str(exe), "--config", str(players / "settings.json"), "--input-test", "--fps", "0"], capture_output=True, text=True, env=run_env, timeout=600) lines = r.stderr.strip().splitlines() @@ -177,6 +184,12 @@ def players_check(exe, cfg, env): "checks passed" in l), "no players' line") good = r.returncode == 0 and line.startswith(f'the players (input.player2 "{mode}")') print(("ok " if good else "FAIL ") + f"the input self-test, player2 {mode}: status {r.returncode}; {line}") + if mode == PLAYER2[0]: + fit = shot_fit(lines) + pic = shot.exists() and shot.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" + print(("ok " if fit == "nothing wider than its window" and pic else "FAIL ") + + f"the Gamepad tab at 640x480: {fit}; {shot if pic else 'no PNG'}") + good &= fit == "nothing wider than its window" and pic if not good: print("\n".join(lines[-15:])) ok &= good