From 1c1b7c9f50e63bf66ec88e67ac063a2107c86801 Mon Sep 17 00:00:00 2001 From: KiwiYu <92352309+kiwiyu-thegoat@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:24:45 +0100 Subject: [PATCH] ImDebugger: Add a soft keyboard toggle, implemented on Android A "Show Keyboard" button in the menu bar brings up the soft keyboard and keeps it up, typing into the debugger. On Android, NativeSurfaceView acts as a text editor while it's forced, so showSoftInput actually works for it, and passes typed text and key presses on to the native side. Closing the keyboard with Back unticks the toggle. The Misc menu can hide the button (which also turns the keyboard off) and choose whether the keyboard comes back after switching apps. Other platforms can provide an implementation through ImDebugger_SetSoftKeyboardHook. Co-Authored-By: Claude Opus 5.5 (1M context) --- UI/ImDebugger/ImDebugger.cpp | 82 ++++++++++++ UI/ImDebugger/ImDebugger.h | 12 ++ android/jni/app-android.cpp | 28 ++++ .../org/ppsspp/ppsspp/NativeSurfaceView.java | 122 ++++++++++++++++++ .../src/org/ppsspp/ppsspp/PpssppActivity.java | 8 ++ 5 files changed, 252 insertions(+) diff --git a/UI/ImDebugger/ImDebugger.cpp b/UI/ImDebugger/ImDebugger.cpp index fd254fdf5a..019f85995d 100644 --- a/UI/ImDebugger/ImDebugger.cpp +++ b/UI/ImDebugger/ImDebugger.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "ppsspp_config.h" @@ -86,6 +87,28 @@ static constexpr bool kIsMobilePlatform = true; static constexpr bool kIsMobilePlatform = false; #endif +// Defined further down (see ImDebugger_SetSoftKeyboardHook). +static std::function g_softKeyboardHook; + +#if PPSSPP_PLATFORM(ANDROID) +void Android_SetKeyboardForced(bool forced); // Defined in android/jni/app-android.cpp. +void Android_SetKeyboardPersist(bool persist); // Whether the keyboard comes back after an app switch. +bool Android_ConsumeAppPaused(); // True once after the app was paused (switched away from). +bool Android_ConsumeKeyboardDismissed(); // True once after the user closed the keyboard with Back. +#endif + +// Shows or hides the soft keyboard. A hook set through ImDebugger_SetSoftKeyboardHook takes priority; otherwise +// Android uses its built-in implementation, and other platforms do nothing. +static void ApplySoftKeyboard(bool show) { + if (g_softKeyboardHook) { + g_softKeyboardHook(show); + return; + } +#if PPSSPP_PLATFORM(ANDROID) + Android_SetKeyboardForced(show); +#endif +} + void ShowInMemoryViewerMenuItem(uint32_t addr, ImControl &control) { if (ImGui::BeginMenu("Show in memory viewer")) { for (int i = 0; i < 4; i++) { @@ -2665,6 +2688,9 @@ ImDebugger::ImDebugger() { } ImDebugger::~ImDebugger() { + if (keyboardShown_) { + ApplySoftKeyboard(false); + } SaveConfig(); } @@ -2677,6 +2703,15 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUCommon *gpuDebug, Draw: const CoreState coreState = ::coreState; const bool showDebugger = g_Config.bShowImDebugger && PSP_IsInited(); // This will be separated from showing the IM UI in general. + // The debugger got hidden some other way (hotkey, settings...): drop the keyboard so it's off when reopened. + if (!g_Config.bShowImDebugger && cfg_.forceKeyboard) { + cfg_.forceKeyboard = false; + if (keyboardShown_) { + keyboardShown_ = false; + ApplySoftKeyboard(false); + } + } + const bool allowSaveStates = !Achievements::HardcoreModeActive() && PSP_IsInited() && (!IsNetworkConnected() || g_Config.bAllowSavestateWhileConnected); if (Achievements::HardcoreModeActive()) { @@ -2736,6 +2771,11 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUCommon *gpuDebug, Draw: }; auto closeDebugger = [&]() { g_Config.bShowImDebugger = false; + cfg_.forceKeyboard = false; // So the keyboard doesn't pop back up when the debugger is reopened. + if (keyboardShown_) { + keyboardShown_ = false; + ApplySoftKeyboard(false); + } }; auto menuFile = [&]() { @@ -3008,6 +3048,8 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUCommon *gpuDebug, Draw: ImGui::MenuItem("Dear ImGui Style editor", nullptr, &cfg_.styleEditorOpen); ImGui::Separator(); ImGui::MenuItem("Quick scroll bar", nullptr, &cfg_.quickScrollEnabled); + ImGui::MenuItem("Show keyboard toggle", nullptr, &cfg_.showKeyboardToggle); + ImGui::MenuItem("Keep keyboard after switching apps", nullptr, &cfg_.keyboardPersist); ImGui::MenuItem("Compact menu", nullptr, &cfg_.compactMenu); ImGui::SetNextItemWidth(em * 10.0f); ImGui::SliderInt("Bar left margin", &cfg_.barLeftMargin, 0, 600, "%d%%"); // Percent of the text height. @@ -3077,9 +3119,40 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUCommon *gpuDebug, Draw: break; } + // Soft keyboard toggle (like the keyboard button in Winlator). + if (cfg_.showKeyboardToggle) { + ImGui::MenuItem(cfg_.forceKeyboard ? "Hide Keyboard" : "Show Keyboard", nullptr, &cfg_.forceKeyboard); + } + ImGui::EndMainMenuBar(); } + // Force the soft keyboard to show while the debugger is open (the platform side is a hook, see ImDebugger.h). + { +#if PPSSPP_PLATFORM(ANDROID) + // If the keyboard shouldn't survive an app switch, turn it off once we've been away. + if (Android_ConsumeAppPaused() && !cfg_.keyboardPersist) { + cfg_.forceKeyboard = false; + } + // The keyboard was closed from the system (Back button): keep the toggle in sync. + if (Android_ConsumeKeyboardDismissed()) { + cfg_.forceKeyboard = false; + } + const int persist = cfg_.keyboardPersist ? 1 : 0; + if (persist != keyboardPersistSent_) { + keyboardPersistSent_ = persist; + Android_SetKeyboardPersist(persist != 0); + } +#endif + // Hiding the toggle turns the keyboard off too, otherwise there'd be no way to dismiss it. + const bool wantKeyboard = cfg_.forceKeyboard && cfg_.showKeyboardToggle && g_Config.bShowImDebugger; + if (wantKeyboard != keyboardShown_) { + keyboardShown_ = wantKeyboard; + ApplySoftKeyboard(wantKeyboard); + } + } + + if (cfg_.demoOpen) { ImGui::ShowDemoWindow(&cfg_.demoOpen); } @@ -3314,6 +3387,12 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUCommon *gpuDebug, Draw: } } +// Platform hook for forcing the soft keyboard. Left empty by default (no-op), so this builds on every platform. +// Android: set it from native code to call InputMethodManager.showSoftInput / hideSoftInputFromWindow via the activity. +void ImDebugger_SetSoftKeyboardHook(std::function hook) { + g_softKeyboardHook = std::move(hook); +} + // ---- Quick scroll strip ---- // The window to scroll, or the nearest parent that actually has something to scroll. @@ -3599,6 +3678,9 @@ void ImConfig::SyncConfig(IniFile *ini, bool save) { sync.Sync("realtimePixelPreview", &realtimePixelPreview, false); sync.Sync("quickScrollEnabled", &quickScrollEnabled, true); sync.Sync("quickScrollCollapsed", &quickScrollCollapsed, false); + sync.Sync("forceKeyboard", &forceKeyboard, false); + sync.Sync("showKeyboardToggle", &showKeyboardToggle, true); + sync.Sync("keyboardPersist", &keyboardPersist, true); sync.Sync("compactMenu", &compactMenu, kIsMobilePlatform); sync.Sync("barLeftMargin", &barLeftMargin, kIsMobilePlatform ? 200 : 0); } diff --git a/UI/ImDebugger/ImDebugger.h b/UI/ImDebugger/ImDebugger.h index d71b1fb7b6..3ae68e1292 100644 --- a/UI/ImDebugger/ImDebugger.h +++ b/UI/ImDebugger/ImDebugger.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "ext/imgui/imgui.h" @@ -134,6 +135,9 @@ struct ImConfig { // Touch / mobile helpers. bool quickScrollEnabled = true; // Show the quick scroll strip on the right edge. bool quickScrollCollapsed = false; // Strip collapsed to a single small button. + bool forceKeyboard = false; // Keyboard currently active (toggled from the bar). Asks the platform to show the soft keyboard (see ImDebugger_SetSoftKeyboardHook). + bool showKeyboardToggle = true; // Show the keyboard button in the bar. Hiding it also turns the keyboard off. + bool keyboardPersist = true; // Keep the keyboard on after switching to another app and back. int barLeftMargin = 0; // Empty space left of the first bar button, in percent of the text height (keeps it clear of rounded screen corners). bool compactMenu = false; // Always use the single "Menu" dropdown (it's also chosen automatically on narrow screens). @@ -230,8 +234,16 @@ private: // Quick scroll strip: the last debugger window the user touched (ImGui window ID). ImGuiID quickScrollTarget_ = 0; + // Whether we last asked the platform to show the soft keyboard. + bool keyboardShown_ = false; + int keyboardPersistSent_ = -1; // Last "keep keyboard after app switch" value sent to the platform. }; +// Optional override for the "force keyboard" toggle. The callback is called with true to show the soft keyboard and +// false to hide it. Android already has a built-in implementation (Android_SetKeyboardForced in app-android.cpp), +// so this is only needed on other platforms. +void ImDebugger_SetSoftKeyboardHook(std::function hook); + // Simple custom controls and utilities. void ImClickableValue(const char *id, uint32_t value, ImControl &control, ImCmd cmd); void ImClickableValueFloat(const char *id, float value); diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 6b7e1538c8..bda0f747f4 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -313,6 +313,31 @@ void System_ShowKeyboard() { PushCommand("showKeyboard", ""); } +// Used by the ImDebugger "Kbd" toggle. Unlike System_ShowKeyboard, this keeps the keyboard up until turned off, +// and lets it type into the game view (see NativeSurfaceView.setKeyboardForced). +void Android_SetKeyboardForced(bool forced) { + PushCommand("forceKeyboard", forced ? "1" : "0"); +} + +// Whether the forced keyboard comes back by itself after switching to another app and returning. +void Android_SetKeyboardPersist(bool persist) { + PushCommand("keyboardPersist", persist ? "1" : "0"); +} + +// Set by NativeApp.pause(), read (and cleared) by the ImDebugger so it can drop the keyboard after an app switch. +static std::atomic g_appWasPaused{false}; + +bool Android_ConsumeAppPaused() { + return g_appWasPaused.exchange(false); +} + +// Set when the user closed the soft keyboard with the Back button, so the debugger can untick its toggle. +static std::atomic g_keyboardDismissed{false}; + +bool Android_ConsumeKeyboardDismissed() { + return g_keyboardDismissed.exchange(false); +} + void System_Vibrate(int length_ms) { char temp[32]; snprintf(temp, sizeof(temp), "%d", length_ms); @@ -919,6 +944,7 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_resume(JNIEnv *, jclass) { extern "C" void Java_org_ppsspp_ppsspp_NativeApp_pause(JNIEnv *, jclass) { INFO_LOG(Log::System, "NativeApp.pause() - begin"); + g_appWasPaused = true; AndroidAudio_Pause(g_audioState); INFO_LOG(Log::System, "NativeApp.pause() - end"); } @@ -1443,6 +1469,8 @@ extern "C" void JNICALL Java_org_ppsspp_ppsspp_NativeApp_sendMessageFromJava(JNI } else if (msg == "permission_granted") { SetPermissionStatusFromJava(prm, PERMISSION_STATUS_GRANTED); System_PostUIMessage(UIMessage::PERMISSION_GRANTED, prm); + } else if (msg == "keyboardDismissed") { + g_keyboardDismissed = true; } else if (msg == "sustained_perf_supported") { sustainedPerfSupported = true; } else if (msg == "safe_insets") { diff --git a/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java b/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java index 5e8f314a59..59b21469a2 100644 --- a/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java +++ b/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java @@ -5,11 +5,25 @@ package org.ppsspp.ppsspp; // Used by the Vulkan backend. import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; +import android.os.SystemClock; +import android.text.InputType; +import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceView; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; public class NativeSurfaceView extends SurfaceView { + // When set, the view pretends to be a text editor so Android will really show the soft keyboard for it + // (a plain SurfaceView is ignored by showSoftInput), and typed text is passed on to the native side. + private boolean keyboardForced = false; + // Bring the keyboard back by itself when the app regains focus (after an app switch). + private boolean keyboardPersist = true; + public NativeSurfaceView(Context context) { super(context); } @@ -23,4 +37,112 @@ public class NativeSurfaceView extends SurfaceView { NativeApp.processTouchEvent(ev); return true; } + + public void setKeyboardPersist(boolean persist) { + keyboardPersist = persist; + } + + // Must be called on the UI thread. + public void setKeyboardForced(boolean forced) { + keyboardForced = forced; + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) { + return; + } + if (forced) { + setFocusable(true); + setFocusableInTouchMode(true); + requestFocus(); + imm.restartInput(this); + showKeyboardSoon(); + } else { + imm.hideSoftInputFromWindow(getWindowToken(), 0); + imm.restartInput(this); + clearFocus(); + setFocusableInTouchMode(false); + setFocusable(false); + } + } + + private void showKeyboardSoon() { + post(() -> { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null && keyboardForced) { + imm.showSoftInput(this, 0); + } + }); + } + + // Android hides the keyboard when the window loses focus (app switch, dialogs). Bring it back. + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + if (hasWindowFocus && keyboardForced && keyboardPersist) { + requestFocus(); + showKeyboardSoon(); + } + } + + // Back is offered to the view before the keyboard sees it, but only while the keyboard is up. Tell the native side + // so its keyboard toggle follows (the keyboard itself is hidden by the system as usual). + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (keyboardForced && keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) { + NativeApp.sendMessageFromJava("keyboardDismissed", ""); + } + return super.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onCheckIsTextEditor() { + return keyboardForced; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + if (!keyboardForced) { + return null; + } + // TYPE_NULL asks the keyboard for plain key events instead of treating this as a text field. + outAttrs.inputType = InputType.TYPE_NULL; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_ACTION_NONE; + return new BaseInputConnection(this, false) { + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + for (int i = 0; i < text.length(); ) { + int cp = Character.codePointAt(text, i); + i += Character.charCount(cp); + if (cp == '\n') { + sendKeyPress(KeyEvent.KEYCODE_ENTER); + } else if (!Character.isISOControl(cp)) { + NativeApp.keyChar(NativeApp.DEVICE_ID_KEYBOARD, cp); + } + } + return true; + } + + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + for (int i = 0; i < beforeLength; i++) { + sendKeyPress(KeyEvent.KEYCODE_DEL); + } + for (int i = 0; i < afterLength; i++) { + sendKeyPress(KeyEvent.KEYCODE_FORWARD_DEL); + } + return true; + } + }; + } + + // Goes through the activity's normal key handling, same as a hardware keyboard would. + private void sendKeyPress(int keyCode) { + Context context = getContext(); + if (!(context instanceof Activity)) { + return; + } + Activity activity = (Activity) context; + long now = SystemClock.uptimeMillis(); + activity.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0)); + activity.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0)); + } } diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index fd2038e780..effd12aea6 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -1844,6 +1844,14 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(surfView.getWindowToken(), 0); return true; + } else if (command.equals("forceKeyboard") && mSurfaceView != null) { + // "1" = keep the soft keyboard up (and let it type into the game), "0" = hide it again. + mSurfaceView.setKeyboardForced(params.equals("1")); + return true; + } else if (command.equals("keyboardPersist") && mSurfaceView != null) { + // "1" = bring the forced keyboard back after an app switch, "0" = don't. + mSurfaceView.setKeyboardPersist(params.equals("1")); + return true; } else if (command.equals("inputbox")) { String title = "Input"; String defString = "";