Skip to content

Commit ba6dc81

Browse files
skerkerclaudeOzy311
authored
fix(gui): share one chart timeframe across the Runtime Monitor tabs (#5496) (#5531)
## Summary Fixes #5496. Runtime Monitor now has one chart timeframe selector in the dialog header. Changing it updates the Memory chart and all four Overview charts, including charts on a non-current tab. The selector and its label hide on Threads and Logs while retaining the row height, so the tab strip stays in place. This implements the maintainer's follow-up ruling in #5427. The four ranges, five-minute default, bucket rules, and lack of timeframe persistence are unchanged. The combo retains `systemInfoTimeframe` and the accessible name “Chart timeframe”; `systemInfoOverviewTimeframe` is removed. The review follow-up also makes an empty Memory chart adopt the selected range before its first sample arrives. The static label uses a buddy association with the combo instead of repeating its explicit accessible name. ## Constitution principle honored Principle XI — Fixes Are Demonstrated. The existing socket-free dialog test exercises the production refresh connections and compares the rendered range captions of all five charts, without adding a public getter. It covers empty/populated history, all four ranges, non-current charts, selector/label visibility, and stable tab geometry. ## Validation - Native macOS arm64, Qt 6.11.1, RelWithDebInfo build and `system_info_dialog_test` pass. - The new test fails on the original PR's empty-history Memory caption; it passes with the fix. - Mutation checks: disconnect Memory refresh → 5 failures; disconnect Overview refresh → 20 failures; force the selector visible on every tab → 4 failures. All mutations were restored, then focused CTest passed. - Automation bridge, isolated settings, explicit owned socket, `DEMO-0001` only, `AETHER_AUTOMATION_NO_TX=1`: reproduced the original separate-selector mismatch using the merge-base sources, then verified shared ranges, both hidden tabs, stable tab-strip position, constrained window size, close/reopen defaults, and disconnect behavior. Every owned instance was stopped afterward. - Test registration, frozen per-PR test gate, accessibility lint for the touched dialog, and `git diff --check` pass. No test target or CI gate was added; the existing target runs in the full-suite and sanitizer workflows. No socket-owning test or synthetic firmware peer was added. The local build disables optional ASR, RADE, DFNR, specbleach, MQTT, and D-STAR; RTL-SDR is unavailable locally. Those features are outside this UI change. Linux/Windows runtime, VoiceOver speech, live radios, and sanitizer execution were not tested locally. Per-PR CI supplies the platform build checks, not full-suite or hardware evidence. Original contributor evidence: [initial demo captures](https://github.com/user-attachments/files/32016658/aethersdr-pr5531-shared-timeframe-2026-09-09.zip), [retained-row captures](https://github.com/user-attachments/files/32017857/aethersdr-pr5531-retain-row-2026-09-09.zip). Original implementation by @skerker; review fixes and verification by @Ozy311. GPT-6 Astra-High --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com>
1 parent 6cbaadf commit ba6dc81

3 files changed

Lines changed: 157 additions & 68 deletions

File tree

src/gui/SystemInfoDialog.cpp

Lines changed: 68 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <QPlainTextEdit>
1616
#include <QPushButton>
1717
#include <QSignalBlocker>
18+
#include <QSizePolicy>
1819
#include <QLocale>
1920
#include <QRegularExpression>
2021
#include <QStyledItemDelegate>
@@ -218,12 +219,65 @@ SystemInfoDialog::SystemInfoDialog(MemoryHistoryRing* history, CpuHistoryRing* c
218219
m_tickLagMeter = tickLagMeter;
219220
}
220221
auto* layout = new QVBoxLayout(bodyWidget());
222+
223+
// One timeframe for every chart in the dialog, in the window header where
224+
// the network dialog keeps its own (#5496). Two per-tab selectors with the
225+
// same four choices could disagree: a range chosen on Overview was not
226+
// the range Memory then showed. Hidden while a tab with no chart is
227+
// current — Threads has a fixed 60 s window and Logs has no time axis —
228+
// which is how the network dialog handles its Logs and TCI pages.
229+
auto* header = new QHBoxLayout;
230+
header->addStretch(1);
231+
m_rangeLabel = new QLabel(QStringLiteral("Timeframe"), bodyWidget());
232+
m_range = new QComboBox(bodyWidget());
233+
m_rangeLabel->setBuddy(m_range);
234+
m_range->setObjectName(QStringLiteral("systemInfoTimeframe"));
235+
m_range->setAccessibleName(QStringLiteral("Chart timeframe"));
236+
m_range->setAccessibleDescription(
237+
QStringLiteral("Choose how much recent history the charts display."));
238+
m_range->setFixedWidth(132);
239+
// The issue's four (#2554); the rings hold an hour raw, so nothing longer
240+
// is offered.
241+
m_range->addItem(QStringLiteral("1 minute"), 60);
242+
m_range->addItem(QStringLiteral("5 minutes"), 5 * 60);
243+
m_range->addItem(QStringLiteral("15 minutes"), 15 * 60);
244+
m_range->addItem(QStringLiteral("1 hour"), 60 * 60);
245+
m_range->setCurrentIndex(1); // 5 minutes: 200 points at 1.5 s
246+
// Hidden, not removed: the row keeps its height while Threads or Logs is
247+
// current, so the tab strip does not jump under the pointer.
248+
for (QWidget* w : {static_cast<QWidget*>(m_rangeLabel), static_cast<QWidget*>(m_range)}) {
249+
QSizePolicy policy = w->sizePolicy();
250+
policy.setRetainSizeWhenHidden(true);
251+
w->setSizePolicy(policy);
252+
}
253+
// Both refreshes, not only the current tab's: switching tabs must never
254+
// show a chart still drawn to the previous range.
255+
connect(m_range, &QComboBox::currentIndexChanged, this,
256+
&SystemInfoDialog::refreshMemoryChart);
257+
connect(m_range, &QComboBox::currentIndexChanged, this,
258+
&SystemInfoDialog::refreshOverview);
259+
header->addWidget(m_rangeLabel);
260+
header->addWidget(m_range);
261+
layout->addLayout(header);
262+
221263
auto* tabs = new QTabWidget(bodyWidget());
222264
// The issue's order: Overview / Threads / Memory / (Painters) / Logs.
223265
tabs->addTab(buildOverviewTab(), QStringLiteral("Overview"));
224-
tabs->addTab(buildThreadsTab(), QStringLiteral("Threads"));
266+
QWidget* threadsTab = buildThreadsTab();
267+
tabs->addTab(threadsTab, QStringLiteral("Threads"));
225268
tabs->addTab(buildMemoryTab(), QStringLiteral("Memory"));
226-
tabs->addTab(buildLogsTab(), QStringLiteral("Logs"));
269+
QWidget* logsTab = buildLogsTab();
270+
tabs->addTab(logsTab, QStringLiteral("Logs"));
271+
// By page, not by index: the order has changed once already (#5427 put
272+
// Overview first) and the rule is about which pages draw a chart.
273+
const auto showTimeframeForPage = [this, tabs, threadsTab, logsTab](int) {
274+
QWidget* page = tabs->currentWidget();
275+
const bool showTimeframe = page != threadsTab && page != logsTab;
276+
m_rangeLabel->setVisible(showTimeframe);
277+
m_range->setVisible(showTimeframe);
278+
};
279+
connect(tabs, &QTabWidget::currentChanged, this, showTimeframeForPage);
280+
showTimeframeForPage(tabs->currentIndex());
227281
layout->addWidget(tabs);
228282

229283
auto* buttonRow = new QHBoxLayout;
@@ -567,36 +621,14 @@ QWidget* SystemInfoDialog::buildMemoryTab()
567621
auto* page = new QWidget;
568622
auto* layout = new QVBoxLayout(page);
569623

570-
// Header row: what is being measured, and — top-right, where the network
571-
// dialog keeps its own — how much history the chart shows. The selector
572-
// lives on this tab rather than the window because Threads has a fixed
573-
// 60 s window and Logs has none; the network dialog reaches the same
574-
// outcome by hiding its combo on those pages.
624+
// Header row: what is being measured. How much history the chart shows
625+
// is the window's timeframe above the tabs, shared with Overview (#5496).
575626
auto* header = new QHBoxLayout;
576627
m_memorySummary = new QLabel(QStringLiteral("Sampling…"), page);
577628
m_memorySummary->setObjectName(QStringLiteral("systemInfoMemorySummary"));
578629
m_memorySummary->setAccessibleName(QStringLiteral("Process memory summary"));
579630
header->addWidget(m_memorySummary);
580631
header->addStretch(1);
581-
auto* rangeLabel = new QLabel(QStringLiteral("Timeframe"), page);
582-
rangeLabel->setAccessibleName(QStringLiteral("Chart timeframe"));
583-
m_memoryRange = new QComboBox(page);
584-
m_memoryRange->setObjectName(QStringLiteral("systemInfoTimeframe"));
585-
m_memoryRange->setAccessibleName(QStringLiteral("Chart timeframe"));
586-
m_memoryRange->setAccessibleDescription(
587-
QStringLiteral("Choose how much recent memory history the chart displays."));
588-
m_memoryRange->setFixedWidth(132);
589-
// The issue's four; the ring holds an hour raw, so nothing longer is offered
590-
// until the compacting history arrives with the Overview tab.
591-
m_memoryRange->addItem(QStringLiteral("1 minute"), 60);
592-
m_memoryRange->addItem(QStringLiteral("5 minutes"), 5 * 60);
593-
m_memoryRange->addItem(QStringLiteral("15 minutes"), 15 * 60);
594-
m_memoryRange->addItem(QStringLiteral("1 hour"), 60 * 60);
595-
m_memoryRange->setCurrentIndex(1); // 5 minutes: 200 points at 1.5 s
596-
connect(m_memoryRange, &QComboBox::currentIndexChanged, this,
597-
&SystemInfoDialog::refreshMemoryChart);
598-
header->addWidget(rangeLabel);
599-
header->addWidget(m_memoryRange);
600632
layout->addLayout(header);
601633

602634
// Readouts: the numbers, not only the line. Virtual is a readout only —
@@ -665,12 +697,12 @@ QWidget* SystemInfoDialog::buildMemoryTab()
665697
return page;
666698
}
667699

668-
int SystemInfoDialog::selectedMemoryRangeSeconds() const
700+
int SystemInfoDialog::selectedRangeSeconds() const
669701
{
670-
if (m_memoryRange == nullptr) {
702+
if (m_range == nullptr) {
671703
return 5 * 60;
672704
}
673-
return m_memoryRange->currentData().toInt();
705+
return m_range->currentData().toInt();
674706
}
675707

676708
void SystemInfoDialog::applyMemorySample(const MemorySample& sample)
@@ -695,6 +727,10 @@ void SystemInfoDialog::refreshMemoryChart()
695727
// reached the history cannot look as though it did.
696728
const MemoryHistoryRing::Record* latest = m_memoryRing->latest();
697729
if (latest == nullptr) {
730+
// The shared selector also applies before the first sample arrives.
731+
if (m_memoryGraph != nullptr) {
732+
m_memoryGraph->setSeries({}, selectedRangeSeconds());
733+
}
698734
return;
699735
}
700736
if (m_memorySummary != nullptr) {
@@ -732,7 +768,7 @@ void SystemInfoDialog::refreshMemoryChart()
732768
if (m_memoryGraph == nullptr) {
733769
return;
734770
}
735-
const int rangeSeconds = selectedMemoryRangeSeconds();
771+
const int rangeSeconds = selectedRangeSeconds();
736772
// The window ends at the newest sample, not at the wall clock: a dialog
737773
// whose sampling is paused shows the history it has, in place, instead of
738774
// sliding it off the left edge while nothing new arrives.
@@ -763,28 +799,7 @@ QWidget* SystemInfoDialog::buildOverviewTab()
763799
auto* page = new QWidget;
764800
auto* layout = new QVBoxLayout(page);
765801

766-
// Timeframe top-right, as on the Memory tab and in the network dialog;
767-
// per tab rather than per window for the reason the Memory tab gives.
768-
auto* header = new QHBoxLayout;
769-
header->addStretch(1);
770-
auto* rangeLabel = new QLabel(QStringLiteral("Timeframe"), page);
771-
rangeLabel->setAccessibleName(QStringLiteral("Chart timeframe"));
772-
m_overviewRange = new QComboBox(page);
773-
m_overviewRange->setObjectName(QStringLiteral("systemInfoOverviewTimeframe"));
774-
m_overviewRange->setAccessibleName(QStringLiteral("Chart timeframe"));
775-
m_overviewRange->setAccessibleDescription(
776-
QStringLiteral("Choose how much recent history the Overview charts display."));
777-
m_overviewRange->setFixedWidth(132);
778-
m_overviewRange->addItem(QStringLiteral("1 minute"), 60);
779-
m_overviewRange->addItem(QStringLiteral("5 minutes"), 5 * 60);
780-
m_overviewRange->addItem(QStringLiteral("15 minutes"), 15 * 60);
781-
m_overviewRange->addItem(QStringLiteral("1 hour"), 60 * 60);
782-
m_overviewRange->setCurrentIndex(1);
783-
connect(m_overviewRange, &QComboBox::currentIndexChanged, this,
784-
&SystemInfoDialog::refreshOverview);
785-
header->addWidget(rangeLabel);
786-
header->addWidget(m_overviewRange);
787-
layout->addLayout(header);
802+
// The charts' timeframe is the window's, above the tabs (#5496).
788803

789804
// Four cards across, the network dialog's row.
790805
auto* cards = new QHBoxLayout;
@@ -893,14 +908,6 @@ QWidget* SystemInfoDialog::buildOverviewTab()
893908
return scroll;
894909
}
895910

896-
int SystemInfoDialog::selectedOverviewRangeSeconds() const
897-
{
898-
if (m_overviewRange == nullptr) {
899-
return 5 * 60;
900-
}
901-
return m_overviewRange->currentData().toInt();
902-
}
903-
904911
void SystemInfoDialog::setCardLevel(QLabel* value, SystemInfo::CardLevel level)
905912
{
906913
if (value == nullptr) {
@@ -960,7 +967,7 @@ void SystemInfoDialog::refreshOverview()
960967
{
961968
const CpuHistoryRing::Record* cpu = m_cpuRing->latest();
962969
const MemoryHistoryRing::Record* mem = m_memoryRing->latest();
963-
const int rangeSeconds = selectedOverviewRangeSeconds();
970+
const int rangeSeconds = selectedRangeSeconds();
964971
const double gapSeconds = CpuHistoryRing::connectGapSecondsFor(rangeSeconds);
965972
ThemeManager& theme = ThemeManager::instance();
966973

src/gui/SystemInfoDialog.h

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ private slots:
9999

100100
void applyAlertStyle();
101101
void refreshMemoryChart();
102-
int selectedMemoryRangeSeconds() const;
103102
void refreshOverview();
104-
int selectedOverviewRangeSeconds() const;
103+
// The window-level timeframe both refreshes draw to (#5496).
104+
int selectedRangeSeconds() const;
105105
// Colour a card's value for its band and expose the band as the label's
106106
// "level" property ("normal" / "warning" / "danger") for tests and the
107107
// automation bridge, which read properties and not stylesheets.
@@ -160,7 +160,8 @@ private slots:
160160
CpuHistoryRing* m_cpuRing{&m_ownCpuRing};
161161
UiTickLagMeter m_ownTickLagMeter;
162162
UiTickLagMeter* m_tickLagMeter{&m_ownTickLagMeter};
163-
QComboBox* m_overviewRange{nullptr};
163+
QLabel* m_rangeLabel{nullptr};
164+
QComboBox* m_range{nullptr};
164165
QLabel* m_cardCpuValue{nullptr};
165166
QLabel* m_cardMaxThreadValue{nullptr};
166167
QLabel* m_cardMaxThreadCaption{nullptr};
@@ -171,7 +172,6 @@ private slots:
171172
TimeSeriesGraphWidget* m_overviewThreadsGraph{nullptr};
172173
TimeSeriesGraphWidget* m_overviewTickGraph{nullptr};
173174
TimeSeriesGraphWidget* m_memoryGraph{nullptr};
174-
QComboBox* m_memoryRange{nullptr};
175175
QLabel* m_memorySummary{nullptr};
176176
QLabel* m_memoryResident{nullptr};
177177
QLabel* m_memoryPeak{nullptr};

tests/system_info_dialog_test.cpp

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "core/ThemeManager.h"
1717
#include "gui/SparklineDelegate.h"
1818
#include "gui/SystemInfoDialog.h"
19+
#include "gui/TimeSeriesGraphWidget.h"
1920

2021
#include <QApplication>
2122
#include <QCheckBox>
@@ -485,15 +486,96 @@ int main(int argc, char** argv)
485486
LogManager::instance().shutdownLogging();
486487
}
487488

489+
// One selector must update every chart, including a non-current page and
490+
// an empty history. Compare the painted range captions with a reference
491+
// chart; this exercises the production slots without exposing private data.
492+
{
493+
SystemInfoDialog shared;
494+
auto* range = shared.findChild<QComboBox*>(QStringLiteral("systemInfoTimeframe"));
495+
auto* tabs = shared.findChild<QTabWidget*>();
496+
report("one shared timeframe exists outside the tab pages",
497+
range != nullptr && tabs != nullptr
498+
&& shared.findChildren<QComboBox*>().size() == 1
499+
&& !tabs->isAncestorOf(range));
500+
if (range != nullptr && tabs != nullptr) {
501+
QLabel* rangeLabel = nullptr;
502+
for (QLabel* label : shared.findChildren<QLabel*>()) {
503+
if (label->buddy() == range) {
504+
rangeLabel = label;
505+
}
506+
}
507+
report("the timeframe label is associated with its control", rangeLabel != nullptr);
508+
const auto caption = [](QWidget* graph) {
509+
const QPixmap pixels = graph->grab();
510+
const qreal scale = pixels.devicePixelRatio();
511+
return pixels.toImage().copy(pixels.width() - qRound(180 * scale),
512+
qRound(6 * scale), qRound(166 * scale), qRound(18 * scale))
513+
.convertToFormat(QImage::Format_RGB32);
514+
};
515+
const char* graphNames[] = {
516+
"systemInfoMemoryGraph", "systemInfoOverviewCpuGraph",
517+
"systemInfoOverviewMemoryGraph", "systemInfoOverviewThreadsGraph",
518+
"systemInfoOverviewTickGraph"};
519+
const auto checkCharts = [&] {
520+
for (const char* name : graphNames) {
521+
QWidget* graph = shared.findChild<QWidget*>(QLatin1String(name));
522+
report("shared chart exists", graph != nullptr);
523+
if (graph != nullptr) {
524+
const QImage actual = caption(graph);
525+
TimeSeriesGraphWidget reference{QString(), QString()};
526+
reference.setFont(graph->font());
527+
reference.resize(graph->size());
528+
reference.setSeries({}, range->currentData().toInt());
529+
report(name, actual == caption(&reference));
530+
}
531+
}
532+
};
533+
range->setCurrentIndex(3);
534+
checkCharts(); // no collector or sample has run yet
535+
MemorySample sample;
536+
sample.wallMs = 1'700'000'000'000LL;
537+
sample.valid = true;
538+
sample.residentBytes = 200ull * 1024 * 1024;
539+
report("shared chart receives a memory sample",
540+
QMetaObject::invokeMethod(&shared, "applyMemorySample", Qt::DirectConnection,
541+
Q_ARG(AetherSDR::MemorySample, sample)));
542+
for (int index : {0, 2, 1, 3}) {
543+
range->setCurrentIndex(index);
544+
checkCharts();
545+
}
546+
547+
shared.show();
548+
QCoreApplication::processEvents();
549+
const int tabY = tabs->y();
550+
for (int index = 0; index < tabs->count(); ++index) {
551+
tabs->setCurrentIndex(index);
552+
QCoreApplication::processEvents();
553+
const QString title = tabs->tabText(index);
554+
const bool charted = title == QLatin1String("Overview") || title == QLatin1String("Memory");
555+
report("timeframe visibility follows the charted page", range->isVisible() == charted);
556+
report("timeframe label visibility follows its control",
557+
rangeLabel != nullptr && rangeLabel->isVisible() == charted);
558+
report("hiding the timeframe does not move the tab strip", tabs->y() == tabY);
559+
if (charted) {
560+
range->setCurrentIndex(index == 0 ? 0 : 3);
561+
checkCharts();
562+
}
563+
}
564+
shared.hide();
565+
}
566+
}
567+
488568
// ── Memory tab (#2554 acceptance criterion 4) ──────────────────────────
489569
// applyMemorySample is a slot so the tab can be driven without a collector
490570
// or a worker thread. Bytes are CONSTRUCTED (routing and formatting only).
491571
{
492572
qRegisterMetaType<AetherSDR::MemorySample>("AetherSDR::MemorySample");
493573
SystemInfoDialog memoryDialog;
494574

575+
// The selector is the dialog's, in the window header (#5496); the
576+
// lookup on the dialog finds it there as it did on the Memory tab.
495577
auto* range = memoryDialog.findChild<QComboBox*>(QStringLiteral("systemInfoTimeframe"));
496-
report("the Memory tab has a timeframe selector", range != nullptr);
578+
report("the dialog has a timeframe selector", range != nullptr);
497579
if (range != nullptr) {
498580
report("it offers the issue's four timeframes", range->count() == 4);
499581
report("it defaults to 5 minutes", range->currentData().toInt() == 5 * 60);
@@ -659,8 +741,8 @@ int main(int argc, char** argv)
659741
{
660742
qRegisterMetaType<AetherSDR::CpuSample>("AetherSDR::CpuSample");
661743
SystemInfoDialog ov;
662-
auto* range = ov.findChild<QComboBox*>(QStringLiteral("systemInfoOverviewTimeframe"));
663-
report("the Overview tab has its own timeframe selector", range != nullptr && range->count() == 4);
744+
report("the Overview tab has no timeframe selector of its own (#5496)",
745+
ov.findChild<QComboBox*>(QStringLiteral("systemInfoOverviewTimeframe")) == nullptr);
664746
auto* cpuCard = ov.findChild<QLabel*>(QStringLiteral("systemInfoCardCpu"));
665747
auto* maxCard = ov.findChild<QLabel*>(QStringLiteral("systemInfoCardMaxThread"));
666748
auto* memCard = ov.findChild<QLabel*>(QStringLiteral("systemInfoCardMemory"));

0 commit comments

Comments
 (0)