- Auto-scroll standard aktiv

- Tag-Filter wird gespeichert
- Tag Monitor kann bei einem Tag jetzt pro Key mehrere Werte in einer Tabelle anzeigen
- Bug für Clear behoben
This commit is contained in:
2026-06-23 23:19:06 +02:00
parent 92168ee5dc
commit d3229c7b64
29 changed files with 342 additions and 380 deletions

View File

@@ -2,7 +2,6 @@
#include <QPushButton>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QGroupBox>
#include <QFileDialog>
#include <QLabel>
@@ -16,19 +15,16 @@ ConnectDialog::ConnectDialog(QWidget *parent)
form->setRowWrapPolicy(QFormLayout::DontWrapRows);
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
// ── Port ──────────────────────────────────────────────────────────────────
auto *portRow = new QHBoxLayout();
m_portCombo = new QComboBox(this);
m_portCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
auto *refreshBtn = new QPushButton(tr(""), this);
refreshBtn->setMaximumWidth(30);
refreshBtn->setToolTip(tr("Refresh port list"));
connect(refreshBtn, &QPushButton::clicked, this, &ConnectDialog::refreshPorts);
portRow->addWidget(m_portCombo, 1);
portRow->addWidget(refreshBtn);
form->addRow(tr("Port:"), portRow);
// ── Baud rate ─────────────────────────────────────────────────────────────
m_baudCombo = new QComboBox(this);
const QList<int> bauds = {1200, 2400, 4800, 9600, 19200, 38400,
57600, 115200, 230400, 460800, 921600};
@@ -37,7 +33,6 @@ ConnectDialog::ConnectDialog(QWidget *parent)
m_baudCombo->setCurrentText("115200");
form->addRow(tr("Baud rate:"), m_baudCombo);
// ── Data bits ─────────────────────────────────────────────────────────────
m_dataBitsCombo = new QComboBox(this);
m_dataBitsCombo->addItem("5", QSerialPort::Data5);
m_dataBitsCombo->addItem("6", QSerialPort::Data6);
@@ -46,7 +41,6 @@ ConnectDialog::ConnectDialog(QWidget *parent)
m_dataBitsCombo->setCurrentText("8");
form->addRow(tr("Data bits:"), m_dataBitsCombo);
// ── Parity ────────────────────────────────────────────────────────────────
m_parityCombo = new QComboBox(this);
m_parityCombo->addItem(tr("None"), QSerialPort::NoParity);
m_parityCombo->addItem(tr("Even"), QSerialPort::EvenParity);
@@ -55,21 +49,18 @@ ConnectDialog::ConnectDialog(QWidget *parent)
m_parityCombo->addItem(tr("Mark"), QSerialPort::MarkParity);
form->addRow(tr("Parity:"), m_parityCombo);
// ── Stop bits ─────────────────────────────────────────────────────────────
m_stopBitsCombo = new QComboBox(this);
m_stopBitsCombo->addItem("1", QSerialPort::OneStop);
m_stopBitsCombo->addItem("1.5", QSerialPort::OneAndHalfStop);
m_stopBitsCombo->addItem("2", QSerialPort::TwoStop);
form->addRow(tr("Stop bits:"), m_stopBitsCombo);
// ── Flow control ──────────────────────────────────────────────────────────
m_flowCombo = new QComboBox(this);
m_flowCombo->addItem(tr("None"), QSerialPort::NoFlowControl);
m_flowCombo->addItem(tr("RTS/CTS"), QSerialPort::HardwareControl);
m_flowCombo->addItem(tr("XON/XOFF"), QSerialPort::SoftwareControl);
form->addRow(tr("Flow control:"), m_flowCombo);
// ── Log file ──────────────────────────────────────────────────────────────
auto *logRow = new QHBoxLayout();
m_logPathEdit = new QLineEdit(this);
m_logPathEdit->setPlaceholderText(tr("(optional) path/to/output.log"));
@@ -80,7 +71,6 @@ ConnectDialog::ConnectDialog(QWidget *parent)
logRow->addWidget(browseBtn);
form->addRow(tr("Log file:"), logRow);
// ── Buttons ───────────────────────────────────────────────────────────────
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);

View File

@@ -1,5 +1,6 @@
#include <QApplication>
#include <QStyleFactory>
#include <QIcon>
#include "mainwindow.h"
int main(int argc, char *argv[])
@@ -9,10 +10,11 @@ int main(int argc, char *argv[])
app.setApplicationVersion("1.0.0");
app.setOrganizationName("ChicaDev");
// Prefer Fusion style for consistent cross-distro look
app.setStyle(QStyleFactory::create("Fusion"));
// Optional: dark palette (comment out if you prefer system theme)
const QIcon appIcon(":/icons/UartscopeLogo.png");
app.setWindowIcon(appIcon);
QPalette darkPalette;
darkPalette.setColor(QPalette::Window, QColor(0x2b, 0x2b, 0x2b));
darkPalette.setColor(QPalette::WindowText, QColor(0xdc, 0xdc, 0xdc));
@@ -29,10 +31,6 @@ int main(int argc, char *argv[])
darkPalette.setColor(QPalette::HighlightedText, Qt::white);
app.setPalette(darkPalette);
// Set application icon (embedded via Qt resource)
const QIcon appIcon(":/icons/UartscopeLogo.png");
app.setWindowIcon(appIcon);
MainWindow w;
w.show();
return app.exec();

View File

@@ -1,9 +1,9 @@
#include "mainwindow.h"
#include <QSettings>
#include <QApplication>
#include <QClipboard>
#include <QDialog>
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QLabel>
#include <QMessageBox>
#include <QMetaObject>
@@ -14,6 +14,7 @@
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFont>
#include <QPixmap>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
@@ -26,7 +27,6 @@ MainWindow::MainWindow(QWidget *parent)
setupStatusBar();
restoreSettings();
// ── Worker thread ─────────────────────────────────────────────────────────
m_thread = new QThread(this);
m_worker = new SerialWorker();
m_worker->moveToThread(m_thread);
@@ -46,9 +46,6 @@ MainWindow::MainWindow(QWidget *parent)
MainWindow::~MainWindow()
{
// All real cleanup happens in closeEvent(). The destructor only runs if
// the window was destroyed without a close event (e.g. programmatically),
// so we guard against double-cleanup with m_shutdownDone.
doShutdown();
}
@@ -66,11 +63,9 @@ void MainWindow::doShutdown()
saveSettings();
// 1. Stop V4L2 capture first (joins the internal std::thread)
if (m_videoWidget)
m_videoWidget->shutdown();
// 2. Stop UART serial worker
if (m_thread && m_thread->isRunning()) {
QMetaObject::invokeMethod(m_worker, "closePort", Qt::BlockingQueuedConnection);
m_thread->quit();
@@ -82,12 +77,10 @@ void MainWindow::doShutdown()
void MainWindow::setupUi()
{
// ── Outer horizontal splitter: (Raw+Table tabs) | right panel ────────────
m_mainSplitter = new QSplitter(Qt::Horizontal, this);
m_mainSplitter->setHandleWidth(5);
auto *mainSplitter = m_mainSplitter;
mainSplitter->setHandleWidth(5);
// Left: tabs for Raw and Table views
auto *tabs = new QTabWidget(mainSplitter);
tabs->setTabPosition(QTabWidget::South);
@@ -97,7 +90,6 @@ void MainWindow::setupUi()
m_tableView = new TableView(tabs);
tabs->addTab(m_tableView, tr("Table view"));
// Right: vertical splitter Tag Monitor on top, Video below
m_rightSplitter = new QSplitter(Qt::Vertical, mainSplitter);
auto *rightSplitter = m_rightSplitter;
rightSplitter->setHandleWidth(5);
@@ -110,9 +102,8 @@ void MainWindow::setupUi()
rightSplitter->addWidget(m_tagWidget);
rightSplitter->addWidget(m_videoWidget);
// Initial split: ~30% tags, ~70% video user can drag to taste
rightSplitter->setStretchFactor(0, 1);
rightSplitter->setStretchFactor(1, 2);
rightSplitter->setStretchFactor(1, 1);
rightSplitter->setSizes({330, 330});
mainSplitter->addWidget(tabs);
@@ -143,7 +134,6 @@ void MainWindow::setupToolBar()
tb->addSeparator();
// ── Auto-reconnect controls ───────────────────────────────────────────────
m_autoReconnectCb = new QCheckBox(tr("Auto-reconnect"), tb);
m_autoReconnectCb->setChecked(true);
m_autoReconnectCb->setToolTip(tr("Automatically reconnect when the port drops"));
@@ -161,7 +151,6 @@ void MainWindow::setupToolBar()
m_reconnectIntervalSb->setSingleStep(500);
m_reconnectIntervalSb->setValue(2000);
m_reconnectIntervalSb->setSuffix(tr(" ms"));
m_reconnectIntervalSb->setToolTip(tr("Interval between reconnect attempts"));
connect(m_reconnectIntervalSb, QOverload<int>::of(&QSpinBox::valueChanged),
this, [this](int ms) {
QMetaObject::invokeMethod(m_worker, [this, ms] {
@@ -172,21 +161,18 @@ void MainWindow::setupToolBar()
tb->addSeparator();
// ── Tag filter ────────────────────────────────────────────────────────────
auto *filterAction = tb->addAction(tr("Tag filter…"));
filterAction->setToolTip(tr("Choose which tags are hidden from the Raw view"));
connect(filterAction, &QAction::triggered, this, &MainWindow::configureTagFilter);
tb->addSeparator();
// ── Format reference ──────────────────────────────────────────────────────
auto *helpAction = tb->addAction(tr("Format reference"));
helpAction->setToolTip(tr("Show UARTScope output format guide (copy for AI)"));
connect(helpAction, &QAction::triggered, this, &MainWindow::showFormatReference);
tb->addSeparator();
// ── About ─────────────────────────────────────────────────────────────────
auto *aboutAction = tb->addAction(tr("Über UARTScope"));
connect(aboutAction, &QAction::triggered, this, &MainWindow::showAbout);
}
@@ -197,7 +183,7 @@ void MainWindow::setupStatusBar()
m_reconnectLabel = new QLabel(this);
m_portLabel = new QLabel(this);
m_reconnectLabel->setStyleSheet("color: #e5a50a;"); // amber
m_reconnectLabel->setStyleSheet("color: #e5a50a;");
statusBar()->addWidget(m_stateLabel);
statusBar()->addWidget(m_reconnectLabel);
@@ -223,7 +209,6 @@ void MainWindow::onConnectClicked()
Qt::QueuedConnection,
Q_ARG(QString, m_lastConfig.logFilePath));
// Push current reconnect settings to worker before opening
const bool autoRec = m_autoReconnectCb->isChecked();
const int interval = m_reconnectIntervalSb->value();
QMetaObject::invokeMethod(m_worker, [this, autoRec, interval] {
@@ -277,13 +262,13 @@ void MainWindow::onReconnecting(int attempt)
void MainWindow::onClearScreen()
{
// ANSI clear-screen received from the firmware: clear ALL three views
// (raw output, table, AND the tag monitor) plus the video preview frame.
clearAllViews();
}
void MainWindow::onError(const QString &message)
{
// Only show a dialog for errors that arrive when we are NOT in reconnect
// mode otherwise the amber status text is sufficient feedback.
if (!m_autoReconnectCb->isChecked()) {
QMessageBox::critical(this, tr("Serial error"), message);
onPortClosed();
@@ -306,6 +291,8 @@ void MainWindow::clearAllViews()
m_rawView->clear();
m_tableView->clear();
m_tagWidget->clearAll();
if (m_videoWidget)
m_videoWidget->clearFrame();
}
// ── Format reference dialog ────────────────────────────────────────────────
@@ -322,12 +309,11 @@ three parallel ways. You can use any combination.
1. RAW OUTPUT (always active)
Every line sent over UART is shown verbatim in the "Raw output" tab.
No special formatting needed.
Every line sent over UART is shown verbatim (with a timestamp) in the
"Raw output" tab. No special formatting needed.
uart_printf("Boot complete. Version 1.0\n");
ANSI clear-screen clears ALL views simultaneously:
ANSI clear-screen clears ALL views simultaneously (raw output, table,
tag monitor, and the video preview frame):
uart_printf("\033[2J\033[H"); // standard VT100 clear-screen + cursor home
@@ -336,17 +322,25 @@ ANSI clear-screen → clears ALL views simultaneously:
Any line that contains [TAGNAME] is intercepted and displayed in a
dedicated panel in the Tag Monitor. The line is still shown in Raw output
(highlighted in cyan) so nothing is hidden.
(highlighted in cyan) unless the tag is added to the Tag Filter.
TAG FORMAT:
[TAGNAME] key1=value1 key2=value2 ...
Rules:
Tag name: uppercase letters, digits, underscore. Examples: WDG, VIDEO, I2C_BUS
Tag must start the meaningful content (leading whitespace is fine)
Key=value pairs are space-separated; values must not contain spaces
If no key=value pairs are present the raw string is shown as-is
LIST VALUES: separate multiple items in one value with ';' they are
shown as a multi-line list under the same key in the Tag Monitor:
uart_printf("[KICKSTART] Devs=trackdisk.device;input.device\n");
Tag Monitor shows:
Devs trackdisk.device
input.device
Examples:
uart_printf("[WDG] uptime=%lu free_heap=%lu temp=%d\n",
uptime_ms, heap_free, cpu_temp);
@@ -354,11 +348,7 @@ Examples:
uart_printf("[VIDEO] line=%d hblank=%d vblank=%d dma_buf=%d\n",
scanline, hblank_cycles, vblank_cycles, dma_remaining);
uart_printf("[AUDIO] buf=%d underruns=%lu vol=%d\n",
audio_buf_level, underrun_count, volume);
uart_printf("[I2C] addr=0x%02X ack=%d err=%d\n",
i2c_addr, ack_received, error_code);
uart_printf("[KICKSTART] Libs=graphics.library;intuition.library;dos.library\n");
Each unique tag gets its own panel. Panels update in-place so repeated
messages don't scroll past perfect for periodic watchdog output.
@@ -390,30 +380,25 @@ No special firmware changes needed.
5. COMPLETE EXAMPLE (Chica / Amiga hardware emulation)
void uart_status_update(void) {
// Watchdog heartbeat → Tag Monitor [WDG]
uart_printf("[WDG] uptime=%lu free=%lu load=%d temp=%d\n",
HAL_GetTick(), xPortGetFreeHeapSize(),
cpu_load_percent, core_temp_c);
// Video timing → Tag Monitor [VIDEO]
uart_printf("[VIDEO] line=%d hblank=%d vblank=%d copper=%d\n",
current_scanline, hblank_ticks, vblank_ticks, copper_dma);
// Audio state → Tag Monitor [AUDIO]
uart_printf("[AUDIO] ch=%d buf=%d underruns=%lu\n",
active_channels, buffer_fill, total_underruns);
uart_printf("[KICKSTART] Devs=trackdisk.device;input.device;console.device\n");
uart_printf("[KICKSTART] Libs=graphics.library;intuition.library\n");
// Structured CSV data → Table view
uart_printf("%lu,%.1f,%d,%d\n",
HAL_GetTick(), vcc_mv / 1000.0f, cpu_load_percent, core_temp_c);
}
// Call from your main loop or a timer ISR, e.g. every 500 ms
// Clear screen from firmware when you want a fresh start:
// uart_printf("\033[2J\033[H");
)";
// ── Dialog ────────────────────────────────────────────────────────────────
auto *dlg = new QDialog(this);
dlg->setWindowTitle(tr("UARTScope Format Reference"));
dlg->resize(720, 620);
@@ -475,7 +460,6 @@ void MainWindow::configureTagFilter()
QFont mono("Monospace");
mono.setStyleHint(QFont::Monospace);
edit->setFont(mono);
// Pre-populate with current filter
QStringList current(m_suppressedTags.values());
current.sort();
edit->setPlainText(current.join('\n'));
@@ -495,66 +479,12 @@ void MainWindow::configureTagFilter()
if (!tag.isEmpty())
m_suppressedTags.insert(tag);
}
// Persist immediately so the filter survives even an unexpected exit.
saveSettings();
}
dlg->deleteLater();
}
// ── Settings: save / restore ───────────────────────────────────────────────
void MainWindow::saveSettings()
{
QSettings s("ChicaDev", "UARTScope");
// Window geometry & state
s.setValue("window/geometry", saveGeometry());
s.setValue("window/state", saveState());
// Splitter sizes
if (m_mainSplitter)
s.setValue("splitter/main", m_mainSplitter->saveState());
if (m_rightSplitter)
s.setValue("splitter/right", m_rightSplitter->saveState());
// Toolbar settings
s.setValue("reconnect/enabled", m_autoReconnectCb->isChecked());
s.setValue("reconnect/interval", m_reconnectIntervalSb->value());
// Tag filter
QStringList tags(m_suppressedTags.values());
tags.sort();
s.setValue("tagfilter/suppressed", tags);
}
void MainWindow::restoreSettings()
{
QSettings s("ChicaDev", "UARTScope");
// Window geometry & state
if (s.contains("window/geometry"))
restoreGeometry(s.value("window/geometry").toByteArray());
if (s.contains("window/state"))
restoreState(s.value("window/state").toByteArray());
// Splitter sizes (only restore if we have saved data)
if (m_mainSplitter && s.contains("splitter/main"))
m_mainSplitter->restoreState(s.value("splitter/main").toByteArray());
if (m_rightSplitter && s.contains("splitter/right"))
m_rightSplitter->restoreState(s.value("splitter/right").toByteArray());
// Toolbar settings
if (s.contains("reconnect/enabled"))
m_autoReconnectCb->setChecked(s.value("reconnect/enabled").toBool());
if (s.contains("reconnect/interval"))
m_reconnectIntervalSb->setValue(s.value("reconnect/interval").toInt());
// Tag filter
const QStringList tags = s.value("tagfilter/suppressed").toStringList();
m_suppressedTags.clear();
for (const QString &t : tags)
if (!t.isEmpty())
m_suppressedTags.insert(t);
}
// ── About dialog ──────────────────────────────────────────────────────────
void MainWindow::showAbout()
@@ -567,7 +497,6 @@ void MainWindow::showAbout()
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// ── Header block (dark accent) ────────────────────────────────────────────
auto *header = new QWidget(dlg);
header->setFixedHeight(110);
header->setStyleSheet("background-color: #1e1e1e;");
@@ -575,14 +504,12 @@ void MainWindow::showAbout()
headerLayout->setContentsMargins(20, 14, 24, 14);
headerLayout->setSpacing(16);
// Logo
auto *logoLabel = new QLabel(header);
const QPixmap logoPix(":/icons/UartscopeLogo.png");
logoLabel->setPixmap(logoPix.scaled(72, 72, Qt::KeepAspectRatio, Qt::SmoothTransformation));
logoLabel->setStyleSheet("background: transparent;");
logoLabel->setFixedSize(72, 72);
// Title + subtitle
auto *titleBlock = new QVBoxLayout();
titleBlock->setSpacing(4);
auto *titleLabel = new QLabel("UARTScope", header);
@@ -598,7 +525,6 @@ void MainWindow::showAbout()
headerLayout->addLayout(titleBlock);
root->addWidget(header);
// ── Info grid ─────────────────────────────────────────────────────────────
auto *body = new QWidget(dlg);
body->setStyleSheet("background-color: #252526;");
auto *grid = new QGridLayout(body);
@@ -628,7 +554,6 @@ void MainWindow::showAbout()
root->addWidget(body, 1);
// ── Footer with close button ──────────────────────────────────────────────
auto *footer = new QWidget(dlg);
footer->setStyleSheet("background-color: #252526; border-top: 1px solid #3c3c3c;");
auto *footerLayout = new QHBoxLayout(footer);
@@ -646,3 +571,54 @@ void MainWindow::showAbout()
dlg->exec();
dlg->deleteLater();
}
// ── Settings: save / restore ───────────────────────────────────────────────
void MainWindow::saveSettings()
{
QSettings s("ChicaDev", "UARTScope");
s.setValue("window/geometry", saveGeometry());
s.setValue("window/state", saveState());
if (m_mainSplitter)
s.setValue("splitter/main", m_mainSplitter->saveState());
if (m_rightSplitter)
s.setValue("splitter/right", m_rightSplitter->saveState());
s.setValue("reconnect/enabled", m_autoReconnectCb->isChecked());
s.setValue("reconnect/interval", m_reconnectIntervalSb->value());
QStringList tags(m_suppressedTags.values());
tags.sort();
s.setValue("tagfilter/suppressed", tags);
s.sync(); // flush to disk immediately (important since we also call
// this mid-session from configureTagFilter())
}
void MainWindow::restoreSettings()
{
QSettings s("ChicaDev", "UARTScope");
if (s.contains("window/geometry"))
restoreGeometry(s.value("window/geometry").toByteArray());
if (s.contains("window/state"))
restoreState(s.value("window/state").toByteArray());
if (m_mainSplitter && s.contains("splitter/main"))
m_mainSplitter->restoreState(s.value("splitter/main").toByteArray());
if (m_rightSplitter && s.contains("splitter/right"))
m_rightSplitter->restoreState(s.value("splitter/right").toByteArray());
if (s.contains("reconnect/enabled"))
m_autoReconnectCb->setChecked(s.value("reconnect/enabled").toBool());
if (s.contains("reconnect/interval"))
m_reconnectIntervalSb->setValue(s.value("reconnect/interval").toInt());
const QStringList tags = s.value("tagfilter/suppressed").toStringList();
m_suppressedTags.clear();
for (const QString &t : tags)
if (!t.isEmpty())
m_suppressedTags.insert(t);
}

View File

@@ -24,7 +24,6 @@ void RawView::setupUi()
mainLayout->setContentsMargins(4, 4, 4, 4);
mainLayout->setSpacing(4);
// ── Toolbar ──────────────────────────────────────────────────────────────
auto *toolbar = new QHBoxLayout();
m_searchEdit = new QLineEdit(this);
@@ -33,7 +32,7 @@ void RawView::setupUi()
connect(m_searchEdit, &QLineEdit::textChanged, this, &RawView::onSearch);
m_autoScrollCb = new QCheckBox(tr("Auto-scroll"), this);
m_autoScrollCb->setChecked(true);
m_autoScrollCb->setChecked(true); // default ON
connect(m_autoScrollCb, &QCheckBox::toggled, this, &RawView::onAutoScrollToggled);
m_lineCountLbl = new QLabel(tr("Lines: 0"), this);
@@ -54,11 +53,10 @@ void RawView::setupUi()
toolbar->addWidget(m_clearBtn);
mainLayout->addLayout(toolbar);
// ── Text area ─────────────────────────────────────────────────────────────
m_textEdit = new QPlainTextEdit(this);
m_textEdit->setReadOnly(true);
m_textEdit->setLineWrapMode(QPlainTextEdit::NoWrap);
m_textEdit->setMaximumBlockCount(0); // unlimited
m_textEdit->setMaximumBlockCount(0);
QFont monoFont("Monospace");
monoFont.setStyleHint(QFont::Monospace);
@@ -84,39 +82,27 @@ void RawView::applyColorScheme()
void RawView::appendLine(const QString &line, const QSet<QString> &suppressedTags)
{
// Check whether this line belongs to a suppressed tag
if (!suppressedTags.isEmpty()) {
const auto match = s_tagRe.match(line);
if (match.hasMatch()) {
if (suppressedTags.contains(match.captured(1).toUpper()))
return; // silently drop it goes to the Tag Monitor only
}
if (match.hasMatch() && suppressedTags.contains(match.captured(1).toUpper()))
return;
}
++m_lineCount;
m_lineCountLbl->setText(tr("Lines: %1").arg(m_lineCount));
// Prepend timestamp
const QString ts = QDateTime::currentDateTime().toString("hh:mm:ss.zzz");
const QString displayLine = QStringLiteral("[%1] %2").arg(ts, line);
// Colour tag lines cyan, everything else default
const bool hasTag = s_tagRe.match(line).hasMatch();
QTextCursor cursor(m_textEdit->document());
cursor.movePosition(QTextCursor::End);
QTextCharFormat fmt;
if (hasTag)
fmt.setForeground(QColor(0x56, 0xb6, 0xc2)); // cyan
else
fmt.setForeground(QColor(0xd4, 0xd4, 0xd4)); // default
// Dim the timestamp portion
QTextCharFormat tsFmt;
tsFmt.setForeground(QColor(0x60, 0x60, 0x60));
cursor.insertText(QStringLiteral("[%1] ").arg(ts), tsFmt);
QTextCharFormat fmt;
fmt.setForeground(hasTag ? QColor(0x56, 0xb6, 0xc2) : QColor(0xd4, 0xd4, 0xd4));
cursor.insertText(line + '\n', fmt);
if (m_autoScroll)

View File

@@ -10,8 +10,8 @@ SerialWorker::SerialWorker(QObject *parent)
m_reconnectTimer->setSingleShot(true);
connect(m_reconnectTimer, &QTimer::timeout, this, &SerialWorker::tryReconnect);
connect(m_port, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead);
connect(m_port, &QSerialPort::errorOccurred, this, &SerialWorker::onPortError);
connect(m_port, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead);
connect(m_port, &QSerialPort::errorOccurred, this, &SerialWorker::onPortError);
}
SerialWorker::~SerialWorker()
@@ -20,7 +20,7 @@ SerialWorker::~SerialWorker()
closePort();
}
// ── Public slots ────────────────────────────────────────────────────────────
// ── public slots ──────────────────────────────────────────────────────────────
void SerialWorker::openPort(const QString &portName, qint32 baudRate,
QSerialPort::DataBits dataBits,
@@ -28,7 +28,6 @@ void SerialWorker::openPort(const QString &portName, qint32 baudRate,
QSerialPort::StopBits stopBits,
QSerialPort::FlowControl flowControl)
{
// Save config for auto-reconnect
m_portName = portName;
m_baudRate = baudRate;
m_dataBits = dataBits;
@@ -55,6 +54,7 @@ void SerialWorker::openPort(const QString &portName, qint32 baudRate,
return;
}
m_buffer.clear();
m_scanTail.clear();
m_reconnectAttempt = 0;
emit portOpened();
}
@@ -107,7 +107,7 @@ void SerialWorker::stopLogging()
}
}
// ── Private slots ───────────────────────────────────────────────────────────
// ── private slots ───────────────────────────────────────────────────────────
void SerialWorker::onReadyRead()
{
@@ -119,7 +119,6 @@ void SerialWorker::onPortError(QSerialPort::SerialPortError err)
if (err == QSerialPort::NoError)
return;
// ResourceError = device physically disconnected (USB pull, power loss)
const bool fatal = (err == QSerialPort::ResourceError ||
err == QSerialPort::DeviceNotFoundError);
@@ -132,8 +131,6 @@ void SerialWorker::onPortError(QSerialPort::SerialPortError err)
emit errorOccurred(m_port->errorString());
}
}
// Non-fatal errors (framing, parity, etc.) are silently ignored to avoid
// spamming the user during noisy serial sessions.
}
void SerialWorker::tryReconnect()
@@ -153,6 +150,7 @@ void SerialWorker::tryReconnect()
if (m_port->open(QIODevice::ReadOnly)) {
m_buffer.clear();
m_scanTail.clear();
m_reconnectAttempt = 0;
emit portOpened();
} else {
@@ -160,7 +158,7 @@ void SerialWorker::tryReconnect()
}
}
// ── Private helpers ─────────────────────────────────────────────────────────
// ── private helpers ─────────────────────────────────────────────────────────
void SerialWorker::scheduleReconnect()
{
@@ -170,23 +168,48 @@ void SerialWorker::scheduleReconnect()
void SerialWorker::processRawData(const QByteArray &data)
{
// Scan raw bytes for ANSI clear-screen BEFORE UTF-8 conversion so we
// never miss a sequence that straddles a read boundary.
// We look for ESC[2J (optionally followed by ESC[H or ESC[1;1H)
// ESC = 0x1B
const QByteArray esc2j("\x1b[2J");
if (data.contains(esc2j)) {
emit clearScreen();
// Strip all ANSI escape sequences from the data before further processing
// so they don't clutter the raw view.
// ── ANSI clear-screen detection ────────────────────────────────────────
// We scan a buffer that includes a small tail from the PREVIOUS chunk so
// that a sequence split across two readyRead() calls (e.g. "...\x1b" +
// "[2J\x1b[H...") is still detected reliably. The clear sequence itself
// is also stripped from what reaches processLine()/the line buffer.
QByteArray combined = m_scanTail + data;
bool sawClear = false;
// Look for ESC[2J (the actual screen-clear command). ESC[H / ESC[1;1H
// (cursor-home) commonly follows but isn't required for us to clear.
const QByteArray clearSeq("\x1b[2J");
int clearPos = combined.indexOf(clearSeq);
while (clearPos != -1) {
sawClear = true;
// Remove the matched sequence so it doesn't leak into the text stream
combined.remove(clearPos, clearSeq.size());
clearPos = combined.indexOf(clearSeq, clearPos);
}
// Strip all ANSI escape sequences for display
// Sequence: ESC [ <params> <letter>
// Keep the last few bytes as the new tail (in case a sequence is cut off
// at the very end of this chunk); only forward the rest.
constexpr int kTailLen = 8; // longer than any escape sequence we match
QByteArray toProcess;
if (combined.size() > kTailLen) {
toProcess = combined.left(combined.size() - kTailLen);
m_scanTail = combined.right(kTailLen);
} else {
// Not enough data yet to safely flush hold everything as tail.
m_scanTail = combined;
}
if (sawClear)
emit clearScreen();
if (toProcess.isEmpty())
return;
// Strip remaining (non-clear) ANSI escape sequences for clean display.
static const QRegularExpression ansiRe(
"\x1b(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])");
QString text = QString::fromUtf8(data);
QString text = QString::fromUtf8(toProcess);
text.remove(ansiRe);
m_buffer += text;

View File

@@ -10,7 +10,6 @@ TableView::TableView(QWidget *parent)
mainLayout->setContentsMargins(4, 4, 4, 4);
mainLayout->setSpacing(4);
// ── Toolbar ──────────────────────────────────────────────────────────────
auto *toolbar = new QHBoxLayout();
m_delimCombo = new QComboBox(this);
@@ -44,7 +43,6 @@ TableView::TableView(QWidget *parent)
toolbar->addWidget(m_clearBtn);
mainLayout->addLayout(toolbar);
// ── Table ─────────────────────────────────────────────────────────────────
m_table = new QTableWidget(this);
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setSelectionBehavior(QAbstractItemView::SelectRows);
@@ -66,7 +64,6 @@ void TableView::onDelimiterChanged(int index)
void TableView::onMaxRowsChanged()
{
m_maxRows = m_maxRowsEdit->text().toInt();
// Trim excess rows
while (m_rows.size() > m_maxRows)
m_rows.removeFirst();
rebuildTable();
@@ -87,14 +84,13 @@ void TableView::appendLine(const QString &line)
if (line.isEmpty())
return;
// Header row starts with '#'
if (!m_headersSet && line.startsWith('#')) {
parseHeaders(line.mid(1)); // strip leading '#'
parseHeaders(line.mid(1));
return;
}
if (!tryAppendRow(line))
return; // didn't match expected column count
return;
if (m_autoScroll)
m_table->scrollToBottom();
@@ -114,7 +110,6 @@ bool TableView::tryAppendRow(const QString &line)
{
const QStringList cells = line.split(m_delimiter);
// Auto-detect column count on the first data row if no header was given
if (!m_headersSet) {
m_headers.clear();
for (int i = 0; i < cells.size(); ++i)
@@ -125,9 +120,8 @@ bool TableView::tryAppendRow(const QString &line)
}
if (cells.size() != m_headers.size())
return false; // column mismatch skip row
return false;
// Enforce max rows
m_rows.append(cells);
if (m_rows.size() > m_maxRows)
m_rows.removeFirst();
@@ -140,7 +134,6 @@ bool TableView::tryAppendRow(const QString &line)
m_table->setItem(row, col, item);
}
// Remove oldest visual row if over limit
if (m_table->rowCount() > m_maxRows)
m_table->removeRow(0);

View File

@@ -12,25 +12,22 @@ TagPanel::TagPanel(const QString &tag, QWidget *parent)
layout->setContentsMargins(6, 14, 6, 6);
layout->setSpacing(4);
// ── Table for key=value pairs ─────────────────────────────────────────────
m_table = new QTableWidget(0, 2, this);
m_table->setHorizontalHeaderLabels({tr("Key"), tr("Value")});
m_table->horizontalHeader()->setStretchLastSection(true);
m_table->verticalHeader()->hide();
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setAlternatingRowColors(true);
m_table->setMaximumHeight(200);
m_table->setMaximumHeight(220);
m_table->verticalHeader()->setDefaultSectionSize(22);
layout->addWidget(m_table);
// ── Fallback label for non-kv data ────────────────────────────────────────
m_rawLabel = new QLabel(this);
m_rawLabel->setWordWrap(true);
m_rawLabel->setStyleSheet("font-family: monospace;");
m_rawLabel->hide();
layout->addWidget(m_rawLabel);
// ── Copy button ───────────────────────────────────────────────────────────
auto *btnRow = new QHBoxLayout();
btnRow->addStretch();
auto *copyBtn = new QPushButton(tr("📋 Copy"), this);
@@ -61,10 +58,59 @@ void TagPanel::parseKeyValue(const QString &value)
auto it = kvRe.globalMatch(value);
while (it.hasNext()) {
const auto match = it.next();
ensureRow(match.captured(1));
const int row = m_keys.indexOf(match.captured(1));
m_table->item(row, 1)->setText(match.captured(2));
m_table->item(row, 1)->setBackground(QColor(0x2d, 0x5a, 0x2d)); // flash green
const QString key = match.captured(1);
const QString raw = match.captured(2);
// Split on ';' -> list of values. A plain (non-list) value yields
// a QStringList with exactly one element, which still goes through
// the same code path for consistency.
QStringList values = raw.split(';', Qt::SkipEmptyParts);
if (values.isEmpty())
values << raw; // preserve even an empty/odd value as-is
setKeyValues(key, values);
}
}
void TagPanel::setKeyValues(const QString &key, const QStringList &values)
{
const int newCount = values.size();
if (m_keyRanges.contains(key)) {
// Key already exists remove its current rows, then re-insert at
// the same starting position with the new row count.
const KeyRange old = m_keyRanges[key];
for (int i = 0; i < old.count; ++i)
m_table->removeRow(old.firstRow);
// Shift all OTHER key ranges that started after this one
for (auto it = m_keyRanges.begin(); it != m_keyRanges.end(); ++it) {
if (it.key() != key && it->firstRow > old.firstRow)
it->firstRow += (newCount - old.count);
}
int insertAt = old.firstRow;
for (int i = 0; i < newCount; ++i) {
m_table->insertRow(insertAt + i);
auto *keyItem = new QTableWidgetItem(i == 0 ? key : QString());
auto *valItem = new QTableWidgetItem(values[i]);
valItem->setBackground(QColor(0x2d, 0x5a, 0x2d)); // flash green on update
m_table->setItem(insertAt + i, 0, keyItem);
m_table->setItem(insertAt + i, 1, valItem);
}
m_keyRanges[key] = {insertAt, newCount};
} else {
// New key append at the bottom
const int insertAt = m_table->rowCount();
for (int i = 0; i < newCount; ++i) {
m_table->insertRow(insertAt + i);
auto *keyItem = new QTableWidgetItem(i == 0 ? key : QString());
auto *valItem = new QTableWidgetItem(values[i]);
valItem->setBackground(QColor(0x2d, 0x5a, 0x2d));
m_table->setItem(insertAt + i, 0, keyItem);
m_table->setItem(insertAt + i, 1, valItem);
}
m_keyRanges[key] = {insertAt, newCount};
}
}
@@ -73,27 +119,22 @@ void TagPanel::showRaw(const QString &value)
m_rawLabel->setText(value);
}
void TagPanel::ensureRow(const QString &key)
{
if (m_keys.contains(key))
return;
m_keys.append(key);
const int row = m_table->rowCount();
m_table->insertRow(row);
m_table->setItem(row, 0, new QTableWidgetItem(key));
m_table->setItem(row, 1, new QTableWidgetItem(QString()));
}
void TagPanel::copyToClipboard()
{
QStringList lines;
lines << QStringLiteral("[%1]").arg(m_tag);
if (m_table->isVisible()) {
QString lastKey;
for (int r = 0; r < m_table->rowCount(); ++r) {
const QString key = m_table->item(r, 0)->text();
const QString val = m_table->item(r, 1)->text();
lines << QStringLiteral(" %1 = %2").arg(key, val);
if (!key.isEmpty()) {
lines << QStringLiteral(" %1 = %2").arg(key, val);
lastKey = key;
} else {
lines << QStringLiteral(" %1").arg(val); // continuation row
}
}
} else {
lines << QStringLiteral(" %1").arg(m_rawLabel->text());

View File

@@ -7,7 +7,6 @@ TagWidget::TagWidget(QWidget *parent)
outerLayout->setContentsMargins(4, 4, 4, 4);
outerLayout->setSpacing(4);
// Header row
auto *headerLayout = new QHBoxLayout();
auto *titleLabel = new QLabel(tr("<b>Tag Monitor</b>"), this);
auto *clearBtn = new QPushButton(tr("Clear all"), this);
@@ -18,7 +17,6 @@ TagWidget::TagWidget(QWidget *parent)
headerLayout->addWidget(clearBtn);
outerLayout->addLayout(headerLayout);
// Scrollable panel area
auto *scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
@@ -26,7 +24,7 @@ TagWidget::TagWidget(QWidget *parent)
m_panelLayout = new QVBoxLayout(container);
m_panelLayout->setContentsMargins(0, 0, 0, 0);
m_panelLayout->setSpacing(6);
m_panelLayout->addStretch(); // pushes panels to the top
m_panelLayout->addStretch();
scrollArea->setWidget(container);
outerLayout->addWidget(scrollArea, 1);
}
@@ -36,7 +34,6 @@ void TagWidget::handleTag(const QString &tag, const QString &value)
if (!m_panels.contains(tag)) {
auto *panel = new TagPanel(tag, this);
m_panels.insert(tag, panel);
// Insert before the stretch at the end
m_panelLayout->insertWidget(m_panelLayout->count() - 1, panel);
}
m_panels[tag]->update(value);

View File

@@ -10,7 +10,6 @@
#include <QBuffer>
#include <QImageReader>
#include <QThread>
static int xioctl(int fd, unsigned long req, void *arg)
{
@@ -19,16 +18,9 @@ static int xioctl(int fd, unsigned long req, void *arg)
return r;
}
V4l2Worker::V4l2Worker(QObject *parent)
: QObject(parent)
{}
V4l2Worker::V4l2Worker(QObject *parent) : QObject(parent) {}
V4l2Worker::~V4l2Worker()
{
stop();
}
// ── public slots ──────────────────────────────────────────────────────────────
V4l2Worker::~V4l2Worker() { stop(); }
void V4l2Worker::startCapture(const QString &device, int width, int height)
{
@@ -39,29 +31,19 @@ void V4l2Worker::startCapture(const QString &device, int width, int height)
return;
m_running.store(true);
// Run the blocking capture loop in a plain std::thread so the Qt event
// loop of this worker object stays responsive to stopCapture() signals.
m_captureThread = std::thread([this] { captureLoop(); });
}
void V4l2Worker::stopCapture()
{
stop();
}
// ── internal stop (safe to call from any thread) ──────────────────────────────
void V4l2Worker::stopCapture() { stop(); }
void V4l2Worker::stop()
{
m_running.store(false);
if (m_captureThread.joinable())
m_captureThread.join(); // wait for captureLoop() to exit
m_captureThread.join();
closeDevice();
}
// ── capture loop (runs in m_captureThread) ────────────────────────────────────
void V4l2Worker::captureLoop()
{
v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
@@ -75,7 +57,7 @@ void V4l2Worker::captureLoop()
fd_set fds;
FD_ZERO(&fds);
FD_SET(m_fd, &fds);
timeval tv{0, 100000}; // 100 ms timeout → checks m_running 10x/sec
timeval tv{0, 100000};
const int r = ::select(m_fd + 1, &fds, nullptr, nullptr, &tv);
if (r == 0) continue;
if (r == -1) {
@@ -108,8 +90,6 @@ void V4l2Worker::captureLoop()
emit captureStopped();
}
// ── device open / close ───────────────────────────────────────────────────────
bool V4l2Worker::openDevice(const QString &device, int width, int height)
{
m_width = width;
@@ -134,10 +114,7 @@ bool V4l2Worker::openDevice(const QString &device, int width, int height)
}
const uint32_t preferred[] = {
V4L2_PIX_FMT_MJPEG,
V4L2_PIX_FMT_YUYV,
V4L2_PIX_FMT_NV12,
0
V4L2_PIX_FMT_MJPEG, V4L2_PIX_FMT_YUYV, V4L2_PIX_FMT_NV12, 0
};
m_pixFmt = 0;

View File

@@ -8,7 +8,6 @@
#include <QMetaObject>
#include <QResizeEvent>
// Enumerate /dev/video* entries
static QStringList enumerateVideoDevices()
{
QStringList devices;
@@ -19,8 +18,6 @@ static QStringList enumerateVideoDevices()
return devices;
}
// ── ctor / dtor ───────────────────────────────────────────────────────────────
VideoWidget::VideoWidget(QWidget *parent)
: QWidget(parent)
{
@@ -32,20 +29,20 @@ VideoWidget::VideoWidget(QWidget *parent)
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
connect(m_worker, &V4l2Worker::newFrame, this, &VideoWidget::onNewFrame,
connect(m_worker, &V4l2Worker::newFrame, this, &VideoWidget::onNewFrame,
Qt::QueuedConnection);
connect(m_worker, &V4l2Worker::captureStarted, this, [this](const QString &dev, int w, int h, const QString &fmt) {
connect(m_worker, &V4l2Worker::captureStarted, this, [this](const QString &dev, int w, int h, const QString &fmt) {
m_infoLabel->setText(tr("%1 %2×%3 %4").arg(dev).arg(w).arg(h).arg(fmt));
m_running = true;
m_startBtn->setText(tr("■ Stop"));
});
connect(m_worker, &V4l2Worker::captureStopped, this, [this] {
connect(m_worker, &V4l2Worker::captureStopped, this, [this] {
m_running = false;
m_startBtn->setText(tr("▶ Start"));
m_infoLabel->setText(tr("Stopped"));
update();
});
connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) {
connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) {
m_infoLabel->setText(tr("Error: %1").arg(msg));
m_running = false;
m_startBtn->setText(tr("▶ Start"));
@@ -60,8 +57,6 @@ void VideoWidget::shutdown()
return;
m_shutdownDone = true;
// stopCapture() sets m_running=false and joins the std::thread internally.
// Must happen before we quit the Qt event thread.
m_worker->stopCapture();
if (m_thread && m_thread->isRunning()) {
@@ -77,8 +72,6 @@ VideoWidget::~VideoWidget()
shutdown();
}
// ── UI setup ─────────────────────────────────────────────────────────────────
void VideoWidget::setupUi()
{
setMinimumHeight(140);
@@ -87,7 +80,6 @@ void VideoWidget::setupUi()
layout->setContentsMargins(2, 2, 2, 2);
layout->setSpacing(3);
// ── Controls row ──────────────────────────────────────────────────────────
auto *ctrlRow = new QHBoxLayout();
ctrlRow->setSpacing(4);
@@ -97,7 +89,6 @@ void VideoWidget::setupUi()
auto *refreshBtn = new QPushButton("", this);
refreshBtn->setMaximumWidth(26);
refreshBtn->setToolTip(tr("Refresh device list"));
connect(refreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshDeviceList);
m_startBtn = new QPushButton(tr("▶ Start"), this);
@@ -111,7 +102,6 @@ void VideoWidget::setupUi()
m_screenshotBtn = new QPushButton(tr("📷"), this);
m_screenshotBtn->setMaximumWidth(32);
m_screenshotBtn->setToolTip(tr("Save screenshot (PNG/JPG)"));
connect(m_screenshotBtn, &QPushButton::clicked, this, &VideoWidget::onScreenshot);
ctrlRow->addWidget(m_deviceCombo, 1);
@@ -121,24 +111,17 @@ void VideoWidget::setupUi()
ctrlRow->addWidget(m_screenshotBtn);
layout->addLayout(ctrlRow);
// ── Info label ────────────────────────────────────────────────────────────
m_infoLabel = new QLabel(tr("No device started"), this);
m_infoLabel->setStyleSheet("color: gray; font-size: 9px;");
layout->addWidget(m_infoLabel);
// The video frame is drawn directly in paintEvent no QLabel needed.
// We leave stretch space for it.
layout->addStretch(1);
}
// ── Slots ─────────────────────────────────────────────────────────────────────
void VideoWidget::onStartStop()
{
if (m_running)
stopCapture();
else
startCapture();
if (m_running) stopCapture();
else startCapture();
}
void VideoWidget::onFreeze(bool frozen)
@@ -172,7 +155,14 @@ void VideoWidget::onNewFrame(const QImage &frame)
return;
m_frame = frame;
updateScaled();
update(); // trigger paintEvent
update();
}
void VideoWidget::clearFrame()
{
m_frame = QImage();
m_scaled = QImage();
update();
}
void VideoWidget::refreshDeviceList()
@@ -182,14 +172,11 @@ void VideoWidget::refreshDeviceList()
const auto devices = enumerateVideoDevices();
for (const auto &d : devices)
m_deviceCombo->addItem(d);
// Restore previous selection if still present
const int idx = m_deviceCombo->findText(current);
if (idx >= 0)
m_deviceCombo->setCurrentIndex(idx);
}
// ── Capture start/stop ────────────────────────────────────────────────────────
void VideoWidget::startCapture()
{
const QString dev = m_deviceCombo->currentText();
@@ -197,7 +184,6 @@ void VideoWidget::startCapture()
return;
m_frame = QImage();
m_scaled = QImage();
// startCapture blocks in the worker thread until stopCapture() is called
QMetaObject::invokeMethod(m_worker, "startCapture",
Qt::QueuedConnection,
Q_ARG(QString, dev),
@@ -210,11 +196,8 @@ void VideoWidget::stopCapture()
QMetaObject::invokeMethod(m_worker, "stopCapture", Qt::QueuedConnection);
}
// ── Paint ─────────────────────────────────────────────────────────────────────
void VideoWidget::updateScaled()
{
// Video area = widget height minus the two control rows (~55 px)
const QRect videoRect(0, 55, width(), height() - 55);
if (videoRect.width() < 10 || videoRect.height() < 10 || m_frame.isNull())
return;
@@ -226,8 +209,6 @@ void VideoWidget::updateScaled()
void VideoWidget::paintEvent(QPaintEvent *)
{
QPainter p(this);
// Background
p.fillRect(rect(), QColor(0x10, 0x10, 0x10));
if (m_scaled.isNull()) {
@@ -237,14 +218,12 @@ void VideoWidget::paintEvent(QPaintEvent *)
return;
}
// Centre the scaled frame in the video area
const QRect videoRect(0, 55, width(), height() - 55);
const QPoint origin(
videoRect.left() + (videoRect.width() - m_scaled.width()) / 2,
videoRect.top() + (videoRect.height() - m_scaled.height()) / 2);
p.drawImage(origin, m_scaled);
// Frozen overlay
if (m_frozen) {
p.setPen(QColor(0xff, 0xcc, 0x00));
p.setFont(QFont("Sans", 9, QFont::Bold));