Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/core/backends/flex/CommandParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ QMap<QString, QString> CommandParser::parseKVs(const QString& body)
QMap<QString, QString> 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];
Expand All @@ -21,7 +28,7 @@ QMap<QString, QString> 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];
}
Expand Down
51 changes: 16 additions & 35 deletions src/gui/DvkPanel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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();
}
Expand Down
5 changes: 0 additions & 5 deletions src/gui/DvkPanel.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,6 @@ private slots:
// (#2464, #2582).
QVector<QShortcut*> m_shortcuts;

// F-key name changes collected for one announcement; a burst (slot load,
// disconnect) of more than one is not announced.
QVector<int> 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.
Expand Down
30 changes: 30 additions & 0 deletions tests/dvk_model_status_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "models/RadioModel.h"

#include <QCoreApplication>
#include <QMap>
#include <QStringList>

#include <cstdio>
Expand Down Expand Up @@ -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<QString, QString> 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(
Expand Down
64 changes: 64 additions & 0 deletions tests/dvk_panel_admission_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
#include "gui/DvkPanel.h"
#include "models/DvkModel.h"

#include <QAccessible>
#include <QApplication>
#include <QElapsedTimer>
#include <QHash>
#include <QLabel>
#include <QPushButton>
#include <QStringList>

Expand Down Expand Up @@ -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<QObject*, int> 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"));
Expand Down Expand Up @@ -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<QPushButton*>(QStringLiteral("dvkPlaySlot3"));
auto* status = panel.findChild<QLabel*>(QStringLiteral("dvkStatus"));

g_nameChanges.clear();
seed(model);
int fkeyEvents = 0;
for (int id = 1; id <= 12; ++id) {
fkeyEvents += g_nameChanges.value(
panel.findChild<QPushButton*>(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;
Expand Down
Loading