├── rumps ├── packages │ ├── __init__.py │ └── ordereddict.py ├── exceptions.py ├── compat.py ├── __init__.py ├── utils.py ├── _internal.py ├── notifications.py └── rumps.py ├── MANIFEST.in ├── .gitignore ├── docs ├── alert.rst ├── timerfunc.rst ├── timers.rst ├── App.rst ├── clicked.rst ├── Timer.rst ├── Window.rst ├── debug_mode.rst ├── notification.rst ├── Response.rst ├── notifications.rst ├── quit_application.rst ├── application_support.rst ├── classes.rst ├── functions.rst ├── MenuItem.rst ├── debugging.rst ├── creating.rst ├── index.rst ├── examples.rst ├── Makefile └── conf.py ├── examples ├── pony.jpg ├── level_4.png ├── rumps_example.png ├── example_windows.py ├── setup.py ├── example_class_new_style.py ├── example_delayed_callbacks.py ├── example_dynamic_title_icon.py ├── example_class.py ├── example_0_2_0_features.py ├── example_timers.py ├── example_menu.py └── example_simple.py ├── tox.ini ├── Makefile ├── tests ├── test_utils.py ├── test_internal.py └── test_notifications.py ├── LICENSE ├── setup.py ├── CHANGES.rst └── README.rst /rumps/packages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.py[co] 4 | 5 | .idea/ 6 | -------------------------------------------------------------------------------- /docs/alert.rst: -------------------------------------------------------------------------------- 1 | alert 2 | ===== 3 | 4 | .. autofunction:: rumps.alert 5 | -------------------------------------------------------------------------------- /docs/timerfunc.rst: -------------------------------------------------------------------------------- 1 | timer 2 | ===== 3 | 4 | .. autofunction:: rumps.timer 5 | -------------------------------------------------------------------------------- /docs/timers.rst: -------------------------------------------------------------------------------- 1 | timers 2 | ====== 3 | 4 | .. autofunction:: rumps.timers 5 | -------------------------------------------------------------------------------- /docs/App.rst: -------------------------------------------------------------------------------- 1 | App 2 | === 3 | 4 | .. autoclass:: rumps.App 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/clicked.rst: -------------------------------------------------------------------------------- 1 | clicked 2 | ======= 3 | 4 | .. autofunction:: rumps.clicked 5 | -------------------------------------------------------------------------------- /docs/Timer.rst: -------------------------------------------------------------------------------- 1 | Timer 2 | ===== 3 | 4 | .. autoclass:: rumps.Timer 5 | :members: 6 | -------------------------------------------------------------------------------- /examples/pony.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dAnjou/rumps/master/examples/pony.jpg -------------------------------------------------------------------------------- /docs/Window.rst: -------------------------------------------------------------------------------- 1 | Window 2 | ====== 3 | 4 | .. autoclass:: rumps.Window 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/debug_mode.rst: -------------------------------------------------------------------------------- 1 | debug_mode 2 | ========== 3 | 4 | .. autofunction:: rumps.debug_mode 5 | -------------------------------------------------------------------------------- /examples/level_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dAnjou/rumps/master/examples/level_4.png -------------------------------------------------------------------------------- /docs/notification.rst: -------------------------------------------------------------------------------- 1 | notification 2 | ============ 3 | 4 | .. autofunction:: rumps.notification 5 | -------------------------------------------------------------------------------- /examples/rumps_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dAnjou/rumps/master/examples/rumps_example.png -------------------------------------------------------------------------------- /docs/Response.rst: -------------------------------------------------------------------------------- 1 | Response 2 | ======== 3 | 4 | .. autoclass:: rumps.rumps.Response 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/notifications.rst: -------------------------------------------------------------------------------- 1 | notifications 2 | ============= 3 | 4 | .. autofunction:: rumps.notifications 5 | -------------------------------------------------------------------------------- /docs/quit_application.rst: -------------------------------------------------------------------------------- 1 | quit_application 2 | ================ 3 | 4 | .. autofunction:: rumps.quit_application 5 | -------------------------------------------------------------------------------- /docs/application_support.rst: -------------------------------------------------------------------------------- 1 | application_support 2 | =================== 3 | 4 | .. autofunction:: rumps.application_support 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35,py36,py37,py38 3 | 4 | [testenv] 5 | whitelist_externals = pytest 6 | commands = pytest tests 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init test test-all 2 | 3 | init: 4 | pip install -e .[dev] 5 | 6 | test: init 7 | pytest tests 8 | 9 | test-all: init 10 | tox 11 | -------------------------------------------------------------------------------- /docs/classes.rst: -------------------------------------------------------------------------------- 1 | rumps Classes 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | App 8 | MenuItem 9 | Window 10 | Response 11 | Timer 12 | -------------------------------------------------------------------------------- /rumps/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class RumpsError(Exception): 5 | """A generic rumps error occurred.""" 6 | 7 | 8 | class InternalRumpsError(RumpsError): 9 | """Internal mechanism powering functionality of rumps failed.""" 10 | -------------------------------------------------------------------------------- /docs/functions.rst: -------------------------------------------------------------------------------- 1 | rumps Functions 2 | =============== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | notifications 8 | clicked 9 | timerfunc 10 | timers 11 | application_support 12 | notification 13 | alert 14 | debug_mode 15 | quit_application 16 | -------------------------------------------------------------------------------- /examples/example_windows.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | 3 | window = rumps.Window('Nothing...', 'ALERTZ') 4 | window.title = 'WINDOWS jk' 5 | window.message = 'Something.' 6 | window.default_text = 'eh' 7 | 8 | response = window.run() 9 | print (response) 10 | 11 | window.add_buttons('One', 'Two', 'Three') 12 | 13 | print (window.run()) 14 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | from rumps.utils import ListDict 6 | 7 | 8 | class TestListDict(object): 9 | def test_clear(self): 10 | ld = ListDict() 11 | 12 | ld[1] = 11 13 | ld['b'] = 22 14 | ld[object()] = 33 15 | assert len(ld) == 3 16 | 17 | ld.clear() 18 | assert len(ld) == 0 19 | assert ld.items() == [] 20 | -------------------------------------------------------------------------------- /examples/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a setup.py script generated by py2applet 3 | 4 | Usage: 5 | python setup.py py2app 6 | """ 7 | 8 | from setuptools import setup 9 | 10 | APP = ['example_class.py'] 11 | DATA_FILES = [] 12 | OPTIONS = { 13 | 'argv_emulation': True, 14 | 'plist': { 15 | 'LSUIElement': True, 16 | }, 17 | 'packages': ['rumps'], 18 | } 19 | 20 | setup( 21 | app=APP, 22 | data_files=DATA_FILES, 23 | options={'py2app': OPTIONS}, 24 | setup_requires=['py2app'], 25 | ) 26 | -------------------------------------------------------------------------------- /docs/MenuItem.rst: -------------------------------------------------------------------------------- 1 | MenuItem 2 | ======== 3 | 4 | .. autoclass:: rumps.MenuItem 5 | :members: 6 | :inherited-members: 7 | 8 | .. method:: d[key] 9 | 10 | Return the item of d with key `key`. Raises a ``KeyError`` if key is not in the map. 11 | 12 | .. method:: d[key] = value 13 | 14 | Set `d[key]` to `value` if `key` does not exist in d. `value` will be converted to a `MenuItem` object if not one already. 15 | 16 | .. method:: del d[key] 17 | 18 | Remove `d[key]` from d. Raises a ``KeyError`` if `key` is not in the map. 19 | -------------------------------------------------------------------------------- /examples/example_class_new_style.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | 3 | class AwesomeStatusBarApp(rumps.App): 4 | @rumps.clicked("Preferences") 5 | def prefs(self, _): 6 | rumps.alert("jk! no preferences available!") 7 | 8 | @rumps.clicked("Silly button") 9 | def onoff(self, sender): 10 | sender.state = not sender.state 11 | 12 | @rumps.clicked("Say hi") 13 | def sayhi(self, _): 14 | rumps.notification("Awesome title", "amazing subtitle", "hi!!1") 15 | 16 | if __name__ == "__main__": 17 | AwesomeStatusBarApp("Awesome App").run() 18 | -------------------------------------------------------------------------------- /examples/example_delayed_callbacks.py: -------------------------------------------------------------------------------- 1 | from rumps import * 2 | 3 | @clicked('Testing') 4 | def tester(sender): 5 | sender.state = not sender.state 6 | 7 | class SomeApp(rumps.App): 8 | def __init__(self): 9 | super(SomeApp, self).__init__(type(self).__name__, menu=['On', 'Testing']) 10 | rumps.debug_mode(True) 11 | 12 | @clicked('On') 13 | def button(self, sender): 14 | sender.title = 'Off' if sender.title == 'On' else 'On' 15 | Window("I can't think of a good example app...").run() 16 | 17 | if __name__ == "__main__": 18 | SomeApp().run() 19 | -------------------------------------------------------------------------------- /examples/example_dynamic_title_icon.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | 3 | rumps.debug_mode(True) 4 | 5 | @rumps.clicked('Icon', 'On') 6 | def a(_): 7 | app.icon = 'test.png' 8 | 9 | @rumps.clicked('Icon', 'Off') 10 | def b(_): 11 | app.icon = None 12 | 13 | @rumps.clicked('Title', 'On') 14 | def c(_): 15 | app.title = 'Buzz' 16 | 17 | @rumps.clicked('Title', 'Off') 18 | def d(_): 19 | app.title = None 20 | 21 | app = rumps.App('Buzz Application', quit_button=rumps.MenuItem('Quit Buzz', key='q')) 22 | app.menu = [ 23 | ('Icon', ('On', 'Off')), 24 | ('Title', ('On', 'Off')) 25 | ] 26 | app.run() 27 | -------------------------------------------------------------------------------- /examples/example_class.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | 3 | class AwesomeStatusBarApp(rumps.App): 4 | def __init__(self): 5 | super(AwesomeStatusBarApp, self).__init__("Awesome App") 6 | self.menu = ["Preferences", "Silly button", "Say hi"] 7 | 8 | @rumps.clicked("Preferences") 9 | def prefs(self, _): 10 | rumps.alert("jk! no preferences available!") 11 | 12 | @rumps.clicked("Silly button") 13 | def onoff(self, sender): 14 | sender.state = not sender.state 15 | 16 | @rumps.clicked("Say hi") 17 | def sayhi(self, _): 18 | rumps.notification("Awesome title", "amazing subtitle", "hi!!1") 19 | 20 | if __name__ == "__main__": 21 | AwesomeStatusBarApp().run() 22 | -------------------------------------------------------------------------------- /rumps/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | rumps.compat 5 | ~~~~~~~~~~~~ 6 | 7 | Compatibility for Python 2 and Python 3 major versions. 8 | 9 | :copyright: (c) 2020 by Jared Suttles 10 | :license: BSD-3-Clause, see LICENSE for details. 11 | """ 12 | 13 | import sys 14 | 15 | PY2 = sys.version_info[0] == 2 16 | 17 | if not PY2: 18 | binary_type = bytes 19 | text_type = str 20 | string_types = (str,) 21 | 22 | iteritems = lambda d: iter(d.items()) 23 | 24 | import collections.abc as collections_abc 25 | 26 | else: 27 | binary_type = () 28 | text_type = unicode 29 | string_types = (str, unicode) 30 | 31 | iteritems = lambda d: d.iteritems() 32 | 33 | import collections as collections_abc 34 | -------------------------------------------------------------------------------- /examples/example_0_2_0_features.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | 3 | rumps.debug_mode(True) 4 | 5 | @rumps.clicked('Print Something') 6 | def print_something(_): 7 | rumps.alert(message='something', ok='YES!', cancel='NO!') 8 | 9 | 10 | @rumps.clicked('On/Off Test') 11 | def on_off_test(_): 12 | print_button = app.menu['Print Something'] 13 | if print_button.callback is None: 14 | print_button.set_callback(print_something) 15 | else: 16 | print_button.set_callback(None) 17 | 18 | 19 | @rumps.clicked('Clean Quit') 20 | def clean_up_before_quit(_): 21 | print('execute clean up code') 22 | rumps.quit_application() 23 | 24 | 25 | app = rumps.App('Hallo Thar', menu=['Print Something', 'On/Off Test', 'Clean Quit'], quit_button=None) 26 | app.run() 27 | -------------------------------------------------------------------------------- /examples/example_timers.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | import time 3 | 4 | 5 | def timez(): 6 | return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.localtime()) 7 | 8 | 9 | @rumps.timer(1) 10 | def a(sender): 11 | print('%r %r' % (sender, timez())) 12 | 13 | 14 | @rumps.clicked('Change timer') 15 | def changeit(_): 16 | response = rumps.Window('Enter new interval').run() 17 | if response.clicked: 18 | global_namespace_timer.interval = int(response.text) 19 | 20 | 21 | @rumps.clicked('All timers') 22 | def activetimers(_): 23 | print(rumps.timers()) 24 | 25 | 26 | @rumps.clicked('Start timer') 27 | def start_timer(_): 28 | global_namespace_timer.start() 29 | 30 | 31 | @rumps.clicked('Stop timer') 32 | def stop_timer(_): 33 | global_namespace_timer.stop() 34 | 35 | 36 | if __name__ == "__main__": 37 | global_namespace_timer = rumps.Timer(a, 4) 38 | rumps.App('fuuu', menu=('Change timer', 'All timers', 'Start timer', 'Stop timer')).run() 39 | -------------------------------------------------------------------------------- /docs/debugging.rst: -------------------------------------------------------------------------------- 1 | Debugging Your Application 2 | ========================== 3 | 4 | When writing your application you will want to turn on debugging mode. 5 | 6 | .. code-block:: python 7 | 8 | import rumps 9 | rumps.debug_mode(True) 10 | 11 | If you are running your program from the interpreter, you should see the informational messages. 12 | 13 | .. code-block:: bash 14 | 15 | python {your app name}.py 16 | 17 | If testing the .app generated using py2app, to be able to see these messages you must not, 18 | 19 | .. code-block:: bash 20 | 21 | open {your app name}.app 22 | 23 | but instead run the executable. While within the directory containing the .app, 24 | 25 | .. code-block:: bash 26 | 27 | ./{your app name}.app/Contents/MacOS/{your app name} 28 | 29 | And, by default, your .app will be in ``dist`` folder after running ``python setup.py py2app``. So of course that would then be, 30 | 31 | .. code-block:: bash 32 | 33 | ./dist/{your app name}.app/Contents/MacOS/{your app name} 34 | -------------------------------------------------------------------------------- /rumps/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. 5 | # Copyright: (c) 2020, Jared Suttles. All rights reserved. 6 | # License: BSD, see LICENSE for details. 7 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 8 | 9 | """ 10 | rumps 11 | ===== 12 | 13 | Ridiculously Uncomplicated macOS Python Statusbar apps. 14 | 15 | rumps exposes Objective-C classes as Python classes and functions which greatly simplifies the process of creating a 16 | statusbar application. 17 | """ 18 | 19 | __title__ = 'rumps' 20 | __version__ = '0.3.0.dev' 21 | __author__ = 'Jared Suttles' 22 | __license__ = 'Modified BSD' 23 | __copyright__ = 'Copyright 2020 Jared Suttles' 24 | 25 | from . import notifications as _notifications 26 | from .rumps import (separator, debug_mode, alert, application_support, timers, quit_application, timer, 27 | clicked, MenuItem, SliderMenuItem, Timer, Window, App, slider) 28 | 29 | notifications = _notifications.on_notification 30 | notification = _notifications.notify 31 | -------------------------------------------------------------------------------- /tests/test_internal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | from rumps._internal import guard_unexpected_errors 6 | 7 | 8 | class TestGuardUnexpectedErrors(object): 9 | def test_raises(self, capfd): 10 | 11 | @guard_unexpected_errors 12 | def callback_func(): 13 | raise ValueError('-.-') 14 | 15 | callback_func() 16 | 17 | captured = capfd.readouterr() 18 | assert not captured.out 19 | assert captured.err.strip().startswith('Traceback (most recent call last):') 20 | assert captured.err.strip().endswith('''ValueError: -.- 21 | 22 | The above exception was the direct cause of the following exception: 23 | 24 | rumps.exceptions.InternalRumpsError: an unexpected error occurred within an internal callback''') 25 | 26 | def test_no_raises(self, capfd): 27 | 28 | @guard_unexpected_errors 29 | def callback_func(): 30 | return 88 * 2 31 | 32 | assert callback_func() == 176 33 | 34 | captured = capfd.readouterr() 35 | assert not captured.out 36 | assert not captured.err 37 | -------------------------------------------------------------------------------- /docs/creating.rst: -------------------------------------------------------------------------------- 1 | Creating Standalone Applications 2 | ================================ 3 | 4 | If you want to create your own bundled .app you need to download py2app: https://pythonhosted.org/py2app/ 5 | 6 | For creating standalone apps, just make sure to include ``rumps`` in the ``packages`` list. Most simple statusbar-based 7 | apps are just "background" apps (no icon in the dock; inability to tab to the application) so it is likely that you 8 | would want to set ``'LSUIElement'`` to ``True``. A basic ``setup.py`` would look like, 9 | 10 | .. code-block:: python 11 | 12 | from setuptools import setup 13 | 14 | APP = ['example_class.py'] 15 | DATA_FILES = [] 16 | OPTIONS = { 17 | 'argv_emulation': True, 18 | 'plist': { 19 | 'LSUIElement': True, 20 | }, 21 | 'packages': ['rumps'], 22 | } 23 | 24 | setup( 25 | app=APP, 26 | data_files=DATA_FILES, 27 | options={'py2app': OPTIONS}, 28 | setup_requires=['py2app'], 29 | ) 30 | 31 | With this you can then create a standalone, 32 | 33 | .. code-block:: bash 34 | 35 | python setup.py py2app 36 | -------------------------------------------------------------------------------- /rumps/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | rumps.utils 5 | ~~~~~~~~~~~ 6 | 7 | Generic container classes and utility functions. 8 | 9 | :copyright: (c) 2020 by Jared Suttles 10 | :license: BSD-3-Clause, see LICENSE for details. 11 | """ 12 | 13 | from .packages.ordereddict import OrderedDict as _OrderedDict 14 | 15 | 16 | # ListDict: OrderedDict subclass with insertion methods for modifying the order of the linked list in O(1) time 17 | # https://gist.github.com/jaredks/6276032 18 | class ListDict(_OrderedDict): 19 | def __insertion(self, link_prev, key_value): 20 | key, value = key_value 21 | if link_prev[2] != key: 22 | if key in self: 23 | del self[key] 24 | link_next = link_prev[1] 25 | self._OrderedDict__map[key] = link_prev[1] = link_next[0] = [link_prev, link_next, key] 26 | dict.__setitem__(self, key, value) 27 | 28 | def insert_after(self, existing_key, key_value): 29 | self.__insertion(self._OrderedDict__map[existing_key], key_value) 30 | 31 | def insert_before(self, existing_key, key_value): 32 | self.__insertion(self._OrderedDict__map[existing_key][0], key_value) 33 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rumps documentation master file, created by 2 | sphinx-quickstart on Mon Aug 4 23:56:00 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to rumps 7 | ================ 8 | 9 | rumps is... 10 | 11 | Ridiculously Uncomplicated Mac os x Python Statusbar apps! 12 | 13 | rumps exposes Objective-C classes as Python classes and functions which greatly simplifies the process of creating a statusbar application. 14 | 15 | Say you have a Python program and want to create a relatively simple interface for end user interaction on a Mac. There are a number of GUI tools available to Python programmers (PyQt, Tkinter, PyGTK, WxPython, etc.) but most are overkill if you just want to expose a few configuration options or an execution switch. 16 | 17 | If all you want is a statusbar app, rumps makes it easy. 18 | 19 | GitHub project: https://github.com/jaredks/rumps 20 | 21 | Contents: 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | 26 | examples 27 | creating 28 | debugging 29 | classes 30 | functions 31 | 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Jared Suttles. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of rumps nor the names of its contributors may be 15 | used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /examples/example_menu.py: -------------------------------------------------------------------------------- 1 | from rumps import * 2 | 3 | try: 4 | from urllib import urlretrieve 5 | except ImportError: 6 | from urllib.request import urlretrieve 7 | 8 | def sayhello(sender): 9 | print('hello {}'.format(sender)) 10 | 11 | def e(_): 12 | print('EEEEEEE') 13 | 14 | def adjust_f(sender): 15 | if adjust_f.huh: 16 | sender.add('$') 17 | sender.add('%') 18 | sender['zzz'] = 'zzz' 19 | sender['separator'] = separator 20 | sender['ppp'] = MenuItem('ppp') 21 | else: 22 | del sender['$'] 23 | del sender['%'] 24 | del sender['separator'] 25 | del sender['ppp'] 26 | adjust_f.huh = not adjust_f.huh 27 | adjust_f.huh = True 28 | 29 | def print_f(_): 30 | print(f) 31 | 32 | f = MenuItem('F', callback=adjust_f) 33 | 34 | urlretrieve('http://upload.wikimedia.org/wikipedia/commons/thumb/c/' 35 | 'c4/Kiss_Logo.svg/200px-Kiss_Logo.svg.png', 'kiss.png') 36 | app = App('lovegun', icon='kiss.png') 37 | app.menu = [ 38 | MenuItem('A', callback=print_f, key='F'), 39 | ('B', ['1', 2, '3', [4, [5, (6, range(7, 14))]]]), 40 | 'C', 41 | [MenuItem('D', callback=sayhello), (1, 11, 111)], 42 | MenuItem('E', callback=e, key='e'), 43 | f, 44 | None, 45 | { 46 | 'x': {'hello', 'hey'}, 47 | 'y': ['what is up'] 48 | }, 49 | [1, [2]], 50 | ('update method', ['walking', 'back', 'to', 'you']), 51 | 'stuff', 52 | None 53 | ] 54 | 55 | @clicked('update method') 56 | def dict_update(menu): 57 | print(menu) 58 | print(menu.setdefault('boo', MenuItem('boo', 59 | callback=lambda _: add_separator(menu)))) # lambda gets THIS menu not submenu 60 | 61 | def add_separator(menu): 62 | menu.add(separator) 63 | 64 | @clicked('C') 65 | def change_main_menu(_): 66 | print(app.menu) 67 | print('goodbye C') 68 | del app.menu['C'] # DELETE SELF!!!1 69 | 70 | @clicked('stuff') 71 | def stuff(sender): 72 | print(sender) 73 | if len(sender): 74 | sender.insert_after('lets', 'go?') 75 | sender['the'].insert_before('band', 'not') 76 | sender['the'].insert_before('band', 'a') 77 | else: 78 | sender.update(['hey', ['ho', MenuItem('HOOOO')], 'lets', 'teenage'], the=['who', 'is', 'band']) 79 | sender.add('waste land') 80 | 81 | app.run() 82 | -------------------------------------------------------------------------------- /examples/example_simple.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | import time 3 | 4 | rumps.debug_mode(True) # turn on command line logging information for development - default is off 5 | 6 | 7 | @rumps.clicked("About") 8 | def about(sender): 9 | sender.title = 'NOM' if sender.title == 'About' else 'About' # can adjust titles of menu items dynamically 10 | rumps.alert("This is a cool app!") 11 | 12 | 13 | @rumps.clicked("Arbitrary", "Depth", "It's pretty easy") # very simple to access nested menu items 14 | def does_something(sender): 15 | my_data = {'poop': 88} 16 | rumps.notification(title='Hi', subtitle='There.', message='Friend!', sound=does_something.sound, data=my_data) 17 | does_something.sound = True 18 | 19 | 20 | @rumps.clicked("Preferences") 21 | def not_actually_prefs(sender): 22 | if not sender.icon: 23 | sender.icon = 'level_4.png' 24 | sender.state = not sender.state 25 | does_something.sound = not does_something.sound 26 | 27 | 28 | @rumps.timer(4) # create a new thread that calls the decorated function every 4 seconds 29 | def write_unix_time(sender): 30 | with app.open('times', 'a') as f: # this opens files in your app's Application Support folder 31 | f.write('The unix time now: {}\n'.format(time.time())) 32 | 33 | 34 | @rumps.clicked("Arbitrary") 35 | def change_statusbar_title(sender): 36 | app.title = 'Hello World' if app.title != 'Hello World' else 'World, Hello' 37 | 38 | 39 | @rumps.notifications 40 | def notifications(notification): # function that reacts to incoming notification dicts 41 | print(notification) 42 | 43 | 44 | def onebitcallback(sender): # functions don't have to be decorated to serve as callbacks for buttons 45 | print(4848484) # this function is specified as a callback when creating a MenuItem below 46 | 47 | 48 | if __name__ == "__main__": 49 | app = rumps.App("My Toolbar App", title='World, Hello') 50 | app.menu = [ 51 | rumps.MenuItem('About', icon='pony.jpg', dimensions=(18, 18)), # can specify an icon to be placed near text 52 | 'Preferences', 53 | None, # None functions as a separator in your menu 54 | {'Arbitrary': 55 | {"Depth": ["Menus", "It's pretty easy"], 56 | "And doesn't": ["Even look like Objective C", rumps.MenuItem("One bit", callback=onebitcallback)]}}, 57 | None 58 | ] 59 | app.run() 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import errno 4 | import os 5 | import re 6 | import sys 7 | import traceback 8 | 9 | from setuptools import setup 10 | 11 | INFO_PLIST_TEMPLATE = '''\ 12 | 13 | 14 | 15 | 16 | CFBundleIdentifier 17 | %(name)s 18 | 19 | 20 | ''' 21 | 22 | 23 | def fix_virtualenv(): 24 | executable_dir = os.path.dirname(sys.executable) 25 | 26 | try: 27 | os.mkdir(os.path.join(executable_dir, 'Contents')) 28 | except OSError as e: 29 | if e.errno != errno.EEXIST: 30 | raise 31 | 32 | with open(os.path.join(executable_dir, 'Contents', 'Info.plist'), 'w') as f: 33 | f.write(INFO_PLIST_TEMPLATE % {'name': 'rumps'}) 34 | 35 | 36 | with open('README.rst') as f: 37 | readme = f.read() 38 | with open('CHANGES.rst') as f: 39 | changes = f.read() 40 | with open('rumps/__init__.py') as f: 41 | version = re.search(r'__version__ = \'(.*?)\'', f.read()).group(1) 42 | 43 | setup( 44 | name='rumps', 45 | version=version, 46 | description='Ridiculously Uncomplicated MacOS Python Statusbar apps.', 47 | author='Jared Suttles', 48 | url='https://github.com/jaredks/rumps', 49 | packages=['rumps', 'rumps.packages'], 50 | package_data={'': ['LICENSE']}, 51 | long_description=readme + '\n\n' + changes, 52 | license='BSD License', 53 | install_requires=[ 54 | 'pyobjc-framework-Cocoa' 55 | ], 56 | extras_require={ 57 | 'dev': [ 58 | 'pytest>=4.3', 59 | 'pytest-mock>=2.0.0', 60 | 'tox>=3.8' 61 | ] 62 | }, 63 | classifiers=[ 64 | 'Development Status :: 4 - Beta', 65 | 'Environment :: MacOS X', 66 | 'Environment :: MacOS X :: Cocoa', 67 | 'Intended Audience :: Developers', 68 | 'License :: OSI Approved :: BSD License', 69 | 'Operating System :: MacOS :: MacOS X', 70 | 'Programming Language :: Python', 71 | 'Programming Language :: Objective C', 72 | 'Topic :: Software Development :: Libraries :: Python Modules', 73 | ] 74 | ) 75 | 76 | # if this looks like a virtualenv 77 | if hasattr(sys, 'real_prefix'): 78 | print('=' * 64) 79 | print( 80 | '\n' 81 | 'It looks like we are inside a virtualenv. Attempting to apply fix.\n' 82 | ) 83 | try: 84 | fix_virtualenv() 85 | except Exception: 86 | traceback.print_exc() 87 | print( 88 | 'WARNING: Could not fix virtualenv. UI interaction likely will ' 89 | 'not function properly.\n' 90 | ) 91 | else: 92 | print( 93 | 'Applied best-effort fix for virtualenv to support proper UI ' 94 | 'interaction.\n' 95 | ) 96 | print( 97 | 'Use of venv is suggested for creating virtual environments:' 98 | '\n\n' 99 | ' python3 -m venv env' 100 | '\n' 101 | ) 102 | print('=' * 64) 103 | -------------------------------------------------------------------------------- /rumps/_internal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | import inspect 6 | import traceback 7 | 8 | import Foundation 9 | 10 | from . import compat 11 | from . import exceptions 12 | 13 | 14 | def require_string(*objs): 15 | for obj in objs: 16 | if not isinstance(obj, compat.string_types): 17 | raise TypeError( 18 | 'a string is required but given {0}, a {1}'.format(obj, type(obj).__name__) 19 | ) 20 | 21 | 22 | def require_string_or_none(*objs): 23 | for obj in objs: 24 | if not(obj is None or isinstance(obj, compat.string_types)): 25 | raise TypeError( 26 | 'a string or None is required but given {0}, a {1}'.format(obj, type(obj).__name__) 27 | ) 28 | 29 | 30 | def call_as_function_or_method(func, event): 31 | # The idea here is that when using decorators in a class, the functions passed are not bound so we have to 32 | # determine later if the functions we have (those saved as callbacks) for particular events need to be passed 33 | # 'self'. 34 | # 35 | # This works for an App subclass method or a standalone decorated function. Will attempt to find function as 36 | # a bound method of the App instance. If it is found, use it, otherwise simply call function. 37 | from . import rumps 38 | try: 39 | app = getattr(rumps.App, '*app_instance') 40 | except AttributeError: 41 | pass 42 | else: 43 | for name, method in inspect.getmembers(app, predicate=inspect.ismethod): 44 | if method.__func__ is func: 45 | return method(event) 46 | return func(event) 47 | 48 | 49 | def guard_unexpected_errors(func): 50 | """Decorator to be used in PyObjC callbacks where an error bubbling up 51 | would cause a crash. Instead of crashing, print the error to stderr and 52 | prevent passing to PyObjC layer. 53 | 54 | For Python 3, print the exception using chaining. Accomplished by setting 55 | the cause of :exc:`rumps.exceptions.InternalRumpsError` to the exception. 56 | 57 | For Python 2, emulate exception chaining by printing the original exception 58 | followed by :exc:`rumps.exceptions.InternalRumpsError`. 59 | """ 60 | def wrapper(*args, **kwargs): 61 | try: 62 | return func(*args, **kwargs) 63 | 64 | except Exception as e: 65 | internal_error = exceptions.InternalRumpsError( 66 | 'an unexpected error occurred within an internal callback' 67 | ) 68 | if compat.PY2: 69 | import sys 70 | traceback.print_exc() 71 | print('\nThe above exception was the direct cause of the following exception:\n', file=sys.stderr) 72 | traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) 73 | else: 74 | internal_error.__cause__ = e 75 | traceback.print_exception(exceptions.InternalRumpsError, internal_error, None) 76 | 77 | return wrapper 78 | 79 | 80 | def string_to_objc(x): 81 | if isinstance(x, compat.binary_type): 82 | return Foundation.NSData.alloc().initWithData_(x) 83 | elif isinstance(x, compat.string_types): 84 | return Foundation.NSString.alloc().initWithString_(x) 85 | else: 86 | raise TypeError( 87 | "expected a string or a bytes-like object but provided %s, " 88 | "having type '%s'" % ( 89 | x, 90 | type(x).__name__ 91 | ) 92 | ) 93 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ============== 3 | 4 | Sometimes the best way to learn something is by example. Form your own application based on some of these samples. 5 | 6 | Simple subclass structure 7 | ------------------------- 8 | 9 | Just a straightforward application, 10 | 11 | .. code-block:: python 12 | 13 | import rumps 14 | 15 | class AwesomeStatusBarApp(rumps.App): 16 | def __init__(self): 17 | super(AwesomeStatusBarApp, self).__init__("Awesome App") 18 | self.menu = ["Preferences", "Silly button", "Say hi"] 19 | 20 | @rumps.clicked("Preferences") 21 | def prefs(self, _): 22 | rumps.alert("jk! no preferences available!") 23 | 24 | @rumps.clicked("Silly button") 25 | def onoff(self, sender): 26 | sender.state = not sender.state 27 | 28 | @rumps.clicked("Say hi") 29 | def sayhi(self, _): 30 | rumps.notification("Awesome title", "amazing subtitle", "hi!!1") 31 | 32 | if __name__ == "__main__": 33 | AwesomeStatusBarApp().run() 34 | 35 | Decorating any functions 36 | ------------------------ 37 | 38 | The following code demonstrates how you can decorate functions with :func:`rumps.clicked` whether or not they are inside a subclass of :class:`rumps.App`. The parameter ``sender``, the :class:`rumps.MenuItem` object, is correctly passed to both functions even though ``button`` needs an instance of ``SomeApp`` as its ``self`` parameter. 39 | 40 | Usually functions registered as callbacks should accept one and only one argument but an `App` subclass is viewed as a special case as its use can provide a simple and pythonic way to implement the logic behind an application. 41 | 42 | .. code-block:: python 43 | 44 | from rumps import * 45 | 46 | @clicked('Testing') 47 | def tester(sender): 48 | sender.state = not sender.state 49 | 50 | class SomeApp(rumps.App): 51 | def __init__(self): 52 | super(SomeApp, self).__init__(type(self).__name__, menu=['On', 'Testing']) 53 | rumps.debug_mode(True) 54 | 55 | @clicked('On') 56 | def button(self, sender): 57 | sender.title = 'Off' if sender.title == 'On' else 'On' 58 | Window("I can't think of a good example app...").run() 59 | 60 | if __name__ == "__main__": 61 | SomeApp().run() 62 | 63 | New features in 0.2.0 64 | --------------------- 65 | 66 | Menu items can be disabled (greyed out) by passing ``None`` to :meth:`rumps.MenuItem.set_callback`. :func:`rumps.alert` no longer requires `title` (will use a default localized string) and allows for custom `cancel` button text. The new parameter `quit_button` for :class:`rumps.App` allows for custom quit button text or removal of the quit button entirely by passing ``None``. 67 | 68 | .. warning:: 69 | By setting :attr:`rumps.App.quit_button` to ``None`` you **must include another way to quit the application** by somehow calling :func:`rumps.quit_application` otherwise you will have to force quit. 70 | 71 | .. code-block:: python 72 | 73 | import rumps 74 | 75 | rumps.debug_mode(True) 76 | 77 | @rumps.clicked('Print Something') 78 | def print_something(_): 79 | rumps.alert(message='something', ok='YES!', cancel='NO!') 80 | 81 | 82 | @rumps.clicked('On/Off Test') 83 | def on_off_test(_): 84 | print_button = app.menu['Print Something'] 85 | if print_button.callback is None: 86 | print_button.set_callback(print_something) 87 | else: 88 | print_button.set_callback(None) 89 | 90 | 91 | @rumps.clicked('Clean Quit') 92 | def clean_up_before_quit(_): 93 | print 'execute clean up code' 94 | rumps.quit_application() 95 | 96 | 97 | app = rumps.App('Hallo Thar', menu=['Print Something', 'On/Off Test', 'Clean Quit'], quit_button=None) 98 | app.run() 99 | 100 | -------------------------------------------------------------------------------- /tests/test_notifications.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | import rumps 6 | 7 | # do this hacky thing 8 | notifications = rumps._notifications 9 | notify = notifications.notify 10 | _clicked = notifications._clicked 11 | on_notification = notifications.on_notification 12 | Notification = notifications.Notification 13 | 14 | 15 | class NSUserNotificationCenterMock: 16 | def __init__(self): 17 | self.ns_user_notification = None 18 | 19 | def scheduleNotification_(self, ns_user_notification): 20 | self.ns_user_notification = ns_user_notification 21 | 22 | def removeDeliveredNotification_(self, ns_user_notification): 23 | assert ns_user_notification is self.ns_user_notification 24 | self.ns_user_notification = None 25 | 26 | 27 | class TestNotify: 28 | path = 'rumps._notifications._default_user_notification_center' 29 | 30 | def test_simple(self, mocker): 31 | """Simple notification is created and scheduled. The internal callback 32 | handler does not raise any exceptions when processing the notification. 33 | """ 34 | 35 | ns_user_notification_center_mock = NSUserNotificationCenterMock() 36 | mocker.patch(self.path, new=lambda: ns_user_notification_center_mock) 37 | 38 | assert ns_user_notification_center_mock.ns_user_notification is None 39 | notify( 40 | 'a', 41 | 'b', 42 | 'c' 43 | ) 44 | assert ns_user_notification_center_mock.ns_user_notification is not None 45 | _clicked( 46 | ns_user_notification_center_mock, 47 | ns_user_notification_center_mock.ns_user_notification 48 | ) 49 | assert ns_user_notification_center_mock.ns_user_notification is None 50 | 51 | def test_with_data(self, mocker): 52 | """Notification that contains serializable data.""" 53 | 54 | ns_user_notification_center_mock = NSUserNotificationCenterMock() 55 | mocker.patch(self.path, new=lambda: ns_user_notification_center_mock) 56 | 57 | @on_notification 58 | def user_defined_notification_callback(notification): 59 | assert notification.data == ['any', {'pickable': 'object'}, 'by', 'default'] 60 | 61 | assert ns_user_notification_center_mock.ns_user_notification is None 62 | notify( 63 | 'a', 64 | 'b', 65 | 'c', 66 | data=['any', {'pickable': 'object'}, 'by', 'default'] 67 | ) 68 | assert ns_user_notification_center_mock.ns_user_notification is not None 69 | _clicked( 70 | ns_user_notification_center_mock, 71 | ns_user_notification_center_mock.ns_user_notification 72 | ) 73 | assert ns_user_notification_center_mock.ns_user_notification is None 74 | 75 | 76 | class TestNotification: 77 | def test_can_access_data(self): 78 | n = Notification(None, 'some test data') 79 | assert n.data == 'some test data' 80 | 81 | def test_can_use_data_as_mapping(self): 82 | n = Notification(None, {2: 22, 3: 333}) 83 | assert n[2] == 22 84 | assert 3 in n 85 | assert len(n) == 2 86 | assert list(n) == [2, 3] 87 | 88 | def test_raises_typeerror_when_no_mapping(self): 89 | n = Notification(None, [4, 55, 666]) 90 | with pytest.raises(TypeError) as excinfo: 91 | n[2] 92 | assert 'cannot be used as a mapping' in str(excinfo.value) 93 | 94 | 95 | class TestDefaultUserNotificationCenter: 96 | def test_basic(self): 97 | """Ensure we can obtain a PyObjC default notification center object.""" 98 | ns_user_notification_center = notifications._default_user_notification_center() 99 | assert type(ns_user_notification_center).__name__ == '_NSConcreteUserNotificationCenter' 100 | 101 | 102 | class TestInitNSApp: 103 | def test_calls(self, mocker): 104 | """Is the method called as expected?""" 105 | path = 'rumps._notifications._default_user_notification_center' 106 | mock_func = mocker.patch(path) 107 | ns_app_fake = object() 108 | notifications._init_nsapp(ns_app_fake) 109 | mock_func().setDelegate_.assert_called_once_with(ns_app_fake) 110 | 111 | def test_exists(self): 112 | """Does the method exist in the framework?""" 113 | ns_user_notification_center = notifications._default_user_notification_center() 114 | ns_app_fake = object() 115 | ns_user_notification_center.setDelegate_(ns_app_fake) 116 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 0.3.0.dev 5 | --------- 6 | 7 | - Fix dark mode alert style #137 8 | - Notifications: fixes, cleanup, and tests #131 9 | - Fix slider for some older macOS versions (10.11 and before?) 10 | - Keyboard interrupts now stop a running application 11 | 12 | 13 | 0.3.0 (2019-02-01) 14 | ------------------ 15 | 16 | - Fix passing data in notifications 17 | - Add `other` and `icon_path` options to ``alert`` 18 | - Add `secure` option to ``Window`` 19 | - Add `action_button`, `other_button`, and `reply_button` options to ``notification`` 20 | - Add ``slider`` 21 | 22 | 23 | 0.2.2 (2017-04-26) 24 | ------------------ 25 | 26 | - Add template icon support for dark menubar theme. 27 | - Fix inability to create notification center by creating ``Info.plist`` file at executable directory with `CFBundleIdentifier` on installation. If that failed, provide more information at runtime in the exception about how to fix the problem. 28 | - Add Python 3 support 29 | 30 | 31 | 0.2.1 (2014-12-13) 32 | ------------------ 33 | 34 | - No longer have to set menu explicitly 35 | + rumps will create the menu as it parses paths in ``clicked`` decorators 36 | - Reverted change to `timers` that produced a list of weak references rather than objects 37 | - New keyword arguments 38 | + `key` for ``clicked`` 39 | + `debug` for ``App.run`` 40 | 41 | 42 | 0.2.0 (2014-08-09) 43 | ------------------ 44 | 45 | **Improvements and compatibility fixes** 46 | 47 | - Added a large number of docstrings 48 | - Merged pull request allowing unicode text 49 | - Compatibility fixes for Python 2.6 50 | + Included OrderedDict recipe 51 | + _TIMERS not using weakref.WeakSet 52 | - Compatibility fixes for Mac OS X versions prior to 10.8 (Notification Center) 53 | + Attempting to send a notification on <10.8 will raise ``RuntimeError`` 54 | - Added ``quit_application`` function to allow for both custom quit buttons and running clean up code before quitting 55 | 56 | **API changes** 57 | 58 | - Most api changes dealt with accepting ``None`` as a parameter to use or restore a default setting 59 | - Raise ``TypeError`` before less obvious exceptions occur in PyObjC 60 | - alert and Window 61 | + No required parameters 62 | + Passing a string as `cancel` parameter will change the button text to that string 63 | + `Window.add_button` now requires a string 64 | - App 65 | + `name` parameter must be a string and `title` must be either a string or ``None`` 66 | + Added `quit_button` parameter allowing custom text or disabling completely by passing ``None`` 67 | - MenuItem 68 | + Passing ``None`` as `callback` parameter to `MenuItem.set_callback` method will disable the callback function and grey out the menu item 69 | + passing an invalid sequence for `dimensions` parameter to `MenuItem.set_icon` will no longer silently error 70 | 71 | 72 | 0.1.5 (2014-08-03) 73 | ------------------ 74 | 75 | - Fix implemented for NSInvalidArgumentException issue on 10.9.x 76 | 77 | 78 | 0.1.4 (2013-08-21) 79 | ------------------ 80 | 81 | - Menu class subclassing ListDict, a subclass of OrderedDict with additional insertion operations 82 | - ``update`` method of Menu works like old App.menu parsing - consumes various nested Python containers and creates menus 83 | 84 | 85 | 0.1.3 (2013-08-19) 86 | ------------------ 87 | 88 | - ``separator`` global for marking menu separators (in addition to None in context of a menu) 89 | - Can now have separators in sub menus using either ``separator`` or None 90 | - Key and menu title not matching doesn't raise an exception since the situation would occur if the title is changed dynamically 91 | + Instead, a warning in the log 92 | - Refactored MenuItem such that it subclasses new Menu class 93 | - Menu class created 94 | + Wraps NSMenu using __setitem__, __delitem__, etc. 95 | + Allows for main menu to be easily changed during runtime as it now uses Menu class instead of vanilla OrderedDict 96 | + ``clear`` method for MenuItem + other irrelevant methods inherited from OrderedDict raise NotImplementedError 97 | - As result of refactoring, could simplify menu parsing for App 98 | 99 | 100 | 0.1.2 (2013-08-11) 101 | ------------------ 102 | 103 | - Interval access and modification added to Timer objects 104 | - timers function for iterating over timers 105 | - Timer class now directly in module namespace 106 | - More specfic case for trying callback with instance of App subclass as first argument 107 | + Point is to avoid catching a completely different TypeError, then sending 2 variables to a function consuming 1 108 | 109 | 110 | 0.1.1 (2013-08-07) 111 | ------------------ 112 | 113 | - Parsing data structures for creating menus is now more robust 114 | - Fixed MenuItem __repr__ for printing instances where no callback function has been given 115 | - Added ``example_menu.py`` to examples serving also as a test for new MenuItem changes 116 | - Can now ``del`` MenuItems of submenus and it will be reflected in the actual menu 117 | - ``add`` method for more convenient addition of MenuItems to a MenuItem's submenu 118 | - Created module docstring 119 | 120 | 121 | 0.1.0 (2013-07-31) 122 | ------------------ 123 | 124 | - world, hello! meet rumps. 125 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rumps.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rumps.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/rumps" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rumps" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # rumps documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Aug 4 23:56:00 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | class Mock(object): 19 | def __init__(self, *_): 20 | pass 21 | 22 | @classmethod 23 | def __getattr__(cls, _): 24 | return cls() 25 | 26 | modules = ['Foundation', 'AppKit', 'PyObjCTools'] 27 | sys.modules.update((module, Mock()) for module in modules) 28 | 29 | # If extensions (or modules to document with autodoc) are in another directory, 30 | # add these directories to sys.path here. If the directory is relative to the 31 | # documentation root, use os.path.abspath to make it absolute, like shown here. 32 | sys.path.insert(0, os.path.abspath('..')) 33 | 34 | # -- General configuration ------------------------------------------------ 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | #needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix of source filenames. 50 | source_suffix = '.rst' 51 | 52 | # The encoding of source files. 53 | #source_encoding = 'utf-8-sig' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # General information about the project. 59 | project = u'rumps' 60 | copyright = u'2014, Jared Suttles' 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The short X.Y version. 67 | version = '0.2.0' 68 | # The full version, including alpha/beta/rc tags. 69 | release = '0.2.0' 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | #language = None 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | #today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | #today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = ['_build'] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all 86 | # documents. 87 | #default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | #add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | #add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | #show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = 'sphinx' 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | #modindex_common_prefix = [] 105 | 106 | # If true, keep warnings as "system message" paragraphs in the built documents. 107 | #keep_warnings = False 108 | 109 | 110 | # -- Options for HTML output ---------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | html_theme = 'default' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # Add any extra paths that contain custom files (such as robots.txt or 146 | # .htaccess) here, relative to this directory. These files are copied 147 | # directly to the root of the documentation. 148 | #html_extra_path = [] 149 | 150 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 151 | # using the given strftime format. 152 | #html_last_updated_fmt = '%b %d, %Y' 153 | 154 | # If true, SmartyPants will be used to convert quotes and dashes to 155 | # typographically correct entities. 156 | #html_use_smartypants = True 157 | 158 | # Custom sidebar templates, maps document names to template names. 159 | #html_sidebars = {} 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'rumpsdoc' 193 | 194 | 195 | # -- Options for LaTeX output --------------------------------------------- 196 | 197 | latex_elements = { 198 | # The paper size ('letterpaper' or 'a4paper'). 199 | #'papersize': 'letterpaper', 200 | 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | #'pointsize': '10pt', 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | #'preamble': '', 206 | } 207 | 208 | # Grouping the document tree into LaTeX files. List of tuples 209 | # (source start file, target name, title, 210 | # author, documentclass [howto, manual, or own class]). 211 | latex_documents = [ 212 | ('index', 'rumps.tex', u'rumps Documentation', 213 | u'Jared Suttles', 'manual'), 214 | ] 215 | 216 | # The name of an image file (relative to this directory) to place at the top of 217 | # the title page. 218 | #latex_logo = None 219 | 220 | # For "manual" documents, if this is true, then toplevel headings are parts, 221 | # not chapters. 222 | #latex_use_parts = False 223 | 224 | # If true, show page references after internal links. 225 | #latex_show_pagerefs = False 226 | 227 | # If true, show URL addresses after external links. 228 | #latex_show_urls = False 229 | 230 | # Documents to append as an appendix to all manuals. 231 | #latex_appendices = [] 232 | 233 | # If false, no module index is generated. 234 | #latex_domain_indices = True 235 | 236 | 237 | # -- Options for manual page output --------------------------------------- 238 | 239 | # One entry per manual page. List of tuples 240 | # (source start file, name, description, authors, manual section). 241 | man_pages = [ 242 | ('index', 'rumps', u'rumps Documentation', 243 | [u'Jared Suttles'], 1) 244 | ] 245 | 246 | # If true, show URL addresses after external links. 247 | #man_show_urls = False 248 | 249 | 250 | # -- Options for Texinfo output ------------------------------------------- 251 | 252 | # Grouping the document tree into Texinfo files. List of tuples 253 | # (source start file, target name, title, author, 254 | # dir menu entry, description, category) 255 | texinfo_documents = [ 256 | ('index', 'rumps', u'rumps Documentation', 257 | u'Jared Suttles', 'rumps', 'One line description of project.', 258 | 'Miscellaneous'), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | #texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | #texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | #texinfo_show_urls = 'footnote' 269 | 270 | # If true, do not generate a @detailmenu in the "Top" node's menu. 271 | #texinfo_no_detailmenu = False 272 | -------------------------------------------------------------------------------- /rumps/notifications.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | _ENABLED = True 4 | try: 5 | from Foundation import NSUserNotification, NSUserNotificationCenter 6 | except ImportError: 7 | _ENABLED = False 8 | 9 | import datetime 10 | import os 11 | import sys 12 | import traceback 13 | 14 | import Foundation 15 | 16 | from . import _internal 17 | from . import compat 18 | 19 | 20 | def on_notification(f): 21 | """Decorator for registering a function to serve as a "notification center" 22 | for the application. This function will receive the data associated with an 23 | incoming macOS notification sent using :func:`rumps.notification`. This 24 | occurs whenever the user clicks on a notification for this application in 25 | the macOS Notification Center. 26 | 27 | .. code-block:: python 28 | 29 | @rumps.notifications 30 | def notification_center(info): 31 | if 'unix' in info: 32 | print 'i know this' 33 | 34 | """ 35 | on_notification.__dict__['*'] = f 36 | return f 37 | 38 | 39 | def _gather_info_issue_9(): # pragma: no cover 40 | missing_plist = False 41 | missing_bundle_ident = False 42 | info_plist_path = os.path.join(os.path.dirname(sys.executable), 'Info.plist') 43 | try: 44 | with open(info_plist_path) as f: 45 | import plistlib 46 | try: 47 | load_plist = plistlib.load 48 | except AttributeError: 49 | load_plist = plistlib.readPlist 50 | try: 51 | load_plist(f)['CFBundleIdentifier'] 52 | except Exception: 53 | missing_bundle_ident = True 54 | 55 | except IOError as e: 56 | import errno 57 | if e.errno == errno.ENOENT: # No such file or directory 58 | missing_plist = True 59 | 60 | info = '\n\n' 61 | if missing_plist: 62 | info += 'In this case there is no file at "%(info_plist_path)s"' 63 | info += '\n\n' 64 | confidence = 'should' 65 | elif missing_bundle_ident: 66 | info += 'In this case the file at "%(info_plist_path)s" does not contain a value for "CFBundleIdentifier"' 67 | info += '\n\n' 68 | confidence = 'should' 69 | else: 70 | confidence = 'may' 71 | info += 'Running the following command %(confidence)s fix the issue:\n' 72 | info += '/usr/libexec/PlistBuddy -c \'Add :CFBundleIdentifier string "rumps"\' %(info_plist_path)s\n' 73 | return info % {'info_plist_path': info_plist_path, 'confidence': confidence} 74 | 75 | 76 | def _default_user_notification_center(): 77 | notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() 78 | if notification_center is None: # pragma: no cover 79 | info = ( 80 | 'Failed to setup the notification center. This issue occurs when the "Info.plist" file ' 81 | 'cannot be found or is missing "CFBundleIdentifier".' 82 | ) 83 | try: 84 | info += _gather_info_issue_9() 85 | except Exception: 86 | pass 87 | raise RuntimeError(info) 88 | else: 89 | return notification_center 90 | 91 | 92 | def _init_nsapp(nsapp): 93 | if _ENABLED: 94 | try: 95 | notification_center = _default_user_notification_center() 96 | except RuntimeError: 97 | pass 98 | else: 99 | notification_center.setDelegate_(nsapp) 100 | 101 | 102 | @_internal.guard_unexpected_errors 103 | def _clicked(ns_user_notification_center, ns_user_notification): 104 | from . import rumps 105 | 106 | ns_user_notification_center.removeDeliveredNotification_(ns_user_notification) 107 | ns_dict = ns_user_notification.userInfo() 108 | if ns_dict is None: 109 | data = None 110 | else: 111 | dumped = ns_dict['value'] 112 | app = getattr(rumps.App, '*app_instance', rumps.App) 113 | try: 114 | data = app.serializer.loads(dumped) 115 | except Exception: 116 | traceback.print_exc() 117 | return 118 | 119 | try: 120 | notification_handler = getattr(on_notification, '*') 121 | except AttributeError: 122 | # notification center function not specified, no error but log warning 123 | rumps._log( 124 | 'WARNING: notification received but no function specified for ' 125 | 'answering it; use @notifications decorator to register a function.' 126 | ) 127 | else: 128 | notification = Notification(ns_user_notification, data) 129 | try: 130 | _internal.call_as_function_or_method(notification_handler, notification) 131 | except Exception: 132 | traceback.print_exc() 133 | 134 | 135 | def notify(title, subtitle, message, data=None, sound=True, 136 | action_button=None, other_button=None, has_reply_button=False, 137 | icon=None): 138 | """Send a notification to Notification Center (OS X 10.8+). If running on a 139 | version of macOS that does not support notifications, a ``RuntimeError`` 140 | will be raised. Apple says, 141 | 142 | "The userInfo content must be of reasonable serialized size (less than 143 | 1k) or an exception will be thrown." 144 | 145 | So don't do that! 146 | 147 | :param title: text in a larger font. 148 | :param subtitle: text in a smaller font below the `title`. 149 | :param message: text representing the body of the notification below the 150 | `subtitle`. 151 | :param data: will be passed to the application's "notification center" (see 152 | :func:`rumps.notifications`) when this notification is clicked. 153 | :param sound: whether the notification should make a noise when it arrives. 154 | :param action_button: title for the action button. 155 | :param other_button: title for the other button. 156 | :param has_reply_button: whether or not the notification has a reply button. 157 | :param icon: the filename of an image for the notification's icon, will 158 | replace the default. 159 | """ 160 | from . import rumps 161 | 162 | if not _ENABLED: 163 | raise RuntimeError('OS X 10.8+ is required to send notifications') 164 | 165 | _internal.require_string_or_none(title, subtitle, message) 166 | 167 | notification = NSUserNotification.alloc().init() 168 | 169 | notification.setTitle_(title) 170 | notification.setSubtitle_(subtitle) 171 | notification.setInformativeText_(message) 172 | 173 | if data is not None: 174 | app = getattr(rumps.App, '*app_instance', rumps.App) 175 | dumped = app.serializer.dumps(data) 176 | objc_string = _internal.string_to_objc(dumped) 177 | ns_dict = Foundation.NSMutableDictionary.alloc().init() 178 | ns_dict.setDictionary_({'value': objc_string}) 179 | notification.setUserInfo_(ns_dict) 180 | 181 | if icon is not None: 182 | notification.set_identityImage_(rumps._nsimage_from_file(icon)) 183 | if sound: 184 | notification.setSoundName_("NSUserNotificationDefaultSoundName") 185 | if action_button: 186 | notification.setActionButtonTitle_(action_button) 187 | notification.set_showsButtons_(True) 188 | if other_button: 189 | notification.setOtherButtonTitle_(other_button) 190 | notification.set_showsButtons_(True) 191 | if has_reply_button: 192 | notification.setHasReplyButton_(True) 193 | 194 | notification.setDeliveryDate_(Foundation.NSDate.dateWithTimeInterval_sinceDate_(0, Foundation.NSDate.date())) 195 | notification_center = _default_user_notification_center() 196 | notification_center.scheduleNotification_(notification) 197 | 198 | 199 | class Notification(compat.collections_abc.Mapping): 200 | def __init__(self, ns_user_notification, data): 201 | self._ns = ns_user_notification 202 | self._data = data 203 | 204 | def __repr__(self): 205 | return '<{0}: [data: {1}]>'.format(type(self).__name__, repr(self._data)) 206 | 207 | @property 208 | def title(self): 209 | return compat.text_type(self._ns.title()) 210 | 211 | @property 212 | def subtitle(self): 213 | return compat.text_type(self._ns.subtitle()) 214 | 215 | @property 216 | def message(self): 217 | return compat.text_type(self._ns.informativeText()) 218 | 219 | @property 220 | def activation_type(self): 221 | activation_type = self._ns.activationType() 222 | if activation_type == 1: 223 | return 'contents_clicked' 224 | elif activation_type == 2: 225 | return 'action_button_clicked' 226 | elif activation_type == 3: 227 | return 'replied' 228 | elif activation_type == 4: 229 | return 'additional_action_clicked' 230 | 231 | @property 232 | def delivered_at(self): 233 | ns_date = self._ns.actualDeliveryDate() 234 | seconds = ns_date.timeIntervalSince1970() 235 | dt = datetime.datetime.fromtimestamp(seconds) 236 | return dt 237 | 238 | @property 239 | def response(self): 240 | ns_attributed_string = self._ns.response() 241 | if ns_attributed_string is None: 242 | return None 243 | ns_string = ns_attributed_string.string() 244 | return compat.text_type(ns_string) 245 | 246 | @property 247 | def data(self): 248 | return self._data 249 | 250 | def _check_if_mapping(self): 251 | if not isinstance(self._data, compat.collections_abc.Mapping): 252 | raise TypeError( 253 | 'notification cannot be used as a mapping when data is not a ' 254 | 'mapping' 255 | ) 256 | 257 | def __getitem__(self, key): 258 | self._check_if_mapping() 259 | return self._data[key] 260 | 261 | def __iter__(self): 262 | self._check_if_mapping() 263 | return iter(self._data) 264 | 265 | def __len__(self): 266 | self._check_if_mapping() 267 | return len(self._data) 268 | -------------------------------------------------------------------------------- /rumps/packages/ordereddict.py: -------------------------------------------------------------------------------- 1 | # Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. 2 | # Passes Python2.7's test suite and incorporates all the latest updates. 3 | # Copyright 2009 Raymond Hettinger, released under the MIT License. 4 | # http://code.activestate.com/recipes/576693/ 5 | try: 6 | from thread import get_ident as _get_ident 7 | except ImportError: 8 | try: 9 | from dummy_thread import get_ident as _get_ident 10 | except ImportError: 11 | from threading import get_ident as _get_ident 12 | 13 | try: 14 | from _abcoll import KeysView, ValuesView, ItemsView 15 | except ImportError: 16 | pass 17 | 18 | 19 | class OrderedDict(dict): 20 | 'Dictionary that remembers insertion order' 21 | # An inherited dict maps keys to values. 22 | # The inherited dict provides __getitem__, __len__, __contains__, and get. 23 | # The remaining methods are order-aware. 24 | # Big-O running times for all methods are the same as for regular dictionaries. 25 | 26 | # The internal self.__map dictionary maps keys to links in a doubly linked list. 27 | # The circular doubly linked list starts and ends with a sentinel element. 28 | # The sentinel element never gets deleted (this simplifies the algorithm). 29 | # Each link is stored as a list of length three: [PREV, NEXT, KEY]. 30 | 31 | def __init__(self, *args, **kwds): 32 | '''Initialize an ordered dictionary. Signature is the same as for 33 | regular dictionaries, but keyword arguments are not recommended 34 | because their insertion order is arbitrary. 35 | 36 | ''' 37 | if len(args) > 1: 38 | raise TypeError('expected at most 1 arguments, got %d' % len(args)) 39 | try: 40 | self.__root 41 | except AttributeError: 42 | self.__root = root = [] # sentinel node 43 | root[:] = [root, root, None] 44 | self.__map = {} 45 | self.__update(*args, **kwds) 46 | 47 | def __setitem__(self, key, value, dict_setitem=dict.__setitem__): 48 | 'od.__setitem__(i, y) <==> od[i]=y' 49 | # Setting a new item creates a new link which goes at the end of the linked 50 | # list, and the inherited dictionary is updated with the new key/value pair. 51 | if key not in self: 52 | root = self.__root 53 | last = root[0] 54 | last[1] = root[0] = self.__map[key] = [last, root, key] 55 | dict_setitem(self, key, value) 56 | 57 | def __delitem__(self, key, dict_delitem=dict.__delitem__): 58 | 'od.__delitem__(y) <==> del od[y]' 59 | # Deleting an existing item uses self.__map to find the link which is 60 | # then removed by updating the links in the predecessor and successor nodes. 61 | dict_delitem(self, key) 62 | link_prev, link_next, key = self.__map.pop(key) 63 | link_prev[1] = link_next 64 | link_next[0] = link_prev 65 | 66 | def __iter__(self): 67 | 'od.__iter__() <==> iter(od)' 68 | root = self.__root 69 | curr = root[1] 70 | while curr is not root: 71 | yield curr[2] 72 | curr = curr[1] 73 | 74 | def __reversed__(self): 75 | 'od.__reversed__() <==> reversed(od)' 76 | root = self.__root 77 | curr = root[0] 78 | while curr is not root: 79 | yield curr[2] 80 | curr = curr[0] 81 | 82 | def clear(self): 83 | 'od.clear() -> None. Remove all items from od.' 84 | try: 85 | for node in self.__map.values(): 86 | del node[:] 87 | root = self.__root 88 | root[:] = [root, root, None] 89 | self.__map.clear() 90 | except AttributeError: 91 | pass 92 | dict.clear(self) 93 | 94 | def popitem(self, last=True): 95 | '''od.popitem() -> (k, v), return and remove a (key, value) pair. 96 | Pairs are returned in LIFO order if last is true or FIFO order if false. 97 | 98 | ''' 99 | if not self: 100 | raise KeyError('dictionary is empty') 101 | root = self.__root 102 | if last: 103 | link = root[0] 104 | link_prev = link[0] 105 | link_prev[1] = root 106 | root[0] = link_prev 107 | else: 108 | link = root[1] 109 | link_next = link[1] 110 | root[1] = link_next 111 | link_next[0] = root 112 | key = link[2] 113 | del self.__map[key] 114 | value = dict.pop(self, key) 115 | return key, value 116 | 117 | # -- the following methods do not depend on the internal structure -- 118 | 119 | def keys(self): 120 | 'od.keys() -> list of keys in od' 121 | return list(self) 122 | 123 | def values(self): 124 | 'od.values() -> list of values in od' 125 | return [self[key] for key in self] 126 | 127 | def items(self): 128 | 'od.items() -> list of (key, value) pairs in od' 129 | return [(key, self[key]) for key in self] 130 | 131 | def iterkeys(self): 132 | 'od.iterkeys() -> an iterator over the keys in od' 133 | return iter(self) 134 | 135 | def itervalues(self): 136 | 'od.itervalues -> an iterator over the values in od' 137 | for k in self: 138 | yield self[k] 139 | 140 | def iteritems(self): 141 | 'od.iteritems -> an iterator over the (key, value) items in od' 142 | for k in self: 143 | yield (k, self[k]) 144 | 145 | def update(*args, **kwds): 146 | '''od.update(E, **F) -> None. Update od from dict/iterable E and F. 147 | 148 | If E is a dict instance, does: for k in E: od[k] = E[k] 149 | If E has a .keys() method, does: for k in E.keys(): od[k] = E[k] 150 | Or if E is an iterable of items, does: for k, v in E: od[k] = v 151 | In either case, this is followed by: for k, v in F.items(): od[k] = v 152 | 153 | ''' 154 | if len(args) > 2: 155 | raise TypeError('update() takes at most 2 positional ' 156 | 'arguments (%d given)' % (len(args),)) 157 | elif not args: 158 | raise TypeError('update() takes at least 1 argument (0 given)') 159 | self = args[0] 160 | # Make progressively weaker assumptions about "other" 161 | other = () 162 | if len(args) == 2: 163 | other = args[1] 164 | if isinstance(other, dict): 165 | for key in other: 166 | self[key] = other[key] 167 | elif hasattr(other, 'keys'): 168 | for key in other.keys(): 169 | self[key] = other[key] 170 | else: 171 | for key, value in other: 172 | self[key] = value 173 | for key, value in kwds.items(): 174 | self[key] = value 175 | 176 | __update = update # let subclasses override update without breaking __init__ 177 | 178 | __marker = object() 179 | 180 | def pop(self, key, default=__marker): 181 | '''od.pop(k[,d]) -> v, remove specified key and return the corresponding value. 182 | If key is not found, d is returned if given, otherwise KeyError is raised. 183 | 184 | ''' 185 | if key in self: 186 | result = self[key] 187 | del self[key] 188 | return result 189 | if default is self.__marker: 190 | raise KeyError(key) 191 | return default 192 | 193 | def setdefault(self, key, default=None): 194 | 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od' 195 | if key in self: 196 | return self[key] 197 | self[key] = default 198 | return default 199 | 200 | def __repr__(self, _repr_running={}): 201 | 'od.__repr__() <==> repr(od)' 202 | call_key = id(self), _get_ident() 203 | if call_key in _repr_running: 204 | return '...' 205 | _repr_running[call_key] = 1 206 | try: 207 | if not self: 208 | return '%s()' % (self.__class__.__name__,) 209 | return '%s(%r)' % (self.__class__.__name__, self.items()) 210 | finally: 211 | del _repr_running[call_key] 212 | 213 | def __reduce__(self): 214 | 'Return state information for pickling' 215 | items = [[k, self[k]] for k in self] 216 | inst_dict = vars(self).copy() 217 | for k in vars(OrderedDict()): 218 | inst_dict.pop(k, None) 219 | if inst_dict: 220 | return (self.__class__, (items,), inst_dict) 221 | return self.__class__, (items,) 222 | 223 | def copy(self): 224 | 'od.copy() -> a shallow copy of od' 225 | return self.__class__(self) 226 | 227 | @classmethod 228 | def fromkeys(cls, iterable, value=None): 229 | '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S 230 | and values equal to v (which defaults to None). 231 | 232 | ''' 233 | d = cls() 234 | for key in iterable: 235 | d[key] = value 236 | return d 237 | 238 | def __eq__(self, other): 239 | '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive 240 | while comparison to a regular mapping is order-insensitive. 241 | 242 | ''' 243 | if isinstance(other, OrderedDict): 244 | return len(self)==len(other) and self.items() == other.items() 245 | return dict.__eq__(self, other) 246 | 247 | def __ne__(self, other): 248 | return not self == other 249 | 250 | # -- the following methods are only used in Python 2.7 -- 251 | 252 | def viewkeys(self): 253 | "od.viewkeys() -> a set-like object providing a view on od's keys" 254 | return KeysView(self) 255 | 256 | def viewvalues(self): 257 | "od.viewvalues() -> an object providing a view on od's values" 258 | return ValuesView(self) 259 | 260 | def viewitems(self): 261 | "od.viewitems() -> a set-like object providing a view on od's items" 262 | return ItemsView(self) 263 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | rumps 2 | ===== 3 | 4 | **R**\ idiculously **U**\ ncomplicated **m**\ acOS **P**\ ython **S**\ tatusbar apps. 5 | 6 | .. image:: https://raw.github.com/jaredks/rumps/master/examples/rumps_example.png 7 | 8 | .. code-block:: python 9 | 10 | import rumps 11 | 12 | class AwesomeStatusBarApp(rumps.App): 13 | @rumps.clicked("Preferences") 14 | def prefs(self, _): 15 | rumps.alert("jk! no preferences available!") 16 | 17 | @rumps.clicked("Silly button") 18 | def onoff(self, sender): 19 | sender.state = not sender.state 20 | 21 | @rumps.clicked("Say hi") 22 | def sayhi(self, _): 23 | rumps.notification("Awesome title", "amazing subtitle", "hi!!1") 24 | 25 | if __name__ == "__main__": 26 | AwesomeStatusBarApp("Awesome App").run() 27 | 28 | How fun!? 29 | 30 | ``rumps`` can greatly shorten the code required to generate a working app. No ``PyObjC`` underscore syntax required! 31 | 32 | 33 | Use case 34 | -------- 35 | 36 | ``rumps`` is for any console-based program that would benefit from a simple configuration toolbar or launch menu. 37 | 38 | Good for: 39 | 40 | * Notification-center-based app 41 | * Controlling daemons / launching separate programs 42 | * Updating simple info from web APIs on a timer 43 | 44 | Not good for: 45 | 46 | * Any app that is first and foremost a GUI application 47 | 48 | 49 | Required 50 | -------- 51 | 52 | * PyObjC 53 | * Python 2.6+ 54 | 55 | Mac OS X 10.6 was shipped with Python 2.6 as the default version and PyObjC has been included in the default Python 56 | since Mac OS X 10.5. If you're using Mac OS X 10.6+ and the default Python that came with it, then ``rumps`` should be 57 | good to go! 58 | 59 | 60 | Recommended 61 | ----------- 62 | 63 | * py2app 64 | 65 | For creating standalone apps, just make sure to include ``rumps`` in the ``packages`` list. Most simple statusbar-based 66 | apps are just "background" apps (no icon in the dock; inability to tab to the application) so it is likely that you 67 | would want to set ``'LSUIElement'`` to ``True``. A basic ``setup.py`` would look like, 68 | 69 | .. code-block:: python 70 | 71 | from setuptools import setup 72 | 73 | APP = ['example_class.py'] 74 | DATA_FILES = [] 75 | OPTIONS = { 76 | 'argv_emulation': True, 77 | 'plist': { 78 | 'LSUIElement': True, 79 | }, 80 | 'packages': ['rumps'], 81 | } 82 | 83 | setup( 84 | app=APP, 85 | data_files=DATA_FILES, 86 | options={'py2app': OPTIONS}, 87 | setup_requires=['py2app'], 88 | ) 89 | 90 | With this you can then create a standalone, 91 | 92 | .. code-block:: bash 93 | 94 | python setup.py py2app 95 | 96 | 97 | Installation 98 | ------------ 99 | 100 | Using pip, 101 | 102 | .. code-block:: bash 103 | 104 | pip install rumps 105 | 106 | Or from source, 107 | 108 | .. code-block:: bash 109 | 110 | python setup.py install 111 | 112 | Both of which will require ``sudo`` if installing in a system-wide location. 113 | 114 | 115 | Virtual Environments 116 | -------------------- 117 | 118 | There are issues with using ``virtualenv`` because of the way the Python 119 | executable is copied. Although ``rumps`` attempts to apply a fix (hack) during 120 | the install process, it is not suggested to use ``virtualenv``. 121 | 122 | To ensure proper functionality, either use ``venv`` (packaged with Python 3) or 123 | create a standalone app using ``py2app``. 124 | 125 | .. code-block:: bash 126 | 127 | python3 -m venv env 128 | 129 | 130 | Documentation 131 | ------------- 132 | 133 | Documentation is available at http://rumps.readthedocs.org 134 | 135 | 136 | License 137 | ------- 138 | 139 | "Modified BSD License". See LICENSE for details. Copyright Jared Suttles, 2020. 140 | 141 | Works Made With rumps 142 | --------------------- 143 | 144 | `20twenty20 - eohomegrownapps 145 | `_ 146 | 147 | `42-CanITakeCoffee - avallete 148 | `_ 149 | 150 | `air-quality-app - grtfou 151 | `_ 152 | 153 | `Airplane - C-Codes 154 | `_ 155 | 156 | `allbar - raphaelhuefner 157 | `_ 158 | 159 | `allofthelights - kenkeiter 160 | `_ 161 | 162 | `attendee-tool-mlh - Bucknalla 163 | `_ 164 | 165 | `Auroratain - Matt-McConway 166 | `_ 167 | 168 | `AutoSSP - viktyz 169 | `_ 170 | 171 | `AutoVPN - shadyabhi 172 | `_ 173 | 174 | `BackgroundsForReddit - karlaugsten 175 | `_ 176 | 177 | `bink - e40 178 | `_ 179 | 180 | `bitracker - JZChen 181 | `_ 182 | 183 | `BluetoothEvent - lostman-github 184 | `_ 185 | 186 | `break-timer - jjmojojjmojo 187 | `_ 188 | 189 | `breaker - amloewi 190 | `_ 191 | 192 | `bundle-checker - jeffgodwyll 193 | `_ 194 | 195 | `c1t1 - e9t 196 | `_ 197 | 198 | `camsketch - pdubroy 199 | `_ 200 | 201 | `ComicStreamer - beville 202 | `_ 203 | 204 | `commitwatch - chrisfosterelli 205 | `_ 206 | 207 | `computer-time - rbrich 208 | `_ 209 | 210 | `crypto-ticker-macOS - mqulateen 211 | `_ 212 | 213 | `cryptocoin-quotes - Sayan98 214 | `_ 215 | 216 | `cuco - jjuanda 217 | `_ 218 | 219 | `currency-converter - ahmedelgohary 220 | `_ 221 | 222 | `dns.app - damln 223 | `_ 224 | 225 | `Dokky - rogierkn 226 | `_ 227 | 228 | `dolar_bitcoin - celis 229 | `_ 230 | 231 | `duplicati - duplicati 232 | `_ 233 | 234 | `earth - nickrobson 235 | `_ 236 | 237 | `ForceNapClone - hroftgit 238 | `_ 239 | 240 | `freelan-bar - privacee 241 | `_ 242 | 243 | `g-assistant-mac - agucova 244 | `_ 245 | 246 | `gapa - ozlerhakan 247 | `_ 248 | 249 | `GitSyncApp - jachin 250 | `_ 251 | 252 | `Gumpy - RobGraham 253 | `_ 254 | 255 | `Habitus - kmundnic 256 | `_ 257 | 258 | `HalfCaff - dougn 259 | `_ 260 | 261 | `happymac - laffra 262 | `_ 263 | 264 | `harmenubar - vekkt0r 265 | `_ 266 | 267 | `hatarake - kfdm-archive 268 | `_ 269 | 270 | `HipStatus - jamfit 271 | `_ 272 | 273 | `hp-lorem - jamesrampton 274 | `_ 275 | 276 | `hs100-status-bar - craig-davis 277 | `_ 278 | 279 | `iBatteryStats - saket13 280 | `_ 281 | 282 | `iBrew - Tristan79 283 | `_ 284 | 285 | `idiot - snare 286 | `_ 287 | 288 | `interlocking - jrauch 289 | `_ 290 | 291 | `istat - Lingdu0 292 | `_ 293 | 294 | `keynote_snap - sasn0 295 | `_ 296 | 297 | `Keypad - jelmer04 298 | `_ 299 | 300 | `keyringo - tokenizecx 301 | `_ 302 | 303 | `kizkiz - TkTech 304 | `_ 305 | 306 | `koinex-status-ticker - kirantambe 307 | `_ 308 | 309 | `leaguefriend - pandarison 310 | `_ 311 | 312 | `LifxController - mitchmcdee 313 | `_ 314 | 315 | `lil_ip_toolbar - mchlrtkwski 316 | `_ 317 | 318 | `mac-shrew - mejmo 319 | `_ 320 | 321 | `MacFaceID - vkalia602 322 | `_ 323 | 324 | `majo-v - r4lv 325 | `_ 326 | 327 | `MBatteryApp - Elliot-Potts 328 | `_ 329 | 330 | `McBing - bagabont 331 | `_ 332 | 333 | `Memcode - aroraenterprise 334 | `_ 335 | 336 | `memdam - joshalbrecht 337 | `_ 338 | 339 | `MenuBarGmail - rcmdnk 340 | `_ 341 | 342 | `midi2dmx - davidbistolas 343 | `_ 344 | 345 | `monero-ticker - Cisplatin 346 | `_ 347 | 348 | `MoodLight - kretash 349 | `_ 350 | 351 | `MoonTicker - skxu 352 | `_ 353 | 354 | `musicbar - russelg 355 | `_ 356 | 357 | `narcissist - helmholtz 358 | `_ 359 | 360 | `Noise-Line - Dnncha 361 | `_ 362 | 363 | `nowplaying_statusbar - MataiulS 364 | `_ 365 | 366 | `obmenka - vlakin 367 | `_ 368 | 369 | `org-clock-dashboard - srid 370 | `_ 371 | 372 | `osx-bamboo-plan-status - spalter 373 | `_ 374 | 375 | `osx-myair - CameronEx 376 | `_ 377 | 378 | `PennAppsX - yousufmsoliman 379 | `_ 380 | 381 | `phd - ChrisCummins 382 | `_ 383 | 384 | `pokemon-go-status - pboardman 385 | `_ 386 | 387 | `polly - interrogator 388 | `_ 389 | 390 | `pompy - camilopayan 391 | `_ 392 | 393 | `project_screen_to_lifx - emiraga 394 | `_ 395 | 396 | `PSPEWC-mac - jacquesCedric 397 | `_ 398 | 399 | `py-Timey - asakasinsky 400 | `_ 401 | 402 | `pymodoro - volflow 403 | `_ 404 | 405 | `pySplash - Egregors 406 | `_ 407 | 408 | `quick-grayscale - shubhamjain 409 | `_ 410 | 411 | `quiet - hiroshi 412 | `_ 413 | 414 | `Radio-Crowd - EliMendelson 415 | `_ 416 | 417 | `RadioBar - wass3r 418 | `_ 419 | 420 | `RadioBar (fork) - mdbraber 421 | `_ 422 | 423 | `rescuetime_statusbar - MauriceZ 424 | `_ 425 | 426 | `rideindegochecker - josepvalls 427 | `_ 428 | 429 | `RitsWifi - fang2hou 430 | `_ 431 | 432 | `safety-bar - pyupio 433 | `_ 434 | 435 | `SAT-Vocab-Quizzer - Legoben 436 | `_ 437 | 438 | `sb-translate - leandroltavares 439 | `_ 440 | 441 | `sharfoo - furqan-shakoor 442 | `_ 443 | 444 | `ShortyURLShortener - Naktrem 445 | `_ 446 | 447 | `shotput - amussey 448 | `_ 449 | 450 | `SingMenuData - ponyfleisch 451 | `_ 452 | 453 | `slack-status-bar - ericwb 454 | `_ 455 | 456 | `slackify - nikodraca 457 | `_ 458 | 459 | `snippets - quillford 460 | `_ 461 | 462 | `sonostus - sarkkine 463 | `_ 464 | 465 | `Spaceapi-Desktop - UrLab 466 | `_ 467 | 468 | `SpaceSwitcher - SankaitLaroiya 469 | `_ 470 | 471 | `SpotifyLyrics - yask123 472 | `_ 473 | 474 | `steemticker-osx - ZachC16 475 | `_ 476 | 477 | `Timebox - visini 478 | `_ 479 | 480 | `Telkom-ADSL-Data-Usage - parautenbach 481 | `_ 482 | 483 | `Telton - Yywww 484 | `_ 485 | 486 | `these-days - hahayes 487 | `_ 488 | 489 | `time-tracking - willsgrigg 490 | `_ 491 | 492 | `timerbar - uberalex 493 | `_ 494 | 495 | `tracker - jtxx000 496 | `_ 497 | 498 | `TrojanA - chrisxiao 499 | `_ 500 | 501 | `umma - mankoff 502 | `_ 503 | 504 | `upbrew - stchris 505 | `_ 506 | 507 | `uptimeIndicator - paulaborde 508 | `_ 509 | 510 | `urstatus - kysely 511 | `_ 512 | 513 | `uStatus - kdungs 514 | `_ 515 | 516 | `VagrantBar - kingsdigitallab 517 | `_ 518 | 519 | `voiceplay - tb0hdan 520 | `_ 521 | 522 | `volsbb - akigugale 523 | `_ 524 | 525 | `Volumio_bar - volderette 526 | `_ 527 | 528 | `votingpowerbar - therealwolf42 529 | `_ 530 | 531 | `WallpDesk - L3rchal 532 | `_ 533 | 534 | `webcronic - josselinauguste 535 | `_ 536 | 537 | `Whale - amka 538 | `_ 539 | 540 | `WhyFi - OzTamir 541 | `_ 542 | 543 | `WordTime - Demonstrandum 544 | `_ 545 | 546 | `work_time_percent_applet - Benhgift 547 | `_ 548 | 549 | `WorkWise - 8ern4ard 550 | `_ 551 | 552 | `xCodea - lowne 553 | `_ 554 | 555 | `yaca - drproteus 556 | `_ 557 | 558 | `Zero - beejhuff 559 | `_ 560 | 561 | Submit a pull request to add your own! 562 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 563 | -------------------------------------------------------------------------------- /rumps/rumps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # rumps: Ridiculously Uncomplicated macOS Python Statusbar apps. 4 | # Copyright: (c) 2020, Jared Suttles. All rights reserved. 5 | # License: BSD, see LICENSE for details. 6 | 7 | 8 | # For compatibility with pyinstaller 9 | # See: http://stackoverflow.com/questions/21058889/pyinstaller-not-finding-pyobjc-library-macos-python 10 | import Foundation 11 | import AppKit 12 | 13 | from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode, NSSearchPathForDirectoriesInDomains, 14 | NSMakeRect, NSLog, NSObject, NSMutableDictionary, NSString) 15 | from AppKit import NSApplication, NSStatusBar, NSMenu, NSMenuItem, NSAlert, NSTextField, NSSecureTextField, NSImage, NSSlider, NSSize, NSWorkspace, NSWorkspaceWillSleepNotification, NSWorkspaceDidWakeNotification 16 | from PyObjCTools import AppHelper 17 | 18 | import os 19 | import pickle 20 | import traceback 21 | import weakref 22 | 23 | from .utils import ListDict 24 | from .compat import text_type, string_types, iteritems, collections_abc 25 | 26 | from . import _internal 27 | from . import notifications 28 | 29 | _TIMERS = weakref.WeakKeyDictionary() 30 | separator = object() 31 | 32 | 33 | def debug_mode(choice): 34 | """Enable/disable printing helpful information for debugging the program. Default is off.""" 35 | global _log 36 | if choice: 37 | def _log(*args): 38 | NSLog(' '.join(map(str, args))) 39 | else: 40 | def _log(*_): 41 | pass 42 | debug_mode(False) 43 | 44 | 45 | def alert(title=None, message='', ok=None, cancel=None, other=None, icon_path=None): 46 | """Generate a simple alert window. 47 | 48 | .. versionchanged:: 0.2.0 49 | Providing a `cancel` string will set the button text rather than only using text "Cancel". `title` is no longer 50 | a required parameter. 51 | 52 | .. versionchanged:: 0.3.0 53 | Add `other` button functionality as well as `icon_path` to change the alert icon. 54 | 55 | :param title: the text positioned at the top of the window in larger font. If ``None``, a default localized title 56 | is used. If not ``None`` or a string, will use the string representation of the object. 57 | :param message: the text positioned below the `title` in smaller font. If not a string, will use the string 58 | representation of the object. 59 | :param ok: the text for the "ok" button. Must be either a string or ``None``. If ``None``, a default 60 | localized button title will be used. 61 | :param cancel: the text for the "cancel" button. If a string, the button will have that text. If `cancel` 62 | evaluates to ``True``, will create a button with text "Cancel". Otherwise, this button will not be 63 | created. 64 | :param other: the text for the "other" button. If a string, the button will have that text. Otherwise, this button will not be 65 | created. 66 | :param icon_path: a path to an image. If ``None``, the applications icon is used. 67 | :return: a number representing the button pressed. The "ok" button is ``1`` and "cancel" is ``0``. 68 | """ 69 | message = text_type(message) 70 | message = message.replace('%', '%%') 71 | if title is not None: 72 | title = text_type(title) 73 | _internal.require_string_or_none(ok) 74 | if not isinstance(cancel, string_types): 75 | cancel = 'Cancel' if cancel else None 76 | alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_( 77 | title, ok, cancel, other, message) 78 | alert.window().setAppearance_(AppKit.NSAppearance.currentAppearance()) 79 | alert.setAlertStyle_(0) # informational style 80 | if icon_path is not None: 81 | icon = _nsimage_from_file(icon_path) 82 | alert.setIcon_(icon) 83 | _log('alert opened with message: {0}, title: {1}'.format(repr(message), repr(title))) 84 | return alert.runModal() 85 | 86 | 87 | def application_support(name): 88 | """Return the application support folder path for the given `name`, creating it if it doesn't exist.""" 89 | app_support_path = os.path.join(NSSearchPathForDirectoriesInDomains(14, 1, 1).objectAtIndex_(0), name) 90 | if not os.path.isdir(app_support_path): 91 | os.mkdir(app_support_path) 92 | return app_support_path 93 | 94 | 95 | def timers(): 96 | """Return a list of all :class:`rumps.Timer` objects. These can be active or inactive.""" 97 | return list(_TIMERS) 98 | 99 | 100 | def quit_application(sender=None): 101 | """Quit the application. Some menu item should call this function so that the application can exit gracefully.""" 102 | nsapplication = NSApplication.sharedApplication() 103 | _log('closing application') 104 | nsapplication.terminate_(sender) 105 | 106 | 107 | def _nsimage_from_file(filename, dimensions=None, template=None): 108 | """Take a path to an image file and return an NSImage object.""" 109 | try: 110 | _log('attempting to open image at {0}'.format(filename)) 111 | with open(filename): 112 | pass 113 | except IOError: # literal file path didn't work -- try to locate image based on main script path 114 | try: 115 | from __main__ import __file__ as main_script_path 116 | main_script_path = os.path.dirname(main_script_path) 117 | filename = os.path.join(main_script_path, filename) 118 | except ImportError: 119 | pass 120 | _log('attempting (again) to open image at {0}'.format(filename)) 121 | with open(filename): # file doesn't exist 122 | pass # otherwise silently errors in NSImage which isn't helpful for debugging 123 | image = NSImage.alloc().initByReferencingFile_(filename) 124 | image.setScalesWhenResized_(True) 125 | image.setSize_((20, 20) if dimensions is None else dimensions) 126 | if not template is None: 127 | image.setTemplate_(template) 128 | return image 129 | 130 | 131 | # Decorators and helper function serving to register functions for dealing with interaction and events 132 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 133 | def timer(interval): 134 | """Decorator for registering a function as a callback in a new thread. The function will be repeatedly called every 135 | `interval` seconds. This decorator accomplishes the same thing as creating a :class:`rumps.Timer` object by using 136 | the decorated function and `interval` as parameters and starting it on application launch. 137 | 138 | .. code-block:: python 139 | 140 | @rumps.timer(2) 141 | def repeating_function(sender): 142 | print 'hi' 143 | 144 | :param interval: a number representing the time in seconds before the decorated function should be called. 145 | """ 146 | def decorator(f): 147 | timers = timer.__dict__.setdefault('*timers', []) 148 | timers.append(Timer(f, interval)) 149 | return f 150 | return decorator 151 | 152 | 153 | def clicked(*args, **options): 154 | """Decorator for registering a function as a callback for a click action on a :class:`rumps.MenuItem` within the 155 | application. The passed `args` must specify an existing path in the main menu. The :class:`rumps.MenuItem` 156 | instance at the end of that path will have its :meth:`rumps.MenuItem.set_callback` method called, passing in the 157 | decorated function. 158 | 159 | .. versionchanged:: 0.2.1 160 | Accepts `key` keyword argument. 161 | 162 | .. code-block:: python 163 | 164 | @rumps.clicked('Animal', 'Dog', 'Corgi') 165 | def corgi_button(sender): 166 | import subprocess 167 | subprocess.call(['say', '"corgis are the cutest"']) 168 | 169 | :param args: a series of strings representing the path to a :class:`rumps.MenuItem` in the main menu of the 170 | application. 171 | :param key: a string representing the key shortcut as an alternative means of clicking the menu item. 172 | """ 173 | def decorator(f): 174 | 175 | def register_click(self): 176 | menuitem = self._menu # self not defined yet but will be later in 'run' method 177 | if menuitem is None: 178 | raise ValueError('no menu created') 179 | for arg in args: 180 | try: 181 | menuitem = menuitem[arg] 182 | except KeyError: 183 | menuitem.add(arg) 184 | menuitem = menuitem[arg] 185 | menuitem.set_callback(f, options.get('key')) 186 | 187 | # delay registering the button until we have a current instance to be able to traverse the menu 188 | buttons = clicked.__dict__.setdefault('*buttons', []) 189 | buttons.append(register_click) 190 | 191 | return f 192 | return decorator 193 | 194 | 195 | def slider(*args, **options): 196 | """Decorator for registering a function as a callback for a slide action on a :class:`rumps.SliderMenuItem` within 197 | the application. All elements of the provided path will be created as :class:`rumps.MenuItem` objects. The 198 | :class:`rumps.SliderMenuItem` will be created as a child of the last menu item. 199 | 200 | Accepts the same keyword arguments as :class:`rumps.SliderMenuItem`. 201 | 202 | .. versionadded:: 0.3.0 203 | 204 | :param args: a series of strings representing the path to a :class:`rumps.SliderMenuItem` in the main menu of the 205 | application. 206 | """ 207 | def decorator(f): 208 | 209 | def register_click(self): 210 | 211 | # self not defined yet but will be later in 'run' method 212 | menuitem = self._menu 213 | if menuitem is None: 214 | raise ValueError('no menu created') 215 | 216 | # create here in case of error so we don't create the path 217 | slider_menu_item = SliderMenuItem(**options) 218 | slider_menu_item.set_callback(f) 219 | 220 | for arg in args: 221 | try: 222 | menuitem = menuitem[arg] 223 | except KeyError: 224 | menuitem.add(arg) 225 | menuitem = menuitem[arg] 226 | 227 | menuitem.add(slider_menu_item) 228 | 229 | # delay registering the button until we have a current instance to be able to traverse the menu 230 | buttons = clicked.__dict__.setdefault('*buttons', []) 231 | buttons.append(register_click) 232 | 233 | return f 234 | return decorator 235 | 236 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 237 | 238 | 239 | class Menu(ListDict): 240 | """Wrapper for Objective-C's NSMenu class. 241 | 242 | Implements core functionality of menus in rumps. :class:`rumps.MenuItem` subclasses `Menu`. 243 | """ 244 | 245 | # NOTE: 246 | # Only ever used as the main menu since every other menu would exist as a submenu of a MenuItem 247 | 248 | _choose_key = object() 249 | 250 | def __init__(self): 251 | self._counts = {} 252 | if not hasattr(self, '_menu'): 253 | self._menu = NSMenu.alloc().init() 254 | super(Menu, self).__init__() 255 | 256 | def __setitem__(self, key, value): 257 | if key not in self: 258 | key, value = self._process_new_menuitem(key, value) 259 | self._menu.addItem_(value._menuitem) 260 | super(Menu, self).__setitem__(key, value) 261 | 262 | def __delitem__(self, key): 263 | value = self[key] 264 | self._menu.removeItem_(value._menuitem) 265 | super(Menu, self).__delitem__(key) 266 | 267 | def add(self, menuitem): 268 | """Adds the object to the menu as a :class:`rumps.MenuItem` using the :attr:`rumps.MenuItem.title` as the 269 | key. `menuitem` will be converted to a `MenuItem` object if not one already. 270 | """ 271 | self.__setitem__(self._choose_key, menuitem) 272 | 273 | def clear(self): 274 | """Remove all `MenuItem` objects from within the menu of this `MenuItem`.""" 275 | self._menu.removeAllItems() 276 | super(Menu, self).clear() 277 | 278 | def copy(self): 279 | raise NotImplementedError 280 | 281 | @classmethod 282 | def fromkeys(cls, *args, **kwargs): 283 | raise NotImplementedError 284 | 285 | def update(self, iterable, **kwargs): 286 | """Update with objects from `iterable` after each is converted to a :class:`rumps.MenuItem`, ignoring 287 | existing keys. This update is a bit different from the usual ``dict.update`` method. It works recursively and 288 | will parse a variety of Python containers and objects, creating `MenuItem` object and submenus as necessary. 289 | 290 | If the `iterable` is an instance of :class:`rumps.MenuItem`, then add to the menu. 291 | 292 | Otherwise, for each element in the `iterable`, 293 | 294 | - if the element is a string or is not an iterable itself, it will be converted to a 295 | :class:`rumps.MenuItem` and the key will be its string representation. 296 | - if the element is a :class:`rumps.MenuItem` already, it will remain the same and the key will be its 297 | :attr:`rumps.MenuItem.title` attribute. 298 | - if the element is an iterable having a length of 2, the first value will be converted to a 299 | :class:`rumps.MenuItem` and the second will act as the submenu for that `MenuItem` 300 | - if the element is an iterable having a length of anything other than 2, a ``ValueError`` will be raised 301 | - if the element is a mapping, each key-value pair will act as an iterable having a length of 2 302 | 303 | """ 304 | def parse_menu(iterable, menu, depth): 305 | if isinstance(iterable, MenuItem): 306 | menu.add(iterable) 307 | return 308 | 309 | for n, ele in enumerate(iteritems(iterable) if isinstance(iterable, collections_abc.Mapping) else iterable): 310 | 311 | # for mappings we recurse but don't drop down a level in the menu 312 | if not isinstance(ele, MenuItem) and isinstance(ele, collections_abc.Mapping): 313 | parse_menu(ele, menu, depth) 314 | 315 | # any iterables other than strings and MenuItems 316 | elif not isinstance(ele, (string_types, MenuItem)) and isinstance(ele, collections_abc.Iterable): 317 | try: 318 | menuitem, submenu = ele 319 | except TypeError: 320 | raise ValueError('menu iterable element #{0} at depth {1} has length {2}; must be a single ' 321 | 'menu item or a pair consisting of a menu item and its ' 322 | 'submenu'.format(n, depth, len(tuple(ele)))) 323 | menuitem = MenuItem(menuitem) 324 | menu.add(menuitem) 325 | parse_menu(submenu, menuitem, depth+1) 326 | 327 | # menu item / could be visual separator where ele is None or separator 328 | else: 329 | menu.add(ele) 330 | parse_menu(iterable, self, 0) 331 | parse_menu(kwargs, self, 0) 332 | 333 | # ListDict insertion methods 334 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 335 | 336 | def insert_after(self, existing_key, menuitem): 337 | """Insert a :class:`rumps.MenuItem` in the menu after the `existing_key`. 338 | 339 | :param existing_key: a string key for an existing `MenuItem` value. 340 | :param menuitem: an object to be added. It will be converted to a `MenuItem` if not one already. 341 | """ 342 | key, menuitem = self._process_new_menuitem(self._choose_key, menuitem) 343 | self._insert_helper(existing_key, key, menuitem, 1) 344 | super(Menu, self).insert_after(existing_key, (key, menuitem)) 345 | 346 | def insert_before(self, existing_key, menuitem): 347 | """Insert a :class:`rumps.MenuItem` in the menu before the `existing_key`. 348 | 349 | :param existing_key: a string key for an existing `MenuItem` value. 350 | :param menuitem: an object to be added. It will be converted to a `MenuItem` if not one already. 351 | """ 352 | key, menuitem = self._process_new_menuitem(self._choose_key, menuitem) 353 | self._insert_helper(existing_key, key, menuitem, 0) 354 | super(Menu, self).insert_before(existing_key, (key, menuitem)) 355 | 356 | def _insert_helper(self, existing_key, key, menuitem, pos): 357 | if existing_key == key: # this would mess stuff up... 358 | raise ValueError('same key provided for location and insertion') 359 | existing_menuitem = self[existing_key] 360 | index = self._menu.indexOfItem_(existing_menuitem._menuitem) 361 | self._menu.insertItem_atIndex_(menuitem._menuitem, index + pos) 362 | 363 | # Processing MenuItems 364 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 365 | 366 | def _process_new_menuitem(self, key, value): 367 | if value is None or value is separator: 368 | value = SeparatorMenuItem() 369 | 370 | if not hasattr(value, '_menuitem'): 371 | value = MenuItem(value) 372 | 373 | if key is self._choose_key: 374 | if hasattr(value, 'title'): 375 | key = value.title 376 | else: 377 | cls = type(value) 378 | count = self._counts[cls] = self._counts.get(cls, 0) + 1 379 | key = '%s_%d' % (cls.__name__, count) 380 | 381 | if hasattr(value, 'title') and key != value.title: 382 | _log('WARNING: key {0} is not the same as the title of the corresponding MenuItem {1}; while this ' 383 | 'would occur if the title is dynamically altered, having different names at the time of menu ' 384 | 'creation may not be desired '.format(repr(key), repr(value.title))) 385 | 386 | return key, value 387 | 388 | 389 | class MenuItem(Menu): 390 | """Represents an item within the application's menu. 391 | 392 | A :class:`rumps.MenuItem` is a button inside a menu but it can also serve as a menu itself whose elements are 393 | other `MenuItem` instances. 394 | 395 | Encapsulates and abstracts Objective-C NSMenuItem (and possibly a corresponding NSMenu as a submenu). 396 | 397 | A couple of important notes: 398 | 399 | - A new `MenuItem` instance can be created from any object with a string representation. 400 | - Attempting to create a `MenuItem` by passing an existing `MenuItem` instance as the first parameter will not 401 | result in a new instance but will instead return the existing instance. 402 | 403 | Remembers the order of items added to menu and has constant time lookup. Can insert new `MenuItem` object before or 404 | after other specified ones. 405 | 406 | .. note:: 407 | When adding a `MenuItem` instance to a menu, the value of :attr:`title` at that time will serve as its key for 408 | lookup performed on menus even if the `title` changes during program execution. 409 | 410 | :param title: the name of this menu item. If not a string, will use the string representation of the object. 411 | :param callback: the function serving as callback for when a click event occurs on this menu item. 412 | :param key: the key shortcut to click this menu item. Must be a string or ``None``. 413 | :param icon: a path to an image. If set to ``None``, the current image (if any) is removed. 414 | :param dimensions: a sequence of numbers whose length is two, specifying the dimensions of the icon. 415 | :param template: a boolean, specifying template mode for a given icon (proper b/w display in dark menu bar) 416 | """ 417 | 418 | # NOTE: 419 | # Because of the quirks of PyObjC, a class level dictionary **inside an NSObject subclass for 10.9.x** is required 420 | # in order to have callback_ be a @classmethod. And we need callback_ to be class level because we can't use 421 | # instances in setTarget_ method of NSMenuItem. Otherwise this would be much more straightfoward like Timer class. 422 | # 423 | # So the target is always the NSApp class and action is always the @classmethod callback_ -- for every function 424 | # decorated with @clicked(...). All we do is lookup the MenuItem instance and the user-provided callback function 425 | # based on the NSMenuItem (the only argument passed to callback_). 426 | 427 | def __new__(cls, *args, **kwargs): 428 | if args and isinstance(args[0], MenuItem): # can safely wrap MenuItem instances 429 | return args[0] 430 | return super(MenuItem, cls).__new__(cls, *args, **kwargs) 431 | 432 | def __init__(self, title, callback=None, key=None, icon=None, dimensions=None, template=None): 433 | if isinstance(title, MenuItem): # don't initialize already existing instances 434 | return 435 | self._menuitem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(text_type(title), None, '') 436 | self._menuitem.setTarget_(NSApp) 437 | self._menu = self._icon = None 438 | self.set_callback(callback, key) 439 | self._template = template 440 | self.set_icon(icon, dimensions, template) 441 | super(MenuItem, self).__init__() 442 | 443 | def __setitem__(self, key, value): 444 | if self._menu is None: 445 | self._menu = NSMenu.alloc().init() 446 | self._menuitem.setSubmenu_(self._menu) 447 | super(MenuItem, self).__setitem__(key, value) 448 | 449 | def __repr__(self): 450 | return '<{0}: [{1} -> {2}; callback: {3}]>'.format(type(self).__name__, repr(self.title), list(map(str, self)), 451 | repr(self.callback)) 452 | 453 | @property 454 | def title(self): 455 | """The text displayed in a menu for this menu item. If not a string, will use the string representation of the 456 | object. 457 | """ 458 | return self._menuitem.title() 459 | 460 | @title.setter 461 | def title(self, new_title): 462 | new_title = text_type(new_title) 463 | self._menuitem.setTitle_(new_title) 464 | 465 | @property 466 | def icon(self): 467 | """The path to an image displayed next to the text for this menu item. If set to ``None``, the current image 468 | (if any) is removed. 469 | 470 | .. versionchanged:: 0.2.0 471 | Setting icon to ``None`` after setting it to an image will correctly remove the icon. Returns the path to an 472 | image rather than exposing a `PyObjC` class. 473 | 474 | """ 475 | return self._icon 476 | 477 | @icon.setter 478 | def icon(self, icon_path): 479 | self.set_icon(icon_path, template=self._template) 480 | 481 | @property 482 | def template(self): 483 | """Template mode for an icon. If set to ``None``, the current icon (if any) is displayed as a color icon. 484 | If set to ``True``, template mode is enabled and the icon will be displayed correctly in dark menu bar mode. 485 | """ 486 | return self._template 487 | 488 | @template.setter 489 | def template(self, template_mode): 490 | self._template = template_mode 491 | self.set_icon(self.icon, template=template_mode) 492 | 493 | def set_icon(self, icon_path, dimensions=None, template=None): 494 | """Sets the icon displayed next to the text for this menu item. If set to ``None``, the current image (if any) 495 | is removed. Can optionally supply `dimensions`. 496 | 497 | .. versionchanged:: 0.2.0 498 | Setting `icon` to ``None`` after setting it to an image will correctly remove the icon. Passing `dimensions` 499 | a sequence whose length is not two will no longer silently error. 500 | 501 | :param icon_path: a file path to an image. 502 | :param dimensions: a sequence of numbers whose length is two. 503 | :param template: a boolean who defines the template mode for the icon. 504 | """ 505 | new_icon = _nsimage_from_file(icon_path, dimensions, template) if icon_path is not None else None 506 | self._icon = icon_path 507 | self._menuitem.setImage_(new_icon) 508 | 509 | @property 510 | def state(self): 511 | """The state of the menu item. The "on" state is symbolized by a check mark. The "mixed" state is symbolized 512 | by a dash. 513 | 514 | .. table:: Setting states 515 | 516 | ===== ====== 517 | State Number 518 | ===== ====== 519 | ON 1 520 | OFF 0 521 | MIXED -1 522 | ===== ====== 523 | 524 | """ 525 | return self._menuitem.state() 526 | 527 | @state.setter 528 | def state(self, new_state): 529 | self._menuitem.setState_(new_state) 530 | 531 | def set_callback(self, callback, key=None): 532 | """Set the function serving as callback for when a click event occurs on this menu item. When `callback` is 533 | ``None``, it will disable the callback function and grey out the menu item. If `key` is a string, set as the 534 | key shortcut. If it is ``None``, no adjustment will be made to the current key shortcut. 535 | 536 | .. versionchanged:: 0.2.0 537 | Allowed passing ``None`` as both `callback` and `key`. Additionally, passing a `key` that is neither a 538 | string nor ``None`` will result in a standard ``TypeError`` rather than various, uninformative `PyObjC` 539 | internal errors depending on the object. 540 | 541 | :param callback: the function to be called when the user clicks on this menu item. 542 | :param key: the key shortcut to click this menu item. 543 | """ 544 | _internal.require_string_or_none(key) 545 | if key is not None: 546 | self._menuitem.setKeyEquivalent_(key) 547 | NSApp._ns_to_py_and_callback[self._menuitem] = self, callback 548 | self._menuitem.setAction_('callback:' if callback is not None else None) 549 | 550 | @property 551 | def callback(self): 552 | """Return the current callback function. 553 | 554 | .. versionadded:: 0.2.0 555 | 556 | """ 557 | return NSApp._ns_to_py_and_callback[self._menuitem][1] 558 | 559 | @property 560 | def key(self): 561 | """The key shortcut to click this menu item. 562 | 563 | .. versionadded:: 0.2.0 564 | 565 | """ 566 | return self._menuitem.keyEquivalent() 567 | 568 | 569 | class SliderMenuItem(object): 570 | """Represents a slider menu item within the application's menu. 571 | 572 | .. versionadded:: 0.3.0 573 | 574 | :param value: a number for the current position of the slider. 575 | :param min_value: a number for the minimum position to which a slider can be moved. 576 | :param max_value: a number for the maximum position to which a slider can be moved. 577 | :param callback: the function serving as callback for when a slide event occurs on this menu item. 578 | :param dimensions: a sequence of numbers whose length is two, specifying the dimensions of the slider. 579 | """ 580 | 581 | def __init__(self, value=50, min_value=0, max_value=100, callback=None, dimensions=(180, 15)): 582 | self._slider = NSSlider.alloc().init() 583 | self._slider.setMinValue_(min_value) 584 | self._slider.setMaxValue_(max_value) 585 | self._slider.setDoubleValue_(value) 586 | self._slider.setFrameSize_(NSSize(*dimensions)) 587 | self._slider.setTarget_(NSApp) 588 | self._menuitem = NSMenuItem.alloc().init() 589 | self._menuitem.setTarget_(NSApp) 590 | self._menuitem.setView_(self._slider) 591 | self.set_callback(callback) 592 | 593 | def __repr__(self): 594 | return '<{0}: [value: {1}; callback: {2}]>'.format( 595 | type(self).__name__, 596 | self.value, 597 | repr(self.callback) 598 | ) 599 | 600 | def set_callback(self, callback): 601 | """Set the function serving as callback for when a slide event occurs on this menu item. 602 | 603 | :param callback: the function to be called when the user drags the marker on the slider. 604 | """ 605 | NSApp._ns_to_py_and_callback[self._slider] = self, callback 606 | self._slider.setAction_('callback:' if callback is not None else None) 607 | 608 | @property 609 | def callback(self): 610 | return NSApp._ns_to_py_and_callback[self._slider][1] 611 | 612 | @property 613 | def value(self): 614 | """The current position of the slider.""" 615 | return self._slider.doubleValue() 616 | 617 | @value.setter 618 | def value(self, new_value): 619 | self._slider.setDoubleValue_(new_value) 620 | 621 | 622 | class SeparatorMenuItem(object): 623 | """Visual separator between :class:`rumps.MenuItem` objects in the application menu.""" 624 | def __init__(self): 625 | self._menuitem = NSMenuItem.separatorItem() 626 | 627 | 628 | class Timer(object): 629 | """ 630 | Python abstraction of an Objective-C event timer in a new thread for application. Controls the callback function, 631 | interval, and starting/stopping the run loop. 632 | 633 | .. versionchanged:: 0.2.0 634 | Method `__call__` removed. 635 | 636 | :param callback: Function that should be called every `interval` seconds. It will be passed this 637 | :class:`rumps.Timer` object as its only parameter. 638 | :param interval: The time in seconds to wait before calling the `callback` function. 639 | """ 640 | def __init__(self, callback, interval): 641 | self.set_callback(callback) 642 | self._interval = interval 643 | self._status = False 644 | 645 | def __repr__(self): 646 | return ('<{0}: [callback: {1}; interval: {2}; ' 647 | 'status: {3}]>').format(type(self).__name__, repr(getattr(self, '*callback').__name__), 648 | self._interval, 'ON' if self._status else 'OFF') 649 | 650 | @property 651 | def interval(self): 652 | """The time in seconds to wait before calling the :attr:`callback` function.""" 653 | return self._interval # self._nstimer.timeInterval() when active but could be inactive 654 | 655 | @interval.setter 656 | def interval(self, new_interval): 657 | if self._status: 658 | if abs(self._nsdate.timeIntervalSinceNow()) >= self._nstimer.timeInterval(): 659 | self.stop() 660 | self._interval = new_interval 661 | self.start() 662 | else: 663 | self._interval = new_interval 664 | 665 | @property 666 | def callback(self): 667 | """The current function specified as the callback.""" 668 | return getattr(self, '*callback') 669 | 670 | def is_alive(self): 671 | """Whether the timer thread loop is currently running.""" 672 | return self._status 673 | 674 | def start(self): 675 | """Start the timer thread loop.""" 676 | if not self._status: 677 | self._nsdate = NSDate.date() 678 | self._nstimer = NSTimer.alloc().initWithFireDate_interval_target_selector_userInfo_repeats_( 679 | self._nsdate, self._interval, self, 'callback:', None, True) 680 | NSRunLoop.currentRunLoop().addTimer_forMode_(self._nstimer, NSDefaultRunLoopMode) 681 | _TIMERS[self] = None 682 | self._status = True 683 | 684 | def stop(self): 685 | """Stop the timer thread loop.""" 686 | if self._status: 687 | self._nstimer.invalidate() 688 | del self._nstimer 689 | del self._nsdate 690 | self._status = False 691 | 692 | def set_callback(self, callback): 693 | """Set the function that should be called every :attr:`interval` seconds. It will be passed this 694 | :class:`rumps.Timer` object as its only parameter. 695 | """ 696 | setattr(self, '*callback', callback) 697 | 698 | def callback_(self, _): 699 | _log(self) 700 | try: 701 | return _internal.call_as_function_or_method(getattr(self, '*callback'), self) 702 | except Exception: 703 | traceback.print_exc() 704 | 705 | 706 | class Window(object): 707 | """Generate a window to consume user input in the form of both text and button clicked. 708 | 709 | .. versionchanged:: 0.2.0 710 | Providing a `cancel` string will set the button text rather than only using text "Cancel". `message` is no 711 | longer a required parameter. 712 | 713 | .. versionchanged:: 0.3.0 714 | Add `secure` text input field functionality. 715 | 716 | :param message: the text positioned below the `title` in smaller font. If not a string, will use the string 717 | representation of the object. 718 | :param title: the text positioned at the top of the window in larger font. If not a string, will use the string 719 | representation of the object. 720 | :param default_text: the text within the editable textbox. If not a string, will use the string representation of 721 | the object. 722 | :param ok: the text for the "ok" button. Must be either a string or ``None``. If ``None``, a default 723 | localized button title will be used. 724 | :param cancel: the text for the "cancel" button. If a string, the button will have that text. If `cancel` 725 | evaluates to ``True``, will create a button with text "Cancel". Otherwise, this button will not be 726 | created. 727 | :param dimensions: the size of the editable textbox. Must be sequence with a length of 2. 728 | :param secure: should the text field be secured or not. With ``True`` the window can be used for passwords. 729 | """ 730 | 731 | def __init__(self, message='', title='', default_text='', ok=None, cancel=None, dimensions=(320, 160), 732 | secure=False): 733 | message = text_type(message) 734 | message = message.replace('%', '%%') 735 | title = text_type(title) 736 | 737 | self._cancel = bool(cancel) 738 | self._icon = None 739 | 740 | _internal.require_string_or_none(ok) 741 | if not isinstance(cancel, string_types): 742 | cancel = 'Cancel' if cancel else None 743 | 744 | self._alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_( 745 | title, ok, cancel, None, message) 746 | self._alert.setAlertStyle_(0) # informational style 747 | 748 | if secure: 749 | self._textfield = NSSecureTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) 750 | else: 751 | self._textfield = NSTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *dimensions)) 752 | self._textfield.setSelectable_(True) 753 | self._alert.setAccessoryView_(self._textfield) 754 | 755 | self.default_text = default_text 756 | 757 | @property 758 | def title(self): 759 | """The text positioned at the top of the window in larger font. If not a string, will use the string 760 | representation of the object. 761 | """ 762 | return self._alert.messageText() 763 | 764 | @title.setter 765 | def title(self, new_title): 766 | new_title = text_type(new_title) 767 | self._alert.setMessageText_(new_title) 768 | 769 | @property 770 | def message(self): 771 | """The text positioned below the :attr:`title` in smaller font. If not a string, will use the string 772 | representation of the object. 773 | """ 774 | return self._alert.informativeText() 775 | 776 | @message.setter 777 | def message(self, new_message): 778 | new_message = text_type(new_message) 779 | self._alert.setInformativeText_(new_message) 780 | 781 | @property 782 | def default_text(self): 783 | """The text within the editable textbox. An example would be 784 | 785 | "Type your message here." 786 | 787 | If not a string, will use the string representation of the object. 788 | """ 789 | return self._default_text 790 | 791 | @default_text.setter 792 | def default_text(self, new_text): 793 | new_text = text_type(new_text) 794 | self._default_text = new_text 795 | self._textfield.setStringValue_(new_text) 796 | 797 | @property 798 | def icon(self): 799 | """The path to an image displayed for this window. If set to ``None``, will default to the icon for the 800 | application using :attr:`rumps.App.icon`. 801 | 802 | .. versionchanged:: 0.2.0 803 | If the icon is set to an image then changed to ``None``, it will correctly be changed to the application 804 | icon. 805 | 806 | """ 807 | return self._icon 808 | 809 | @icon.setter 810 | def icon(self, icon_path): 811 | new_icon = _nsimage_from_file(icon_path) if icon_path is not None else None 812 | self._icon = icon_path 813 | self._alert.setIcon_(new_icon) 814 | 815 | def add_button(self, name): 816 | """Create a new button. 817 | 818 | .. versionchanged:: 0.2.0 819 | The `name` parameter is required to be a string. 820 | 821 | :param name: the text for a new button. Must be a string. 822 | """ 823 | _internal.require_string(name) 824 | self._alert.addButtonWithTitle_(name) 825 | 826 | def add_buttons(self, iterable=None, *args): 827 | """Create multiple new buttons. 828 | 829 | .. versionchanged:: 0.2.0 830 | Since each element is passed to :meth:`rumps.Window.add_button`, they must be strings. 831 | 832 | """ 833 | if iterable is None: 834 | return 835 | if isinstance(iterable, string_types): 836 | self.add_button(iterable) 837 | else: 838 | for ele in iterable: 839 | self.add_button(ele) 840 | for arg in args: 841 | self.add_button(arg) 842 | 843 | def run(self): 844 | """Launch the window. :class:`rumps.Window` instances can be reused to retrieve user input as many times as 845 | needed. 846 | 847 | :return: a :class:`rumps.rumps.Response` object that contains the text and the button clicked as an integer. 848 | """ 849 | _log(self) 850 | self._alert.window().setAppearance_(AppKit.NSAppearance.currentAppearance()) 851 | clicked = self._alert.runModal() % 999 852 | if clicked > 2 and self._cancel: 853 | clicked -= 1 854 | self._textfield.validateEditing() 855 | text = self._textfield.stringValue() 856 | self.default_text = self._default_text # reset default text 857 | return Response(clicked, text) 858 | 859 | 860 | class Response(object): 861 | """Holds information from user interaction with a :class:`rumps.Window` after it has been closed.""" 862 | 863 | def __init__(self, clicked, text): 864 | self._clicked = clicked 865 | self._text = text 866 | 867 | def __repr__(self): 868 | shortened_text = self._text if len(self._text) < 21 else self._text[:17] + '...' 869 | return '<{0}: [clicked: {1}, text: {2}]>'.format(type(self).__name__, self._clicked, repr(shortened_text)) 870 | 871 | @property 872 | def clicked(self): 873 | """Return a number representing the button pressed by the user. 874 | 875 | The "ok" button will return ``1`` and the "cancel" button will return ``0``. This makes it convenient to write 876 | a conditional like, 877 | 878 | .. code-block:: python 879 | 880 | if response.clicked: 881 | do_thing_for_ok_pressed() 882 | else: 883 | do_thing_for_cancel_pressed() 884 | 885 | Where `response` is an instance of :class:`rumps.rumps.Response`. 886 | 887 | Additional buttons added using methods :meth:`rumps.Window.add_button` and :meth:`rumps.Window.add_buttons` 888 | will return ``2``, ``3``, ... in the order they were added. 889 | """ 890 | return self._clicked 891 | 892 | @property 893 | def text(self): 894 | """Return the text collected from the user.""" 895 | return self._text 896 | 897 | 898 | class NSApp(NSObject): 899 | """Objective-C delegate class for NSApplication. Don't instantiate - use App instead.""" 900 | 901 | _ns_to_py_and_callback = {} 902 | 903 | def userNotificationCenter_didActivateNotification_(self, notification_center, notification): 904 | notifications._clicked(notification_center, notification) 905 | 906 | def initializeStatusBar(self): 907 | self.nsstatusitem = NSStatusBar.systemStatusBar().statusItemWithLength_(-1) # variable dimensions 908 | self.nsstatusitem.setHighlightMode_(True) 909 | 910 | self.setStatusBarIcon() 911 | self.setStatusBarTitle() 912 | 913 | mainmenu = self._app['_menu'] 914 | quit_button = self._app['_quit_button'] 915 | if quit_button is not None: 916 | quit_button.set_callback(quit_application) 917 | mainmenu.add(quit_button) 918 | else: 919 | _log('WARNING: the default quit button is disabled. To exit the application gracefully, another button ' 920 | 'should have a callback of quit_application or call it indirectly.') 921 | self.nsstatusitem.setMenu_(mainmenu._menu) # mainmenu of our status bar spot (_menu attribute is NSMenu) 922 | 923 | def setStatusBarTitle(self): 924 | self.nsstatusitem.setTitle_(self._app['_title']) 925 | self.fallbackOnName() 926 | 927 | def setStatusBarIcon(self): 928 | self.nsstatusitem.setImage_(self._app['_icon_nsimage']) 929 | self.fallbackOnName() 930 | 931 | def fallbackOnName(self): 932 | if not (self.nsstatusitem.title() or self.nsstatusitem.image()): 933 | self.nsstatusitem.setTitle_(self._app['_name']) 934 | 935 | def applicationDidFinishLaunching_(self, notification): 936 | workspace = NSWorkspace.sharedWorkspace() 937 | notificationCenter = workspace.notificationCenter() 938 | notificationCenter.addObserver_selector_name_object_( 939 | self, 940 | self.receiveSleepNotification_, 941 | NSWorkspaceWillSleepNotification, 942 | None 943 | ) 944 | notificationCenter.addObserver_selector_name_object_( 945 | self, 946 | self.receiveWakeNotification_, 947 | NSWorkspaceDidWakeNotification, 948 | None 949 | ) 950 | 951 | def receiveSleepNotification_(self, notification): 952 | _log("receiveSleepNotification") 953 | app = getattr(App, '*app_instance') 954 | return app.sleep() 955 | 956 | def receiveWakeNotification_(self, notification): 957 | _log("receiveWakeNotification") 958 | app = getattr(App, '*app_instance') 959 | return app.wake() 960 | 961 | @classmethod 962 | def callback_(cls, nsmenuitem): 963 | self, callback = cls._ns_to_py_and_callback[nsmenuitem] 964 | _log(self) 965 | try: 966 | return _internal.call_as_function_or_method(callback, self) 967 | except Exception: 968 | traceback.print_exc() 969 | 970 | 971 | class App(object): 972 | """Represents the statusbar application. 973 | 974 | Provides a simple and pythonic interface for all those long and ugly `PyObjC` calls. :class:`rumps.App` may be 975 | subclassed so that the application logic can be encapsulated within a class. Alternatively, an `App` can be 976 | instantiated and the various callback functions can exist at module level. 977 | 978 | .. versionchanged:: 0.2.0 979 | `name` parameter must be a string and `title` must be either a string or ``None``. `quit_button` parameter added. 980 | 981 | :param name: the name of the application. 982 | :param title: text that will be displayed for the application in the statusbar. 983 | :param icon: file path to the icon that will be displayed for the application in the statusbar. 984 | :param menu: an iterable of Python objects or pairs of objects that will be converted into the main menu for the 985 | application. Parsing is implemented by calling :meth:`rumps.MenuItem.update`. 986 | :param quit_button: the quit application menu item within the main menu. If ``None``, the default quit button will 987 | not be added. 988 | """ 989 | 990 | # NOTE: 991 | # Serves as a setup class for NSApp since Objective-C classes shouldn't be instantiated normally. 992 | # This is the most user-friendly way. 993 | 994 | #: A serializer for notification data. The default is pickle. 995 | serializer = pickle 996 | 997 | def __init__(self, name, title=None, icon=None, template=None, menu=None, quit_button='Quit'): 998 | _internal.require_string(name) 999 | self._name = name 1000 | self._icon = self._icon_nsimage = self._title = None 1001 | self._template = template 1002 | self.icon = icon 1003 | self.title = title 1004 | self.quit_button = quit_button 1005 | self._menu = Menu() 1006 | if menu is not None: 1007 | self.menu = menu 1008 | self._application_support = application_support(self._name) 1009 | 1010 | # Properties 1011 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1012 | 1013 | @property 1014 | def name(self): 1015 | """The name of the application. Determines the application support folder name. Will also serve as the title 1016 | text of the application if :attr:`title` is not set. 1017 | """ 1018 | return self._name 1019 | 1020 | @property 1021 | def title(self): 1022 | """The text that will be displayed for the application in the statusbar. Can be ``None`` in which case the icon 1023 | will be used or, if there is no icon set the application text will fallback on the application :attr:`name`. 1024 | 1025 | .. versionchanged:: 0.2.0 1026 | If the title is set then changed to ``None``, it will correctly be removed. Must be either a string or 1027 | ``None``. 1028 | 1029 | """ 1030 | return self._title 1031 | 1032 | @title.setter 1033 | def title(self, title): 1034 | _internal.require_string_or_none(title) 1035 | self._title = title 1036 | try: 1037 | self._nsapp.setStatusBarTitle() 1038 | except AttributeError: 1039 | pass 1040 | 1041 | @property 1042 | def icon(self): 1043 | """A path to an image representing the icon that will be displayed for the application in the statusbar. 1044 | Can be ``None`` in which case the text from :attr:`title` will be used. 1045 | 1046 | .. versionchanged:: 0.2.0 1047 | If the icon is set to an image then changed to ``None``, it will correctly be removed. 1048 | 1049 | """ 1050 | return self._icon 1051 | 1052 | @icon.setter 1053 | def icon(self, icon_path): 1054 | new_icon = _nsimage_from_file(icon_path, template=self._template) if icon_path is not None else None 1055 | self._icon = icon_path 1056 | self._icon_nsimage = new_icon 1057 | try: 1058 | self._nsapp.setStatusBarIcon() 1059 | except AttributeError: 1060 | pass 1061 | 1062 | @property 1063 | def template(self): 1064 | """Template mode for an icon. If set to ``None``, the current icon (if any) is displayed as a color icon. 1065 | If set to ``True``, template mode is enabled and the icon will be displayed correctly in dark menu bar mode. 1066 | """ 1067 | return self._template 1068 | 1069 | @template.setter 1070 | def template(self, template_mode): 1071 | self._template = template_mode 1072 | # resetting the icon to apply template setting 1073 | self.icon = self._icon 1074 | 1075 | @property 1076 | def menu(self): 1077 | """Represents the main menu of the statusbar application. Setting `menu` works by calling 1078 | :meth:`rumps.MenuItem.update`. 1079 | """ 1080 | return self._menu 1081 | 1082 | @menu.setter 1083 | def menu(self, iterable): 1084 | self._menu.update(iterable) 1085 | 1086 | @property 1087 | def quit_button(self): 1088 | """The quit application menu item within the main menu. This is a special :class:`rumps.MenuItem` object that 1089 | will both replace any function callback with :func:`rumps.quit_application` and add itself to the end of the 1090 | main menu when :meth:`rumps.App.run` is called. If set to ``None``, the default quit button will not be added. 1091 | 1092 | .. warning:: 1093 | If set to ``None``, some other menu item should call :func:`rumps.quit_application` so that the 1094 | application can exit gracefully. 1095 | 1096 | .. versionadded:: 0.2.0 1097 | 1098 | """ 1099 | return self._quit_button 1100 | 1101 | @quit_button.setter 1102 | def quit_button(self, quit_text): 1103 | if quit_text is None: 1104 | self._quit_button = None 1105 | else: 1106 | self._quit_button = MenuItem(quit_text) 1107 | 1108 | # Open files in application support folder 1109 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1110 | 1111 | def open(self, *args): 1112 | """Open a file within the application support folder for this application. 1113 | 1114 | .. code-block:: python 1115 | 1116 | app = App('Cool App') 1117 | with app.open('data.json') as f: 1118 | pass 1119 | 1120 | Is a shortcut for, 1121 | 1122 | .. code-block:: python 1123 | 1124 | app = App('Cool App') 1125 | filename = os.path.join(application_support(app.name), 'data.json') 1126 | with open(filename) as f: 1127 | pass 1128 | 1129 | """ 1130 | return open(os.path.join(self._application_support, args[0]), *args[1:]) 1131 | 1132 | # Run the application 1133 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1134 | 1135 | def run(self, **options): 1136 | """Performs various setup tasks including creating the underlying Objective-C application, starting the timers, 1137 | and registering callback functions for click events. Then starts the application run loop. 1138 | 1139 | .. versionchanged:: 0.2.1 1140 | Accepts `debug` keyword argument. 1141 | 1142 | :param debug: determines if application should log information useful for debugging. Same effect as calling 1143 | :func:`rumps.debug_mode`. 1144 | 1145 | """ 1146 | dont_change = object() 1147 | debug = options.get('debug', dont_change) 1148 | if debug is not dont_change: 1149 | debug_mode(debug) 1150 | 1151 | nsapplication = NSApplication.sharedApplication() 1152 | nsapplication.activateIgnoringOtherApps_(True) # NSAlerts in front 1153 | self._nsapp = NSApp.alloc().init() 1154 | self._nsapp._app = self.__dict__ # allow for dynamic modification based on this App instance 1155 | nsapplication.setDelegate_(self._nsapp) 1156 | notifications._init_nsapp(self._nsapp) 1157 | 1158 | setattr(App, '*app_instance', self) # class level ref to running instance (for passing self to App subclasses) 1159 | t = b = None 1160 | for t in getattr(timer, '*timers', []): 1161 | t.start() 1162 | for b in getattr(clicked, '*buttons', []): 1163 | b(self) # we waited on registering clicks so we could pass self to access _menu attribute 1164 | del t, b 1165 | 1166 | self._nsapp.initializeStatusBar() 1167 | 1168 | AppHelper.installMachInterrupt() 1169 | AppHelper.runEventLoop() 1170 | --------------------------------------------------------------------------------