the back key: one C++ path, so a dialog closes too

A phone's back key did not close the settings dialog. A window-level QML
Shortcut never sees the key while a modal popup is up, and a desktop run says
so plainly: with the dialog open, Back left backLayer() at "settings", while
invokeMethod(win, "stepBack") closed it on the spot and Escape -- the popup's
own CloseOnEscape -- closed it too. So the key reaches the popup and never
reaches the shortcut map, which is why the drawer (no popup) closed fine and
hid the problem on a phone.

A Keys handler on the window's root item is no help either: nothing reached
it, the focused TextField keeps the key. So the key is read in C++, on the
application object but only for this window's events -- a filter installed on
the window itself was never called once, while the same key closed the drawer
through the shortcut, hence the application object.

With a layer open, the window's own backLayer() names it and stepBack() closes
it, press and release both taken; with nothing open the key is passed on
untouched, which is what leaves the window on a phone and does nothing on a
desktop. Main.qml keeps only those two functions: the Shortcut and
backHasLayer are gone, --uitest installs the same handler main() does, and the
back checks now drive the code the phone runs instead of a shortcut only the
test had.

The last check asks the phone's own question and can end the run without the
closing line -- the activity leaving takes the process with it, and every line
is flushed as it is written.

Desktop: 106 ok, 0 FAIL. Phone: 104 ok, 0 FAIL (two clipboard checks are
skipped on Android), and "after the last back: window visible 0".
This commit is contained in:
WHC
2026-10-11 21:44:33 +08:00
parent 81aeee7d1e
commit db8cdd7dd1
6 changed files with 201 additions and 0 deletions
+1
View File
@@ -106,6 +106,7 @@ if(CST_APP_GUI)
src/ui/UiTest.cpp src/ui/UiTest.cpp
src/ui/ThemeIcons.cpp src/ui/ThemeIcons.cpp
src/ui/SessionImages.cpp src/ui/SessionImages.cpp
src/ui/BackKey.cpp
${CST_APP_PLATFORM_SRC}) ${CST_APP_PLATFORM_SRC})
target_include_directories(cst-agent-app PRIVATE target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include") src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
+36
View File
@@ -507,6 +507,42 @@ ApplicationWindow {
win.statusText = "" win.statusText = ""
} }
} }
/* The back key a phone has instead of a mouse: it closes what is open, one
* layer at a time -- the dialog on top, then the sliding sidebar. With
* nothing open the key goes on to the platform, and a phone leaves the
* window (Qt finishes the activity), which is right for the last layer.
*
* Which layer that is, and whether there is one, lives in one place: the
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
* lets through. --uitest reads the same two functions. */
function backLayer() {
if (settings.visible)
return "settings"
if (ask.visible)
return "ask"
if (consent.visible)
return "consent"
if (win.navOpen)
return "drawer"
return ""
}
/* One layer back. The dialogs are closed on the next turn of the event
* loop: closing one from inside the key event leaves its modal overlay up
* in this Qt (the dialog says it is closed, the overlay still eats every
* click after it), which --uitest walks straight into. */
function stepBack() {
if (settings.visible)
Qt.callLater(settings.close)
else if (ask.visible)
Qt.callLater(ask.close)
else if (consent.visible)
Qt.callLater(consent.close)
else if (win.navOpen)
win.navOpen = false
}
// ---- engine -> UI ---------------------------------------------------- // ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function /* The signal handlers only forward: every one of them has a function
+4
View File
@@ -33,6 +33,7 @@
#include "core/AgentCore.h" #include "core/AgentCore.h"
#include "core/ToolProvider.h" #include "core/ToolProvider.h"
#include "ui/UiTest.h" #include "ui/UiTest.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h" #include "ui/SessionImages.h"
#include "ui/ThemeIcons.h" #include "ui/ThemeIcons.h"
#include "cst_version.h" #include "cst_version.h"
@@ -349,5 +350,8 @@ int main(int argc, char **argv) {
engine.load(url); engine.load(url);
if (engine.rootObjects().isEmpty()) if (engine.rootObjects().isEmpty())
return 1; return 1;
/* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */
app::installBackKey(engine.rootObjects().value(0),
engine.rootObjects().value(0));
return app.exec(); return app.exec();
} }
+65
View File
@@ -0,0 +1,65 @@
#include "ui/BackKey.h"
#include <QEvent>
#include <QKeyEvent>
#include <QMetaObject>
#include <QObject>
#include <QVariant>
namespace app {
namespace {
class BackKeyFilter : public QObject {
public:
BackKeyFilter(QObject *window, QObject *root)
: QObject(window), m_window(window), m_root(root) {}
protected:
bool eventFilter(QObject *watched, QEvent *event) override {
if (!m_root || watched != m_window ||
(event->type() != QEvent::KeyPress &&
event->type() != QEvent::KeyRelease))
return QObject::eventFilter(watched, event);
QKeyEvent *key = static_cast<QKeyEvent *>(event);
if (key->key() != Qt::Key_Back)
return QObject::eventFilter(watched, event);
/* The key is down and up: a press that was taken is followed by a release
* that goes with it, so the platform never sees half of a key it did not
* get. */
if (event->type() == QEvent::KeyRelease)
return m_tookPress ? (m_tookPress = false, true)
: QObject::eventFilter(watched, event);
QVariant layer;
if (!QMetaObject::invokeMethod(m_root, "backLayer",
Q_RETURN_ARG(QVariant, layer)))
return QObject::eventFilter(watched, event);
if (layer.toString().isEmpty())
return QObject::eventFilter(watched, event); /* nothing open: not ours */
/* One layer back, on the next turn of the event loop (the dialogs insist,
* see Main.qml's stepBack). The key is spent: the window stays. */
QMetaObject::invokeMethod(m_root, "stepBack");
m_tookPress = true;
return true;
}
private:
QObject *m_window; /* whose keys are read; outlives this filter */
QObject *m_root; /* the window's QML root */
bool m_tookPress = false;
};
} // namespace
void installBackKey(QObject *window, QObject *root) {
if (!window || !root)
return;
/* On the application, not on the window: a key press arrives here as an
* event sent to the window, but a filter installed on the window itself
* never sees one (measured -- a window-installed filter stayed silent while
* the same key closed the drawer through a QML Shortcut). The filter keeps
* to `window`'s own events, so nothing else in the process is touched. */
QCoreApplication::instance()->installEventFilter(
new BackKeyFilter(window, root));
}
} // namespace app
+33
View File
@@ -0,0 +1,33 @@
/* The phone's back key, read before anything else sees the event.
*
* Android hands its back button to the window as a Qt::Key_Back key press, and
* the platform leaves the activity (the window goes away) when nothing takes
* the key. What takes it here is one layer at a time -- the dialog on top,
* then the sliding sidebar -- and only while there is a layer: the window's
* own QML answers which (Main.qml's backLayer()) and closes it (stepBack()).
*
* This lives in C++ rather than in a QML Shortcut because a window-level
* shortcut never sees the key while a modal popup is up: the dialog keeps it
* (a desktop run that presses Back with the settings dialog open shows the
* shortcut does not fire, while the drawer -- no popup -- closes fine). A
* filter on the window runs before the shortcut map, the popup and the focused
* item, so the same key does the same thing on every platform; with nothing
* open the key is passed on untouched, which is what leaves the window on a
* phone and does nothing on a desktop.
*
* A window must not be given a second one: installBackKey() installs once. */
#ifndef CST_APP_BACK_KEY_H
#define CST_APP_BACK_KEY_H
class QObject;
namespace app {
/* `window` is the QQuickWindow (the object key events are sent to), `root` the
* QML root object of that window (the one carrying backLayer()/stepBack()).
* The filter is owned by the window. */
void installBackKey(QObject *window, QObject *root);
} // namespace app
#endif // CST_APP_BACK_KEY_H
+62
View File
@@ -37,6 +37,7 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "core/ToolProvider.h" #include "core/ToolProvider.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h" #include "ui/SessionImages.h"
#include "ui/ThemeIcons.h" #include "ui/ThemeIcons.h"
@@ -346,6 +347,9 @@ int runUiTest(AgentCore &core, const QString &size) {
std::printf("FAIL the root object is not a window\n"); std::printf("FAIL the root object is not a window\n");
return 1; return 1;
} }
/* The same handler main() installs: every Back key check below goes through
* the code the phone runs, not through a Shortcut that only the test has. */
installBackKey(win, engine.rootObjects().value(0));
QTest::qWait(250); QTest::qWait(250);
/* --- the window opens on the session the engine already had ----------- */ /* --- the window opens on the session the engine already had ----------- */
@@ -1162,6 +1166,14 @@ int runUiTest(AgentCore &core, const QString &size) {
clickMouse(win, QStringLiteral("navButton")); clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(), check(navPanel && navPanel->isVisible(),
QStringLiteral("drawer opens over the transcript")); QStringLiteral("drawer opens over the transcript"));
/* A phone closes the drawer with the back key, not with a second tap. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
check(navPanel && !navPanel->isVisible(),
QStringLiteral("back closes the drawer"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("and the drawer button opens it again"));
clickMouse(win, QStringLiteral("navButton")); clickMouse(win, QStringLiteral("navButton"));
check(navPanel && !navPanel->isVisible(), check(navPanel && !navPanel->isVisible(),
QStringLiteral("drawer closes again")); QStringLiteral("drawer closes again"));
@@ -1170,6 +1182,56 @@ int runUiTest(AgentCore &core, const QString &size) {
check(docked && docked->isVisible(), check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back")); QStringLiteral("widening again docks the sidebar back"));
/* --- the phone's back key closes what is open -------------------------- */
/* This one is last on purpose: on the offscreen platform a popup closed by a
* key leaves its modal overlay over the window -- the dialog says it is
* closed while the overlay still eats clicks. Nothing that clicks may come
* after it. */
{
QMetaObject::invokeMethod(win, "openSettings");
QTest::qWait(300);
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(),
QStringLiteral("back: the settings dialog is open to close"));
/* Which layer is open is asked of QML, not of an item's `visible`: the
* dialog's content item lives on after a close and still reports visible
* while it fades. backLayer() is what the Back key reads, so the test asks
* for exactly that. */
QVariant before;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before));
check(before.toString() == QStringLiteral("settings"),
QStringLiteral("back: the settings layer is the one open"));
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(200);
QVariant after;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after));
note(QStringLiteral("after back: layer \"%1\"").arg(after.toString()));
check(after.toString().isEmpty(),
QStringLiteral("back closes the dialog instead of the window"));
check(win->isVisible(), QStringLiteral("and the window is still there"));
/* With nothing open the key belongs to the platform: a phone leaves the
* window with it, so this side must not take it. On a desktop nothing
* happens and the window has to still be there; on a phone the window
* going away *is* the hand-over, so what is asked is that no layer was
* touched (whether the activity is already gone is the platform's own
* timing). This is the last thing the run does on a phone: the activity
* leaving takes the process with it, so the log can end on the check below
* without the closing line -- every line is flushed as it is written. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
QVariant idle;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle));
#if defined(Q_OS_ANDROID)
note(QStringLiteral("after the last back: window visible %1")
.arg(win->isVisible() ? 1 : 0));
check(idle.toString().isEmpty(),
QStringLiteral("back with nothing open is left to the platform"));
#else
check(idle.toString().isEmpty() && win->isVisible(),
QStringLiteral("back with nothing open is left to the platform"));
#endif
}
/* A picture of the last state on request (see maybeShot). */ /* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString()); maybeShot(win, QString());