diff --git a/lib/rumps/__init__.py b/lib/rumps/__init__.py index 966fa2d1..5c232f5f 100644 --- a/lib/rumps/__init__.py +++ b/lib/rumps/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. -# Copyright: (c) 2017, Jared Suttles. All rights reserved. +# Copyright: (c) 2020, Jared Suttles. All rights reserved. # License: BSD, see LICENSE for details. # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -17,10 +17,14 @@ statusbar application. """ __title__ = 'rumps' -__version__ = '0.3.0' +__version__ = '0.4.0' __author__ = 'Jared Suttles' __license__ = 'Modified BSD' -__copyright__ = 'Copyright 2017 Jared Suttles' +__copyright__ = 'Copyright 2020 Jared Suttles' -from .rumps import (separator, debug_mode, alert, notification, application_support, timers, quit_application, timer, - clicked, notifications, MenuItem, SliderMenuItem, Timer, Window, App, slider) +from . import notifications as _notifications +from .rumps import (separator, debug_mode, alert, application_support, timers, quit_application, timer, + clicked, MenuItem, SliderMenuItem, Timer, Window, App, slider) + +notifications = _notifications.on_notification +notification = _notifications.notify diff --git a/lib/rumps/_internal.py b/lib/rumps/_internal.py new file mode 100644 index 00000000..a4d5320f --- /dev/null +++ b/lib/rumps/_internal.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import inspect +import traceback + +import Foundation + +from . import compat +from . import exceptions + + +def require_string(*objs): + for obj in objs: + if not isinstance(obj, compat.string_types): + raise TypeError( + 'a string is required but given {0}, a {1}'.format(obj, type(obj).__name__) + ) + + +def require_string_or_none(*objs): + for obj in objs: + if not(obj is None or isinstance(obj, compat.string_types)): + raise TypeError( + 'a string or None is required but given {0}, a {1}'.format(obj, type(obj).__name__) + ) + + +def call_as_function_or_method(func, *args, **kwargs): + # The idea here is that when using decorators in a class, the functions passed are not bound so we have to + # determine later if the functions we have (those saved as callbacks) for particular events need to be passed + # 'self'. + # + # This works for an App subclass method or a standalone decorated function. Will attempt to find function as + # a bound method of the App instance. If it is found, use it, otherwise simply call function. + from . import rumps + try: + app = getattr(rumps.App, '*app_instance') + except AttributeError: + pass + else: + for name, method in inspect.getmembers(app, predicate=inspect.ismethod): + if method.__func__ is func: + return method(*args, **kwargs) + return func(*args, **kwargs) + + +def guard_unexpected_errors(func): + """Decorator to be used in PyObjC callbacks where an error bubbling up + would cause a crash. Instead of crashing, print the error to stderr and + prevent passing to PyObjC layer. + + For Python 3, print the exception using chaining. Accomplished by setting + the cause of :exc:`rumps.exceptions.InternalRumpsError` to the exception. + + For Python 2, emulate exception chaining by printing the original exception + followed by :exc:`rumps.exceptions.InternalRumpsError`. + """ + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + + except Exception as e: + internal_error = exceptions.InternalRumpsError( + 'an unexpected error occurred within an internal callback' + ) + if compat.PY2: + import sys + traceback.print_exc() + print('\nThe above exception was the direct cause of the following exception:\n', file=sys.stderr) + traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) + else: + internal_error.__cause__ = e + traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) + + return wrapper + + +def string_to_objc(x): + if isinstance(x, compat.binary_type): + return Foundation.NSData.alloc().initWithData_(x) + elif isinstance(x, compat.string_types): + return Foundation.NSString.alloc().initWithString_(x) + else: + raise TypeError( + "expected a string or a bytes-like object but provided %s, " + "having type '%s'" % ( + x, + type(x).__name__ + ) + ) diff --git a/lib/rumps/compat.py b/lib/rumps/compat.py index a0fb4156..e17d45a3 100644 --- a/lib/rumps/compat.py +++ b/lib/rumps/compat.py @@ -1,17 +1,33 @@ # -*- coding: utf-8 -*- +""" +rumps.compat +~~~~~~~~~~~~ + +Compatibility for Python 2 and Python 3 major versions. + +:copyright: (c) 2020 by Jared Suttles +:license: BSD-3-Clause, see LICENSE for details. +""" + import sys PY2 = sys.version_info[0] == 2 if not PY2: + binary_type = bytes text_type = str string_types = (str,) iteritems = lambda d: iter(d.items()) + import collections.abc as collections_abc + else: + binary_type = () text_type = unicode string_types = (str, unicode) iteritems = lambda d: d.iteritems() + + import collections as collections_abc diff --git a/lib/rumps/events.py b/lib/rumps/events.py new file mode 100644 index 00000000..64f4439c --- /dev/null +++ b/lib/rumps/events.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +import traceback + +from . import _internal + + +class EventEmitter(object): + def __init__(self, name): + self.name = name + self.callbacks = set() + self._executor = _internal.call_as_function_or_method + + def register(self, func): + self.callbacks.add(func) + return func + + def unregister(self, func): + try: + self.callbacks.remove(func) + return True + except KeyError: + return False + + def emit(self, *args, **kwargs): + #print('EventEmitter("%s").emit called' % self.name) + for callback in self.callbacks: + try: + self._executor(callback, *args, **kwargs) + except Exception: + traceback.print_exc() + + __call__ = register + + +before_start = EventEmitter('before_start') +on_notification = EventEmitter('on_notification') +on_sleep = EventEmitter('on_sleep') +on_wake = EventEmitter('on_wake') +before_quit = EventEmitter('before_quit') diff --git a/lib/rumps/exceptions.py b/lib/rumps/exceptions.py new file mode 100644 index 00000000..05c6d46d --- /dev/null +++ b/lib/rumps/exceptions.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + + +class RumpsError(Exception): + """A generic rumps error occurred.""" + + +class InternalRumpsError(RumpsError): + """Internal mechanism powering functionality of rumps failed.""" diff --git a/lib/rumps/notifications.py b/lib/rumps/notifications.py index 760e5f2a..b111e50e 100644 --- a/lib/rumps/notifications.py +++ b/lib/rumps/notifications.py @@ -1,10 +1,266 @@ -INFO_PLIST_TEMPLATE = '''\ - - - - - CFBundleIdentifier - %(name)s - - -''' +# -*- coding: utf-8 -*- + +_ENABLED = True +try: + from Foundation import NSUserNotification, NSUserNotificationCenter +except ImportError: + _ENABLED = False + +import datetime +import os +import sys +import traceback + +import Foundation + +from . import _internal +from . import compat +from . import events + + +def on_notification(f): + """Decorator for registering a function to serve as a "notification center" + for the application. This function will receive the data associated with an + incoming macOS notification sent using :func:`rumps.notification`. This + occurs whenever the user clicks on a notification for this application in + the macOS Notification Center. + + .. code-block:: python + + @rumps.notifications + def notification_center(info): + if 'unix' in info: + print 'i know this' + + """ + return events.on_notification.register(f) + + +def _gather_info_issue_9(): # pragma: no cover + missing_plist = False + missing_bundle_ident = False + info_plist_path = os.path.join(os.path.dirname(sys.executable), 'Info.plist') + try: + with open(info_plist_path) as f: + import plistlib + try: + load_plist = plistlib.load + except AttributeError: + load_plist = plistlib.readPlist + try: + load_plist(f)['CFBundleIdentifier'] + except Exception: + missing_bundle_ident = True + + except IOError as e: + import errno + if e.errno == errno.ENOENT: # No such file or directory + missing_plist = True + + info = '\n\n' + if missing_plist: + info += 'In this case there is no file at "%(info_plist_path)s"' + info += '\n\n' + confidence = 'should' + elif missing_bundle_ident: + info += 'In this case the file at "%(info_plist_path)s" does not contain a value for "CFBundleIdentifier"' + info += '\n\n' + confidence = 'should' + else: + confidence = 'may' + info += 'Running the following command %(confidence)s fix the issue:\n' + info += '/usr/libexec/PlistBuddy -c \'Add :CFBundleIdentifier string "rumps"\' %(info_plist_path)s\n' + return info % {'info_plist_path': info_plist_path, 'confidence': confidence} + + +def _default_user_notification_center(): + notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() + if notification_center is None: # pragma: no cover + info = ( + 'Failed to setup the notification center. This issue occurs when the "Info.plist" file ' + 'cannot be found or is missing "CFBundleIdentifier".' + ) + try: + info += _gather_info_issue_9() + except Exception: + pass + raise RuntimeError(info) + else: + return notification_center + + +def _init_nsapp(nsapp): + if _ENABLED: + try: + notification_center = _default_user_notification_center() + except RuntimeError: + pass + else: + notification_center.setDelegate_(nsapp) + + +@_internal.guard_unexpected_errors +def _clicked(ns_user_notification_center, ns_user_notification): + from . import rumps + + ns_user_notification_center.removeDeliveredNotification_(ns_user_notification) + ns_dict = ns_user_notification.userInfo() + if ns_dict is None: + data = None + else: + dumped = ns_dict['value'] + app = getattr(rumps.App, '*app_instance', rumps.App) + try: + data = app.serializer.loads(dumped) + except Exception: + traceback.print_exc() + return + + # notification center function not specified => no error but log warning + if not events.on_notification.callbacks: + rumps._log( + 'WARNING: notification received but no function specified for ' + 'answering it; use @notifications decorator to register a function.' + ) + else: + notification = Notification(ns_user_notification, data) + events.on_notification.emit(notification) + + +def notify(title, subtitle, message, data=None, sound=True, + action_button=None, other_button=None, has_reply_button=False, + icon=None, ignoreDnD=False): + """Send a notification to Notification Center (OS X 10.8+). If running on a + version of macOS that does not support notifications, a ``RuntimeError`` + will be raised. Apple says, + + "The userInfo content must be of reasonable serialized size (less than + 1k) or an exception will be thrown." + + So don't do that! + + :param title: text in a larger font. + :param subtitle: text in a smaller font below the `title`. + :param message: text representing the body of the notification below the + `subtitle`. + :param data: will be passed to the application's "notification center" (see + :func:`rumps.notifications`) when this notification is clicked. + :param sound: whether the notification should make a noise when it arrives. + :param action_button: title for the action button. + :param other_button: title for the other button. + :param has_reply_button: whether or not the notification has a reply button. + :param icon: the filename of an image for the notification's icon, will + replace the default. + :param ignoreDnD: whether the notification should ignore do not disturb, + e.g., appear also while screen sharing. + """ + from . import rumps + + if not _ENABLED: + raise RuntimeError('OS X 10.8+ is required to send notifications') + + _internal.require_string_or_none(title, subtitle, message) + + notification = NSUserNotification.alloc().init() + + notification.setTitle_(title) + notification.setSubtitle_(subtitle) + notification.setInformativeText_(message) + + if data is not None: + app = getattr(rumps.App, '*app_instance', rumps.App) + dumped = app.serializer.dumps(data) + objc_string = _internal.string_to_objc(dumped) + ns_dict = Foundation.NSMutableDictionary.alloc().init() + ns_dict.setDictionary_({'value': objc_string}) + notification.setUserInfo_(ns_dict) + + if icon is not None: + notification.set_identityImage_(rumps._nsimage_from_file(icon)) + if sound: + notification.setSoundName_("NSUserNotificationDefaultSoundName") + if action_button: + notification.setActionButtonTitle_(action_button) + notification.set_showsButtons_(True) + if other_button: + notification.setOtherButtonTitle_(other_button) + notification.set_showsButtons_(True) + if has_reply_button: + notification.setHasReplyButton_(True) + if ignoreDnD: + notification.set_ignoresDoNotDisturb_(True) + + notification.setDeliveryDate_(Foundation.NSDate.dateWithTimeInterval_sinceDate_(0, Foundation.NSDate.date())) + notification_center = _default_user_notification_center() + notification_center.scheduleNotification_(notification) + + +class Notification(compat.collections_abc.Mapping): + def __init__(self, ns_user_notification, data): + self._ns = ns_user_notification + self._data = data + + def __repr__(self): + return '<{0}: [data: {1}]>'.format(type(self).__name__, repr(self._data)) + + @property + def title(self): + return compat.text_type(self._ns.title()) + + @property + def subtitle(self): + return compat.text_type(self._ns.subtitle()) + + @property + def message(self): + return compat.text_type(self._ns.informativeText()) + + @property + def activation_type(self): + activation_type = self._ns.activationType() + if activation_type == 1: + return 'contents_clicked' + elif activation_type == 2: + return 'action_button_clicked' + elif activation_type == 3: + return 'replied' + elif activation_type == 4: + return 'additional_action_clicked' + + @property + def delivered_at(self): + ns_date = self._ns.actualDeliveryDate() + seconds = ns_date.timeIntervalSince1970() + dt = datetime.datetime.fromtimestamp(seconds) + return dt + + @property + def response(self): + ns_attributed_string = self._ns.response() + if ns_attributed_string is None: + return None + ns_string = ns_attributed_string.string() + return compat.text_type(ns_string) + + @property + def data(self): + return self._data + + def _check_if_mapping(self): + if not isinstance(self._data, compat.collections_abc.Mapping): + raise TypeError( + 'notification cannot be used as a mapping when data is not a ' + 'mapping' + ) + + def __getitem__(self, key): + self._check_if_mapping() + return self._data[key] + + def __iter__(self): + self._check_if_mapping() + return iter(self._data) + + def __len__(self): + self._check_if_mapping() + return len(self._data) diff --git a/lib/rumps/packages/ordereddict.py b/lib/rumps/packages/ordereddict.py index 34ba259d..c416b5a1 100644 --- a/lib/rumps/packages/ordereddict.py +++ b/lib/rumps/packages/ordereddict.py @@ -82,7 +82,7 @@ class OrderedDict(dict): def clear(self): 'od.clear() -> None. Remove all items from od.' try: - for node in self.__map.itervalues(): + for node in self.__map.values(): del node[:] root = self.__root root[:] = [root, root, None] diff --git a/lib/rumps/rumps.py b/lib/rumps/rumps.py index b3fa19f5..00148cb9 100644 --- a/lib/rumps/rumps.py +++ b/lib/rumps/rumps.py @@ -1,38 +1,32 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- -# -# rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. -# Copyright: (c) 2017, Jared Suttles. All rights reserved. -# License: BSD, see LICENSE for details. -# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -_NOTIFICATIONS = True +# rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. +# Copyright: (c) 2020, Jared Suttles. All rights reserved. +# License: BSD, see LICENSE for details. + # For compatibility with pyinstaller # See: http://stackoverflow.com/questions/21058889/pyinstaller-not-finding-pyobjc-library-macos-python import Foundation import AppKit -try: - from Foundation import NSUserNotification, NSUserNotificationCenter -except ImportError: - _NOTIFICATIONS = False - from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode, NSSearchPathForDirectoriesInDomains, - NSMakeRect, NSLog, NSObject, NSMutableDictionary, NSString) -from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize + NSMakeRect, NSLog, NSObject, NSMutableDictionary, NSString, NSUserDefaults) +from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize, NSWorkspace, NSWorkspaceWillSleepNotification, NSWorkspaceDidWakeNotification from PyObjCTools import AppHelper -import inspect import os import pickle -import sys import traceback import weakref -from collections import Mapping, Iterable +from .compat import text_type, string_types, iteritems, collections_abc +from .text_field import Editing, SecureEditing from .utils import ListDict -from .compat import text_type, string_types, iteritems + +from . import _internal +from . import events +from . import notifications _TIMERS = weakref.WeakKeyDictionary() separator = object() @@ -78,11 +72,13 @@ def alert(title=None, message='', ok=None, cancel=None, other=None, icon_path=No message = message.replace('%', '%%') if title is not None: title = text_type(title) - _require_string_or_none(ok) + _internal.require_string_or_none(ok) if not isinstance(cancel, string_types): cancel = 'Cancel' if cancel else None alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_( title, ok, cancel, other, message) + if NSUserDefaults.standardUserDefaults().stringForKey_('AppleInterfaceStyle') == 'Dark': + alert.window().setAppearance_(AppKit.NSAppearance.appearanceNamed_('NSAppearanceNameVibrantDark')) alert.setAlertStyle_(0) # informational style if icon_path is not None: icon = _nsimage_from_file(icon_path) @@ -91,119 +87,6 @@ def alert(title=None, message='', ok=None, cancel=None, other=None, icon_path=No return alert.runModal() -def _gather_info_issue_9(): - missing_plist = False - missing_bundle_ident = False - info_plist_path = os.path.join(os.path.dirname(sys.executable), 'Info.plist') - try: - with open(info_plist_path) as f: - import plistlib - try: - load_plist = plistlib.load - except AttributeError: - load_plist = plistlib.readPlist - try: - load_plist(f)['CFBundleIdentifier'] - except Exception: - missing_bundle_ident = True - - except IOError as e: - import errno - if e.errno == errno.ENOENT: # No such file or directory - missing_plist = True - - info = '\n\n' - if missing_plist: - info += 'In this case there is no file at "%(info_plist_path)s"' - info += '\n\n' - confidence = 'should' - elif missing_bundle_ident: - info += 'In this case the file at "%(info_plist_path)s" does not contain a value for "CFBundleIdentifier"' - info += '\n\n' - confidence = 'should' - else: - confidence = 'may' - info += 'Running the following command %(confidence)s fix the issue:\n' - info += '/usr/libexec/PlistBuddy -c \'Add :CFBundleIdentifier string "rumps"\' %(info_plist_path)s\n' - return info % {'info_plist_path': info_plist_path, 'confidence': confidence} - - -def _default_user_notification_center(): - notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() - if notification_center is None: - info = ( - 'Failed to setup the notification center. This issue occurs when the "Info.plist" file ' - 'cannot be found or is missing "CFBundleIdentifier".' - ) - try: - info += _gather_info_issue_9() - except Exception: - pass - raise RuntimeError(info) - else: - return notification_center - - -def notification(title, subtitle, message, data=None, sound=True, action_button=None, other_button=None, - has_reply_button=False, icon=None): - """Send a notification to Notification Center (OS X 10.8+). If running on a version of macOS that does not - support notifications, a ``RuntimeError`` will be raised. Apple says, - - "The userInfo content must be of reasonable serialized size (less than 1k) or an exception will be thrown." - - So don't do that! - - :param title: text in a larger font. - :param subtitle: text in a smaller font below the `title`. - :param message: text representing the body of the notification below the `subtitle`. - :param data: will be passed to the application's "notification center" (see :func:`rumps.notifications`) when this - notification is clicked. - :param sound: whether the notification should make a noise when it arrives. - :param action_button: title for the action button. - :param other_button: title for the other button. - :param has_reply_button: whether or not the notification has a reply button. - :param icon: the filename of an image for the notification's icon, will replace the default. - """ - if not _NOTIFICATIONS: - raise RuntimeError('OS X 10.8+ is required to send notifications') - - if data is not None and not isinstance(data, Mapping): - raise TypeError('notification data must be a mapping') - - _require_string_or_none(title, subtitle, message) - - notification = NSUserNotification.alloc().init() - - notification.setTitle_(title) - notification.setSubtitle_(subtitle) - notification.setInformativeText_(message) - - if data is not None: - app = getattr(App, '*app_instance') - dumped = app.serializer.dumps(data) - ns_dict = NSMutableDictionary.alloc().init() - ns_string = NSString.alloc().initWithString_(dumped) - ns_dict.setDictionary_({'value': ns_string}) - notification.setUserInfo_(ns_dict) - - if icon is not None: - notification.set_identityImage_(_nsimage_from_file(icon)) - if sound: - notification.setSoundName_("NSUserNotificationDefaultSoundName") - if action_button: - notification.setActionButtonTitle_(action_button) - notification.set_showsButtons_(True) - if other_button: - notification.setOtherButtonTitle_(other_button) - notification.set_showsButtons_(True) - if has_reply_button: - notification.setHasReplyButton_(True) - - notification.setDeliveryDate_(NSDate.dateWithTimeInterval_sinceDate_(0, NSDate.date())) - notification_center = _default_user_notification_center() - notification_center.scheduleNotification_(notification) - - def application_support(name): """Return the application support folder path for the given `name`, creating it if it doesn't exist.""" app_support_path = os.path.join(NSSearchPathForDirectoriesInDomains(14, 1, 1).objectAtIndex_(0), name) @@ -248,18 +131,6 @@ def _nsimage_from_file(filename, dimensions=None, template=None): return image -def _require_string(*objs): - for obj in objs: - if not isinstance(obj, string_types): - raise TypeError('a string is required but given {0}, a {1}'.format(obj, type(obj).__name__)) - - -def _require_string_or_none(*objs): - for obj in objs: - if not(obj is None or isinstance(obj, string_types)): - raise TypeError('a string or None is required but given {0}, a {1}'.format(obj, type(obj).__name__)) - - # Decorators and helper function serving to register functions for dealing with interaction and events #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def timer(interval): @@ -365,36 +236,6 @@ def slider(*args, **options): return f return decorator - -def notifications(f): - """Decorator for registering a function to serve as a "notification center" for the application. This function will - receive the data associated with an incoming macOS notification sent using :func:`rumps.notification`. This occurs - whenever the user clicks on a notification for this application in the macOS Notification Center. - - .. code-block:: python - - @rumps.notifications - def notification_center(info): - if 'unix' in info: - print 'i know this' - - """ - notifications.__dict__['*notification_center'] = f - return f - - -def _call_as_function_or_method(func, event): - # The idea here is that when using decorators in a class, the functions passed are not bound so we have to - # determine later if the functions we have (those saved as callbacks) for particular events need to be passed - # 'self'. - # - # This works for an App subclass method or a standalone decorated function. Will attempt to find function as - # a bound method of the App instance. If it is found, use it, otherwise simply call function. - app = getattr(App, '*app_instance') - for name, method in inspect.getmembers(app, predicate=inspect.ismethod): - if method.__func__ is func: - return method(event) - return func(event) #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -468,14 +309,14 @@ class Menu(ListDict): menu.add(iterable) return - for n, ele in enumerate(iteritems(iterable) if isinstance(iterable, Mapping) else iterable): + for n, ele in enumerate(iteritems(iterable) if isinstance(iterable, collections_abc.Mapping) else iterable): # for mappings we recurse but don't drop down a level in the menu - if not isinstance(ele, MenuItem) and isinstance(ele, Mapping): + if not isinstance(ele, MenuItem) and isinstance(ele, collections_abc.Mapping): parse_menu(ele, menu, depth) # any iterables other than strings and MenuItems - elif not isinstance(ele, (string_types, MenuItem)) and isinstance(ele, Iterable): + elif not isinstance(ele, (string_types, MenuItem)) and isinstance(ele, collections_abc.Iterable): try: menuitem, submenu = ele except TypeError: @@ -580,7 +421,7 @@ class MenuItem(Menu): # NOTE: # Because of the quirks of PyObjC, a class level dictionary **inside an NSObject subclass for 10.9.x** is required # in order to have callback_ be a @classmethod. And we need callback_ to be class level because we can't use - # instances in setTarget_ method of NSMenuItem. Otherwise this would be much more straightfoward like Timer class. + # instances in setTarget_ method of NSMenuItem. Otherwise this would be much more straightforward like Timer class. # # So the target is always the NSApp class and action is always the @classmethod callback_ -- for every function # decorated with @clicked(...). All we do is lookup the MenuItem instance and the user-provided callback function @@ -690,6 +531,35 @@ class MenuItem(Menu): def state(self, new_state): self._menuitem.setState_(new_state) + @property + def hidden(self): + """Indicates whether the menu item is hidden. + + .. versionadded:: 0.4.0 + + """ + return self._menuitem.isHidden() + + @hidden.setter + def hidden(self, value): + self._menuitem.setHidden_(value) + + def hide(self): + """Hide the menu item. + + .. versionadded:: 0.4.0 + + """ + self.hidden = True + + def show(self): + """Show the menu item. + + .. versionadded:: 0.4.0 + + """ + self.hidden = False + def set_callback(self, callback, key=None): """Set the function serving as callback for when a click event occurs on this menu item. When `callback` is ``None``, it will disable the callback function and grey out the menu item. If `key` is a string, set as the @@ -703,7 +573,7 @@ class MenuItem(Menu): :param callback: the function to be called when the user clicks on this menu item. :param key: the key shortcut to click this menu item. """ - _require_string_or_none(key) + _internal.require_string_or_none(key) if key is not None: self._menuitem.setKeyEquivalent_(key) NSApp._ns_to_py_and_callback[self._menuitem] = self, callback @@ -744,7 +614,7 @@ class SliderMenuItem(object): self._slider = NSSlider.alloc().init() self._slider.setMinValue_(min_value) self._slider.setMaxValue_(max_value) - self._slider.setValue_(value) + self._slider.setDoubleValue_(value) self._slider.setFrameSize_(NSSize(*dimensions)) self._slider.setTarget_(NSApp) self._menuitem = NSMenuItem.alloc().init() @@ -774,11 +644,11 @@ class SliderMenuItem(object): @property def value(self): """The current position of the slider.""" - return self._slider.value() + return self._slider.doubleValue() @value.setter def value(self, new_value): - self._slider.setValue_(new_value) + self._slider.setDoubleValue_(new_value) class SeparatorMenuItem(object): @@ -860,9 +730,9 @@ class Timer(object): def callback_(self, _): _log(self) try: - return _call_as_function_or_method(getattr(self, '*callback'), self) + return _internal.call_as_function_or_method(getattr(self, '*callback'), self) except Exception: - _log(traceback.format_exc()) + traceback.print_exc() class Window(object): @@ -899,7 +769,7 @@ class Window(object): self._cancel = bool(cancel) self._icon = None - _require_string_or_none(ok) + _internal.require_string_or_none(ok) if not isinstance(cancel, string_types): cancel = 'Cancel' if cancel else None @@ -908,9 +778,9 @@ class Window(object): self._alert.setAlertStyle_(0) # informational style if secure: - self._textfield = NSSecureTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) + self._textfield = SecureEditing.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) else: - self._textfield = NSTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) + self._textfield = Editing.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) self._textfield.setSelectable_(True) self._alert.setAccessoryView_(self._textfield) @@ -982,7 +852,7 @@ class Window(object): :param name: the text for a new button. Must be a string. """ - _require_string(name) + _internal.require_string(name) self._alert.addButtonWithTitle_(name) def add_buttons(self, iterable=None, *args): @@ -1009,6 +879,8 @@ class Window(object): :return: a :class:`rumps.rumps.Response` object that contains the text and the button clicked as an integer. """ _log(self) + if NSUserDefaults.standardUserDefaults().stringForKey_('AppleInterfaceStyle') == 'Dark': + self._alert.window().setAppearance_(AppKit.NSAppearance.appearanceNamed_('NSAppearanceNameVibrantDark')) clicked = self._alert.runModal() % 999 if clicked > 2 and self._cancel: clicked -= 1 @@ -1062,27 +934,7 @@ class NSApp(NSObject): _ns_to_py_and_callback = {} def userNotificationCenter_didActivateNotification_(self, notification_center, notification): - notification_center.removeDeliveredNotification_(notification) - ns_dict = notification.userInfo() - if ns_dict is None: - data = None - else: - dumped = ns_dict['value'] - app = getattr(App, '*app_instance') - data = app.serializer.loads(dumped) - - try: - notification_function = getattr(notifications, '*notification_center') - except AttributeError: # notification center function not specified -> no error but warning in log - _log('WARNING: notification received but no function specified for answering it; use @notifications ' - 'decorator to register a function.') - else: - try: - data['activationType'] = notification.activationType() - data['actualDeliveryDate'] = notification.actualDeliveryDate() - _call_as_function_or_method(notification_function, data) - except Exception: - _log(traceback.format_exc()) + notifications._clicked(notification_center, notification) def initializeStatusBar(self): self.nsstatusitem = NSStatusBar.systemStatusBar().statusItemWithLength_(-1) # variable dimensions @@ -1113,14 +965,42 @@ class NSApp(NSObject): if not (self.nsstatusitem.title() or self.nsstatusitem.image()): self.nsstatusitem.setTitle_(self._app['_name']) + def applicationDidFinishLaunching_(self, notification): + workspace = NSWorkspace.sharedWorkspace() + notificationCenter = workspace.notificationCenter() + notificationCenter.addObserver_selector_name_object_( + self, + self.receiveSleepNotification_, + NSWorkspaceWillSleepNotification, + None + ) + notificationCenter.addObserver_selector_name_object_( + self, + self.receiveWakeNotification_, + NSWorkspaceDidWakeNotification, + None + ) + + def receiveSleepNotification_(self, ns_notification): + _log('receiveSleepNotification') + events.on_sleep.emit() + + def receiveWakeNotification_(self, ns_notification): + _log('receiveWakeNotification') + events.on_wake.emit() + + def applicationWillTerminate_(self, ns_notification): + _log('applicationWillTerminate') + events.before_quit.emit() + @classmethod def callback_(cls, nsmenuitem): self, callback = cls._ns_to_py_and_callback[nsmenuitem] _log(self) try: - return _call_as_function_or_method(callback, self) + return _internal.call_as_function_or_method(callback, self) except Exception: - _log(traceback.format_exc()) + traceback.print_exc() class App(object): @@ -1150,7 +1030,7 @@ class App(object): serializer = pickle def __init__(self, name, title=None, icon=None, template=None, menu=None, quit_button='Quit'): - _require_string(name) + _internal.require_string(name) self._name = name self._icon = self._icon_nsimage = self._title = None self._template = template @@ -1186,7 +1066,7 @@ class App(object): @title.setter def title(self, title): - _require_string_or_none(title) + _internal.require_string_or_none(title) self._title = title try: self._nsapp.setStatusBarTitle() @@ -1308,13 +1188,7 @@ class App(object): self._nsapp = NSApp.alloc().init() self._nsapp._app = self.__dict__ # allow for dynamic modification based on this App instance nsapplication.setDelegate_(self._nsapp) - if _NOTIFICATIONS: - try: - notification_center = _default_user_notification_center() - except RuntimeError: - pass - else: - notification_center.setDelegate_(self._nsapp) + notifications._init_nsapp(self._nsapp) setattr(App, '*app_instance', self) # class level ref to running instance (for passing self to App subclasses) t = b = None @@ -1326,4 +1200,6 @@ class App(object): self._nsapp.initializeStatusBar() + AppHelper.installMachInterrupt() + events.before_start.emit() AppHelper.runEventLoop() diff --git a/lib/rumps/text_field.py b/lib/rumps/text_field.py new file mode 100644 index 00000000..f33f0ff4 --- /dev/null +++ b/lib/rumps/text_field.py @@ -0,0 +1,32 @@ +from AppKit import NSApplication, NSTextField, NSSecureTextField, NSKeyDown, NSCommandKeyMask + + +class Editing(NSTextField): + """NSTextField with cut, copy, paste, undo and selectAll""" + def performKeyEquivalent_(self, event): + return _perform_key_equivalent(self, event) + + +class SecureEditing(NSSecureTextField): + """NSSecureTextField with cut, copy, paste, undo and selectAll""" + def performKeyEquivalent_(self, event): + return _perform_key_equivalent(self, event) + + +def _perform_key_equivalent(self, event): + if event.type() == NSKeyDown and event.modifierFlags() & NSCommandKeyMask: + if event.charactersIgnoringModifiers() == "x": + NSApplication.sharedApplication().sendAction_to_from_("cut:", None, self) + return True + elif event.charactersIgnoringModifiers() == "c": + NSApplication.sharedApplication().sendAction_to_from_("copy:", None, self) + return True + elif event.charactersIgnoringModifiers() == "v": + NSApplication.sharedApplication().sendAction_to_from_("paste:", None, self) + return True + elif event.charactersIgnoringModifiers() == "z": + NSApplication.sharedApplication().sendAction_to_from_("undo:", None, self) + return True + elif event.charactersIgnoringModifiers() == "a": + NSApplication.sharedApplication().sendAction_to_from_("selectAll:", None, self) + return True diff --git a/lib/rumps/utils.py b/lib/rumps/utils.py index 7480a081..eb71b9b8 100644 --- a/lib/rumps/utils.py +++ b/lib/rumps/utils.py @@ -1,10 +1,15 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- -# -# rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. -# Copyright: (c) 2017, Jared Suttles. All rights reserved. -# License: BSD, see LICENSE for details. -# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +""" +rumps.utils +~~~~~~~~~~~ + +Generic container classes and utility functions. + +:copyright: (c) 2020 by Jared Suttles +:license: BSD-3-Clause, see LICENSE for details. +""" + from .packages.ordereddict import OrderedDict as _OrderedDict diff --git a/requirements.txt b/requirements.txt index 7b597123..a0e0c1b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ python-twitter==3.5 pytz==2022.6 requests==2.28.1 requests-oauthlib==1.3.1 -rumps==0.3.0; platform_system == "Darwin" +rumps==0.4.0; platform_system == "Darwin" simplejson==3.17.6 six==1.16.0 soupsieve==2.3.2.post1