427 lines
9.8 KiB
Plaintext
427 lines
9.8 KiB
Plaintext
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "platform/mac/tray_mac.h"
|
|
|
|
#include "base/platform/mac/base_utilities_mac.h"
|
|
#include "core/application.h"
|
|
#include "core/sandbox.h"
|
|
#include "window/window_controller.h"
|
|
#include "window/window_session_controller.h"
|
|
#include "styles/style_window.h"
|
|
|
|
#include <QtWidgets/QMenu>
|
|
|
|
#import <AppKit/NSMenu.h>
|
|
#import <AppKit/NSStatusItem.h>
|
|
|
|
@interface CommonDelegate : NSObject<NSMenuDelegate> {
|
|
}
|
|
|
|
- (void) menuDidClose:(NSMenu *)menu;
|
|
- (void) menuWillOpen:(NSMenu *)menu;
|
|
- (void) observeValueForKeyPath:(NSString *)keyPath
|
|
ofObject:(id)object
|
|
change:(NSDictionary<NSKeyValueChangeKey, id> *)change
|
|
context:(void *)context;
|
|
|
|
- (rpl::producer<>) closes;
|
|
- (rpl::producer<>) aboutToShowRequests;
|
|
- (rpl::producer<>) appearanceChanges;
|
|
|
|
@end // @interface CommonDelegate
|
|
|
|
@implementation CommonDelegate {
|
|
rpl::event_stream<> _closes;
|
|
rpl::event_stream<> _aboutToShowRequests;
|
|
rpl::event_stream<> _appearanceChanges;
|
|
}
|
|
|
|
- (void) menuDidClose:(NSMenu *)menu {
|
|
Core::Sandbox::Instance().customEnterFromEventLoop([&] {
|
|
_closes.fire({});
|
|
});
|
|
}
|
|
|
|
- (void) menuWillOpen:(NSMenu *)menu {
|
|
Core::Sandbox::Instance().customEnterFromEventLoop([&] {
|
|
_aboutToShowRequests.fire({});
|
|
});
|
|
}
|
|
|
|
// Thanks https://stackoverflow.com/a/64525038
|
|
- (void) observeValueForKeyPath:(NSString *)keyPath
|
|
ofObject:(id)object
|
|
change:(NSDictionary<NSKeyValueChangeKey, id> *)change
|
|
context:(void *)context {
|
|
if ([keyPath isEqualToString:@"button.effectiveAppearance"]) {
|
|
_appearanceChanges.fire({});
|
|
}
|
|
}
|
|
|
|
- (rpl::producer<>) closes {
|
|
return _closes.events();
|
|
}
|
|
|
|
- (rpl::producer<>) aboutToShowRequests {
|
|
return _aboutToShowRequests.events();
|
|
}
|
|
|
|
- (rpl::producer<>) appearanceChanges {
|
|
return _appearanceChanges.events();
|
|
}
|
|
|
|
@end // @implementation MenuDelegate
|
|
|
|
namespace Platform {
|
|
|
|
namespace {
|
|
|
|
[[nodiscard]] bool IsAnyActiveForTrayMenu() {
|
|
for (const NSWindow *w in [[NSApplication sharedApplication] windows]) {
|
|
if (w.isKeyWindow) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
[[nodiscard]] QImage TrayIconBack(bool darkMode) {
|
|
static const auto WithColor = [](QColor color) {
|
|
return st::macTrayIcon.instance(color, 100);
|
|
};
|
|
static const auto DarkModeResult = WithColor({ 255, 255, 255 });
|
|
static const auto LightModeResult = WithColor({ 0, 0, 0, 180 });
|
|
auto result = darkMode ? DarkModeResult : LightModeResult;
|
|
result.detach();
|
|
return result;
|
|
}
|
|
|
|
void PlaceCounter(
|
|
QImage &img,
|
|
int size,
|
|
int count,
|
|
style::color bg,
|
|
style::color color) {
|
|
if (!count) {
|
|
return;
|
|
}
|
|
const auto savedRatio = img.devicePixelRatio();
|
|
img.setDevicePixelRatio(1.);
|
|
|
|
{
|
|
Painter p(&img);
|
|
PainterHighQualityEnabler hq(p);
|
|
|
|
const auto cnt = (count < 100)
|
|
? QString("%1").arg(count)
|
|
: QString("..%1").arg(count % 100, 2, 10, QChar('0'));
|
|
const auto cntSize = cnt.size();
|
|
|
|
p.setBrush(bg);
|
|
p.setPen(Qt::NoPen);
|
|
int32 fontSize, skip;
|
|
if (size == 22) {
|
|
skip = 1;
|
|
fontSize = 8;
|
|
} else {
|
|
skip = 2;
|
|
fontSize = 16;
|
|
}
|
|
style::font f(fontSize, 0, 0);
|
|
int32 w = f->width(cnt), d, r;
|
|
if (size == 22) {
|
|
d = (cntSize < 2) ? 3 : 2;
|
|
r = (cntSize < 2) ? 6 : 5;
|
|
} else {
|
|
d = (cntSize < 2) ? 6 : 5;
|
|
r = (cntSize < 2) ? 9 : 11;
|
|
}
|
|
p.drawRoundedRect(
|
|
QRect(
|
|
size - w - d * 2 - skip,
|
|
size - f->height - skip,
|
|
w + d * 2,
|
|
f->height),
|
|
r,
|
|
r);
|
|
|
|
p.setCompositionMode(QPainter::CompositionMode_Source);
|
|
p.setFont(f);
|
|
p.setPen(color);
|
|
p.drawText(
|
|
size - w - d - skip,
|
|
size - f->height + f->ascent - skip,
|
|
cnt);
|
|
}
|
|
img.setDevicePixelRatio(savedRatio);
|
|
}
|
|
|
|
void UpdateIcon(const NSStatusItem *status) {
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
const auto appearance = status.button.effectiveAppearance;
|
|
const auto darkMode = [[appearance.name lowercaseString]
|
|
containsString:@"dark"];
|
|
|
|
// The recommended maximum title bar icon height is 18 points
|
|
// (device independent pixels). The menu height on past and
|
|
// current OS X versions is 22 points. Provide some future-proofing
|
|
// by deriving the icon height from the menu height.
|
|
const int padding = 0;
|
|
const int menuHeight = NSStatusBar.systemStatusBar.thickness;
|
|
// [[status.button window] backingScaleFactor];
|
|
const int maxImageHeight = (menuHeight - padding)
|
|
* style::DevicePixelRatio();
|
|
|
|
// Select pixmap based on the device pixel height. Ideally we would use
|
|
// the devicePixelRatio of the target screen, but that value is not
|
|
// known until draw time. Use qApp->devicePixelRatio, which returns the
|
|
// devicePixelRatio for the "best" screen on the system.
|
|
|
|
const auto side = 22 * style::DevicePixelRatio();
|
|
const auto selectedSize = QSize(side, side);
|
|
|
|
auto result = TrayIconBack(darkMode);
|
|
auto resultActive = result;
|
|
resultActive.detach();
|
|
|
|
const auto counter = Core::App().unreadBadge();
|
|
const auto muted = Core::App().unreadBadgeMuted();
|
|
|
|
const auto &bg = (muted ? st::trayCounterBgMute : st::trayCounterBg);
|
|
const auto &fg = st::trayCounterFg;
|
|
const auto &fgInvert = st::trayCounterFgMacInvert;
|
|
const auto &bgInvert = st::trayCounterBgMacInvert;
|
|
|
|
const auto &resultFg = !darkMode ? fg : muted ? fgInvert : fg;
|
|
PlaceCounter(result, side, counter, bg, resultFg);
|
|
PlaceCounter(resultActive, side, counter, bgInvert, fgInvert);
|
|
|
|
// Scale large pixmaps to fit the available menu bar area.
|
|
if (result.height() > maxImageHeight) {
|
|
result = result.scaledToHeight(
|
|
maxImageHeight,
|
|
Qt::SmoothTransformation);
|
|
}
|
|
if (resultActive.height() > maxImageHeight) {
|
|
resultActive = resultActive.scaledToHeight(
|
|
maxImageHeight,
|
|
Qt::SmoothTransformation);
|
|
}
|
|
|
|
status.button.image = Q2NSImage(result);
|
|
status.button.alternateImage = Q2NSImage(resultActive);
|
|
status.button.imageScaling = NSImageScaleProportionallyDown;
|
|
}
|
|
|
|
[[nodiscard]] QWidget *Parent() {
|
|
Expects(Core::App().primaryWindow() != nullptr);
|
|
return Core::App().primaryWindow()->widget();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class NativeIcon final {
|
|
public:
|
|
NativeIcon();
|
|
~NativeIcon();
|
|
|
|
void updateIcon();
|
|
void showMenu(not_null<QMenu*> menu);
|
|
void deactivateButton();
|
|
|
|
[[nodiscard]] rpl::producer<> clicks() const;
|
|
[[nodiscard]] rpl::producer<> aboutToShowRequests() const;
|
|
|
|
private:
|
|
CommonDelegate *_delegate;
|
|
NSStatusItem *_status;
|
|
|
|
rpl::event_stream<> _clicks;
|
|
|
|
rpl::lifetime _lifetime;
|
|
|
|
};
|
|
|
|
NativeIcon::NativeIcon()
|
|
: _delegate([[CommonDelegate alloc] init])
|
|
, _status([
|
|
[NSStatusBar.systemStatusBar
|
|
statusItemWithLength:NSSquareStatusItemLength] retain]) {
|
|
|
|
[_status
|
|
addObserver:_delegate
|
|
forKeyPath:@"button.effectiveAppearance"
|
|
options:0
|
|
| NSKeyValueObservingOptionNew
|
|
| NSKeyValueObservingOptionInitial
|
|
context:nil];
|
|
|
|
[_delegate closes] | rpl::start_with_next([=] {
|
|
_status.menu = nil;
|
|
}, _lifetime);
|
|
|
|
[_delegate appearanceChanges] | rpl::start_with_next([=] {
|
|
updateIcon();
|
|
}, _lifetime);
|
|
|
|
const auto masks = NSEventMaskLeftMouseDown
|
|
| NSEventMaskLeftMouseUp
|
|
| NSEventMaskRightMouseDown
|
|
| NSEventMaskRightMouseUp
|
|
| NSEventMaskOtherMouseUp;
|
|
[_status.button sendActionOn:masks];
|
|
|
|
id buttonCallback = [^{
|
|
const auto type = NSApp.currentEvent.type;
|
|
|
|
if ((type == NSEventTypeLeftMouseDown)
|
|
|| (type == NSEventTypeRightMouseDown)) {
|
|
Core::Sandbox::Instance().customEnterFromEventLoop([=] {
|
|
_clicks.fire({});
|
|
});
|
|
}
|
|
} copy];
|
|
|
|
_lifetime.add([=] {
|
|
[buttonCallback release];
|
|
});
|
|
|
|
_status.button.target = buttonCallback;
|
|
_status.button.action = @selector(invoke);
|
|
_status.button.toolTip = Q2NSString(AppName.utf16());
|
|
}
|
|
|
|
NativeIcon::~NativeIcon() {
|
|
[_status
|
|
removeObserver:_delegate
|
|
forKeyPath:@"button.effectiveAppearance"];
|
|
[NSStatusBar.systemStatusBar removeStatusItem:_status];
|
|
|
|
[_status release];
|
|
[_delegate release];
|
|
}
|
|
|
|
void NativeIcon::updateIcon() {
|
|
UpdateIcon(_status);
|
|
}
|
|
|
|
void NativeIcon::showMenu(not_null<QMenu*> menu) {
|
|
_status.menu = menu->toNSMenu();
|
|
_status.menu.delegate = _delegate;
|
|
[_status.button performClick:nil];
|
|
}
|
|
|
|
void NativeIcon::deactivateButton() {
|
|
[_status.button highlight:false];
|
|
}
|
|
|
|
rpl::producer<> NativeIcon::clicks() const {
|
|
return _clicks.events();
|
|
}
|
|
|
|
rpl::producer<> NativeIcon::aboutToShowRequests() const {
|
|
return [_delegate aboutToShowRequests];
|
|
}
|
|
|
|
Tray::Tray() {
|
|
}
|
|
|
|
void Tray::createIcon() {
|
|
if (!_nativeIcon) {
|
|
_nativeIcon = std::make_unique<NativeIcon>();
|
|
// On macOS we are activating the window on click
|
|
// instead of showing the menu, when the window is not activated.
|
|
_nativeIcon->clicks(
|
|
) | rpl::start_with_next([=] {
|
|
if (IsAnyActiveForTrayMenu()) {
|
|
_nativeIcon->showMenu(_menu.get());
|
|
} else {
|
|
_nativeIcon->deactivateButton();
|
|
_showFromTrayRequests.fire({});
|
|
}
|
|
}, _lifetime);
|
|
}
|
|
updateIcon();
|
|
}
|
|
|
|
void Tray::destroyIcon() {
|
|
_nativeIcon = nullptr;
|
|
}
|
|
|
|
void Tray::updateIcon() {
|
|
if (_nativeIcon) {
|
|
_nativeIcon->updateIcon();
|
|
}
|
|
}
|
|
|
|
void Tray::createMenu() {
|
|
if (!_menu) {
|
|
_menu = base::make_unique_q<QMenu>(Parent());
|
|
}
|
|
}
|
|
|
|
void Tray::destroyMenu() {
|
|
if (_menu) {
|
|
_menu->clear();
|
|
}
|
|
_actionsLifetime.destroy();
|
|
}
|
|
|
|
void Tray::addAction(rpl::producer<QString> text, Fn<void()> &&callback) {
|
|
if (!_menu) {
|
|
return;
|
|
}
|
|
|
|
const auto action = _menu->addAction(QString(), std::move(callback));
|
|
std::move(
|
|
text
|
|
) | rpl::start_with_next([=](const QString &text) {
|
|
action->setText(text);
|
|
}, _actionsLifetime);
|
|
}
|
|
|
|
void Tray::showTrayMessage() const {
|
|
}
|
|
|
|
bool Tray::hasTrayMessageSupport() const {
|
|
return false;
|
|
}
|
|
|
|
rpl::producer<> Tray::aboutToShowRequests() const {
|
|
return _nativeIcon
|
|
? _nativeIcon->aboutToShowRequests()
|
|
: rpl::never<>();
|
|
}
|
|
|
|
rpl::producer<> Tray::showFromTrayRequests() const {
|
|
return _showFromTrayRequests.events();
|
|
}
|
|
|
|
rpl::producer<> Tray::hideToTrayRequests() const {
|
|
return rpl::never<>();
|
|
}
|
|
|
|
rpl::producer<> Tray::iconClicks() const {
|
|
return rpl::never<>();
|
|
}
|
|
|
|
bool Tray::hasIcon() const {
|
|
return _nativeIcon != nullptr;
|
|
}
|
|
|
|
rpl::lifetime &Tray::lifetime() {
|
|
return _lifetime;
|
|
}
|
|
|
|
Tray::~Tray() = default;
|
|
|
|
} // namespace Platform
|