├── 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 |
--------------------------------------------------------------------------------