Files
transmission/qt/NativeIcon.cc
Charles Kerr bfa1950fbe feat: native icons in Qt client (#7819)
* chore: savepoint

* chore: code style

* refactor: add std::string_view constructor for NativeIcon::Spec

* chore: add TODO comment

* feat: honor per-desktop HIG on when to show menu icons

* chore: remove Faenza system-run icon

unused since b58e95910b

* chore: remove Faenza view-refresh icon

not needed due to b58e95910b

* chore: remove Faenza media-playback-pause icon

not needed due to b58e95910b

* chore: remove Faenza media-playback-start icon

not needed due to b58e95910b

* chore: add a safeguard against merging with incomplete TODO items

* feat: add more icons

refactor: remove some tracer cerr statements

* refactor: remove IconCache use from MainWindow

* chore: remove Faenza icon set

* chore: re-enable remote session network icon

* fix: FTBFS on Windows

* refactor: use symbolic names for Segoe icons

* docs: add links to Segoe MDL2 Assets icon list

* chore: savepoint

segoe icons work

still a WIP; includes test code that should not ship

* feat: use segoe::FastForward for action_StartNow

feat: use segoe::Move for action_SetLocation

refactor: make it easier for devs to force a font at compile time for development work

segoe license does not allow bundling but does allow dev work

chore: code_style.sh

* refactor: remove unused addEmblem()

* docs: add code comment on how to force an icon font

* fix: Win 10, 11 icons play nicely with dark mode

* chore: savepoint

add draft of SF Symbol -> QPixmap loader

* chore: remove dangling font reference from qrc file

* fix: FTBFS

* refactor: use bribri code for NSImage -> QPixmap

* feat: support dark, light mode when rendering SF Symbol monochrome icons

* fixup! feat: support dark, light mode when rendering SF Symbol monochrome icons

fix: fail gracefully on macOS 11

* chore: code style

* chore: tweak some SF Symbol icon choices

* chore: consistent uppercase for hex segoe QChars

* chore: undefine DEV_FORCE_FONT_FAMILY and DEV_FORCE_FONT_RESOURCE

* chore: savepoint

* refactor: clean up NativeIcon impl

* refactor: remove unused MenuMode::Other

* refactor: DRY in FilterBar::createActivityCombo()

* chore: remove obsolete code comment

* refactor: rename icons::Facet as icons::Type

* fix: oops

* refactor: minor cleanup

* fix: tyop

* chore: remove unused #includes

* fix: add modes for some icons

* refactor: tweak some icon choices on macOS

* fix: ensure icons are visible on File, Help menus

fix: remove unused local variable

* refactor: tweak some icon choices for XDG

* refactor: remove the fallback QStyle::StandardPixmaps

These interfere with deciding whether an icon is well-defined and
unambiguous as per the macOS and Windows HIG guidelines.

If a standard or unambiguous icon exists in the native icon sets,
specify it with an SF Symbols name, a Segoe codepoint,
or XDG standard icon name. Otherwise, leave those fields blank.

* refactor: remove unused #includes

* docs: add "choosing icons" section in NativeIcons.cc

* refactor: simplify icons::shouldBeShownInMenu()

* refactor: reduce unnecessary code shear from main

* refactor: make TorrentDelegate::warning_emblem_ const

* refactor: extract-method MainWindow::updateActionIcons()

* feat: update MainWindow icons when light/dark theme changes

* feat: restore the QStyle::StandardPixmaps as fallbacks

Can be used on older Windows / macOS if Segoe or SF Symbols are unavailable

* refactor: add button text for add/edit/remove tracker buttons

QStyle::StandardPixmap doesn't have good icons for these,
so let's ensure that these buttons have visible text.

* fix: building NativeIconMac.mm on mac even if not clang

* chore: iwyu in new code

* docs: tweak the "Choosing Icons" comments again

* fix: handle changed QStyles in icons::icon()

do not cache point_sizes set between calls

refactor: const correctness

* fixup! refactor: simplify icons::shouldBeShownInMenu()

refactor: minor code tweak, declare vars in order that they are used
2025-11-30 10:09:20 -06:00

485 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This file Copyright © Mnemosyne LLC.
// It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
// or any future license endorsed by Mnemosyne LLC.
// License text can be found in the licenses/ folder.
#include "NativeIcon.h"
#include <optional>
#include <string_view>
#include <QChar>
#include <QFontDatabase>
#include <QOperatingSystemVersion>
#include <QStyle>
#include <QtGui/QFont>
#include <QtGui/QGuiApplication> // qApp
#include <QtGui/QIcon>
#include <QtGui/QPainter>
#include <QtGui/QPalette>
#include <QtGui/QPixmap>
#include <small/set.hpp>
#if defined(Q_OS_MAC)
extern QPixmap loadSFSymbol(QString symbol_name, int pixel_size);
#endif
namespace icons
{
namespace
{
// https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-ui-symbol-font
auto const Win10IconFamily = QStringLiteral("Segoe MDL2 Assets");
// https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-fluent-icons-font
auto const Win11IconFamily = QStringLiteral("Segoe Fluent Icons");
// Define these two macros to force a specific icon icon during development.
// Their EULA doesn't allow redistribution but does allow using them
// during design/develop/testing.
// 1. Snag the ttf you want to use (Win 10 uses https://aka.ms/SegoeFonts,
// Win 11 uses https://aka.ms/SegoeFluentIcons).
// 2. Add it to application.qrc
// 3. Set these two macros accordingly
// #define DEV_FORCE_FONT_FAMILY Win11IconFamily
// #define DEV_FORCE_FONT_RESOURCE QStringLiteral(":devonly/segoe_fluent_icons.ttf")
QString getWindowsFontFamily()
{
#ifdef DEV_FORCE_FONT_FAMILY
return DEV_FORCE_FONT_FAMILY;
#else
if (QOperatingSystemVersion::current() >= QOperatingSystemVersion(QOperatingSystemVersion::Windows, 11))
return Win11IconFamily;
if (QOperatingSystemVersion::current() >= QOperatingSystemVersion(QOperatingSystemVersion::Windows, 10))
return Win10IconFamily;
return {};
#endif
}
void ensureFontsLoaded()
{
#ifdef DEV_FORCE_FONT_RESOURCE
[[maybe_unused]] static auto const font_id = QFontDatabase::addApplicationFont(DEV_FORCE_FONT_RESOURCE);
#endif
}
QPixmap makeIconFromCodepoint(QString const family, QChar const codepoint, int const point_size)
{
auto const font = QFont{ family, point_size - 8 };
if (!QFontMetrics{ font }.inFont(codepoint))
return {};
// FIXME: HDPI, pixel size vs point size?
auto const rect = QRect{ 0, 0, point_size, point_size };
auto pixmap = QPixmap{ rect.size() };
pixmap.fill(Qt::transparent);
auto painter = QPainter{ &pixmap };
painter.setFont(font);
painter.setBrush(Qt::NoBrush);
painter.setPen(qApp->palette().color(QPalette::ButtonText));
painter.setRenderHint(QPainter::TextAntialiasing);
auto br = QRect{};
painter.drawText(rect, Qt::AlignCenter, QString{ codepoint }, &br);
painter.end();
return pixmap;
}
struct Info
{
std::string_view sf_symbol_name;
char16_t segoe_codepoint = {};
std::string_view xdg_icon_name;
std::optional<QStyle::StandardPixmap> fallback;
bool ok_in_gnome_menus = false;
};
/**
* # Choosing Icons
*
* Use icons that follow the per-platform guidelines below.
* If there's not a match, don't give that Type an icon on that platform.
*
* ## Windows
*
* https://learn.microsoft.com/en-us/windows/apps/design/controls/menus
* Consider providing menu item icons for:
* - The most commonly used items.
* - Menu items whose icon is standard or well known.
* - Menu items whose icon well illustrates what the command does.
* Don't feel obligated to provide icons for commands that don't have
* a standard visualization. Cryptic icons aren't helpful, create visual
* clutter, and prevent users from focusing on the important menu items.
*
* ## macOS
*
* https://developer.apple.com/design/human-interface-guidelines/menus
* Represent menu item actions with familiar icons. Icons help people
* recognize common actions throughout your app. Use the same icons as
* the system to represent actions such as Copy, Share, and Delete,
* wherever they appear. For a list of icons that represent common
* actions, see Standard icons.
* Dont display an icon if you cant find one that clearly represents
* the menu item. Not all menu items need an icon. Be careful when adding
* icons for custom menu items to avoid confusion with other existing
* actions, and dont add icons just for the sake of ornamentation.
*
* ## GNOME
*
* https://discourse.gnome.org/t/principle-of-icons-in-menus/4803
* We do not “block” icons in menus; icons are generally reserved
* for “nouns”, or “objects”—for instance: website favicons in
* bookmark menus, or file-type icons—instead of having them for
* “verbs”, or “actions”—for instance: save, copy, print, etc.
*
* ## KDE
*
* https://develop.kde.org/hig/icons/#icons-for-menu-items-and-buttons-with-text
* Set an icon on every button and menu item, making sure not to
* use the same icon for multiple visible buttons or menu items.
* Choose different icons, or use more specific ones to disambiguate.
*
* ## QStyle::StandardPixmap
*
* This is an extremely limited icon set. Use with caution.
* https://github.com/transmission/transmission/pull/2283 has galleries.
* This is used as a fallback to ensure all toolbar acitions have icons,
* even on very old Windows / macOS systems lacking Segoe / SF Symbols.
*/
[[nodiscard]] constexpr Info getInfo(Type const type)
{
auto sf_symbol_name = std::string_view{};
auto xdg_icon_name = std::string_view{};
auto segoe_codepoint = char16_t{};
auto fallback = std::optional<QStyle::StandardPixmap>{};
auto ok_in_gnome_menus = false;
switch (type)
{
case Type::AddTracker:
sf_symbol_name = "plus";
segoe_codepoint = 0xE710U; // Add
xdg_icon_name = "list-add";
break;
case Type::EditTrackers:
sf_symbol_name = "pencil";
segoe_codepoint = 0xE70FU; // Edit
xdg_icon_name = "document-edit";
break;
case Type::RemoveTracker:
sf_symbol_name = "minus";
segoe_codepoint = 0xE738U; // Remove
xdg_icon_name = "list-remove";
break;
case Type::AddTorrentFromFile:
sf_symbol_name = "folder";
segoe_codepoint = 0xE8E5U; // OpenFile
xdg_icon_name = "document-open";
fallback = QStyle::SP_FileIcon;
break;
case Type::AddTorrentFromURL:
sf_symbol_name = "network";
segoe_codepoint = 0xE774U; // Globe
xdg_icon_name = "network-workgroup";
fallback = QStyle::SP_DriveNetIcon;
break;
case Type::CreateNewTorrent:
sf_symbol_name = "plus";
segoe_codepoint = 0xE710U; // Add
xdg_icon_name = "document-new";
fallback = QStyle::SP_FileIcon;
break;
case Type::OpenTorrentDetails:
sf_symbol_name = "doc.text.magnifyingglass";
segoe_codepoint = 0xE946U; // Info
xdg_icon_name = "document-properties";
ok_in_gnome_menus = true;
fallback = QStyle::SP_FileDialogContentsView;
break;
case Type::OpenTorrentLocalFolder:
sf_symbol_name = "folder";
segoe_codepoint = 0xED25U; // OpenFolderHorizontal
xdg_icon_name = "folder-open";
fallback = QStyle::SP_DirOpenIcon;
break;
case Type::StartTorrent:
sf_symbol_name = "play";
segoe_codepoint = 0xE768U; // Play
xdg_icon_name = "media-playback-start";
fallback = QStyle::SP_MediaPlay;
break;
case Type::StartTorrentNow:
sf_symbol_name = "forward";
segoe_codepoint = 0xEB9DU; // FastForward
xdg_icon_name = "media-seek-forward";
fallback = QStyle::SP_MediaSeekForward;
break;
case Type::RemoveTorrent:
sf_symbol_name = "minus";
segoe_codepoint = 0xE738U; // Remove
xdg_icon_name = "list-remove";
fallback = QStyle::SP_DialogCancelButton;
break;
case Type::RemoveTorrentAndDeleteData:
sf_symbol_name = "trash";
segoe_codepoint = 0xE74DU; // Delete
xdg_icon_name = "edit-delete";
fallback = QStyle::SP_TrashIcon;
break;
case Type::SetTorrentLocation:
sf_symbol_name = "arrow.up.and.down.and.arrow.left.and.right";
segoe_codepoint = 0xE7C2U; // Move
xdg_icon_name = "edit-find";
break;
case Type::CopyMagnetLinkToClipboard:
sf_symbol_name = "clipboard";
segoe_codepoint = 0xE8C8U; // Copy
xdg_icon_name = "edit-copy";
break;
case Type::SelectAll:
sf_symbol_name = "checkmark.square";
segoe_codepoint = 0xE8B3U; // SelectAll
xdg_icon_name = "edit-select-all";
break;
case Type::DeselectAll:
sf_symbol_name = "square";
segoe_codepoint = 0xE739U; // Checkbox
xdg_icon_name = "edit-select-none";
break;
case Type::Statistics:
sf_symbol_name = "chart.bar";
segoe_codepoint = 0xED5EU; // Ruler
xdg_icon_name = "info";
ok_in_gnome_menus = true;
break;
case Type::Donate:
sf_symbol_name = "heart";
segoe_codepoint = 0xEB51U; // Heart
xdg_icon_name = "donate";
break;
case Type::Settings:
sf_symbol_name = "gearshape";
segoe_codepoint = 0xE713U; // Settings
xdg_icon_name = "preferences-system";
ok_in_gnome_menus = true;
break;
case Type::QuitApp:
sf_symbol_name = "power";
segoe_codepoint = 0xE7E8U; // PowerButton
xdg_icon_name = "application-exit";
break;
case Type::About:
sf_symbol_name = "info.circle";
segoe_codepoint = 0xE946U; // Info
xdg_icon_name = "help-about";
fallback = QStyle::SP_MessageBoxInformation;
ok_in_gnome_menus = true;
break;
case Type::Help:
sf_symbol_name = "questionmark.circle";
segoe_codepoint = 0xE897U; // Help
xdg_icon_name = "help-faq";
fallback = QStyle::SP_DialogHelpButton;
ok_in_gnome_menus = true;
break;
case Type::QueueMoveTop:
sf_symbol_name = "arrow.up.to.line";
segoe_codepoint = 0xEDDBU; // CaretUpSolid8
xdg_icon_name = "go-top";
break;
case Type::QueueMoveUp:
sf_symbol_name = "arrow.up";
segoe_codepoint = 0xEDD7U; // CaretUp8
xdg_icon_name = "go-up";
break;
case Type::QueueMoveDown:
sf_symbol_name = "arrow.down";
segoe_codepoint = 0xEDD8U; // CaretDown8
xdg_icon_name = "go-down";
break;
case Type::QueueMoveBottom:
sf_symbol_name = "arrow.down.to.line";
segoe_codepoint = 0xEDDCU; // CaretDownSolid8
xdg_icon_name = "go-bottom";
break;
case Type::NetworkIdle:
xdg_icon_name = "network-idle";
break;
case Type::NetworkReceive:
sf_symbol_name = "arrow.down.circle";
segoe_codepoint = 0xE896U; // Download
xdg_icon_name = "network-receive";
break;
case Type::NetworkTransmit:
sf_symbol_name = "arrow.up.circle";
segoe_codepoint = 0xE898U; // Upload
xdg_icon_name = "network-transmit";
break;
case Type::NetworkTransmitReceive:
sf_symbol_name = "arrow.up.arrow.down.circle";
segoe_codepoint = 0xE174U; // UploadDownload
xdg_icon_name = "network-transmit-receive";
break;
case Type::NetworkError:
sf_symbol_name = "wifi.exclamationmark";
segoe_codepoint = 0xE783U; // Error
xdg_icon_name = "network-error";
fallback = QStyle::SP_MessageBoxCritical;
break;
case Type::TorrentStateActive:
sf_symbol_name = "play";
segoe_codepoint = 0xE768U; // Play
xdg_icon_name = "media-playback-start";
fallback = QStyle::SP_MediaPlay;
break;
case Type::TorrentStateSeeding:
sf_symbol_name = "chevron.up";
segoe_codepoint = 0xE70EU; // ChevronUp
xdg_icon_name = "go-up";
fallback = QStyle::SP_ArrowUp;
break;
case Type::TorrentStateDownloading:
sf_symbol_name = "chevron.down";
segoe_codepoint = 0xE70DU; // ChevronDown
xdg_icon_name = "go-down";
fallback = QStyle::SP_ArrowDown;
break;
case Type::PauseTorrent:
[[fallthrough]];
case Type::TorrentStatePaused:
sf_symbol_name = "pause";
segoe_codepoint = 0xE769U; // Pause
xdg_icon_name = "media-playback-pause";
fallback = QStyle::SP_MediaPause;
break;
case Type::VerifyTorrent:
[[fallthrough]];
case Type::TorrentStateVerifying:
sf_symbol_name = "arrow.clockwise";
segoe_codepoint = 0xE72CU; // Refresh
xdg_icon_name = "view-refresh";
fallback = QStyle::SP_BrowserReload;
break;
case Type::TorrentErrorEmblem:
[[fallthrough]];
case Type::TorrentStateError:
sf_symbol_name = "xmark.circle";
segoe_codepoint = 0xEB90U; // StatusErrorFull
xdg_icon_name = "dialog-error";
fallback = QStyle::SP_MessageBoxWarning;
break;
}
return { sf_symbol_name, segoe_codepoint, xdg_icon_name, fallback, ok_in_gnome_menus };
}
} // namespace
QIcon icon(Type const type, QStyle const* const style)
{
ensureFontsLoaded();
auto const point_sizes = small::max_size_set<int, 7U>{
style->pixelMetric(QStyle::PM_ButtonIconSize), style->pixelMetric(QStyle::PM_LargeIconSize),
style->pixelMetric(QStyle::PM_ListViewIconSize), style->pixelMetric(QStyle::PM_MessageBoxIconSize),
style->pixelMetric(QStyle::PM_SmallIconSize), style->pixelMetric(QStyle::PM_TabBarIconSize),
style->pixelMetric(QStyle::PM_ToolBarIconSize)
};
auto const info = getInfo(type);
#if defined(Q_OS_MAC)
if (auto const key = info.sf_symbol_name; !std::empty(key))
{
auto icon = QIcon{};
auto const name = QString::fromUtf8(std::data(key), std::size(key));
for (int const point_size : point_sizes)
if (auto const pixmap = loadSFSymbol(name, point_size); !pixmap.isNull())
icon.addPixmap(pixmap);
if (!icon.isNull())
return icon;
}
#endif
if (auto const key = info.segoe_codepoint)
{
if (auto const family = getWindowsFontFamily(); !family.isEmpty())
{
auto icon = QIcon{};
auto const ch = QChar{ key };
for (int const point_size : point_sizes)
if (auto pixmap = makeIconFromCodepoint(family, ch, point_size); !pixmap.isNull())
icon.addPixmap(pixmap);
if (!icon.isNull())
return icon;
}
}
if (auto const key = info.xdg_icon_name; !std::empty(key))
{
auto const name = QString::fromUtf8(std::data(key), std::size(key));
if (auto icon = QIcon::fromTheme(name); !icon.isNull())
return icon;
if (auto icon = QIcon::fromTheme(name + QStringLiteral("-symbolic")); !icon.isNull())
return icon;
}
if (info.fallback)
return style->standardIcon(*info.fallback);
return {};
}
[[nodiscard]] bool shouldBeShownInMenu(Type type)
{
static bool const force_icons = !qgetenv("TR_SHOW_MENU_ICONS").isEmpty();
static bool const is_gnome = qgetenv("XDG_CURRENT_DESKTOP").contains("GNOME");
return force_icons || !is_gnome || getInfo(type).ok_in_gnome_menus;
}
} // namespace icons