Cockatrice/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
BruebachL 12b5525a2d
[TabArchidekt] Cleaner filters, infinite scrolling, and a "go back button" (#6545)
* [TabArchidekt] Cleaner filters, infinite scrolling, and a "go back button"

Took 46 minutes

Took 5 seconds

* Fix infinite scroll triggering in detail view.

Took 25 minutes

Took 3 seconds

* Use setLabelText() so it's white

Took 2 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-01-24 11:21:12 +01:00

669 lines
No EOL
23 KiB
C++

#include "tab_archidekt.h"
#include "../../../../../client/settings/cache_settings.h"
#include "../../../cards/additional_info/mana_symbol_widget.h"
#include "../../tab_supervisor.h"
#include "api_response/archidekt_deck_listing_api_response.h"
#include "display/archidekt_api_response_deck_display_widget.h"
#include "display/archidekt_api_response_deck_listings_display_widget.h"
#include <QCompleter>
#include <QDebug>
#include <QFormLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPushButton>
#include <QRegularExpression>
#include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QUrlQuery>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h>
#include <version_string.h>
TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
: Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true)
{
// Initialize network
networkManager = new QNetworkAccessManager(this);
networkManager->setTransferTimeout();
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson);
// Initialize debounce timer
searchDebounceTimer = new QTimer(this);
searchDebounceTimer->setSingleShot(true);
searchDebounceTimer->setInterval(300);
connect(searchDebounceTimer, &QTimer::timeout, this, &TabArchidekt::doSearchImmediate);
initializeUi();
setupFilterWidgets();
connectSignals();
retranslateUi();
getTopDecks();
}
void TabArchidekt::initializeUi()
{
// Main container
container = new QWidget(this);
mainLayout = new QVBoxLayout(container);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
// Primary toolbar (most important filters)
primaryToolbar = new QWidget(container);
primaryToolbar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
primaryToolbarLayout = new QHBoxLayout(primaryToolbar);
primaryToolbarLayout->setContentsMargins(6, 6, 6, 6);
primaryToolbarLayout->setSpacing(6);
// Sort controls
sortByLabel = new QLabel(primaryToolbar);
orderByCombo = new QComboBox(primaryToolbar);
orderByCombo->addItems({"name", "updatedAt", "createdAt", "viewCount", "size", "edhBracket"});
orderByCombo->setCurrentText("updatedAt");
orderByCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
orderDirButton = new QPushButton(tr("Desc."), primaryToolbar);
orderDirButton->setCheckable(true);
orderDirButton->setChecked(true);
orderDirButton->setFixedWidth(60);
// Color filter (inline)
QWidget *colorWidget = new QWidget(primaryToolbar);
QHBoxLayout *colorLayout = new QHBoxLayout(colorWidget);
colorLayout->setContentsMargins(0, 0, 0, 0);
colorLayout->setSpacing(2);
QString colorIdentity = "WUBRG";
for (const QChar &color : colorIdentity) {
auto *manaSymbol = new ManaSymbolWidget(colorWidget, color, false, true);
manaSymbol->setFixedSize(28, 28);
colorSymbols.append(manaSymbol);
colorLayout->addWidget(manaSymbol);
connect(manaSymbol, &ManaSymbolWidget::colorToggled, this, [this](QChar c, bool active) {
if (active)
activeColors.insert(c);
else
activeColors.remove(c);
doSearch();
});
}
logicalAndCheck = new QCheckBox(tr("AND"), primaryToolbar);
logicalAndCheck->setToolTip(tr("Require ALL selected colors"));
// Common search fields
nameField = new QLineEdit(primaryToolbar);
nameField->setPlaceholderText(tr("Deck name..."));
nameField->setMinimumWidth(150);
ownerField = new QLineEdit(primaryToolbar);
ownerField->setPlaceholderText(tr("Owner..."));
ownerField->setMinimumWidth(120);
// Filter by label
filterByLabel = new QLabel(primaryToolbar);
// Package toggle
packagesCheck = new QCheckBox(tr("Packages"), primaryToolbar);
// Search button
searchButton = new QPushButton(tr("Search"), primaryToolbar);
searchButton->setDefault(true);
// Advanced filters toggle button
advancedFiltersButton = new QPushButton(tr("Advanced Filters"), primaryToolbar);
advancedFiltersButton->setCheckable(true);
advancedFiltersButton->setChecked(false);
// Settings
settingsButton = new SettingsButtonWidget(primaryToolbar);
cardSizeSlider = new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().getArchidektPreviewSize());
settingsButton->addSettingsWidget(cardSizeSlider);
// Assemble primary toolbar
primaryToolbarLayout->addWidget(sortByLabel);
primaryToolbarLayout->addWidget(orderByCombo);
primaryToolbarLayout->addWidget(orderDirButton);
// Add separator/spacing
primaryToolbarLayout->addSpacing(12);
primaryToolbarLayout->addWidget(filterByLabel);
primaryToolbarLayout->addWidget(colorWidget);
primaryToolbarLayout->addWidget(logicalAndCheck);
primaryToolbarLayout->addWidget(nameField, 1);
primaryToolbarLayout->addWidget(ownerField, 1);
primaryToolbarLayout->addWidget(packagesCheck);
primaryToolbarLayout->addWidget(searchButton, 1);
primaryToolbarLayout->addWidget(advancedFiltersButton);
primaryToolbarLayout->addWidget(settingsButton);
// Secondary toolbar (advanced filters - initially hidden)
secondaryToolbar = new QWidget(container);
secondaryToolbar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
secondaryToolbar->setVisible(false); // Start hidden
secondaryToolbarLayout = new QHBoxLayout(secondaryToolbar);
secondaryToolbarLayout->setContentsMargins(6, 3, 6, 6);
secondaryToolbarLayout->setSpacing(6);
// Scrollable results area
scrollArea = new QScrollArea(container);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
resultsContainer = new QWidget();
resultsLayout = new QVBoxLayout(resultsContainer);
resultsLayout->setContentsMargins(0, 0, 0, 0);
resultsLayout->setSpacing(0);
scrollArea->setWidget(resultsContainer);
scrollArea->viewport()->installEventFilter(this);
mainLayout->addWidget(primaryToolbar);
mainLayout->addWidget(secondaryToolbar);
mainLayout->addWidget(scrollArea);
setCentralWidget(container);
}
bool TabArchidekt::eventFilter(QObject *obj, QEvent *event)
{
if (obj == scrollArea->viewport() && event->type() == QEvent::Wheel) {
auto *wheelEvent = static_cast<QWheelEvent *>(event);
if (wheelEvent->angleDelta().y() < 0 && !isLoadingMore && isListMode) {
loadNextPage();
wheelEvent->accept();
return false; // allow scrolling
}
}
// Always pass the event to the parent to handle normal scrolling
return QWidget::eventFilter(obj, event);
}
void TabArchidekt::setupFilterWidgets()
{
// Advanced filters (in secondary toolbar)
// EDH Bracket
auto *bracketLabel = new QLabel(tr("Bracket:"), secondaryToolbar);
edhBracketCombo = new QComboBox(secondaryToolbar);
edhBracketCombo->addItem(tr("Any"));
edhBracketCombo->addItems({"1", "2", "3", "4", "5"});
edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
// Format filter (collapsible)
formatButton = new SettingsButtonWidget(secondaryToolbar);
formatButton->setButtonText(tr("Formats"));
formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced"));
QWidget *formatContainer = new QWidget(secondaryToolbar);
QGridLayout *formatLayout = new QGridLayout(formatContainer);
formatLayout->setContentsMargins(4, 4, 4, 4);
QStringList formatNames = {"Standard", "Modern", "Commander", "Legacy", "Vintage",
"Pauper", "Custom", "Frontier", "Future Std", "Penny Dreadful",
"1v1 Commander", "Dual Commander", "Brawl"};
int row = 0, col = 0;
for (const QString &formatName : formatNames) {
auto *formatCheckBox = new QCheckBox(formatName, formatContainer);
formatChecks << formatCheckBox;
formatLayout->addWidget(formatCheckBox, row, col);
connect(formatCheckBox, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
col++;
if (col >= 3) {
col = 0;
row++;
}
}
formatButton->addSettingsWidget(formatContainer);
cardsField = new QLineEdit(secondaryToolbar);
cardsField->setPlaceholderText(tr("Contains card..."));
cardsField->setMinimumWidth(140);
commandersField = new QLineEdit(secondaryToolbar);
commandersField->setPlaceholderText(tr("Commander..."));
commandersField->setMinimumWidth(140);
deckTagNameField = new QLineEdit(secondaryToolbar);
deckTagNameField->setPlaceholderText(tr("Tag..."));
deckTagNameField->setMinimumWidth(100);
// Deck size filter (collapsible)
deckSizeButton = new SettingsButtonWidget(secondaryToolbar);
deckSizeButton->setButtonText(tr("Deck Size"));
QWidget *sizeContainer = new QWidget(secondaryToolbar);
QHBoxLayout *sizeLayout = new QHBoxLayout(sizeContainer);
sizeLayout->setContentsMargins(4, 4, 4, 4);
minDeckSizeSpin = new QSpinBox(sizeContainer);
minDeckSizeSpin->setSpecialValueText(tr("Any"));
minDeckSizeSpin->setRange(0, 200);
minDeckSizeSpin->setValue(0);
minDeckSizeLogicCombo = new QComboBox(sizeContainer);
minDeckSizeLogicCombo->addItems({"Exact", "", ""});
minDeckSizeLogicCombo->setCurrentIndex(1);
sizeLayout->addWidget(new QLabel(tr("Cards:"), sizeContainer));
sizeLayout->addWidget(minDeckSizeSpin);
sizeLayout->addWidget(minDeckSizeLogicCombo);
deckSizeButton->addSettingsWidget(sizeContainer);
// Setup card name autocomplete
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel);
auto *searchModel = new CardSearchModel(displayModel, this);
auto *proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
auto *completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(10);
cardsField->setCompleter(completer);
commandersField->setCompleter(completer);
// Keep autocomplete working for both fields
connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty())
completer->complete();
});
connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty())
completer->complete();
});
// Assemble secondary toolbar
secondaryToolbarLayout->addWidget(bracketLabel);
secondaryToolbarLayout->addWidget(edhBracketCombo);
secondaryToolbarLayout->addWidget(formatButton);
secondaryToolbarLayout->addWidget(cardsField);
secondaryToolbarLayout->addWidget(commandersField);
secondaryToolbarLayout->addWidget(deckTagNameField);
secondaryToolbarLayout->addWidget(deckSizeButton);
secondaryToolbarLayout->addStretch();
}
void TabArchidekt::connectSignals()
{
// Advanced filters toggle
connect(advancedFiltersButton, &QPushButton::clicked, this,
[this](bool checked) { secondaryToolbar->setVisible(checked); });
// These trigger immediate search (no debounce needed)
connect(orderByCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
connect(orderDirButton, &QPushButton::clicked, [this](bool checked) {
orderDirButton->setText(checked ? tr("Desc.") : tr("Asc."));
doSearch();
});
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(),
&SettingsCache::setArchidektPreviewCardSize);
// Search button triggers immediate search
connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate);
// These trigger search (but not text fields)
connect(logicalAndCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
connect(packagesCheck, &QCheckBox::clicked, [this]() {
updatePackageModeState(packagesCheck->isChecked());
doSearch();
});
// Format filters trigger search
connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
connect(minDeckSizeSpin, qOverload<int>(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch);
connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
// Allow Enter key in text fields to trigger search
connect(nameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate);
connect(ownerField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate);
connect(cardsField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate);
connect(commandersField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate);
connect(deckTagNameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate);
// Format checkboxes trigger search
for (auto *formatCheck : formatChecks) {
connect(formatCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
}
}
void TabArchidekt::updatePackageModeState(bool isPackageMode)
{
// Disable format-specific and commander-specific filters in package mode
for (auto *cb : formatChecks) {
cb->setEnabled(!isPackageMode);
}
edhBracketCombo->setEnabled(!isPackageMode);
if (isPackageMode) {
edhBracketCombo->setCurrentIndex(0);
}
commandersField->setEnabled(!isPackageMode);
deckTagNameField->setEnabled(!isPackageMode);
}
void TabArchidekt::retranslateUi()
{
sortByLabel->setText(tr("Sort by:"));
orderDirButton->setText(orderDirButton->isChecked() ? tr("Desc.") : tr("Asc."));
filterByLabel->setText(tr("Filter by:"));
logicalAndCheck->setText(tr("AND"));
logicalAndCheck->setToolTip(tr("Require ALL selected colors"));
nameField->setPlaceholderText(tr("Deck name..."));
ownerField->setPlaceholderText(tr("Owner..."));
packagesCheck->setText(tr("Packages"));
advancedFiltersButton->setText(tr("Advanced Filters"));
cardsField->setPlaceholderText(tr("Contains card..."));
commandersField->setPlaceholderText(tr("Commander..."));
deckTagNameField->setPlaceholderText(tr("Tag..."));
formatButton->setButtonText(tr("Formats"));
deckSizeButton->setButtonText(tr("Deck Size"));
searchButton->setText(tr("Search"));
settingsButton->setToolTip(tr("Display Settings"));
}
QString TabArchidekt::buildSearchUrl()
{
QUrlQuery query;
// orderBy (field + direction)
QString field = orderByCombo->currentText();
if (!field.isEmpty()) {
bool desc = orderDirButton->isChecked();
QString final = desc ? "-" + field : field;
query.addQueryItem("orderBy", final);
}
// Colors
QStringList selectedColors;
for (QChar c : activeColors) {
selectedColors.append(c);
}
if (!selectedColors.isEmpty()) {
query.addQueryItem("colors", selectedColors.join(","));
}
// logicalAnd
if (logicalAndCheck->isChecked()) {
query.addQueryItem("logicalAnd", "true");
}
// Formats (disabled in package mode)
if (!packagesCheck->isChecked()) {
QStringList formatIds;
for (int i = 0; i < formatChecks.size(); ++i) {
if (formatChecks[i]->isChecked()) {
formatIds << QString::number(i + 1);
}
}
if (!formatIds.isEmpty()) {
query.addQueryItem("deckFormat", formatIds.join(","));
}
// edhBracket
if (edhBracketCombo->currentIndex() > 0) {
query.addQueryItem("edhBracket", edhBracketCombo->currentText());
}
}
// Package mode
if (packagesCheck->isChecked()) {
query.addQueryItem("packages", "true");
}
// Name
if (!nameField->text().isEmpty()) {
query.addQueryItem("name", nameField->text());
}
// Owner
if (!ownerField->text().isEmpty()) {
query.addQueryItem("ownerUsername", ownerField->text());
}
// Cards
if (!cardsField->text().isEmpty()) {
query.addQueryItem("cards", cardsField->text());
}
// Commander (disabled in package mode)
if (!packagesCheck->isChecked() && !commandersField->text().isEmpty()) {
query.addQueryItem("commanderName", commandersField->text());
}
// Deck tag (disabled in package mode)
if (!packagesCheck->isChecked() && !deckTagNameField->text().isEmpty()) {
query.addQueryItem("deckTagName", deckTagNameField->text());
}
// Page number (for infinite scroll)
query.addQueryItem("page", QString::number(currentPage));
// Min deck size
if (minDeckSizeSpin->value() != 0) {
query.addQueryItem("size", QString::number(minDeckSizeSpin->value()));
QString logic = "GTE";
QString selected = minDeckSizeLogicCombo->currentText();
if (selected == "")
logic = "GTE";
else if (selected == "")
logic = "LTE";
else
logic = "";
if (!logic.isEmpty()) {
query.addQueryItem("sizeLogic", logic);
}
}
QUrl url("https://archidekt.com/api/decks/v3/");
url.setQuery(query);
return url.toString();
}
void TabArchidekt::doSearch()
{
// Reset to first page on new search
currentPage = 1;
// We're searching, so we'll be in list mode
isListMode = true;
// Don't debounce - only called by explicit user actions now
doSearchImmediate();
}
void TabArchidekt::doSearchImmediate()
{
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
}
void TabArchidekt::loadNextPage()
{
if (isLoadingMore) {
return;
}
isLoadingMore = true;
currentPage++;
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
}
void TabArchidekt::actNavigatePage(QString url)
{
QNetworkRequest request{QUrl(url)};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
}
void TabArchidekt::getTopDecks()
{
currentPage = 1;
QNetworkRequest request{QUrl(buildSearchUrl())};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
}
void TabArchidekt::processApiJson(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
isLoadingMore = false;
reply->deleteLater();
return;
}
QByteArray responseData = reply->readAll();
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
if (!jsonDoc.isObject()) {
isLoadingMore = false;
reply->deleteLater();
return;
}
QJsonObject jsonObj = jsonDoc.object();
QString responseUrl = reply->url().toString();
if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) {
processTopDecksResponse(jsonObj);
} else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) {
processDeckResponse(jsonObj);
} else {
prettyPrintJson(jsonObj, 4);
}
isLoadingMore = false;
reply->deleteLater();
}
void TabArchidekt::processTopDecksResponse(QJsonObject reply)
{
ArchidektDeckListingApiResponse deckData;
deckData.fromJson(reply);
// New search → clear everything
if (currentPage == 1) {
QLayoutItem *item;
while ((item = resultsLayout->takeAt(0)) != nullptr) {
delete item->widget();
delete item;
}
listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider);
connect(listingsWidget, &ArchidektApiResponseDeckListingsDisplayWidget::requestNavigation, this,
&TabArchidekt::actNavigatePage);
resultsLayout->addWidget(listingsWidget);
return;
}
// Infinite scroll → append
if (listingsWidget) {
listingsWidget->append(deckData);
}
}
void TabArchidekt::processDeckResponse(QJsonObject reply)
{
ArchidektApiResponseDeck deckData;
deckData.fromJson(reply);
// We're in single deck mode - disable infinite scroll
isListMode = false;
// Clear existing results for single deck view
QLayoutItem *item;
while ((item = resultsLayout->takeAt(0)) != nullptr) {
delete item->widget();
delete item;
}
auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider);
connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage);
connect(display, &ArchidektApiResponseDeckDisplayWidget::requestSearch, this, &TabArchidekt::doSearchImmediate);
connect(display, &ArchidektApiResponseDeckDisplayWidget::openInDeckEditor, tabSupervisor,
&TabSupervisor::openDeckInNewTab);
resultsLayout->addWidget(display);
}
void TabArchidekt::prettyPrintJson(const QJsonValue &value, int indentLevel)
{
const QString indent(indentLevel * 2, ' ');
if (value.isObject()) {
QJsonObject obj = value.toObject();
for (auto it = obj.begin(); it != obj.end(); ++it) {
qInfo().noquote() << indent + it.key() + ":";
prettyPrintJson(it.value(), indentLevel + 1);
}
} else if (value.isArray()) {
QJsonArray array = value.toArray();
for (int i = 0; i < array.size(); ++i) {
qInfo().noquote() << indent + QString("[%1]:").arg(i);
prettyPrintJson(array[i], indentLevel + 1);
}
} else if (value.isString()) {
qInfo().noquote() << indent + "\"" + value.toString() + "\"";
} else if (value.isDouble()) {
qInfo().noquote() << indent + QString::number(value.toDouble());
} else if (value.isBool()) {
qInfo().noquote() << indent + (value.toBool() ? "true" : "false");
} else if (value.isNull()) {
qInfo().noquote() << indent + "null";
}
}