From 46e036469289f1f27f8861325635fd625a312cb5 Mon Sep 17 00:00:00 2001 From: Fred Klassen Date: Fri, 11 Sep 2026 19:56:29 -0700 Subject: [PATCH] Port Android tablet control layout Adapt the large-screen control layout from QK4-Android PR #3 onto the v1.0.5 phone baseline. Preserve the current compact phone UX, digital modes, macro state refresh, and user-controlled waterfall behavior. --- CMakeLists.txt | 2 + src/mainwindow.cpp | 465 ++++++++++++++++++++++++++---- src/mainwindow.h | 2 + src/ui/adjustoverlay.cpp | 186 ++++++++++++ src/ui/adjustoverlay.h | 74 +++++ src/ui/baloverlay.cpp | 37 ++- src/ui/baloverlay.h | 9 + src/ui/bottommenubar.cpp | 73 +++-- src/ui/dualcontrolbutton.cpp | 34 ++- src/ui/dualcontrolbutton.h | 1 + src/ui/filterindicatorwidget.cpp | 106 ++++++- src/ui/filterindicatorwidget.h | 14 + src/ui/frequencydisplaywidget.cpp | 98 ++++++- src/ui/frequencydisplaywidget.h | 25 ++ src/ui/k4styles.cpp | 52 +++- src/ui/k4styles.h | 4 +- src/ui/monoverlay.cpp | 38 ++- src/ui/monoverlay.h | 9 + src/ui/optionsdialog.cpp | 28 +- src/ui/rightsidepanel.cpp | 125 ++++++-- src/ui/rightsidepanel.h | 19 ++ src/ui/sidecontrolpanel.cpp | 303 ++++++++++++++++--- src/ui/sidecontrolpanel.h | 35 ++- src/ui/txmeterwidget.cpp | 37 ++- src/ui/txmeterwidget.h | 10 +- src/ui/vforowwidget.cpp | 55 +++- src/ui/vforowwidget.h | 16 + src/ui/vfowidget.cpp | 10 + 28 files changed, 1662 insertions(+), 205 deletions(-) create mode 100644 src/ui/adjustoverlay.cpp create mode 100644 src/ui/adjustoverlay.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c1aed6c..048a0a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -226,6 +226,7 @@ set(SOURCES src/ui/notificationwidget.cpp src/ui/k4styles.cpp src/ui/k4popupbase.cpp + src/ui/adjustoverlay.cpp src/ui/kpa1500panel.cpp src/ui/kpa1500window.cpp src/ui/sidecontroloverlay.cpp @@ -318,6 +319,7 @@ set(HEADERS src/ui/notificationwidget.h src/ui/k4styles.h src/ui/k4popupbase.h + src/ui/adjustoverlay.h src/ui/inwindowpopup.h src/ui/inwindowdialog.h src/ui/kpa1500panel.h diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2a6d025..b879991 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -89,6 +89,8 @@ #include #include #include +#include +#include #include #include #include @@ -96,7 +98,7 @@ #include #include #include -#ifdef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) #include #endif @@ -256,6 +258,33 @@ QString temperatureStyle(int celsius) { // changing over; do not make transmission depend on a CAT state echo. constexpr int SstvKeyUpGuardMs = 500; constexpr int SstvDrainMarginMs = 150; +// Horizontal QSlider that jumps to the tapped position (groove-tap-to-set), +// so a touch anywhere on the track moves the handle there rather than +// page-stepping. Used for the RIT/XIT offset slider. +class TouchSlider final : public QSlider { +public: + using QSlider::QSlider; + +protected: + void mousePressEvent(QMouseEvent *event) override { + setValueFromX(event->pos().x()); + event->accept(); + } + void mouseMoveEvent(QMouseEvent *event) override { + if (event->buttons() & Qt::LeftButton) { + setValueFromX(event->pos().x()); + event->accept(); + } + } + +private: + void setValueFromX(int x) { + const int handleWidth = qMax(12, height() / 2); + const int span = qMax(1, width() - handleWidth); + const int pos = qBound(0, x - handleWidth / 2, span); + setValue(QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, invertedAppearance())); + } +}; } // namespace // Convert K4 tuning step index (VT command, 0-5) to Hz @@ -290,7 +319,7 @@ static int getNextSpanDown(int currentSpan) { return qMax(newSpan, SPAN_MIN); } -#ifdef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) static bool ensureMicrophonePermission(QWidget *parent) { QMicrophonePermission permission; Qt::PermissionStatus status = qApp->checkPermission(permission); @@ -483,7 +512,11 @@ MainWindow::MainWindow(QWidget *parent) // prevents the RHI backing store from being set up correctly, causing // "QRhiWidget: No QRhi" errors and blank panadapter display. setupUi(); + // iOS is a touch app with on-screen controls (Settings via the bottom-bar + // gear); the desktop File/Tools/View/Help menu bar only wastes a row. +#ifndef Q_OS_IOS setupMenuBar(); +#endif connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { @@ -1736,6 +1769,12 @@ MainWindow::MainWindow(QWidget *parent) // Also updates DIV indicator since DIV requires SUB to be on // Also dims VFO B frequency and mode labels when SUB RX is off connect(m_radioState, &RadioState::subRxEnabledChanged, this, [this](bool enabled) { + // Drive the right-panel SUB / DIVERSITY LEDs (SUB/DIV were removed from + // the centre VFO area; the radio shows them as LEDs here). + if (m_rightSidePanel) { + m_rightSidePanel->setSubActive(enabled); + m_rightSidePanel->setDiversityActive(enabled && m_radioState->diversityEnabled()); + } if (enabled) { m_subLabel->setStyleSheet(QString("background-color: %1;" "color: black;" @@ -1792,6 +1831,8 @@ MainWindow::MainWindow(QWidget *parent) connect(m_radioState, &RadioState::diversityChanged, this, [this](bool enabled) { // DIV only shows green if both diversity is enabled AND sub RX is enabled bool showActive = enabled && m_radioState->subReceiverEnabled(); + if (m_rightSidePanel) + m_rightSidePanel->setDiversityActive(showActive); if (showActive) { m_divLabel->setStyleSheet(QString("background-color: %1;" "color: black;" @@ -1860,6 +1901,8 @@ MainWindow::MainWindow(QWidget *parent) connect(m_radioState, &RadioState::filterBandwidthBChanged, this, updateFilterDisplay); connect(m_radioState, &RadioState::ifShiftBChanged, this, updateFilterDisplay); connect(m_radioState, &RadioState::bSetChanged, this, updateFilterDisplay); + // Mode and DATA sub-mode change the valid BW/SHFT ranges (e.g. FSK is + // 150-800 Hz), so refresh the control ranges when they change too. connect(m_radioState, &RadioState::modeChanged, this, updateFilterDisplay); connect(m_radioState, &RadioState::modeBChanged, this, updateFilterDisplay); connect(m_radioState, &RadioState::dataSubModeChanged, this, updateFilterDisplay); @@ -1920,11 +1963,17 @@ MainWindow::MainWindow(QWidget *parent) [this](int bw) { m_filterBWidget->setBandwidth(bw); }); connect(m_radioState, &RadioState::ifShiftChanged, this, [this](int shift) { m_filterAWidget->setShift(shift); }); connect(m_radioState, &RadioState::ifShiftBChanged, this, [this](int shift) { m_filterBWidget->setShift(shift); }); - // Mode affects filter indicator shift center calculation + // Mode affects the filter indicator (shift centre, and FSK/AFSK draws two + // peaks). Use the full mode string so the DATA sub-mode (FSK/AFSK/PSK) is + // reflected, and refresh when the sub-mode alone changes. connect(m_radioState, &RadioState::modeChanged, this, - [this](RadioState::Mode mode) { m_filterAWidget->setMode(RadioState::modeToString(mode)); }); + [this](RadioState::Mode) { m_filterAWidget->setMode(m_radioState->modeStringFull()); }); connect(m_radioState, &RadioState::modeBChanged, this, - [this](RadioState::Mode mode) { m_filterBWidget->setMode(RadioState::modeToString(mode)); }); + [this](RadioState::Mode) { m_filterBWidget->setMode(m_radioState->modeStringFullB()); }); + connect(m_radioState, &RadioState::dataSubModeChanged, this, + [this](int) { m_filterAWidget->setMode(m_radioState->modeStringFull()); }); + connect(m_radioState, &RadioState::dataSubModeBChanged, this, + [this](int) { m_filterBWidget->setMode(m_radioState->modeStringFullB()); }); // RadioState signals -> Processing state updates (AGC, PRE, ATT, NB, NR) connect(m_radioState, &RadioState::processingChanged, this, &MainWindow::onProcessingChanged); @@ -2651,7 +2700,7 @@ MainWindow::MainWindow(QWidget *parent) // but always honor PTT-off so a stale gate can be cleared. if (on && !m_tcpClient->isConnected()) return; -#ifdef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) if (on && !ensureMicrophonePermission(this)) { m_pttActive = false; m_bottomMenuBar->setPttActive(false); @@ -2684,7 +2733,7 @@ MainWindow::MainWindow(QWidget *parent) m_catServer->start(RadioSettings::instance()->catServerPort()); } -#ifdef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) // Prime Android runtime permission early, before the first TX attempt. ensureMicrophonePermission(this); #endif @@ -2819,15 +2868,19 @@ void MainWindow::showSettings() { m_radioState->setKeyerSpeed(boundedWpm); }); } -#ifdef Q_OS_ANDROID + // On touch platforms the dialog is an in-window overlay: fill the console + // so its "RETURN TO OPERATE" header button is on-screen and reachable. + // Without this the iPad opened it at its default size with the close + // button out of reach. +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) m_optionsDialog->setGeometry(centralWidget()->rect()); #endif m_optionsDialog->show(); m_optionsDialog->raise(); -#ifndef Q_OS_ANDROID - m_optionsDialog->activateWindow(); -#else +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) m_optionsDialog->setFocus(Qt::OtherFocusReason); +#else + m_optionsDialog->activateWindow(); #endif } @@ -2969,6 +3022,26 @@ void MainWindow::setupUi() { setStyleSheet(QString("QMainWindow { background-color: %1; }").arg(K4Styles::Colors::Background)); +#if defined(Q_OS_ANDROID) + // Android tablet fit: the window (~1007 logical px) can't give each VFO panel + // its full 270px column once the side-control panels (107+132) and the centre + // panel are placed, so each panel is squeezed to ~246 and the 270/260 column + // content (meter +40 scale, frequency, feature-label row) is clipped. Size the + // VFO column and meter to what actually fits so the content renders in full + // (the S-meter scale is a touch denser than desktop). macOS/iOS keep 270/260. + if (!K4Styles::isCompactLayout()) { + K4Styles::Dimensions::VfoColumnWidth = 244; + K4Styles::Dimensions::VfoMeterWidth = 244; + // Restore the desktop meter/content heights: at 120 the meter clipped + // the Id row's scale (it lands at ~y129), and the feature row (AGC-S…APF) + // sat right under the meter with no gap. 130/150 shows the Id scale and + // drops the feature row to match macOS. The spectrum below absorbs the + // few extra pixels. + K4Styles::Dimensions::VfoMeterHeight = 130; + K4Styles::Dimensions::VfoContentHeight = 150; + } +#endif + auto *centralWidget = new QWidget(this); centralWidget->setStyleSheet(QString("background-color: %1;").arg(K4Styles::Colors::Background)); setCentralWidget(centralWidget); @@ -3042,11 +3115,14 @@ void MainWindow::setupUi() { // Right Side Panel (mirrors left panel dimensions) m_rightPanelScroll = new QScrollArea(middleWidget); m_rightPanelScroll->setFrameShape(QFrame::NoFrame); - m_rightPanelScroll->setWidgetResizable(false); + // iPad: let the panel fill the viewport height so its trailing addStretch + // can bottom-anchor the fine-tune pad near the PTT button, while a taller + // panel still scrolls (minimumHeight below). Phone keeps manual sizing. + m_rightPanelScroll->setWidgetResizable(!K4Styles::isCompactLayout()); m_rightPanelScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_rightPanelScroll->setVerticalScrollBarPolicy(K4Styles::isCompactLayout() ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); - m_rightPanelScroll->setFixedWidth(K4Styles::Dimensions::SidePanelWidth + sidePanelScrollExtra); + m_rightPanelScroll->setFixedWidth(K4Styles::Dimensions::RightSidePanelWidth + sidePanelScrollExtra); m_rightSidePanel = new RightSidePanel(m_rightPanelScroll); m_rightPanelScroll->setWidget(m_rightSidePanel); if (K4Styles::isCompactLayout()) { @@ -3059,6 +3135,10 @@ void MainWindow::setupUi() { if (K4Styles::isCompactLayout()) { m_rightPanelScroll->hide(); } else { + // Floor the panel at its natural height: when the viewport is taller, + // widgetResizable stretches it and the addStretch anchors the pad to + // the bottom; when shorter, it keeps full height and scrolls. + m_rightSidePanel->setMinimumHeight(m_rightSidePanel->sizeHint().height()); middleLayout->addWidget(m_rightPanelScroll); } @@ -3426,6 +3506,11 @@ void MainWindow::setupUi() { m_bSetLabel->setVisible(enabled); m_splitLabel->setVisible(!enabled); + // Green highlight on the right-panel B SET button so the mode is easy + // to spot (like the SUB button). + if (m_rightSidePanel) + m_rightSidePanel->setBSetActive(enabled); + // Change side panel BW/SHFT indicator color (cyan=MainRx, green=SubRx) m_sideControlPanel->setActiveReceiver(enabled); }); @@ -3801,12 +3886,58 @@ void MainWindow::setupUi() { m_tcpClient->sendCAT("SW157;"); }); - // NORM is placed with BW/SHFT and performs the K4's nominal-passband - // action. K4 MON is intentionally not exposed in the remote control UI. + // NORM performs the K4's nominal-passband action. After the radio settles + // to its nominal width, learn that width for the active RX's current mode + // so the filter indicator's NORM edge ticks show at the true nominal + // rather than a hardcoded guess. connect(m_sideControlPanel, &SideControlPanel::normalizeFilterRequested, this, [this]() { queueControlFeedback("FILTER_NORM", "Filter passband normalized"); m_tcpClient->sendCAT("SW129;"); + QTimer::singleShot(300, this, [this]() { + if (m_radioState->bSetEnabled()) + m_filterBWidget->setNormBandwidth(m_radioState->filterBandwidthB()); + else + m_filterAWidget->setNormBandwidth(m_radioState->filterBandwidth()); + }); }); + // MON / BAL toggle their K4 functions; the overlays adjust ML / BL levels. + connect(m_sideControlPanel, &SideControlPanel::monClicked, this, [this]() { + queueControlFeedback("MON", "Monitor toggled"); + m_tcpClient->sendCAT("SW128;"); + }); + connect(m_sideControlPanel, &SideControlPanel::balClicked, this, [this]() { + queueControlFeedback("BAL", "Sub-RX balance toggled"); + m_tcpClient->sendCAT("SW130;"); + }); + connect(m_sideControlPanel, &SideControlPanel::monLevelChangeRequested, this, + [this](int mode, int level) { + m_tcpClient->sendCAT(QString("ML%1%2;").arg(mode).arg(level, 3, 10, QChar('0'))); + m_radioState->setMonitorLevel(mode, level); + }); + connect(m_sideControlPanel, &SideControlPanel::balChangeRequested, this, + [this](int mode, int offset) { + const QString sign = offset >= 0 ? "+" : "-"; + m_tcpClient->sendCAT(QString("BL%1%2%3;") + .arg(mode) + .arg(sign) + .arg(qAbs(offset), 2, 10, QChar('0'))); + m_radioState->setBalance(mode, offset); + }); + connect(m_radioState, &RadioState::monitorLevelChanged, + m_sideControlPanel, &SideControlPanel::updateMonitorLevel); + connect(m_radioState, &RadioState::balanceChanged, + m_sideControlPanel, &SideControlPanel::updateBalance); + // Keep the MON overlay pointed at the right ML register as the mode changes. + auto updateMonitorMode = [this](RadioState::Mode mode) { + int monMode = 2; // Voice + if (mode == RadioState::CW || mode == RadioState::CW_R) + monMode = 0; + else if (mode == RadioState::DATA || mode == RadioState::DATA_R) + monMode = 1; + m_sideControlPanel->updateMonitorMode(monMode); + }; + connect(m_radioState, &RadioState::modeChanged, this, updateMonitorMode); + updateMonitorMode(m_radioState->mode()); // seed initial monitor mode // Forward audio mix routing (MX command) to audio engine connect(m_radioState, &RadioState::audioMixChanged, this, [this](int left, int right) { @@ -3999,6 +4130,81 @@ void MainWindow::setupUi() { connect(m_rightSidePanel, &RightSidePanel::lockBClicked, this, [this]() { queueControlFeedback("LOCK_B", "VFO B lock changed"); m_tcpClient->sendCAT("SW151;"); }); + // iPad fine-tune pad. Steps the VFO by the radio's current tuning step, + // mirroring the phone's A-/A+/B-/B+ buttons (signals only fire on iPad). + // Snap to the current tuning-step grid: from x.957 with a 100 Hz step, + // "-" lands on x.900 and "+" on the next x.000, matching the radio. Uses + // the same rate source as the panadapter drag/scroll tuning. + auto snapStep = [](qint64 cur, int dir, int stepHz) -> qint64 { + const qint64 base = (cur / stepHz) * stepHz; // floor to grid (freq > 0) + if (dir < 0) + return (cur == base) ? base - stepHz : base; + return base + stepHz; + }; + connect(m_rightSidePanel, &RightSidePanel::tuneARequested, this, [this, snapStep](int dir) { + // While the blue edit field is open, A-/A+ change the selected digit + // (with carry) so a frequency can be entered entirely by touch. + if (m_vfoA->frequencyDisplay()->isEditing()) { + m_vfoA->frequencyDisplay()->nudgeCursorDigit(dir); + return; + } + if (!m_tcpClient->isConnected()) + return; + const int stepHz = m_phoneTuneStepAHz > 0 ? m_phoneTuneStepAHz : tuningStepToHz(m_radioState->tuningStep()); + const qint64 next = snapStep(static_cast(m_radioState->vfoA()), dir, stepHz); + if (next > 0) { + const QString command = QString("FA%1;").arg(next, 11, 10, QChar('0')); + m_tcpClient->sendCAT(command); + m_radioState->parseCATCommand(command); + } + }); + connect(m_rightSidePanel, &RightSidePanel::tuneBRequested, this, [this, snapStep](int dir) { + if (!m_tcpClient->isConnected()) + return; + const int stepHz = m_phoneTuneStepBHz > 0 ? m_phoneTuneStepBHz : tuningStepToHz(m_radioState->tuningStepB()); + const qint64 next = snapStep(static_cast(m_radioState->vfoB()), dir, stepHz); + if (next > 0) { + const QString command = QString("FB%1;").arg(next, 11, 10, QChar('0')); + m_tcpClient->sendCAT(command); + m_radioState->parseCATCommand(command); + } + }); + + // FREQ ENT switches the main VFO's frequency display into the blue edit + // field, matching the radio (a dedicated key enters edit mode rather than + // tapping the frequency, which selects the tuning rate). + connect(m_rightSidePanel, &RightSidePanel::freqEntClicked, this, [this]() { + auto *fd = m_vfoA->frequencyDisplay(); + // Toggle: FREQ ENT opens the blue field, and pressing it again commits + // (sends the entered frequency), so the whole entry is touch-only. + if (fd->isEditing()) + fd->commitEdit(); + else + fd->beginEdit(); + }); + + // iPad: tapping a frequency digit sets the tuning rate at that place + // (1 Hz .. 10 kHz, the five rightmost digits), matching the radio. The + // compact layout wires this on the bottom bar instead. + if (!K4Styles::isCompactLayout()) { + connect(m_vfoA, &VFOWidget::tuningDigitSelected, this, [this](int digitFromRight) { + const int digit = qBound(0, digitFromRight, 4); + if (m_tcpClient->isConnected()) { + const QString command = QString("VT%1;").arg(digit); + m_tcpClient->sendCAT(command); + m_radioState->parseCATCommand(command); + } + }); + connect(m_vfoB, &VFOWidget::tuningDigitSelected, this, [this](int digitFromRight) { + const int digit = qBound(0, digitFromRight, 4); + if (m_tcpClient->isConnected()) { + const QString command = QString("VT$%1;").arg(digit); + m_tcpClient->sendCAT(command); + m_radioState->parseCATCommand(command); + } + }); + } + // Resolve CTRL-panel actions from the state echoed by the K4. These // confirmations remain useful after the drawer has closed, particularly // for controls whose state is not represented in the compact phone view. @@ -4253,6 +4459,18 @@ void MainWindow::setupVfoSection(QWidget *parent) { // ===== Center Section ===== auto *centerWidget = new QWidget(parent); centerWidget->setFixedWidth(K4Styles::Dimensions::CenterPanelWidth); +#if defined(Q_OS_ANDROID) + // Root cause of the old centre/meter overlap: the VFO row is width-bound on + // the tablet. The row is ~752px; each VFO panel needs ~246 (VfoColumnWidth + // 244 + margins) and the two inter-column gaps are ~4 each, so the fixed + // centre panel must be <= 752 - 2*246 - 2*4 = 252 or the HBox can't shrink it + // (it's fixed) and it overflows into the right meter panel. Size it to fit + // that budget (250) instead of the desktop 330. The VFO cluster and the + // narrowed memory strip (~242) still fit. If the window/panel widths change + // materially, recompute against this budget. + if (!K4Styles::isCompactLayout()) + centerWidget->setFixedWidth(250); +#endif centerWidget->setStyleSheet(QString("background-color: %1;").arg(K4Styles::Colors::Background)); auto *centerLayout = new QVBoxLayout(centerWidget); centerLayout->setContentsMargins(K4Styles::isCompactLayout() ? 2 : 4, @@ -4293,8 +4511,9 @@ void MainWindow::setupVfoSection(QWidget *parent) { m_splitLabel = new QLabel("SPLIT OFF", centerWidget); m_splitLabel->setAlignment(Qt::AlignCenter); m_splitLabel->setStyleSheet(QString("color: %1; font-size: 11px;").arg(K4Styles::Colors::AccentAmber)); - if (!K4Styles::isCompactLayout()) - centerLayout->addWidget(m_splitLabel); + // In regular layout SPLIT/MSG/RIT are stacked in the VFO row's centre + // column (between the filters, like the radio); added after the RIT box is + // built below. Compact keeps SPLIT in the shared status row. // B SET indicator (green rounded rect with black text, hidden by default) m_bSetLabel = new QLabel("B SET", centerWidget); @@ -4312,7 +4531,8 @@ void MainWindow::setupVfoSection(QWidget *parent) { m_bSetLabel->setCursor(Qt::PointingHandCursor); m_bSetLabel->installEventFilter(this); m_bSetLabel->setVisible(false); - centerLayout->addWidget(m_bSetLabel, 0, Qt::AlignHCenter); + if (K4Styles::isCompactLayout()) + centerLayout->addWidget(m_bSetLabel, 0, Qt::AlignHCenter); // Message Bank indicator m_msgBankLabel = new QLabel("MSG: I", centerWidget); @@ -4326,9 +4546,9 @@ void MainWindow::setupVfoSection(QWidget *parent) { compactStatusRow->setSpacing(6); compactStatusRow->addWidget(m_splitLabel, 1); compactStatusRow->addWidget(m_msgBankLabel, 1); - } else { - centerLayout->addWidget(m_msgBankLabel); } + // Regular layout adds SPLIT/MSG/RIT to the centre column below (after the + // RIT box exists). // RIT/XIT Box with border - constrained size // Supports mouse wheel to adjust RIT/XIT offset @@ -4381,31 +4601,43 @@ void MainWindow::setupVfoSection(QWidget *parent) { ritXitLayout->addWidget(m_ritXitValueLabel); // Create filter/RIT/XIT row - filter indicators flanking the RIT/XIT box - auto *filterRitXitRow = new QHBoxLayout(); - filterRitXitRow->setContentsMargins(0, 0, 0, 0); - filterRitXitRow->setSpacing(0); - - // VFO A filter indicator (left side, cyan #00BFFF to match VFO A square/slider) - m_filterAWidget = new FilterIndicatorWidget(centerWidget); + // Filter indicators now live under each VFO square+mode inside the VFO row + // (like the radio), not in a row beside RIT/XIT. Pull them from there and + // keep the color/cursor/event-filter setup MainWindow relies on. + m_filterAWidget = m_vfoRow->filterAWidget(); m_filterAWidget->setShapeColor(QColor(0x00, 0xBF, 0xFF), QColor(0x00, 0xBF, 0xFF)); // Cyan solid m_filterAWidget->setCursor(Qt::PointingHandCursor); m_filterAWidget->installEventFilter(this); - filterRitXitRow->addWidget(m_filterAWidget); - filterRitXitRow->addStretch(); - - // RIT/XIT box (centered) - filterRitXitRow->addWidget(m_ritXitBox); - filterRitXitRow->addStretch(); - - // VFO B filter indicator (right side, green #00FF00 to match VFO B square/slider) - m_filterBWidget = new FilterIndicatorWidget(centerWidget); + m_filterBWidget = m_vfoRow->filterBWidget(); m_filterBWidget->setShapeColor(QColor(0x00, 0xFF, 0x00), QColor(0x00, 0xFF, 0x00)); // Green solid m_filterBWidget->setCursor(Qt::PointingHandCursor); m_filterBWidget->installEventFilter(this); - filterRitXitRow->addWidget(m_filterBWidget); - centerLayout->addLayout(filterRitXitRow); + // Seed a default shape so B renders before its first bandwidth/mode update. + m_filterBWidget->setMode(QStringLiteral("USB")); + m_filterBWidget->setBandwidth(2400); + m_filterAWidget->show(); + m_filterBWidget->show(); + + if (K4Styles::isCompactLayout()) { + // Phone: RIT/XIT box centred on its own row. + auto *ritXitRow = new QHBoxLayout(); + ritXitRow->setContentsMargins(0, 0, 0, 0); + ritXitRow->setSpacing(0); + ritXitRow->addStretch(); + ritXitRow->addWidget(m_ritXitBox); + ritXitRow->addStretch(); + centerLayout->addLayout(ritXitRow); + } else { + // Tablet/iPad: stack SPLIT / B SET / MSG / RIT-XIT in the VFO row's + // centre column so they sit between the two VFO filters, as on the + // radio. This also widens the centre column, pushing A/B outward. + m_vfoRow->addToCenterColumn(m_splitLabel); + m_vfoRow->addToCenterColumn(m_bSetLabel); + m_vfoRow->addToCenterColumn(m_msgBankLabel); + m_vfoRow->addToCenterColumn(m_ritXitBox); + } if (compactStatusRow) centerLayout->addLayout(compactStatusRow); @@ -4464,22 +4696,38 @@ void MainWindow::setupVfoSection(QWidget *parent) { // Container: VBox with 2px spacing, button centered, sub-label below // Button: MemoryButtonWidth x ButtonHeightSmall (42x28) // Sub-label: FontSizeSmall (8px), AccentAmber color - auto createMemoryButton = [centerWidget](const QString &label, const QString &subLabel, + // On an Android tablet the meter panels leave less room between them, so a + // narrower memory button lets the whole strip fit the (also narrower) centre + // panel without the meters overlapping STORE/RCL. +#if defined(Q_OS_ANDROID) + const int mbW = K4Styles::isCompactLayout() ? K4Styles::Dimensions::MemoryButtonWidth : 32; + // Smaller font so 5-char labels (STORE) fit the narrower button. + const QString mbFont = + K4Styles::isCompactLayout() ? QString() : QStringLiteral(" QPushButton{font-size:9px;padding:0px;}"); +#else + const int mbW = K4Styles::Dimensions::MemoryButtonWidth; + const QString mbFont; +#endif + auto createMemoryButton = [centerWidget, mbW, mbFont](const QString &label, const QString &subLabel, bool isLighter) -> QWidget * { auto *container = new QWidget(centerWidget); + // Cap the container to the button width so wide sub-labels (AF REC, + // AF PLAY) don't widen it and push the whole row past the centre panel. + container->setFixedWidth(mbW); auto *layout = new QVBoxLayout(container); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(2); auto *btn = new QPushButton(label, container); - btn->setFixedSize(K4Styles::Dimensions::MemoryButtonWidth, K4Styles::Dimensions::ButtonHeightSmall); + btn->setFixedSize(mbW, K4Styles::Dimensions::ButtonHeightSmall); btn->setCursor(Qt::PointingHandCursor); - btn->setStyleSheet(isLighter ? K4Styles::sidePanelButtonLight() : K4Styles::sidePanelButton()); + btn->setStyleSheet((isLighter ? K4Styles::sidePanelButtonLight() : K4Styles::sidePanelButton()) + mbFont); layout->addWidget(btn, 0, Qt::AlignHCenter); // Add sub-label if provided if (!subLabel.isEmpty()) { auto *sub = new QLabel(subLabel, container); + sub->setFixedWidth(mbW); sub->setStyleSheet(QString("color: %1; font-size: %2px;") .arg(K4Styles::Colors::AccentAmber) .arg(K4Styles::Dimensions::FontSizeSmall)); @@ -4493,7 +4741,7 @@ void MainWindow::setupVfoSection(QWidget *parent) { // Single row: M1-M4 group, REC, STORE, RCL (all centered) auto *memoryRow = new QHBoxLayout(); memoryRow->setContentsMargins(0, 0, 0, 0); - memoryRow->setSpacing(4); + memoryRow->setSpacing(3); memoryRow->addStretch(); @@ -4506,15 +4754,15 @@ void MainWindow::setupVfoSection(QWidget *parent) { // M1-M4 button row auto *m1m4Row = new QHBoxLayout(); m1m4Row->setContentsMargins(0, 0, 0, 0); - m1m4Row->setSpacing(4); + m1m4Row->setSpacing(3); // Helper to create just a button (no sub-label container) // Button: MemoryButtonWidth x ButtonHeightSmall (42x28), dark sidePanelButton style - auto createSimpleButton = [centerWidget](const QString &label) -> QPushButton * { + auto createSimpleButton = [centerWidget, mbW, mbFont](const QString &label) -> QPushButton * { auto *btn = new QPushButton(label, centerWidget); - btn->setFixedSize(K4Styles::Dimensions::MemoryButtonWidth, K4Styles::Dimensions::ButtonHeightSmall); + btn->setFixedSize(mbW, K4Styles::Dimensions::ButtonHeightSmall); btn->setCursor(Qt::PointingHandCursor); - btn->setStyleSheet(K4Styles::sidePanelButton()); + btn->setStyleSheet(K4Styles::sidePanelButton() + mbFont); return btn; }; @@ -4714,7 +4962,17 @@ void MainWindow::setupSpectrumPlaceholder(QWidget *parent) { m_panadapterA->setSecondaryPassbandColor(vfoBPassbandAlpha); m_panadapterA->setSecondaryMarkerColor(QColor(K4Styles::Colors::VfoBGreen)); m_panadapterA->setSecondaryVisible(true); - layout->addWidget(m_panadapterA); + // Thin border around each panadapter so both panes are clearly visible in + // dual (A+B) mode, matching the radio's outlined panes. + m_panAFrame = new QFrame(m_spectrumContainer); + m_panAFrame->setObjectName("panFrameA"); + m_panAFrame->setStyleSheet(QStringLiteral("#panFrameA { border: 1px solid #A0A0A0; }")); + { + auto *frameLayout = new QVBoxLayout(m_panAFrame); + frameLayout->setContentsMargins(1, 1, 1, 1); + frameLayout->addWidget(m_panadapterA); + } + layout->addWidget(m_panAFrame); // Sub panadapter for VFO B (right side) - QRhiWidget with Metal/DirectX/Vulkan m_panadapterB = new PanadapterRhiWidget(m_spectrumContainer); @@ -4731,8 +4989,16 @@ void MainWindow::setupSpectrumPlaceholder(QWidget *parent) { m_panadapterB->setSecondaryPassbandColor(vfoAPassbandAlpha); m_panadapterB->setSecondaryMarkerColor(QColor(K4Styles::Colors::VfoACyan)); m_panadapterB->setSecondaryVisible(true); - layout->addWidget(m_panadapterB); - m_panadapterB->hide(); // Start hidden (MainOnly mode) + m_panBFrame = new QFrame(m_spectrumContainer); + m_panBFrame->setObjectName("panFrameB"); + m_panBFrame->setStyleSheet(QStringLiteral("#panFrameB { border: 1px solid #A0A0A0; }")); + { + auto *frameLayout = new QVBoxLayout(m_panBFrame); + frameLayout->setContentsMargins(1, 1, 1, 1); + frameLayout->addWidget(m_panadapterB); + } + layout->addWidget(m_panBFrame); + m_panBFrame->hide(); // Start hidden (MainOnly mode) // Span control buttons - overlay on panadapter (lower right, above freq labels) // Note: rgba used intentionally for transparent overlay effect on spectrum @@ -5606,9 +5872,33 @@ void MainWindow::onBandwidthBChanged(int bw) { // Could update a bandwidth display if needed } +#if defined(Q_OS_ANDROID) +#include +// Toggle FLAG_KEEP_SCREEN_ON (WindowManager.LayoutParams = 128) on the activity +// window so the tablet stays awake while connected to the radio, and may sleep +// normally once disconnected. +static void androidSetKeepScreenOn(bool on) { + QNativeInterface::QAndroidApplication::runOnAndroidMainThread([on]() { + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) + return; + QJniObject win = activity.callObjectMethod("getWindow", "()Landroid/view/Window;"); + if (!win.isValid()) + return; + if (on) + win.callMethod("addFlags", "(I)V", 128); + else + win.callMethod("clearFlags", "(I)V", 128); + }); +} +#endif + void MainWindow::updateConnectionState(TcpClient::ConnectionState state) { switch (state) { case TcpClient::Disconnected: +#if defined(Q_OS_ANDROID) + androidSetKeepScreenOn(false); // allow sleep once disconnected +#endif // Clear the local TX gate on every disconnect, including unexpected // radio/network closure. Never leave the next connection latched TX. m_pttActive = false; @@ -5733,6 +6023,24 @@ void MainWindow::updateConnectionState(TcpClient::ConnectionState state) { m_vfoRow->setLockA(false); m_vfoRow->setLockB(false); + // SUB / DIVERSITY indicators back to inactive (they otherwise keep the + // green state from the last connection after a disconnect). + if (m_rightSidePanel) { + m_rightSidePanel->setSubActive(false); + m_rightSidePanel->setDiversityActive(false); + m_rightSidePanel->setBSetActive(false); + } + { + const QString subDivOffStyle = QString("background-color: %1; color: %2; font-size: 9px;" + "font-weight: bold; border-radius: 2px;") + .arg(K4Styles::Colors::DisabledBackground, + K4Styles::Colors::LightGradientTop); + if (m_subLabel) + m_subLabel->setStyleSheet(subDivOffStyle); + if (m_divLabel) + m_divLabel->setStyleSheet(subDivOffStyle); + } + // Side control panel values m_sideControlPanel->setBandwidth(0); m_sideControlPanel->setShift(0); @@ -5811,6 +6119,9 @@ void MainWindow::updateConnectionState(TcpClient::ConnectionState state) { m_connectionStatusLabel->setText("K4 OK"); m_connectionStatusLabel->setStyleSheet( QString("color: %1; font-size: 12px; font-weight: bold;").arg(K4Styles::Colors::StatusGreen)); +#if defined(Q_OS_ANDROID) + androidSetKeepScreenOn(true); // stay awake while connected +#endif break; } } @@ -5983,7 +6294,7 @@ void MainWindow::onQskEnabledChanged(bool enabled) { // QSK indicator: white when enabled, grey when disabled if (enabled) { m_qskLabel->setStyleSheet( - QString("color: %1; font-size: 11px; font-weight: bold;").arg(K4Styles::Colors::TextWhite)); + QString("color: %1; font-size: 11px; font-weight: bold;").arg(K4Styles::Colors::AccentAmber)); } else { m_qskLabel->setStyleSheet( QString("color: %1; font-size: 11px; font-weight: bold;").arg(K4Styles::Colors::TextGray)); @@ -6275,10 +6586,19 @@ void MainWindow::showRitXitAdjustment(bool preferXit) { K4Styles::Colors::InactiveGray)); layout->addWidget(offsetValue); + // Slider for coarse offset (drag the handle); the -/+ buttons below give + // fine 10 Hz steps. Range is the K4's +/-9.99 kHz RIT/XIT span. + auto *offsetSlider = new TouchSlider(Qt::Horizontal, panel); + offsetSlider->setRange(-9990, 9990); + offsetSlider->setSingleStep(10); + offsetSlider->setPageStep(100); + offsetSlider->setMinimumHeight(40); + layout->addWidget(offsetSlider); + auto usesBRegister = [this, &adjustXit]() { return adjustXit ? m_radioState->splitEnabled() : m_radioState->bSetEnabled(); }; - auto refreshTarget = [this, &adjustXit, ritTarget, xitTarget, targetDescription, offsetValue, + auto refreshTarget = [this, &adjustXit, ritTarget, xitTarget, targetDescription, offsetValue, offsetSlider, &usesBRegister]() { ritTarget->setChecked(!adjustXit); xitTarget->setChecked(adjustXit); @@ -6291,6 +6611,9 @@ void MainWindow::showRitXitAdjustment(bool preferXit) { offsetValue->setText(QString("%1%2 kHz") .arg(offset >= 0 ? "+" : "") .arg(offset / 1000.0, 0, 'f', 2)); + offsetSlider->blockSignals(true); + offsetSlider->setValue(qBound(offsetSlider->minimum(), offset, offsetSlider->maximum())); + offsetSlider->blockSignals(false); }; connect(ritTarget, &QPushButton::clicked, &dialog, [&adjustXit, &refreshTarget]() { adjustXit = false; @@ -6348,6 +6671,18 @@ void MainWindow::showRitXitAdjustment(bool preferXit) { connect(down, &QPushButton::clicked, &dialog, [&sendJog]() { sendJog(false); }); connect(up, &QPushButton::clicked, &dialog, [&sendJog]() { sendJog(true); }); + // Slider sets the offset absolutely on the selected register (10 Hz grid). + connect(offsetSlider, &QSlider::valueChanged, &dialog, [this, &usesBRegister](int value) { + const int v = (value / 10) * 10; + const bool registerB = usesBRegister(); + const QString cmd = QString("%1%2%3;") + .arg(registerB ? "RO$" : "RO") + .arg(v >= 0 ? "+" : "-") + .arg(qAbs(v), 4, 10, QChar('0')); + m_tcpClient->sendCAT(cmd); + m_radioState->parseCATCommand(cmd); + }); + auto querySelectedOffset = [this, &usesBRegister]() { m_tcpClient->sendCAT(usesBRegister() ? "RO$;" : "RO;"); }; @@ -6732,7 +7067,7 @@ void MainWindow::onPttPressed() { return; } -#ifdef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) if (!ensureMicrophonePermission(this)) { return; } @@ -6889,6 +7224,21 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event) { // short tap toggles while a long press opens the offset jog control. if (watched == m_ritXitBox || watched == m_ritLabel || watched == m_xitLabel || watched == m_ritXitValueLabel) { + // iPad (touch, no wheel): tap the box to open the offset adjuster. + // RIT/XIT on/off toggling lives on the right panel's RIT/XIT buttons. + if (!K4Styles::isCompactLayout() && event->type() == QEvent::MouseButtonPress) { + auto *mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::LeftButton) { + const bool ritActive = + m_radioState->bSetEnabled() ? m_radioState->ritEnabledB() : m_radioState->ritEnabled(); + const bool preferXit = (watched == m_xitLabel) || (m_radioState->xitEnabled() && !ritActive); + if ((preferXit && m_radioState->xitEnabled()) || (!preferXit && ritActive)) + showRitXitAdjustment(preferXit); + else + showControlFeedback("Enable RIT or XIT before adjusting"); + return true; + } + } if (K4Styles::isCompactLayout() && event->type() == QEvent::MouseButtonPress) { auto *mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::LeftButton) { @@ -7014,18 +7364,19 @@ void MainWindow::keyPressEvent(QKeyEvent *event) { void MainWindow::setPanadapterMode(PanadapterMode mode) { m_panadapterMode = mode; + // Show/hide the bordered frames (the panadapters stay shown inside them). switch (mode) { case PanadapterMode::MainOnly: - m_panadapterA->show(); - m_panadapterB->hide(); + m_panAFrame->show(); + m_panBFrame->hide(); break; case PanadapterMode::Dual: - m_panadapterA->show(); - m_panadapterB->show(); + m_panAFrame->show(); + m_panBFrame->show(); break; case PanadapterMode::SubOnly: - m_panadapterA->hide(); - m_panadapterB->show(); + m_panAFrame->hide(); + m_panBFrame->show(); break; } } diff --git a/src/mainwindow.h b/src/mainwindow.h index 0d94e02..26f9006 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -333,6 +333,8 @@ private slots: // Spectrum/Waterfall displays (QRhiWidget - Metal/DirectX/Vulkan) PanadapterRhiWidget *m_panadapterA; // VFO A (Main RX) PanadapterRhiWidget *m_panadapterB; // VFO B (Sub RX) - for future use + QWidget *m_panAFrame = nullptr; // thin bordered wrapper around panadapter A + QWidget *m_panBFrame = nullptr; // thin bordered wrapper around panadapter B QWidget *m_spectrumContainer; // Span control buttons (overlay on panadapter A) diff --git a/src/ui/adjustoverlay.cpp b/src/ui/adjustoverlay.cpp new file mode 100644 index 0000000..88c6bcd --- /dev/null +++ b/src/ui/adjustoverlay.cpp @@ -0,0 +1,186 @@ +#include "adjustoverlay.h" +#include "k4styles.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AdjustOverlay::AdjustOverlay(QWidget *parent) : QWidget(parent, Qt::Popup) { + setAttribute(Qt::WA_TranslucentBackground); + setFixedWidth(240); + + auto *outer = new QVBoxLayout(this); + // Leave room for the indicator bar on the left. + outer->setContentsMargins(IndicatorBarWidth + 12, 10, 12, 12); + outer->setSpacing(8); + + m_titleLabel = new QLabel(this); + m_titleLabel->setStyleSheet( + QString("color: %1; font-size: 12px; font-weight: bold;").arg(K4Styles::Colors::TextWhite)); + outer->addWidget(m_titleLabel); + + m_valueLabel = new QLabel(this); + m_valueLabel->setAlignment(Qt::AlignCenter); + m_valueLabel->setStyleSheet( + QString("color: %1; font-size: 22px; font-weight: bold;").arg(K4Styles::Colors::TextWhite)); + outer->addWidget(m_valueLabel); + + auto *row = new QHBoxLayout(); + row->setSpacing(10); + + const QString stepBtnStyle = + QString("QPushButton { color: %1; background: %2; border: 1px solid %3; border-radius: 6px; " + "font-size: 22px; font-weight: bold; } QPushButton:pressed { background: %3; }") + .arg(K4Styles::Colors::TextWhite, K4Styles::Colors::DarkBackground, K4Styles::Colors::BorderNormal); + + m_minusBtn = new QPushButton(QStringLiteral("−"), this); // minus sign + m_minusBtn->setFixedSize(44, 44); + m_minusBtn->setStyleSheet(stepBtnStyle); + row->addWidget(m_minusBtn); + + m_slider = new QSlider(Qt::Horizontal, this); + m_slider->setMinimumHeight(40); + // A raw QSlider treats a touch that lands on the groove as a page-step and + // does not track the drag. Map x->value directly instead (the popup is not + // inside a scroll area, so there is no scroll-vs-adjust ambiguity). + m_slider->installEventFilter(this); + row->addWidget(m_slider, 1); + + m_plusBtn = new QPushButton(QStringLiteral("+"), this); + m_plusBtn->setFixedSize(44, 44); + m_plusBtn->setStyleSheet(stepBtnStyle); + row->addWidget(m_plusBtn); + + outer->addLayout(row); + + connect(m_minusBtn, &QPushButton::clicked, this, [this]() { + m_slider->setValue(m_slider->value() - m_slider->singleStep()); + pokeActivity(); + }); + connect(m_plusBtn, &QPushButton::clicked, this, [this]() { + m_slider->setValue(m_slider->value() + m_slider->singleStep()); + pokeActivity(); + }); + connect(m_slider, &QSlider::valueChanged, this, [this](int value) { + m_valueLabel->setText(formatValue(value)); + pokeActivity(); + }); + + m_inactivityTimer = new QTimer(this); + m_inactivityTimer->setSingleShot(true); + m_inactivityTimer->setInterval(InactivityMs); + connect(m_inactivityTimer, &QTimer::timeout, this, &QWidget::hide); +} + +void AdjustOverlay::configure(const QString &title, DualControlButton::Context context) { + m_context = context; + m_titleLabel->setText(title); + m_slider->setStyleSheet( + K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, barColor().name())); + update(); +} + +void AdjustOverlay::setValueText(const QString &text) { + m_valueLabel->setText(text); +} + +void AdjustOverlay::setValueFormatter(std::function formatter) { + m_formatter = std::move(formatter); + if (m_slider) + m_valueLabel->setText(formatValue(m_slider->value())); +} + +QString AdjustOverlay::formatValue(int value) const { + return m_formatter ? m_formatter(value) : QString::number(value); +} + +void AdjustOverlay::pokeActivity() { + if (m_inactivityTimer) + m_inactivityTimer->start(); +} + +void AdjustOverlay::showOver(QWidget *anchor) { + adjustSize(); + QPoint pos; + if (anchor) { + // Sit just to the right of the tile, vertically centred on it. + const QPoint tl = anchor->mapToGlobal(QPoint(anchor->width(), 0)); + pos = QPoint(tl.x() + 8, tl.y() + anchor->height() / 2 - height() / 2); + } else { + pos = QCursor::pos(); + } + // Keep on screen. + if (QScreen *screen = QGuiApplication::screenAt(pos) ? QGuiApplication::screenAt(pos) + : QGuiApplication::primaryScreen()) { + const QRect avail = screen->availableGeometry(); + int x = qBound(avail.left() + 4, pos.x(), avail.right() - width() - 4); + int y = qBound(avail.top() + 4, pos.y(), avail.bottom() - height() - 4); + pos = QPoint(x, y); + } + move(pos); + show(); + raise(); + pokeActivity(); +} + +QColor AdjustOverlay::barColor() const { + switch (m_context) { + case DualControlButton::MainRx: + return QColor(K4Styles::Colors::VfoACyan); + case DualControlButton::SubRx: + return QColor(K4Styles::Colors::VfoBGreen); + case DualControlButton::Global: + default: + return QColor(K4Styles::Colors::AccentAmber); + } +} + +bool AdjustOverlay::eventFilter(QObject *watched, QEvent *event) { + if (watched == m_slider) { + if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseMove) { + auto *me = static_cast(event); + if (me->buttons() & Qt::LeftButton || event->type() == QEvent::MouseButtonPress) { + setSliderFromX(me->pos().x()); + pokeActivity(); + return true; // consume: we position absolutely, not by page-step + } + } + } + return QWidget::eventFilter(watched, event); +} + +void AdjustOverlay::setSliderFromX(int xPosition) { + if (!m_slider) + return; + const int handleWidth = qMax(12, m_slider->height() / 2); + const int span = qMax(1, m_slider->width() - handleWidth); + const int position = qBound(0, xPosition - handleWidth / 2, span); + m_slider->setValue(QStyle::sliderValueFromPosition(m_slider->minimum(), m_slider->maximum(), position, span, + m_slider->invertedAppearance())); +} + +void AdjustOverlay::paintEvent(QPaintEvent *) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRect r = rect(); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(K4Styles::Colors::DarkBackground)); + painter.drawRoundedRect(r, CornerRadius, CornerRadius); + + QRect barRect(0, 0, IndicatorBarWidth, r.height()); + painter.setBrush(barColor()); + painter.drawRoundedRect(barRect, CornerRadius / 2, CornerRadius / 2); + + painter.setPen(QPen(QColor(K4Styles::Colors::BorderNormal), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(r.adjusted(0, 0, -1, -1), CornerRadius, CornerRadius); +} diff --git a/src/ui/adjustoverlay.h b/src/ui/adjustoverlay.h new file mode 100644 index 0000000..1b06cbb --- /dev/null +++ b/src/ui/adjustoverlay.h @@ -0,0 +1,74 @@ +#ifndef ADJUSTOVERLAY_H +#define ADJUSTOVERLAY_H + +#include +#include +#include "dualcontrolbutton.h" + +class QSlider; +class QLabel; +class QPushButton; +class QTimer; + +/** + * @brief Touch adjustment popup for a single left-column control tile. + * + * Mirrors the macOS SideControlOverlay look (dark rounded panel with a + * context-coloured indicator bar) but is driven by a large slider plus fine + * -/+ buttons instead of the mouse wheel, so a DualControlButton value can be + * set by touch on iPad/iPhone. Opened by a long-press on the tile (the touch + * equivalent of the macOS right-click/wheel interaction). + * + * A Qt::Popup window: tapping anywhere outside dismisses it. It also closes + * itself after a short period of inactivity. + */ +class AdjustOverlay : public QWidget { + Q_OBJECT + +public: + explicit AdjustOverlay(QWidget *parent = nullptr); + + /// The slider the owner configures (range/value) and connects to. + QSlider *slider() const { return m_slider; } + + /// Set the title text and indicator-bar colour for this control. + void configure(const QString &title, DualControlButton::Context context); + + /// Update the large value readout shown above the slider. + void setValueText(const QString &text); + + /// Set how the slider value is rendered in the readout (e.g. kHz). Pass an + /// empty function to fall back to the raw integer. + void setValueFormatter(std::function formatter); + + /// Position over @p anchor (global coords) and show. Resets inactivity. + void showOver(QWidget *anchor); + + /// Restart the inactivity auto-close timer (call on any interaction). + void pokeActivity(); + +protected: + void paintEvent(QPaintEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + QColor barColor() const; + void setSliderFromX(int xPosition); + + DualControlButton::Context m_context = DualControlButton::Global; + QString formatValue(int value) const; + + std::function m_formatter; + QLabel *m_titleLabel = nullptr; + QLabel *m_valueLabel = nullptr; + QSlider *m_slider = nullptr; + QPushButton *m_minusBtn = nullptr; + QPushButton *m_plusBtn = nullptr; + QTimer *m_inactivityTimer = nullptr; + + static constexpr int IndicatorBarWidth = 5; + static constexpr int CornerRadius = 8; + static constexpr int InactivityMs = 3500; +}; + +#endif // ADJUSTOVERLAY_H diff --git a/src/ui/baloverlay.cpp b/src/ui/baloverlay.cpp index a119a0a..71fe170 100644 --- a/src/ui/baloverlay.cpp +++ b/src/ui/baloverlay.cpp @@ -2,6 +2,7 @@ #include "k4styles.h" #include #include +#include #include BalOverlay::BalOverlay(QWidget *parent) @@ -113,6 +114,38 @@ void BalOverlay::wheelEvent(QWheelEvent *event) { } void BalOverlay::mousePressEvent(QMouseEvent *event) { - // Don't close on click - allow adjustment via wheel - Q_UNUSED(event) + m_dragActive = true; + m_dragMoved = false; + m_dragStartX = event->position().x(); + m_dragStartY = event->position().y(); + event->accept(); +} + +void BalOverlay::mouseMoveEvent(QMouseEvent *event) { + if (!m_dragActive) + return; + const qreal x = event->position().x(); + const qreal y = event->position().y(); + if (!m_dragMoved && (qAbs(y - m_dragStartY) > 4 || qAbs(x - m_dragStartX) > 4)) + m_dragMoved = true; + if (m_dragMoved) { + // Top of the overlay is +50 (toward SUB), bottom is -50 (toward MAIN). + const qreal h = qMax(1, height()); + const qreal frac = 1.0 - qBound(0.0, y, h) / h; + const int newOffset = qBound(-50, int(qRound((frac - 0.5) * 100.0)), 50); + if (newOffset != m_offset) { + m_offset = newOffset; + updateDisplay(); + emit balanceChangeRequested(m_mode, m_offset); + } + } + event->accept(); +} + +void BalOverlay::mouseReleaseEvent(QMouseEvent *event) { + // A tap (press with no drag) dismisses the overlay; a drag adjusted it. + if (m_dragActive && !m_dragMoved) + hide(); + m_dragActive = false; + event->accept(); } diff --git a/src/ui/baloverlay.h b/src/ui/baloverlay.h index 0730be7..24a98c2 100644 --- a/src/ui/baloverlay.h +++ b/src/ui/baloverlay.h @@ -35,6 +35,8 @@ class BalOverlay : public SideControlOverlay { protected: void wheelEvent(QWheelEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; private: void setupUi(); @@ -46,6 +48,13 @@ class BalOverlay : public SideControlOverlay { int m_mode = 0; // 0=NOR, 1=BAL int m_offset = 0; // -50 to +50 + + // Touch drag-to-adjust: a drag maps the finger's vertical position to the + // balance offset; a tap (no drag) dismisses the overlay. + bool m_dragActive = false; + bool m_dragMoved = false; + qreal m_dragStartX = 0; + qreal m_dragStartY = 0; }; #endif // BALOVERLAY_H diff --git a/src/ui/bottommenubar.cpp b/src/ui/bottommenubar.cpp index 731efd8..10dab03 100644 --- a/src/ui/bottommenubar.cpp +++ b/src/ui/bottommenubar.cpp @@ -8,8 +8,35 @@ #include #include #include +#include #include +namespace { +// Draw a monochrome gear onto a settings button so no platform can substitute +// a colored emoji glyph. Shared by the compact and regular layouts. +void applyGearIcon(QPushButton *button) { + QPixmap gearPixmap(16, 16); + gearPixmap.fill(Qt::transparent); + QPainter gearPainter(&gearPixmap); + gearPainter.setRenderHint(QPainter::Antialiasing); + gearPainter.setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap)); + const QPointF center(8.0, 8.0); + constexpr qreal Pi = 3.14159265358979323846; + for (int i = 0; i < 8; ++i) { + const qreal angle = i * Pi / 4.0; + gearPainter.drawLine(center + QPointF(std::cos(angle) * 4.0, std::sin(angle) * 4.0), + center + QPointF(std::cos(angle) * 6.5, std::sin(angle) * 6.5)); + } + gearPainter.drawEllipse(center, 4.0, 4.0); + gearPainter.drawEllipse(center, 1.5, 1.5); + gearPainter.end(); + button->setAccessibleName("QK4 Settings"); + button->setToolTip("QK4 Settings"); + button->setIcon(QIcon(gearPixmap)); + button->setIconSize(QSize(16, 16)); +} +} // namespace + BottomMenuBar::BottomMenuBar(QWidget *parent) : QWidget(parent) { setupUi(); } @@ -74,27 +101,8 @@ void BottomMenuBar::setupUi() { button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } m_settingsBtn = createMenuButton(QString()); - m_settingsBtn->setAccessibleName("QK4 Settings"); - m_settingsBtn->setToolTip("QK4 Settings"); m_settingsBtn->setFixedSize(34, 26); - // Draw a monochrome gear so Android cannot substitute a colored emoji. - QPixmap gearPixmap(16, 16); - gearPixmap.fill(Qt::transparent); - QPainter gearPainter(&gearPixmap); - gearPainter.setRenderHint(QPainter::Antialiasing); - gearPainter.setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap)); - const QPointF center(8.0, 8.0); - constexpr qreal Pi = 3.14159265358979323846; - for (int i = 0; i < 8; ++i) { - const qreal angle = i * Pi / 4.0; - gearPainter.drawLine(center + QPointF(std::cos(angle) * 4.0, std::sin(angle) * 4.0), - center + QPointF(std::cos(angle) * 6.5, std::sin(angle) * 6.5)); - } - gearPainter.drawEllipse(center, 4.0, 4.0); - gearPainter.drawEllipse(center, 1.5, 1.5); - gearPainter.end(); - m_settingsBtn->setIcon(QIcon(gearPixmap)); - m_settingsBtn->setIconSize(QSize(16, 16)); + applyGearIcon(m_settingsBtn); tuneRow->addWidget(m_settingsBtn); tuneRow->addWidget(m_tuneADownBtn); tuneRow->addWidget(m_tuneAUpBtn); @@ -117,7 +125,11 @@ void BottomMenuBar::setupUi() { m_subVolumeSlider->setStyleSheet( K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, K4Styles::Colors::VfoBGreen)); } else { +#if defined(Q_OS_ANDROID) + setFixedHeight(K4Styles::isCompactLayout() ? K4Styles::Dimensions::MenuBarHeight : 40); +#else setFixedHeight(K4Styles::Dimensions::MenuBarHeight); +#endif auto *layout = new QHBoxLayout(this); // Left margin matches side panel/scroll width to align with waterfall above @@ -126,6 +138,14 @@ void BottomMenuBar::setupUi() { K4Styles::Dimensions::PaddingSmall); layout->setSpacing(K4Styles::Dimensions::PopupButtonSpacing); + // Connect and Settings at far left (desktop/macOS parity). On iOS these are + // the only connect/settings entry points, since the menu bar is hidden. + m_connectBtn = createMenuButton("CONN"); + layout->addWidget(m_connectBtn); + m_settingsBtn = createMenuButton(QString()); + applyGearIcon(m_settingsBtn); + layout->addWidget(m_settingsBtn); + // Add stretch before buttons to center them layout->addStretch(); @@ -254,7 +274,14 @@ void BottomMenuBar::setTuneStepB(int hertz) { QPushButton *BottomMenuBar::createMenuButton(const QString &text) { auto *btn = new QPushButton(text, this); - btn->setFixedSize(K4Styles::Dimensions::MenuBarButtonWidth, K4Styles::Dimensions::ButtonHeightMedium); +#if defined(Q_OS_ANDROID) + // Shorter bottom-bar buttons on the tablet free vertical space for the + // middle section so the left column's SUB slider isn't clipped. + const int h = K4Styles::isCompactLayout() ? K4Styles::Dimensions::ButtonHeightMedium : 28; +#else + const int h = K4Styles::Dimensions::ButtonHeightMedium; +#endif + btn->setFixedSize(K4Styles::Dimensions::MenuBarButtonWidth, h); btn->setCursor(Qt::PointingHandCursor); btn->setStyleSheet(K4Styles::menuBarButton()); return btn; @@ -323,7 +350,9 @@ void BottomMenuBar::setPttActive(bool active) { } else { m_pttLocked = false; m_pttLockTimer->stop(); - m_pttBtn->setText("TX / RX"); + // Compact keeps the phone's "TX / RX" latch label; the regular/iPad + // layout returns to "PTT" to match QK4 on macOS and the radio. + m_pttBtn->setText(K4Styles::isCompactLayout() ? "TX / RX" : "PTT"); m_pttBtn->setStyleSheet(K4Styles::menuBarButton()); } } diff --git a/src/ui/dualcontrolbutton.cpp b/src/ui/dualcontrolbutton.cpp index 0777fed..623a855 100644 --- a/src/ui/dualcontrolbutton.cpp +++ b/src/ui/dualcontrolbutton.cpp @@ -14,13 +14,20 @@ DualControlButton::DualControlButton(QWidget *parent) : QWidget(parent) { m_longPressTimer->setSingleShot(true); m_longPressTimer->setInterval(550); connect(m_longPressTimer, &QTimer::timeout, this, [this]() { - if (!K4Styles::isCompactLayout() || m_dragging) + if (m_dragging) return; - if (!m_showIndicator) - emit becameActive(); - swapFunctions(); - emit swapped(); m_longPressHandled = true; + if (K4Styles::isCompactLayout()) { + // Phone: long-press swaps to the amber alternate function. + if (!m_showIndicator) + emit becameActive(); + swapFunctions(); + emit swapped(); + } else { + // iPad (macOS-style column): long-press opens the touch adjust + // popup, the touch equivalent of the macOS wheel/right-click. + emit adjustRequested(); + } }); } @@ -187,8 +194,9 @@ void DualControlButton::mousePressEvent(QMouseEvent *event) { m_lastDragY = event->pos().y(); m_dragging = false; m_longPressHandled = false; - if (K4Styles::isCompactLayout()) - m_longPressTimer->start(); + // Compact: long-press swaps to alternate. Regular (iPad): long-press + // opens the adjust popup. Either way the timer is armed on press. + m_longPressTimer->start(); event->accept(); } else { QWidget::mousePressEvent(event); @@ -232,11 +240,19 @@ void DualControlButton::mouseReleaseEvent(QMouseEvent *event) { if (!m_dragging) { if (!m_showIndicator) { emit becameActive(); - } else if (!K4Styles::isCompactLayout()) { + emit clicked(); + } else if (K4Styles::isCompactLayout()) { + // Phone: a tap selects the primary function (handled by the + // panel's clicked handler); no swap here. + emit clicked(); + } else { + // iPad: a tap swaps white<->yellow (active function). Emit + // only swapped so the panel does not also run the compact + // clicked handler, which would swap a second time and cancel + // it out. swapFunctions(); emit swapped(); } - emit clicked(); } event->accept(); return; diff --git a/src/ui/dualcontrolbutton.h b/src/ui/dualcontrolbutton.h index a6af828..0517f97 100644 --- a/src/ui/dualcontrolbutton.h +++ b/src/ui/dualcontrolbutton.h @@ -73,6 +73,7 @@ class DualControlButton : public QWidget { void clicked(); // Button was clicked void swapped(); // Primary/alternate were swapped (only when already active) void becameActive(); // User clicked to activate this button + void adjustRequested(); // Long-press on iPad: open touch adjust popup protected: void paintEvent(QPaintEvent *event) override; diff --git a/src/ui/filterindicatorwidget.cpp b/src/ui/filterindicatorwidget.cpp index ed7d011..2fd4660 100644 --- a/src/ui/filterindicatorwidget.cpp +++ b/src/ui/filterindicatorwidget.cpp @@ -4,6 +4,8 @@ #include #include +QHash FilterIndicatorWidget::s_normByMode; + FilterIndicatorWidget::FilterIndicatorWidget(QWidget *parent) : QWidget(parent) { setFixedSize(62, 62); // 50 * 1.25 = 62 } @@ -51,6 +53,13 @@ void FilterIndicatorWidget::setShapeColor(const QColor &fill, const QColor &outl update(); } +void FilterIndicatorWidget::setNormBandwidth(int hz) { + if (hz <= 0) + return; + s_normByMode.insert(m_mode, hz); + update(); +} + void FilterIndicatorWidget::drawBandwidthShape(QPainter &painter, int lineY, int lineWidth) { // Shape height const float shapeHeight = 16.0f; @@ -140,6 +149,34 @@ void FilterIndicatorWidget::drawBandwidthShape(QPainter &painter, int lineY, int float bottomY = lineY - gapAboveLine; float topY = bottomY - shapeHeight; + // FSK/AFSK: the K4 draws the same passband trapezoid as other modes but + // with a notch in the top edge, so the mark/space tones show as two peaks + // at the top corners. At the narrow end it collapses to a single triangle; + // as BW widens the top spreads into a plateau with two corner peaks. Drawn + // centred (the pair straddles the passband centre), matching the radio. + if (m_mode.startsWith(QLatin1String("FSK")) || m_mode.startsWith(QLatin1String("AFSK"))) { + const float fcx = width() / 2.0f; + // FSK always resolves two tone peaks; their separation scales with the + // filter bandwidth. Map the FSK working range (~150-800 Hz) to a peak + // spacing that starts clearly apart and grows, capped so the widest + // setting still fits the 62px widget instead of clipping. + const float bwMin = 150.0f, bwMax = 800.0f; + const float t = std::clamp((static_cast(m_bandwidthHz) - bwMin) / (bwMax - bwMin), 0.0f, 1.0f); + const float halfTop = 9.0f + t * 12.0f; // peaks: ~18px..42px apart + const float halfBase = halfTop + 5.0f; // sides slope outward below the peaks + const float tl = fcx - halfTop, tr = fcx + halfTop; + const float bl = fcx - halfBase, br = fcx + halfBase; + const float valleyY = topY + shapeHeight * 0.30f; + painter.setPen(Qt::NoPen); + painter.setBrush(m_shapeColor); + QPolygonF shape; + shape << QPointF(bl, bottomY) << QPointF(tl, topY) << QPointF(fcx, valleyY) << QPointF(tr, topY) + << QPointF(br, bottomY); + painter.drawPolygon(shape); + drawFilterBaseline(painter, bl, br, lineY); + return; + } + float bottomLeft = centerX - baseWidth / 2.0f; float bottomRight = centerX + baseWidth / 2.0f; float topLeft = centerX - topWidth / 2.0f; @@ -160,6 +197,62 @@ void FilterIndicatorWidget::drawBandwidthShape(QPainter &painter, int lineY, int painter.setPen(Qt::NoPen); painter.setBrush(m_shapeColor); painter.drawPolygon(shape); + + drawFilterBaseline(painter, bottomLeft, bottomRight, lineY); +} + +int FilterIndicatorWidget::normBandwidthHz() const { + // The nominal width learned when the operator last pressed NORM in this + // mode is authoritative; the per-mode guesses below are only a fallback + // for a mode NORM has not been pressed in yet this session. + auto it = s_normByMode.constFind(m_mode); + if (it != s_normByMode.constEnd()) + return it.value(); + if (m_mode == "FM" || m_mode.startsWith(QLatin1String("PSK"))) + return 0; // no NORM marker + if (m_mode.startsWith(QLatin1String("FSK")) || m_mode.startsWith(QLatin1String("AFSK"))) + return 300; + if (m_mode == "CW" || m_mode == "CW-R") + return 400; + if (m_mode == "AM") + return 6000; + return 2700; // SSB / DATA nominal +} + +void FilterIndicatorWidget::drawFilterBaseline(QPainter &painter, float leftX, float rightX, float lineY) { + // The K4 draws a fixed-length yellow reference line, the same for every + // mode (the coloured filter shape varies, this line does not). Centre it + // under the current shape and give it a constant half-width. + const float cx = (leftX + rightX) / 2.0f; + const float half = 22.0f; // fixed: CW and LSB lines are identical length + const float lx = cx - half; + const float rx = cx + half; + + painter.setBrush(Qt::NoBrush); + QPen pen(m_lineColor, 2); + pen.setJoinStyle(Qt::RoundJoin); // clean corner, no miter spike above the flat + painter.setPen(pen); + + // When the passband is at (near) the mode's NORM width, the two ends turn + // downward. A tolerance (~10%, min 40 Hz) absorbs small differences between + // the radio's actual nominal and our per-mode fallback so both VFOs show + // the ends at their default width. Drawn as one polyline so the corners + // join cleanly and the legs never rise above the flat line. + const int norm = normBandwidthHz(); + const int tol = qMax(40, norm / 10); + if (norm > 0 && qAbs(m_bandwidthHz - norm) <= tol) { + const float len = 5.0f; + const float out = 2.0f; + const QPointF pts[4] = { + QPointF(lx - out, lineY + len), + QPointF(lx, lineY), + QPointF(rx, lineY), + QPointF(rx + out, lineY + len), + }; + painter.drawPolyline(pts, 4); + } else { + painter.drawLine(QPointF(lx, lineY), QPointF(rx, lineY)); + } } void FilterIndicatorWidget::paintEvent(QPaintEvent *) { @@ -172,19 +265,12 @@ void FilterIndicatorWidget::paintEvent(QPaintEvent *) { // Line parameters // Preserve breathing room above the phone's always-visible antenna row. int lineY = K4Styles::isCompactLayout() ? 36 : 40; - int lineHeight = 3; int lineWidth = 58; // 38 + 20 (10px wider on each side) - int lineX = (w - lineWidth) / 2; - // Draw bandwidth shape above the line + // Draw bandwidth shape; the yellow passband line (and NORM ends) are drawn + // with it so the line width matches the current filter. drawBandwidthShape(painter, lineY, lineWidth); - // Draw horizontal line - QRectF lineRect(lineX, lineY, lineWidth, lineHeight); - painter.setPen(Qt::NoPen); - painter.setBrush(m_lineColor); - painter.drawRect(lineRect); - // FIL text below line QFont textFont = font(); textFont.setPixelSize(K4Styles::Dimensions::FontSizeButton); @@ -193,7 +279,7 @@ void FilterIndicatorWidget::paintEvent(QPaintEvent *) { painter.setPen(m_textColor); QString text = QString("FIL%1").arg(m_filterPosition); - int textY = lineY + lineHeight + 2; + int textY = lineY + 3 + 2; // 3 = passband line thickness (see drawFilterBaseline) QRectF textRect(0, textY, w, h - textY); painter.drawText(textRect, Qt::AlignHCenter | Qt::AlignTop, text); } diff --git a/src/ui/filterindicatorwidget.h b/src/ui/filterindicatorwidget.h index 21f29a8..00646c6 100644 --- a/src/ui/filterindicatorwidget.h +++ b/src/ui/filterindicatorwidget.h @@ -2,6 +2,7 @@ #define FILTERINDICATORWIDGET_H #include +#include #include // Compact filter indicator widget showing filter position, @@ -35,11 +36,20 @@ class FilterIndicatorWidget : public QWidget { // Shape color (for VFO A/B color coding) void setShapeColor(const QColor &fill, const QColor &outline); + // Record the current mode's nominal (NORM) passband width in Hz, learned + // when the operator presses NORM. The down-turned edge ticks then show + // only when the live bandwidth returns to this learned width. + void setNormBandwidth(int hz); + protected: void paintEvent(QPaintEvent *event) override; private: void drawBandwidthShape(QPainter &painter, int lineY, int lineWidth); + // Mode's nominal (NORM) bandwidth in Hz, or 0 if unknown. + int normBandwidthHz() const; + // Down-turned yellow ticks at the shape's base edges when at NORM. + void drawFilterBaseline(QPainter &painter, float leftX, float rightX, float lineY); int m_filterPosition = 2; int m_bandwidthHz = 2400; // Current bandwidth in Hz @@ -48,6 +58,10 @@ class FilterIndicatorWidget : public QWidget { int m_minBandwidthHz = 50; // Minimum bandwidth (triangle) int m_maxBandwidthHz = 5000; // Maximum bandwidth (full trapezoid) + // Learned NORM width per mode string, shared across both VFO indicators + // (nominal is a property of the mode, not the receiver). + static QHash s_normByMode; + QColor m_lineColor{0xFF, 0xD0, 0x40}; // Gold #FFD040 QColor m_textColor{0xFF, 0xD0, 0x40}; // Gold #FFD040 QColor m_shapeColor{0xFF, 0xD0, 0x40, 128}; // Gold with 50% alpha diff --git a/src/ui/frequencydisplaywidget.cpp b/src/ui/frequencydisplaywidget.cpp index 99f9191..1ae3a39 100644 --- a/src/ui/frequencydisplaywidget.cpp +++ b/src/ui/frequencydisplaywidget.cpp @@ -240,7 +240,7 @@ QRect FrequencyDisplayWidget::charRectAt(int charIndex) const { int FrequencyDisplayWidget::digitPositionFromX(int x) const { QString display = formatWithDots(); - int currentX = 0; + int currentX = drawStartX(); for (int i = 0; i < display.length(); ++i) { int charW = (display[i] == '.') ? m_dotWidth : m_charWidth; @@ -280,7 +280,46 @@ void FrequencyDisplayWidget::enterEditMode(int digitPosition) { m_originalDigits = m_digits; m_cursorPosition = digitPosition; setFocus(); - grabMouse(); // Capture all mouse events to detect clicks outside +#if !defined(Q_OS_IOS) && !defined(Q_OS_ANDROID) + grabMouse(); // Desktop: capture mouse to detect clicks outside. On touch this + // would steal taps from the +/- controls used to edit digits. +#endif + update(); +} + +void FrequencyDisplayWidget::beginEdit() { + if (m_cursorPosition < 0) + enterEditMode(displayStartIndex()); // start at the leftmost visible digit +} + +void FrequencyDisplayWidget::commitEdit() { + if (m_cursorPosition >= 0) + exitEditMode(true); +} + +void FrequencyDisplayWidget::cancelEdit() { + if (m_cursorPosition >= 0) + exitEditMode(false); +} + +void FrequencyDisplayWidget::nudgeCursorDigit(int delta) { + if (m_cursorPosition < 0 || delta == 0) + return; + // Add/subtract the place value of the cursor digit so carries ripple + // naturally (e.g. 9->0 bumps the next digit up). + const int place = kMaxDigitIndex - m_cursorPosition; + quint64 placeValue = 1; + for (int i = 0; i < place; ++i) + placeValue *= 10; + qint64 value = static_cast(m_digits.toULongLong()) + static_cast(delta) * static_cast(placeValue); + if (value < 0) + value = 0; + QString s = QString::number(static_cast(value)); + while (s.length() < kDigits) + s.prepend('0'); + if (s.length() > kDigits) + s = s.right(kDigits); + m_digits = s; update(); } @@ -289,7 +328,9 @@ void FrequencyDisplayWidget::exitEditMode(bool send) { return; // Not in edit mode } - releaseMouse(); // Release mouse grab +#if !defined(Q_OS_IOS) && !defined(Q_OS_ANDROID) + releaseMouse(); // Release mouse grab (desktop only; see enterEditMode) +#endif if (send) { // Remove leading zeros for the signal (but keep at least one digit) @@ -309,6 +350,39 @@ void FrequencyDisplayWidget::exitEditMode(bool send) { update(); } +void FrequencyDisplayWidget::setRightAligned(bool rightAligned) { + if (m_rightAligned != rightAligned) { + m_rightAligned = rightAligned; + update(); + } +} + +int FrequencyDisplayWidget::displayPixelWidth() const { + QString display = formatWithDots(); + int w = 0; + for (int i = 0; i < display.length(); ++i) + w += (display[i] == '.') ? m_dotWidth : m_charWidth; + return w; +} + +void FrequencyDisplayWidget::setRightAlignEdge(int edgeX) { + if (m_rightAlignEdge != edgeX) { + m_rightAlignEdge = edgeX; + update(); + } +} + +int FrequencyDisplayWidget::drawStartX() const { + if (!m_rightAligned) + return 0; + // Digits end just inside m_rightAlignEdge (or the widget's right edge if + // unset). The small inset keeps the last digit off the clipped boundary and + // matches the radio, where the frequency sits a touch inside the meter edge. + constexpr int kRightInset = 16; + const int ref = (m_rightAlignEdge >= 0) ? m_rightAlignEdge : width(); + return qMax(0, ref - kRightInset - displayPixelWidth()); +} + void FrequencyDisplayWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); @@ -317,7 +391,7 @@ void FrequencyDisplayWidget::paintEvent(QPaintEvent *) { QString display = formatWithDots(); // Draw each character - int x = 0; + int x = drawStartX(); int digitIdx = displayStartIndex(); for (int i = 0; i < display.length(); ++i) { @@ -333,10 +407,11 @@ void FrequencyDisplayWidget::paintEvent(QPaintEvent *) { // Dots always in normal color charColor = m_normalColor; } else { - // Normal mode: check if this digit should be grayed (tuning rate indicator) + // Normal mode: digits strictly below the tuning rate are grayed. + // The active tuning-rate digit itself stays normal and is marked by + // the underline below (matching the radio and QK4 on macOS). const int posFromRight = kMaxDigitIndex - digitIdx; - if (m_tuningRateDigit >= 0 && posFromRight <= m_tuningRateDigit) { - // This digit is at or below tuning rate - show in gray + if (m_tuningRateDigit >= 0 && posFromRight < m_tuningRateDigit) { charColor = QColor(K4Styles::Colors::TextGray); } else { charColor = m_normalColor; @@ -355,6 +430,15 @@ void FrequencyDisplayWidget::paintEvent(QPaintEvent *) { p.fillRect(x + 2, underlineY, charW - 4, 2, m_editColor); } + // Tuning-rate indicator underline under the active digit. Guarded by + // m_cursorPosition < 0 so the edit-mode cursor underline takes + // precedence and we do not double-draw. + if (m_cursorPosition < 0 && c != '.' && m_tuningRateDigit >= 0 && + (kMaxDigitIndex - digitIdx) == m_tuningRateDigit) { + int underlineY = height() - 4; + p.fillRect(x + 2, underlineY, charW - 4, 2, m_normalColor); + } + // Advance digit index (only for non-dot characters) if (c != '.') { digitIdx++; diff --git a/src/ui/frequencydisplaywidget.h b/src/ui/frequencydisplaywidget.h index b68ebb5..9825676 100644 --- a/src/ui/frequencydisplaywidget.h +++ b/src/ui/frequencydisplaywidget.h @@ -56,6 +56,13 @@ class FrequencyDisplayWidget : public QWidget { // e.g., rate 2 (100Hz) grays out digits 0,1,2 (1s, 10s, 100s places) void setTuningRateDigit(int digitFromRight); + // Right-align the digits within the widget (VFO A, to line the frequency up + // with the right edge of the meters like the radio). Default is left. + void setRightAligned(bool rightAligned); + // X (in widget coords) the right edge of the digits aligns to when + // right-aligned. Defaults to the widget's own width when unset (<0). + void setRightAlignEdge(int edgeX); + // Check if currently in edit mode bool isEditing() const; // Opt-in fitting and phone gestures for embedded frequency displays. @@ -63,6 +70,16 @@ class FrequencyDisplayWidget : public QWidget { void setTouchTuningEnabled(bool enabled); void setSelectedTuningDigit(int digitFromRight); + // Enter the blue frequency edit field (FREQ ENT button / radio-style), + // as opposed to tapping a digit which selects the tuning rate. + void beginEdit(); + // Commit the edit field (send) / cancel it (restore). + void commitEdit(); + void cancelEdit(); + // Adjust the digit under the cursor by delta (+/-1), carrying across + // digits, for touch entry via +/- controls while the field is open. + void nudgeCursorDigit(int delta); + signals: // Emitted when user presses Enter to confirm frequency entry // digits is the frequency as plain digits (e.g., "7024980") @@ -131,6 +148,14 @@ class FrequencyDisplayWidget : public QWidget { // Tuning rate indicator: digits from this position to 0 show in gray int m_tuningRateDigit = -1; // -1 = no indicator, 0-4 = position from right + bool m_rightAligned = false; // draw digits against the widget's right edge + int m_rightAlignEdge = -1; // right-align target x; <0 = use width() + + // Pixel width of the current display string, and the left x the paint/hit + // logic starts from (nonzero when right-aligned). + int displayPixelWidth() const; + int drawStartX() const; + WheelAccumulator m_wheelAccumulator; // Cached character metrics for click detection diff --git a/src/ui/k4styles.cpp b/src/ui/k4styles.cpp index a5849d5..2ebe179 100644 --- a/src/ui/k4styles.cpp +++ b/src/ui/k4styles.cpp @@ -51,12 +51,14 @@ void applyDefaultDimensions() { VfoSquareSize = 45; NavButtonWidth = 54; SidePanelWidth = 105; + RightSidePanelWidth = 130; MemoryButtonWidth = 42; CenterPanelWidth = 330; VfoColumnWidth = 270; VfoContentHeight = 150; VfoMeterWidth = 260; + VfoMeterHeight = 130; SpectrumMinHeight = 300; VfoIndicatorBadgeWidth = 34; VfoIndicatorBadgeHeight = 30; @@ -133,6 +135,7 @@ void applyCompactDimensions() { // Original QK4 control banks are now presented side-by-side in the // phone Controls screen, so each needs room for its two-column grid. SidePanelWidth = 170; + RightSidePanelWidth = 170; MemoryButtonWidth = 34; // 62 px filter shapes on both sides plus the 80 px RIT/XIT readout. @@ -195,15 +198,31 @@ void configureForScreen(const QSize &availableSize, qreal devicePixelRatio, qrea bool forceCompact) { applyDefaultDimensions(); - // TEMPORARY: Until the tablet layout has been physically validated, use the - // proven landscape phone layout on every Android screen size. Keep the - // original size-based selection below for restoration once tablet testing - // is available. + // Large screens (iPad / Android tablet) get the regular desktop-like layout, + // closer to QK4 on macOS and the physical radio; phones keep the compact + // layout. iOS separates by the landscape short edge (iPad >= ~740 pt, + // iPhone <= ~440 pt). Android logical sizes vary a lot, so prefer the + // reported physical diagonal there (phones <= ~7", tablets larger), falling + // back to the short edge when the physical size is unknown. +#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID) + Q_UNUSED(devicePixelRatio); + const int shortEdge = std::min(availableSize.width(), availableSize.height()); +#if defined(Q_OS_ANDROID) + const bool regular = + (physicalDiagonalInches > 0.0) ? (physicalDiagonalInches >= 7.0) : (shortEdge > 700); +#else + Q_UNUSED(physicalDiagonalInches); + const bool regular = (shortEdge > 700); +#endif + const bool useCompact = forceCompact || !regular; +#else + // Desktop dev builds keep the compact layout (unchanged). Q_UNUSED(availableSize); Q_UNUSED(devicePixelRatio); Q_UNUSED(physicalDiagonalInches); Q_UNUSED(forceCompact); const bool useCompact = true; +#endif /* bool forceCompactEnvOk = false; bool forceRegularEnvOk = false; @@ -226,6 +245,31 @@ void configureForScreen(const QSize &availableSize, qreal devicePixelRatio, qrea } */ +#if defined(Q_OS_ANDROID) + // Android tablets are wider and shorter than an iPad (e.g. 1340x800), so + // the regular layout's iPad vertical rhythm overflows and clips the bottom + // of each column. Tighten the vertical density for the Android tablet + // layout (iOS/iPad keep the roomier values). + if (!useCompact) { + using namespace K4Styles::Dimensions; + // The left control column is the tallest. Shrink its DualControlButton + // tiles and the group padding to fit. Keep the shared ButtonHeightSmall + // at its default so the right panel's two-line function buttons (FREQ + // ENT) are not squished; the left MON/NORM/BAL use their own compact + // height in SideControlPanel. Shrink the S-meter (and the matching VFO + // content height) so the centre's SUB/DIV badges and the B filter + // indicator fit; the panadapter absorbs the difference. + ButtonHeightLarge = 32; // DualControlButton tiles (left column) + ButtonHeightSmall = 22; // right-panel function buttons + left MON/NORM/BAL + PaddingLarge = 6; + PaddingMedium = 5; + PaddingSmall = 4; + SpectrumMinHeight = 190; + VfoMeterHeight = 120; // was 130; keeps all 5 meter rows, a little tighter + VfoContentHeight = 138; // meter + feature labels + } +#endif + g_compactLayout = useCompact; if (useCompact) { applyCompactDimensions(); diff --git a/src/ui/k4styles.h b/src/ui/k4styles.h index 74665a4..c59218e 100644 --- a/src/ui/k4styles.h +++ b/src/ui/k4styles.h @@ -287,7 +287,8 @@ inline int MenuBarHeight = 52; // Bottom menu bar container height inline int FormLabelWidth = 80; // Form field labels in dialogs inline int VfoSquareSize = 45; // VFO A/B indicator squares and mode labels inline int NavButtonWidth = 54; // Navigation buttons in overlays -inline int SidePanelWidth = 105; // Left and right side panels +inline int SidePanelWidth = 105; // Left side panel (and both panels on phone) +inline int RightSidePanelWidth = 130; // Right side panel; wider on iPad to match macOS inline int MemoryButtonWidth = 42; // M1-M4, REC, STORE, RCL buttons // Main layout widths/heights @@ -295,6 +296,7 @@ inline int CenterPanelWidth = 330; // Center controls column between VFO A/B inline int VfoColumnWidth = 270; // VFO A/B column width inline int VfoContentHeight = 150; // VFO normal/mini-pan content height inline int VfoMeterWidth = 260; // TX meter width inside VFO column +inline int VfoMeterHeight = 130; // TX/S meter height inside VFO column (regular) inline int SpectrumMinHeight = 300; // Minimum spectrum/waterfall section height inline int VfoIndicatorBadgeWidth = 34; inline int VfoIndicatorBadgeHeight = 30; diff --git a/src/ui/monoverlay.cpp b/src/ui/monoverlay.cpp index 60e4394..b7c3801 100644 --- a/src/ui/monoverlay.cpp +++ b/src/ui/monoverlay.cpp @@ -2,6 +2,7 @@ #include "k4styles.h" #include #include +#include #include MonOverlay::MonOverlay(QWidget *parent) : SideControlOverlay(Global, parent) { @@ -71,7 +72,38 @@ void MonOverlay::wheelEvent(QWheelEvent *event) { } void MonOverlay::mousePressEvent(QMouseEvent *event) { - // Don't close on click - allow adjustment via wheel - // Click does nothing, user must click the MON button again to close - Q_UNUSED(event) + m_dragActive = true; + m_dragMoved = false; + m_dragStartX = event->position().x(); + m_dragStartY = event->position().y(); + event->accept(); +} + +void MonOverlay::mouseMoveEvent(QMouseEvent *event) { + if (!m_dragActive) + return; + const qreal x = event->position().x(); + const qreal y = event->position().y(); + if (!m_dragMoved && (qAbs(y - m_dragStartY) > 4 || qAbs(x - m_dragStartX) > 4)) + m_dragMoved = true; + if (m_dragMoved) { + // Top of the overlay is 100, bottom is 0 (vertical slider feel). + const qreal h = qMax(1, height()); + const qreal frac = 1.0 - qBound(0.0, y, h) / h; + const int newValue = qBound(0, int(qRound(frac * 100.0)), 100); + if (newValue != m_value) { + m_value = newValue; + updateValueDisplay(); + emit levelChangeRequested(m_mode, m_value); + } + } + event->accept(); +} + +void MonOverlay::mouseReleaseEvent(QMouseEvent *event) { + // A tap (press with no drag) dismisses the overlay; a drag adjusted it. + if (m_dragActive && !m_dragMoved) + hide(); + m_dragActive = false; + event->accept(); } diff --git a/src/ui/monoverlay.h b/src/ui/monoverlay.h index e5b3c8b..580ee06 100644 --- a/src/ui/monoverlay.h +++ b/src/ui/monoverlay.h @@ -50,6 +50,8 @@ class MonOverlay : public SideControlOverlay { protected: void wheelEvent(QWheelEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; private: void setupUi(); @@ -61,6 +63,13 @@ class MonOverlay : public SideControlOverlay { int m_value = 0; int m_mode = 0; // 0=CW, 1=Data, 2=Voice + + // Touch drag-to-adjust: a drag maps the finger's vertical position to the + // level; a tap (no drag) dismisses the overlay. + bool m_dragActive = false; + bool m_dragMoved = false; + qreal m_dragStartX = 0; + qreal m_dragStartY = 0; }; #endif // MONOVERLAY_H diff --git a/src/ui/optionsdialog.cpp b/src/ui/optionsdialog.cpp index bd59492..4e60027 100644 --- a/src/ui/optionsdialog.cpp +++ b/src/ui/optionsdialog.cpp @@ -102,7 +102,8 @@ OptionsDialog::~OptionsDialog() { void OptionsDialog::setupUi() { new OverlayBackHandler(this, [this] { requestReturnToOperate(); }); setWindowTitle("Options"); -#ifdef Q_OS_ANDROID + // Touch platforms show this as an in-window overlay sized to the console. +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) setMinimumSize(0, 0); #else setMinimumSize(700, 550); @@ -122,7 +123,10 @@ void OptionsDialog::setupUi() { .arg(K4Styles::Dimensions::FontSizePopup) .arg(K4Styles::Colors::GradientBottom)); -#ifdef Q_OS_ANDROID + // Touch platforms have no native window chrome, so provide an in-dialog + // header with a "RETURN TO OPERATE" button; the desktop uses the window + // title bar's close control instead. +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) auto *outerLayout = new QVBoxLayout(this); outerLayout->setContentsMargins(6, 4, 6, 6); outerLayout->setSpacing(4); @@ -130,9 +134,13 @@ void OptionsDialog::setupUi() { auto *title = new QLabel("QK4 SETTINGS", this); title->setStyleSheet(QString("color: %1; font-size: 16px; font-weight: bold;") .arg(K4Styles::Colors::AccentAmber)); - auto *close = new QPushButton("RETURN TO OPERATE", this); - close->setMinimumHeight(30); - close->setStyleSheet(K4Styles::menuBarButton()); + // Compact return/enter key on the far right, matching the "↵" button in + // the in-window control dialogs (e.g. NR ADJUST) rather than a wide label. + auto *close = new QPushButton(QString::fromUtf8("↵"), this); + close->setFixedSize(48, 32); + close->setStyleSheet(K4Styles::menuBarButton() + "QPushButton { font-size: 20px; font-weight: bold; }"); + close->setToolTip("Return to operate"); + close->setAccessibleName("Return to operate"); connect(close, &QPushButton::clicked, this, &OptionsDialog::requestReturnToOperate); header->addWidget(title); header->addStretch(1); @@ -2116,7 +2124,8 @@ QWidget *OptionsDialog::createCwKeyerPage() { deviceTypeLabel->setStyleSheet(QString("color: %1; font-size: %2px;") .arg(K4Styles::Colors::TextGray) .arg(K4Styles::Dimensions::FontSizePopup)); - deviceTypeLabel->setFixedWidth(K4Styles::Dimensions::FormLabelWidth); + // Size to the text (a fixed FormLabelWidth clipped "Device Type:"). + deviceTypeLabel->setMinimumWidth(deviceTypeLabel->sizeHint().width()); m_cwKeyerDeviceTypeCombo = new QComboBox(page); m_cwKeyerDeviceTypeCombo->setStyleSheet( @@ -2132,11 +2141,16 @@ QWidget *OptionsDialog::createCwKeyerPage() { .arg(K4Styles::Dimensions::FontSizePopup) .arg(K4Styles::Dimensions::PaddingSmall) .arg(K4Styles::Dimensions::SliderBorderRadius)); +#ifndef Q_OS_IOS + // The serial/HID V1.4 keyer is desktop-only; iOS supports MIDI only. m_cwKeyerDeviceTypeCombo->addItem("HaliKey V1.4", 0); +#endif m_cwKeyerDeviceTypeCombo->addItem("HaliKey MIDI", 1); + // Select by stored device-type value (index differs once V1.4 is absent). int savedDeviceType = RadioSettings::instance()->halikeyDeviceType(); - m_cwKeyerDeviceTypeCombo->setCurrentIndex(savedDeviceType); + int savedIndex = m_cwKeyerDeviceTypeCombo->findData(savedDeviceType); + m_cwKeyerDeviceTypeCombo->setCurrentIndex(savedIndex >= 0 ? savedIndex : 0); updateCwKeyerDescription(); connect(m_cwKeyerDeviceTypeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { diff --git a/src/ui/rightsidepanel.cpp b/src/ui/rightsidepanel.cpp index 7f2cb26..d6605ba 100644 --- a/src/ui/rightsidepanel.cpp +++ b/src/ui/rightsidepanel.cpp @@ -36,8 +36,7 @@ RightSidePanel::RightSidePanel(QWidget *parent) void RightSidePanel::setupUi() { const bool compact = K4Styles::isCompactLayout(); - // Match left panel dimensions exactly - setFixedWidth(K4Styles::Dimensions::SidePanelWidth); + setFixedWidth(K4Styles::Dimensions::RightSidePanelWidth); QPalette panelPalette = palette(); panelPalette.setColor(QPalette::Window, QColor(K4Styles::Colors::PopupBackground)); setPalette(panelPalette); @@ -52,7 +51,7 @@ void RightSidePanel::setupUi() { auto *buttonGrid = new QGridLayout(); buttonGrid->setContentsMargins(0, 0, 0, 0); buttonGrid->setHorizontalSpacing(K4Styles::Dimensions::PopupButtonSpacing); - buttonGrid->setVerticalSpacing(K4Styles::Dimensions::PopupButtonSpacing); + buttonGrid->setVerticalSpacing(K4Styles::isCompactLayout() ? K4Styles::Dimensions::PopupButtonSpacing : 2); auto *preControl = createFunctionButton("PRE", "ATTN", m_preBtn); auto *nbControl = createFunctionButton("NB", "LEVEL", m_nbBtn); @@ -118,7 +117,7 @@ void RightSidePanel::setupUi() { auto *pfGrid = new QGridLayout(); pfGrid->setContentsMargins(0, 0, 0, 0); pfGrid->setHorizontalSpacing(K4Styles::Dimensions::PopupButtonSpacing); - pfGrid->setVerticalSpacing(K4Styles::Dimensions::PopupButtonSpacing); + pfGrid->setVerticalSpacing(K4Styles::isCompactLayout() ? K4Styles::Dimensions::PopupButtonSpacing : 2); auto *bsetControl = createFunctionButton("B SET", "PF 1", m_bsetBtn, true); auto *clrControl = createFunctionButton("CLR", "PF 2", m_clrBtn, true); @@ -157,12 +156,16 @@ void RightSidePanel::setupUi() { auto *bottomGrid = new QGridLayout(); bottomGrid->setContentsMargins(0, 0, 0, 0); bottomGrid->setHorizontalSpacing(K4Styles::Dimensions::PopupButtonSpacing); - bottomGrid->setVerticalSpacing(K4Styles::Dimensions::PopupButtonSpacing); + bottomGrid->setVerticalSpacing(K4Styles::isCompactLayout() ? K4Styles::Dimensions::PopupButtonSpacing : 2); - auto *freqControl = createFunctionButton("FREQ\nENT", "SCAN", m_freqEntBtn); + auto *freqControl = createFunctionButton("FREQ ENT", "SCAN", m_freqEntBtn); auto *rateControl = createFunctionButton("RATE", "KHZ", m_rateBtn); auto *lockControl = createFunctionButton("LOCK A", "LOCK B", m_lockABtn); auto *subControl = createFunctionButton("SUB", "DIVERSITY", m_subBtn); + // The "DIVERSITY" amber sub-label; turned green as the DIV LED (regular only, + // where the sub-label is a separate QLabel under the button). + if (!compact) + m_diversityLabel = subControl->findChild(); if (compact) { buttonGrid->addWidget(freqControl, 3, 2); buttonGrid->addWidget(rateControl, 3, 3); @@ -186,30 +189,84 @@ void RightSidePanel::setupUi() { m_rateBtn->installEventFilter(this); m_lockABtn->installEventFilter(this); m_subBtn->installEventFilter(this); + + // iPad fine-tune pad (A-/A+/B-/B+). Fine tuning is awkward by touch on the + // panadapter; these give the phone app's well-liked step buttons on iPad. + // These are not controls on the radio, so separate them from the radio + // button groups with the same inter-group spacing used above, and style + // them like the function buttons above (style guide sidePanelButton). + if (!compact) { + auto makeTuneBtn = [this](const QString &text) { + auto *btn = new QPushButton(text, this); + btn->setFixedHeight(K4Styles::Dimensions::ButtonHeightSmall); + btn->setCursor(Qt::PointingHandCursor); + btn->setStyleSheet(K4Styles::sidePanelButton()); + // Typematic: one tap = one step; press-and-hold repeats after a short + // delay until released (like a keyboard arrow key). + btn->setAutoRepeat(true); + btn->setAutoRepeatDelay(400); + btn->setAutoRepeatInterval(90); + return btn; + }; + m_layout->addSpacing(K4Styles::Dimensions::PaddingLarge * 2 + K4Styles::Dimensions::PaddingSmall); + auto *tuneGrid = new QGridLayout(); + tuneGrid->setContentsMargins(0, 0, 0, 0); + tuneGrid->setHorizontalSpacing(K4Styles::Dimensions::PopupButtonSpacing); + tuneGrid->setVerticalSpacing(K4Styles::isCompactLayout() ? K4Styles::Dimensions::PopupButtonSpacing : 2); + m_tuneADownBtn = makeTuneBtn(QStringLiteral("A −")); + m_tuneAUpBtn = makeTuneBtn(QStringLiteral("A +")); + m_tuneBDownBtn = makeTuneBtn(QStringLiteral("B −")); + m_tuneBUpBtn = makeTuneBtn(QStringLiteral("B +")); + tuneGrid->addWidget(m_tuneADownBtn, 0, 0); + tuneGrid->addWidget(m_tuneAUpBtn, 0, 1); + tuneGrid->addWidget(m_tuneBDownBtn, 1, 0); + tuneGrid->addWidget(m_tuneBUpBtn, 1, 1); + m_layout->addLayout(tuneGrid); + + connect(m_tuneADownBtn, &QPushButton::clicked, this, [this]() { emit tuneARequested(-1); }); + connect(m_tuneAUpBtn, &QPushButton::clicked, this, [this]() { emit tuneARequested(1); }); + connect(m_tuneBDownBtn, &QPushButton::clicked, this, [this]() { emit tuneBRequested(-1); }); + connect(m_tuneBUpBtn, &QPushButton::clicked, this, [this]() { emit tuneBRequested(1); }); + } } QWidget *RightSidePanel::createFunctionButton(const QString &mainText, const QString &subText, QPushButton *&btnOut, bool isLighter) { - // The alternate action belongs inside the same touch target as its primary. + const bool compact = K4Styles::isCompactLayout(); auto *container = new QWidget(this); auto *layout = new QVBoxLayout(container); - layout->setContentsMargins(0, K4Styles::isCompactLayout() ? 0 : K4Styles::Dimensions::SeparatorHeight + 1, - 0, K4Styles::isCompactLayout() ? 0 : K4Styles::Dimensions::SeparatorHeight + 1); - layout->setSpacing(K4Styles::isCompactLayout() ? 0 : K4Styles::Dimensions::PaddingSmall); - - // Button - scaled down from bottom menu bar style (matching left panel TX buttons) - auto *btn = new DualLinePanelButton(mainText, subText, container); - btn->setFixedHeight(42); - btn->setCursor(Qt::PointingHandCursor); + layout->setContentsMargins(0, compact ? 0 : 1, 0, compact ? 0 : 3); + // Tight gap above the amber label so it clearly belongs to the button it + // sits under, with a larger gap below to the next row (matches macOS). + layout->setSpacing(compact ? 0 : 1); - if (isLighter) { - btn->setStyleSheet(K4Styles::sidePanelButtonLight()); + // Phone keeps both labels inside one touch target (DualLinePanelButton). + // iPad matches macOS/the radio: white primary on the button, amber + // alternate rendered on the case (a QLabel below), so long names like + // "DIVERSITY" are not clipped by the button width. + QPushButton *btn; + if (compact) { + btn = new DualLinePanelButton(mainText, subText, container); + btn->setFixedHeight(42); } else { - btn->setStyleSheet(K4Styles::sidePanelButton()); + btn = new QPushButton(mainText, container); + btn->setFixedHeight(K4Styles::Dimensions::ButtonHeightSmall); } + btn->setCursor(Qt::PointingHandCursor); + btn->setStyleSheet(isLighter ? K4Styles::sidePanelButtonLight() : K4Styles::sidePanelButton()); btnOut = btn; layout->addWidget(btn); + if (!compact) { + auto *subLabel = new QLabel(subText, container); + subLabel->setStyleSheet(QString("color: %1; font-size: %2px;") + .arg(K4Styles::Colors::AccentAmber) + .arg(K4Styles::Dimensions::FontSizeSmall)); + subLabel->setAlignment(Qt::AlignCenter); + subLabel->setFixedHeight(12); + layout->addWidget(subLabel); + } + return container; } @@ -236,6 +293,38 @@ void RightSidePanel::cancelPendingLongPress() { m_revBtn->setDown(false); } +void RightSidePanel::setSubActive(bool on) { + // LED only on regular layout (compact packs SUB/DIV into one custom button). + if (K4Styles::isCompactLayout() || !m_subBtn) + return; + if (on) + m_subBtn->setStyleSheet(QString("QPushButton { background-color: %1; color: black;" + "font-weight: bold; border-radius: 4px; }") + .arg(K4Styles::Colors::StatusGreen)); + else + m_subBtn->setStyleSheet(K4Styles::sidePanelButton()); +} + +void RightSidePanel::setDiversityActive(bool on) { + if (!m_diversityLabel) + return; + m_diversityLabel->setStyleSheet(QString("color: %1; font-size: %2px; font-weight: %3;") + .arg(on ? K4Styles::Colors::StatusGreen : K4Styles::Colors::AccentAmber) + .arg(K4Styles::Dimensions::FontSizeSmall) + .arg(on ? "bold" : "normal")); +} + +void RightSidePanel::setBSetActive(bool on) { + if (K4Styles::isCompactLayout() || !m_bsetBtn) + return; + if (on) + m_bsetBtn->setStyleSheet(QString("QPushButton { background-color: %1; color: black;" + "font-weight: bold; border-radius: 4px; }") + .arg(K4Styles::Colors::StatusGreen)); + else + m_bsetBtn->setStyleSheet(K4Styles::sidePanelButtonLight()); +} + bool RightSidePanel::eventFilter(QObject *watched, QEvent *event) { if (watched == m_revBtn) { if (event->type() == QEvent::MouseButtonPress) { diff --git a/src/ui/rightsidepanel.h b/src/ui/rightsidepanel.h index d101983..fa02940 100644 --- a/src/ui/rightsidepanel.h +++ b/src/ui/rightsidepanel.h @@ -45,6 +45,14 @@ class RightSidePanel : public QWidget { // Cancel an alternate-action hold when the phone drawer begins scrolling. void cancelPendingLongPress(); + // Green "LED" state on the SUB / DIVERSITY button (the radio shows these as + // LEDs; they were removed from the centre VFO area). Regular layout only. + void setSubActive(bool on); + void setDiversityActive(bool on); + // Green highlight on the B SET button while B SET (target Sub RX) is active, + // so the mode is easy to see. Regular layout only. + void setBSetActive(bool on); + signals: // Button click signals (main function - left click) void preClicked(); @@ -94,6 +102,10 @@ class RightSidePanel : public QWidget { void lockBClicked(); // LOCK A right-click (LOCK B) void diversityClicked(); // SUB right-click + // iPad fine-tune buttons (A-/A+/B-/B+). steps = +/-1 tuning increment. + void tuneARequested(int steps); + void tuneBRequested(int steps); + protected: bool eventFilter(QObject *watched, QEvent *event) override; @@ -128,6 +140,13 @@ class RightSidePanel : public QWidget { QPushButton *m_rateBtn; QPushButton *m_lockABtn; QPushButton *m_subBtn; + QLabel *m_diversityLabel = nullptr; // amber "DIVERSITY" sub-label; green when active + + // iPad fine-tune buttons (regular layout only; null on phone) + QPushButton *m_tuneADownBtn = nullptr; + QPushButton *m_tuneAUpBtn = nullptr; + QPushButton *m_tuneBDownBtn = nullptr; + QPushButton *m_tuneBUpBtn = nullptr; // Qt maps a desktop secondary action to a right click. Android has no // such gesture, so a held touch triggers the same alternate action. diff --git a/src/ui/sidecontrolpanel.cpp b/src/ui/sidecontrolpanel.cpp index 3eed68b..3c8b62d 100644 --- a/src/ui/sidecontrolpanel.cpp +++ b/src/ui/sidecontrolpanel.cpp @@ -1,5 +1,8 @@ #include "sidecontrolpanel.h" #include "dualcontrolbutton.h" +#include "adjustoverlay.h" +#include "monoverlay.h" +#include "baloverlay.h" #include "duallinepanelbutton.h" #include "k4styles.h" #include "../settings/radiosettings.h" @@ -14,6 +17,8 @@ #include #include #include +#include +#include SideControlPanel::SideControlPanel(QWidget *parent) : QWidget(parent) { m_longPressTimer = new QTimer(this); @@ -110,7 +115,13 @@ void SideControlPanel::setupUi() { auto *layout = new QVBoxLayout(this); layout->setContentsMargins(K4Styles::Dimensions::PaddingSmall, K4Styles::Dimensions::PopupButtonSpacing, K4Styles::Dimensions::PaddingSmall, K4Styles::Dimensions::PopupButtonSpacing); + // Tighter inter-row spacing on the Android tablet reclaims the vertical + // room needed to keep the bottom MAIN/SUB sliders on-screen. +#if defined(Q_OS_ANDROID) + layout->setSpacing(K4Styles::isCompactLayout() ? 4 : 2); +#else layout->setSpacing(4); // Default spacing between buttons in a group +#endif auto addAdjustmentRow = [this, layout](DualControlButton *button, QSlider *&slider, const QString &color) { auto *row = new QWidget(this); @@ -124,67 +135,92 @@ void SideControlPanel::setupUi() { slider->setStyleSheet(K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, color)); slider->installEventFilter(this); rowLayout->addWidget(slider, 1); + // iPad (macOS-style column): the thin per-tile rail is replaced by the + // long-press adjust popup, matching QK4 on macOS which has no rail. + // The slider stays wired (radio echoes keep it in sync) but hidden. + if (!K4Styles::isCompactLayout()) { + slider->hide(); + rowLayout->addStretch(1); + rowLayout->setAlignment(button, Qt::AlignHCenter); + } layout->addWidget(row); }; - // ===== Receiver AF controls: always first in the phone CTRL bank ===== - m_volumeLabel = new QLabel("A AF", this); + // ===== Receiver AF + local mic controls ===== + // Compact (phone CTRL bank): these lead the scrollable page. Regular + // (iPad): they move to the bottom of the always-visible column and the + // volumes are labelled MAIN/SUB, matching QK4 on macOS and the radio. + const bool afAtTop = K4Styles::isCompactLayout(); + auto *afGroup = new QWidget(this); + auto *afLayout = new QVBoxLayout(afGroup); + afLayout->setContentsMargins(0, 0, 0, 0); + afLayout->setSpacing(4); + + m_volumeLabel = new QLabel(afAtTop ? "A AF" : "MAIN", afGroup); m_volumeLabel->setStyleSheet( QString("color: %1; font-size: 10px; font-weight: bold;").arg(K4Styles::Colors::VfoACyan)); m_volumeLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(m_volumeLabel); + afLayout->addWidget(m_volumeLabel); - m_volumeSlider = new QSlider(Qt::Horizontal, this); + m_volumeSlider = new QSlider(Qt::Horizontal, afGroup); m_volumeSlider->setRange(0, 100); m_volumeSlider->setValue(RadioSettings::instance()->volume()); m_volumeSlider->setMinimumHeight(K4Styles::isCompactLayout() ? 32 : 24); m_volumeSlider->setStyleSheet( K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, K4Styles::Colors::VfoACyan)); m_volumeSlider->installEventFilter(this); - layout->addWidget(m_volumeSlider); + afLayout->addWidget(m_volumeSlider); connect(m_volumeSlider, &QSlider::valueChanged, this, &SideControlPanel::volumeChanged); - m_subVolumeLabel = new QLabel("B AF", this); + m_subVolumeLabel = new QLabel(afAtTop ? "B AF" : "SUB", afGroup); m_subVolumeLabel->setStyleSheet( QString("color: %1; font-size: 10px; font-weight: bold;").arg(K4Styles::Colors::VfoBGreen)); m_subVolumeLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(m_subVolumeLabel); + afLayout->addWidget(m_subVolumeLabel); - m_subVolumeSlider = new QSlider(Qt::Horizontal, this); + m_subVolumeSlider = new QSlider(Qt::Horizontal, afGroup); m_subVolumeSlider->setRange(0, 100); m_subVolumeSlider->setValue(RadioSettings::instance()->subVolume()); m_subVolumeSlider->setMinimumHeight(K4Styles::isCompactLayout() ? 32 : 24); m_subVolumeSlider->setStyleSheet( K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, K4Styles::Colors::VfoBGreen)); m_subVolumeSlider->installEventFilter(this); - layout->addWidget(m_subVolumeSlider); + afLayout->addWidget(m_subVolumeSlider); connect(m_subVolumeSlider, &QSlider::valueChanged, this, &SideControlPanel::subVolumeChanged); // Local input gain is intentionally separate from the K4 MIC control. // It scales the phone/headset microphone stream before Opus encoding. - m_phoneMicGainLabel = new QLabel("PHONE MIC", this); - m_phoneMicGainLabel->setStyleSheet( - QString("color: %1; font-size: 10px; font-weight: bold;").arg(K4Styles::Colors::AccentAmber)); - m_phoneMicGainLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(m_phoneMicGainLabel); - - m_phoneMicGainSlider = new QSlider(Qt::Horizontal, this); - m_phoneMicGainSlider->setRange(0, 100); - m_phoneMicGainSlider->setValue(RadioSettings::instance()->micGain()); - m_phoneMicGainSlider->setMinimumHeight(K4Styles::isCompactLayout() ? 32 : 24); - m_phoneMicGainSlider->setStyleSheet( - K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, K4Styles::Colors::AccentAmber)); - m_phoneMicGainSlider->installEventFilter(this); - layout->addWidget(m_phoneMicGainSlider); - connect(m_phoneMicGainSlider, &QSlider::valueChanged, this, &SideControlPanel::phoneMicGainChanged); - - layout->addSpacing(K4Styles::Dimensions::PaddingMedium); + // On iPad the K4 MIC gain is adjusted from the MIC/CMP tile, so this + // duplicate-looking PHONE MIC rail is omitted there; it stays on the + // phone layout where the tiles are less prominent. + if (K4Styles::isCompactLayout()) { + m_phoneMicGainLabel = new QLabel("PHONE MIC", afGroup); + m_phoneMicGainLabel->setStyleSheet( + QString("color: %1; font-size: 10px; font-weight: bold;").arg(K4Styles::Colors::AccentAmber)); + m_phoneMicGainLabel->setAlignment(Qt::AlignCenter); + afLayout->addWidget(m_phoneMicGainLabel); + + m_phoneMicGainSlider = new QSlider(Qt::Horizontal, afGroup); + m_phoneMicGainSlider->setRange(0, 100); + m_phoneMicGainSlider->setValue(RadioSettings::instance()->micGain()); + m_phoneMicGainSlider->setMinimumHeight(32); + m_phoneMicGainSlider->setStyleSheet( + K4Styles::sliderHorizontal(K4Styles::Colors::DarkBackground, K4Styles::Colors::AccentAmber)); + m_phoneMicGainSlider->installEventFilter(this); + afLayout->addWidget(m_phoneMicGainSlider); + connect(m_phoneMicGainSlider, &QSlider::valueChanged, this, &SideControlPanel::phoneMicGainChanged); + } + + if (afAtTop) { + layout->addWidget(afGroup); + layout->addSpacing(K4Styles::Dimensions::PaddingMedium); + } // ===== TX Function Buttons (2x3 grid) ===== auto *txGrid = new QGridLayout(); txGrid->setContentsMargins(0, 0, 0, 0); txGrid->setHorizontalSpacing(K4Styles::Dimensions::PopupButtonSpacing); - txGrid->setVerticalSpacing(K4Styles::Dimensions::PopupButtonSpacing); + txGrid->setVerticalSpacing(K4Styles::isCompactLayout() ? K4Styles::Dimensions::PopupButtonSpacing : 2); // Row 0: TUNE, XMIT txGrid->addWidget(createTxFunctionButton("TUNE", "TUNE LP", m_tuneBtn), 0, 0); @@ -220,6 +256,43 @@ void SideControlPanel::setupUi() { // ===== Spacing after TX buttons ===== layout->addSpacing(K4Styles::Dimensions::PaddingLarge); + // MON / NORM / BAL each sit under the control pair they act on, matching + // the K4 front panel. Full-width so they never crowd each other; taller on + // the iPad for touch, compact-mini on the phone. +#if defined(Q_OS_ANDROID) + // Android tablet: a compact MON/NORM/BAL height (independent of the shared + // ButtonHeightSmall, which the right panel needs at full size) so the left + // column fits. + const int swBtnHeight = K4Styles::isCompactLayout() ? K4Styles::Dimensions::ButtonHeightMini : 22; +#else + const int swBtnHeight = K4Styles::isCompactLayout() ? K4Styles::Dimensions::ButtonHeightMini + : K4Styles::Dimensions::ButtonHeightSmall; +#endif + auto addSwButton = [this, layout, swBtnHeight](QPushButton *&btn, const QString &text) { + btn = new QPushButton(text, this); + btn->setFixedHeight(swBtnHeight); + btn->setStyleSheet(K4Styles::compactButton()); + if (K4Styles::isCompactLayout()) { + layout->addWidget(btn); + } else { + // Align the button's visible box with the DualControlButton tiles + // above. Their painted box is inset inside the 90px widget by + // barWidth(5)+margin(1)+2 on the left and margin(1) on the right + // (see DualControlButton::paintEvent); a plain button paints to its + // own edge, so inset it by the same amounts to line the boxes up. + constexpr int tileBoxLeft = 5 + 1 + 2; + const int tileBoxWidth = K4Styles::Dimensions::MenuBarButtonWidth - tileBoxLeft - 1; + btn->setFixedWidth(tileBoxWidth); + auto *row = new QWidget(this); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(tileBoxLeft, 0, 0, 0); + rowLayout->setSpacing(0); + rowLayout->addWidget(btn); + rowLayout->addStretch(1); + layout->addWidget(row); + } + }; + // ===== Group 1: Global (CW/Power) - Orange bar ===== m_wpmBtn = new DualControlButton(this); m_wpmBtn->setPrimaryLabel("WPM"); @@ -239,6 +312,10 @@ void SideControlPanel::setupUi() { m_pwrBtn->setShowIndicator(false); // Second button starts inactive addAdjustmentRow(m_pwrBtn, m_pwrSlider, K4Styles::Colors::AccentAmber); + // MON: monitor level, under the WPM/PWR (MIC/PWR/CMP/DLY) group. + addSwButton(m_monBtn, QStringLiteral("MON")); + m_monBtn->setToolTip(QStringLiteral("Monitor (sidetone / TX audio) level")); + // ===== Spacing between groups ===== layout->addSpacing(K4Styles::Dimensions::PaddingLarge); @@ -261,15 +338,10 @@ void SideControlPanel::setupUi() { m_shiftBtn->setShowIndicator(false); // Second button starts inactive addAdjustmentRow(m_shiftBtn, m_shiftSlider, K4Styles::Colors::VfoACyan); - // NORM affects only the filter passband, so keep it in the filter group. - m_normBtn = new QPushButton(QStringLiteral("NORM"), this); - m_normBtn->setFixedHeight(32); - m_normBtn->setStyleSheet(K4Styles::compactButton()); + // NORM: normalize the filter passband, under the BW/SHFT (HI/LO) group. + addSwButton(m_normBtn, QStringLiteral("NORM")); m_normBtn->setAccessibleName(QStringLiteral("Normalize receive filter passband")); m_normBtn->setToolTip(QStringLiteral("Restore the current mode's nominal filter passband")); - m_normBtn->installEventFilter(this); - layout->addWidget(m_normBtn); - connect(m_normBtn, &QPushButton::clicked, this, &SideControlPanel::normalizeFilterRequested); // ===== Spacing between groups ===== layout->addSpacing(K4Styles::Dimensions::PaddingLarge); @@ -293,6 +365,42 @@ void SideControlPanel::setupUi() { m_subSqlBtn->setShowIndicator(false); // Second button starts inactive addAdjustmentRow(m_subSqlBtn, m_subSqlSlider, K4Styles::Colors::VfoBGreen); + // BAL: sub-RX audio balance, under the M.RF/S.SQL (M.SQL/S.RF) group. + addSwButton(m_balBtn, QStringLiteral("BAL")); + m_balBtn->setToolTip(QStringLiteral("Sub-RX audio balance")); + + // Overlays cover their control groups; construct after all groups exist so + // raise() in showOverGroup lands them on top. + m_monOverlay = new MonOverlay(this); + m_balOverlay = new BalOverlay(this); + + connect(m_normBtn, &QPushButton::clicked, this, &SideControlPanel::normalizeFilterRequested); + connect(m_monBtn, &QPushButton::clicked, this, [this]() { + emit monClicked(); + if (m_monOverlay->isVisible()) + m_monOverlay->hide(); + else + m_monOverlay->showOverGroup(m_wpmBtn, m_pwrBtn); + }); + connect(m_balBtn, &QPushButton::clicked, this, [this]() { + emit balClicked(); + if (m_balOverlay->isVisible()) + m_balOverlay->hide(); + else + m_balOverlay->showOverGroup(m_mainRfBtn, m_subSqlBtn); + }); + connect(m_monOverlay, &MonOverlay::levelChangeRequested, + this, &SideControlPanel::monLevelChangeRequested); + connect(m_balOverlay, &BalOverlay::balanceChangeRequested, + this, &SideControlPanel::balChangeRequested); + + // Regular (iPad): MAIN/SUB volumes and PHONE MIC sit at the bottom of the + // column, as on QK4 for macOS, instead of leading it. + if (!afAtTop) { + layout->addSpacing(K4Styles::Dimensions::PaddingMedium); + layout->addWidget(afGroup); + } + // ===== Stretch to push status/icons to bottom ===== layout->addStretch(); @@ -310,6 +418,23 @@ void SideControlPanel::setupUi() { m_voltageCurrentLabel->setStyleSheet(QString("color: %1; font-size: 11px;").arg(K4Styles::Colors::TextWhite)); layout->addWidget(m_voltageCurrentLabel); +#if defined(Q_OS_ANDROID) + // On the Android tablet the top status bar already shows time / power-SWR / + // voltage-current, so hide these duplicates here (kept as members so the + // radio-state setters still update them harmlessly). The version line below + // stays — it isn't in the top bar. Compact phone keeps all of them. + if (!K4Styles::isCompactLayout()) { + m_timeLabel->hide(); + m_powerSwrLabel->hide(); + m_voltageCurrentLabel->hide(); + } +#endif + + // App version, lower-left, matching QK4 on macOS. + auto *versionLabel = new QLabel(QString("v%1").arg(QCoreApplication::applicationVersion()), this); + versionLabel->setStyleSheet(QString("color: %1; font-size: 10px;").arg(K4Styles::Colors::InactiveGray)); + layout->addWidget(versionLabel); + layout->addSpacing(K4Styles::Dimensions::PopupButtonSpacing); // ===== Connect Group 1 signals (WPM/PWR) ===== @@ -429,6 +554,60 @@ void SideControlPanel::setupUi() { configureAdjustmentSlider(m_shiftBtn, m_shiftSlider); configureAdjustmentSlider(m_mainRfBtn, m_mainRfSlider); configureAdjustmentSlider(m_subSqlBtn, m_subSqlSlider); + + // iPad: a long-press on any value tile opens the touch adjust popup. + for (DualControlButton *btn : {m_wpmBtn, m_pwrBtn, m_bwBtn, m_shiftBtn, m_mainRfBtn, m_subSqlBtn}) { + connect(btn, &DualControlButton::adjustRequested, this, [this, btn]() { openAdjustOverlay(btn); }); + } +} + +void SideControlPanel::openAdjustOverlay(DualControlButton *button) { + if (!button) + return; + + if (!m_adjustOverlay) { + m_adjustOverlay = new AdjustOverlay(window()); + connect(m_adjustOverlay->slider(), &QSlider::valueChanged, this, [this](int value) { + QSlider *s = m_adjustOverlay->slider(); + const int previous = s->property("lastRadioValue").toInt(); + s->setProperty("lastRadioValue", value); + const int delta = value - previous; + if (delta == 0 || !m_adjustButton) + return; + // Route through the same per-control handlers the tiles/rail use. + if (m_adjustButton == m_wpmBtn) + onWpmScrolled(delta); + else if (m_adjustButton == m_pwrBtn) + onPwrScrolled(delta); + else if (m_adjustButton == m_bwBtn) + onBwScrolled(delta); + else if (m_adjustButton == m_shiftBtn) + onShiftScrolled(delta); + else if (m_adjustButton == m_mainRfBtn) + onMainRfScrolled(delta); + else if (m_adjustButton == m_subSqlBtn) + onSubSqlScrolled(delta); + }); + } + + m_adjustButton = button; + configureAdjustmentSlider(button, m_adjustOverlay->slider()); + m_adjustOverlay->configure(button->primaryLabel(), button->context()); + + // Show the readout in the control's real units (kHz for the filter + // controls, seconds for DLY) instead of the raw slider integer. + const auto kHzFromHz = [](double hz) { return QString::number(hz / 1000.0, 'f', 2); }; + std::function fmt; // empty => raw integer + if (button == m_bwBtn) + fmt = m_bwIsPrimary ? std::function([kHzFromHz](int v) { return kHzFromHz(v * 50.0); }) // BW + : std::function([kHzFromHz](int v) { return kHzFromHz(v * 10.0); }); // HI + else if (button == m_shiftBtn) + fmt = [kHzFromHz](int v) { return kHzFromHz(v * 10.0); }; // SHFT / LO (10 Hz units) + else if (button == m_pwrBtn && !m_pwrIsPrimary) + fmt = [](int v) { return QString::number(v / 100.0, 'f', 2); }; // DLY seconds + m_adjustOverlay->setValueFormatter(fmt); + + m_adjustOverlay->showOver(button); } void SideControlPanel::configureAdjustmentSlider(DualControlButton *button, QSlider *slider) { @@ -881,20 +1060,47 @@ void SideControlPanel::setCurrent(double amps) { QWidget *SideControlPanel::createTxFunctionButton(const QString &mainText, const QString &subText, QPushButton *&btnOut) { + const bool compact = K4Styles::isCompactLayout(); // Container widget for button + sub-text label auto *container = new QWidget(this); auto *layout = new QVBoxLayout(container); - layout->setContentsMargins(0, K4Styles::Dimensions::SeparatorHeight + 1, 0, K4Styles::Dimensions::SeparatorHeight + 1); - layout->setSpacing(K4Styles::Dimensions::PaddingSmall); - - // Keep both the primary and amber alternate action inside one touch target. - auto *btn = new DualLinePanelButton(mainText, subText, container); - btn->setFixedHeight(42); + layout->setContentsMargins(0, compact ? 0 : 1, 0, compact ? 0 : 3); + // Tight gap above the amber label so it clearly belongs to the button it + // sits under, with a larger gap below to the next row (matches macOS). + layout->setSpacing(compact ? 0 : 1); + + // Phone keeps both labels inside one touch target. iPad matches the radio + // and macOS: white primary on the button, amber alternate on the case + // (a QLabel below the button). + QPushButton *btn; + if (compact) { + btn = new DualLinePanelButton(mainText, subText, container); + btn->setFixedHeight(42); + } else { + btn = new QPushButton(mainText, container); + btn->setFixedHeight(K4Styles::Dimensions::ButtonHeightSmall); + } btn->setCursor(Qt::PointingHandCursor); - btn->setStyleSheet(K4Styles::sidePanelButtonLight()); + // A two-line label (e.g. "ATU\nTUNE") clips inside the single-line tile + // height; drop its font a touch and remove padding so both lines fit and + // read clearly without growing the (already tight) left column. + QString btnStyle = K4Styles::sidePanelButtonLight(); + if (!compact && mainText.contains('\n')) + btnStyle += QStringLiteral(" QPushButton { font-size: 10px; padding: 0px; }"); + btn->setStyleSheet(btnStyle); btnOut = btn; layout->addWidget(btn); + if (!compact) { + auto *subLabel = new QLabel(subText, container); + subLabel->setStyleSheet(QString("color: %1; font-size: %2px;") + .arg(K4Styles::Colors::AccentAmber) + .arg(K4Styles::Dimensions::FontSizeSmall)); + subLabel->setAlignment(Qt::AlignCenter); + subLabel->setFixedHeight(12); + layout->addWidget(subLabel); + } + return container; } @@ -1070,3 +1276,18 @@ void SideControlPanel::triggerSecondary(QObject *watched) { else if (watched == m_antBtn) emit remAntClicked(); else if (watched == m_rxAntBtn) emit subAntClicked(); } + +void SideControlPanel::updateMonitorLevel(int mode, int level) { + if (m_monOverlay && m_monOverlay->mode() == mode) + m_monOverlay->setValue(level); +} + +void SideControlPanel::updateMonitorMode(int mode) { + if (m_monOverlay) + m_monOverlay->setMode(mode); +} + +void SideControlPanel::updateBalance(int mode, int offset) { + if (m_balOverlay) + m_balOverlay->setBalance(mode, offset); +} diff --git a/src/ui/sidecontrolpanel.h b/src/ui/sidecontrolpanel.h index f76bc91..1d27cb0 100644 --- a/src/ui/sidecontrolpanel.h +++ b/src/ui/sidecontrolpanel.h @@ -8,6 +8,9 @@ #include class DualControlButton; +class AdjustOverlay; +class MonOverlay; +class BalOverlay; class QGridLayout; class QScrollArea; @@ -95,6 +98,14 @@ class SideControlPanel : public QWidget { // Cancel an alternate-action hold when a containing phone panel begins scrolling. void cancelPendingLongPress(); +public slots: + // Monitor level from radio (mode 0=CW/1=Data/2=Voice); updates MON overlay. + void updateMonitorLevel(int mode, int level); + // Track current monitor mode so ML commands target the right register. + void updateMonitorMode(int mode); + // Sub-AF balance from radio (mode 0=NOR/1=BAL, offset -50..+50). + void updateBalance(int mode, int offset); + signals: // TX Function button signals (left-click = primary, right-click = secondary) void tuneClicked(); // TUNE - SW16; @@ -109,6 +120,8 @@ class SideControlPanel : public QWidget { void remAntClicked(); // REM ANT - TBD void rxAntClicked(); // RX ANT - SW70; void subAntClicked(); // SUB ANT - SW157; + void monClicked(); // MON - SW128; + void balClicked(); // BAL - SW130; // Value changed signals (emitted when user scrolls to change value) // CW mode signals @@ -137,6 +150,11 @@ class SideControlPanel : public QWidget { // Restore the current mode's nominal filter passband. void normalizeFilterRequested(); + // Monitor level edited on the MON overlay (mode 0/1/2, level 0-100). + void monLevelChangeRequested(int mode, int level); + // Sub-AF balance edited on the BAL overlay (mode 0/1, offset -50..+50). + void balChangeRequested(int mode, int offset); + private slots: // Group 1: WPM/PWR - handle activation and scrolling void onWpmBecameActive(); @@ -166,6 +184,7 @@ private slots: void setupUi(); void triggerSecondary(QObject *watched); void configureAdjustmentSlider(DualControlButton *button, QSlider *slider); + void openAdjustOverlay(DualControlButton *button); void setSliderValueFromTouchPosition(QSlider *slider, int xPosition); QScrollArea *containingScrollArea() const; void setGroup1Active(DualControlButton *activeBtn); @@ -204,6 +223,11 @@ private slots: QSlider *m_mainRfSlider = nullptr; QSlider *m_subSqlSlider = nullptr; + // iPad touch adjust popup (shared, one at a time). m_adjustButton is the + // control whose value the popup slider currently drives. + AdjustOverlay *m_adjustOverlay = nullptr; + DualControlButton *m_adjustButton = nullptr; + int m_wpmValue = 20; int m_pitchValue = 600; int m_micValue = 0; @@ -257,12 +281,15 @@ private slots: QLabel *m_volumeLabel; QSlider *m_subVolumeSlider; QLabel *m_subVolumeLabel; - QSlider *m_phoneMicGainSlider; - QLabel *m_phoneMicGainLabel; + QSlider *m_phoneMicGainSlider = nullptr; + QLabel *m_phoneMicGainLabel = nullptr; - // NORM stays with filter controls. K4 MON and BAL are intentionally - // omitted from the remote UI; A AF and B AF provide independent levels. + // MON / NORM / BAL row, grouped as on the K4 and QK4 for macOS. + QPushButton *m_monBtn = nullptr; QPushButton *m_normBtn = nullptr; + QPushButton *m_balBtn = nullptr; + MonOverlay *m_monOverlay = nullptr; + BalOverlay *m_balOverlay = nullptr; }; #endif // SIDECONTROLPANEL_H diff --git a/src/ui/txmeterwidget.cpp b/src/ui/txmeterwidget.cpp index 5f9931b..84b231b 100644 --- a/src/ui/txmeterwidget.cpp +++ b/src/ui/txmeterwidget.cpp @@ -8,7 +8,7 @@ TxMeterWidget::TxMeterWidget(QWidget *parent) : QWidget(parent) { // phone it is a compact status meter; leaving the desktop 130px minimum // here forces the entire operating dock below the visible viewport. const bool compact = K4Styles::isCompactLayout(); - setFixedHeight(compact ? 56 : 130); + setFixedHeight(compact ? 56 : K4Styles::Dimensions::VfoMeterHeight); setMinimumWidth(compact ? 130 : 200); setMaximumWidth(compact ? 150 : 380); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); @@ -151,6 +151,14 @@ void TxMeterWidget::setSMeter(double sValue) { update(); } +void TxMeterWidget::setSMeterColor(const QColor &color) { + if (m_sMeterColor != color) { + m_sMeterColor = color; + if (!m_isTransmitting) + update(); + } +} + void TxMeterWidget::setTransmitting(bool isTx) { if (m_isTransmitting != isTx) { m_isTransmitting = isTx; @@ -259,8 +267,10 @@ void TxMeterWidget::paintEvent(QPaintEvent *event) { peakValue = m_powerPeak; } + // RX S-meter uses the VFO colour (A cyan, B green); TX Po uses the + // standard power gradient. drawMeterRow(painter, y, rowHeight, compact ? "S" : "S/Po", displayValue, peakValue, labels, scaleFont, barStartX, barWidth, - barHeight, MeterType::Gradient); + barHeight, m_isTransmitting ? MeterType::Gradient : MeterType::SMeter); y += rowHeight + spacing; } @@ -324,18 +334,23 @@ void TxMeterWidget::drawMeterRow(QPainter &painter, int y, int rowHeight, const // Filled meter bar if (fillRatio > 0.001) { int fillWidth = static_cast(barWidth * fillRatio); - QLinearGradient gradient(barStartX, 0, barStartX + barWidth, 0); - if (type == MeterType::Gradient) { - // Standard meter gradient: green → yellow → orange → red - gradient = K4Styles::meterGradient(barStartX, 0, barStartX + barWidth, 0); + if (type == MeterType::SMeter) { + // RX S-meter: solid VFO colour (A cyan, B green), matching the radio. + painter.fillRect(barStartX + 1, barY + 1, fillWidth - 2, barHeight - 2, m_sMeterColor); } else { - // Red style for Id meter (PA drain current) - gradient.setColorAt(0.0, QColor(K4Styles::Colors::MeterIdDark)); - gradient.setColorAt(0.7, QColor(K4Styles::Colors::MeterIdDark)); - gradient.setColorAt(1.0, QColor(K4Styles::Colors::MeterIdLight)); + QLinearGradient gradient(barStartX, 0, barStartX + barWidth, 0); + if (type == MeterType::Gradient) { + // Standard meter gradient: green → yellow → orange → red + gradient = K4Styles::meterGradient(barStartX, 0, barStartX + barWidth, 0); + } else { + // Red style for Id meter (PA drain current) + gradient.setColorAt(0.0, QColor(K4Styles::Colors::MeterIdDark)); + gradient.setColorAt(0.7, QColor(K4Styles::Colors::MeterIdDark)); + gradient.setColorAt(1.0, QColor(K4Styles::Colors::MeterIdLight)); + } + painter.fillRect(barStartX + 1, barY + 1, fillWidth - 2, barHeight - 2, gradient); } - painter.fillRect(barStartX + 1, barY + 1, fillWidth - 2, barHeight - 2, gradient); } // Draw peak indicator diff --git a/src/ui/txmeterwidget.h b/src/ui/txmeterwidget.h index bebd072..a3bb54b 100644 --- a/src/ui/txmeterwidget.h +++ b/src/ui/txmeterwidget.h @@ -3,6 +3,8 @@ #include #include +#include +#include "k4styles.h" /** * TxMeterWidget - Multi-function TX meter display (IC-7760 style) @@ -38,6 +40,9 @@ class TxMeterWidget : public QWidget { void setSMeter(double sValue); // S-units (0-9 for S1-S9, 9+ for +dB over S9) void setTransmitting(bool isTx); // Switch between RX (S-meter) and TX (Po) mode + // Fill colour for the RX S-meter bar (per VFO: A cyan, B green). + void setSMeterColor(const QColor &color); + protected: void paintEvent(QPaintEvent *event) override; @@ -90,7 +95,10 @@ private slots: static constexpr int PeakHoldTicks = 10; // 500ms hold time (10 × 50ms) // Meter types for color selection - enum class MeterType { Gradient, Red }; + enum class MeterType { Gradient, Red, SMeter }; + + // RX S-meter fill colour (VFO A cyan, VFO B green). Default green. + QColor m_sMeterColor = QColor(K4Styles::Colors::VfoBGreen); // Drawing helpers void drawMeterRow(QPainter &painter, int y, int rowHeight, const QString &label, double fillRatio, double peakRatio, diff --git a/src/ui/vforowwidget.cpp b/src/ui/vforowwidget.cpp index 72d34fb..1a00014 100644 --- a/src/ui/vforowwidget.cpp +++ b/src/ui/vforowwidget.cpp @@ -1,4 +1,5 @@ #include "vforowwidget.h" +#include "filterindicatorwidget.h" #include "k4styles.h" #include #include @@ -61,7 +62,27 @@ void VfoSquareWidget::paintEvent(QPaintEvent *) { VfoRowWidget::VfoRowWidget(QWidget *parent) : QWidget(parent) { setupWidgets(); - setFixedHeight(K4Styles::Dimensions::VfoRowHeight); + recomputeHeight(); +} + +void VfoRowWidget::recomputeHeight() { + // Tall enough for the tallest of the three columns: the A/B square + mode + + // filter stacks, and the centre column (TX glyph + any SPLIT/MSG/RIT stack). + m_vfoAContainer->adjustSize(); + m_vfoBContainer->adjustSize(); + m_txContainer->adjustSize(); + const int stacked = qMax(m_txContainer->sizeHint().height(), + qMax(m_vfoAContainer->sizeHint().height(), + m_vfoBContainer->sizeHint().height())); + setFixedHeight(qMax(K4Styles::Dimensions::VfoRowHeight, stacked)); +} + +void VfoRowWidget::addToCenterColumn(QWidget *w) { + if (!m_txColumn) + return; + m_txColumn->addWidget(w, 0, Qt::AlignHCenter); + recomputeHeight(); + positionWidgets(); } void VfoRowWidget::setLockA(bool locked) { @@ -76,17 +97,19 @@ void VfoRowWidget::setupWidgets() { // No layout manager - we use absolute positioning // All containers are children of this widget // === VFO A Container (square + mode label) === + // The VFO column is as wide as the filter indicator that sits under it. + const int filterW = 62; m_vfoAContainer = new QWidget(this); - m_vfoAContainer->setFixedWidth(K4Styles::Dimensions::VfoSquareSize); + m_vfoAContainer->setFixedWidth(filterW); auto *vfoAColumn = new QVBoxLayout(m_vfoAContainer); vfoAColumn->setContentsMargins(0, 0, 0, 0); - vfoAColumn->setSpacing(2); + vfoAColumn->setSpacing(1); m_vfoASquare = new VfoSquareWidget("A", QColor(K4Styles::Colors::VfoACyan), m_vfoAContainer); vfoAColumn->addWidget(m_vfoASquare, 0, Qt::AlignHCenter); m_modeALabel = new QLabel("USB", m_vfoAContainer); - m_modeALabel->setFixedWidth(K4Styles::Dimensions::VfoSquareSize); + m_modeALabel->setFixedWidth(filterW); m_modeALabel->setAlignment(Qt::AlignCenter); m_modeALabel->setCursor(Qt::PointingHandCursor); m_modeALabel->setStyleSheet(QString("color: %1; font-size: %2px; font-weight: bold;") @@ -94,11 +117,16 @@ void VfoRowWidget::setupWidgets() { .arg(K4Styles::Dimensions::FontSizeLarge)); vfoAColumn->addWidget(m_modeALabel, 0, Qt::AlignHCenter); + // VFO A filter indicator, directly under the square+mode (like the radio). + m_filterAWidget = new FilterIndicatorWidget(m_vfoAContainer); + vfoAColumn->addWidget(m_filterAWidget, 0, Qt::AlignHCenter); + // === TX Container (TEST label + triangles + TX) === m_txContainer = new QWidget(this); auto *txVLayout = new QVBoxLayout(m_txContainer); txVLayout->setContentsMargins(0, 0, 0, 0); txVLayout->setSpacing(0); + m_txColumn = txVLayout; // widgets stacked here sit under the TX glyph // TEST indicator - hidden by default // TEST is positioned independently from the TX container below. Keeping it @@ -111,10 +139,13 @@ void VfoRowWidget::setupWidgets() { m_testLabel->setVisible(false); // TX row (triangles + TX label) + // Stretches keep the TX glyph centred when the column widens to hold the + // SPLIT/MSG/RIT stack beneath it. auto *txIndicatorRow = new QHBoxLayout(); txIndicatorRow->setSpacing(0); + txIndicatorRow->addStretch(); - m_txTriangle = new QLabel(QString::fromUtf8("\u25C0"), m_txContainer); // ◀ + m_txTriangle = new QLabel(QString::fromUtf8("\u25C0"), m_txContainer); //◀ m_txTriangle->setFixedSize(K4Styles::Dimensions::ButtonHeightMini, K4Styles::Dimensions::ButtonHeightMini); m_txTriangle->setAlignment(Qt::AlignCenter); m_txTriangle->setStyleSheet(QString("color: %1; font-size: 18px;").arg(K4Styles::Colors::AccentAmber)); @@ -130,6 +161,7 @@ void VfoRowWidget::setupWidgets() { m_txTriangleB->setAlignment(Qt::AlignCenter); m_txTriangleB->setStyleSheet(QString("color: %1; font-size: 18px;").arg(K4Styles::Colors::AccentAmber)); txIndicatorRow->addWidget(m_txTriangleB); + txIndicatorRow->addStretch(); txVLayout->addLayout(txIndicatorRow); @@ -138,16 +170,16 @@ void VfoRowWidget::setupWidgets() { // === VFO B Container (square + mode label) === m_vfoBContainer = new QWidget(this); - m_vfoBContainer->setFixedWidth(K4Styles::Dimensions::VfoSquareSize); + m_vfoBContainer->setFixedWidth(filterW); auto *vfoBColumn = new QVBoxLayout(m_vfoBContainer); vfoBColumn->setContentsMargins(0, 0, 0, 0); - vfoBColumn->setSpacing(2); + vfoBColumn->setSpacing(1); m_vfoBSquare = new VfoSquareWidget("B", QColor(K4Styles::Colors::VfoBGreen), m_vfoBContainer); vfoBColumn->addWidget(m_vfoBSquare, 0, Qt::AlignHCenter); m_modeBLabel = new QLabel("USB", m_vfoBContainer); - m_modeBLabel->setFixedWidth(K4Styles::Dimensions::VfoSquareSize); + m_modeBLabel->setFixedWidth(filterW); m_modeBLabel->setAlignment(Qt::AlignCenter); m_modeBLabel->setCursor(Qt::PointingHandCursor); m_modeBLabel->setStyleSheet(QString("color: %1; font-size: %2px; font-weight: bold;") @@ -155,6 +187,10 @@ void VfoRowWidget::setupWidgets() { .arg(K4Styles::Dimensions::FontSizeLarge)); vfoBColumn->addWidget(m_modeBLabel, 0, Qt::AlignHCenter); + // VFO B filter indicator, directly under the square+mode. + m_filterBWidget = new FilterIndicatorWidget(m_vfoBContainer); + vfoBColumn->addWidget(m_filterBWidget, 0, Qt::AlignHCenter); + // === SUB/DIV Container === m_subDivContainer = new QWidget(this); auto *subDivStack = new QVBoxLayout(m_subDivContainer); @@ -188,6 +224,9 @@ void VfoRowWidget::setupWidgets() { subDivStack->addWidget(m_divLabel); m_subDivContainer->adjustSize(); + // SUB/DIV are not shown in the centre VFO area (the radio shows them as LEDs + // elsewhere). State is reflected on the right panel's SUB/DIVERSITY button. + m_subDivContainer->hide(); } void VfoRowWidget::resizeEvent(QResizeEvent *event) { diff --git a/src/ui/vforowwidget.h b/src/ui/vforowwidget.h index ec7b4b7..e567ba3 100644 --- a/src/ui/vforowwidget.h +++ b/src/ui/vforowwidget.h @@ -6,6 +6,8 @@ #include #include +class FilterIndicatorWidget; + /** * VfoSquareWidget - Custom painted VFO A/B indicator with lock arc * @@ -58,6 +60,14 @@ class VfoRowWidget : public QWidget { QLabel *testLabel() const { return m_testLabel; } QLabel *subLabel() const { return m_subLabel; } QLabel *divLabel() const { return m_divLabel; } + // Filter indicators live under each VFO square+mode (like the radio). + FilterIndicatorWidget *filterAWidget() const { return m_filterAWidget; } + FilterIndicatorWidget *filterBWidget() const { return m_filterBWidget; } + + // Stack a widget in the centre (TX) column, beneath the TX glyph. Used to + // pull SPLIT / MSG / RIT-XIT up between the two VFO filters, as on the + // radio. Reparents w and regrows the row to fit. + void addToCenterColumn(QWidget *w); protected: void resizeEvent(QResizeEvent *event) override; @@ -65,6 +75,10 @@ class VfoRowWidget : public QWidget { private: void setupWidgets(); void positionWidgets(); + void recomputeHeight(); + + // TX (centre) column layout, so extra widgets can be stacked under TX. + QVBoxLayout *m_txColumn = nullptr; // Containers (absolute positioned within this widget) QWidget *m_vfoAContainer; @@ -84,6 +98,8 @@ class VfoRowWidget : public QWidget { QLabel *m_testLabel; QLabel *m_subLabel; QLabel *m_divLabel; + FilterIndicatorWidget *m_filterAWidget = nullptr; + FilterIndicatorWidget *m_filterBWidget = nullptr; }; #endif // VFOROWWIDGET_H diff --git a/src/ui/vfowidget.cpp b/src/ui/vfowidget.cpp index 9e39286..3fcbf0c 100644 --- a/src/ui/vfowidget.cpp +++ b/src/ui/vfowidget.cpp @@ -48,6 +48,14 @@ void VFOWidget::setupUi() { freqContainerLayout->addWidget(m_frequencyDisplay); freqContainerLayout->addStretch(); + // Match the radio: the A frequency is right-aligned so its right edge lines + // up with the right edge of the A meter block, not the left. Right-align the + // digits within the display (whose right edge already coincides with the + // meter's, both filling the column). Regular layout only; the phone console + // keeps its own left-aligned placement. + if (m_type == VFO_A && !K4Styles::isCompactLayout()) + m_frequencyDisplay->setRightAligned(true); + if (m_type == VFO_A) { freqRow->addWidget(freqContainer); freqRow->addStretch(); @@ -78,6 +86,8 @@ void VFOWidget::setupUi() { // Meter fills full width of normal content (both are 200px) m_txMeter = new TxMeterWidget(m_normalContent); m_txMeter->setFixedWidth(K4Styles::Dimensions::VfoMeterWidth); + m_txMeter->setSMeterColor( + QColor(m_type == VFO_A ? K4Styles::Colors::VfoACyan : K4Styles::Colors::VfoBGreen)); normalLayout->addWidget(m_txMeter); // Row 3: AGC, PRE, ATT, NB, NR labels (aligned with meter)