diff --git a/docs/style/aethersdr-style-guide.md b/docs/style/aethersdr-style-guide.md index a330e63a23..10acae437f 100644 --- a/docs/style/aethersdr-style-guide.md +++ b/docs/style/aethersdr-style-guide.md @@ -115,7 +115,7 @@ windows" lists what it keeps (the `FramelessMoveHelper` drag, Escape and Which windows the canon covers: short-lived, informational windows (About, Remote Access, and the candidates listed on #6239, once the maintainer picks them), and the tool windows the maintainer has moved to it (Updates, Waveforms, -Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Radio Setup, TX Band Settings, Connect to Radio, AGC-T Calibration, Icom RC-28 Button Mapping, FFTW Wisdom). Other tool and workspace dialogs keep +Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Radio Setup, TX Band Settings, Connect to Radio, AGC-T Calibration, Icom RC-28 Button Mapping, FFTW Wisdom, Ulanzi Dial — Control Mapping). Other tool and workspace dialogs keep `PersistentDialog` and `FramelessWindowTitleBar` as `docs/agents/gui.md` describes. For windows the canon covers, `FramelessWindowTitleBar` is deprecated: those dialogs move to diff --git a/docs/style/dialog-patterns.md b/docs/style/dialog-patterns.md index 194beb3a68..a6c81cb640 100644 --- a/docs/style/dialog-patterns.md +++ b/docs/style/dialog-patterns.md @@ -234,7 +234,7 @@ replace these with a single `QSet>` walk. A window built on `CanonWindow` (`src/gui/CanonWindow.{h,cpp}`, RFC #6226) deliberately opts out of frameless chrome integration, and by default of geometry persistence too. About AetherSDR, Remote Access (Tailscale), -Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Radio Setup, TX Band Settings, Connect to Radio (`ConnectionPanel`), AGC-T Calibration, Icom RC-28 Button Mapping, NR2's FFTW Wisdom window and the AetherRX / AetherTX Settings (profile library) dialogs are built on it. +Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Radio Setup, TX Band Settings, Connect to Radio (`ConnectionPanel`), AGC-T Calibration, Icom RC-28 Button Mapping, NR2's FFTW Wisdom window, Ulanzi Dial — Control Mapping and the AetherRX / AetherTX Settings (profile library) dialogs are built on it. | Concern | `CanonWindow` | Why | |---|---|---| @@ -271,8 +271,10 @@ What it keeps: It is translucent, so the rounded corners need a compositor; an X11 session without one shows square black corners. Use `CanonWindow` only for windows the style guide covers: short-lived windows, and the tool windows the maintainer -has moved to it (Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Icom RC-28 Button Mapping, Radio Setup, TX Band Settings, FFTW Wisdom). A tool window on it also -installs `FramelessResizer`, since `CanonWindow` itself only moves; a +has moved to it (Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Icom RC-28 Button Mapping, Radio Setup, TX Band Settings, FFTW Wisdom, Ulanzi Dial — Control Mapping). A tool window on it also +installs `FramelessResizer`, since `CanonWindow` itself only moves, unless its +contents are placed at fixed positions: Ulanzi Dial — Control Mapping keeps a +fixed size, because its pills sit at fixed points on the dial picture. A short-lived one such as FFTW Wisdom needs neither the resizer nor saved geometry. Other tool and workspace dialogs keep the persistent pattern above. diff --git a/docs/style/theme-style-guide.md b/docs/style/theme-style-guide.md index aa7a45c5a9..dcd34382c7 100644 --- a/docs/style/theme-style-guide.md +++ b/docs/style/theme-style-guide.md @@ -43,8 +43,8 @@ checked-toggle accent red where the root theme has it blue — scope paths are slash-joined, and a widget opts in by carrying the path in its `themeContainer` property). The `canon` scope does the same for whole windows: AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, AetherControl, the multiFLEX Dashboard, Radio Setup, -TX Band Settings, Connect to Radio, AGC-T Calibration, Icom RC-28 Button Mapping and FFTW Wisdom sit under `canon/aetherRx`, `canon/aetherTx`, `canon/aetherModem`, -`canon/aetherMap`, `canon/aetherGps`, `canon/spotHub`, `canon/aetherControl`, `canon/multiFlex`, `canon/radioSetup`, `canon/txBand`, `canon/connection`, `canon/agcCalibration`, `canon/rc28Mapping` and `canon/nr2Wisdom`, and it maps the base tokens onto the canon palette +TX Band Settings, Connect to Radio, AGC-T Calibration, Icom RC-28 Button Mapping, FFTW Wisdom and Ulanzi Dial — Control Mapping sit under `canon/aetherRx`, `canon/aetherTx`, `canon/aetherModem`, +`canon/aetherMap`, `canon/aetherGps`, `canon/spotHub`, `canon/aetherControl`, `canon/multiFlex`, `canon/radioSetup`, `canon/txBand`, `canon/connection`, `canon/agcCalibration`, `canon/rc28Mapping`, `canon/nr2Wisdom` and `canon/ulanziDial`, and it maps the base tokens onto the canon palette there (see [`aethersdr-style-guide.md`](aethersdr-style-guide.md)). The widget-aware accessors below resolve scope automatically — prefer them inside applets and anything painted inside a scoped window. diff --git a/docs/user/docs/ulanzi-dial.md b/docs/user/docs/ulanzi-dial.md index 282d9a3464..c97222fd88 100644 --- a/docs/user/docs/ulanzi-dial.md +++ b/docs/user/docs/ulanzi-dial.md @@ -46,6 +46,8 @@ Open **Settings → Controllers → Ulanzi Dial Mapping...** ("Ulanzi Dial — C - a **Last event** readout that names the last key or rotation the dial sent and the action it ran; - **Reset to Defaults**. +Close it with **Close**, the corner ✕, Esc or Ctrl+W (⌘W on macOS). + Mappings are saved with your settings and survive a restart; a mapping you clear stays cleared. ### Keys and knob press diff --git a/src/gui/MainWindow_Menus.cpp b/src/gui/MainWindow_Menus.cpp index e9b5c3d69f..691022e2d9 100644 --- a/src/gui/MainWindow_Menus.cpp +++ b/src/gui/MainWindow_Menus.cpp @@ -627,8 +627,17 @@ void MainWindow::buildMenuBar() #else MidiControlManager* midi = nullptr; #endif - showOrRaisePersistent(m_ulanziMapperDialog, m_dialBackend, - &m_shortcutManager, midi); + // A canon dialog, so not showOrRaisePersistent(): that applies View → + // Frameless Window, which a CanonWindow does not follow. + if (!m_ulanziMapperDialog) { + auto* dlg = new UlanziDialMapperDialog(m_dialBackend, &m_shortcutManager, + midi, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + m_ulanziMapperDialog = dlg; + } + m_ulanziMapperDialog->show(); + m_ulanziMapperDialog->raise(); + m_ulanziMapperDialog->activateWindow(); }); settingsMenu->addSeparator(); auto* usbCablesAction = settingsMenu->addAction("USB Cables..."); diff --git a/src/gui/UlanziDialMapperDialog.cpp b/src/gui/UlanziDialMapperDialog.cpp index e550d5b4ae..6befda3167 100644 --- a/src/gui/UlanziDialMapperDialog.cpp +++ b/src/gui/UlanziDialMapperDialog.cpp @@ -1,6 +1,8 @@ #include #include "UlanziDialMapperDialog.h" +#include "CanonIndicators.h" +#include "ModemChrome.h" #include "core/AppSettings.h" #include "core/ShortcutManager.h" #include "core/ThemeManager.h" @@ -21,10 +23,11 @@ #include #include #include +#include #include #ifdef Q_OS_LINUX +#include "CanonMessageBox.h" #include "core/LogManager.h" -#include #include "ScopedChildWidget.h" #include #include @@ -68,11 +71,17 @@ class UlanziDialCanvas : public QWidget { explicit UlanziDialCanvas(UlanziDialMapperDialog* owner) : QWidget(owner->bodyWidget()), m_owner(owner) { - // Stop the global QSS from painting an opaque background here. + // Stop the global QSS from painting an opaque background here. Scoped + // to the canvas: an unscoped rule would beat the body's canon sheet on + // every pill inside it, and their popup lists. + setObjectName(QStringLiteral("UlanziDialCanvas")); setAttribute(Qt::WA_StyledBackground, false); setAutoFillBackground(false); - setStyleSheet(QStringLiteral("background: transparent;")); + setStyleSheet(QStringLiteral("QWidget#UlanziDialCanvas { background: transparent; }")); setFixedSize(620, 590); + // The fallback body paints from canon tokens. + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, + this, qOverload<>(&QWidget::update)); } protected: @@ -114,47 +123,86 @@ struct PillSpec { // operator never bound into MOX or TUNE — on Windows, which cannot grab the // dial, while the key also still reaches the OS. Transmit actions are only // ever the operator's own binding. ulanzi_mapping_migration_test pins this. +// The labels are marked for translation here and translated where they are +// read (the pills' accessible names). constexpr PillSpec kPillSpecs[] = { - {"top_left", "Top Left", "KEY_PREVIOUSSONG", "None", 0.21, 0.06, 0}, - {"top_middle", "Top Middle", "KEY_PLAYPAUSE", "shortcut:rit_toggle", 0.50, 0.06, 0}, - {"top_right", "Top Right", "KEY_NEXTSONG", "None", 0.79, 0.06, 0}, - {"side_lt", "Left Top", "Ctrl+V", "None", 0.04, 0.52, 3}, - {"side_lb", "Left Bottom","Ctrl+C", "None", 0.04, 0.68, 3}, - {"side_rt", "Right Top", "Ctrl+Y", "shortcut:next_slice", 0.96, 0.52, 1}, - {"side_rb", "Right Bot", "Ctrl+Z", "None", 0.96, 0.68, 1}, - {"dial_press", "Dial Press", "KEY_MUTE", "shortcut:mute_toggle", 0.50, 0.73, 2}, + {"top_left", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Top Left"), "KEY_PREVIOUSSONG", "None", 0.21, 0.06, 0}, + {"top_middle", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Top Middle"), "KEY_PLAYPAUSE", "shortcut:rit_toggle", 0.50, 0.06, 0}, + {"top_right", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Top Right"), "KEY_NEXTSONG", "None", 0.79, 0.06, 0}, + {"side_lt", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Left Top"), "Ctrl+V", "None", 0.04, 0.52, 3}, + {"side_lb", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Left Bottom"),"Ctrl+C", "None", 0.04, 0.68, 3}, + {"side_rt", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Right Top"), "Ctrl+Y", "shortcut:next_slice", 0.96, 0.52, 1}, + {"side_rb", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Right Bot"), "Ctrl+Z", "None", 0.96, 0.68, 1}, + {"dial_press", QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Dial Press"), "KEY_MUTE", "shortcut:mute_toggle", 0.50, 0.73, 2}, }; constexpr int kPillCount = sizeof(kPillSpecs) / sizeof(kPillSpecs[0]); -QString pillComboStyle() -{ - return QStringLiteral( - "QComboBox {" - " background: #1a2a3a;" - " color: #00b4d8;" - " border: 1px solid #304050;" - " border-radius: 11px;" - " padding: 2px 10px 2px 10px;" - " font-size: 11px;" - "}" - "QComboBox:hover { border-color: #00b4d8; }" - "QComboBox::drop-down {" - " border: none;" - " width: 0;" - "}" - "QComboBox::down-arrow {" - " image: none;" - " width: 0;" - " height: 0;" - "}" - "QComboBox QAbstractItemView {" - " background: #1a2a3a;" - " color: #c8d8e8;" - " selection-background-color: #0070c0;" - " border: 1px solid #304050;" - " font-size: 11px;" - "}"); +// The 620 x 590 canvas with the canon header above it and the status row +// below, inside the body margins and the window's hairline inset. +constexpr QSize kDialogSize(642, 722); + +// The style guide's canon vocabulary (RFC #6226) over the AetherRX / AetherTX +// Settings sheet: each control's action is a round pill on control in cyan, +// the Tuning and Single tap keys in ink, and the status line coloured by its +// ulanziState (connected green, disconnected or failed red, a permission +// warning amber, turned off muted). +constexpr const char* kUlanziDialStyle = R"( +QComboBox#UlanziPill { + color: {{color.canon.cyan}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 11px; + padding: 2px 10px; + font-size: 11px; +} +QComboBox#UlanziPill:hover, +QComboBox#UlanziPill:focus { + border-color: {{color.canon.aqua}}; +} +QComboBox#UlanziPill::drop-down { + border: none; + width: 0; +} +QComboBox#UlanziPill::down-arrow { + image: none; + width: 0; + height: 0; +} +QComboBox#UlanziPill QAbstractItemView { + color: {{color.canon.inkSoft}}; + background: {{color.canon.nested}}; + border: 1px solid {{color.canon.lineHi}}; + selection-background-color: {{color.canon.cyan}}; + selection-color: {{color.canon.onAccent}}; + font-size: 11px; +} +QLabel#UlanziKey { + color: {{color.canon.ink}}; + font-size: 11px; + font-weight: bold; +} +QLabel#UlanziLastEvent { + color: {{color.canon.muted}}; + font-size: 12px; +} +QLabel#UlanziStatus { + font-size: 12px; + font-weight: 600; +} +QLabel#UlanziStatus[ulanziState='connected'] { + color: {{color.accent.success}}; } +QLabel#UlanziStatus[ulanziState='disconnected'], +QLabel#UlanziStatus[ulanziState='error'] { + color: {{color.accent.danger}}; +} +QLabel#UlanziStatus[ulanziState='warning'] { + color: {{color.accent.warning}}; +} +QLabel#UlanziStatus[ulanziState='off'] { + color: {{color.canon.muted}}; +} +)"; } // namespace @@ -162,29 +210,37 @@ UlanziDialMapperDialog::UlanziDialMapperDialog(UlanziDialBackend* manager, ShortcutManager* shortcuts, MidiControlManager* midi, QWidget* parent) - : PersistentDialog(tr("Ulanzi Dial — Control Mapping"), - QStringLiteral("UlanziDialMapperGeom"), - parent) + : CanonWindow(tr("Ulanzi Dial — Control Mapping"), parent) , m_manager(manager) , m_shortcuts(shortcuts) , m_midi(midi) { - setFixedSize(640, 675); - + setObjectName(QStringLiteral("UlanziDialMapperDialog")); + theme::setContainer(this, QStringLiteral("canon/ulanziDial")); + setFixedSize(kDialogSize); + // A page of pills, not a form: Return in a pill must not press Reset. + setReturnClicksDefault(false); + applyCanonSheet(bodyWidget(), [] { + return ModemChrome::styleSheet(ModemChrome::Scale::Dialog, ModemChrome::Look::Canon) + + QString::fromLatin1(kUlanziDialStyle); + }); auto* outer = new QVBoxLayout(bodyWidget()); - outer->setContentsMargins(0, 0, 0, 8); - outer->setSpacing(0); + outer->setContentsMargins(10, 12, 10, 12); + outer->setSpacing(4); + outer->addWidget(makeCanonHeader(tr("Ulanzi Dial — Control Mapping"))); m_canvas = new UlanziDialCanvas(this); outer->addWidget(m_canvas, 1, Qt::AlignCenter); auto* bottomRow = new QHBoxLayout; - bottomRow->setContentsMargins(12, 0, 12, 0); + bottomRow->setContentsMargins(2, 0, 2, 0); bottomRow->setSpacing(10); m_statusLabel = new QLabel(tr("Disconnected")); - setStatusStyle("QLabel { color: #8ea8c0; }"); + m_statusLabel->setObjectName(QStringLiteral("UlanziStatus")); + m_statusLabel->setAccessibleName(tr("Ulanzi Dial connection status")); + setStatusState(QStringLiteral("off")); bottomRow->addWidget(m_statusLabel); #ifdef Q_OS_LINUX @@ -202,7 +258,8 @@ UlanziDialMapperDialog::UlanziDialMapperDialog(UlanziDialBackend* manager, bottomRow->addStretch(1); m_lastEventLabel = new QLabel(tr("Last event: —")); - m_lastEventLabel->setStyleSheet("QLabel { color: #8ea8c0; }"); + m_lastEventLabel->setObjectName(QStringLiteral("UlanziLastEvent")); + m_lastEventLabel->setAccessibleName(tr("Last Ulanzi Dial event")); bottomRow->addWidget(m_lastEventLabel); bottomRow->addStretch(1); @@ -265,7 +322,7 @@ QRect UlanziDialMapperDialog::dialBodyRect() const // Fixed geometry inside the upper region (620 × 555) of the canvas // (620 × 590). Body honours the trimmed image aspect (512:562 ≈ 0.911) // at 320×352, centered in the upper region to leave vertical space for - // the bottom rotary and single-tap combo controls at y >= 525. + // the bottom rotary and single-tap combo controls at y >= 488. const int bw = 320; const int bh = 352; const int bx = (620 - bw) / 2; // 150 @@ -285,16 +342,14 @@ void UlanziDialMapperDialog::buildPills() m_pills.clear(); if (!m_rotaryCombo) { - m_rotaryLabel = new QLabel(tr("Tuning:"), this); - ThemeManager::instance().applyStyleSheet(m_rotaryLabel, - QStringLiteral("QLabel { color: {{color.text.primary}}; font-size: 11px; font-weight: bold; }")); + m_rotaryLabel = new QLabel(tr("Tuning:"), m_canvas); + m_rotaryLabel->setObjectName(QStringLiteral("UlanziKey")); m_rotaryLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_rotaryLabel->raise(); - m_rotaryCombo = new QComboBox(this); - ThemeManager::instance().applyStyleSheet(m_rotaryCombo, pillComboStyle()); + m_rotaryCombo = new QComboBox(m_canvas); + m_rotaryCombo->setObjectName(QStringLiteral("UlanziPill")); + m_rotaryCombo->setAccessibleName(tr("Tuning action")); m_rotaryCombo->setFixedWidth(180); - m_rotaryCombo->raise(); struct RotaryOption { const char* label; @@ -329,34 +384,25 @@ void UlanziDialMapperDialog::buildPills() UlanziDialMappings::setRotaryAction(actionId); }); - m_singleTapLabel = new QLabel(tr("Single tap:"), this); - ThemeManager::instance().applyStyleSheet(m_singleTapLabel, - QStringLiteral("QLabel { color: {{color.text.primary}}; font-size: 11px; font-weight: bold; }")); + m_singleTapLabel = new QLabel(tr("Single tap:"), m_canvas); + m_singleTapLabel->setObjectName(QStringLiteral("UlanziKey")); m_singleTapLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_singleTapLabel->raise(); } for (int i = 0; i < kPillCount; ++i) { Pill p; p.id = QString::fromLatin1(kPillSpecs[i].id); - p.defaultLabel = QString::fromLatin1(kPillSpecs[i].label); + p.defaultLabel = tr(kPillSpecs[i].label); p.signature = QString::fromLatin1(kPillSpecs[i].signature); p.defaultAction = QString::fromLatin1(kPillSpecs[i].defaultAction); p.anchor = normalizedAnchor(kPillSpecs[i].nx, kPillSpecs[i].ny); - // Pills with hardcoded dialog-coord positions (all three top - // pills + dial press) are parented to the dialog so move() is - // in dialog coords. The side_* pills stay as canvas children. - const QString pillId = QString::fromLatin1(kPillSpecs[i].id); - const bool dialogParented = - (pillId == QLatin1String("top_left") || - pillId == QLatin1String("top_middle") || - pillId == QLatin1String("top_right") || - pillId == QLatin1String("dial_press")); - p.combo = new QComboBox(dialogParented ? static_cast(this) - : static_cast(m_canvas)); - p.combo->setStyleSheet(pillComboStyle()); - p.combo->setFixedWidth(pillId == QLatin1String("dial_press") ? 180 : 120); - if (dialogParented) p.combo->raise(); + // Every pill is a canvas child, placed in canvas coordinates. + p.combo = new QComboBox(m_canvas); + p.combo->setObjectName(QStringLiteral("UlanziPill")); + p.combo->setAccessibleName(p.id == QLatin1String("dial_press") + ? tr("Single tap action") + : tr("%1 key action").arg(p.defaultLabel)); + p.combo->setFixedWidth(p.id == QLatin1String("dial_press") ? 180 : 120); // (unassigned) — always first. p.combo->addItem(tr("(unassigned)"), QStringLiteral("None")); @@ -406,12 +452,12 @@ void UlanziDialMapperDialog::layoutPills() if (m_rotaryLabel && m_rotaryCombo && m_singleTapLabel) { m_rotaryLabel->resize(75, 24); - m_rotaryLabel->move(185, 525); + m_rotaryLabel->move(175, 488); m_rotaryCombo->resize(180, 24); - m_rotaryCombo->move(265, 525); + m_rotaryCombo->move(255, 488); m_singleTapLabel->resize(75, 24); - m_singleTapLabel->move(185, 557); + m_singleTapLabel->move(175, 520); } // No connector lines: pills sit "almost touching" the body edge at @@ -423,27 +469,20 @@ void UlanziDialMapperDialog::layoutPills() const int touchGap = 4; // tiny visible breathing room QPoint center; if (p.id == QLatin1String("top_left")) { - // Dialog-parented — hardcoded dialog coords. p.combo->resize(120, p.combo->sizeHint().height()); - p.combo->move(120, 100); - p.combo->raise(); - p.pillCenter = QPoint(120 + 60, 100 + p.combo->height() / 2); + p.combo->move(110, 63); + p.pillCenter = QPoint(110 + 60, 63 + p.combo->height() / 2); return; } else if (p.id == QLatin1String("top_middle")) { - // Pill is a dialog child — hardcode dialog coords. - // Window 640 wide, combo 120 wide → x = 260. - // Hardcoded y above the dial body within the dialog. + // Centred over the dial: canvas 620 wide, combo 120 wide. p.combo->resize(120, p.combo->sizeHint().height()); - p.combo->move(260, 100); - p.combo->raise(); - p.pillCenter = QPoint(320, 100 + p.combo->height() / 2); + p.combo->move(250, 63); + p.pillCenter = QPoint(310, 63 + p.combo->height() / 2); return; } else if (p.id == QLatin1String("top_right")) { - // Dialog-parented — hardcoded dialog coords. p.combo->resize(120, p.combo->sizeHint().height()); - p.combo->move(400, 100); - p.combo->raise(); - p.pillCenter = QPoint(400 + 60, 100 + p.combo->height() / 2); + p.combo->move(390, 63); + p.pillCenter = QPoint(390 + 60, 63 + p.combo->height() / 2); return; } else if (p.id == QLatin1String("side_lt")) { center = QPoint(body.left() - sz.width() / 2 - touchGap + 138, @@ -459,9 +498,8 @@ void UlanziDialMapperDialog::layoutPills() body.top() + body.height() * 68 / 100 - 15); } else if (p.id == QLatin1String("dial_press")) { p.combo->resize(180, p.combo->sizeHint().height()); - p.combo->move(265, 557); - p.combo->raise(); - p.pillCenter = QPoint(355, 557 + p.combo->height() / 2); + p.combo->move(255, 520); + p.pillCenter = QPoint(345, 520 + p.combo->height() / 2); return; } @@ -489,9 +527,7 @@ void UlanziDialCanvas::paintEvent(QPaintEvent*) QPainter p(this); p.setRenderHint(QPainter::Antialiasing); - // Background — match the surrounding dialog. - p.fillRect(rect(), QColor("#0f0f1a")); - + // Nothing behind the dial: the canon ground shows through. const QRect body = m_owner->dialBodyRect(); const QPixmap& dialImage = ulanziDialPixmap(); @@ -504,24 +540,28 @@ void UlanziDialCanvas::paintEvent(QPaintEvent*) } else { // Fallback: painted stylized representation matching the product // shape so layout still looks correct without the asset. + const ThemeManager& tm = ThemeManager::instance(); + const auto canon = [&](const char* name) { + return tm.color(this, QStringLiteral("color.canon.") + QLatin1String(name)); + }; QPainterPath bodyPath; bodyPath.addRoundedRect(body, 22, 22); - p.fillPath(bodyPath, QColor("#1a2a3a")); - p.setPen(QPen(QColor("#304050"), 1)); + p.fillPath(bodyPath, canon("raised")); + p.setPen(QPen(canon("lineHi"), 1)); p.drawPath(bodyPath); const int btnW = 56, btnH = 28; const int btnY = body.top() + 14; const int gapX = (body.width() - btnW * 3) / 4; - p.setPen(QPen(QColor("#3a4a5a"), 1)); - p.setBrush(QColor("#0f0f1a")); + p.setPen(QPen(canon("line"), 1)); + p.setBrush(canon("nested")); for (int i = 0; i < 3; ++i) { const int btnX = body.left() + gapX + i * (btnW + gapX); p.drawRoundedRect(QRect(btnX, btnY, btnW, btnH), 5, 5); } const int tabW = 10, tabH = 24; - p.setBrush(QColor("#1a2a3a")); + p.setBrush(canon("raised")); for (double yFrac : { 0.45, 0.65 }) { const int ty = body.top() + int(body.height() * yFrac) - tabH / 2; p.drawRoundedRect(QRect(body.left() - tabW + 2, ty, tabW, tabH), 3, 3); @@ -530,11 +570,11 @@ void UlanziDialCanvas::paintEvent(QPaintEvent*) const int knobR = body.width() / 4; const QPoint knobCenter(body.center().x(), body.top() + body.height() / 2 + 10); - p.setBrush(QColor("#0f0f1a")); - p.setPen(QPen(QColor("#506070"), 2)); + p.setBrush(canon("nested")); + p.setPen(QPen(canon("lineHi"), 2)); p.drawEllipse(knobCenter, knobR, knobR); - p.setBrush(QColor("#1a2a3a")); - p.setPen(QPen(QColor("#3a4a5a"), 1)); + p.setBrush(canon("control")); + p.setPen(QPen(canon("line"), 1)); p.drawEllipse(knobCenter, 12, 12); } @@ -646,17 +686,9 @@ void UlanziDialMapperDialog::onButtonEvent(const QString& signature, int action) void UlanziDialMapperDialog::showEvent(QShowEvent* event) { - PersistentDialog::showEvent(event); - // The first layoutPills happens from buildPills (constructor), - // before Qt has positioned m_canvas inside the dialog. After the - // dialog is shown, m_canvas->mapFrom(this, ...) returns the right - // values, so re-run the layout to put the dialog-centred pills - // (top_middle, dial_press) at their proper x. - layoutPills(); - if (m_canvas) m_canvas->update(); - - // The dialog is persistent and usually opened long after launch, when the - // backend's scan has already reported. Re-read the state that decides what + CanonWindow::showEvent(event); + // The dialog is usually opened long after launch, when the backend's + // scan has already reported. Re-read the state that decides what // the status line should say rather than waiting for an edge. refreshStatus(); } @@ -675,14 +707,11 @@ void UlanziDialMapperDialog::refreshStatus() } } -void UlanziDialMapperDialog::setStatusStyle(const QString& css) +void UlanziDialMapperDialog::setStatusState(const QString& state) { - // The disabled line is themed through ThemeManager, which re-applies its - // template on every theme change. Drop that tracking first, or a later - // theme change would repaint a connected or permission line in the - // disabled colour. - ThemeManager::instance().clearWidgetTracking(m_statusLabel); - m_statusLabel->setStyleSheet(css); + m_statusLabel->setProperty("ulanziState", state); + m_statusLabel->style()->unpolish(m_statusLabel); + m_statusLabel->style()->polish(m_statusLabel); } void UlanziDialMapperDialog::showDisabledStatus() @@ -690,8 +719,7 @@ void UlanziDialMapperDialog::showDisabledStatus() if (!m_statusLabel) return; m_statusLabel->setText( tr("Turned off in Radio Setup → Serial & Controllers")); - ThemeManager::instance().applyStyleSheet( - m_statusLabel, QStringLiteral("QLabel { color: {{color.text.secondary}}; }")); + setStatusState(QStringLiteral("off")); #ifdef Q_OS_LINUX if (m_grantAccessBtn) m_grantAccessBtn->setVisible(false); @@ -721,9 +749,8 @@ void UlanziDialMapperDialog::onConnectionChanged(bool connected, const QString& m_statusLabel->setText(connected ? tr("Connected — %1").arg(display) : tr("Disconnected")); - setStatusStyle(connected - ? "QLabel { color: #4dd87a; }" - : "QLabel { color: #cc3333; }"); + setStatusState(connected ? QStringLiteral("connected") + : QStringLiteral("disconnected")); #ifdef Q_OS_LINUX // Once connected, the access problem is resolved — hide the offer. if (connected && m_grantAccessBtn) @@ -739,7 +766,7 @@ void UlanziDialMapperDialog::onAccessRequired(const QString& deviceName) if (display.endsWith(QStringLiteral(" Keyboard"), Qt::CaseInsensitive)) display.chop(QStringLiteral(" Keyboard").size()); m_statusLabel->setText(tr("%1 detected — needs permission").arg(display)); - setStatusStyle("QLabel { color: #e0a030; }"); + setStatusState(QStringLiteral("warning")); if (m_grantAccessBtn) { m_grantAccessBtn->setVisible(true); m_grantAccessBtn->setEnabled(true); @@ -750,11 +777,12 @@ void UlanziDialMapperDialog::onGrantAccessClicked() { const QString pkexec = QStandardPaths::findExecutable(QStringLiteral("pkexec")); if (pkexec.isEmpty()) { - ScopedChildWidget boxOwner( - QMessageBox::Warning, tr("Grant access"), + ScopedChildWidget box( + tr("Grant access"), tr("pkexec was not found. Install polkit, or add this user to the " - "'input' group manually."), QMessageBox::Ok, this); - boxOwner.get()->exec(); + "'input' group manually."), this); + box.get()->setDefaultButton(box.get()->addButton(tr("OK"), CanonMessageBox::Role::Accept)); + box.get()->exec(); return; } @@ -790,7 +818,7 @@ void UlanziDialMapperDialog::onGrantAccessClicked() } if (m_statusLabel) { m_statusLabel->setText(tr("Access install failed")); - setStatusStyle("QLabel { color: #cc3333; }"); + setStatusState(QStringLiteral("error")); } }); connect(proc, &QProcess::finished, this, @@ -801,7 +829,7 @@ void UlanziDialMapperDialog::onGrantAccessClicked() if (code == 0) { if (m_statusLabel) { m_statusLabel->setText(tr("Access granted — connecting…")); - setStatusStyle("QLabel { color: #4dd87a; }"); + setStatusState(QStringLiteral("connected")); } // The udev trigger applies the ACL asynchronously; give logind a // moment, then ask the backend to rescan. If that misses, the user @@ -820,7 +848,7 @@ void UlanziDialMapperDialog::onGrantAccessClicked() m_statusLabel->setText(code == 126 || code == 127 ? tr("Authorization cancelled") : tr("Access install failed")); - setStatusStyle("QLabel { color: #cc3333; }"); + setStatusState(QStringLiteral("error")); } } }); diff --git a/src/gui/UlanziDialMapperDialog.h b/src/gui/UlanziDialMapperDialog.h index 4629c45b19..929dcceb0a 100644 --- a/src/gui/UlanziDialMapperDialog.h +++ b/src/gui/UlanziDialMapperDialog.h @@ -1,7 +1,7 @@ #pragma once #include -#include "PersistentDialog.h" +#include "CanonWindow.h" #include "core/UlanziDialBackend.h" #include @@ -24,8 +24,10 @@ class UlanziDialCanvas; // with callout pills per physical control, each showing its action and learned // device-event signature. Click a pill to Learn, press the dial control, and the // signature (e.g. "KEY_PLAYPAUSE", "Ctrl+V") is persisted to AppSettings. -// This dialog owns capture + persistence; MainWindow dispatches. -class UlanziDialMapperDialog : public PersistentDialog { +// This dialog owns capture + persistence; MainWindow dispatches. A canon +// dialog (style guide, RFC #6226), opened from Settings → Controllers → +// Ulanzi Dial Mapping... +class UlanziDialMapperDialog : public CanonWindow { Q_OBJECT public: @@ -37,10 +39,8 @@ class UlanziDialMapperDialog : public PersistentDialog { friend class UlanziDialCanvas; protected: - // Re-run layoutPills after the dialog is fully shown. Until that - // point, m_canvas->mapTo/mapFrom return values relative to an - // uninitialised position, so any "centre on window" calc has to - // wait for the layout to settle. + // Re-reads the backend's state, so a dialog opened after the scan + // reported still shows the right status line. void showEvent(QShowEvent* event) override; public: @@ -81,8 +81,9 @@ private slots: // Status line for a dial turned off in Radio Setup: without it the dialog // reads "Disconnected" with nothing to say why. void showDisabledStatus(); - // Plain (untracked) status-line style; see the definition. - void setStatusStyle(const QString& css); + // Connected, disconnected, warning, error or off: the status line + // restyles from the ulanziState property. + void setStatusState(const QString& state); #ifdef Q_OS_LINUX // Dial present but its evdev node isn't accessible — offer to install the // udev access rule via polkit. @@ -101,7 +102,7 @@ private slots: QString defaultAction; // default function on this pill (matches // the original mockup labels). QPoint anchor; // dial-image anchor (normalized × body rect) - QPoint pillCenter; // computed each resize, screen coords + QPoint pillCenter; // computed each layout, canvas coords QComboBox* combo{nullptr}; }; diff --git a/src/main.cpp b/src/main.cpp index 8cf2589b51..fe61c40dd7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -634,11 +634,13 @@ int main(int argc, char* argv[]) static_cast(AetherSDR::Nr2SettingsModel::instance()); AetherSDR::AppSettings::instance().initializeGuiClientIdentity(); { - // Retired keys nothing reads any more. AboutDialogGeometry went when - // About moved to CanonWindow, which opens centred (RFC #6226). + // Retired keys nothing reads any more. AboutDialogGeometry and + // UlanziDialMapperGeom went when About and the Ulanzi Dial mapper + // moved to CanonWindow, where they open centred (RFC #6226). auto& s = AetherSDR::AppSettings::instance(); bool removed = false; - for (const char* key : {"SHistorySoftEdgeDb", "AboutDialogGeometry"}) { + for (const char* key : {"SHistorySoftEdgeDb", "AboutDialogGeometry", + "UlanziDialMapperGeom"}) { if (s.contains(key)) { s.remove(key); removed = true; diff --git a/tests/tests.cmake b/tests/tests.cmake index 98a266b5c7..933846ea39 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -6022,6 +6022,11 @@ if(PYTHON3_EXECUTABLE) add_test(NAME bridge_docs_check COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_bridge_docs.py --check) + # The Generated Reference pages read names out of C++ string tables; + # literal() must read a translation-marked entry as its text. + add_test(NAME gen_reference_literal_test + COMMAND ${PYTHON3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_gen_reference_literal.py) # The generated seam-probe table (tests/SeamSignalProbeTable.inc) vs # IRadioBackend.h. static-checks.yml runs the same --check on every PR; # this is the local copy, so `ctest` says so before a push does. @@ -8584,6 +8589,26 @@ add_executable(midi_relative_cc_decoder_test target_include_directories(midi_relative_cc_decoder_test PRIVATE src) add_test(NAME midi_relative_cc_decoder_test COMMAND midi_relative_cc_decoder_test) +# Ulanzi Dial — Control Mapping on CanonWindow: fixed size, Return never +# resets the pills, the status line follows the dial, named controls, and +# every close destroys it. No backend or device; no socket. +add_executable(ulanzi_dial_mapper_dialog_test + tests/ulanzi_dial_mapper_dialog_test.cpp + src/gui/UlanziDialMapperDialog.cpp + src/gui/CanonWindow.cpp + src/gui/CanonMessageBox.cpp + src/gui/ModemChrome.cpp + src/gui/DragValuePopup.cpp + src/gui/FramelessResizer.cpp + ${THEME_TEST_RESOURCES} +) +target_include_directories(ulanzi_dial_mapper_dialog_test PRIVATE src tests) +target_link_libraries(ulanzi_dial_mapper_dialog_test PRIVATE + aetherdesktop_support Qt6::Widgets Qt6::Test) +add_test(NAME ulanzi_dial_mapper_dialog_test COMMAND ulanzi_dial_mapper_dialog_test) +set_tests_properties(ulanzi_dial_mapper_dialog_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(ulanzi_chord_decoder_test tests/ulanzi_chord_decoder_test.cpp src/core/UlanziChordDecoder.cpp @@ -9787,6 +9812,7 @@ endif() set(AETHER_SETTINGS_CONSUMERS update_installer_test update_stager_test + ulanzi_dial_mapper_dialog_test flex_readonly_metadata_test dsp_parameter_reset_test cw_rx_model_test diff --git a/tests/ulanzi_dial_mapper_dialog_test.cpp b/tests/ulanzi_dial_mapper_dialog_test.cpp new file mode 100644 index 0000000000..cf77053b8f --- /dev/null +++ b/tests/ulanzi_dial_mapper_dialog_test.cpp @@ -0,0 +1,197 @@ +// Ulanzi Dial — Control Mapping on the style guide's CanonWindow (RFC #6226): +// a fixed-size canon dialog whose pills Return never resets, whose status line +// follows the dial's state, and whose controls carry accessible names. +// Socket-free and device-free: no backend, no shortcut or MIDI manager. + +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/UlanziDialMappings.h" +#include "gui/CanonWindow.h" +#include "gui/FramelessResizer.h" +#include "gui/UlanziDialMapperDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace AetherSDR; + +namespace { + +int g_failures = 0; + +void check(bool ok, const char* name) +{ + std::printf("[%s] %s\n", ok ? "OK" : "FAIL", name); + g_failures += !ok; +} + +QList pills(QWidget& dlg) +{ + return dlg.findChildren(QStringLiteral("UlanziPill")); +} + +QLabel* status(QWidget& dlg) +{ + return dlg.findChild(QStringLiteral("UlanziStatus")); +} + +void announce(UlanziDialMapperDialog& dlg, bool connected, const QString& name) +{ + QMetaObject::invokeMethod(&dlg, "onConnectionChanged", + Q_ARG(bool, connected), Q_ARG(QString, name)); +} + +// A canon dialog that keeps its fixed size: its pills sit at fixed points on +// the dial picture, so it opts out of FramelessResizer. +void fixedSizeCanonDialog() +{ + UlanziDialMapperDialog dlg(nullptr, nullptr, nullptr); + check(qobject_cast(&dlg) != nullptr, "the mapper is a CanonWindow"); + check(dlg.minimumSize() == dlg.maximumSize(), "it keeps a fixed size"); + check(dlg.findChild() == nullptr, + "it installs no FramelessResizer, since its contents are pixel-placed"); +} + +// Return in a pill must not press Reset to Defaults: on the PersistentDialog, +// a keyboard user tabbing through the pills could rewrite every mapping. +void returnInAPillDoesNotReset() +{ + UlanziDialMapperDialog dlg(nullptr, nullptr, nullptr); + dlg.show(); + check(QTest::qWaitForWindowExposed(&dlg), "the window is exposed"); + const QList all = pills(dlg); + check(!all.isEmpty(), "the mapper has pills"); + if (all.isEmpty()) return; + QComboBox* pill = all.first(); + // "(unassigned)" is first; any other index differs from at least one + // pill's default, so pick the last item for a value Reset would undo. + const int chosen = pill->count() - 1; + for (QComboBox* p : all) + p->setCurrentIndex(qMin(chosen, p->count() - 1)); + const QList before = [&] { + QList v; + for (QComboBox* p : all) v << p->currentIndex(); + return v; + }(); + pill->setFocus(); + QTest::keyClick(pill, Qt::Key_Return); + QTest::keyClick(&dlg, Qt::Key_Return); + QList after; + for (QComboBox* p : all) after << p->currentIndex(); + check(after == before, "Return in a pill leaves every mapping as it was"); +} + +// The status line's state follows the dial: connected, disconnected, and +// turned off in Radio Setup, where a disconnect must not read as a fault. +void statusFollowsTheDial() +{ + UlanziDialMappings::setEnabled(true); + UlanziDialMapperDialog dlg(nullptr, nullptr, nullptr); + QLabel* line = status(dlg); + check(line != nullptr, "the status line exists"); + if (!line) return; + check(line->text() == QStringLiteral("Ulanzi Dial manager unavailable"), + "with no backend the line says so"); + + announce(dlg, true, QStringLiteral("Ulanzi D200 Keyboard")); + check(line->property("ulanziState").toString() == QStringLiteral("connected") + && line->text() == QStringLiteral("Connected — Ulanzi D200"), + "a connected dial reads Connected, without the Keyboard suffix"); + + announce(dlg, false, QString()); + check(line->property("ulanziState").toString() == QStringLiteral("disconnected"), + "an enabled dial that drops reads Disconnected"); + + UlanziDialMappings::setEnabled(false); + announce(dlg, false, QString()); + check(line->property("ulanziState").toString() == QStringLiteral("off") + && line->text().startsWith(QStringLiteral("Turned off")), + "a dial turned off in Radio Setup reads off, not Disconnected"); + UlanziDialMappings::setEnabled(true); +} + +// Every pill, the status line and the Last event readout carry a name. +void controlsAreNamed() +{ + UlanziDialMapperDialog dlg(nullptr, nullptr, nullptr); + QSet names; + bool allNamed = true; + for (QComboBox* p : pills(dlg)) { + allNamed = allNamed && !p->accessibleName().isEmpty(); + names.insert(p->accessibleName()); + } + check(allNamed && names.size() == pills(dlg).size(), + "every pill has its own accessible name"); + check(names.contains(QStringLiteral("Top Left key action")) + && names.contains(QStringLiteral("Single tap action")), + "a pill's name says which control it maps"); + QLabel* line = status(dlg); + auto* last = dlg.findChild(QStringLiteral("UlanziLastEvent")); + check(line && !line->accessibleName().isEmpty(), "the status line is named"); + check(last && !last->accessibleName().isEmpty(), "the Last event readout is named"); +} + +// MainWindow opens the window with WA_DeleteOnClose. Close, Esc, Ctrl+W / +// Cmd+W and the corner button each destroy it, so the next open is fresh. +void everyCloseDestroysTheWindow() +{ + const char* names[] = { + "Close destroys the window", + "Esc destroys the window", + "Ctrl+W / Cmd+W destroys the window", + "the corner close button destroys the window", + }; + for (int path = 0; path < 4; ++path) { + QPointer dlg = new UlanziDialMapperDialog(nullptr, nullptr, nullptr); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->show(); + const bool exposed = QTest::qWaitForWindowExposed(dlg); + if (path == 0) { + for (QPushButton* b : dlg->findChildren()) { + if (b->text() == QStringLiteral("Close")) { + b->click(); + break; + } + } + } else if (path == 1) { + QTest::keyClick(dlg, Qt::Key_Escape); + } else if (path == 2) { + QTest::keySequence(dlg, QKeySequence::Close); + } else { + dlg->close(); + } + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + check(exposed && dlg.isNull(), names[path]); + delete dlg.data(); + } +} + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("ulanzi-dial-mapper-dialog")); + if (!profile.isValid()) { + return 1; + } + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + QApplication app(argc, argv); + app.setQuitOnLastWindowClosed(false); + AppSettings::instance().load(); + + fixedSizeCanonDialog(); + returnInAPillDoesNotReset(); + statusFollowsTheDial(); + controlsAreNamed(); + everyCloseDestroysTheWindow(); + + return g_failures ? 1 : 0; +} diff --git a/tools/docs/gen_reference.py b/tools/docs/gen_reference.py index 389b12f74a..2c643ddd0c 100644 --- a/tools/docs/gen_reference.py +++ b/tools/docs/gen_reference.py @@ -279,16 +279,21 @@ def unescape(body: str, where: str) -> str: return "".join(out) -STRING_WRAPPERS = ("QStringLiteral", "QLatin1String", "QString", "tr", "QObject::tr") +STRING_WRAPPERS = ("QStringLiteral", "QLatin1String", "QString", "tr", "QObject::tr", + "QT_TR_NOOP") +# QT_TRANSLATE_NOOP("context", text): the text is the second argument. +QT_TRANSLATE_NOOP_RE = r'QT_TRANSLATE_NOOP\s*\(\s*"(?:[^"\\\n]|\\.)*"\s*,(.*)\)' def literal(expr: str, where: str): """The value of a string literal expression (adjacent literals joined, - an optional QStringLiteral()/tr()/... wrapper), or None if ``expr`` is not - one.""" + an optional QStringLiteral()/tr()/QT_TRANSLATE_NOOP()/... wrapper), or + None if ``expr`` is not one.""" e = expr.strip() m = re.fullmatch(r"(?:%s)\s*\((.*)\)" % "|".join( re.escape(w) for w in STRING_WRAPPERS), e, re.S) + if not m: + m = re.fullmatch(QT_TRANSLATE_NOOP_RE, e, re.S) if m: e = m.group(1).strip() if not e.startswith('"'): diff --git a/tools/test_gen_reference_literal.py b/tools/test_gen_reference_literal.py new file mode 100644 index 0000000000..bddd3514d3 --- /dev/null +++ b/tools/test_gen_reference_literal.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Self-test for literal() in tools/docs/gen_reference.py. + +The Generated Reference pages read button and action names out of C++ string +tables, so a table entry wrapped in translation markup has to read as the +text it marks. A wrapper literal() does not know falls through as raw source, +and `gen_reference.py --check` then reports the macro call itself as the new +table cell; regenerating would put that call into the user manual. + + python3 tools/test_gen_reference_literal.py # exits non-zero on any failure +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "docs")) +import gen_reference # noqa: E402 + +# (expression, the value literal() must return) +CASES = [ + ('"Top Left"', "Top Left"), + ('"Top " "Left"', "Top Left"), + ('QStringLiteral("Top Left")', "Top Left"), + ('tr("Top Left")', "Top Left"), + ('QT_TR_NOOP("Top Left")', "Top Left"), + ('QT_TRANSLATE_NOOP("AetherSDR::UlanziDialMapperDialog", "Top Left")', + "Top Left"), + ('QT_TRANSLATE_NOOP( "Ctx" ,\n "Right Bot" )', "Right Bot"), + ('QT_TRANSLATE_NOOP("Ctx, with comma", "Dial Press")', "Dial Press"), + # Not string literals: literal() says so instead of guessing. + ('kPillLabel', None), + ('QT_TRANSLATE_NOOP("Ctx", kPillLabel)', None), + ('QT_TRANSLATE_NOOP(ctx, "Top Left")', None), +] + + +def main() -> int: + failures = 0 + for expr, want in CASES: + got = gen_reference.literal(expr, "test") + ok = got == want + failures += not ok + print(f"[{'PASS' if ok else 'FAIL'}] literal({expr!r}) -> {got!r}" + + ("" if ok else f", expected {want!r}")) + print(f"{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())