diff --git a/README.md b/README.md index 4a5f49a..c8baffa 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,7 @@ Please refer to the [example subdirectory](https://github.com/benthillerkus/betr # Development ## TBD before v1 -- FIXME Find out all possible errors and repackage / handle them - TODO Find out, communicate and memoize the correct system metrics (icon resolution) -- TODO Support interaction ## Style diff --git a/lib/src/imperative.dart b/lib/src/imperative.dart index 455e765..cf93bd5 100644 --- a/lib/src/imperative.dart +++ b/lib/src/imperative.dart @@ -63,6 +63,8 @@ class TrayIcon { if (kDebugMode) _plugin._noop(); } + final __callbacks = {}; + @override String toString() => "TrayIcon(${_id.hex}, active: $_isActive, visible: $_isVisible)"; diff --git a/lib/src/interaction.dart b/lib/src/interaction.dart new file mode 100644 index 0000000..db05893 --- /dev/null +++ b/lib/src/interaction.dart @@ -0,0 +1,154 @@ +part of 'plugin.dart'; + +/// A data class to store events fired by native code. +/// +/// Interactions / events are identifer by [id] + [event] + [hWnd]. +/// +/// It might be useful to expose more of these +class _TrayIconInteraction { + final Offset position; + final int hWnd; + final WinEvent event; + final Id id; + + _TrayIconInteraction(this.event, this.position, this.id, this.hWnd); + + @override + String toString() { + return "${event.name} at $position"; + } +} + +typedef _EventCallback = void Function(Offset position)?; + +/// Gives the [TrayIcon] the ability to react to [WinEvent]s. +/// +/// Relies on the [TrayIcon.__callbacks] map to store the callbacks. +extension InteractionHandling on TrayIcon { + /// {@template interaction_handling.set} + /// Register this callback to be called, whenever the associated [WinEvent] is fired. + /// + /// If `null` is passed, the callback will be removed. + /// This should mean better performance as the native code + /// won't have to call into dart for this even anymore. + /// + /// If you need access to more data than the position, please file an issue. + /// + /// It would be possible to also expose the [hWnd] and [WinEvent] as well (See [_TrayIconInteraction]). + /// {@endtemplate} + set onTap(_EventCallback onTap) => + _manageEventSubscription(WinEvent.select, onTap); + + /// {@template betrayal.interaction_handling.get} + /// The callback that is currently being run `onTap`. + /// + /// If `null`, this [WinEvent] can be skipped. + /// {@endtemplate} + _EventCallback get onTap => _callbacks[WinEvent.select]; + + /// {@macro betrayal.interaction_handling.set} + set onSecondaryTap(_EventCallback onSecondaryTap) => + _manageEventSubscription(WinEvent.contextMenu, onSecondaryTap); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onSecondaryTap => _callbacks[WinEvent.contextMenu]; + + /// {@macro betrayal.interaction_handling.set} + set onTapDown(_EventCallback onTapDown) => + _manageEventSubscription(WinEvent.leftButtonDown, onTapDown); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onTapDown => _callbacks[WinEvent.leftButtonDown]; + + /// {@macro betrayal.interaction_handling.set} + set onSecondaryTapDown(_EventCallback onSecondaryTapDown) => + _manageEventSubscription(WinEvent.rightButtonDown, onSecondaryTapDown); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onSecondaryTapDown => _callbacks[WinEvent.rightButtonDown]; + + /// {@macro betrayal.interaction_handling.set} + set onTertiaryTapDown(_EventCallback onTertiaryTapDown) => + _manageEventSubscription(WinEvent.middleButtonDown, onTertiaryTapDown); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onTertiaryTapDown => _callbacks[WinEvent.middleButtonDown]; + + /// {@macro betrayal.interaction_handling.set} + set onTapUp(_EventCallback onTapUp) => + _manageEventSubscription(WinEvent.leftButtonUp, onTapUp); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onTapUp => _callbacks[WinEvent.leftButtonUp]; + + /// {@macro betrayal.interaction_handling.set} + set onSecondaryTapUp(_EventCallback onSecondaryTapUp) => + _manageEventSubscription(WinEvent.rightButtonUp, onSecondaryTapUp); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onSecondaryTapUp => _callbacks[WinEvent.rightButtonUp]; + + /// {@macro betrayal.interaction_handling.set} + set onTertiaryTapUp(_EventCallback onTertiaryTapUp) => + _manageEventSubscription(WinEvent.middleButtonUp, onTertiaryTapUp); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onTertiaryTapUp => _callbacks[WinEvent.middleButtonUp]; + + /// {@macro betrayal.interaction_handling.set} + set onDoubleTap(_EventCallback onDoubleTap) => + _manageEventSubscription(WinEvent.select, onDoubleTap); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onDoubleTap => _callbacks[WinEvent.select]; + + /// {@macro betrayal.interaction_handling.set} + set onSecondaryDoubleTap(_EventCallback onSecondaryDoubleTap) => + _manageEventSubscription( + WinEvent.rightButtonDoubleClick, onSecondaryDoubleTap); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onSecondaryDoubleTap => + _callbacks[WinEvent.rightButtonDoubleClick]; + + /// {@macro betrayal.interaction_handling.set} + set onTertiaryDoubleTap(_EventCallback onTertiaryDoubleTap) => + _manageEventSubscription( + WinEvent.middleButtonDoubleClick, onSecondaryDoubleTap); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onTertiaryDoubleTap => + _callbacks[WinEvent.middleButtonDoubleClick]; + + /// {@macro betrayal.interaction_handling.set} + set onPointerMove(_EventCallback onPointerMove) => + _manageEventSubscription(WinEvent.mouseMove, onPointerMove); + + /// {@macro betrayal.interaction_handling.get} + _EventCallback get onPointerMove => _callbacks[WinEvent.mouseMove]; + + set _callbacks(Map _callbacks) { + _callbacks.forEach(_manageEventSubscription); + } + + Map get _callbacks => __callbacks; + + void _manageEventSubscription(WinEvent event, _EventCallback callback) { + final oldCallback = __callbacks[event]; + if (callback == oldCallback) return; + if (callback != null && oldCallback == null) { + _logger.fine("subscribing to ${event.name}"); + TrayIcon._plugin.subscribeTo(_id, event); + } else if (callback == null && oldCallback != null) { + _logger.fine("unsubscribing from ${event.name}"); + TrayIcon._plugin.unsubscribeFrom(_id, event); + } + __callbacks[event] = callback; + return; + } + + void _handleInteraction(_TrayIconInteraction interaction) { + _logger.finer("received interaction: $interaction"); + _callbacks[interaction.event]?.call(interaction.position); + } +} diff --git a/lib/src/plugin.dart b/lib/src/plugin.dart index dcca2ca..38fd800 100644 --- a/lib/src/plugin.dart +++ b/lib/src/plugin.dart @@ -17,6 +17,7 @@ import 'win_icon.dart'; import 'win_event.dart'; part 'imperative.dart'; +part 'interaction.dart'; part 'widgets.dart'; /// An identifier. @@ -88,9 +89,9 @@ class BetrayalPlugin { final icon = TrayIcon._allIcons[message - 0x0400]!; icon._logger.info("added to tray at $position"); } else { - final action = fromCode(event); final icon = TrayIcon._allIcons[id]!; - icon._logger.fine("${action.name} at $position}"); + icon._handleInteraction( + _TrayIconInteraction(fromCode(event), position, id, hWnd)); } } on Error { _logger.warning( @@ -100,6 +101,26 @@ class BetrayalPlugin { } } + /// Asks the plugin to call [_handleMethod] whenever [id] + [event] happens. + /// + /// Effectively, this means that events will per default *not* clog up the `MethodChannel`. + @protected + Future subscribeTo(Id id, WinEvent event) async { + await _channel.invokeMethod("subscribeTo", { + "id": id, + "event": event.code, + }); + } + + /// Notifies the plugin that events that the pattern [id] + [event] can be ignored. + @protected + Future unsubscribeFrom(Id id, WinEvent event) async { + await _channel.invokeMethod("unsubscribeFrom", { + "id": id, + "event": event.code, + }); + } + /// Removes and cleans up any icon managed by the plugin. @protected Future reset() async { diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart index 4544404..d5e6ac6 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -40,6 +40,8 @@ class TrayIconWidget extends StatefulWidget { late final TrayIconImageDelegate? _delegate; + late final Map _callbacks; + /// Manages a [TrayIcon] as a [StatefulWidget]. /// /// The underlying resource will automatically be disposed according to the @@ -57,6 +59,18 @@ class TrayIconWidget extends StatefulWidget { this.visible = true, this.addToContext = true, this.tooltip, + _EventCallback onTap, + _EventCallback onSecondaryTap, + _EventCallback onTapDown, + _EventCallback onSecondaryTapDown, + _EventCallback onTertiaryTapDown, + _EventCallback onTapUp, + _EventCallback onSecondaryTapUp, + _EventCallback onTertiaryTapUp, + _EventCallback onDoubleTap, + _EventCallback onSecondaryDoubleTap, + _EventCallback onTertiaryDoubleTap, + _EventCallback onPointerMove, TrayIconImageDelegate? imageDelegate, ByteBuffer? imagePixels, String? imageAsset, @@ -79,6 +93,21 @@ class TrayIconWidget extends StatefulWidget { } else { _delegate = null; } + + _callbacks = { + WinEvent.select: onTap, + WinEvent.leftButtonDoubleClick: onDoubleTap, + WinEvent.leftButtonDown: onTapDown, + WinEvent.leftButtonUp: onTapUp, + WinEvent.contextMenu: onSecondaryTap, + WinEvent.rightButtonDown: onSecondaryTapDown, + WinEvent.rightButtonUp: onSecondaryTapUp, + WinEvent.rightButtonDoubleClick: onSecondaryDoubleTap, + WinEvent.middleButtonDown: onTertiaryTapDown, + WinEvent.middleButtonUp: onTertiaryTapUp, + WinEvent.middleButtonDoubleClick: onTertiaryDoubleTap, + WinEvent.mouseMove: onPointerMove, + }; } @override @@ -124,6 +153,7 @@ class _TrayIconWidgetState extends State { void didUpdateWidget(covariant TrayIconWidget oldWidget) { super.didUpdateWidget(oldWidget); _logger.fine("updating icon…"); + _icon._callbacks = widget._callbacks; if (widget.visible != null && !widget.visible!) { _icon.hide(); return; @@ -152,5 +182,6 @@ class _TrayIconWidgetState extends State { if (widget._delegate != null) _icon.setImage(delegate: widget._delegate); if (widget.tooltip != null) _icon.setTooltip(widget.tooltip!); if (widget.visible ?? false) _icon.show(); + _icon._callbacks = widget._callbacks; } } diff --git a/windows/betrayal_plugin.cpp b/windows/betrayal_plugin.cpp index 03ee7dd..881d76d 100644 --- a/windows/betrayal_plugin.cpp +++ b/windows/betrayal_plugin.cpp @@ -59,9 +59,21 @@ namespace Betrayal auto x = GET_X_LPARAM(wParam); auto y = GET_Y_LPARAM(wParam); - // if (event == WM_CONTEXTMENU) DefWindowProc(hWnd, message, wParam, lParam); + try { + auto icon = icons.get(hWnd, id); + if (icon != nullptr && icon->is_subscribed(event)) + { + invokeInteraction(hWnd, message, event, id, x, y); + return result; + } + } + catch (const std::out_of_range&) { - invokeInteraction(hWnd, message, event, id, x, y); + } + if (event == WM_CONTEXTMENU) + { + DefWindowProc(hWnd, message, wParam, lParam); + } } return result; @@ -89,9 +101,10 @@ namespace Betrayal channel->InvokeMethod("print", std::make_unique(message)); } -#define WITH_ARGS const flutter::EncodableMap &args = std::get(*method_call.arguments()) +#define WITH_ARGS const flutter::EncodableMap &args = std::get(*method_call.arguments()); #define WITH_HWND HWND hWnd = GetMainWindow(); #define WITH_ID const int id = std::get(args.at(flutter::EncodableValue("id"))); +#define WITH_ARGS_HWND_ID WITH_ARGS WITH_HWND WITH_ID void HandleMethodCall( const flutter::MethodCall &method_call, @@ -102,80 +115,81 @@ namespace Betrayal { icons.clear_all(); } + else if (method.compare("subscribeTo") == 0) + { + WITH_ARGS_HWND_ID + UINT event = std::get(args.at(flutter::EncodableValue("event"))); + SubscribeTo(hWnd, id, event, std::move(result)); + } + else if (method.compare("unsubscribeFrom") == 0) + { + WITH_ARGS_HWND_ID + UINT event = std::get(args.at(flutter::EncodableValue("event"))); + UnsubscribeFrom(hWnd, id, event, std::move(result)); + } else if (method.compare("addTray") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + AddTray(hWnd, id, std::move(result)); } else if (method.compare("disposeTray") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + DisposeTray(hWnd, id, std::move(result)); } else if (method.compare("hideIcon") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + HideIcon(hWnd, id, std::move(result)); } else if (method.compare("showIcon") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + ShowIcon(hWnd, id, std::move(result)); } else if (method.compare("setTooltip") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + auto tooltip = std::get(args.at(flutter::EncodableValue("tooltip"))); SetTooltip(hWnd, id, tooltip, std::move(result)); } else if (method.compare("removeTooltip") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID RemoveTooltip(hWnd, id, std::move(result)); } else if (method.compare("setImageFromPath") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + auto path = std::get(args.at(flutter::EncodableValue("path"))); auto is_shared = std::get(args.at(flutter::EncodableValue("isShared"))); SetImageFromPath(hWnd, id, path, is_shared, std::move(result)); } else if (method.compare("setImageAsWinIcon") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + auto resource_id = std::get(args.at(flutter::EncodableValue("resourceId"))); SetImageAsWinIcon(hWnd, id, resource_id, std::move(result)); } else if (method.compare("setImageAsStockIcon") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + auto stockIconId = std::get(args.at(flutter::EncodableValue("stockIconId"))); SetImageAsStockIcon(hWnd, id, stockIconId, std::move(result)); } else if (method.compare("setImageFromPixels") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID + auto width = std::get(args.at(flutter::EncodableValue("width"))); auto height = std::get(args.at(flutter::EncodableValue("height"))); auto pixels = std::get>(args.at(flutter::EncodableValue("pixels"))); @@ -184,9 +198,7 @@ namespace Betrayal } else if (method.compare("removeImage") == 0) { - WITH_ARGS; - WITH_HWND; - WITH_ID; + WITH_ARGS_HWND_ID RemoveImage(hWnd, id, std::move(result)); } @@ -195,10 +207,18 @@ namespace Betrayal result->NotImplemented(); } }; - -#undef WITH_ARGS -#undef WITH_HWND +#undef WITH_ARGS_HWND_ID #undef WITH_ID +#undef WITH_HWND +#undef WITH_ARGS + +#define WITH_ICON \ + auto icon = icons.get(hWnd, id); \ + if (icon == nullptr) \ + { \ + result->Error("Icon found"); \ + return; \ + } void AddTray( const HWND hWnd, const UINT id, @@ -251,17 +271,36 @@ namespace Betrayal } }; + void SubscribeTo( + const HWND hWnd, const UINT id, const UINT event, + std::unique_ptr> + result) + { + WITH_ICON; + + icon->subscribe(event); + + result->Success(flutter::EncodableValue(static_cast(id))); + }; + + void UnsubscribeFrom( + const HWND hWnd, const UINT id, const UINT event, + std::unique_ptr> + result) + { + WITH_ICON; + + icon->unsubscribe(event); + + result->Success(flutter::EncodableValue(static_cast(id))); + }; + void SetTooltip( const HWND hWnd, const UINT id, const std::string &tooltip, std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; icon->set_tooltip(tooltip); icon->update(); @@ -271,12 +310,7 @@ namespace Betrayal void RemoveTooltip( const HWND hWnd, const UINT id, std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; icon->remove_tooltip(); icon->update(); @@ -288,12 +322,7 @@ namespace Betrayal std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; auto path = std::wstring(filepath.begin(), filepath.end()); @@ -314,12 +343,7 @@ namespace Betrayal std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; icon->set_image(LoadIcon(nullptr, MAKEINTRESOURCE(resource_id)), true); icon->update(); @@ -331,12 +355,7 @@ namespace Betrayal std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; SHSTOCKICONINFO info = {0}; info.cbSize = sizeof(SHSTOCKICONINFO); @@ -361,12 +380,7 @@ namespace Betrayal std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; icon->remove_image(); icon->update(); @@ -445,12 +459,7 @@ namespace Betrayal std::unique_ptr> result) { - auto icon = icons.get(hWnd, id); - if (icon == nullptr) - { - result->Error("Icon not found"); - return; - } + WITH_ICON; auto hIcon = CreateIconFromBytes( hWnd, width, height, pixels, icon); @@ -460,6 +469,8 @@ namespace Betrayal result->Success(flutter::EncodableValue(true)); } + +#undef WITH_ICON }; // static diff --git a/windows/tray_icon.hpp b/windows/tray_icon.hpp index 1d455f7..b7bd3ab 100644 --- a/windows/tray_icon.hpp +++ b/windows/tray_icon.hpp @@ -8,12 +8,14 @@ #include #include #include +#include class TrayIcon { private: bool m_is_visible; bool m_icon_is_shared; + std::vector m_subbed_events; public: NOTIFYICONDATA data; @@ -37,6 +39,28 @@ class TrayIcon DestroyIcon(data.hIcon); }; + bool is_subscribed(UINT event) + { + return std::find(m_subbed_events.begin(), m_subbed_events.end(), event) != m_subbed_events.end(); + } + + void subscribe(UINT event) + { + m_subbed_events.push_back(event); + }; + + void unsubscribe(UINT event) + { + for (auto it = m_subbed_events.begin(); it != m_subbed_events.end(); ++it) + { + if (*it == event) + { + m_subbed_events.erase(it); + break; + } + } + }; + void set_tooltip(const std::string &tooltip) { // https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-multibytetowidechar