diff --git a/src/core/backends/flex/CommandParser.cpp b/src/core/backends/flex/CommandParser.cpp index f6bf6d3abd..e2159ab17a 100644 --- a/src/core/backends/flex/CommandParser.cpp +++ b/src/core/backends/flex/CommandParser.cpp @@ -10,9 +10,16 @@ QMap CommandParser::parseKVs(const QString& body) QMap result; // Body may look like: "freq=14.225000 mode=USB filter_lo=-1500 filter_hi=1500" // A quoted value may contain spaces (`dvk … name="CQ Contest"`) and is kept - // whole, quotes included, up to the token that closes it. A quote left - // open (no closing token before the next `key="` or the end of the line) - // splits as before, so a malformed value cannot swallow the keys after it. + // whole, quotes included, up to the token that closes it: the next token + // holding a quote, unless that token opens another quoted field + // (`key="text…`). A token whose only quote is its last character always + // closes, so a name may end in '=' (`name="CQ a="`); on a malformed line + // that same shape (`label="` alone) is read as the close. A quote left + // open splits as before, so it cannot swallow the keys after it. + const auto closesQuotedValue = [](const QString& token) { + return !token.contains(QLatin1String("=\"")) + || (token.endsWith(QLatin1Char('"')) && token.count(QLatin1Char('"')) == 1); + }; const QStringList tokens = body.split(' ', Qt::SkipEmptyParts); for (qsizetype i = 0; i < tokens.size(); ++i) { QString token = tokens[i]; @@ -21,7 +28,7 @@ QMap CommandParser::parseKVs(const QString& body) while (end < tokens.size() && !tokens[end].contains('"')) { ++end; } - if (end < tokens.size() && !tokens[end].contains(QLatin1String("=\""))) { + if (end < tokens.size() && closesQuotedValue(tokens[end])) { for (qsizetype j = i + 1; j <= end; ++j) { token += QLatin1Char(' ') + tokens[j]; } diff --git a/src/gui/DvkPanel.cpp b/src/gui/DvkPanel.cpp index fcb29f4dbe..97438d7b52 100644 --- a/src/gui/DvkPanel.cpp +++ b/src/gui/DvkPanel.cpp @@ -119,6 +119,10 @@ DvkPanel::DvkPanel(DvkModel* model, QWidget* parent) statusRow->addWidget(m_statusDot, 0, Qt::AlignTop); m_statusLabel = new QLabel("Idle"); m_statusLabel->setObjectName(QStringLiteral("dvkStatus")); + // A QLabel with no accessible name announces every setText(). This one's + // name carries the last announced state, so the 10 Hz elapsed-time text is + // silent and each state change is one NameChanged (announceStatus). + m_statusLabel->setAccessibleName(QStringLiteral("Idle")); m_statusLabel->setWordWrap(true); m_statusLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); // Two lines are reserved so a wrapped refusal never shifts the slots. @@ -359,11 +363,6 @@ DvkPanel::DvkPanel(DvkModel* model, QWidget* parent) stopActiveOperation(); }); - m_announceTimer = new QTimer(this); - m_announceTimer->setSingleShot(true); - m_announceTimer->setInterval(150); - connect(m_announceTimer, &QTimer::timeout, this, &DvkPanel::flushSlotAnnouncement); - // Elapsed timer for recording/playback/preview progress m_elapsedTimer = new QTimer(this); m_elapsedTimer->setInterval(100); @@ -397,36 +396,20 @@ void DvkPanel::updateSlotAccessibility(int id, const QString& name, int duration { const int idx = id - 1; const QString length = durationMs > 0 ? formatDuration(durationMs) : QStringLiteral("empty"); - m_rowFrames[idx]->setAccessibleName(QString("Slot %1: %2, %3").arg(id).arg(name, length)); + // setAccessibleName() emits NameChanged itself, so names are set only when + // they change: a rename is one event, and a reload of unchanged slots none. + const auto setName = [](QWidget* widget, const QString& name) { + if (widget->accessibleName() != name) { + widget->setAccessibleName(name); + } + }; + setName(m_rowFrames[idx], QString("Slot %1: %2, %3").arg(id).arg(name, length)); auto* play = m_fkeyBtns[idx]; - const QString playName = QString("Play slot %1: %2").arg(id).arg(name); - const bool changed = play->accessibleName() != playName; - play->setAccessibleName(playName); + setName(play, QString("Play slot %1: %2").arg(id).arg(name)); play->setAccessibleDescription(durationMs > 0 ? QString("%1 recording. Transmits on the transmit slice (F%2).").arg(length).arg(id) : QStringLiteral("Empty slot, nothing to play.")); - // Announce a real change the operator can see, one slot at a time. A - // load or reset changes many slots at once; flushSlotAnnouncement() stays - // silent for those instead of reading a dozen names back to back. - if (changed && m_announceTimer) { - if (!m_announceSlots.contains(id)) { - m_announceSlots.append(id); - } - m_announceTimer->start(); - } -} - -void DvkPanel::flushSlotAnnouncement() -{ - if (m_announceSlots.size() == 1) { - QPushButton* play = m_fkeyBtns[m_announceSlots.first() - 1]; - if (play->isVisible()) { - QAccessibleEvent ev(play, QAccessible::NameChanged); - QAccessible::updateAccessibility(&ev); - } - } - m_announceSlots.clear(); } void DvkPanel::announceStatus(const QString& text, bool error) @@ -436,8 +419,7 @@ void DvkPanel::announceStatus(const QString& text, bool error) return; } m_statusLabel->setText(text); - QAccessibleEvent ev(m_statusLabel, QAccessible::NameChanged); - QAccessible::updateAccessibility(&ev); + m_statusLabel->setAccessibleName(text); // emits the one NameChanged } void DvkPanel::togglePlayback(int id) @@ -563,11 +545,10 @@ void DvkPanel::onStatusChanged(int status, int id) m_elapsedTimer->start(); // Announce the new operation once; the 100 ms tick that follows - // only rewrites the elapsed time and stays silent. + // only rewrites the visible elapsed time. setStyleProperty(m_statusLabel, "tone", QString()); onElapsedTick(); - QAccessibleEvent ev(m_statusLabel, QAccessible::NameChanged); - QAccessible::updateAccessibility(&ev); + m_statusLabel->setAccessibleName(m_statusLabel->text()); } else { onElapsedTick(); } diff --git a/src/gui/DvkPanel.h b/src/gui/DvkPanel.h index 6e2e1e3a43..56c3b8b4f5 100644 --- a/src/gui/DvkPanel.h +++ b/src/gui/DvkPanel.h @@ -70,11 +70,6 @@ private slots: // (#2464, #2582). QVector m_shortcuts; - // F-key name changes collected for one announcement; a burst (slot load, - // disconnect) of more than one is not announced. - QVector m_announceSlots; - QTimer* m_announceTimer{nullptr}; - void flushSlotAnnouncement(); void selectSlot(int id); // Row and F-key names carry the slot's name and length for screen readers. diff --git a/tests/dvk_model_status_test.cpp b/tests/dvk_model_status_test.cpp index 736daf0b7b..28b9e212b3 100644 --- a/tests/dvk_model_status_test.cpp +++ b/tests/dvk_model_status_test.cpp @@ -7,6 +7,7 @@ #include "models/RadioModel.h" #include +#include #include #include @@ -100,6 +101,35 @@ int main(int argc, char* argv[]) && kvs.value("name") == QStringLiteral("\"Solo\"") && kvs.contains("removed") && kvs.value("removed").isEmpty()); } + { + const QStringList names{QStringLiteral("CQ a="), QStringLiteral("CQ ="), + QStringLiteral("CQ a=b"), QStringLiteral("CQ a= b=")}; + for (const QString& name : names) { + const QString body = QStringLiteral("id=1 name=\"%1\" duration=1000").arg(name); + const QMap kvs = CommandParser::parseKVs(body); + const QByteArray parsed = + QStringLiteral("equals signs inside the quoted name '%1' stay whole").arg(name).toUtf8(); + report(parsed.constData(), + kvs.value("name") == QStringLiteral("\"%1\"").arg(name) + && kvs.value("duration") == QStringLiteral("1000") && kvs.size() == 3); + + DvkModel model; + feed(model, QStringLiteral("S629BA1D4|dvk added %1").arg(body)); + const QByteArray modelled = + QStringLiteral("a DVK status keeps the name '%1' and its duration").arg(name).toUtf8(); + report(modelled.constData(), nameOf(model, 1) == name && durationOf(model, 1) == 1000); + } + } + { + // The one shape the closing rule cannot tell apart: on a malformed line, + // a `key="` token with nothing after its quote reads as the close (it is + // exactly how a valid name ending in '=' looks). Keys after it survive. + const auto kvs = CommandParser::parseKVs( + QStringLiteral("name=\"abc label=\" x y\" baz=2")); + report("a lone trailing-quote token closes an open value; later keys survive", + kvs.value("name") == QStringLiteral("\"abc label=\"") + && kvs.value("baz") == QStringLiteral("2")); + } { // Review #6247: an unterminated value must not swallow a later quoted key. const auto kvs = CommandParser::parseKVs( diff --git a/tests/dvk_panel_admission_test.cpp b/tests/dvk_panel_admission_test.cpp index 2492007ae5..8d0a6998ad 100644 --- a/tests/dvk_panel_admission_test.cpp +++ b/tests/dvk_panel_admission_test.cpp @@ -7,7 +7,11 @@ #include "gui/DvkPanel.h" #include "models/DvkModel.h" +#include #include +#include +#include +#include #include #include @@ -35,6 +39,24 @@ void feed(DvkModel& model, const QString& line) model.applyStatus(msg.object, msg.kvs); } +// NameChanged events per widget, through Qt's accessibility update hook. +QHash g_nameChanges; +void countNameChanges(QAccessibleEvent* event) +{ + if (event->type() == QAccessible::NameChanged) { + ++g_nameChanges[event->object()]; + } +} + +void spin(int ms) +{ + QElapsedTimer t; + t.start(); + while (t.elapsed() < ms) { + QApplication::processEvents(QEventLoop::AllEvents, 20); + } +} + void seed(DvkModel& model) { feed(model, QStringLiteral("S1|dvk status=idle enabled=1")); @@ -101,6 +123,48 @@ int main(int argc, char** argv) sent == QStringList{QStringLiteral("dvk rec_start id=1")}); } + { + // Review #6262: setAccessibleName() and QLabel::setText() emit their own + // NameChanged. The panel adds none of its own, so each real change is + // one event, unchanged reloads are none, and the elapsed tick is silent. + QAccessible::setActive(true); + QAccessible::installUpdateHandler(countNameChanges); + DvkModel model; + DvkPanel panel(&model); + panel.show(); + auto* f3 = panel.findChild(QStringLiteral("dvkPlaySlot3")); + auto* status = panel.findChild(QStringLiteral("dvkStatus")); + + g_nameChanges.clear(); + seed(model); + int fkeyEvents = 0; + for (int id = 1; id <= 12; ++id) { + fkeyEvents += g_nameChanges.value( + panel.findChild(QStringLiteral("dvkPlaySlot%1").arg(id))); + } + report("loading slots whose names did not change emits no F-key NameChanged", + fkeyEvents == 0); + + g_nameChanges.clear(); + feed(model, QStringLiteral("S1|dvk id=3 name=\"CQ Contest\" duration=4210")); + spin(300); // past any deferred announcement + report("a rename emits exactly one NameChanged on its F-key", + g_nameChanges.value(f3) == 1); + + g_nameChanges.clear(); + feed(model, QStringLiteral("S1|dvk status=preview id=3 enabled=1")); + spin(550); // five elapsed-time ticks + report("a state change is one status NameChanged; the elapsed tick adds none", + g_nameChanges.value(status) == 1); + + g_nameChanges.clear(); + feed(model, QStringLiteral("S0|dvk status=idle enabled=1")); + model.reset(); + report("disconnect announces the state once and only the renamed slot's F-key", + g_nameChanges.value(status) == 1 && g_nameChanges.value(f3) == 1); + QAccessible::installUpdateHandler(nullptr); + } + { // The bridge refuses controls marked as keying TX. DvkModel model;