From ff2989f6e4ec9a4fb369873c0e9295e4969c3812 Mon Sep 17 00:00:00 2001 From: rpnesseling Date: Fri, 5 Jun 2026 01:50:05 +0200 Subject: [PATCH] qt: add optional on-screen software keyboard --- src/citra_qt/applets/swkbd.cpp | 193 ++++++++++++++++-- src/citra_qt/applets/swkbd.h | 34 ++- src/citra_qt/configuration/config.cpp | 2 + .../configuration/configure_input.cpp | 4 + src/citra_qt/configuration/configure_input.ui | 7 + src/citra_qt/uisettings.h | 2 + 6 files changed, 227 insertions(+), 15 deletions(-) diff --git a/src/citra_qt/applets/swkbd.cpp b/src/citra_qt/applets/swkbd.cpp index 44e6c5738..7dc60d141 100644 --- a/src/citra_qt/applets/swkbd.cpp +++ b/src/citra_qt/applets/swkbd.cpp @@ -2,14 +2,44 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. +#include #include #include +#include +#include #include #include #include +#include #include #include #include "citra_qt/applets/swkbd.h" +#include "citra_qt/uisettings.h" + +namespace { +QString GetValidationErrorMessage(Frontend::ValidationError error, int max_text_length) { + using namespace Frontend; + const std::unordered_map validation_error_messages = { + {ValidationError::FixedLengthRequired, + QObject::tr("Text length is not correct (should be %1 characters)").arg(max_text_length)}, + {ValidationError::MaxLengthExceeded, + QObject::tr("Text is too long (should be no more than %1 characters)") + .arg(max_text_length)}, + {ValidationError::BlankInputNotAllowed, QObject::tr("Blank input is not allowed")}, + {ValidationError::EmptyInputNotAllowed, QObject::tr("Empty input is not allowed")}, + }; + const auto message = validation_error_messages.find(error); + return message == validation_error_messages.end() + ? QObject::tr("Input does not match the requested software keyboard format") + : message->second; +} + +void ShowValidationError(QWidget* parent, Frontend::ValidationError error, int max_text_length) { + QMessageBox::critical(parent, QObject::tr("Validation error"), + GetValidationErrorMessage(error, max_text_length)); +} + +} // Anonymous namespace QtKeyboardValidator::QtKeyboardValidator(QtKeyboard* keyboard_) : keyboard(keyboard_) {} @@ -73,7 +103,7 @@ QtKeyboardDialog::QtKeyboardDialog(QWidget* parent, QtKeyboard* keyboard_) void QtKeyboardDialog::Submit() { auto error = keyboard->ValidateInput(line_edit->text().toStdString()); if (error != Frontend::ValidationError::None) { - HandleValidationError(error); + ShowValidationError(this, error, keyboard->config.max_text_length); } else { button = keyboard->ok_id; text = line_edit->text(); @@ -81,21 +111,139 @@ void QtKeyboardDialog::Submit() { } } -void QtKeyboardDialog::HandleValidationError(Frontend::ValidationError error) { - using namespace Frontend; - const std::unordered_map VALIDATION_ERROR_MESSAGES = { - {ValidationError::FixedLengthRequired, - tr("Text length is not correct (should be %1 characters)") - .arg(keyboard->config.max_text_length)}, - {ValidationError::MaxLengthExceeded, - tr("Text is too long (should be no more than %1 characters)") - .arg(keyboard->config.max_text_length)}, - {ValidationError::BlankInputNotAllowed, tr("Blank input is not allowed")}, - {ValidationError::EmptyInputNotAllowed, tr("Empty input is not allowed")}, +QtSoftwareKeyboardDialog::QtSoftwareKeyboardDialog(QWidget* parent, QtKeyboard* keyboard_) + : QDialog(parent), keyboard(keyboard_) { + setWindowTitle(tr("Software Keyboard")); + setMinimumWidth(560); + + auto* const layout = new QVBoxLayout; + auto* const label = new QLabel(QString::fromStdString(keyboard->config.hint_text)); + line_edit = new QLineEdit; + line_edit->setMinimumHeight(34); + line_edit->setValidator(new QtKeyboardValidator(keyboard)); + length_label = new QLabel; + validation_label = new QLabel; + validation_label->setStyleSheet(QStringLiteral("color: palette(highlight);")); + validation_label->setVisible(false); + + auto* const keys = new QGridLayout; + const QString rows[] = { + QStringLiteral("1234567890"), + QStringLiteral("QWERTYUIOP"), + QStringLiteral("ASDFGHJKL"), + QStringLiteral("ZXCVBNM"), }; - QMessageBox::critical(this, tr("Validation error"), VALIDATION_ERROR_MESSAGES.at(error)); + + for (int row = 0; row < std::size(rows); ++row) { + for (int column = 0; column < rows[row].size(); ++column) { + const QString value = rows[row].mid(column, 1); + auto* const button = new QPushButton(value); + button->setMinimumHeight(42); + button->setFocusPolicy(Qt::NoFocus); + if (value.front().isLetter()) { + letter_buttons.push_back(button); + } + connect(button, &QPushButton::clicked, this, [this, button] { + AppendText(button->text()); + }); + keys->addWidget(button, row, column); + } + } + + auto* const controls = new QHBoxLayout; + shift_button = new QPushButton(tr("Shift")); + shift_button->setCheckable(true); + shift_button->setChecked(uppercase); + auto* const space = new QPushButton(tr("Space")); + auto* const backspace = new QPushButton(tr("Backspace")); + auto* const ok = new QPushButton(tr(Frontend::SWKBD_BUTTON_OKAY)); + auto* const cancel = new QPushButton(tr(Frontend::SWKBD_BUTTON_CANCEL)); + + for (auto* const button : {shift_button, space, backspace, ok, cancel}) { + button->setMinimumHeight(42); + button->setFocusPolicy(Qt::NoFocus); + } + + connect(shift_button, &QPushButton::clicked, this, [this] { ToggleCase(); }); + connect(space, &QPushButton::clicked, this, [this] { AppendText(QStringLiteral(" ")); }); + connect(backspace, &QPushButton::clicked, this, [this] { Backspace(); }); + connect(ok, &QPushButton::clicked, this, [this] { Submit(); }); + connect(cancel, &QPushButton::clicked, this, [this] { Cancel(); }); + + controls->addWidget(shift_button); + controls->addWidget(space); + controls->addWidget(backspace); + controls->addWidget(ok); + controls->addWidget(cancel); + + layout->addWidget(label); + layout->addWidget(line_edit); + layout->addWidget(length_label); + layout->addWidget(validation_label); + layout->addLayout(keys); + layout->addLayout(controls); + setLayout(layout); + UpdateLengthLabel(); + line_edit->setFocus(); } +void QtSoftwareKeyboardDialog::AppendText(const QString& value) { + QString next = line_edit->text(); + const int cursor_position = line_edit->cursorPosition(); + next.insert(cursor_position, value); + if (next.size() > keyboard->config.max_text_length) { + return; + } + line_edit->setText(next); + line_edit->setCursorPosition(cursor_position + value.size()); + UpdateLengthLabel(); + ClearValidationError(); +} + +void QtSoftwareKeyboardDialog::Backspace() { + line_edit->backspace(); + UpdateLengthLabel(); + ClearValidationError(); +} + +void QtSoftwareKeyboardDialog::Cancel() { + button = QtKeyboard::cancel_id; + accept(); +} + +void QtSoftwareKeyboardDialog::ToggleCase() { + uppercase = !uppercase; + for (auto* const button : letter_buttons) { + button->setText(uppercase ? button->text().toUpper() : button->text().toLower()); + } + shift_button->setChecked(uppercase); +} + +void QtSoftwareKeyboardDialog::UpdateLengthLabel() { + length_label->setText( + tr("%1 / %2").arg(line_edit->text().size()).arg(keyboard->config.max_text_length)); +} + +void QtSoftwareKeyboardDialog::Submit() { + auto error = keyboard->ValidateInput(line_edit->text().toStdString()); + if (error != Frontend::ValidationError::None) { + ShowInlineValidationError(error); + } else { + button = keyboard->ok_id; + text = line_edit->text(); + accept(); + } +} + +void QtSoftwareKeyboardDialog::ShowInlineValidationError(Frontend::ValidationError error) { + validation_label->setText(GetValidationErrorMessage(error, keyboard->config.max_text_length)); + validation_label->setVisible(true); +} + +void QtSoftwareKeyboardDialog::ClearValidationError() { + validation_label->clear(); + validation_label->setVisible(false); +} QtKeyboard::QtKeyboard(QWidget& parent_) : parent(parent_) {} void QtKeyboard::Execute(const Frontend::KeyboardConfig& config) { @@ -103,7 +251,11 @@ void QtKeyboard::Execute(const Frontend::KeyboardConfig& config) { if (this->config.button_config != Frontend::ButtonConfig::None) { ok_id = static_cast(this->config.button_config); } - QMetaObject::invokeMethod(this, "OpenInputDialog", Qt::BlockingQueuedConnection); + if (UISettings::values.use_on_screen_software_keyboard.GetValue()) { + QMetaObject::invokeMethod(this, "OpenSoftwareKeyboardDialog", Qt::BlockingQueuedConnection); + } else { + QMetaObject::invokeMethod(this, "OpenInputDialog", Qt::BlockingQueuedConnection); + } Finalize(result_text, result_button); } @@ -126,6 +278,19 @@ void QtKeyboard::OpenInputDialog() { result_button); } +void QtKeyboard::OpenSoftwareKeyboardDialog() { + QtSoftwareKeyboardDialog dialog(&parent, this); + dialog.setWindowFlags(dialog.windowFlags() & + ~(Qt::WindowCloseButtonHint | Qt::WindowContextHelpButtonHint)); + dialog.setWindowModality(Qt::WindowModal); + dialog.exec(); + + result_text = dialog.text.toStdString(); + result_button = dialog.button; + LOG_INFO(Frontend, "SWKBD software keyboard dialog finished, text={}, button={}", result_text, + result_button); +} + void QtKeyboard::ShowErrorDialog(QString message) { QMessageBox::critical(&parent, tr("Software Keyboard"), message); } diff --git a/src/citra_qt/applets/swkbd.h b/src/citra_qt/applets/swkbd.h index 95ed95844..ae43f97d2 100644 --- a/src/citra_qt/applets/swkbd.h +++ b/src/citra_qt/applets/swkbd.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include "core/frontend/applets/swkbd.h" @@ -11,6 +12,7 @@ class QDialogButtonBox; class QLabel; class QLineEdit; +class QPushButton; class QVBoxLayout; class QtKeyboard; @@ -31,7 +33,6 @@ public: void Submit(); private: - void HandleValidationError(Frontend::ValidationError error); QDialogButtonBox* buttons; QLabel* label; QLineEdit* line_edit; @@ -43,6 +44,35 @@ private: friend class QtKeyboard; }; +class QtSoftwareKeyboardDialog final : public QDialog { + Q_OBJECT + +public: + QtSoftwareKeyboardDialog(QWidget* parent, QtKeyboard* keyboard); + +private: + void AppendText(const QString& value); + void Backspace(); + void Cancel(); + void Submit(); + void ToggleCase(); + void UpdateLengthLabel(); + void ShowInlineValidationError(Frontend::ValidationError error); + void ClearValidationError(); + + QLineEdit* line_edit; + QLabel* length_label; + QLabel* validation_label; + QPushButton* shift_button; + QtKeyboard* keyboard; + QString text; + u8 button; + std::vector letter_buttons; + bool uppercase = true; + + friend class QtKeyboard; +}; + class QtKeyboard final : public QObject, public Frontend::SoftwareKeyboard { Q_OBJECT @@ -53,6 +83,7 @@ public: private: Q_INVOKABLE void OpenInputDialog(); + Q_INVOKABLE void OpenSoftwareKeyboardDialog(); Q_INVOKABLE void ShowErrorDialog(QString message); /// Index of the buttons @@ -66,5 +97,6 @@ private: int result_button; friend class QtKeyboardDialog; + friend class QtSoftwareKeyboardDialog; friend class QtKeyboardValidator; }; diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index 9091d734b..0cfd39658 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -858,6 +858,7 @@ void QtConfig::ReadUIValues() { ReadBasicSetting(UISettings::values.pause_when_in_background); ReadBasicSetting(UISettings::values.mute_when_in_background); ReadBasicSetting(UISettings::values.hide_mouse); + ReadBasicSetting(UISettings::values.use_on_screen_software_keyboard); } qt_config->endGroup(); @@ -1391,6 +1392,7 @@ void QtConfig::SaveUIValues() { WriteBasicSetting(UISettings::values.pause_when_in_background); WriteBasicSetting(UISettings::values.mute_when_in_background); WriteBasicSetting(UISettings::values.hide_mouse); + WriteBasicSetting(UISettings::values.use_on_screen_software_keyboard); } qt_config->endGroup(); diff --git a/src/citra_qt/configuration/configure_input.cpp b/src/citra_qt/configuration/configure_input.cpp index 2cc319968..5409c886e 100644 --- a/src/citra_qt/configuration/configure_input.cpp +++ b/src/citra_qt/configuration/configure_input.cpp @@ -15,6 +15,7 @@ #include "citra_qt/configuration/config.h" #include "citra_qt/configuration/configure_input.h" #include "citra_qt/configuration/configure_motion_touch.h" +#include "citra_qt/uisettings.h" #include "common/param_package.h" #include "core/core.h" #include "ui_configure_input.h" @@ -422,6 +423,7 @@ ConfigureInput::~ConfigureInput() = default; void ConfigureInput::ApplyConfiguration() { Settings::values.use_artic_base_controller = ui->use_artic_controller->isChecked(); + UISettings::values.use_on_screen_software_keyboard = ui->use_onscreen_keyboard->isChecked(); std::transform(buttons_param.begin(), buttons_param.end(), Settings::values.current_input_profile.buttons.begin(), @@ -470,6 +472,8 @@ void ConfigureInput::LoadConfiguration() { ui->use_artic_controller->setChecked(Settings::values.use_artic_base_controller.GetValue()); ui->use_artic_controller->setEnabled(!system.IsPoweredOn()); + ui->use_onscreen_keyboard->setChecked( + UISettings::values.use_on_screen_software_keyboard.GetValue()); std::transform(Settings::values.current_input_profile.buttons.begin(), Settings::values.current_input_profile.buttons.end(), buttons_param.begin(), diff --git a/src/citra_qt/configuration/configure_input.ui b/src/citra_qt/configuration/configure_input.ui index 67cc7688f..39a50c331 100644 --- a/src/citra_qt/configuration/configure_input.ui +++ b/src/citra_qt/configuration/configure_input.ui @@ -1057,6 +1057,13 @@ + + + + Use on-screen software keyboard + + + diff --git a/src/citra_qt/uisettings.h b/src/citra_qt/uisettings.h index ef87b3fe3..93ee6a324 100644 --- a/src/citra_qt/uisettings.h +++ b/src/citra_qt/uisettings.h @@ -89,6 +89,8 @@ struct Values { Settings::Setting pause_when_in_background{false, "pauseWhenInBackground"}; Settings::Setting mute_when_in_background{false, "muteWhenInBackground"}; Settings::Setting hide_mouse{false, "hideInactiveMouse"}; + Settings::Setting use_on_screen_software_keyboard{false, + "useOnScreenSoftwareKeyboard"}; #ifdef ENABLE_QT_UPDATE_CHECKER Settings::Setting check_for_update_on_start{true, "check_for_update_on_start"}; Settings::Setting update_check_channel{UpdateCheckChannels::STABLE,