3 Commits

Author SHA1 Message Date
Oleg Shparber 454ad0b009 fix(ui): discard partial archive on failed download (#1957) 2026-08-18 16:27:20 +03:00
Oleg Shparber e025a0a0dc fix(ui): limit parallel docset downloads (#1956)
Fixes #1953.
2026-08-18 13:46:30 +03:00
Oleg Shparber 50f3ddb6bc fix(ui): make tray icon readable on dark panels (#1955)
Fixes #1950.
2026-08-17 12:34:35 +03:00
14 changed files with 296 additions and 84 deletions
+2
View File
@@ -63,6 +63,8 @@ path = [
"assets/freedesktop/*-apps-zeal.png",
"assets/freedesktop/sc-apps-zeal.svg",
"assets/freedesktop/sc-apps-zeal-tray.svg",
"assets/freedesktop/sc-apps-zeal-tray-light.svg",
"assets/freedesktop/sc-apps-zeal-tray-dark.svg",
"src/app/resources/zeal.icns",
"src/app/resources/zeal.ico",
]
+2
View File
@@ -20,6 +20,8 @@ if(UNIX AND NOT APPLE)
"512-apps-zeal.png"
"sc-apps-zeal.svg"
"sc-apps-zeal-tray.svg"
"sc-apps-zeal-tray-light.svg"
"sc-apps-zeal-tray-dark.svg"
DESTINATION ${KDE_INSTALL_ICONDIR}
)
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<title>Zeal</title>
<desc>Offline documentation browser — dark monochrome Z silhouette for light system tray panels.</desc>
<g fill="#000000" transform="translate(8.446 25.000) scale(0.02514 -0.02514)">
<path d="M58.667,0L58.667,112.667L376.833,591.167L71,591.167L71,716L534,716L534,603.333L216.333,124.833L541.333,124.833L541.333,0L58.667,0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<title>Zeal</title>
<desc>Offline documentation browser — light monochrome Z silhouette for dark system tray panels.</desc>
<g fill="#ffffff" transform="translate(8.446 25.000) scale(0.02514 -0.02514)">
<path d="M58.667,0L58.667,112.667L376.833,591.167L71,591.167L71,716L534,716L534,603.333L216.333,124.833L541.333,124.833L541.333,0L58.667,0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 433 B

+7 -1
View File
@@ -1,7 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<title>Zeal</title>
<desc>Offline documentation browser — monochrome Z silhouette for system tray and menu bar icons.</desc>
<g transform="translate(8.446 25.000) scale(0.02514 -0.02514)" fill="#000000">
<!-- Desktops that recolor tray icons replace this rule with the panel's text
color. Those that do not fall back to white, which suits the dark panels
such desktops overwhelmingly ship. -->
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color: #ffffff; }
</style>
<g class="ColorScheme-Text" fill="currentColor" transform="translate(8.446 25.000) scale(0.02514 -0.02514)">
<path d="M58.667,0L58.667,112.667L376.833,591.167L71,591.167L71,716L534,716L534,603.333L216.333,124.833L541.333,124.833L541.333,0L58.667,0Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 775 B

+2
View File
@@ -2,6 +2,8 @@
<qresource prefix="/">
<file alias="zeal.svg">../../assets/freedesktop/sc-apps-zeal.svg</file>
<file alias="zeal-tray.svg">../../assets/freedesktop/sc-apps-zeal-tray.svg</file>
<file alias="zeal-tray-light.svg">../../assets/freedesktop/sc-apps-zeal-tray-light.svg</file>
<file alias="zeal-tray-dark.svg">../../assets/freedesktop/sc-apps-zeal-tray-dark.svg</file>
</qresource>
<qresource prefix="/browser">
<file alias="new-tab.html">resources/browser/new-tab.html</file>
+18
View File
@@ -47,6 +47,7 @@ Settings::Settings(QObject *parent)
{
qRegisterMetaType<ContentAppearance>("ContentAppearance");
qRegisterMetaType<ExternalLinkPolicy>("ExternalLinkPolicy");
qRegisterMetaType<TrayIconStyle>("TrayIconStyle");
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
// When the OS color scheme changes, reapply the color scheme.
@@ -140,6 +141,8 @@ void Settings::load()
showSystrayIcon = settings->value(QStringLiteral("show_systray_icon"), true).toBool();
minimizeToSystray = settings->value(QStringLiteral("minimize_to_systray"), false).toBool();
hideOnClose = settings->value(QStringLiteral("hide_on_close"), false).toBool();
trayIconStyle = settings->value(QStringLiteral("systray_icon_style"), QVariant::fromValue(TrayIconStyle::Automatic))
.value<TrayIconStyle>();
settings->beginGroup(GroupUI);
hideMenuBar = settings->value(QStringLiteral("hide_menu_bar"), false).toBool();
@@ -295,6 +298,7 @@ void Settings::save()
settings->setValue(QStringLiteral("show_systray_icon"), showSystrayIcon);
settings->setValue(QStringLiteral("minimize_to_systray"), minimizeToSystray);
settings->setValue(QStringLiteral("hide_on_close"), hideOnClose);
settings->setValue(QStringLiteral("systray_icon_style"), QVariant::fromValue(trayIconStyle));
settings->beginGroup(GroupUI);
settings->setValue(QStringLiteral("hide_menu_bar"), hideMenuBar);
@@ -450,3 +454,17 @@ QDataStream &operator>>(QDataStream &in, Zeal::Core::Settings::ExternalLinkPolic
policy = static_cast<Zeal::Core::Settings::ExternalLinkPolicy>(value);
return in;
}
QDataStream &operator<<(QDataStream &out, Zeal::Core::Settings::TrayIconStyle style)
{
out << static_cast<std::underlying_type_t<Zeal::Core::Settings::TrayIconStyle>>(style);
return out;
}
QDataStream &operator>>(QDataStream &in, Zeal::Core::Settings::TrayIconStyle &style)
{
std::underlying_type_t<Zeal::Core::Settings::TrayIconStyle> value = 0;
in >> value;
style = static_cast<Zeal::Core::Settings::TrayIconStyle>(value);
return in;
}
+13
View File
@@ -35,6 +35,15 @@ public:
bool minimizeToSystray;
bool hideOnClose;
enum class TrayIconStyle : unsigned int {
Automatic = 0,
Colorful,
MonochromeLight,
MonochromeDark
};
Q_ENUM(TrayIconStyle)
TrayIconStyle trayIconStyle = TrayIconStyle::Automatic;
// Global Shortcuts
QKeySequence showShortcut;
// TODO: QKeySequence searchSelectedTextShortcut;
@@ -148,7 +157,11 @@ QDataStream &operator>>(QDataStream &in, Zeal::Core::Settings::ContentAppearance
QDataStream &operator<<(QDataStream &out, Zeal::Core::Settings::ExternalLinkPolicy policy);
QDataStream &operator>>(QDataStream &in, Zeal::Core::Settings::ExternalLinkPolicy &policy);
QDataStream &operator<<(QDataStream &out, Zeal::Core::Settings::TrayIconStyle style);
QDataStream &operator>>(QDataStream &in, Zeal::Core::Settings::TrayIconStyle &style);
Q_DECLARE_METATYPE(Zeal::Core::Settings::ContentAppearance)
Q_DECLARE_METATYPE(Zeal::Core::Settings::ExternalLinkPolicy)
Q_DECLARE_METATYPE(Zeal::Core::Settings::TrayIconStyle)
#endif // ZEAL_CORE_SETTINGS_H
+117 -67
View File
@@ -30,6 +30,7 @@
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPushButton>
#include <QStringList>
#include <QTemporaryFile>
#include <QUrl>
@@ -43,13 +44,6 @@ Q_LOGGING_CATEGORY(log, "zeal.widgetui.docsetsdialog")
using Qt::Literals::StringLiterals::operator""_L1;
enum class DownloadType {
DashFeed,
Docset,
DocsetList,
TarixIndex
};
constexpr auto ApiServerUrl = "https://api.zealdocs.org/v1"_L1;
constexpr auto RedirectServerUrl = "https://go.zealdocs.org/d/%1/%2/latest"_L1;
// TODO: Each source plugin should have its own cache
@@ -69,15 +63,8 @@ constexpr const char *TarixRetryProperty = "tarixRetry";
constexpr int MaxTarixIndexRetries = 2;
void setDownloadType(QNetworkReply *reply, DownloadType type)
{
reply->setProperty(DownloadTypeProperty, static_cast<int>(type));
}
DownloadType downloadType(const QNetworkReply *reply)
{
return static_cast<DownloadType>(reply->property(DownloadTypeProperty).toInt());
}
// The download servers rate limit clients.
constexpr int MaxConcurrentDownloads = 6;
// An empty name, or one with path separators, could escape the cache and storage directories.
bool isDocsetNameSafe(const QString &docsetName)
@@ -169,8 +156,7 @@ void DocsetsDialog::addDashFeed()
feedUrl = QUrl::fromPercentEncoding(feedUrl.toUtf8());
}
QNetworkReply *reply = download(QUrl(feedUrl));
setDownloadType(reply, DownloadType::DashFeed);
enqueueDownload({.url = QUrl(feedUrl), .type = DownloadType::DashFeed});
}
void DocsetsDialog::updateSelectedDocsets()
@@ -266,7 +252,7 @@ void DocsetsDialog::downloadSelectedDocsets()
}
QAbstractItemModel *model = ui->availableDocsetList->model();
model->setData(index, tr("Downloading: %p%"), DocsetListItemDelegate::FormatRole);
model->setData(index, tr("Queued"), DocsetListItemDelegate::FormatRole);
model->setData(index, 0, DocsetListItemDelegate::ValueRole);
model->setData(index, true, DocsetListItemDelegate::ShowProgressRole);
@@ -308,17 +294,30 @@ void DocsetsDialog::downloadCompleted()
m_replies.removeOne(reply.data());
processDownload(reply.data());
startPendingDownloads();
}
void DocsetsDialog::processDownload(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
if (downloadType(reply.data()) == DownloadType::TarixIndex) {
if (downloadType(reply) == DownloadType::TarixIndex) {
if (reply->error() != QNetworkReply::OperationCanceledError) {
onTarixIndexFailed(reply.data());
onTarixIndexFailed(reply);
}
updateStatus();
return;
}
if (reply->error() != QNetworkReply::OperationCanceledError) {
const auto type = downloadType(reply);
const QString docsetName = reply->property(DocsetNameProperty).toString();
// Downloads cannot be resumed, so a retry would append to whatever was received.
if (type == DownloadType::Docset) {
delete m_tmpFiles.take(docsetName);
}
const QString msg = tr("Download failed!<br><br><b>Error:</b> %1<br><b>URL:</b> %2")
.arg(reply->errorString().toHtmlEscaped(),
reply->request().url().toString().toHtmlEscaped());
@@ -328,31 +327,26 @@ void DocsetsDialog::downloadCompleted()
QMessageBox::Retry | QMessageBox::Cancel);
if (ret == QMessageBox::Retry) {
QNetworkReply *newReply = download(reply->request().url());
// Copy properties
newReply->setProperty(DocsetNameProperty, reply->property(DocsetNameProperty));
setDownloadType(newReply, downloadType(reply.data()));
newReply->setProperty(ListItemIndexProperty, reply->property(ListItemIndexProperty));
enqueueDownload({.url = reply->request().url(),
.type = type,
.docsetName = docsetName,
.listItemIndex = reply->property(ListItemIndexProperty).toInt()});
return;
}
bool ok = false;
QListWidgetItem *listItem = ui->availableDocsetList->item(
reply->property(ListItemIndexProperty).toInt(&ok));
if (ok && listItem != nullptr) {
QListWidgetItem *listItem = ui->availableDocsetList->item(reply->property(ListItemIndexProperty).toInt());
if (listItem != nullptr) {
listItem->setData(DocsetListItemDelegate::ShowProgressRole, false);
}
}
updateStatus();
return;
}
const auto type = downloadType(reply.data());
const auto type = downloadType(reply);
switch (type) {
case DownloadType::DocsetList:
processDocsetListReply(reply.data());
processDocsetListReply(reply);
break;
case DownloadType::DashFeed: {
@@ -369,9 +363,10 @@ void DocsetsDialog::downloadCompleted()
if (docset == nullptr) {
// Fetch docset only on first feed download,
// since further downloads are only update checks
QNetworkReply *mdReply = download(metadata.url());
mdReply->setProperty(DocsetNameProperty, metadata.name());
setDownloadType(mdReply, DownloadType::Docset);
enqueueDownload({.url = metadata.url(),
.type = DownloadType::Docset,
.docsetName = metadata.name(),
.listItemIndex = ui->availableDocsetList->row(findDocsetListItem(metadata.name()))});
} else {
// Check for feed update
if (metadata.latestVersion() != docset->version() || metadata.revision() > docset->revision()) {
@@ -509,9 +504,6 @@ void DocsetsDialog::downloadCompleted()
qCWarning(log, "Unknown download type %d.", static_cast<int>(type));
break;
}
// If all enqueued downloads have finished executing.
updateStatus();
}
// creates a total download progress for multiple QNetworkReplies
@@ -747,7 +739,7 @@ void DocsetsDialog::setupAvailableDocsetsTab()
ui->availableDocsetList->selectionModel()->select(index, QItemSelectionModel::Deselect);
QAbstractItemModel *model = ui->availableDocsetList->model();
model->setData(index, tr("Downloading: %p%"), DocsetListItemDelegate::FormatRole);
model->setData(index, tr("Queued"), DocsetListItemDelegate::FormatRole);
model->setData(index, 0, DocsetListItemDelegate::ValueRole);
model->setData(index, true, DocsetListItemDelegate::ShowProgressRole);
@@ -804,12 +796,12 @@ void DocsetsDialog::updateAvailableDocsetsEmptyState()
m_availableDocsetsEmptyState->setText(tr("No available docsets"));
}
m_availableDocsetsEmptyState->setEmpty(!hasVisibleDocsets && m_replies.isEmpty());
m_availableDocsetsEmptyState->setEmpty(!hasVisibleDocsets && m_replies.isEmpty() && m_pendingDownloads.isEmpty());
}
void DocsetsDialog::enableControls()
{
if (m_isStorageReadOnly || !m_replies.isEmpty() || !m_tmpFiles.isEmpty()) {
if (m_isStorageReadOnly || !m_replies.isEmpty() || !m_pendingDownloads.isEmpty() || !m_tmpFiles.isEmpty()) {
return;
}
@@ -875,22 +867,70 @@ bool DocsetsDialog::updatesAvailable() const
});
}
QNetworkReply *DocsetsDialog::download(const QUrl &url)
DocsetsDialog::DownloadType DocsetsDialog::downloadType(const QNetworkReply *reply)
{
QNetworkReply *reply = m_application->download(url);
return static_cast<DownloadType>(reply->property(DownloadTypeProperty).toInt());
}
void DocsetsDialog::enqueueDownload(const DownloadRequest &request)
{
// Other work waits on metadata requests, so they are served before the queued archives.
if (request.type == DownloadType::Docset) {
m_pendingDownloads.append(request);
} else {
m_pendingDownloads.prepend(request);
}
disableControls();
startPendingDownloads();
}
void DocsetsDialog::startDownload(const DownloadRequest &request)
{
QNetworkReply *reply = m_application->download(request.url);
reply->setProperty(DownloadTypeProperty, static_cast<int>(request.type));
reply->setProperty(DocsetNameProperty, request.docsetName);
reply->setProperty(ListItemIndexProperty, request.listItemIndex);
reply->setProperty(TarixRetryProperty, request.tarixRetry);
connect(reply, &QNetworkReply::downloadProgress, this, &DocsetsDialog::downloadProgress);
connect(reply, &QNetworkReply::finished, this, &DocsetsDialog::downloadCompleted);
m_replies.append(reply);
disableControls();
updateStatus();
QListWidgetItem *listItem = ui->availableDocsetList->item(request.listItemIndex);
if (listItem != nullptr && listItem->data(DocsetListItemDelegate::ShowProgressRole).toBool()) {
listItem->setData(DocsetListItemDelegate::FormatRole, tr("Downloading: %p%"));
}
}
return reply;
void DocsetsDialog::startPendingDownloads()
{
while (!m_pendingDownloads.isEmpty() && m_replies.size() < MaxConcurrentDownloads) {
startDownload(m_pendingDownloads.takeFirst());
}
updateStatus();
}
void DocsetsDialog::cancelDownloads()
{
for (QNetworkReply *reply : std::as_const(m_replies)) {
for (const DownloadRequest &request : std::as_const(m_pendingDownloads)) {
QListWidgetItem *listItem = ui->availableDocsetList->item(request.listItemIndex);
if (listItem != nullptr) {
listItem->setData(DocsetListItemDelegate::ShowProgressRole, false);
}
// The archive is already downloaded, so nothing below will release it.
if (request.type == DownloadType::TarixIndex) {
delete m_tmpFiles.take(request.docsetName);
}
}
m_pendingDownloads.clear();
// Aborting emits finished(), which removes the reply from m_replies, so iterate over a copy.
const QList<QNetworkReply *> replies = m_replies;
for (QNetworkReply *reply : replies) {
// Hide progress bar
QListWidgetItem *listItem = ui->availableDocsetList->item(reply->property(ListItemIndexProperty).toInt());
if (listItem != nullptr) {
@@ -913,8 +953,7 @@ void DocsetsDialog::loadUserFeedList()
const auto docsets = m_docsetRegistry->docsets();
for (const Registry::Docset *docset : docsets) {
if (!docset->feedUrl().isEmpty()) {
QNetworkReply *reply = download(QUrl(docset->feedUrl()));
setDownloadType(reply, DownloadType::DashFeed);
enqueueDownload({.url = QUrl(docset->feedUrl()), .type = DownloadType::DashFeed});
}
}
}
@@ -924,8 +963,7 @@ void DocsetsDialog::downloadDocsetList()
ui->availableDocsetList->clear();
m_availableDocsets.clear();
QNetworkReply *reply = download(QUrl(ApiServerUrl + QLatin1String("/docsets")));
setDownloadType(reply, DownloadType::DocsetList);
enqueueDownload({.url = QUrl(ApiServerUrl + QLatin1String("/docsets")), .type = DownloadType::DocsetList});
}
void DocsetsDialog::processDocsetListReply(QNetworkReply *reply)
@@ -1054,6 +1092,14 @@ void DocsetsDialog::downloadDashDocset(const QModelIndex &index)
}
}
// Skip if a download is already queued for this docset.
const bool isQueued = std::ranges::any_of(m_pendingDownloads, [&name](const DownloadRequest &request) {
return request.type == DownloadType::Docset && request.docsetName == name;
});
if (isQueued) {
return;
}
QUrl url;
if (!m_userFeeds.contains(name)) {
// No feed present means that this is a Kapeli docset
@@ -1071,19 +1117,19 @@ void DocsetsDialog::downloadDashDocset(const QModelIndex &index)
return;
}
QNetworkReply *reply = download(url);
reply->setProperty(DocsetNameProperty, name);
setDownloadType(reply, DownloadType::Docset);
reply->setProperty(ListItemIndexProperty, ui->availableDocsetList->row(findDocsetListItem(name)));
enqueueDownload({.url = url,
.type = DownloadType::Docset,
.docsetName = name,
.listItemIndex = ui->availableDocsetList->row(findDocsetListItem(name))});
}
void DocsetsDialog::downloadTarixIndex(const QString &docsetName, const QUrl &indexUrl, int attempt)
{
QNetworkReply *reply = download(indexUrl);
reply->setProperty(DocsetNameProperty, docsetName);
reply->setProperty(TarixRetryProperty, attempt);
reply->setProperty(ListItemIndexProperty, ui->availableDocsetList->row(findDocsetListItem(docsetName)));
setDownloadType(reply, DownloadType::TarixIndex);
enqueueDownload({.url = indexUrl,
.type = DownloadType::TarixIndex,
.docsetName = docsetName,
.listItemIndex = ui->availableDocsetList->row(findDocsetListItem(docsetName)),
.tarixRetry = attempt});
}
void DocsetsDialog::onTarixIndexFailed(QNetworkReply *reply)
@@ -1164,17 +1210,21 @@ bool DocsetsDialog::removeDocset(const QString &name)
void DocsetsDialog::updateStatus()
{
QString text;
QStringList parts;
if (!m_replies.isEmpty()) {
text = tr("Downloading: %n.", nullptr, static_cast<int>(m_replies.size()));
parts << tr("Downloading: %n.", nullptr, static_cast<int>(m_replies.size()));
}
if (!m_pendingDownloads.isEmpty()) {
parts << tr("Queued: %n.", nullptr, static_cast<int>(m_pendingDownloads.size()));
}
if (!m_tmpFiles.isEmpty()) {
text += QLatin1String(" ") + tr("Installing: %n.", nullptr, static_cast<int>(m_tmpFiles.size()));
parts << tr("Installing: %n.", nullptr, static_cast<int>(m_tmpFiles.size()));
}
ui->statusLabel->setText(text);
ui->statusLabel->setText(parts.join(QLatin1Char(' ')));
updateAvailableDocsetsEmptyState();
enableControls();
+26 -2
View File
@@ -10,13 +10,14 @@
#include <QDialog>
#include <QHash>
#include <QList>
#include <QMap>
#include <QUrl>
class QDateTime;
class QListWidgetItem;
class QNetworkReply;
class QTemporaryFile;
class QUrl;
namespace Zeal {
@@ -45,6 +46,23 @@ public:
~DocsetsDialog() override;
private:
enum class DownloadType {
DashFeed,
Docset,
DocsetList,
TarixIndex
};
struct DownloadRequest
{
QUrl url = {};
DownloadType type = DownloadType::Docset;
QString docsetName = {};
// Row in the available docsets list, or -1 if there is no matching entry.
int listItemIndex = -1;
int tarixRetry = 0;
};
void addDashFeed();
void updateSelectedDocsets();
void updateAllDocsets();
@@ -54,6 +72,7 @@ private:
void downloadSelectedDocsets();
void downloadCompleted();
void processDownload(QNetworkReply *reply);
void downloadProgress(qint64 received, qint64 total);
void extractionCompleted(const QString &filePath);
@@ -71,6 +90,7 @@ private:
bool m_isStorageReadOnly = false;
QList<QNetworkReply *> m_replies;
QList<DownloadRequest> m_pendingDownloads;
// TODO: Create a special model
Util::CaseInsensitiveMap<Registry::DocsetMetadata> m_availableDocsets;
@@ -90,7 +110,9 @@ private:
QListWidgetItem *findDocsetListItem(const QString &name) const;
bool updatesAvailable() const;
QNetworkReply *download(const QUrl &url);
void enqueueDownload(const DownloadRequest &request);
void startDownload(const DownloadRequest &request);
void startPendingDownloads();
void cancelDownloads();
void loadUserFeedList();
@@ -117,6 +139,8 @@ private:
// FIXME: Come up with a better approach
QString docsetNameForTmpFilePath(const QString &filePath) const;
static DownloadType downloadType(const QNetworkReply *reply);
static inline int percent(qint64 fraction, qint64 total);
static QString cacheLocation(const QString &fileName);
+21
View File
@@ -44,6 +44,23 @@ SettingsDialog::SettingsDialog(QWidget *parent)
}
});
// System tray
using TrayIconStyle = Core::Settings::TrayIconStyle;
ui->trayIconStyleComboBox->addItem(tr("Automatic"), QVariant::fromValue(TrayIconStyle::Automatic));
ui->trayIconStyleComboBox->addItem(tr("Colorful"), QVariant::fromValue(TrayIconStyle::Colorful));
ui->trayIconStyleComboBox->addItem(tr("Monochrome Light"), QVariant::fromValue(TrayIconStyle::MonochromeLight));
ui->trayIconStyleComboBox->addItem(tr("Monochrome Dark"), QVariant::fromValue(TrayIconStyle::MonochromeDark));
ui->trayIconStyleComboBox->setToolTip(
tr("Automatic lets the desktop tint a monochrome icon to match the panel. Choose a fixed style if the icon is "
"hard to see."));
#if defined(Q_OS_MACOS) || defined(Q_OS_WIN)
// The platform dictates the icon: a template image on macOS, the full-color
// window icon on Windows.
ui->trayIconStyleLabel->hide();
ui->trayIconStyleComboBox->hide();
#endif
// Fonts
ui->defaultFontComboBox->addItem(tr("Serif"), QStringLiteral("serif"));
ui->defaultFontComboBox->addItem(tr("Sans-serif"), QStringLiteral("sans-serif"));
@@ -166,6 +183,9 @@ void SettingsDialog::loadSettings()
ui->checkForUpdateCheckBox->setChecked(settings->checkForUpdate);
ui->systrayGroupBox->setChecked(settings->showSystrayIcon);
// Fall back to the first entry if the stored style is not a known value.
ui->trayIconStyleComboBox->setCurrentIndex(
qMax(0, ui->trayIconStyleComboBox->findData(QVariant::fromValue(settings->trayIconStyle))));
ui->minimizeToSystrayCheckBox->setChecked(settings->minimizeToSystray);
ui->hideToSystrayCheckBox->setChecked(settings->hideOnClose);
@@ -262,6 +282,7 @@ void SettingsDialog::saveSettings()
settings->checkForUpdate = ui->checkForUpdateCheckBox->isChecked();
settings->showSystrayIcon = ui->systrayGroupBox->isChecked();
settings->trayIconStyle = ui->trayIconStyleComboBox->currentData().value<Core::Settings::TrayIconStyle>();
settings->minimizeToSystray = ui->minimizeToSystrayCheckBox->isChecked();
settings->hideOnClose = ui->hideToSystrayCheckBox->isChecked();
+17
View File
@@ -62,6 +62,23 @@
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<layout class="QFormLayout" name="formLayout_5">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="trayIconStyleLabel">
<property name="text">
<string>Icon style:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="trayIconStyleComboBox"/>
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="minimizeToSystrayCheckBox">
<property name="text">
+56 -14
View File
@@ -20,6 +20,32 @@
namespace Zeal::WidgetUi {
#if !defined(Q_OS_MACOS) && !defined(Q_OS_WIN)
namespace {
QIcon themedTrayIcon(Core::Settings::TrayIconStyle style)
{
// Every variant is resolved by theme name so that the tray host receives an
// icon name rather than a pixmap. That is what lets desktops recolor the
// automatic variant, and lets icon themes override any of them.
switch (style) {
case Core::Settings::TrayIconStyle::Colorful:
return QIcon::fromTheme(QStringLiteral("zeal"), QIcon(QStringLiteral(":/zeal.svg")));
case Core::Settings::TrayIconStyle::MonochromeLight:
return QIcon::fromTheme(QStringLiteral("zeal-tray-light"), QIcon(QStringLiteral(":/zeal-tray-light.svg")));
case Core::Settings::TrayIconStyle::MonochromeDark:
return QIcon::fromTheme(QStringLiteral("zeal-tray-dark"), QIcon(QStringLiteral(":/zeal-tray-dark.svg")));
case Core::Settings::TrayIconStyle::Automatic:
break;
}
// Carries a color scheme stylesheet, so desktops that recolor tray icons tint
// it to match the panel. Those that do not fall back to its white default,
// hence the explicit choices above (#1950).
return QIcon::fromTheme(QStringLiteral("zeal-tray"), QIcon(QStringLiteral(":/zeal-tray.svg")));
}
} // namespace
#endif
WindowManager::WindowManager(Core::Application *application, QObject *parent)
: QObject(parent)
, m_application(application)
@@ -117,10 +143,17 @@ MainWindow *WindowManager::activeWindow() const
void WindowManager::applySettings()
{
if (m_settings->isTrayActive()) {
if (!m_settings->isTrayActive()) {
removeTrayIcon();
return;
}
// createTrayIcon() applies the icon itself, so only an already visible icon
// needs a refresh, e.g. after the style setting changed.
if (m_trayIcon == nullptr) {
createTrayIcon();
} else {
removeTrayIcon();
updateTrayIcon();
}
}
@@ -131,18 +164,7 @@ void WindowManager::createTrayIcon()
}
m_trayIcon = new QSystemTrayIcon(this);
#ifdef Q_OS_MACOS
// macOS menu-bar items render as template images: monochrome silhouettes
// tinted by the system to match light/dark mode and the active accent.
QIcon trayIcon(QStringLiteral(":/zeal-tray.svg"));
trayIcon.setIsMask(true);
#elif defined(Q_OS_WIN)
// Windows tray takes the icon as-is — reuse the full-color window icon.
const QIcon trayIcon = qApp->windowIcon();
#else
const QIcon trayIcon = QIcon::fromTheme(QStringLiteral("zeal-tray"), QIcon(QStringLiteral(":/zeal-tray.svg")));
#endif
m_trayIcon->setIcon(trayIcon);
updateTrayIcon();
m_trayIcon->setToolTip(QStringLiteral("Zeal"));
connect(m_trayIcon, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) {
@@ -183,6 +205,26 @@ void WindowManager::createTrayIcon()
m_trayIcon->show();
}
void WindowManager::updateTrayIcon()
{
if (m_trayIcon == nullptr) {
return;
}
#ifdef Q_OS_MACOS
// macOS menu-bar items render as template images: monochrome silhouettes
// tinted by the system to match light/dark mode and the active accent.
QIcon trayIcon(QStringLiteral(":/zeal-tray.svg"));
trayIcon.setIsMask(true);
#elif defined(Q_OS_WIN)
// Windows tray takes the icon as-is — reuse the full-color window icon.
const QIcon trayIcon = qApp->windowIcon();
#else
const QIcon trayIcon = themedTrayIcon(m_settings->trayIconStyle);
#endif
m_trayIcon->setIcon(trayIcon);
}
void WindowManager::removeTrayIcon()
{
if (m_trayIcon == nullptr) {
+1
View File
@@ -41,6 +41,7 @@ private:
void applySettings();
void createTrayIcon();
void updateTrayIcon();
void removeTrayIcon();
Core::Application *m_application = nullptr;