├── gui_o_matic ├── gui │ ├── __init__.py │ ├── auto.py │ ├── unity.py │ ├── pil_bmp_fix.py │ ├── macosx.py │ ├── base.py │ ├── gtkbase.py │ └── winapi.py ├── __init__.py ├── __main__.py └── control │ └── __init__.py ├── stdeb.cfg ├── scripts ├── img │ ├── gt-splash.png │ ├── gt-wallpaper.png │ ├── x11-flipper.png │ ├── gt-normal-light.png │ ├── gt-shutdown-light.png │ ├── gt-startup-light.png │ ├── gt-working-light.png │ └── gt-attention-light.png ├── gui-test ├── xflipflop └── gui-test.py ├── CHANGES.txt ├── MANIFEST.in ├── update-version ├── setup.py ├── README.md ├── LICENSE.txt ├── distribute_setup.py └── PROTOCOL.md /gui_o_matic/gui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gui_o_matic/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.89' 2 | -------------------------------------------------------------------------------- /stdeb.cfg: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | Depends: python-gtk2 3 | Recommends: python-notify, python-appindicator 4 | -------------------------------------------------------------------------------- /scripts/img/gt-splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-splash.png -------------------------------------------------------------------------------- /scripts/img/gt-wallpaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-wallpaper.png -------------------------------------------------------------------------------- /scripts/img/x11-flipper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/x11-flipper.png -------------------------------------------------------------------------------- /scripts/img/gt-normal-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-normal-light.png -------------------------------------------------------------------------------- /scripts/img/gt-shutdown-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-shutdown-light.png -------------------------------------------------------------------------------- /scripts/img/gt-startup-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-startup-light.png -------------------------------------------------------------------------------- /scripts/img/gt-working-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-working-light.png -------------------------------------------------------------------------------- /scripts/img/gt-attention-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mailpile/gui-o-matic/HEAD/scripts/img/gt-attention-light.png -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | 6 | 0.1 - March 18, 2016 7 | ==================== 8 | * Initial release. 9 | -------------------------------------------------------------------------------- /gui_o_matic/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from gui_o_matic.control import GUIPipeControl 3 | 4 | GUIPipeControl(sys.stdin).bootstrap() 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include distribute_setup.py 2 | include *.md 3 | include *.txt 4 | recursive-include docs *.txt *.md 5 | recursive-include gui_o_matic *.py 6 | prune dist 7 | -------------------------------------------------------------------------------- /update-version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script updates the versions in setup.py and gui_o_matic/__init__.py 4 | # based on the length of our git commit log. 5 | # 6 | MAIN_VERSION=0.1 7 | 8 | VERSION=$MAIN_VERSION.$((1 + $(git log --pretty=oneline|wc -l))) 9 | 10 | perl -i -npe "s/^VERSION =.*/VERSION = '$VERSION'/m" setup.py 11 | 12 | perl -i -npe "s/^__version__ =.*/__version__ = '$VERSION'/m" \ 13 | gui_o_matic/__init__.py 14 | 15 | git add setup.py gui_o_matic/__init__.py 16 | git commit -m "This is version $VERSION" 17 | git tag -f v"$VERSION" 18 | -------------------------------------------------------------------------------- /gui_o_matic/gui/auto.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import importlib 3 | 4 | # Note: This is NOT dict, because order matters. 5 | # Some GUIs are better than others, and we want to try them first. 6 | # 7 | _registry = ( 8 | ('winapi', 'winapi'), 9 | ('macosx', 'macosx'), 10 | ('unity', 'unity'), 11 | ('gtk', 'gtkbase'), 12 | ) 13 | 14 | 15 | def _known_guis(): 16 | ''' 17 | List known guis. 18 | ''' 19 | return [gui for gui, lib in _registry] 20 | 21 | 22 | def _gui_libname(gui): 23 | ''' 24 | Convert a gui-name to a libname, assume well-formed if not in registry 25 | ''' 26 | try: 27 | return 'gui_o_matic.gui.{}'.format(dict(_registry)[gui]) 28 | except KeyError: 29 | return gui 30 | 31 | 32 | def AutoGUI(config, *args, **kwargs): 33 | """ 34 | Load and instanciate the best GUI available for this machine. 35 | """ 36 | for candidate in config.get('_prefer_gui', _known_guis()): 37 | try: 38 | impl = importlib.import_module(_gui_libname(candidate)) 39 | return impl.GUI( config, *args, **kwargs ) 40 | except ImportError: 41 | pass 42 | 43 | raise NotImplementedError("No working GUI found!") 44 | 45 | -------------------------------------------------------------------------------- /gui_o_matic/gui/unity.py: -------------------------------------------------------------------------------- 1 | # This is a general-purpose GUI which can be configured and controlled 2 | # using a very simple line-based (JSON) protocol. 3 | # 4 | import appindicator 5 | import gobject 6 | import gtk 7 | 8 | from gui_o_matic.gui.gtkbase import GtkBaseGUI 9 | 10 | 11 | class UnityGUI(GtkBaseGUI): 12 | _HAVE_INDICATOR = True 13 | _STATUS_MODES = { 14 | 'startup': appindicator.STATUS_ACTIVE, 15 | 'normal': appindicator.STATUS_ACTIVE, 16 | 'working': appindicator.STATUS_ACTIVE, 17 | 'attention': appindicator.STATUS_ATTENTION, 18 | 'shutdown': appindicator.STATUS_ATTENTION} 19 | 20 | def _indicator_setup(self): 21 | self.ind = appindicator.Indicator( 22 | self.config.get('app_name', 'gui-o-matic').lower() + "-indicator", 23 | # FIXME: Make these two configurable... 24 | "indicator-messages", appindicator.CATEGORY_COMMUNICATIONS) 25 | self.set_status('startup', _now=True) 26 | self.ind.set_menu(self.menu) 27 | 28 | def _indicator_set_icon(self, icon, do=gobject.idle_add): 29 | do(self.ind.set_icon, self._theme_image(icon)) 30 | 31 | def _indicator_set_status(self, status, do=gobject.idle_add): 32 | do(self.ind.set_status, 33 | self._STATUS_MODES.get(status, appindicator.STATUS_ATTENTION)) 34 | 35 | 36 | GUI = UnityGUI 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | import setuptools 3 | except ImportError: 4 | from distribute_setup import use_setuptools 5 | use_setuptools() 6 | 7 | from setuptools import setup, find_packages 8 | 9 | # Do not edit: The VERSION gets updated by the update-version script 10 | VERSION = '0.1.89' 11 | 12 | setup( 13 | name='gui_o_matic', 14 | version=VERSION, 15 | author='Mailpile ehf.', 16 | author_email='team@mailpile.is', 17 | url='https://github.com/mailpile/gui-o-matic/', 18 | packages=find_packages(), 19 | entry_points={ 20 | 'console_scripts': [ 21 | 'gui-o-matic = gui_o_matic.__main__:main' 22 | ]}, 23 | license='See LICENSE.txt', 24 | description='A cross-platform tool for minimal GUIs', 25 | long_description=open('README.md').read(), 26 | classifiers=[ 27 | 'Environment :: MacOS X', 28 | # TODO: 'Environment :: Win32 (MS Windows)', 29 | 'Environment :: X11 Applications', 30 | 'Intended Audience :: Developers', 31 | 'Intended Audience :: End Users/Desktop', 32 | 'Programming Language :: Python :: 2.7', 33 | 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 34 | 'Operating System :: MacOS :: MacOS X', 35 | # TODO: 'Operating System :: Microsoft :: Windows', 36 | 'Operating System :: POSIX', 37 | 'Topic :: Desktop Environment', 38 | 'Topic :: Software Development :: Libraries :: Python Modules', 39 | 'Topic :: Software Development :: User Interfaces', 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /gui_o_matic/gui/pil_bmp_fix.py: -------------------------------------------------------------------------------- 1 | 2 | ''' 3 | Modification from BmpImagePlugin.py, pillow project 4 | 5 | Change: 'RGBA' requires a different header, v3+ 6 | ''' 7 | 8 | from PIL.BmpImagePlugin import Image, ImageFile, o32, o16, o8 9 | 10 | import struct 11 | 12 | def bitmask( start, stop ): 13 | return(((1 << stop) - 1) >> start) << start 14 | 15 | BITMAPINFOHEADER = 40 16 | BITMAPINFOV4HEADER = 108 17 | 18 | 19 | 20 | SAVE = { 21 | "1": ("1", 1, 2), 22 | "L": ("L", 8, 256), 23 | "P": ("P", 8, 256), 24 | "RGB": ("BGR", 24, 0), 25 | "RGBA": ("BGRA", 32, 0), 26 | } 27 | 28 | 29 | def _save(im, fp, filename): 30 | try: 31 | rawmode, bits, colors = SAVE[im.mode] 32 | except KeyError: 33 | raise IOError("cannot write mode %s as BMP" % im.mode) 34 | 35 | info = im.encoderinfo 36 | 37 | dpi = info.get("dpi", (96, 96)) 38 | 39 | # 1 meter == 39.3701 inches 40 | ppm = tuple(map(lambda x: int(x * 39.3701), dpi)) 41 | 42 | stride = ((im.size[0]*bits+7)//8+3) & (~3) 43 | if rawmode == "BGRA": 44 | header = BITMAPINFOV4HEADER # for v4 header 45 | compression = 3 # BI_BITFIELDS 46 | else: 47 | header = BITMAPINFOHEADER # or 64 for OS/2 version 2 48 | compression = 0 # uncompressed 49 | 50 | offset = 14 + header + colors * 4 51 | image = stride * im.size[1] 52 | 53 | # bitmap header 54 | fp.write(b"BM" + # file type (magic) 55 | o32(offset+image) + # file size 56 | o32(0) + # reserved 57 | o32(offset)) # image data offset 58 | 59 | # bitmap info header 60 | fp.write(o32(header) + # info header size 61 | o32(im.size[0]) + # width 62 | o32(im.size[1]) + # height 63 | o16(1) + # planes 64 | o16(bits) + # depth 65 | o32(compression) + # compression (0=uncompressed) 66 | o32(image) + # size of bitmap 67 | o32(ppm[0]) + o32(ppm[1]) + # resolution 68 | o32(colors) + # colors used 69 | o32(colors)) # colors important 70 | if header >= BITMAPINFOV4HEADER: 71 | fp.write(o32(bitmask(16,24)) + # red channel bit mask 72 | o32(bitmask(8, 16)) + # green channel bit mask 73 | o32(bitmask(0, 8)) + # blue channel bit mask 74 | o32(bitmask(24,32)) + # alpha channel bit mask 75 | "sRGB"[::-1]) # LCS windows color space 76 | 77 | # Pad remaining header(unused color space info) 78 | padding = offset - fp.tell() 79 | fp.write(b"\0" * (padding)) 80 | 81 | 82 | if im.mode == "1": 83 | for i in (0, 255): 84 | fp.write(o8(i) * 4) 85 | elif im.mode == "L": 86 | for i in range(256): 87 | fp.write(o8(i) * 4) 88 | elif im.mode == "P": 89 | fp.write(im.im.getpalette("RGB", "BGRX")) 90 | 91 | ImageFile._save(im, fp, [("raw", (0, 0)+im.size, 0, 92 | (rawmode, stride, -1))]) 93 | -------------------------------------------------------------------------------- /gui_o_matic/gui/macosx.py: -------------------------------------------------------------------------------- 1 | import objc 2 | import traceback 3 | 4 | from Foundation import * 5 | from AppKit import * 6 | from PyObjCTools import AppHelper 7 | 8 | from gui_o_matic.gui.base import BaseGUI 9 | 10 | 11 | class MacOSXThing(NSObject): 12 | indicator = None 13 | 14 | def applicationDidFinishLaunching_(self, notification): 15 | self.indicator._menu_setup() 16 | self.indicator._ind_setup() 17 | self.indicator.ready = True 18 | 19 | def activate_(self, notification): 20 | for i, v in self.indicator.items.iteritems(): 21 | if notification == v: 22 | if i in self.indicator.callbacks: 23 | self.indicator.callbacks[i]() 24 | return 25 | print('activated an unknown item: %s' % notification) 26 | 27 | 28 | class MacOSXGUI(BaseGUI): 29 | 30 | ICON_THEME = 'osx' # OS X has its own theme because it is too 31 | # dumb to auto-resize menu bar icons. 32 | 33 | def _menu_setup(self): 34 | # Build a very simple menu 35 | self.menu = NSMenu.alloc().init() 36 | self.menu.setAutoenablesItems_(objc.NO) 37 | self.items = {} 38 | self.callbacks = {} 39 | self._create_menu_from_config() 40 | 41 | def _add_menu_item(self, id='item', label='Menu item', 42 | sensitive=False, 43 | op=None, args=None, 44 | **ignored_kwarg): 45 | # For now, bind everything to the notify method 46 | menuitem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 47 | label, 'activate:', '') 48 | menuitem.setEnabled_(sensitive) 49 | self.menu.addItem_(menuitem) 50 | self.items[id] = menuitem 51 | if op: 52 | def activate(o, a): 53 | return lambda: self._do(o, a) 54 | self.callbacks[id] = activate(op, args or []) 55 | 56 | def _ind_setup(self): 57 | # Create the statusbar item 58 | self.ind = NSStatusBar.systemStatusBar().statusItemWithLength_( 59 | NSVariableStatusItemLength) 60 | 61 | # Load all images, set initial 62 | self.images = {} 63 | images = self.config.get('indicator', {}).get('images', {}) 64 | for s, p in images.iteritems(): 65 | p = self._theme_image(p) 66 | self.images[s] = NSImage.alloc().initByReferencingFile_(p) 67 | if self.images: 68 | self.ind.setImage_(self.images['normal']) 69 | 70 | self.ind.setHighlightMode_(1) 71 | #self.ind.setToolTip_('Sync Trigger') 72 | self.ind.setMenu_(self.menu) 73 | self.set_status() 74 | 75 | def set_status(self, status='startup', badge=None): 76 | # FIXME: Can we support badges? 77 | self.ind.setImage_(self.images.get(status, self.images['normal'])) 78 | 79 | def set_item(self, id=None, label=None, sensitive=None): 80 | if label is not None and id and id in self.items: 81 | self.items[id].setTitle_(label) 82 | if sensitive is not None and id and id in self.items: 83 | self.items[id].setEnabled_(sensitive) 84 | 85 | def notify_user(self, 86 | message=None, popup=False, alert=False, actions=None): 87 | pass # FIXME 88 | 89 | def run(self): 90 | app = NSApplication.sharedApplication() 91 | osxthing = MacOSXThing.alloc().init() 92 | osxthing.indicator = self 93 | app.setDelegate_(osxthing) 94 | try: 95 | AppHelper.runEventLoop() 96 | except: 97 | traceback.print_exc() 98 | 99 | 100 | GUI = MacOSXGUI 101 | -------------------------------------------------------------------------------- /scripts/gui-test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export BASEDIR=$(cd $(dirname "$(readlink -f "$0" || echo "$0")"); pwd) 3 | export PARPID=$$ 4 | ( 5 | cat <. 121 | -------------------------------------------------------------------------------- /gui_o_matic/control/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import socket 5 | import time 6 | import threading 7 | import traceback 8 | import urllib2 9 | from gui_o_matic.gui.auto import AutoGUI 10 | 11 | 12 | class GUIPipeControl(threading.Thread): 13 | OK_GO = 'OK GO' 14 | OK_LISTEN = 'OK LISTEN' 15 | OK_LISTEN_TO = 'OK LISTEN TO:' 16 | OK_LISTEN_TCP = 'OK LISTEN TCP:' 17 | OK_LISTEN_HTTP = 'OK LISTEN HTTP:' 18 | 19 | def __init__(self, fd, config=None, gui_object=None): 20 | threading.Thread.__init__(self) 21 | self.daemon = True 22 | self.config = config 23 | self.gui = gui_object 24 | self.sock = None 25 | self.fd = fd 26 | self.child = None 27 | self.listening = None 28 | 29 | def shell_pivot(self, command): 30 | self.child = subprocess.Popen(command, 31 | shell=True, 32 | close_fds= (os.name != 'nt'), # Doesn't work on windows! 33 | stdin=subprocess.PIPE, 34 | stdout=subprocess.PIPE) 35 | self.fd = self.child.stdout 36 | 37 | def _listen(self): 38 | self.listening = socket.socket() 39 | self.listening.bind(('127.0.0.1', 0)) 40 | self.listening.listen(0) 41 | return str(self.listening.getsockname()[1]) 42 | 43 | def _accept(self): 44 | if self.child is not None: 45 | self.listening.settimeout(1) 46 | for count in range(0, 60): 47 | try: 48 | self.sock = self.listening.accept()[0] 49 | break 50 | except socket.timeout: 51 | pass 52 | else: 53 | self.listening.settimeout(60) 54 | self.sock = self.listening.accept()[0] 55 | 56 | # https://stackoverflow.com/questions/19570672/non-blocking-error-when-adding-timeout-to-python-server 57 | self.sock.setblocking(True) 58 | self.fd = self.sock.makefile() 59 | 60 | def shell_tcp_pivot(self, command): 61 | port = self._listen() 62 | self.shell_pivot(command.replace('%PORT%', port)) 63 | self._accept() 64 | 65 | def http_tcp_pivot(self, url): 66 | port = self._listen() 67 | urllib2.urlopen(url.replace('%PORT%', port)).read() 68 | self._accept() 69 | 70 | def do_line_magic(self, line, listen): 71 | try: 72 | if not line or line.strip() in (self.OK_GO, self.OK_LISTEN): 73 | return True, self.OK_LISTEN in line 74 | 75 | elif line.startswith(self.OK_LISTEN_TO): 76 | self.shell_pivot(line[len(self.OK_LISTEN_TO):].strip()) 77 | return True, True 78 | 79 | elif line.startswith(self.OK_LISTEN_TCP): 80 | self.shell_tcp_pivot(line[len(self.OK_LISTEN_TCP):].strip()) 81 | return True, True 82 | 83 | elif line.startswith(self.OK_LISTEN_HTTP): 84 | self.http_tcp_pivot(line[len(self.OK_LISTEN_HTTP):].strip()) 85 | return True, True 86 | 87 | else: 88 | return False, listen 89 | except Exception, e: 90 | if self.gui: 91 | self.gui._report_error(e) 92 | time.sleep(30) 93 | raise 94 | 95 | def bootstrap(self, dry_run=False): 96 | assert(self.config is None) 97 | assert(self.gui is None) 98 | 99 | listen = False 100 | config = [] 101 | while True: 102 | line = self.fd.readline() 103 | 104 | match, listen = self.do_line_magic(line, listen) 105 | if match: 106 | break 107 | else: 108 | config.append(line.strip()) 109 | 110 | self.config = json.loads(''.join(config)) 111 | self.gui = AutoGUI(self.config) 112 | if not dry_run: 113 | if listen: 114 | self.start() 115 | self.gui.run() 116 | 117 | def do(self, command, kwargs): 118 | if hasattr(self.gui, command): 119 | getattr(self.gui, command)(**kwargs) 120 | else: 121 | print('Unknown method: %s' % command) 122 | 123 | def run(self): 124 | try: 125 | while not self.gui.ready: 126 | time.sleep(0.1) 127 | time.sleep(0.1) 128 | while True: 129 | try: 130 | line = self.fd.readline() 131 | except IOError as e: 132 | line = None 133 | 134 | if not line: 135 | break 136 | if line: 137 | match, lstn = self.do_line_magic(line, None) 138 | if not match: 139 | try: 140 | cmd, args = line.strip().split(' ', 1) 141 | args = json.loads(args) 142 | self.do(cmd, args) 143 | except (ValueError, IndexError, NameError), e: 144 | if self.gui: 145 | self.gui._report_error(e) 146 | time.sleep(30) 147 | else: 148 | traceback.print_exc() 149 | 150 | except KeyboardInterrupt: 151 | return 152 | except: 153 | traceback.print_exc() 154 | finally: 155 | # Use sys.exit to allow atxit.register() to fire... 156 | # 157 | self.gui.quit() 158 | time.sleep(0.5) 159 | os._exit(0) 160 | -------------------------------------------------------------------------------- /gui_o_matic/gui/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import os 4 | import subprocess 5 | import threading 6 | import traceback 7 | import urllib 8 | import webbrowser 9 | 10 | 11 | class BaseGUI(object): 12 | """ 13 | This is the parent GUI class, which is subclassed by the various 14 | platform-specific implementations. 15 | """ 16 | 17 | ICON_THEME = 'light' 18 | 19 | def __init__(self, config): 20 | self.config = config 21 | self.ready = False 22 | self.next_error_message = None 23 | 24 | def _get_url(self, args, remove=False): 25 | if isinstance(args, list): 26 | if remove: 27 | return args.pop(0), args 28 | else: 29 | return args[0], args 30 | elif isinstance(args, dict): 31 | url = args['_url'] 32 | if remove: 33 | del args['_url'] 34 | return url, args 35 | elif remove: 36 | return args, None 37 | else: 38 | return args, args 39 | 40 | def _do(self, op, args): 41 | op, args = op.lower(), copy.copy(args) 42 | try: 43 | if op == 'show_url': 44 | url, args = self._get_url(args) 45 | self.show_url(url=url) 46 | 47 | elif op in ('get_url', 'post_url'): 48 | url, args = self._get_url(args, remove=True) 49 | base_url = '/'.join(url.split('/')[:3]) 50 | 51 | uo = urllib.URLopener() 52 | for cookie, value in self.config.get('http_cookies', {} 53 | ).get(base_url, []): 54 | uo.addheader('Cookie', '%s=%s' % (cookie, value)) 55 | 56 | if op == 'post_url': 57 | (fn, hdrs) = uo.retrieve(url, data=args) 58 | else: 59 | (fn, hdrs) = uo.retrieve(url) 60 | hdrs = unicode(hdrs) 61 | 62 | with open(fn, 'rb') as fd: 63 | data = fd.read().strip() 64 | 65 | if data.startswith('{') and 'application/json' in hdrs: 66 | data = json.loads(data) 67 | if 'message' in data: 68 | self.notify_user(data['message']) 69 | 70 | elif op == "shell": 71 | for arg in args: 72 | rv = os.system(arg) 73 | if 0 != rv: 74 | raise OSError( 75 | 'Failed with exit code %d: %s' % (rv, arg)) 76 | 77 | elif hasattr(self, op): 78 | getattr(self, op)(**(args or {})) 79 | 80 | except Exception, e: 81 | self._report_error(e) 82 | 83 | def _spawn(self, cmd, report_errors=True, _raise=False): 84 | def waiter(proc): 85 | try: 86 | rv = proc.wait() 87 | if rv: 88 | raise Exception('%s returned: %d' % (cmd[0], rv)) 89 | except Exception, e: 90 | if report_errors: 91 | self._report_error(e) 92 | try: 93 | proc = subprocess.Popen(cmd, close_fds=True) 94 | st = threading.Thread(target=waiter, args=[proc]) 95 | st.daemon = True 96 | st.start() 97 | return True 98 | except Exception, e: 99 | if _raise: 100 | raise 101 | elif report_errors: 102 | self._report_error(e) 103 | return False 104 | 105 | def set_http_cookie(self, domain=None, key=None, value=None, remove=False): 106 | all_cookies = self.config.get('http_cookies', {}) 107 | domain_cookies = all_cookies.get(domain, {}) 108 | if remove: 109 | if key in domain_cookies: 110 | del domain_cookies[key] 111 | else: 112 | domain_cookies[key] = value 113 | # Ensure the cookie config section exists 114 | all_cookies[domain] = domain_cookies 115 | self.config['http_cookies'] = all_cookies 116 | 117 | def terminal(self, command='/bin/bash', title=None, icon=None): 118 | cmd = [ 119 | "xterm", 120 | "-T", title or self.config.get('app_name', 'gui-o-matic'), 121 | "-e", command] 122 | if icon: 123 | cmd += ["-n", self._theme_image(icon)] 124 | self._spawn(cmd) 125 | 126 | def _theme_image(self, path): 127 | if path.startswith('image:'): 128 | path = self.config['images'][path.split(':', 1)[1]] 129 | path = path.replace('%(theme)s', self.ICON_THEME) 130 | if path != os.path.abspath(path): 131 | # The protocol mandates absolute paths, to avoid weird breakage 132 | # if the config and GUI app are generated from different working 133 | # directories. Fail here to help developers catch bugs early. 134 | raise ValueError('Path is not absolute: %s' % path) 135 | return path 136 | 137 | def _add_menu_item(self, id='item', label='Menu item', sensitive=False, 138 | op=None, args=None, **ignored_kwargs): 139 | pass 140 | 141 | def _create_menu_from_config(self): 142 | menu = self.config.get('indicator', {}).get('menu_items', []) 143 | for item_info in menu: 144 | self._add_menu_item(**item_info) 145 | 146 | def set_status(self, status=None, badge=None): 147 | print('STATUS: %s (badge=%s)' % (status, badge)) 148 | 149 | def quit(self): 150 | raise KeyboardInterrupt("User quit") 151 | 152 | def set_item(self, item=None, label=None, sensitive=None): 153 | pass 154 | 155 | def set_status_display(self, 156 | id=None, title=None, details=None, icon=None, color=None): 157 | pass 158 | 159 | def update_splash_screen(self, message=None, progress=None): 160 | pass 161 | 162 | def set_next_error_message(self, message=None): 163 | self.next_error_message = message 164 | 165 | def show_splash_screen(self, height=None, width=None, 166 | progress_bar=False, image=None, 167 | message=None, message_x=0.5, message_y=0.5): 168 | pass 169 | 170 | def hide_splash_screen(self): 171 | pass 172 | 173 | def show_main_window(self): 174 | pass 175 | 176 | def hide_main_window(self): 177 | pass 178 | 179 | def show_url(self, url=None): 180 | assert(url is not None) 181 | try: 182 | webbrowser.open(url) 183 | except Exception, e: 184 | self._report_error(e) 185 | 186 | def _report_error(self, e): 187 | traceback.print_exc() 188 | self.notify_user( 189 | (self.next_error_message or 'Error: %(error)s') 190 | % {'error': unicode(e)}) 191 | 192 | def notify_user(self, 193 | message='Hello', popup=False, alert=False, actions=None): 194 | print('NOTIFY: %s' % message) 195 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /scripts/gui-test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os.path 4 | import sys 5 | import subprocess 6 | import time 7 | import copy 8 | import json 9 | 10 | def readlink_f( path ): 11 | while True: 12 | try: 13 | path = os.readlink( path ) 14 | except (OSError, AttributeError): 15 | break 16 | return os.path.abspath( path ) 17 | 18 | def format_pod( template, **kwargs ): 19 | ''' 20 | apply str.format to all str elements of a simple object tree template 21 | ''' 22 | if isinstance( template, dict ): 23 | template = { format_pod( key, **kwargs ): format_pod( value, **kwargs ) for (key,value) in template.items() } 24 | elif isinstance( template, str ): 25 | template = template.format( **kwargs ) 26 | elif isinstance( template, list ): 27 | template = [ format_pod( value, **kwargs ) for value in template ] 28 | elif callable( template ): 29 | template = format_pod( template(), **kwargs ) 30 | else: 31 | # Maybe raise an error instead? 32 | # 33 | template = copy.copy( template ) 34 | 35 | return template 36 | 37 | class TestSession( object ): 38 | ''' 39 | Test session for scripting testing gui-o-matic 40 | ''' 41 | 42 | def __init__( self, parameters, log = "test.log" ): 43 | self.parameters = parameters 44 | cmd = (sys.executable,'-m','gui_o_matic') 45 | parentdir = os.path.split( parameters['basedir'] )[0] 46 | 47 | with open( log, "w" ) as handle: 48 | cwd = os.getcwd() 49 | os.chdir( parentdir ) 50 | self.proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=handle, stderr=handle ) 51 | os.chdir( cwd ) 52 | 53 | 54 | def send( self, text ): 55 | self.proc.stdin.write( text + '\n' ) 56 | self.proc.stdin.flush() 57 | 58 | def config( self, template ): 59 | text = json.dumps( format_pod( template, **self.parameters ) ) 60 | self.send( text ) 61 | 62 | 63 | def command( self, command, template): 64 | text = '{} {}'.format( command, 65 | json.dumps( format_pod( template, **self.parameters ) ) ) 66 | self.send( text ) 67 | 68 | 69 | def close( self ): 70 | self.proc.stdin.close() 71 | self.proc.wait() 72 | 73 | script = { 74 | "app_name": "Indicator Test", 75 | "app_icon": "{basedir}/img/gt-normal-%(theme)s.png", 76 | "_prefer_gui": ["unity", "macosx", "winapi", "gtk"], 77 | "images": { 78 | "startup": "{basedir}/img/gt-startup-%(theme)s.png", 79 | "normal": "{basedir}/img/gt-normal-%(theme)s.png", 80 | "working": "{basedir}/img/gt-working-%(theme)s.png", 81 | "attention": "{basedir}/img/gt-attention-%(theme)s.png", 82 | "shutdown": "{basedir}/img/gt-shutdown-%(theme)s.png" 83 | }, 84 | "font_styles": { 85 | "title": { 86 | "family": "normal", 87 | "points": 18, 88 | "bold": True 89 | }, 90 | "details": { 91 | "points": 10, 92 | "italic": True 93 | }, 94 | "buttons": { 95 | "points":14 96 | } 97 | }, 98 | "main_window": { 99 | "show": False, 100 | "initial_notification": "Hello world!\nThis is my message for you.", 101 | "close_quits": True, 102 | "width": 480, 103 | "height": 360, 104 | "background": "{basedir}/img/gt-wallpaper.png", 105 | "action_items": [ 106 | { 107 | "id": "btn-xkcd", 108 | "label": "XKCD", 109 | "position": "first", 110 | "sensitive": False, 111 | "op": "show_url", 112 | "args": "https://xkcd.com/" 113 | },{ 114 | "id": "mp", 115 | "label": "Mailpile?", 116 | "position": "first", 117 | "sensitive": True, 118 | "op": "terminal", 119 | "args": {"command": "screen -x -r mailpile || screen"} 120 | },{ 121 | "id": "quit", 122 | "label": "Quit", 123 | "position": "last", 124 | "sensitive": True, 125 | "op": "quit" 126 | } 127 | ], 128 | "status_displays": [ 129 | { 130 | "id": "internal-identifying-name", 131 | "icon": "image:working", 132 | "title": "Hello world!", 133 | "details": "Greetings and salutations to all!" 134 | },{ 135 | "id": "id2", 136 | #"icon": "image:attention", 137 | "title": "Launching Frobnicator", 138 | "details": "The beginning and end of all things...\n...or is it?" 139 | } 140 | ] 141 | }, 142 | "indicator": { 143 | "menu_items": [ 144 | { 145 | "label": "Indicator test", 146 | "id": "info" 147 | },{ 148 | "separator": True 149 | },{ 150 | "label": "XKCD", 151 | "id": "menu-xkcd", 152 | "op": "show_url", 153 | "args": ["https://xkcd.com/"], 154 | "sensitive": False 155 | },{ 156 | "label": "Mailpile", 157 | "id": "mailpile", 158 | "op": "show_url", 159 | "args": ["https://www.mailpile.is/"], 160 | "sensitive": True 161 | } 162 | ] 163 | } 164 | } 165 | 166 | session = TestSession( parameters = { 167 | 'basedir': os.path.split( readlink_f( sys.argv[ 0 ] ) )[ 0 ] 168 | }) 169 | 170 | session.config( script ) 171 | session.send( "OK LISTEN" ) 172 | 173 | session.command( 'show_splash_screen', 174 | { 175 | "background": "{basedir}/img/gt-splash.png", 176 | "width": 320, 177 | "message": "Hello world!", 178 | "progress_bar": True 179 | }) 180 | time.sleep( 2 ) 181 | 182 | session.command( 'update_splash_screen', {"progress": 0.2} ) 183 | session.command( 'set_status', {"status": "normal"} ) 184 | time.sleep( 2 ) 185 | 186 | session.command( 'update_splash_screen', {"progress": 0.5, "message": "Woohooooo"} ) 187 | session.command( 'update_splash_screen', {"progress": 0.5} ) 188 | session.command( 'set_item', {"id": "menu-xkcd", "sensitive": True} ) 189 | session.command( 'set_item', {"id": "btn-xkcd", "sensitive": True} ) 190 | session.command( 'notify_user', {"message": "This is a notification"} ) 191 | session.command( 'notify_user', {"message": "This is a popup notification", 192 | "popup": True, 193 | "actions": [{ 194 | "op": "show_url", 195 | "label": "XKCD", 196 | "url": "https://xkcd.com" 197 | }] 198 | }) 199 | time.sleep( 2 ) 200 | session.command( 'set_status', {"badge": ""} ) 201 | session.command( 'update_splash_screen', {"progress": 1.0} ) 202 | session.command( 'set_status', {"status": "working"} ) 203 | time.sleep( 2 ) 204 | 205 | session.command( 'hide_splash_screen', {} ) 206 | session.command( 'show_main_window', {} ) 207 | session.command( 'set_status', {"status": "attention"} ) 208 | time.sleep( 2 ) 209 | 210 | 211 | session.command( 'set_status_display', {"id": "id2", 212 | "icon":"image:shutdown", 213 | "title": "Whoops!", 214 | "details": "Just kidding!", 215 | "color": "#f00"} ) 216 | 217 | session.command( 'set_status_display', {"id": "id2", 218 | "icon":"image:shutdown", 219 | "title": "Whoops!", 220 | "details": "Just kidding!", 221 | "color": "#0088FF"} ) 222 | 223 | session.command( 'set_item', {"id": "menu-xkcd", "label": "No really, XKCD"} ) 224 | session.command( 'set_item', {"id": "btn-xkcd", "label": "XKCDonk"} ) 225 | 226 | session.command( 'notify_user', {"message": "This is an overly long notification. It should get truncated somehow to print well"} ) 227 | time.sleep( 30 ) 228 | 229 | session.command( 'set_status', {"status": "shutdown"} ) 230 | time.sleep( 5 ) 231 | 232 | session.close() 233 | -------------------------------------------------------------------------------- /distribute_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap distribute installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from distribute_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import sys 18 | import time 19 | import fnmatch 20 | import tempfile 21 | import tarfile 22 | from distutils import log 23 | 24 | try: 25 | from site import USER_SITE 26 | except ImportError: 27 | USER_SITE = None 28 | 29 | try: 30 | import subprocess 31 | 32 | def _python_cmd(*args): 33 | args = (sys.executable,) + args 34 | return subprocess.call(args) == 0 35 | 36 | except ImportError: 37 | # will be used for python 2.3 38 | def _python_cmd(*args): 39 | args = (sys.executable,) + args 40 | # quoting arguments if windows 41 | if sys.platform == 'win32': 42 | def quote(arg): 43 | if ' ' in arg: 44 | return '"%s"' % arg 45 | return arg 46 | args = [quote(arg) for arg in args] 47 | return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 48 | 49 | DEFAULT_VERSION = "0.6.19" 50 | DEFAULT_URL = "https://pypi.python.org/packages/source/d/distribute/" 51 | SETUPTOOLS_FAKED_VERSION = "0.6c11" 52 | 53 | SETUPTOOLS_PKG_INFO = """\ 54 | Metadata-Version: 1.0 55 | Name: setuptools 56 | Version: %s 57 | Summary: xxxx 58 | Home-page: xxx 59 | Author: xxx 60 | Author-email: xxx 61 | License: xxx 62 | Description: xxx 63 | """ % SETUPTOOLS_FAKED_VERSION 64 | 65 | 66 | def _install(tarball): 67 | # extracting the tarball 68 | tmpdir = tempfile.mkdtemp() 69 | log.warn('Extracting in %s', tmpdir) 70 | old_wd = os.getcwd() 71 | try: 72 | os.chdir(tmpdir) 73 | tar = tarfile.open(tarball) 74 | _extractall(tar) 75 | tar.close() 76 | 77 | # going in the directory 78 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 79 | os.chdir(subdir) 80 | log.warn('Now working in %s', subdir) 81 | 82 | # installing 83 | log.warn('Installing Distribute') 84 | if not _python_cmd('setup.py', 'install'): 85 | log.warn('Something went wrong during the installation.') 86 | log.warn('See the error message above.') 87 | finally: 88 | os.chdir(old_wd) 89 | 90 | 91 | def _build_egg(egg, tarball, to_dir): 92 | # extracting the tarball 93 | tmpdir = tempfile.mkdtemp() 94 | log.warn('Extracting in %s', tmpdir) 95 | old_wd = os.getcwd() 96 | try: 97 | os.chdir(tmpdir) 98 | tar = tarfile.open(tarball) 99 | _extractall(tar) 100 | tar.close() 101 | 102 | # going in the directory 103 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 104 | os.chdir(subdir) 105 | log.warn('Now working in %s', subdir) 106 | 107 | # building an egg 108 | log.warn('Building a Distribute egg in %s', to_dir) 109 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 110 | 111 | finally: 112 | os.chdir(old_wd) 113 | # returning the result 114 | log.warn(egg) 115 | if not os.path.exists(egg): 116 | raise IOError('Could not build the egg.') 117 | 118 | 119 | def _do_download(version, download_base, to_dir, download_delay): 120 | egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg' 121 | % (version, sys.version_info[0], sys.version_info[1])) 122 | if not os.path.exists(egg): 123 | tarball = download_setuptools(version, download_base, 124 | to_dir, download_delay) 125 | _build_egg(egg, tarball, to_dir) 126 | sys.path.insert(0, egg) 127 | import setuptools 128 | setuptools.bootstrap_install_from = egg 129 | 130 | 131 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 132 | to_dir=os.curdir, download_delay=15, no_fake=True): 133 | # making sure we use the absolute path 134 | to_dir = os.path.abspath(to_dir) 135 | was_imported = 'pkg_resources' in sys.modules or \ 136 | 'setuptools' in sys.modules 137 | try: 138 | try: 139 | import pkg_resources 140 | if not hasattr(pkg_resources, '_distribute'): 141 | if not no_fake: 142 | _fake_setuptools() 143 | raise ImportError 144 | except ImportError: 145 | return _do_download(version, download_base, to_dir, download_delay) 146 | try: 147 | pkg_resources.require("distribute>="+version) 148 | return 149 | except pkg_resources.VersionConflict: 150 | e = sys.exc_info()[1] 151 | if was_imported: 152 | sys.stderr.write( 153 | "The required version of distribute (>=%s) is not available,\n" 154 | "and can't be installed while this script is running. Please\n" 155 | "install a more recent version first, using\n" 156 | "'easy_install -U distribute'." 157 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 158 | sys.exit(2) 159 | else: 160 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 161 | return _do_download(version, download_base, to_dir, 162 | download_delay) 163 | except pkg_resources.DistributionNotFound: 164 | return _do_download(version, download_base, to_dir, 165 | download_delay) 166 | finally: 167 | if not no_fake: 168 | _create_fake_setuptools_pkg_info(to_dir) 169 | 170 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 171 | to_dir=os.curdir, delay=15): 172 | """Download distribute from a specified location and return its filename 173 | 174 | `version` should be a valid distribute version number that is available 175 | as an egg for download under the `download_base` URL (which should end 176 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 177 | `delay` is the number of seconds to pause before an actual download 178 | attempt. 179 | """ 180 | # making sure we use the absolute path 181 | to_dir = os.path.abspath(to_dir) 182 | try: 183 | from urllib.request import urlopen 184 | except ImportError: 185 | from urllib2 import urlopen 186 | tgz_name = "distribute-%s.tar.gz" % version 187 | url = download_base + tgz_name 188 | saveto = os.path.join(to_dir, tgz_name) 189 | src = dst = None 190 | if not os.path.exists(saveto): # Avoid repeated downloads 191 | try: 192 | log.warn("Downloading %s", url) 193 | src = urlopen(url) 194 | # Read/write all in one block, so we don't create a corrupt file 195 | # if the download is interrupted. 196 | data = src.read() 197 | dst = open(saveto, "wb") 198 | dst.write(data) 199 | finally: 200 | if src: 201 | src.close() 202 | if dst: 203 | dst.close() 204 | return os.path.realpath(saveto) 205 | 206 | def _no_sandbox(function): 207 | def __no_sandbox(*args, **kw): 208 | try: 209 | from setuptools.sandbox import DirectorySandbox 210 | if not hasattr(DirectorySandbox, '_old'): 211 | def violation(*args): 212 | pass 213 | DirectorySandbox._old = DirectorySandbox._violation 214 | DirectorySandbox._violation = violation 215 | patched = True 216 | else: 217 | patched = False 218 | except ImportError: 219 | patched = False 220 | 221 | try: 222 | return function(*args, **kw) 223 | finally: 224 | if patched: 225 | DirectorySandbox._violation = DirectorySandbox._old 226 | del DirectorySandbox._old 227 | 228 | return __no_sandbox 229 | 230 | def _patch_file(path, content): 231 | """Will backup the file then patch it""" 232 | existing_content = open(path).read() 233 | if existing_content == content: 234 | # already patched 235 | log.warn('Already patched.') 236 | return False 237 | log.warn('Patching...') 238 | _rename_path(path) 239 | f = open(path, 'w') 240 | try: 241 | f.write(content) 242 | finally: 243 | f.close() 244 | return True 245 | 246 | _patch_file = _no_sandbox(_patch_file) 247 | 248 | def _same_content(path, content): 249 | return open(path).read() == content 250 | 251 | def _rename_path(path): 252 | new_name = path + '.OLD.%s' % time.time() 253 | log.warn('Renaming %s into %s', path, new_name) 254 | os.rename(path, new_name) 255 | return new_name 256 | 257 | def _remove_flat_installation(placeholder): 258 | if not os.path.isdir(placeholder): 259 | log.warn('Unkown installation at %s', placeholder) 260 | return False 261 | found = False 262 | for file in os.listdir(placeholder): 263 | if fnmatch.fnmatch(file, 'setuptools*.egg-info'): 264 | found = True 265 | break 266 | if not found: 267 | log.warn('Could not locate setuptools*.egg-info') 268 | return 269 | 270 | log.warn('Removing elements out of the way...') 271 | pkg_info = os.path.join(placeholder, file) 272 | if os.path.isdir(pkg_info): 273 | patched = _patch_egg_dir(pkg_info) 274 | else: 275 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 276 | 277 | if not patched: 278 | log.warn('%s already patched.', pkg_info) 279 | return False 280 | # now let's move the files out of the way 281 | for element in ('setuptools', 'pkg_resources.py', 'site.py'): 282 | element = os.path.join(placeholder, element) 283 | if os.path.exists(element): 284 | _rename_path(element) 285 | else: 286 | log.warn('Could not find the %s element of the ' 287 | 'Setuptools distribution', element) 288 | return True 289 | 290 | _remove_flat_installation = _no_sandbox(_remove_flat_installation) 291 | 292 | def _after_install(dist): 293 | log.warn('After install bootstrap.') 294 | placeholder = dist.get_command_obj('install').install_purelib 295 | _create_fake_setuptools_pkg_info(placeholder) 296 | 297 | def _create_fake_setuptools_pkg_info(placeholder): 298 | if not placeholder or not os.path.exists(placeholder): 299 | log.warn('Could not find the install location') 300 | return 301 | pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) 302 | setuptools_file = 'setuptools-%s-py%s.egg-info' % \ 303 | (SETUPTOOLS_FAKED_VERSION, pyver) 304 | pkg_info = os.path.join(placeholder, setuptools_file) 305 | if os.path.exists(pkg_info): 306 | log.warn('%s already exists', pkg_info) 307 | return 308 | 309 | log.warn('Creating %s', pkg_info) 310 | f = open(pkg_info, 'w') 311 | try: 312 | f.write(SETUPTOOLS_PKG_INFO) 313 | finally: 314 | f.close() 315 | 316 | pth_file = os.path.join(placeholder, 'setuptools.pth') 317 | log.warn('Creating %s', pth_file) 318 | f = open(pth_file, 'w') 319 | try: 320 | f.write(os.path.join(os.curdir, setuptools_file)) 321 | finally: 322 | f.close() 323 | 324 | _create_fake_setuptools_pkg_info = _no_sandbox(_create_fake_setuptools_pkg_info) 325 | 326 | def _patch_egg_dir(path): 327 | # let's check if it's already patched 328 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 329 | if os.path.exists(pkg_info): 330 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 331 | log.warn('%s already patched.', pkg_info) 332 | return False 333 | _rename_path(path) 334 | os.mkdir(path) 335 | os.mkdir(os.path.join(path, 'EGG-INFO')) 336 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 337 | f = open(pkg_info, 'w') 338 | try: 339 | f.write(SETUPTOOLS_PKG_INFO) 340 | finally: 341 | f.close() 342 | return True 343 | 344 | _patch_egg_dir = _no_sandbox(_patch_egg_dir) 345 | 346 | def _before_install(): 347 | log.warn('Before install bootstrap.') 348 | _fake_setuptools() 349 | 350 | 351 | def _under_prefix(location): 352 | if 'install' not in sys.argv: 353 | return True 354 | args = sys.argv[sys.argv.index('install')+1:] 355 | for index, arg in enumerate(args): 356 | for option in ('--root', '--prefix'): 357 | if arg.startswith('%s=' % option): 358 | top_dir = arg.split('root=')[-1] 359 | return location.startswith(top_dir) 360 | elif arg == option: 361 | if len(args) > index: 362 | top_dir = args[index+1] 363 | return location.startswith(top_dir) 364 | if arg == '--user' and USER_SITE is not None: 365 | return location.startswith(USER_SITE) 366 | return True 367 | 368 | 369 | def _fake_setuptools(): 370 | log.warn('Scanning installed packages') 371 | try: 372 | import pkg_resources 373 | except ImportError: 374 | # we're cool 375 | log.warn('Setuptools or Distribute does not seem to be installed.') 376 | return 377 | ws = pkg_resources.working_set 378 | try: 379 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools', 380 | replacement=False)) 381 | except TypeError: 382 | # old distribute API 383 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools')) 384 | 385 | if setuptools_dist is None: 386 | log.warn('No setuptools distribution found') 387 | return 388 | # detecting if it was already faked 389 | setuptools_location = setuptools_dist.location 390 | log.warn('Setuptools installation detected at %s', setuptools_location) 391 | 392 | # if --root or --preix was provided, and if 393 | # setuptools is not located in them, we don't patch it 394 | if not _under_prefix(setuptools_location): 395 | log.warn('Not patching, --root or --prefix is installing Distribute' 396 | ' in another location') 397 | return 398 | 399 | # let's see if its an egg 400 | if not setuptools_location.endswith('.egg'): 401 | log.warn('Non-egg installation') 402 | res = _remove_flat_installation(setuptools_location) 403 | if not res: 404 | return 405 | else: 406 | log.warn('Egg installation') 407 | pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') 408 | if (os.path.exists(pkg_info) and 409 | _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): 410 | log.warn('Already patched.') 411 | return 412 | log.warn('Patching...') 413 | # let's create a fake egg replacing setuptools one 414 | res = _patch_egg_dir(setuptools_location) 415 | if not res: 416 | return 417 | log.warn('Patched done.') 418 | _relaunch() 419 | 420 | 421 | def _relaunch(): 422 | log.warn('Relaunching...') 423 | # we have to relaunch the process 424 | # pip marker to avoid a relaunch bug 425 | if sys.argv[:3] == ['-c', 'install', '--single-version-externally-managed']: 426 | sys.argv[0] = 'setup.py' 427 | args = [sys.executable] + sys.argv 428 | sys.exit(subprocess.call(args)) 429 | 430 | 431 | def _extractall(self, path=".", members=None): 432 | """Extract all members from the archive to the current working 433 | directory and set owner, modification time and permissions on 434 | directories afterwards. `path' specifies a different directory 435 | to extract to. `members' is optional and must be a subset of the 436 | list returned by getmembers(). 437 | """ 438 | import copy 439 | import operator 440 | from tarfile import ExtractError 441 | directories = [] 442 | 443 | if members is None: 444 | members = self 445 | 446 | for tarinfo in members: 447 | if tarinfo.isdir(): 448 | # Extract directories with a safe mode. 449 | directories.append(tarinfo) 450 | tarinfo = copy.copy(tarinfo) 451 | tarinfo.mode = 448 # decimal for oct 0700 452 | self.extract(tarinfo, path) 453 | 454 | # Reverse sort directories. 455 | if sys.version_info < (2, 4): 456 | def sorter(dir1, dir2): 457 | return cmp(dir1.name, dir2.name) 458 | directories.sort(sorter) 459 | directories.reverse() 460 | else: 461 | directories.sort(key=operator.attrgetter('name'), reverse=True) 462 | 463 | # Set correct owner, mtime and filemode on directories. 464 | for tarinfo in directories: 465 | dirpath = os.path.join(path, tarinfo.name) 466 | try: 467 | self.chown(tarinfo, dirpath) 468 | self.utime(tarinfo, dirpath) 469 | self.chmod(tarinfo, dirpath) 470 | except ExtractError: 471 | e = sys.exc_info()[1] 472 | if self.errorlevel > 1: 473 | raise 474 | else: 475 | self._dbg(1, "tarfile: %s" % e) 476 | 477 | 478 | def main(argv, version=DEFAULT_VERSION): 479 | """Install or upgrade setuptools and EasyInstall""" 480 | tarball = download_setuptools() 481 | _install(tarball) 482 | 483 | 484 | if __name__ == '__main__': 485 | main(sys.argv[1:]) 486 | -------------------------------------------------------------------------------- /gui_o_matic/gui/gtkbase.py: -------------------------------------------------------------------------------- 1 | import pango 2 | import gobject 3 | import gtk 4 | import threading 5 | import traceback 6 | try: 7 | import pynotify 8 | except ImportError: 9 | pynotify = None 10 | 11 | from gui_o_matic.gui.base import BaseGUI 12 | 13 | 14 | class GtkBaseGUI(BaseGUI): 15 | 16 | _HAVE_INDICATOR = False 17 | 18 | def __init__(self, config): 19 | BaseGUI.__init__(self, config) 20 | self.splash = None 21 | self.font_styles = {} 22 | self.status_display = {} 23 | self.popup = None 24 | if pynotify: 25 | pynotify.init(config.get('app_name', 'gui-o-matic')) 26 | gobject.threads_init() 27 | 28 | def _menu_setup(self): 29 | self.items = {} 30 | self.menu = gtk.Menu() 31 | self._create_menu_from_config() 32 | 33 | def _add_menu_item(self, id=None, label='Menu item', 34 | sensitive=False, 35 | separator=False, 36 | op=None, args=None, 37 | **ignored_kwarg): 38 | if separator: 39 | menu_item = gtk.SeparatorMenuItem() 40 | else: 41 | menu_item = gtk.MenuItem(label) 42 | menu_item.set_sensitive(sensitive) 43 | if op: 44 | def activate(o, a): 45 | return lambda d: self._do(o, a) 46 | menu_item.connect("activate", activate(op, args or [])) 47 | menu_item.show() 48 | self.menu.append(menu_item) 49 | if id: 50 | self.items[id] = menu_item 51 | 52 | def _set_background_image(self, container, image): 53 | themed_image = self._theme_image(image) 54 | img = gtk.gdk.pixbuf_new_from_file(themed_image) 55 | def draw_background(widget, ev): 56 | alloc = widget.get_allocation() 57 | pb = img.scale_simple(alloc.width, alloc.height, 58 | gtk.gdk.INTERP_BILINEAR) 59 | widget.window.draw_pixbuf( 60 | widget.style.bg_gc[gtk.STATE_NORMAL], 61 | pb, 0, 0, alloc.x, alloc.y) 62 | if (hasattr(widget, 'get_child') and 63 | widget.get_child() is not None): 64 | widget.propagate_expose(widget.get_child(), ev) 65 | return False 66 | container.connect('expose_event', draw_background) 67 | 68 | def _main_window_add_action_items(self, button_box): 69 | for action in self.config['main_window'].get('action_items', []): 70 | if action.get('type', 'button') == 'button': 71 | widget = gtk.Button(label=action.get('label', 'OK')) 72 | event = "clicked" 73 | if 'buttons' in self.font_styles: 74 | widget.get_child().modify_font(self.font_styles['buttons']) 75 | # 76 | # Disabled for now - what was supposed to happen when the box was ticked 77 | # or unticked was never really resolved in a satisfactory way. 78 | # 79 | # elif action['type'] == 'checkbox': 80 | # widget = gtk.CheckButton(label=action.get('label', '?')) 81 | # if action.get('checked'): 82 | # widget.set_active(True) 83 | # event = "toggled" 84 | # 85 | else: 86 | raise NotImplementedError('We only have buttons ATM!') 87 | 88 | if action.get('position', 'left') in ('first', 'left', 'top'): 89 | button_box.pack_start(widget, False, True) 90 | elif action['position'] in ('last', 'right', 'bottom'): 91 | button_box.pack_end(widget, False, True) 92 | else: 93 | raise NotImplementedError('Invalid position: %s' 94 | % action['position']) 95 | 96 | if action.get('op'): 97 | def activate(o, a): 98 | return lambda d: self._do(o, a) 99 | widget.connect(event, 100 | activate(action['op'], action.get('args', []))) 101 | 102 | widget.set_sensitive(action.get('sensitive', True)) 103 | self.items[action['id']] = widget 104 | 105 | def _main_window_indicator(self, menu_container, icon_container): 106 | if not self._HAVE_INDICATOR: 107 | menubar = gtk.MenuBar() 108 | im = gtk.MenuItem(self.config.get('app_name', 'GUI-o-Matic')) 109 | im.set_submenu(self.menu) 110 | menubar.append(im) 111 | menu_container.pack_start(menubar, False, True) 112 | 113 | icon = gtk.Image() 114 | icon_container.pack_start(icon, False, True) 115 | self.main_window['indicator_icon'] = icon 116 | 117 | def _set_status_display_icon(self, status, icon_path, size=32): 118 | if 'icon' in status: 119 | themed_icon = self._theme_image(icon_path) 120 | img = gtk.gdk.pixbuf_new_from_file(themed_icon) 121 | img = img.scale_simple(size, size, gtk.gdk.INTERP_BILINEAR) 122 | status['icon'].set_from_pixbuf(img) 123 | status['icon_size'] = size 124 | 125 | def _main_window_default_style(self): 126 | wcfg = self.config['main_window'] 127 | 128 | button_style = self.config.get('font_styles', {}).get('buttons', {}) 129 | border_padding = int(2 * button_style.get('points', 10) / 3) 130 | 131 | vbox = gtk.VBox(False, 0) 132 | vbox.set_border_width(border_padding) 133 | 134 | # Enforce that the window always has at least one status section, 135 | # even if the configuration doesn't specify one. 136 | sd_defs = wcfg.get("status_displays") or [{ 137 | "id": "notification", 138 | "details": wcfg.get('initial_notification', '')}] 139 | 140 | # Scale the status icons relative to a) how tall the window is, 141 | # and b) how many status lines we are showing. 142 | icon_size = int(0.66 * wcfg.get('height', 360) / len(sd_defs)) 143 | 144 | status_displays = [] 145 | for st in sd_defs: 146 | ss = { 147 | 'id': st['id'], 148 | 'hbox': gtk.HBox(False, border_padding), 149 | 'vbox': gtk.VBox(False, border_padding), 150 | 'title': gtk.Label(), 151 | 'details': gtk.Label()} 152 | 153 | for which in ('title', 'details'): 154 | ss[which].set_markup(st.get(which, '')) 155 | exact = '%s_%s' % (ss['id'], which) 156 | if exact in self.font_styles: 157 | ss[which].modify_font(self.font_styles[exact]) 158 | elif which in self.font_styles: 159 | ss[which].modify_font(self.font_styles[which]) 160 | 161 | if 'icon' in st: 162 | ss['icon'] = gtk.Image() 163 | ss['hbox'].pack_start(ss['icon'], False, True) 164 | self._set_status_display_icon(ss, st['icon'], icon_size) 165 | text_x = 0.0 166 | else: 167 | # If there is no icon, center our title and details 168 | text_x = 0.5 169 | 170 | ss['title'].set_alignment(text_x, 1.0) 171 | ss['details'].set_alignment(text_x, 0.0) 172 | ss['vbox'].pack_start(ss['title'], True, True) 173 | ss['vbox'].pack_end(ss['details'], True, True) 174 | ss['vbox'].set_spacing(1) 175 | ss['hbox'].pack_start(ss['vbox'], True, True) 176 | ss['hbox'].set_spacing(7) 177 | status_displays.append(ss) 178 | self.status_display = dict((ss['id'], ss) for ss in status_displays) 179 | 180 | notify = None 181 | if 'notification' not in self.status_display: 182 | notify = gtk.Label() 183 | notify.set_markup(wcfg.get('initial_notification', '')) 184 | notify.set_alignment(0, 0.5) 185 | if 'notification' in self.font_styles: 186 | notify.modify_font(self.font_styles['notification']) 187 | 188 | if wcfg.get('background'): 189 | self._set_background_image(vbox, wcfg.get('background')) 190 | 191 | button_box = gtk.HBox(False, border_padding) 192 | 193 | self._main_window_indicator(vbox, button_box) 194 | self._main_window_add_action_items(button_box) 195 | if notify: 196 | button_box.pack_start(notify, True, True) 197 | for ss in status_displays: 198 | vbox.pack_start(ss['hbox'], True, True) 199 | vbox.pack_end(button_box, False, True) 200 | 201 | self.main_window['window'].add(vbox) 202 | self.main_window.update({ 203 | 'vbox': vbox, 204 | 'notification': (notify if notify 205 | else self.status_display['notification']['details']), 206 | 'status_displays': status_displays, 207 | 'buttons': button_box}) 208 | 209 | # TODO: Add other window styles? 210 | 211 | def _main_window_setup(self, _now=False): 212 | def create(self): 213 | wcfg = self.config['main_window'] 214 | 215 | window = gtk.Window(gtk.WINDOW_TOPLEVEL) 216 | self.main_window = {'window': window} 217 | 218 | if wcfg.get('style', 'default') == 'default': 219 | self._main_window_default_style() 220 | else: 221 | raise NotImplementedError('We only have one style atm.') 222 | 223 | if wcfg.get('close_quits'): 224 | window.connect('delete-event', lambda w, e: gtk.main_quit()) 225 | else: 226 | window.connect('delete-event', lambda w, e: w.hide() or True) 227 | window.connect("destroy", lambda wid: gtk.main_quit()) 228 | 229 | window.set_title(self.config.get('app_name', 'gui-o-matic')) 230 | window.set_decorated(True) 231 | if wcfg.get('center', False): 232 | window.set_position(gtk.WIN_POS_CENTER) 233 | window.set_size_request( 234 | wcfg.get('width', 360), wcfg.get('height',360)) 235 | if wcfg.get('show'): 236 | window.show_all() 237 | 238 | if _now: 239 | create(self) 240 | else: 241 | gobject.idle_add(create, self) 242 | 243 | def quit(self): 244 | def q(self): 245 | gtk.main_quit() 246 | gobject.idle_add(q, self) 247 | 248 | def show_main_window(self): 249 | def show(self): 250 | if self.main_window: 251 | self.main_window['window'].show_all() 252 | gobject.idle_add(show, self) 253 | 254 | def hide_main_window(self): 255 | def hide(self): 256 | if self.main_window: 257 | self.main_window['window'].hide() 258 | gobject.idle_add(hide, self) 259 | 260 | def update_splash_screen(self, progress=None, message=None, _now=False): 261 | def update(self): 262 | if self.splash: 263 | if message is not None and 'message' in self.splash: 264 | self.splash['message'].set_markup( 265 | message.replace('<', '<')) 266 | if progress is not None and 'progress' in self.splash: 267 | self.splash['progress'].set_fraction(progress) 268 | if _now: 269 | update(self) 270 | else: 271 | gobject.idle_add(update, self) 272 | 273 | def show_splash_screen(self, height=None, width=None, 274 | progress_bar=False, background=None, 275 | message=None, message_x=0.5, message_y=0.5, 276 | _now=False): 277 | wait_lock = threading.Lock() 278 | def show(self): 279 | self.hide_splash_screen(_now=True) 280 | 281 | window = gtk.Window(gtk.WINDOW_TOPLEVEL) 282 | vbox = gtk.VBox(False, 1) 283 | 284 | if message: 285 | lbl = gtk.Label() 286 | lbl.set_markup(message or '') 287 | lbl.set_alignment(message_x, message_y) 288 | if 'splash' in self.font_styles: 289 | lbl.modify_font(self.font_styles['splash']) 290 | vbox.pack_start(lbl, True, True) 291 | else: 292 | lbl = None 293 | 294 | if background: 295 | self._set_background_image(vbox, background) 296 | 297 | if progress_bar: 298 | pbar = gtk.ProgressBar() 299 | pbar.set_orientation(gtk.PROGRESS_LEFT_TO_RIGHT) 300 | vbox.pack_end(pbar, False, True) 301 | else: 302 | pbar = None 303 | 304 | window.set_title(self.config.get('app_name', 'gui-o-matic')) 305 | window.set_decorated(False) 306 | window.set_position(gtk.WIN_POS_CENTER) 307 | window.set_size_request(width or 240, height or 320) 308 | window.add(vbox) 309 | window.show_all() 310 | 311 | self.splash = { 312 | 'window': window, 313 | 'message_x': message_x, 314 | 'message_y': message_y, 315 | 'vbox': vbox, 316 | 'message': lbl, 317 | 'progress': pbar} 318 | wait_lock.release() 319 | with wait_lock: 320 | if _now: 321 | show(self) 322 | else: 323 | gobject.idle_add(show, self) 324 | wait_lock.acquire() 325 | 326 | def hide_splash_screen(self, _now=False): 327 | wait_lock = threading.Lock() 328 | def hide(self): 329 | for k in self.splash or []: 330 | if hasattr(self.splash[k], 'destroy'): 331 | self.splash[k].destroy() 332 | self.splash = None 333 | wait_lock.release() 334 | with wait_lock: 335 | if _now: 336 | hide(self) 337 | else: 338 | gobject.idle_add(hide, self) 339 | wait_lock.acquire() 340 | 341 | def notify_user(self, 342 | message='Hello', popup=False, alert=False, actions=None): 343 | # FIXME: Can we do something for alerts? Actions? 344 | def notify(self): 345 | # We always update the indicator status with the latest 346 | # notification. 347 | if 'notification' in self.items: 348 | self.items['notification'].set_label(message) 349 | 350 | # Popups! 351 | if popup: 352 | popup_icon = 'dialog-warning' 353 | if 'app_icon' in self.config: 354 | popup_icon = self._theme_image(self.config['app_icon']) 355 | popup_appname = self.config.get('app_name', 'gui-o-matic') 356 | if pynotify is not None: 357 | if self.popup is None: 358 | self.popup = pynotify.Notification( 359 | popup_appname, message, popup_icon) 360 | self.popup.set_urgency(pynotify.URGENCY_NORMAL) 361 | self.popup.update(popup_appname, message, popup_icon) 362 | self.popup.show() 363 | return 364 | elif not self.config.get('disable-popup-fallback'): 365 | try: 366 | if self._spawn([ 367 | 'notify-send', 368 | '-i', popup_icon, popup_appname, 369 | message], 370 | report_errors=False): 371 | return 372 | except: 373 | print('FIXME: Should popup: %s' % message) 374 | 375 | # Note: popups also fall through to here if we can't pop up 376 | if self.splash: 377 | self.update_splash_screen(message=message, _now=True) 378 | elif self.main_window: 379 | msg = message.replace('<', '<') 380 | self.main_window['notification'].set_markup(msg) 381 | else: 382 | print('FIXME: Notify: %s' % message) 383 | gobject.idle_add(notify, self) 384 | 385 | def _indicator_setup(self): 386 | pass 387 | 388 | def _indicator_set_icon(self, icon, **kwargs): 389 | if 'indicator_icon' in self.main_window: 390 | themed_icon = self._theme_image(icon) 391 | img = gtk.gdk.pixbuf_new_from_file(themed_icon) 392 | img = img.scale_simple(32, 32, gtk.gdk.INTERP_BILINEAR) 393 | self.main_window['indicator_icon'].set_from_pixbuf(img) 394 | 395 | def _indicator_set_status(self, status, **kwargs): 396 | pass 397 | 398 | def set_status(self, status=None, badge=None, _now=False): 399 | # FIXME: Can we support badges? 400 | if status is None: 401 | return 402 | 403 | if _now: 404 | do = lambda o, a: o(a) 405 | else: 406 | do = gobject.idle_add 407 | images = self.config.get('images') 408 | if images: 409 | icon = images.get(status) 410 | if not icon: 411 | icon = images.get('normal') 412 | if icon: 413 | self._indicator_set_icon(icon, do=do) 414 | self._indicator_set_status(status, do=do) 415 | 416 | def set_status_display(self, 417 | id=None, title=None, details=None, icon=None, color=None): 418 | status = self.status_display.get(id) 419 | if not status: 420 | return 421 | if icon: 422 | self._set_status_display_icon( 423 | status, icon, status.get('icon_size', 32)) 424 | if title: 425 | status['title'].set_markup(title) 426 | if details: 427 | status['details'].set_markup(details) 428 | if color: 429 | color = gtk.gdk.color_parse(color) 430 | for which in ('title', 'details'): 431 | status[which].modify_fg(gtk.STATE_NORMAL, color) 432 | status[which].modify_text(gtk.STATE_NORMAL, color) 433 | 434 | def set_item(self, id=None, label=None, sensitive=None): 435 | if label is not None and id and id in self.items: 436 | def set_label(label): 437 | obj = self.items[id] 438 | if (isinstance(obj, gtk.Button) 439 | and 'buttons' in self.font_styles): 440 | obj.set_label(' %s ' % label) 441 | obj.get_child().modify_font(self.font_styles['buttons']) 442 | else: 443 | obj.set_label(label) 444 | gobject.idle_add(set_label, label) 445 | if sensitive is not None and id and id in self.items: 446 | gobject.idle_add(self.items[id].set_sensitive, sensitive) 447 | 448 | def _font_setup(self): 449 | for name, style in self.config.get('font_styles', {}).iteritems(): 450 | pfd = pango.FontDescription() 451 | pfd.set_family(style.get('family', 'normal')) 452 | pfd.set_size(style.get('points', 12) * pango.SCALE) 453 | if style.get('italic'): pfd.set_style(pango.STYLE_ITALIC) 454 | if style.get('bold'): pfd.set_weight(pango.WEIGHT_BOLD) 455 | self.font_styles[name] = pfd 456 | 457 | def run(self): 458 | self._font_setup() 459 | self._menu_setup() 460 | if self.config.get('indicator') and self._HAVE_INDICATOR: 461 | self._indicator_setup() 462 | if self.config.get('main_window'): 463 | self._main_window_setup() 464 | 465 | def ready(s): 466 | s.ready = True 467 | gobject.idle_add(ready, self) 468 | 469 | try: 470 | gtk.main() 471 | except: 472 | traceback.print_exc() 473 | 474 | 475 | GUI = GtkBaseGUI 476 | -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # The GUI-o-Matic Protocol 2 | 3 | GUI-o-Matic implements a relatively simple protocol for communicating 4 | between the main application and the GUI tool. 5 | 6 | There are three main stages to the protocol: 7 | 8 | 1. Configuration 9 | 2. Handing Over Control 10 | 3. Ongoing GUI Updates 11 | 12 | The protocol is a one-way stream of text (ASCII/JSON), and is line-based 13 | and case sensitive at all stages. 14 | 15 | The initial stream should be read from standard input, a file, or by 16 | capturing the output of another tool. 17 | 18 | Conceptually, stages 2 and 3 are separate because they accomplish very 19 | different things, but in practice they overlap; the source of the protocol 20 | stream may change at any time after stage 1. 21 | 22 | Note: There's no strong reason stages 1, 2 and 3 use different syntax; 23 | mostly I think it looks nicer this way and makes it easy to read and 24 | write raw command transcripts. Similar things look similar, different things 25 | look different! 26 | 27 | 28 | ----------------------------------------------------------------------------- 29 | ## 1. Configuration 30 | 31 | The first stage uses the simplest protocol, but communicates the richest set 32 | of data: at this stage the GUI-o-Matic tool simply reads a JSON-formatted 33 | dictionary. The dictionary defines the main characteristics of our user 34 | interface. 35 | 36 | GUI-o-Matic should read until it sees a line starting with the words `OK GO` 37 | or `OK LISTEN`, at which point it should attempt to parse the JSON structure 38 | and then proceed to stage two. 39 | 40 | When the configuration dictionary is parsed, it should be treated in the 41 | forgiving spirit of JSON in general: most missing fields should be replaced 42 | with reasonable defaults and unrecognized fields should be ignored. 43 | 44 | The following is an example of a complete configuration dictionary, along 45 | with descriptions of how it is interpreted. 46 | 47 | **Note:** Lines beginning with a `#` are comments explaining what each 48 | section means. They should be omitted from any actual implementation 49 | (comments are sadly not legal in JSON). 50 | 51 | { 52 | # Basics 53 | "app_name": "Your App Name", 54 | "app_icon": "/reference/or/absolute/path/to/icon.png", 55 | 56 | # i18n hint to GUI: ltr, rtl, ...? 57 | "text_direction": "ltr", 58 | 59 | # These are for testing only; implementations may ignore them. 60 | "_require_gui": ["unity", "macosx", "gtk"], 61 | "_prefer_gui": ["unity", "macosx", "gtk"], 62 | 63 | # HTTP Cookie { key: value, ... } pairs, by domain. 64 | # These get sent as cookies along with get_url/post_url HTTP requests. 65 | "http_cookies": { 66 | "localhost:33411": { 67 | "session": "abacabadonk" 68 | } 69 | }, 70 | ... 71 | 72 | The `images` section defines a dictionary of named icons/images. The names can 73 | be used directly by the `set_status` method, or anywhere an icon path can be 74 | provided by using the syntax `image:NAME` instead. Note that all paths should 75 | be absolute, as we don't want to make assumptions about the working directory 76 | of the GUI app itself. 77 | 78 | The only required entry is `normal`. 79 | 80 | There is also preliminary support for light/dark/... themes, by embedding the 81 | magic marker `%(theme)s` in the name. The idea is that if the backend somehow 82 | detects that a dark theme is more appropriate, it will replace `%(theme)s` with 83 | the word `dark`. The current draft OS X backend requests an `osx` theme because 84 | at some point Mac OS X needed slightly different icons from the others. 85 | 86 | ... 87 | "images": { 88 | "normal": "/absolute/path/to/%(theme)s/normal.svg", 89 | "flipper": "/absolute/path/to/unthemed/flipper.png", 90 | "flopper": "/absolute/path/to/flop-%(theme)s.png" 91 | "background": "/absolute/path/to/a/nice/background.jpg" 92 | }, 93 | ... 94 | 95 | In `font_styles`, we define font styles used in different parts of the app. 96 | 97 | ... 98 | "font_styles": { 99 | # Style used by status display titles in the main window 100 | "title": { 101 | "family": "normal", 102 | "points": 18, 103 | "bold": True 104 | }, 105 | 106 | # Style used by status display details in the main window 107 | "details": { 108 | "points": 10, 109 | "italic": True 110 | }, 111 | 112 | # Title and detail styles can be scoped to only apply to 113 | # a single status display, by prepending the ID. 114 | "id_title": { ... }, 115 | "id_details": { ... }, 116 | 117 | # The main-window may have a standalone notification element, 118 | # for messages that don't go anywhere else. 119 | "notification": { ... }, 120 | 121 | # Labels on buttons in the main window 122 | "buttons": { ... } 123 | 124 | # The progress reporting label on the splash screen 125 | "splash": { ... } 126 | }, 127 | ... 128 | 129 | The `main_window` section defines the main app window. The main app window has 130 | the following elements: 131 | 132 | * Status displays (an icon and some text: title + details) 133 | * Actions (buttons or menu items) 134 | * A notification display element (text label) 135 | * A background image 136 | 137 | How these are actually laid out is up to the GUI backend. Desktop platforms 138 | should largely behave the same way, but we could envision a mobile (android?) 139 | implementation that for example ignored the width/height parameters and moved 140 | some of the actions to a hamburger "overflow" menu. 141 | 142 | ... 143 | "main_window": { 144 | # Is the main window displayed immediately on startup? This 145 | # will be set to False when we are using a splash-screen. 146 | "show": False, 147 | 148 | # If True, closing the main window exits the app. If False, 149 | # it just hides the main window, and we rely on the indicator 150 | # or other mechanisms to bring it back as necessary. 151 | "close_quits": False, 152 | 153 | # Recommended height/width. May be ignored on some platforms. 154 | "width": 550, 155 | "height": 330, 156 | 157 | # Background image. May be ignored on some platforms. 158 | "background": "image:background", 159 | 160 | # Default notification label text 161 | "initial_notification": "", 162 | ... 163 | 164 | The `status_displays` in the main window are used to communicate both visual 165 | and textual clues about different things. Each consists of an icon, a main 166 | label and a hint. The values provided are defaults, all are likely to change 167 | later on. The GUI backend has a fair bit of freedom in how it renders these, 168 | but order should be preserved and labels should be made more prominent than 169 | hints. 170 | 171 | ... 172 | "status_displays": [ 173 | { 174 | "id": "internal-identifying-name", 175 | "icon": "image:something", 176 | "title": "Hello world!", 177 | "details": "Greetings and salutations to all!" 178 | },{ 179 | "id": "id2", 180 | "icon": "/absolute/path/to/some/icon.png", 181 | "title": "Launching Frobnicator", 182 | "details": "The beginning and end of all things" 183 | } 184 | ], 185 | ... 186 | 187 | The main window `action_items` are generally implemented as buttons in desktop 188 | environments. Actions are to be allocated space in the GUI, in the order they 189 | are specified - if we run out of space, latter actions may be moved to some 190 | sort of overflow or "hamburger". 191 | 192 | The `position` field gives a clue about ordering on the display itself, but 193 | does not influence priority. As an example, in a typical left-to-right row of 194 | buttons, the first action to request `last` should be rendered furthest to the 195 | right, and the first action to request `first` furthest to the left. Latter 196 | buttons get rendered progressively closer to the middle, until we run out of 197 | space. Adjust accordingly if the buttons are rendered top-to-bottom (portrait 198 | mode on mobile?). 199 | 200 | The `op` and `args` fields together define what happens if the user clicks the 201 | button. The operation can be any of the Stage 3 operations defined below, in 202 | which case "args" should be a dictionary of arguments, or it can be one of: 203 | `show_url`, `get_url`, `post_url`, or `shell`. See below for further 204 | clarifications on these ops and their arguments. 205 | 206 | ... 207 | "action_items": [ 208 | { 209 | "id": "open", 210 | "type": "button", # button is the default 211 | "position": "first", 212 | "label": "Open", 213 | "op": "show_url", 214 | "args": "http://www.google.com/" 215 | },{ 216 | "id": "evil666", 217 | "position": "last", 218 | "label": "Harakiri", 219 | "op": "shell", 220 | "args": ["rm -rf /ha/ha/just/kidding", 221 | "echo 'That was close'"] 222 | } 223 | ] 224 | # The "main_window" example ends here 225 | }, 226 | ... 227 | 228 | The final section of the configuration is the `indicator`, which ideally is 229 | implemented as a mutable icon and action menu, displayed in the appropriate 230 | place on the Desktop (top-bar on the mac? system tray on Windows?). If no 231 | such placement is possible, the indicator may instead show up as an icon 232 | in the main window itself. 233 | 234 | The menu items should be rendered in the order specified. 235 | 236 | Items in the menu with `sensitive` set to false should be displayed, but 237 | not clickable by the user (greyed out). Note that the label text and 238 | sensitivity of an item may later be modified by Stage 3 commands. 239 | 240 | Menu items may also be separators, which in most environments draws a 241 | horizontal dividor. Environments not supporting that may use a blank menu 242 | item instead, or omit, as deemed appropriate. 243 | 244 | Within these menus, the `id`, `op` and `args` fields have the same 245 | meanings and function as they do in the main window actions. Configuration 246 | writes should take care to avoid collissions when chosing item IDs. 247 | 248 | An menu item with the ID `notification` is special and should receive text 249 | updates from the `notify_user` method. 250 | 251 | "indicator": { 252 | "initial_status": "startup", # Should match an icon 253 | "menu_items": [ 254 | { 255 | "id": "notification", 256 | "label": "Starting up!", 257 | "sensitive": False 258 | },{ 259 | "separator": True 260 | },{ 261 | "id": "xkcd", 262 | "label": "XKCD is great", 263 | "op": "show_url", 264 | "args": "https://xkcd.com/" 265 | } 266 | ] 267 | } 268 | } 269 | 270 | There are more examples in the [scripts/](scripts) folder! 271 | 272 | 273 | ### Operations and arguments 274 | 275 | Both main-window actions and indicator menu items specify `op` and `args` 276 | to define what happens when the user clicks on them. 277 | 278 | These actions are either GUI-o-Matic Stage 3 operations (in which case `args` 279 | should be a dictionary of arguments), web actions, or a shell command. 280 | 281 | In all cases, execution (or network) errors result in a notification being 282 | displayed to the user. 283 | 284 | **FIXME:** It should be possible to customize the error messages... 285 | 286 | 287 | #### Web Actions: `show_url` 288 | 289 | The most basic web action is `show_url`. This action takes a single argument, 290 | which is the URL to display. The JSON structure may be any of: a string, a 291 | list with a single element (the string) or a dictionary with a `_url`. 292 | 293 | No cookies or POST data can be specified with this method. When activated, this 294 | operation should request the given URL be opened in the user's default browser. 295 | 296 | **FIXME:** In a new tab? Or reuse a tab we already opened? Make this configurable 297 | by adding args to a dictionary? 298 | 299 | 300 | #### Web Actions: `get_url`, `post_url` 301 | 302 | These actions will in the background send an HTTP GET or HTTP POST request 303 | to the URL specified in the argument. 304 | 305 | For GET requests, the JSON structure may be any of: a string, a list with a 306 | single element (the string) or a dictionary with a `_url`. 307 | 308 | For POST requests, `args` should be a dictionary, where the URL is specified in 309 | an element named `_url`. All other elements in the dictionary will be encoded 310 | as payload/data and sent along with the POST request. 311 | 312 | If the response data has the MIME type `application/json`, it parses as a JSON 313 | dictionary, and the JSON has a top-level element named `message`, that result 314 | text will be displayed to the user as a notification. 315 | 316 | 317 | #### Shell Actions: `shell` 318 | 319 | Shell actions expect `args` to be a list of strings. Each string is passed 320 | to the operating system shell as a command to execute (so a single click can 321 | result in multiple shell actions). If any fails (returns a non-zero exit code), 322 | the following commands will not run. 323 | 324 | The output from the shell commands is discarded. 325 | 326 | 327 | ----------------------------------------------------------------------------- 328 | ## 2. Handing Over Control 329 | 330 | The GUI-o-Matic protocol has five options for handing over control (changing 331 | the stream of commands) after the configuration has been processed: 332 | 333 | 1. **OK GO** - No more input 334 | 2. **OK LISTEN** - No change, keep reading the same source 335 | 3. **OK LISTEN TO: cmd** - Launch cmd and read its standard output 336 | 4. **OK LISTEN TCP: cmd** - Launch cmd and read from a socket 337 | 5. **OK LISTEN HTTP: url** - Fetch and URL and read from a socket 338 | 339 | Options 2.1 and 2.2 are trivial and will not be discussed further. 340 | 341 | In all cases except "OK GO", if GUI-o-Matic reaches "end of file" on the 342 | update stream, that should result in shutdown of GUI-o-Matic itself. 343 | 344 | 345 | ### 2.3. OK LISTEN TO 346 | 347 | Example: `OK LISTEN TO: cat /tmp/magic.txt` 348 | 349 | If the handover command begins with "OK LISTEN TO: ", the rest of the 350 | line should be treated verbatim as something to be passed to the operating 351 | system shell. 352 | 353 | The standard output of the spawned command shall be read and parsed for 354 | stage 2 or stage 3 updates. 355 | 356 | Errors: The GUI-o-Matic should monitor whether the spawned command 357 | crashes/exits with a non-zero exit code and communicate that to the user. 358 | 359 | 360 | ### 2.4. OK LISTEN TCP 361 | 362 | Example: `OK LISTEN TCP: mailpile --www= --gui=%PORT% --wait` 363 | 364 | In this case, the GUI-o-Matic must open a new listening TCP socket 365 | (preferably on a random OS-assigned localhost-only port). 366 | 367 | The rest of the "OK LISTEN TCP: ..." line should have all occurrances 368 | of `%PORT%` replaced with the port number, and the resulting string 369 | passed to the operating system shell to execute. 370 | 371 | The spawned command is expected to connect to `localhost:PORT` and 372 | send further stage 2 or stage 3 updates over that channel. 373 | 374 | Errors: In addition to checking the exit code of the spawned process as 375 | described above, GUI-o-Matic should also monitor whether the spawned command 376 | crashes/exits without ever establishing a connection and treat that and 377 | excessive timeouts as error conditions. 378 | 379 | 380 | ### 2.5. OK LISTEN HTTP 381 | 382 | Example: `OK LISTEN HTTP: http://localhost:33411/gui/%PORT%/` 383 | 384 | This command behaves identically to `OK LISTEN TCP`, except instead of 385 | spawning a new process the app connects to an HTTP server on localhost 386 | and passes information about the control port in the URL. 387 | 388 | Again, HTTP errors (non-200 result codes) and socket errors should be 389 | communicated to the user and treated as fatal. The body of the HTTP 390 | reply is ignored. 391 | 392 | **TODO:** *An alternate HTTP method which repeatedly long-polls an URL 393 | for commands would allow GUI-o-Matic to easily play nice over the web! 394 | We don't need this today, but it might be nice for someone else? Food for 395 | thought...* **DANGER! This could become a huge security hole!** 396 | 397 | 398 | ----------------------------------------------------------------------------- 399 | ## 3. Ongoing GUI Updates 400 | 401 | The third stage (which is processed in parallel to stage 2), is commands 402 | which send updates to the GUI itself. 403 | 404 | These updates all use the same syntax: 405 | 406 | lowercase_command_with_underscores {"arguments": "as JSON"} 407 | 408 | Each command will fit on a single line (no newlines are allowed in 409 | the JSON section) and be terminated by a CRLF or LF sequence. If there 410 | are no arguments, an empty JSON dictionary `{}` is expected. 411 | 412 | A description of the existing commands follows; see also 413 | `gui_o_matic/gui/base.py` for the Python definitions. 414 | 415 | 416 | ### show_splash_screen 417 | 418 | Arguments: 419 | 420 | * background: (string) absolute path to a background image file 421 | * message: (string) initial status message 422 | * message_x: (float [0-1]) positioning hint for message in window 423 | * message_y: (float [0-1]) positioning hint for message in window 424 | * progress_bar: (bool) display a progress bar? 425 | 426 | This displays a splash-screen, to keep the user happy while something 427 | slow happens. 428 | 429 | ### update_splash_screen 430 | 431 | Arguments: 432 | 433 | * progress: (optional float) progress bar size in the range 0 - 1.0 434 | * message: (optional string) updated status message 435 | 436 | ### hide_splash_screen 437 | 438 | Arguments: none 439 | 440 | Hides the splash-screen. 441 | 442 | ### show_main_window 443 | 444 | Arguments: none 445 | 446 | Display the main application window. 447 | 448 | ### hide_main_window 449 | 450 | Arguments: none 451 | 452 | Hide the main application window. 453 | 454 | ### set_status 455 | 456 | Arguments: 457 | 458 | * status: (optional string) "startup", "normal", "working", ... 459 | * badge: (optional string) A very short snippet of text 460 | 461 | If `status` is provided, set the overall "status" of the application. 462 | This is generally displayed by changing an indicator icon somewhere within 463 | the app. All statuses should have an icon defined in the `images: { ... }` 464 | section of the configuration. 465 | 466 | The `badge` is a small amount of text to overlay over the app's icon (which 467 | may or may not be the same icon as the status icon, where this goes, if 468 | anywhere is platform dependent), representing unread message counts or 469 | other similar data. Callers should assume only some platforms implement 470 | this and should assume the amount of text is limited to 1-3 characters at 471 | most. 472 | 473 | GUI implementors must assume that the `status` and `badge` may be set 474 | independently of each other, as many callers will use different logic to 475 | track and report each one. 476 | 477 | 478 | ### set_status_display 479 | 480 | Arguments: 481 | 482 | * id: (string) The ID of the status display section 483 | * title: (optional string) Updated text for the title label 484 | * details: (optional string) Updated text for the details label 485 | * icon: (optional string) FS path or reference to an entry in `images` 486 | * color: (optional #rgb/#rrggbb string) Color for label text 487 | 488 | This will update some or all of the elements of one of the status display 489 | sections in the main window. 490 | 491 | ### set_item 492 | 493 | Arguments: 494 | 495 | * id: (string) The item ID as defined in the configuration 496 | * label: (optional string) A new label! 497 | * sensitive: (optional bool) Make item senstive (default) or insensitive 498 | 499 | This can be used to change the labels displayed in the indicator menu 500 | (the `indicator: menu: [ ... ]` section of the configuration). 501 | 502 | This can also be used to change the sensitivity of one of the entries in the 503 | indicator menu (the `indicator: menu: [ ... ]` section of the config). 504 | Insensitive items are greyed out but should still be displayed, as apps 505 | may choose to use the to communicate low-priority information to the user. 506 | 507 | 508 | ### set_next_error_message 509 | 510 | Arguments: 511 | 512 | * message: (optional string) What to say next time something fails 513 | 514 | This can be used to override GUI-o-Matic internal error messages (including 515 | those generated by stage 2 commands above). Calling this with no arguments 516 | reverts back to the default behaviour. 517 | 518 | This is important to allow apps to give friendlier (albeit less precise) 519 | messages to users, including respecting localization settings in the 520 | controlling app. 521 | 522 | ### notify_user 523 | 524 | Arguments: 525 | 526 | * message: (string) Tell the user something 527 | * popup: (optional bool) Prefer an OSD/growl/popup style notification 528 | * alert: (optional bool) Try harder to get the user's attention 529 | * actions: (optional list of dicts) Actions relating to the notification 530 | 531 | This method should always try and display a message to the user, no matter 532 | which windows are visible: 533 | 534 | * If popus are requested, be intrusive! 535 | * If the splash screen is visible, display there 536 | * If the main window is visible, display there 537 | * ...? 538 | 539 | If a notifications has `"alert": true`, that is a signal to the GUI that 540 | it should flash a light, bounce an icon, vibrate or otherwise try to draw 541 | the user's attention to the app. 542 | 543 | If present, `actions` should be a list of dictionaries containing the same 544 | `label`, `op`, `args` and `position` arguments as are used in the main 545 | window `action_items` section. 546 | 547 | Since support for notification actions varies a great deal from platform to 548 | platform (and toolkit to toolkit), the caller must assume some or all items 549 | in `actions` will be silently ignored. The list should be sorted by 550 | priority (most important first) and the caller should assume list 551 | processing may be truncated at any point or individual items skipped due to 552 | platform limitations. 553 | 554 | The `actions` list is likely to be ignored if `popup` is not set to True. 555 | 556 | GUI implementors should carefully consider the user experience of 557 | notification actions on their platform. It may be better to not implement 558 | `actions` at all than to provide confusing or destructive implementations. 559 | As an example, if an URL is to be opened in the browser, but the 560 | implementation cannot raise/focus/display the browser on click, it's 561 | probably best not to offer browser actions at all (confusion). Similarly, 562 | implementations that clobber pre-existing tabs in the user's browser should 563 | also be avoided (destructive). 564 | 565 | The presence (or absence) of an `actions` list should not alter the 566 | priority or placement of displayed notifications. 567 | 568 | 569 | ### show_url 570 | 571 | Arguments: 572 | 573 | * url: (url) The URL to open 574 | 575 | Open the named URL in the user's preferred browser. 576 | 577 | **FIXME:** *For access control reasons, this method should support POST, and/or 578 | allow the app to configure cookies. However it's unclear whether the methods 579 | available to us for launching the browser actually support that accross 580 | platforms. Needs further research.* 581 | 582 | ### terminal 583 | 584 | Arguments: 585 | 586 | * command: (string) The shell command to launch 587 | * title: (optional string) The preferred terminal window title 588 | * icon: (optional string) FS path or reference to an entry in `images` 589 | 590 | Spawn a command in a visible terminal, so the user can interact with it. 591 | 592 | ### set_http_cookie 593 | 594 | Arguments: 595 | 596 | * domain: (string) The domain of the cookie being updated 597 | * key: (string) A the cookie key 598 | * value: (optional string) A new value for the cookie 599 | * remove: (optional bool) If true, delete the cookie (ignore value) 600 | 601 | Modify or remove one of the HTTP cookies. 602 | 603 | 604 | ### quit 605 | 606 | Arguments: none 607 | 608 | Shut down GUI-o-Matic. 609 | 610 | 611 | ----------------------------------------------------------------------------- 612 | *The end* 613 | -------------------------------------------------------------------------------- /gui_o_matic/gui/winapi.py: -------------------------------------------------------------------------------- 1 | 2 | # Windows-specific includes, uses pywin32 for winapi 3 | # 4 | import win32api 5 | import win32con 6 | import win32gui 7 | import win32gui_struct 8 | import win32ui 9 | import win32print 10 | import commctrl 11 | import ctypes 12 | 13 | # Utility imports 14 | # 15 | import re 16 | import tempfile 17 | import PIL.Image 18 | import os 19 | import uuid 20 | import traceback 21 | import atexit 22 | import itertools 23 | import struct 24 | import functools 25 | import Queue 26 | 27 | import pil_bmp_fix 28 | 29 | # Work-around till upstream PIL is patched. 30 | # 31 | BMP_FORMAT = "BMP+ALPHA" 32 | PIL.Image.register_save( BMP_FORMAT, pil_bmp_fix._save ) 33 | 34 | from gui_o_matic.gui.base import BaseGUI 35 | 36 | def rect_intersect( rect_a, rect_b ): 37 | x_min = max(rect_a[0], rect_b[0]) 38 | y_min = max(rect_a[1], rect_b[1]) 39 | x_max = min(rect_a[2], rect_b[2]) 40 | y_max = min(rect_a[3], rect_b[3]) 41 | return (x_min, y_min, x_max, y_max) 42 | 43 | class Image( object ): 44 | ''' 45 | Helper class for importing arbitrary graphics to winapi bitmaps. Mode is a 46 | tuple of (winapi image type, file extension, and cleanup callback). 47 | ''' 48 | 49 | @classmethod 50 | def Bitmap( cls, *args, **kwargs ): 51 | mode = (win32con.IMAGE_BITMAP,'bmp',win32gui.DeleteObject) 52 | return cls( *args, mode = mode, **kwargs ) 53 | 54 | # https://blog.barthe.ph/2009/07/17/wmseticon/ 55 | # 56 | @classmethod 57 | def Icon( cls, *args, **kwargs ): 58 | mode = (win32con.IMAGE_ICON,'ico',win32gui.DestroyIcon) 59 | return cls( *args, mode = mode, **kwargs ) 60 | 61 | @classmethod 62 | def IconLarge( cls, *args, **kwargs ): 63 | dims =(win32con.SM_CXICON, win32con.SM_CYICON) 64 | size = tuple(map(win32api.GetSystemMetrics,dims)) 65 | return cls.Icon( *args, size = size, **kwargs ) 66 | 67 | @classmethod 68 | def IconSmall( cls, *args, **kwargs ): 69 | dims =(win32con.SM_CXSMICON, win32con.SM_CYSMICON) 70 | size = tuple(map(win32api.GetSystemMetrics,dims)) 71 | return cls.Icon( *args, size = size, **kwargs ) 72 | 73 | def __init__( self, path, mode, size = None, debug = None ): 74 | ''' 75 | Load the image into memory, with appropriate conversions. 76 | 77 | size: 78 | None: use image size 79 | number: scale image size 80 | tuple: transform image size 81 | ''' 82 | if isinstance( path, PIL.Image.Image ): 83 | source = path 84 | else: 85 | source = PIL.Image.open( path ) 86 | 87 | if source.mode != 'RGBA': 88 | source = source.convert( 'RGBA' ) 89 | if size: 90 | if not hasattr( size, '__len__' ): 91 | factor = float( size ) / max( source.size ) 92 | size = tuple([ int(factor * dim) for dim in source.size ]) 93 | source = source.resize( size, PIL.Image.ANTIALIAS ) 94 | #source.thumbnail( size, PIL.Image.ANTIALIAS ) 95 | 96 | self.size = source.size 97 | self.mode = mode 98 | 99 | if debug: 100 | source.save( debug, mode[ 1 ] ) 101 | 102 | with tempfile.NamedTemporaryFile( delete = False ) as handle: 103 | filename = handle.name 104 | source.save( handle, mode[ 1 ] ) 105 | 106 | try: 107 | self.handle = win32gui.LoadImage( None, 108 | handle.name, 109 | mode[ 0 ], 110 | source.width, 111 | source.height, 112 | win32con.LR_LOADFROMFILE )#| win32con.LR_CREATEDIBSECTION ) 113 | finally: 114 | os.unlink( filename ) 115 | 116 | def __del__( self ): 117 | # TODO: swap mode to a more descriptive structure 118 | # 119 | #self.mode[2]( self.handle ) 120 | pass 121 | 122 | class Compositor( object ): 123 | ''' 124 | Alpha-blend compatability class. 125 | 126 | Since we're having trouble getting alpha into winapi objects, blend images 127 | in python, then move them out to winapi as RGB 128 | ''' 129 | 130 | class Operation( object ): 131 | ''' 132 | Applies and effect to an image 133 | ''' 134 | 135 | class Fill( Operation ): 136 | ''' 137 | Stretches the target region with the specified color. 138 | ''' 139 | 140 | def __init__( self, color, rect = None ): 141 | self.rect = rect 142 | self.color = color 143 | 144 | def __call__( self, image ): 145 | rect = self.rect or (0, 0, image.width, image.height) 146 | image.paste( self.color, rect ) 147 | 148 | class Blend( Operation ): 149 | 150 | def __init__( self, source, rect = None ): 151 | self.set_image( source ) 152 | self.rect = rect 153 | 154 | def set_image( self, source ): 155 | self.source = source if source.mode == "RGBA" else source.convert("RGBA") 156 | 157 | def __call__( self, image ): 158 | rect = self.rect or (0, 0, image.width, image.height) 159 | dst_size = (rect[2] - rect[0], rect[3] - rect[1]) 160 | if dst_size != self.source.size: 161 | scaled = self.source.resize( dst_size, PIL.Image.ANTIALIAS ) 162 | else: 163 | scaled = self.source 164 | dst = image.crop( rect ) 165 | blend = dst.alpha_composite( scaled ) 166 | image.paste( dst, rect ) 167 | 168 | def render( self, size, background = (0,0,0,0) ): 169 | image = PIL.Image.new( "RGBA", size, background ) 170 | for operation in self.operations: 171 | operation( image ) 172 | return image 173 | 174 | def __init__( self ): 175 | self.operations = [] 176 | 177 | class Registry( object ): 178 | ''' 179 | Registry that maps objects to IDs 180 | ''' 181 | _objectmap = {} 182 | 183 | _next_id = 1024 184 | 185 | @classmethod 186 | def register( cls, obj, dst_attr = 'registry_id' ): 187 | ''' 188 | Register an object at the next available id. 189 | ''' 190 | next_id = cls._next_id 191 | cls._next_id = next_id + 1 192 | cls._objectmap[ next_id ] = obj 193 | if dst_attr: 194 | setattr( obj, dst_attr, next_id ) 195 | 196 | @classmethod 197 | def lookup( cls, registry_id ): 198 | ''' 199 | Get a registered action by id, probably for invoking it. 200 | ''' 201 | return cls._objectmap[ registry_id ] 202 | 203 | class AutoRegister( object ): 204 | def __init__( self, *args ): 205 | ''' 206 | Register subclasses at init time. 207 | ''' 208 | Registry.register( self, *args ) 209 | 210 | def lookup( self, registry_id ): 211 | return Registry.lookup( self, registry_id ) 212 | 213 | class Action( Registry.AutoRegister ): 214 | ''' 215 | Class binding a string id to numeric id, op, and action, allowing 216 | WM_COMMAND etc to be easily mapped to gui-o-matic protocol elements. 217 | ''' 218 | 219 | 220 | def __init__( self, gui, identifier, label, operation = None, sensitive = True, args = None ): 221 | ''' 222 | Bind the action state to the gui for later invocation or modification. 223 | ''' 224 | super( Action, self ).__init__() 225 | self.gui = gui 226 | self.identifier = identifier 227 | self.label = label 228 | self.operation = operation 229 | self.sensitive = sensitive 230 | self.args = args 231 | 232 | def get_id( self ): 233 | return self.registry_id 234 | 235 | def __call__( self, *args ): 236 | ''' 237 | Apply the bound action arguments 238 | ''' 239 | assert( self.sensitive ) 240 | self.gui._do( op = self.operation, args = self.args ) 241 | 242 | 243 | class Window( object ): 244 | ''' 245 | Window class: Provides convenience methods for managing windows. Also globs 246 | systray icon display functionality, since that has to hang off a window/ 247 | windproc. Principle methods: 248 | 249 | - set_visiblity( True|False ) 250 | - set_size( x, y, width, height ) 251 | - get_size() -> ( x, y, width, height ) 252 | - set_systray( icon|None, hovertext ) # None removes systray icon 253 | - set_menu( [ Actions... ] ) # for systray 254 | - set_icon( small_icon, large_icon ) # window and taskbar 255 | 256 | Rendering: 257 | Add Layer objects to layers list to have them rendered on WM_PAINT. 258 | ''' 259 | # Standard window style except disable resizing 260 | # 261 | main_window_style = win32con.WS_OVERLAPPEDWINDOW \ 262 | ^ win32con.WS_THICKFRAME \ 263 | ^ win32con.WS_MAXIMIZEBOX 264 | 265 | # Window style with no frills 266 | # 267 | splash_screen_style = win32con.WS_POPUP 268 | 269 | # Window styel for systray 270 | # 271 | systray_style = win32con.WS_OVERLAPPED | win32con.WS_SYSMENU 272 | 273 | _next_window_class_id = 0 274 | 275 | class Layer( object ): 276 | ''' 277 | Abstract base for something to be rendered in response to WM_PAINT. 278 | Implement __call__ to update the window as desired. 279 | ''' 280 | 281 | def __call__( self, window, hdc, paint_struct ): 282 | raise NotImplementedError 283 | 284 | class CompositorLayer( Layer, Compositor ): 285 | ''' 286 | Layer that moves compositor output into an HDC, caching rendering. 287 | ''' 288 | 289 | def __init__( self, rect = None, background = None ): 290 | super(Window.CompositorLayer, self).__init__() 291 | self.image = None 292 | self.rect = rect 293 | self.background = background 294 | 295 | def update( self, window, hdc ): 296 | rect = self.rect or window.get_client_region() 297 | try: 298 | background = self.background or win32gui.GetPixel( hdc, rect[0], rect[1] ) 299 | self.last_background = background 300 | except: 301 | print "FIXME: Figure out why GetPixel( hdc, 0, 0 ) is failing..." 302 | #traceback.print_exc() 303 | #print "GetLastError() => {}".format( win32api.GetLastError() ) 304 | background = self.last_background 305 | 306 | color = ((background >> 0 ) & 255, 307 | (background >> 8 ) & 255, 308 | (background >> 16 ) & 255, 309 | 255) 310 | size = ( rect[2] - rect[0], rect[3] - rect[1] ) 311 | combined = self.render( size, color ) 312 | self.image = Image.Bitmap( combined ) 313 | 314 | def dirty( self, window ): 315 | rect = self.rect or window.get_client_region() 316 | size = ( rect[2] - rect[0], rect[3] - rect[1] ) 317 | result = self.image is None or self.image.size != size 318 | return result 319 | 320 | def invalidate( self ): 321 | self.image = None 322 | 323 | def __call__( self, window, hdc, paint_struct ): 324 | dirty = self.dirty( window ) 325 | if dirty: 326 | self.update( window, hdc ) 327 | 328 | rect = self.rect or window.get_client_region() 329 | roi = rect_intersect( rect, paint_struct[2] ) 330 | hdc_mem = win32gui.CreateCompatibleDC( hdc ) 331 | prior_bitmap = win32gui.SelectObject( hdc_mem, self.image.handle ) 332 | 333 | win32gui.BitBlt( hdc, 334 | roi[0], 335 | roi[1], 336 | roi[2] - roi[0], 337 | roi[3] - roi[1], 338 | hdc_mem, 339 | roi[0] - rect[0], 340 | roi[1] - rect[1], 341 | win32con.SRCCOPY ) 342 | 343 | win32gui.SelectObject( hdc_mem, prior_bitmap ) 344 | win32gui.DeleteDC( hdc_mem ) 345 | 346 | class BitmapLayer( Layer ): 347 | ''' 348 | Stretch a bitmap across an ROI. May no longer be useful... 349 | ''' 350 | 351 | def __init__( self, bitmap, src_roi = None, dst_roi = None, blend = None ): 352 | super(Window.BitmapLayer, self).__init__() 353 | self.bitmap = bitmap 354 | self.src_roi = src_roi 355 | self.dst_roi = dst_roi 356 | self.blend = blend 357 | 358 | 359 | def __call__( self, window, hdc, paint_struct ): 360 | src_roi = self.src_roi or (0, 0, self.bitmap.size[0], self.bitmap.size[1]) 361 | dst_roi = self.dst_roi or win32gui.GetClientRect( window.window_handle ) 362 | blend = self.blend or (win32con.AC_SRC_OVER, 0, 255, win32con.AC_SRC_ALPHA ) 363 | 364 | hdc_mem = win32gui.CreateCompatibleDC( hdc ) 365 | prior = win32gui.SelectObject( hdc_mem, self.bitmap.handle ) 366 | 367 | # Blit with alpha channel blending 368 | win32gui.AlphaBlend( hdc, 369 | dst_roi[ 0 ], 370 | dst_roi[ 1 ], 371 | dst_roi[ 2 ] - dst_roi[ 0 ], 372 | dst_roi[ 3 ] - dst_roi[ 1 ], 373 | hdc_mem, 374 | src_roi[ 0 ], 375 | src_roi[ 1 ], 376 | src_roi[ 2 ] - src_roi[ 0 ], 377 | src_roi[ 3 ] - src_roi[ 1 ], 378 | blend ) 379 | 380 | win32gui.SelectObject( hdc_mem, prior ) 381 | win32gui.DeleteDC( hdc_mem ) 382 | 383 | 384 | class TextLayer( Layer ): 385 | ''' 386 | Stub text layer, need to add font handling. 387 | ''' 388 | 389 | def __init__( self, text, rect, style = win32con.DT_WORDBREAK, 390 | font = None, 391 | color = None ): 392 | assert( isinstance( style, int ) ) 393 | self.text = text 394 | self.rect = rect 395 | self.style = style 396 | self.font = font 397 | self.color = color 398 | self.bk_mode = win32con.TRANSPARENT 399 | self.height = None 400 | self.roi = None 401 | 402 | def _set_text( self, text ): 403 | self.text = re.sub( "(\r\n|\n|\r)", "\r\n", text, re.M ) 404 | 405 | def set_props( self, window = None, **kwargs ): 406 | 407 | if window and self.roi: 408 | win32gui.InvalidateRect( window.window_handle, 409 | self.roi, True ) 410 | 411 | for key in ('text','rect','style','font','color'): 412 | if key in kwargs: 413 | setter = '_set_' + key 414 | if hasattr( self, setter ): 415 | getattr( self, setter )( kwargs[ key ] ) 416 | else: 417 | setattr( self, key, kwargs[ key ] ) 418 | 419 | if window: 420 | hdc = win32gui.GetWindowDC( window.window_handle ) 421 | roi = self.calc_roi( hdc ) 422 | win32gui.ReleaseDC( window.window_handle, hdc ) 423 | win32gui.InvalidateRect( window.window_handle, 424 | roi, True ) 425 | 426 | def calc_roi( self, hdc ): 427 | ''' 428 | Figure out where text is actually drawn given a rect and a hdc. 429 | 430 | DT_CALCRECT disables drawing and updates the width parameter of the 431 | rectangle(but only width!) 432 | 433 | Use DT_LEFT, DT_RIGHT, DT_CENTER and DT_TOP, DT_BOTTOM, DT_VCENTER 434 | to back out actual roi. 435 | ''' 436 | 437 | if self.font: 438 | original_font = win32gui.SelectObject( hdc, self.font ) 439 | 440 | # Height from DT_CALCRECT is strange...maybe troubleshoot later... 441 | #height, roi = win32gui.DrawText( hdc, 442 | # self.text, 443 | # len( self.text ), 444 | # self.rect, 445 | # self.style | win32con.DT_CALCRECT ) 446 | #self.width = roi[2] - roi[0] 447 | #print "roi {}".format( (self.width, self.height) ) 448 | 449 | # FIXME: manually line wrap:( 450 | # 451 | if self.style & win32con.DT_SINGLELINE: 452 | (self.width,self.height) = win32gui.GetTextExtentPoint32( hdc, self.text ) 453 | else: 454 | (self.width, self.height) = (0,0) 455 | for line in self.text.split( '\r\n' ): 456 | (width,height) = win32gui.GetTextExtentPoint32( hdc, line ) 457 | self.height += height 458 | self.width = max( self.width, width ) 459 | 460 | if self.font: 461 | win32gui.SelectObject( hdc, original_font ) 462 | 463 | # Resolve text style against DC alignment 464 | # 465 | align = win32gui.GetTextAlign( hdc ) 466 | 467 | if self.style & win32con.DT_CENTER: 468 | horizontal = win32con.TA_CENTER 469 | elif self.style & win32con.DT_RIGHT: 470 | horizontal = win32con.TA_RIGHT 471 | elif self.style & win32con.DT_LEFT: 472 | horizontal = win32con.TA_LEFT 473 | else: 474 | horizontal = align & ( win32con.TA_LEFT | win32con.TA_RIGHT | win32con.TA_CENTER ) 475 | 476 | if self.style & win32con.DT_VCENTER: 477 | vertical = win32con.VTA_CENTER 478 | elif self.style & win32con.DT_BOTTOM: 479 | vertical = win32con.TA_BOTTOM 480 | elif self.style & win32con.DT_TOP: 481 | vertical = win32con.TA_TOP 482 | else: 483 | vertical = align & ( win32con.TA_TOP | win32con.TA_BOTTOM | win32con.VTA_CENTER ) 484 | 485 | # Calc ROI from resolved alignment 486 | # 487 | if horizontal == win32con.TA_CENTER: 488 | x_min = (self.rect[ 2 ] + self.rect[ 0 ] - self.width)/2 489 | x_max = x_min + self.width 490 | elif horizontal == win32con.TA_RIGHT: 491 | x_min = self.rect[ 2 ] - self.width 492 | x_max = self.rect[ 2 ] 493 | else: # horizontal == win32con.TA_LEFT 494 | x_min = self.rect[ 0 ] 495 | x_max = self.rect[ 0 ] + self.width 496 | 497 | if vertical == win32con.VTA_CENTER: 498 | y_min = (self.rect[ 1 ] + self.rect[ 3 ] - self.height)/2 499 | y_max = y_min + self.height 500 | elif vertical == win32con.TA_BOTTOM: 501 | y_min = self.rect[ 3 ] - self.height 502 | y_max = self.rect[ 3 ] 503 | else: # vertical == win32con.TA_TOP 504 | y_min = self.rect[ 1 ] 505 | y_max = self.rect[ 1 ] + self.height 506 | 507 | self.roi = (x_min, y_min, x_max, y_max) 508 | return self.roi 509 | 510 | __mode_setters = { 511 | 'font': win32gui.SelectObject, 512 | 'color': win32gui.SetTextColor, 513 | 'bk_mode': win32gui.SetBkMode 514 | } 515 | 516 | def __call__( self, window, hdc, paint_struct ): 517 | 518 | prior = {} 519 | for key, setter in self.__mode_setters.items(): 520 | value = getattr( self, key ) 521 | if value is not None: 522 | prior[ key ] = setter( hdc, getattr( self, key ) ) 523 | 524 | self.calc_roi( hdc ) 525 | 526 | win32gui.DrawText( hdc, 527 | self.text, 528 | len( self.text ), 529 | self.rect, 530 | self.style ) 531 | 532 | for key, value in prior.items(): 533 | self.__mode_setters[ key ]( hdc, value ) 534 | 535 | 536 | class Control( Registry.AutoRegister ): 537 | ''' 538 | Base class for controls based subwindows (common controls) 539 | ''' 540 | 541 | _next_control_id = 1024 542 | 543 | def __init__( self ): 544 | super( Window.Control, self ).__init__() 545 | self.action = None 546 | 547 | def __call__( self, window, message, wParam, lParam ): 548 | print "Not implemented __call__ for " + self.__class__.__name__ 549 | 550 | def __del__( self ): 551 | if hasattr( self, 'handle' ): 552 | win32gui.DestroyWindow( self.handle ) 553 | 554 | def set_size( self, rect ): 555 | win32gui.MoveWindow( self.handle, 556 | rect[ 0 ], 557 | rect[ 1 ], 558 | rect[ 2 ] - rect[ 0 ], 559 | rect[ 3 ] - rect[ 1 ], 560 | True ) 561 | 562 | 563 | def set_action( self, action ): 564 | win32gui.EnableWindow( self.handle, action.sensitive ) 565 | win32gui.SetWindowText( self.handle, action.label ) 566 | self.action = action 567 | 568 | def set_font( self, font ): 569 | win32gui.SendMessage( self.handle, win32con.WM_SETFONT, font, True ) 570 | 571 | class Button( Control ): 572 | 573 | def __init__( self, parent, rect, action ): 574 | super( Window.Button, self ).__init__() 575 | 576 | style = win32con.WS_TABSTOP | win32con.WS_VISIBLE | win32con.WS_CHILD | win32con.BS_DEFPUSHBUTTON 577 | 578 | self.handle = win32gui.CreateWindowEx( 0, 579 | "BUTTON", 580 | action.label, 581 | style, 582 | rect[ 0 ], 583 | rect[ 1 ], 584 | rect[ 2 ], 585 | rect[ 3 ], 586 | parent.window_handle, 587 | self.registry_id, 588 | win32gui.GetModuleHandle(None), 589 | None ) 590 | self.set_action( action ) 591 | 592 | def __call__( self, window, message, wParam, lParam ): 593 | self.action( window, message, wParam, lParam ) 594 | 595 | 596 | class ProgressBar( Control ): 597 | # https://msdn.microsoft.com/en-us/library/windows/desktop/hh298373(v=vs.85).aspx 598 | # 599 | def __init__( self, parent ): 600 | super( Window.ProgressBar, self ).__init__() 601 | rect = win32gui.GetClientRect( parent.window_handle ) 602 | yscroll = win32api.GetSystemMetrics(win32con.SM_CYVSCROLL) 603 | self.handle = win32gui.CreateWindowEx( 0, 604 | commctrl.PROGRESS_CLASS, 605 | None, 606 | win32con.WS_VISIBLE | win32con.WS_CHILD, 607 | rect[ 0 ] + yscroll, 608 | (rect[ 3 ]) - 2 * yscroll, 609 | (rect[ 2 ] - rect[ 0 ]) - 2*yscroll, 610 | yscroll, 611 | parent.window_handle, 612 | self.registry_id, 613 | win32gui.GetModuleHandle(None), 614 | None ) 615 | 616 | 617 | def set_range( self, value ): 618 | win32gui.SendMessage( self.handle, 619 | commctrl.PBM_SETRANGE, 620 | 0, 621 | win32api.MAKELONG( 0, value ) ) 622 | def set_step( self, value ): 623 | win32gui.SendMessage( self.handle, commctrl.PBM_SETSTEP, int( value ), 0 ) 624 | 625 | def set_pos( self, value ): 626 | win32gui.SendMessage( self.handle, commctrl.PBM_SETPOS, int( value ), 0 ) 627 | 628 | @classmethod 629 | def _make_window_class_name( cls ): 630 | result = "window_class_{}".format( cls._next_window_class_id ) 631 | cls._next_window_class_id += 1 632 | return result 633 | 634 | _notify_event_id = win32con.WM_USER + 22 635 | 636 | def __init__(self, title, style, 637 | size = (win32con.CW_USEDEFAULT, 638 | win32con.CW_USEDEFAULT), 639 | messages = {}): 640 | '''Setup a window class and a create window''' 641 | self.layers = [] 642 | self.module_handle = win32gui.GetModuleHandle(None) 643 | self.systray = False 644 | self.systray_map = { 645 | win32con.WM_RBUTTONDOWN: self._show_menu 646 | } 647 | 648 | # Setup window class 649 | # 650 | self.window_class_name = self._make_window_class_name() 651 | self.message_map = { 652 | win32con.WM_PAINT: self._on_paint, 653 | win32con.WM_CLOSE: self._on_close, 654 | win32con.WM_COMMAND: self._on_command, 655 | self._notify_event_id: self._on_notify, 656 | } 657 | self.message_map.update( messages ) 658 | self.window_class = win32gui.WNDCLASS() 659 | self.window_class.style = win32con.CS_HREDRAW | win32con.CS_VREDRAW 660 | self.window_class.lpfnWndProc = self.message_map 661 | self.window_class.hInstance = self.module_handle 662 | self.window_class.hCursor = win32gui.LoadCursor( None, win32con.IDC_ARROW ) 663 | self.window_class.hbrBackground = win32con.COLOR_WINDOW 664 | self.window_class.lpszClassName = self.window_class_name 665 | 666 | self.window_classHandle = win32gui.RegisterClass( self.window_class ) 667 | 668 | self.window_handle = win32gui.CreateWindow( 669 | self.window_class_name, 670 | title, 671 | style, 672 | win32con.CW_USEDEFAULT, 673 | win32con.CW_USEDEFAULT, 674 | size[ 0 ], 675 | size[ 1 ], 676 | None, 677 | None, 678 | self.module_handle, 679 | None ) 680 | 681 | def set_visibility( self, visibility ): 682 | state = win32con.SW_SHOW if visibility else win32con.SW_HIDE 683 | win32gui.ShowWindow( self.window_handle, state ) 684 | win32gui.UpdateWindow( self.window_handle ) 685 | 686 | def get_visibility( self ): 687 | return win32gui.IsWindowVisible( self.window_handle ) 688 | 689 | def get_size( self ): 690 | return win32gui.GetWindowRect( self.window_handle ) 691 | 692 | def get_client_region( self ): 693 | return win32gui.GetClientRect( self.window_handle ) 694 | 695 | def set_size( self, rect ): 696 | win32gui.MoveWindow( self.window_handle, 697 | rect[ 0 ], 698 | rect[ 1 ], 699 | rect[ 2 ] - rect[ 0 ], 700 | rect[ 3 ] - rect[ 1 ], 701 | True ) 702 | 703 | @staticmethod 704 | def screen_size(): 705 | return tuple( map( win32api.GetSystemMetrics, 706 | (win32con.SM_CXVIRTUALSCREEN, 707 | win32con.SM_CYVIRTUALSCREEN))) 708 | 709 | def center( self ): 710 | rect = self.get_size() 711 | screen_size = self.screen_size() 712 | width = rect[2]-rect[0] 713 | height = rect[3]-rect[1] 714 | rect = ((screen_size[ 0 ] - width)/2, 715 | (screen_size[ 1 ] - height)/2, 716 | (screen_size[ 0 ] + width)/2, 717 | (screen_size[ 1 ] + height)/2) 718 | self.set_size( rect ) 719 | 720 | def focus( self ): 721 | win32gui.SetForegroundWindow( self.window_handle ) 722 | 723 | def set_icon( self, small_icon, big_icon ): 724 | # https://stackoverflow.com/questions/16472538/changing-taskbar-icon-programatically-win32-c 725 | # 726 | win32gui.SendMessage(self.window_handle, 727 | win32con.WM_SETICON, 728 | win32con.ICON_BIG, 729 | big_icon.handle ) 730 | 731 | win32gui.SendMessage(self.window_handle, 732 | win32con.WM_SETICON, 733 | win32con.ICON_SMALL, 734 | small_icon.handle ) 735 | 736 | 737 | def show_toast( self, title, baloon, timeout ): 738 | if self.small_icon: 739 | message = win32gui.NIM_MODIFY 740 | data = (self.window_handle, 741 | 0, 742 | win32gui.NIF_INFO | win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP, 743 | self._notify_event_id, 744 | self.small_icon.handle, 745 | self.text, 746 | baloon, 747 | int(timeout * 1000), 748 | title) 749 | 750 | win32gui.Shell_NotifyIcon( message, data ) 751 | else: 752 | print "Can't send popup without systray!" 753 | 754 | def set_systray_actions( self, actions ): 755 | self.systray_map.update( actions ) 756 | 757 | def set_systray( self, small_icon = None, text = '' ): 758 | if small_icon: 759 | self.small_icon = small_icon 760 | self.text = text 761 | message = win32gui.NIM_MODIFY if self.systray else win32gui.NIM_ADD 762 | data = (self.window_handle, 763 | 0, 764 | win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP, 765 | self._notify_event_id, 766 | self.small_icon.handle, 767 | self.text) 768 | elif self.systray: 769 | message = win32gui.NIM_DELETE 770 | data = (self.window_handle, 0) 771 | else: 772 | message = None 773 | data = tuple() 774 | 775 | self.systray = True if small_icon else False 776 | 777 | if message is not None: 778 | win32gui.Shell_NotifyIcon( message, data ) 779 | 780 | def set_menu( self, actions ): 781 | self.menu_actions = actions 782 | 783 | def _on_command( self, window_handle, message, wparam, lparam ): 784 | target_id = win32gui.LOWORD(wparam) 785 | target = Registry.lookup( target_id ) 786 | target( self, message, wparam, lparam ) 787 | return 0 788 | 789 | def _on_notify( self, window_handle, message, wparam, lparam ): 790 | try: 791 | self.systray_map[ lparam ]() 792 | except KeyError: 793 | pass 794 | return True 795 | 796 | def _show_menu( self ): 797 | menu = win32gui.CreatePopupMenu() 798 | for action in self.menu_actions: 799 | if action: 800 | flags = win32con.MF_STRING 801 | if not action.sensitive: 802 | flags |= win32con.MF_GRAYED 803 | win32gui.AppendMenu( menu, flags, action.get_id(), action.label ) 804 | else: 805 | win32gui.AppendMenu( menu, win32con.MF_SEPARATOR, 0, '' ) 806 | 807 | pos = win32gui.GetCursorPos() 808 | 809 | win32gui.SetForegroundWindow( self.window_handle ) 810 | win32gui.TrackPopupMenu( menu, 811 | win32con.TPM_LEFTALIGN | win32con.TPM_BOTTOMALIGN, 812 | pos[ 0 ], 813 | pos[ 1 ], 814 | 0, 815 | self.window_handle, 816 | None ) 817 | win32gui.PostMessage( self.window_handle, win32con.WM_NULL, 0, 0 ) 818 | 819 | def _on_paint( self, window_handle, message, wparam, lparam ): 820 | (hdc, paint_struct) = win32gui.BeginPaint( self.window_handle ) 821 | for layer in self.layers: 822 | layer( self, hdc, paint_struct ) 823 | win32gui.EndPaint( self.window_handle, paint_struct ) 824 | return 0 825 | 826 | def _on_close( self, window_handle, message, wparam, lparam ): 827 | self.set_visibility( False ) 828 | return 0 829 | 830 | def destroy( self ): 831 | self.set_systray( None, None ) 832 | win32gui.DestroyWindow( self.window_handle ) 833 | win32gui.UnregisterClass( self.window_class_name, self.module_handle ) 834 | self.window_handle = None 835 | 836 | def __del__( self ): 837 | # check that window was destroyed 838 | assert( self.window_handle == None ) 839 | 840 | def close( self ): 841 | self.onClose() 842 | 843 | class WinapiGUI(BaseGUI): 844 | """ 845 | Winapi GUI, using pywin32 to programatically generate/update GUI components. 846 | 847 | Background: pywin32 presents the windows C API via python bindings, with 848 | minimal helpers where neccissary. In essence, using pywin32 is C winapi 849 | programming, just via python, a very low-level experience. Some things are 850 | slightly different from C(perhaps to work with the FFI), some things plain 851 | just don't work(unclear if windows or python is at fault), some things are 852 | entirely abscent(for example, PutText(...)). In short, it's the usual 853 | windows/microsoft experience. When in doubt, google C(++) examples and msdn 854 | articles. 855 | 856 | Structure: We create and maintain context for two windows, applying state 857 | changes directly to the associated context. While a declarative approach 858 | would be possible, it would run contrary to WINAPI's design and hit some 859 | pywin32 limitations. For asthetic conformance, each window will have it's 860 | own window class and associated resources. We will provide a high level 861 | convience wrapper around window primatives to abstract much of the c API 862 | boilerplate. 863 | 864 | For indicator purposes, we create a system tray resource. This also requires 865 | a window, though the window is never shown. The indicator menu is attached 866 | to the systray, as are the icons. 867 | 868 | TODO: Notifications 869 | 870 | Window Characteristics: 871 | - Style: Splash vs regular window. This maps pretty well onto window 872 | class style in winapi. Splash is an image with progress bar and text, 873 | regular window has regular boarders and some status items. 874 | - Graphic resources: For winapi, we'll have to convert all graphics into 875 | bitmaps, using winapi buffers to hold the contents. 876 | - Menu items: We'll have to manage associations 877 | between menu items, actions, and all that: For an item ID (gui-o-matic 878 | protocol), we'll have to track menu position, generate a winapi command 879 | id, and catch/map associated WM_COMMAND event back to actions. This 880 | allows us to toggle sensitivity, replace text, etc. 881 | """ 882 | 883 | _variable_re = re.compile( "%\(([\w]+)\)s" ) 884 | 885 | _progress_range = 1000 886 | 887 | # Signal that our Queue should be drained 888 | # 889 | WM_USER_QUEUE = win32con.WM_USER + 26 890 | 891 | def _lookup_token( self, match ): 892 | ''' 893 | Convert re match token to variable definitions. 894 | ''' 895 | return self.variables[ match.group( 1 ) ] 896 | 897 | def _resolve_variables( self, path ): 898 | ''' 899 | Apply %(variable) expansion. 900 | ''' 901 | return self._variable_re.sub( self._lookup_token, path ) 902 | 903 | def __init__(self, config, variables = {'theme': 'light' } ): 904 | ''' 905 | Inflate superclass--defer construction to run(). 906 | ''' 907 | super(WinapiGUI,self).__init__(config) 908 | self.variables = variables 909 | self.ready = False 910 | self.statuses = {} 911 | self.items = {} 912 | 913 | def layout_displays( self, padding = 10 ): 914 | ''' 915 | layout displays top-to-bottom, placing notification text after 916 | 917 | TODO: use 2 passes to split v-spacing 918 | ''' 919 | region = self.main_window.get_client_region() 920 | region = (region[0] + padding, 921 | region[1] + padding, 922 | region[2] - 2 * padding, 923 | min(region[3] - 2 * padding, self.button_region[1])) 924 | 925 | hdc = win32gui.GetWindowDC( self.main_window.window_handle ) 926 | def display_keys(): 927 | items = self.config['main_window']['status_displays'] 928 | return map( lambda item: item['id'], items ) 929 | 930 | rect = region 931 | 932 | for key in display_keys(): 933 | display = self.displays[ key ] 934 | 935 | detail_text = display.details.text 936 | detail_lines = max( detail_text.count( '\n' ), 2 ) 937 | display.details.set_props( text = 'placeholder\n' * detail_lines ) 938 | rect = display.layout( hdc, rect, padding ) 939 | display.details.set_props( text = detail_text ) 940 | 941 | if len( self.displays ) > 1: 942 | v_spacing = min( (rect[3] - rect[1]) / (len( self.displays ) -1), padding * 2 ) 943 | else: 944 | v_spacing = 0 945 | rect = region 946 | 947 | for key in display_keys(): 948 | display = self.displays[ key ] 949 | 950 | detail_text = display.details.text 951 | detail_lines = max( detail_text.count( '\n' ), 2 ) 952 | display.details.set_props( text = 'placeholder\n' * detail_lines ) 953 | rect = display.layout( hdc, rect, padding ) 954 | display.details.set_props( text = detail_text ) 955 | 956 | rect = (rect[0], 957 | rect[1] + v_spacing, 958 | rect[2], 959 | rect[3]) 960 | 961 | #self.notification_text.rect = rect 962 | win32gui.ReleaseDC( self.main_window.window_handle, hdc ) 963 | 964 | def layout_buttons( self, padding = 10, spacing = 10 ): 965 | ''' 966 | layout buttons, assuming the config declaration is in order. 967 | ''' 968 | def button_items(): 969 | button_keys = map( lambda item: item['id'], 970 | self.config['main_window']['action_items'] ) 971 | return map( lambda key: self.items[ key ], button_keys ) 972 | window_size = self.main_window.get_client_region() 973 | 974 | # Layout left to right across the bottom 975 | min_width = 20 976 | min_height = 20 977 | x_offset = window_size[0] + spacing 978 | y_offset = window_size[3] - window_size[1] - spacing 979 | x_limit = window_size[2], 980 | y_min = y_offset 981 | 982 | for index, item in enumerate( button_items() ): 983 | action = item[ 'action' ] 984 | button = item[ 'control' ] 985 | 986 | hdc = win32gui.GetDC( button.handle ) 987 | prior_font = win32gui.SelectObject( hdc, self.fonts['buttons'] ) 988 | width, height = win32gui.GetTextExtentPoint32( hdc, action.label ) 989 | win32gui.SelectObject( hdc, prior_font ) 990 | win32gui.ReleaseDC( None, hdc ) 991 | 992 | width = max( width + padding * 2, min_width ) 993 | height = max( height + padding, min_height ) 994 | 995 | # create new row if wrapping needed(not well tested) 996 | if x_offset + width > x_limit: 997 | x_offset = window_size[0] + spacing 998 | y_offset -= spacing + height 999 | 1000 | y_min = min( y_min, y_offset - height ) 1001 | 1002 | rect = (x_offset, 1003 | y_offset - height, 1004 | x_offset + width, 1005 | y_offset) 1006 | 1007 | button.set_size( rect ) 1008 | x_offset += width + spacing 1009 | 1010 | self.button_region = (window_size[0] + spacing, 1011 | y_min, 1012 | window_size[2] - spacing * 2, 1013 | window_size[3] - window_size[ 1 ] - spacing) 1014 | 1015 | notification_rect = (x_offset, 1016 | y_min, 1017 | window_size[2] - spacing * 2, 1018 | y_offset) 1019 | 1020 | self.notification_text.set_props( self.main_window, rect = notification_rect ) 1021 | 1022 | # Force buttons to refresh overlapped regions 1023 | for item in button_items(): 1024 | button = item[ 'control' ] 1025 | win32gui.InvalidateRect( button.handle, None, False ) 1026 | 1027 | def create_action( self, control_factory, item ): 1028 | action = Action( self.proxy or self, 1029 | identifier = item['id'], 1030 | label = item['label'], 1031 | operation = item.get('op'), 1032 | args = item.get('args'), 1033 | sensitive = item.get('sensitive', True)) 1034 | control = control_factory( action ) 1035 | self.items[action.identifier] = dict( action = action, control = control ) 1036 | 1037 | def create_menu_control( self, action ): 1038 | return None 1039 | 1040 | def create_button_control( self, action ): 1041 | control = Window.Button( self.main_window, (10,10,100,30), action ) 1042 | control.set_font( self.fonts['buttons'] ) 1043 | return control 1044 | 1045 | def create_controls( self ): 1046 | ''' 1047 | Grab all the controls (actions+menu items) out of the config 1048 | and instantiate them. self.items contains action+control pairs 1049 | for each item. 1050 | ''' 1051 | # menu items 1052 | for item in self.config['indicator']['menu_items']: 1053 | if 'id' in item: 1054 | self.create_action( self.create_menu_control, item ) 1055 | 1056 | menu_items = [] 1057 | for item in self.config['indicator']['menu_items']: 1058 | if 'id' in item: 1059 | menu_item = self.items[ item['id'] ]['action'] 1060 | else: 1061 | menu_item = None #separator 1062 | menu_items.append( menu_item ) 1063 | self.systray_window.set_menu( menu_items ) 1064 | 1065 | # actions 1066 | for item in self.config['main_window']['action_items']: 1067 | self.create_action( self.create_button_control, item ) 1068 | 1069 | self.layout_buttons() 1070 | 1071 | def create_font( self, hdc, points = 0, family = None, bold = False, italic = False ): 1072 | ''' 1073 | Create font objects for configured fonts 1074 | ''' 1075 | font_config = win32gui.LOGFONT() 1076 | #https://support.microsoft.com/en-us/help/74299/info-calculating-the-logical-height-and-point-size-of-a-font 1077 | font_config.lfHeight = -int((points * win32print.GetDeviceCaps(hdc, win32con.LOGPIXELSY))/72) 1078 | font_config.lfWidth = 0 1079 | font_config.lfWeight = win32con.FW_BOLD if bold else win32con.FW_NORMAL 1080 | font_config.lfItalic = italic 1081 | font_config.lfCharSet = win32con.DEFAULT_CHARSET 1082 | font_config.lfOutPrecision = win32con.OUT_TT_PRECIS 1083 | font_config.lfClipPrecision = win32con.CLIP_DEFAULT_PRECIS 1084 | font_config.lfQuality = win32con.CLEARTYPE_QUALITY 1085 | font_config.lfPitchAndFamily = win32con.DEFAULT_PITCH | win32con.FF_DONTCARE 1086 | 1087 | if family and family not in self.known_fonts: 1088 | print "Unknown font: '{}', using '{}'".format( family, self.default_font ) 1089 | 1090 | font_config.lfFaceName = family if family in self.known_fonts else self.default_font 1091 | 1092 | return win32gui.CreateFontIndirect( font_config ) 1093 | 1094 | def create_fonts( self ): 1095 | ''' 1096 | Create all font objects 1097 | ''' 1098 | self.known_fonts = {} 1099 | def handle_font( font_config, text_metric, font_type, param ): 1100 | #print font_config.lfFaceName 1101 | self.known_fonts[ font_config.lfFaceName ] = font_config 1102 | return True 1103 | 1104 | hdc = win32gui.GetWindowDC( self.main_window.window_handle ) 1105 | 1106 | #print "=== begin availalbe fonts ===" 1107 | win32gui.EnumFontFamilies( hdc, None, handle_font, None ) 1108 | #print "=== end available fonts ===" 1109 | 1110 | # https://stackoverflow.com/questions/6057239/which-font-is-the-default-for-mfc-dialog-controls 1111 | self.non_client_metrics = win32gui.SystemParametersInfo( win32con.SPI_GETNONCLIENTMETRICS, None, 0 ) 1112 | self.default_font = self.non_client_metrics[ 'lfMessageFont' ].lfFaceName 1113 | 1114 | #print "Default font: " + self.default_font 1115 | keys = ( 'title', 'details', 'notification', 'splash', 'buttons' ) 1116 | font_config = self.config.get( 'font_styles', {} ) 1117 | self.fonts = { key: self.create_font( hdc, **font_config.get(key, {}) ) for key in keys } 1118 | if 'buttons' not in self.fonts: 1119 | self.fonts['buttons'] = win32gui.CreateFontIndirect( self.non_client_metrics[ 'lfMessageFont' ] ) 1120 | 1121 | win32gui.ReleaseDC( self.main_window.window_handle, hdc ) 1122 | 1123 | class StatusDisplay( object ): 1124 | 1125 | def __init__( self, gui, id, icon = None, title = ' ', details = ' ' ): 1126 | self.title = Window.TextLayer( text = title, 1127 | rect = (0,0,0,0), 1128 | style = win32con.DT_SINGLELINE, 1129 | font = gui.fonts[ 'title' ] ) 1130 | self.details = Window.TextLayer( text = details, 1131 | rect = (0,0,0,0), 1132 | font = gui.fonts[ 'details' ] ) 1133 | self.icon = Compositor.Blend( gui.open_image( icon ) ) 1134 | 1135 | self.id = id 1136 | 1137 | def layout( self, hdc, rect, spacing ): 1138 | self.title.rect = rect 1139 | title_roi = self.title.calc_roi( hdc ) 1140 | self.details.rect = (rect[0], title_roi[3], rect[2], rect[3]) 1141 | details_roi = self.details.calc_roi( hdc ) 1142 | 1143 | text_height = details_roi[3] - title_roi[1] 1144 | 1145 | icon_roi = (rect[0], 1146 | rect[1], 1147 | rect[0] + text_height, 1148 | rect[1] + text_height) 1149 | 1150 | self.icon.rect = icon_roi 1151 | 1152 | title_roi = (rect[0] + text_height + spacing, 1153 | title_roi[1], 1154 | rect[2], 1155 | title_roi[3]) 1156 | 1157 | self.title.rect = title_roi 1158 | 1159 | details_rect = (rect[0] + text_height + spacing, 1160 | details_roi[1], 1161 | rect[2], 1162 | details_roi[3]) 1163 | 1164 | self.details.rect = details_rect 1165 | 1166 | self.rect = (rect[0], rect[1], details_rect[3], rect[3]) 1167 | 1168 | return (rect[0], 1169 | details_rect[3], 1170 | rect[2], 1171 | rect[3]) 1172 | 1173 | def create_displays( self ): 1174 | ''' 1175 | create status displays and do layout 1176 | ''' 1177 | self.displays = { item['id']: self.StatusDisplay( gui = self, **item ) for item in self.config['main_window']['status_displays'] } 1178 | 1179 | for display in self.displays.values(): 1180 | layers = ( display.title, display.details ) 1181 | self.main_window.layers.extend( layers ) 1182 | self.compositor.operations.append( display.icon ) 1183 | 1184 | def _process_queue( self, *ignored ): 1185 | ''' 1186 | Drain the thread-safe action queue inside winproc for synchrounous gui side-effects 1187 | ''' 1188 | while self.queue: 1189 | try: 1190 | msg = self.queue.get_nowait() 1191 | msg() 1192 | except Queue.Empty: 1193 | break 1194 | 1195 | def _signal_queue( self ): 1196 | ''' 1197 | signal that there are actions to process in the queue 1198 | ''' 1199 | win32gui.PostMessage( self.systray_window.window_handle, self.WM_USER_QUEUE, 0, 0 ) 1200 | 1201 | def run( self ): 1202 | ''' 1203 | Initialize GUI and enter run loop 1204 | ''' 1205 | # https://stackoverflow.com/questions/1551605/how-to-set-applications-taskbar-icon-in-windows-7/1552105#1552105 1206 | # 1207 | self.appid = unicode( uuid.uuid4() ) 1208 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(self.appid) 1209 | win32gui.InitCommonControls() 1210 | 1211 | user_messages = { self.WM_USER_QUEUE: self._process_queue } 1212 | self.systray_window = Window(title = self.config['app_name'], 1213 | style = Window.systray_style, 1214 | messages = user_messages ) 1215 | 1216 | 1217 | def show_main_window(): 1218 | win32gui.ShowWindow( self.main_window.window_handle, 1219 | win32con.SW_SHOWNORMAL ) 1220 | self.main_window.focus() 1221 | 1222 | def minimize_main_window(*ignored ): 1223 | win32gui.ShowWindow( self.main_window.window_handle, 1224 | win32con.SW_SHOWMINIMIZED ) 1225 | return True 1226 | 1227 | self.systray_window.set_systray_actions({ 1228 | win32con.WM_LBUTTONDBLCLK: show_main_window 1229 | }) 1230 | 1231 | window_size = ( self.config['main_window']['width'], 1232 | self.config['main_window']['height'] ) 1233 | 1234 | self.main_window = Window(title = self.config['app_name'], 1235 | style = Window.main_window_style, 1236 | size = window_size, 1237 | messages = { win32con.WM_CLOSE: minimize_main_window }) 1238 | 1239 | self.main_window.center() 1240 | self.compositor = Window.CompositorLayer() 1241 | self.main_window.layers.append( self.compositor ) 1242 | 1243 | # need a window to query available fonts 1244 | self.create_fonts() 1245 | 1246 | window_roi = win32gui.GetClientRect( self.main_window.window_handle ) 1247 | window_size = tuple( window_roi[2:] ) 1248 | try: 1249 | background_path = self.get_image_path( self.config['main_window']['background'] ) 1250 | background = PIL.Image.open( background_path ) 1251 | self.compositor.operations.append( Compositor.Blend( background ) ) 1252 | except KeyError: 1253 | pass 1254 | 1255 | notification_style = win32con.DT_SINGLELINE | win32con.DT_END_ELLIPSIS | win32con.DT_VCENTER 1256 | self.notification_text = Window.TextLayer( text = self.config['main_window'].get( 'initial_notification', ' ' ), 1257 | rect = self.main_window.get_client_region(), 1258 | font = self.fonts['notification'], 1259 | style = notification_style) 1260 | 1261 | self.main_window.layers.append( self.notification_text ) 1262 | 1263 | self.create_controls() 1264 | self.create_displays() 1265 | self.layout_displays() 1266 | 1267 | self.main_window.set_visibility( self.config['main_window']['show'] ) 1268 | 1269 | self.splash_window = Window(title = self.config['app_name'], 1270 | style = Window.splash_screen_style, 1271 | size = window_size ) 1272 | 1273 | # DT_VCENTER only works with DT_SINGLINE, linewrap needs 1274 | # manual positioning logic. 1275 | # 1276 | self.splash_text = Window.TextLayer( text = '', 1277 | rect = (0,0,0,0), 1278 | style = win32con.DT_SINGLELINE | 1279 | win32con.DT_CENTER | 1280 | win32con.DT_VCENTER, 1281 | font = self.fonts['splash'] ) 1282 | 1283 | self.windows = [ self.main_window, 1284 | self.splash_window, 1285 | self.systray_window ] 1286 | 1287 | self.set_status( 'normal' ) 1288 | 1289 | #FIXME: Does not run! 1290 | # 1291 | @atexit.register 1292 | def cleanup_context(): 1293 | print( "cleanup" ) 1294 | self.systray_window.set_systray( None, None ) 1295 | win32gui.PostQuitMessage(0) 1296 | 1297 | # Enter run loop 1298 | # 1299 | self.ready = True 1300 | if self.proxy: 1301 | self.proxy.ready = True 1302 | 1303 | # Gotta clean up window handles on exit for windows 10, regardless of 1304 | # exit reason 1305 | # 1306 | try: 1307 | win32gui.PumpMessages() 1308 | ''' 1309 | while win32gui.PumpWaitingMessages() == 0: 1310 | if not self.queue: 1311 | continue 1312 | 1313 | self._signal_queue() 1314 | 1315 | while True: 1316 | try: 1317 | msg = self.queue.get_nowait() 1318 | msg() 1319 | except Queue.Empty: 1320 | break 1321 | ''' 1322 | 1323 | finally: 1324 | # Windows 10's CRT crashes if we leave windows open 1325 | # 1326 | self.main_window.destroy() 1327 | self.splash_window.destroy() 1328 | self.systray_window.destroy() 1329 | 1330 | def terminal(self, command='/bin/bash', title=None, icon=None): 1331 | print( "FIXME: Terminal not supported!" ) 1332 | 1333 | def set_status(self, status='startup', badge = 'ignored'): 1334 | icon_path = self.get_image_path( self.config['images'][status] ) 1335 | small_icon = Image.IconSmall( icon_path ) 1336 | large_icon = Image.IconLarge( icon_path ) 1337 | 1338 | for window in self.windows: 1339 | window.set_icon( small_icon, large_icon ) 1340 | 1341 | systray_hover_text = self.config['app_name'] + ": " + status 1342 | self.systray_window.set_systray( small_icon, systray_hover_text ) 1343 | 1344 | def quit(self): 1345 | win32gui.PostQuitMessage(0) 1346 | raise KeyboardInterrupt("User quit") 1347 | 1348 | def set_item(self, id=None, label=None, sensitive = None): 1349 | action = self.items[id]['action'] 1350 | if label: 1351 | action.label = label 1352 | if sensitive is not None: 1353 | action.sensitive = sensitive 1354 | 1355 | control = self.items[id]['control'] 1356 | if control: 1357 | control.set_action( action ) 1358 | self.layout_buttons() 1359 | 1360 | def set_status_display(self, id, title=None, details=None, icon=None, color=None): 1361 | display = self.displays[ id ] 1362 | if title is not None: 1363 | display.title.set_props( self.main_window, text = title ) 1364 | 1365 | if details is not None: 1366 | display.details.set_props( self.main_window, text = details ) 1367 | 1368 | if color is not None: 1369 | 1370 | def decode( pattern, scale, value ): 1371 | match = re.match( pattern, value ) 1372 | hexToInt = lambda hex: int( int( hex, 16 ) * scale ) 1373 | return tuple( map( hexToInt, match.groups() ) ) 1374 | 1375 | decoders = [ 1376 | functools.partial( decode, '^#([\w]{2})([\w]{2})([\w]{2})$', 1 ), 1377 | functools.partial( decode, '^#([\w]{1})([\w]{1})([\w]{1})$', 255.0/15.0 ) 1378 | ] 1379 | 1380 | for decoder in decoders: 1381 | try: 1382 | rgb = win32api.RGB( *decoder(color) ) 1383 | display.title.set_props( self.main_window, color = rgb ) 1384 | display.details.set_props( self.main_window, color = rgb ) 1385 | break 1386 | except AttributeError: 1387 | pass 1388 | 1389 | if icon is not None: 1390 | display.icon.source = self.open_image( icon ) 1391 | self.compositor.invalidate() 1392 | win32gui.InvalidateRect( self.main_window.window_handle, 1393 | display.rect, 1394 | True ) 1395 | 1396 | def update_splash_screen(self, message=None, progress=None): 1397 | if progress: 1398 | self.progress_bar.set_pos( self._progress_range * progress ) 1399 | if message: 1400 | self.splash_text.set_props( self.splash_window, text = message ) 1401 | 1402 | def set_next_error_message(self, message=None): 1403 | self.next_error_message = message 1404 | 1405 | def open_image( self, name ): 1406 | if name: 1407 | return PIL.Image.open( self.get_image_path( name ) ) 1408 | else: 1409 | return PIL.Image.new("RGBA", (1,1), color = (0,0,0,0)) 1410 | 1411 | def get_image_path( self, name ): 1412 | prefix = 'image:' 1413 | if name.startswith( prefix ): 1414 | key = name[ len( prefix ): ] 1415 | name = self.config['images'][ key ] 1416 | path = self._resolve_variables( name ) 1417 | 1418 | # attempt symlink 1419 | with open( path, 'rb' ) as handle: 1420 | data = handle.read(256) 1421 | 1422 | try: 1423 | symlink = data.decode("utf-8") 1424 | base, ignored = os.path.split( path ) 1425 | update = os.path.join( base, symlink ) 1426 | print "Following symlink {} -> {}".format( path, update ) 1427 | return os.path.abspath( update ) 1428 | except UnicodeDecodeError: 1429 | return path 1430 | 1431 | def show_splash_screen(self, height=None, width=None, 1432 | progress_bar=False, background=None, 1433 | message='', message_x=0.5, message_y=0.5): 1434 | 1435 | # Reset splash window layers 1436 | # 1437 | self.splash_window.layers = [] 1438 | 1439 | if background: 1440 | image = PIL.Image.open( self.get_image_path( background ) ) 1441 | background = Window.CompositorLayer() 1442 | background.operations.append( Compositor.Blend( image ) ) 1443 | self.splash_window.layers.append( background ) 1444 | 1445 | if width and height: 1446 | pass 1447 | elif height and not width: 1448 | width = height * image.size[0] / image.size[1] 1449 | elif width and not height: 1450 | height = width * image.size[1] / image.size[0] 1451 | else: 1452 | height = image.size[1] 1453 | width = image.size[0] 1454 | 1455 | if width and height: 1456 | self.splash_window.set_size( (0, 0, width, height) ) 1457 | 1458 | # TODO: position splash text 1459 | # 1460 | window_roi = win32gui.GetClientRect( self.splash_window.window_handle ) 1461 | width = window_roi[2] - window_roi[0] 1462 | height = window_roi[3] - window_roi[1] 1463 | 1464 | text_center = (window_roi[0] + int((window_roi[2] - window_roi[0]) * message_x), 1465 | window_roi[1] + int((window_roi[3] - window_roi[1]) * message_y)) 1466 | width_pad = min(window_roi[2] - text_center[0], 1467 | text_center[0] - window_roi[0]) 1468 | height_pad = min(window_roi[3] - text_center[1], 1469 | text_center[1] - window_roi[1]) 1470 | 1471 | text_roi = (text_center[0] - width_pad, 1472 | text_center[1] - height_pad, 1473 | text_center[0] + width_pad, 1474 | text_center[1] + height_pad) 1475 | 1476 | text_props = { 'rect': text_roi } 1477 | if message: 1478 | text_props['text'] = message 1479 | self.splash_text.set_props( self.splash_window, **text_props ) 1480 | self.splash_window.layers.append( self.splash_text ) 1481 | 1482 | if progress_bar: 1483 | self.progress_bar = Window.ProgressBar( self.splash_window ) 1484 | self.progress_bar.set_range( self._progress_range ) 1485 | self.progress_bar.set_step( 1 ) 1486 | 1487 | self.splash_window.center() 1488 | self.splash_window.set_visibility( True ) 1489 | self.splash_window.focus() 1490 | 1491 | def hide_splash_screen(self): 1492 | self.splash_window.set_visibility( False ) 1493 | if hasattr( self, 'progress_bar' ): 1494 | del self.progress_bar 1495 | 1496 | def show_main_window(self): 1497 | self.main_window.set_visibility( True ) 1498 | self.main_window.focus() 1499 | 1500 | def hide_main_window(self): 1501 | self.main_window.set_visibility( False ) 1502 | 1503 | def _report_error(self, e): 1504 | traceback.print_exc() 1505 | self.notify_user( 1506 | (self.next_error_message or 'Error: %(error)s') 1507 | % {'error': unicode(e)}) 1508 | 1509 | def notify_user(self, message, popup=False, alert = False, actions = []): 1510 | if alert: 1511 | win32gui.FlashWindowEx( self.main_window.window_handle, 1512 | win32con.FLASHW_TRAY | win32con.FLASHW_TIMERNOFG, 1513 | 0, 1514 | 0) 1515 | 1516 | if popup: 1517 | self.systray_window.show_toast( self.config[ 'app_name' ], 1518 | message, 60 ) 1519 | else: 1520 | #if self.main_window.get_visibility(): 1521 | self.notification_text.set_props( self.main_window, 1522 | text = message ) 1523 | 1524 | #if self.splash_window.get_visibility(): 1525 | self.splash_text.set_props( self.splash_window, 1526 | text = message ) 1527 | 1528 | class AsyncWrapper( object ): 1529 | ''' 1530 | Creates a factory that produces proxy-object pairs for a given class. 1531 | 1532 | Motivation: having the control thread call into the GUI whenever is an 1533 | obvious source of race conditions. We could try to do locking throughout, 1534 | but it's both easier and more robust to present an actor-style interface 1535 | to the remote thread. Rather than explicitly re-dispatching each method 1536 | inside WinapiGUI, we just create a proxy object in tandem with the original 1537 | object, leaving the original object intact. 1538 | 1539 | Within the proxy object, we discover and redirect all external methods via 1540 | an async queue(identified by not starting with '_'). Not the best proxy, 1541 | but good enough for our purposes. (We could do better by overriding 1542 | attribute lookup in the proxy instead) 1543 | 1544 | Finally, we provide a touch-up hook, so that external code can correct/ 1545 | adjust behavior. 1546 | ''' 1547 | 1548 | def __init__( self, cls, touchup, get_signal ): 1549 | ''' 1550 | Create a class-like object that wraps creating the specifed class with 1551 | an async proxy interface. 1552 | ''' 1553 | self.cls = cls 1554 | self.proxy = type( "Proxy_" + cls.__name__, (cls,), {} ) 1555 | self.touchup = touchup 1556 | self.get_signal = get_signal 1557 | 1558 | @staticmethod 1559 | def wrap( function, queue, signal ): 1560 | ''' 1561 | Wrap calling functions as async queue messages 1562 | ''' 1563 | @functools.wraps( function ) 1564 | def post_message( *args, **kwargs ): 1565 | msg = functools.partial( function, *args, **kwargs ) 1566 | queue.put( msg ) 1567 | signal() 1568 | 1569 | return post_message 1570 | 1571 | def __call__( self, *args, **kwargs ): 1572 | ''' 1573 | Create a new instance of the wrapped class and a proxy for it, 1574 | returning the proxy. 1575 | ''' 1576 | target = self.cls( *args, **kwargs ) 1577 | proxy = self.proxy( *args, **kwargs ) 1578 | queue = Queue.Queue() 1579 | 1580 | signal = self.get_signal(target) 1581 | 1582 | for attr in dir( target ): 1583 | if attr.startswith('_'): 1584 | continue 1585 | value = getattr( target, attr ) 1586 | if callable( value ): 1587 | setattr( proxy, attr, self.wrap(value ,queue, signal) ) 1588 | 1589 | self.touchup( target, proxy, queue ) 1590 | return proxy 1591 | 1592 | def signal_gui( winapi ): 1593 | return winapi._signal_queue 1594 | 1595 | def touchup_winapi_gui( self, proxy, queue ): 1596 | ''' 1597 | glue winapi gui to the proxy: 1598 | - allow WinapiGUI to toggel proxy.ready 1599 | - specify the async queue 1600 | - override run to be a direct call 1601 | ''' 1602 | self.proxy = proxy 1603 | self.queue = queue 1604 | proxy.run = self.run 1605 | 1606 | GUI = AsyncWrapper( WinapiGUI, touchup_winapi_gui, signal_gui ) 1607 | --------------------------------------------------------------------------------