├── LICENSE
├── README.md
├── __init__.py
├── notification.kv
├── notification.py
├── screenshot.png
├── screenshot2.png
└── utils.py
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Kivy Garden
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # garden.notification
2 | :name_badge: A floating popup-like notification
3 |
4 |
5 |
6 | This widget provides a browser-like
7 |
8 | notification with all of the basic Kivy features available. You can use it
9 | either in its default state, where is only basic title and message with some of
10 | the color configuration, or you can input your own message layout in kv
11 | language. The message string is available through ``app.message`` property.
12 | For more such properties, read the code.
13 |
14 | ## Features:
15 |
16 | - message scrolls
17 | - available icon option
18 | - title will be shortened if too long
19 | - callback after the notification disappears
20 | - stacking multiple notifs on top of each other
21 | - markup turned on in title and message by default
22 | - kv language input
23 | - positioning and stacking relatively to the taskbar (Windows)
24 |
25 | ## TODO:
26 |
27 | - Ubuntu's Unity & OSX window hide implementation
28 | (needed for hiding the window another python interpreter creates)
29 | - grab window focus back - each notification steals focus from the main window
30 | (linux & OSX)
31 | - forbid notification to print Kivy initialisation logs to output
32 | unless asked for it
33 | - positioning and stacking relatively to the taskbar (linux & OSX)
34 |
35 | ## Example:
36 |
37 | ```
38 | from kivy.app import App
39 | from functools import partial
40 | from kivy.uix.button import Button
41 | from kivy.resources import resource_find
42 | from kivy.garden.notification import Notification
43 |
44 |
45 | class Notifier(Button):
46 | def __init__(self, **kwargs):
47 | super(Notifier, self).__init__(**kwargs)
48 | self.bind(on_release=self.show_notification)
49 |
50 | def printer(self, *args):
51 | print(args)
52 |
53 | def show_notification(self, *args):
54 | # open default notification
55 | Notification().open(
56 | title='Kivy Notification',
57 | message='Hello from the other side?',
58 | timeout=5,
59 | icon=resource_find('data/logo/kivy-icon-128.png'),
60 | on_stop=partial(self.printer, 'Notification closed')
61 | )
62 |
63 | # open notification with layout in kv
64 | Notification().open(
65 | title='Kivy Notification',
66 | message="I'm a Button!",
67 | kv="Button:\n text: app.message"
68 | )
69 |
70 |
71 | class KivyNotification(App):
72 | def build(self):
73 | return Notifier()
74 |
75 |
76 | if __name__ == '__main__':
77 | KivyNotification().run()
78 | ```
79 |
80 | ## Taskbar awareness
81 |
82 | The widget is aware of the system taskbar's position and size, therefore it
83 | can position itself correctly and stack new notifications in the correct
84 | direction from the taskbar.
85 |
86 |
87 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | '''
2 | # Copyright (c) 2017 Peter Badida (KeyWeeUsr)
3 |
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 |
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 |
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 |
22 | .. |notification| replace:: notification
23 | .. _notification: https://developer.mozilla.org/en-US/docs/Web/API/notification
24 |
25 | This widget provides a browser-like |notification|_ with all of the Kivy
26 | features available. You can use it either in its default state, where is
27 | only basic title and message with some of the color configuration, or
28 | you can input your own message layout in kv language. The message string
29 | is available through ``app.message`` property.
30 |
31 | Features:
32 | - message scrolls
33 | - available icon option
34 | - title will be shortened if too long
35 | - callback after the notification disappears
36 | - stacking multiple notifs on top of each other
37 | - markup turned on in title and message by default
38 | - kv language input
39 | - positioning and stacking relatively to the taskbar (Windows)
40 |
41 | TODO:
42 | - Ubuntu's Unity & OSX window hide implementation
43 | (needed for hiding the window another python interpreter creates)
44 | - grab window focus back - each notification steals focus from the main window
45 | (linux & OSX)
46 | - forbid notification to print Kivy initialisation logs to output
47 | unless asked for it
48 | - positioning and stacking relatively to the taskbar (linux & OSX)
49 | '''
50 |
51 | import os
52 | import sys
53 | import threading
54 | from subprocess import Popen
55 | from os.path import dirname, abspath, join
56 | from kivy.app import App
57 | from kivy.garden.notification import utils
58 |
59 |
60 | class Notification(object):
61 | def open(self, title='Title', message='Message', icon=None,
62 | width=300, height=100, offset_x=10, offset_y=10,
63 | timeout=15, timeout_close=True, color=None, line_color=None,
64 | background_color=None, parent_title=None, stack=True,
65 | stack_offset_x=10, stack_offset_y=10, on_stop=None, kv=None):
66 |
67 | app = App.get_running_app()
68 | taskbar = utils.taskbar()['pos']
69 |
70 | # set default colors
71 | if not color:
72 | color = (0, 0, 0, 1)
73 | if not line_color:
74 | line_color = (.2, .64, .81, .5)
75 | if not background_color:
76 | background_color = (.92, .92, .92, 1)
77 |
78 | # stacking on top of each other
79 | if stack and not hasattr(app, '_gardennotification_count'):
80 | app._gardennotification_count = 1
81 | elif stack and hasattr(app, '_gardennotification_count'):
82 | inc = app._gardennotification_count
83 | if taskbar in ('top', 'bottom'):
84 | app._gardennotification_count += 1
85 | offset_y += (height + stack_offset_y) * inc
86 | else:
87 | app._gardennotification_count += 1
88 | offset_x += (width + stack_offset_x) * inc
89 | else:
90 | app._gardennotification_count = 0
91 |
92 | # Window name necessary for win32 api
93 | if not parent_title:
94 | parent_title = app.get_application_name()
95 |
96 | self.path = dirname(abspath(__file__))
97 |
98 | # subprocess callback after the App dies
99 | def popen_back(callback, on_stop, args):
100 | # str(dict) is used for passing arguments to the child notification
101 | # it might trigger an issue on some OS if a length of X characters
102 | # is exceeded in the called console string, e.g. too long path
103 | # to the interpreter executable, notification file, etc.
104 | # https://support.microsoft.com/en-us/help/830473
105 | os.environ['KIVY_NO_FILELOG'] = '1'
106 | p = Popen([
107 | sys.executable,
108 | join(self.path, 'notification.py'),
109 | args
110 | ])
111 | p.wait()
112 | callback()
113 | if on_stop:
114 | on_stop()
115 | return
116 |
117 | # open Popen in Thread to wait for exit status
118 | # then decrement the stacking variable
119 | t = threading.Thread(
120 | target=popen_back, args=(
121 | self._decrement,
122 | on_stop,
123 | str({
124 | 'title': title,
125 | 'message': message,
126 | 'icon': icon,
127 | 'kv': kv,
128 | 'width': width,
129 | 'height': height,
130 | 'offset_x': offset_x,
131 | 'offset_y': offset_y,
132 | 'timeout': timeout,
133 | 'timeout_close': timeout_close,
134 | 'line_color': line_color,
135 | 'color': color,
136 | 'background_color': background_color,
137 | 'parent_title': parent_title,
138 | })))
139 | t.start()
140 |
141 | def _decrement(self):
142 | app = App.get_running_app()
143 | if hasattr(app, '_gardennotification_count'):
144 | app._gardennotification_count -= 1
145 |
--------------------------------------------------------------------------------
/notification.kv:
--------------------------------------------------------------------------------
1 | #:import dp kivy.metrics.dp
2 | #:import stopTouchApp kivy.base.stopTouchApp
3 |
4 | BoxLayout:
5 | canvas:
6 | Color:
7 | rgba: app.background_color
8 | Rectangle:
9 | size: self.size
10 | pos: self.pos
11 | orientation: 'vertical'
12 |
13 | GridLayout:
14 | cols: 2
15 | size_hint_y: 0.2
16 | canvas:
17 | Color:
18 | rgba: app.line_color
19 |
20 | Line:
21 | points:
22 | [self.pos[0], self.pos[1] + dp(1),
23 | self.pos[0] + self.width,
24 | self.pos[1] + dp(1)]
25 |
26 | Label:
27 | color: app.color
28 | text_size:
29 | [self.width - dp(10),
30 | self.height]
31 | text: app.notif_title
32 | halign: 'left'
33 | markup: True
34 | shorten: True
35 | shorten_from: 'right'
36 |
37 | Button:
38 | background_normal:
39 | 'atlas://data/images/defaulttheme/bubble_btn'
40 | color: app.color
41 | size_hint_x: None
42 | width: self.height
43 | text: 'X'
44 | on_release: stopTouchApp()
45 |
46 | GridLayout:
47 | id: container
48 | cols: 2
49 | size_hint_y: 0.8
50 |
51 | FloatLayout:
52 | size_hint_x: 0.3
53 | Image:
54 | source: app.notif_icon
55 | size_hint: (None, None)
56 | width: dp(64)
57 | height: dp(64)
58 | pos_hint: {'center': (0.5, 0.5)}
59 |
60 | ScrollView:
61 | Label:
62 | color: app.color
63 | text: app.message
64 | text_size:
65 | [self.width,
66 | self.height]
67 | size_hint_y: None
68 | padding: (5, 5)
69 | text_size: self.width, None
70 | height: self.texture_size[1]
71 | markup: True
72 |
--------------------------------------------------------------------------------
/notification.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import traceback
4 | from ast import literal_eval
5 | from kivy.utils import platform
6 | from subprocess import check_output
7 | from os.path import dirname, abspath, join
8 | from kivy.garden.notification import utils
9 |
10 | # platform dependent imports
11 | if platform == 'win':
12 | import ctypes
13 | import win32gui
14 | from win32con import (
15 | SW_HIDE, SW_SHOW,
16 | GWL_EXSTYLE, WS_EX_TOOLWINDOW
17 | )
18 |
19 | # get platform dependent values
20 | RESOLUTION = utils.sys_resolution()
21 | _TB = utils.taskbar()
22 |
23 | # get width or height only for appropriate sides
24 | TASKBAR = {
25 | 'width': _TB['width'] if _TB['pos'] in ('right', 'left') else 0,
26 | 'height': _TB['height'] if _TB['pos'] in ('bottom', 'top') else 0,
27 | 'pos': _TB['pos']
28 | }
29 |
30 | # convert dict passed as a str arg back
31 | KWARGS = literal_eval(sys.argv[1])
32 |
33 | # fetch values from Notification.open kwargs
34 | WIDTH = KWARGS['width']
35 | HEIGHT = KWARGS['height']
36 | OFFSET = {
37 | 'x': KWARGS['offset_x'],
38 | 'y': KWARGS['offset_y']
39 | }
40 |
41 | # set window from Notification.open arguments
42 | from kivy.config import Config
43 | Config.set('graphics', 'resizable', 0)
44 | Config.set('graphics', 'borderless', 1)
45 | Config.set('graphics', 'position', 'custom')
46 |
47 | Config.set('graphics', 'width', WIDTH)
48 | Config.set('graphics', 'height', HEIGHT)
49 |
50 | # position the notification window according to taskbar
51 | if TASKBAR['pos'] == 'top':
52 | Config.set(
53 | 'graphics', 'left',
54 | RESOLUTION['x'] - WIDTH - OFFSET['x'] - TASKBAR['width']
55 | )
56 | Config.set(
57 | 'graphics', 'top',
58 | TASKBAR['height'] + OFFSET['y']
59 | )
60 | elif TASKBAR['pos'] == 'left':
61 | Config.set(
62 | 'graphics', 'left',
63 | TASKBAR['width'] + OFFSET['x']
64 | )
65 | Config.set(
66 | 'graphics', 'top',
67 | RESOLUTION['y'] - HEIGHT - OFFSET['y'] - TASKBAR['height']
68 | )
69 | else:
70 | Config.set(
71 | 'graphics', 'left',
72 | RESOLUTION['x'] - WIDTH - OFFSET['x'] - TASKBAR['width']
73 | )
74 | Config.set(
75 | 'graphics', 'top',
76 | RESOLUTION['y'] - HEIGHT - OFFSET['y'] - TASKBAR['height']
77 | )
78 |
79 | from kivy.app import App
80 | from kivy.clock import Clock
81 | from kivy.lang import Builder
82 | from kivy.logger import Logger
83 | from kivy.properties import StringProperty, ListProperty
84 |
85 |
86 | class Notification(App):
87 | title = StringProperty(KWARGS['title'].replace(' ', '') + str(os.getpid()))
88 | notif_title = StringProperty(KWARGS['title'])
89 | message = StringProperty(KWARGS['message'])
90 | notif_icon = StringProperty(KWARGS['icon'])
91 | background_color = ListProperty(KWARGS['background_color'])
92 | line_color = ListProperty(KWARGS['line_color'])
93 | color = ListProperty(KWARGS['color'])
94 |
95 | def build(self):
96 | if not self.notif_icon:
97 | self.notif_icon = self.get_application_icon()
98 | Clock.schedule_once(self._hide_window, 0)
99 | if KWARGS['timeout_close']:
100 | Clock.schedule_once(self.stop, KWARGS['timeout'])
101 | if KWARGS['kv']:
102 | path = dirname(abspath(__file__))
103 | kv = Builder.load_file(join(path, 'notification.kv'))
104 | kv.ids.container.clear_widgets()
105 | kv.ids.container.add_widget(
106 | Builder.load_string(KWARGS['kv'])
107 | )
108 | return kv
109 |
110 | def _hide_window(self, *args):
111 | if platform == 'win':
112 | self._hide_w32_window()
113 | elif platform in ('linux', 'macosx'):
114 | self._hide_x11_window()
115 |
116 | def _hide_w32_window(self):
117 | try:
118 | w32win = win32gui.FindWindow(None, self.title)
119 | win32gui.ShowWindow(w32win, SW_HIDE)
120 | win32gui.SetWindowLong(
121 | w32win,
122 | GWL_EXSTYLE,
123 | win32gui.GetWindowLong(
124 | w32win, GWL_EXSTYLE) | WS_EX_TOOLWINDOW
125 | )
126 | win32gui.ShowWindow(w32win, SW_SHOW)
127 | self._return_focus_w32()
128 | except Exception:
129 | tb = traceback.format_exc()
130 | Logger.error(
131 | 'Notification: An error occured in {}\n'
132 | '{}'.format(self.title, tb)
133 | )
134 |
135 | def _hide_x11_window(self):
136 | try:
137 | # Ubuntu's Unity for some reason ignores this if there are
138 | # multiple windows stacked to a single icon on taskbar.
139 | # Unity probably calls the same thing to stack the running
140 | # programs to a single icon, which makes this command worthless.
141 | x11_command = [
142 | 'xprop', '-name', '{}'.format(self.title), '-f',
143 | '_NET_WM_STATE', '32a', '-set', '_NET_WM_STATE',
144 | '_NET_WM_STATE_SKIP_TASKBAR'
145 | ]
146 | check_output(x11_command)
147 | except Exception as e:
148 | tb = traceback.format_exc()
149 | Logger.error(
150 | 'Notification: An error occured in {}\n'
151 | '{}'.format(self.title, tb)
152 | )
153 |
154 | def _return_focus_w32(self):
155 | w32win = win32gui.FindWindow(None, KWARGS['parent_title'])
156 | win32gui.SetForegroundWindow(w32win)
157 |
158 |
159 | if __name__ == '__main__':
160 | Notification().run()
161 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kivy-garden/garden.notification/576e77e14312adf3235271735f9edd2dded5d8cb/screenshot.png
--------------------------------------------------------------------------------
/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kivy-garden/garden.notification/576e77e14312adf3235271735f9edd2dded5d8cb/screenshot2.png
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | from kivy.utils import platform
2 | from subprocess import check_output
3 |
4 | # platform dependent imports
5 | if platform == 'win':
6 | import ctypes
7 | import win32gui
8 | from win32con import (
9 | SW_HIDE, SW_SHOW,
10 | GWL_EXSTYLE, WS_EX_TOOLWINDOW
11 | )
12 |
13 |
14 | def sys_resolution():
15 | # waiting for https://github.com/kivy/plyer/pull/201
16 | if platform == 'win':
17 | u32 = ctypes.windll.user32
18 | RESOLUTION = {
19 | 'x': u32.GetSystemMetrics(0),
20 | 'y': u32.GetSystemMetrics(1)
21 | }
22 | elif platform == 'linux':
23 | o = check_output('xrandr')
24 | start = o.find('current') + 7
25 | end = o.find(', maximum')
26 | res = [int(n) for n in o[start:end].split('x')]
27 | RESOLUTION = {
28 | 'x': res[0],
29 | 'y': res[1]
30 | }
31 | elif platform == 'macosx':
32 | o = check_output(['system_profiler', 'SPDisplaysDataType'])
33 | start = o.find(b'Resolution: ')
34 | end = o.find(b'\n', start)
35 | o = o[start:end].strip().split(b' ')
36 | RESOLUTION = {
37 | 'x': int(o[1]),
38 | 'y': int(o[3])
39 | }
40 | else:
41 | raise NotImplementedError("Not a desktop platform!")
42 | return RESOLUTION
43 |
44 | def taskbar():
45 | if platform == 'win':
46 | # example:
47 | # right side x>0 y=0 (1474, 0, 1536, 864)
48 | # left side x=0 yR/2 ( 0, 837, 1536, 864)
51 |
52 | # get resolution to get taskbar position
53 | res = sys_resolution()
54 |
55 | # get taskbar rectangle
56 | handle = win32gui.FindWindow("Shell_traywnd", None)
57 | left, top, right, bottom = win32gui.GetWindowRect(handle)
58 |
59 | x = y = 0
60 | width, height = res
61 |
62 | if left:
63 | pos = 'right'
64 | x = left
65 | width = right - left
66 | elif right < res['y'] / 2.0:
67 | pos = 'left'
68 | width = right
69 | elif right > res['y'] / 2.0 and not top:
70 | pos = 'top'
71 | height = bottom
72 | elif right > res['y'] / 2.0 and top:
73 | pos = 'bottom'
74 | y = top
75 | height = bottom - top
76 | else:
77 | x, y, width, height, pos = (0, 0, 0, 0, '')
78 |
79 | return {
80 | 'x': x, 'y': y,
81 | 'width': width, 'height': height,
82 | 'pos': pos
83 | }
84 |
--------------------------------------------------------------------------------