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