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