Moved classes into their own special modules
M .hgignore +2 -0
@@ 1,3 1,5 @@ 
 __pycache__
 egg-info
 .cache
+build
+dist

          
R README =>  +0 -0

        
A => README.rst +5 -0
@@ 0,0 1,5 @@ 
+WinRustler
+==========
+
+Program for rustling windows in Windows. Mainly for setting position. Can also
+try setting opacity or removing the border/window frame.

          
M setup.py +6 -5
@@ 5,20 5,21 @@ def read(fname):
     return open(os.path.join(os.path.dirname(__file__), fname)).read()
 
 setup(
-    name = "winrustler",
-    version = "0.0.1",
+    name = "WinRustler",
+    version = "0.9.0",  # Keep in sync with winrustler/__init__.py
     author = "somebody",
     author_email = "somebody@froghat.ca",
-    description = ("thing for rustling windows in windows"),
-    license = "???",
+    description = ("Thing for rustling windows in Windows."),
+    license = "GPLv3",
     packages=['winrustler'],
-    long_description=read('README'),
+    long_description=read('README.rst'),
     classifiers=[
         "Banana :: Bread",
     ],
     entry_points={
         'console_scripts': [
             'winrustler=winrustler.__main__:main',
+            'winrustler-ui=winrustler.ui.__main__:main',
         ],
     },
 )

          
A => winrustler/__init__.py +1 -0
@@ 0,0 1,1 @@ 
+__version__ = '0.9.0'  # Keep in sync with ../setup.py

          
M winrustler/__main__.py +1 -1
@@ 1,4 1,4 @@ 
-# Copyright 2017 Sqwishy Trick. All rights reserved.
+# Copyright 2018 Sqwishy Trick. All rights reserved.
 #
 #  This program is free software: you can redistribute it and/or modify
 #  it under the terms of the GNU General Public License as published by

          
A winrustler/ui/__init__.py +10 -0
@@ 0,0 1,10 @@ 
+import os.path
+
+from PyQt5.QtGui import QIcon
+
+
+RES_DIR = os.path.join(os.path.dirname(__file__), 'res')
+
+
+def icon(filename):
+    return QIcon(os.path.join(RES_DIR, filename))

          
M winrustler/ui/__main__.py +15 -333
@@ 1,351 1,30 @@ 
-""" hi
-"""
-
 import sys
-import os.path
 import logging
 
-import attr
+from PyQt5.QtWidgets import qApp
 
-from PyQt5.QtCore import (
-        QObject,
-        pyqtSignal,
-        pyqtSlot,
-        Qt,
-        QTimer,
-)
-from PyQt5.QtGui import (
-        QIcon,
-)
-from PyQt5.QtWidgets import (
-        qApp,
-        QApplication,
-        QSystemTrayIcon,
-        QMenu,
-        QDialog,
-        QMainWindow,
-        QMessageBox,
-        QAction,
-        QSizePolicy,
-)
+from winrustler.ui.app import WinRustlerApp
+from winrustler.ui.widgets.tray import RustlerTray
 
-from winrustler.core import REGISTRY
-from winrustler.winapi import WindowDiscovery, get_window_title
-from winrustler.ui.winapi import get_window_icon, WinHooker
-from winrustler.ui.state import save_window_state, restore_window_state
-
-VERSION = 420
-ABOUT_TEXT = """\
-<h1>WinRustler</h1>
-
-<p>Version: <b>{VERSION}</b></p>
-
-<p><a href='https://bitbucket.org/sqwishy/winrustler'>bitbucket.org/sqwishy/winrustler</a></p>
-
-<p>Project built with
-<a href='https://www.python.org/'>Python 3</a>,
-<a href='https://www.riverbankcomputing.com/software/pyqt/intro'>Qt 5</a>,
-<a href='https://www.qt.io/'>PyQt5</a>, and
-<a href='https://www.emojione.com/emoji/v2'>EmojiOne v2</a>.</p>
-""".format(**locals())
-RES_DIR = os.path.join(os.path.dirname(__file__), 'res')
 
 logger = logging.getLogger(__name__)
 
 
-def icon(filename):
-    return QIcon(os.path.join(RES_DIR, filename))
-
-
-@attr.s(frozen=True)
-class History(object):
-    search = attr.ib()
-    rustle = attr.ib()
-
-
-class RustlerWindow(QDialog):
-
-    rustle = pyqtSignal(object)
-
-    def __init__(self, winset, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.setAttribute(Qt.WA_DeleteOnClose)
-        self.setWindowTitle("WinRustler")
-
-        from .widgets.select import WindowSelect
-        from .widgets.rustle import MoveControls, FadeControls
-
-        self._select = WindowSelect(self)
-        from PyQt5.QtWidgets import QPushButton
-        self._match_select = QPushButton(icon('1f984.png'), "", self, shortcut="Alt+m")
-        self._match_select.clicked.connect(self._show_match)
-
-        #from PyQt5.QtWidgets import QTabWidget
-        #self._window_tab = QTabWidget(self)
-        #self._window_tab.addTab(self._select, icon('1f44b.png'), "&Selection")
-        #self._window_tab.addTab(self._match, icon('1f50d.png'), "&Match")
-
-        self._move = MoveControls(self)
-        self._fade = FadeControls(self)
-
-        from PyQt5.QtWidgets import QTabWidget
-        self._function_tab = QTabWidget(self)
-        self._function_tab.addTab(self._move, icon('1f4d0.png'), "M&ove")
-        self._function_tab.addTab(self._fade, icon('1f47b.png'), "F&ade")
-
-        from PyQt5.QtWidgets import QDialogButtonBox
-        self._bb = QDialogButtonBox(self)
-        self._bb.accepted.connect(self.accept)
-        self._bb.rejected.connect(self.reject)
-        self._bb.clicked.connect(self._bb_on_clicked)
-
-        self._apply = self._bb.addButton(QDialogButtonBox.Apply)
-        self._apply_and_close = self._bb.addButton('&Apply && Close', QDialogButtonBox.AcceptRole)
-        self._close = self._bb.addButton(QDialogButtonBox.Close)
-
-        from PyQt5.QtWidgets import QVBoxLayout
-        self._layout = QVBoxLayout(self)
-        from PyQt5.QtWidgets import QHBoxLayout
-        self._select_layout = QHBoxLayout()
-        self._select_layout.addWidget(self._select, stretch=1)
-        self._select_layout.addWidget(self._match_select)
-        self._layout.addLayout(self._select_layout)
-        self._layout.addWidget(self._function_tab)
-        self._layout.addWidget(self._bb)
-        self._layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
-
-        self._select.updated.connect(self._refresh_engagement)
-        self._move.updated.connect(self._refresh_engagement)
-        self._refresh_engagement()
-
-        self.winset = winset
-        self.winset.tell_and_connect(self.sync_windows)
-
-    @pyqtSlot(object, object)
-    def sync_windows(self, *args, **kwargs):
-        """
-        This needs to exist so that it can be a slot and Qt disconnects it
-        properly, otherwise PyQt start holding reference to things and fucks
-        everything up.
-        """
-        self._select.sync_windows(*args, **kwargs)
-
-    def _refresh_engagement(self):
-        is_acceptable = self.request() is not None
-        self._apply.setEnabled(is_acceptable)
-        self._apply_and_close.setEnabled(is_acceptable)
-
-    def _bb_on_clicked(self, button):
-        from PyQt5.QtWidgets import QDialogButtonBox
-        if button == self._apply:
-            self.rustle.emit(self.request())
-        elif button == self._apply_and_close:
-            self.rustle.emit(self.request())
-            self.accept()
-        elif button == self._close:
-            self.reject()
-        else:
-            raise NotImplementedError()
-
-    def _show_match(self):
-        #from .widgets import ComposeMatch
-        from .widgets.match import MatchDialog
-        m = MatchDialog(self.winset, parent=self)
-        #m = ComposeMatch(self.winset)
-        #app.tell_windows_and_connect(m.sync_windows)
-        print(m.exec_())
-
-    def request(self):
-        hwnd = self._select.hwnd
-        tab = self._function_tab.currentWidget() 
-        if tab is not None:
-            hwnd = self._select.hwnd()
-            return tab.window_request(hwnd)
-
-    def showEvent(self, event):
-        restore_window_state(self)
-        return super().showEvent(event)
-
-    def hideEvent(self, event):
-        save_window_state(self)
-        return super().hideEvent(event)
-
-
-class RustlerTray(QSystemTrayIcon):
-
-    rustle = pyqtSignal(object)
-
-    def __init__(self, winset, *args, **kwargs):
-        super().__init__(winset, *args, **kwargs)
-        self.rustle_icon = icon('1f412.png')
-        self.about_icon = icon('1f49f.png')
-        self.exit_icon = QIcon()
-
-        self.menu = QMenu(parent=None)
-        self.separator = self.menu.addSeparator()
-        self.rustle_act = self.menu.addAction(self.rustle_icon, '&Rustle...', self.show_window)
-        self.about_act = self.menu.addAction(self.about_icon, '&About...', self._about)
-        self.exit_act = self.menu.addAction(self.exit_icon, '&Exit', self._exit)
-
-        self.setContextMenu(self.menu)
-        self.activated.connect(self._icon_clicky)
-
-        #self.suggest = []
-        self.winset = winset
-        self.window = None
-        self.setIcon(self.rustle_icon)
-
-    #def add_suggestion(self, suggestion):
-    #    #try:
-    #    #    replace = next(s for s in self.suggest if s.value())
-    #    #except StopIteration:
-    #    #    else:
-    #    before = self.suggest[-1] if self.suggest else self.separator
-    #    icon = get_window_icon(suggestion.rustle.hwnd)
-    #    msg = "Move {s.search} to {s.rustle.x}, {s.rustle.y}.".format(s=suggestion)
-    #    act = QAction(icon, msg, self.menu)
-    #    act.setData(suggestion)
-    #    act.triggered.connect(self._do_suggestion)
-    #    self.suggest.append(act)
-    #    self.menu.insertAction(before, act)
-    #    if len(self.suggest) > 2:
-    #        self.menu.removeAction(self.suggest.pop(0))
-
-    #def _do_suggestion(self):
-    #    suggestion = self.sender().data()
-    #    try:
-    #        hwnd = next(hwnd for (hwnd, title) in WindowCollection().items() if title == suggestion.search)
-    #    except StopIteration:
-    #        self.showMessage("Yikes!", "Couldn't find a window matching %r." % suggestion.search)
-    #    else:
-    #        rustle = attr.evolve(suggestion.rustle, hwnd=hwnd)
-    #        self.rustle.emit(rustle)
-
-    def _icon_clicky(self, reason):
-        if reason == QSystemTrayIcon.Trigger:  # Left-click
-            self.show_window()
-
-    def show_window(self):
-        if self.window is None:
-            self.window = RustlerWindow(self.winset)
-            self.window.rustle.connect(self.rustle)
-            self.window.destroyed.connect(self._window_destroyed)
-        self.window.show()
-        self.window.raise_()
-        self.window.activateWindow()
-
-    def show_rustle_message(self, req):
-        icon = get_window_icon(req.hwnd)
-        title = get_window_title(req.hwnd)
-        from winrustler.mover import MoveWindow
-        from winrustler.fader import FadeWindow
-        if isinstance(req, MoveWindow):
-            msg = "Moved {title} to {req.x} x {req.y}.".format(**locals())
-        elif isinstance(req, FadeWindow):
-            msg = "Set {title} opacity to {req.opacity}.".format(**locals())
-        else:
-            assert False, req
-            msg = "Did something, not sure what."
-        self.showMessage("I did something.", msg, icon)
-
-    def _window_destroyed(self, ptr):
-        self.window = None
-
-    def _about(self):
-        QMessageBox.about(None, "About WinRustler", ABOUT_TEXT)
-
-    def _exit(self):
-        qApp.quit()
-
-
-class SuggestiveRustles(QObject):
-
-    def __init__(self, app, *args, **kwargs):
-        super().__init__(app, *args, **kwargs)
-        #self.app = app
-        #app.rustled.connect(
-        #suggestion = self.create_rustle_suggestion(rustle, self.suggestions)
-        #self.suggested.emit(suggestion)
-
-    def create_rustle_suggestion(self, rustle, suggestions):
-        """ Something was rustled, make a suggestion for repeating...
-        """
-        search = get_window_title(rustle.hwnd)
-        return Suggestion(search, rustle)
-
-
-class WindowSet(QObject):
-    """ This is very much like the WindowDiscovery but it has a signal ...
-    """
-
-    discovered = pyqtSignal(set, set)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.data = set()
-
-    def tell_and_connect(self, slot):
-        slot(self.data, ())
-        self.discovered.connect(slot)
-
-    def sync(self, adds, removes):
-        self.data.update(adds)
-        self.data.difference_update(removes)
-        self.discovered.emit(adds, removes)
-
-
-class WinRustlerApp(QApplication):
-
-    #rustle = pyqtSignal(object)
-    rustled = pyqtSignal(object)
-    suggested = pyqtSignal(object)
-    # Tells you of hwnds that are newly added and removed.
-    # For use with something like `SyncToComboBox.sync()`.
-    #windows_discovered = pyqtSignal(set, set)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.winset = WindowSet(self)
-        self.windisc = WindowDiscovery(self.winset.sync)
-        self.discovery_timer = QTimer(self, interval=200, singleShot=True)  # refresh debounce
-        self.hooker = WinHooker(self)
-        # Use a windows event hook to determine when we might want to update
-        # the list of windows. Connect it to the debounce timer.
-        self.hooker.event.connect(self.discovery_timer.start)
-        # When this debounce timer fires, we tell the window discovery to do
-        # update its list of windows.
-        self.discovery_timer.timeout.connect(self.windisc.refresh)
-
-        self.suggesty = SuggestiveRustles(self)
-
-    def event(self, e):
-        # This is here just so we can go into the interpreter in case SIGINT.
-        return super().event(e)
-
-    def do_rustling(self, rustle):
-        try:
-            rustle.run()
-        except (SystemExit, KeyboardInterrupt):
-            raise
-        except:
-            logger.exception("Unhandled exception doing %s", rustle)
-        else:
-            self.rustled.emit(rustle)
-
-
-if __name__ == "__main__":
+def main():
     import argparse
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('--show', action='store_true')
+    parser.add_argument('-v', help='Verbosity, more of these increases logging.', action='count', default=0)
+    parser.add_argument('--show', help='Display rustler window on startu..', action='store_true')
     parser.add_argument('qt_args', nargs='*')
     args = parser.parse_args()
-    logging.basicConfig(level=logging.DEBUG)
+    log_level = {0: logging.WARNING, 1: logging.INFO}.get(args.v, logging.DEBUG)
+    logging.basicConfig(level=log_level)
+    logger.info("Logging level set to %s.", log_level)
 
-    if not QSystemTrayIcon.isSystemTrayAvailable():
+    if not RustlerTray.isSystemTrayAvailable():
         raise Exception("System tray not available.")
 
     import signal
-    #@log_exceptions
     def quit_on_sigint(*args):
         print("CTRL-C handled, quitting...")
         qApp.quit()

          
@@ 354,11 33,10 @@ if __name__ == "__main__":
     qt_args = [sys.argv[0]] + args.qt_args
     app = WinRustlerApp(qt_args, quitOnLastWindowClosed=False)
     app.startTimer(100)  # So the interpreter can handle SIGINT
-    tray = RustlerTray(app.winset)
+    tray = RustlerTray(app.winset, app.history_feature)
     tray.show()
     tray.rustle.connect(app.do_rustling)
     app.rustled.connect(tray.show_rustle_message)
-    #app.suggested.connect(tray.add_suggestion)
     app.setWindowIcon(tray.icon())
 
     if args.show:

          
@@ 370,3 48,7 @@ if __name__ == "__main__":
     finally:
         tray.hide()
     print("hey, we exited cleanly!")
+
+
+if __name__ == "__main__":
+    main()

          
A => winrustler/ui/app.py +78 -0
@@ 0,0 1,78 @@ 
+import logging
+
+from PyQt5.QtCore import (
+        QObject,
+        pyqtSignal,
+        pyqtSlot,
+        QTimer,
+)
+from PyQt5.QtWidgets import QApplication, QMenu
+
+from winrustler.winapi import WindowDiscovery
+from winrustler.ui.winapi import WinHooker
+from winrustler.ui.history import HistoryFeature
+
+
+logger = logging.getLogger(__name__)
+
+
+class WindowSet(QObject):
+    """ This is very much like the WindowDiscovery but it has a signal ...
+    """
+
+    discovered = pyqtSignal(set, set)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.data = set()
+
+    def tell_and_connect(self, slot):
+        slot(self.data, ())
+        self.discovered.connect(slot)
+
+    def sync(self, adds, removes):
+        self.data.update(adds)
+        self.data.difference_update(removes)
+        self.discovered.emit(adds, removes)
+
+
+class WinRustlerApp(QApplication):
+
+    #rustle = pyqtSignal(object)
+    rustled = pyqtSignal(object)
+    suggested = pyqtSignal(object)
+    # Tells you of hwnds that are newly added and removed.
+    # For use with something like `SyncToComboBox.sync()`.
+    #windows_discovered = pyqtSignal(set, set)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.winset = WindowSet(self)
+        self.windisc = WindowDiscovery(self.winset.sync)
+        self.discovery_timer = QTimer(self, interval=200, singleShot=True)  # refresh debounce
+        self.hooker = WinHooker(self)
+        # Use a windows event hook to determine when we might want to update
+        # the list of windows. Connect it to the debounce timer.
+        self.hooker.event.connect(self.discovery_timer.start)
+        # When this debounce timer fires, we tell the window discovery to do
+        # update its list of windows.
+        self.discovery_timer.timeout.connect(self.windisc.refresh)
+
+        self.history_menu = QMenu(parent=None)
+        self.history_feature = HistoryFeature(self.winset, self.history_menu)
+        self.rustled.connect(self.history_feature.update_from_rustle)
+
+    def event(self, e):
+        # This is here just so we can go into the interpreter in case SIGINT.
+        return super().event(e)
+
+    def do_rustling(self, rustle):
+        try:
+            rustle.run()
+        except (SystemExit, KeyboardInterrupt):
+            raise
+        except:
+            logger.exception("Unhandled exception doing %s", rustle)
+        else:
+            self.rustled.emit(rustle)
+

          
M winrustler/ui/debug.py +21 -0
@@ 1,5 1,8 @@ 
 import functools
 import logging
+import traceback
+
+from PyQt5.QtWidgets import QMessageBox
 
 logger = logging.getLogger(__name__)
 

          
@@ 14,3 17,21 @@ def log_exceptions(fn):
         except:
             logger.exception("Unhandled exception in %r", fn)
     return inner
+
+
+def show_exceptions(fn):
+    @functools.wraps(fn)
+    def inner(*args, **kwargs):
+        try:
+            return fn(*args, **kwargs)
+        except (SystemExit, KeyboardInterrupt):
+            raise
+        except:
+            logger.exception("Unhandled exception in %r", fn)
+            msg = QMessageBox(
+                    icon=QMessageBox.Critical,
+                    text="Unhandled exception in %r" % fn,
+                    parent=None,
+                    detailedText=traceback.format_exc())
+            msg.exec_()
+    return inner

          
A => winrustler/ui/history.py +141 -0
@@ 0,0 1,141 @@ 
+import logging
+
+import pytest
+import attr
+
+from PyQt5.QtCore import QObject, pyqtSlot
+from PyQt5.QtWidgets import QMenu, QAction
+
+from winrustler.winapi import get_window_title
+from winrustler.ui.debug import show_exceptions
+from winrustler.ui.winapi import get_window_icon
+
+logger = logging.getLogger(__name__)
+
+HISTORY_LENGTH = 10
+
+
+@attr.s(frozen=True, cmp=False)
+class PastRustle(object):
+    window_icon = attr.ib()
+    window_title = attr.ib()
+    #process_path = attr.ib()
+    rustle = attr.ib()
+
+    def __hash__(self):
+        return hash((self.window_title, self.rustle))
+
+    def __eq__(self, other):  # Match
+        return self.rustle == other.rustle \
+                and self.window_title == other.window_title
+                #and self.process_path == other.process_path
+
+
+class HistoryFeature(QObject):
+
+    def __init__(self, winset, menu, *args, **kwargs):
+        super().__init__(menu, *args, **kwargs)
+        self.winset = winset
+        self.menu = menu
+        self.separator = self.menu.addSeparator()
+        self.menu.addAction("&Clear")
+        self.data = []
+        self.actions = {}  # Maps data to actions?
+        self.winset.tell_and_connect(self._update_engagement)
+
+    @pyqtSlot(object, object)
+    @show_exceptions
+    def _update_engagement(self, new, lost):
+        #new
+        pass
+
+    @pyqtSlot(object)
+    @show_exceptions
+    def update_from_rustle(self, rustle):
+        icon = get_window_icon(rustle.hwnd)
+        title = get_window_title(rustle.hwnd)
+        rustle = attr.evolve(rustle, hwnd=None)
+        past = PastRustle(window_icon=icon, window_title=title, rustle=rustle)
+        self.extend_history(past)
+
+    def extend_history(self, past):
+        """ Inserts at the "beginning" of the menu, should be the most recent.
+        """
+        logger.debug("Extending %r into history.", past)
+
+        if self.data:
+            before = self.actions[self.data[0]]
+        else:
+            before = self.separator
+
+        if past in self.data:
+            act = self.actions[past]
+            if act != before:
+                self.data.remove(past)
+                self.data.insert(0, past)
+                self.menu.removeAction(act)
+                self.menu.insertAction(before, act)
+        else:
+            self.data.insert(0, past)
+            act = QAction(past.window_icon, past.window_title, parent=self.menu)
+            act.triggered.connect(self._activate_past)
+            self.menu.insertAction(before, act)
+            self.actions[past] = act
+
+            while len(self.data) > HISTORY_LENGTH:
+                extranious = self.actions.remove(self.data.pop())
+                self.menu.removeAction(extranious)
+
+        logger.debug("data=%r", self.data)
+        logger.debug("actions=%r", self.actions)
+
+        return act
+
+    @pyqtSlot()
+    @show_exceptions
+    def _activate_past(self):
+        act = self.sender()
+        print(act)
+
+
+@pytest.fixture
+def app():
+    from PyQt5.QtWidgets import QApplication
+    return QApplication([])
+
+
+def test(app):
+    from winrustler.mover import MoveWindow
+    from winrustler.ui.app import WindowSet
+    from PyQt5.QtGui import QIcon
+
+    winset = WindowSet()
+    menu = QMenu()
+    history = HistoryFeature(winset, menu)
+    assert len(menu.actions()) == 2
+    assert menu.actions()[0] == history.separator
+
+    # First second past item
+    first_rustle = MoveWindow(hwnd=None, x=1, y=2)
+    first_past = PastRustle(window_icon=QIcon(), window_title="foobar", rustle=first_rustle)
+    first_act = history.extend_history(first_past)
+    assert len(menu.actions()) == 3
+    assert history.data == [first_past]
+    assert menu.actions()[:-2] == [first_act]
+
+    # Add second past item
+    second_rustle = MoveWindow(hwnd=None, x=1, y=2)
+    second_past = PastRustle(window_icon=QIcon(), window_title="foobar2", rustle=second_rustle)
+    second_act = history.extend_history(second_past)
+    assert len(menu.actions()) == 4
+    assert history.data == [second_past, first_past]
+    assert menu.actions()[:-2] == [second_act, first_act]
+
+    # Update first past item
+    third_rustle = MoveWindow(hwnd=None, x=1, y=2)
+    third_past = PastRustle(window_icon=QIcon(), window_title="foobar", rustle=third_rustle)
+    third_act = history.extend_history(third_past)
+    assert len(menu.actions()) == 4
+    assert first_act == third_act
+    assert history.data == [third_past, second_past]
+    assert menu.actions()[:-2] == [third_act, second_act]

          
A => winrustler/ui/rustle.py +13 -0
@@ 0,0 1,13 @@ 
+from winrustler.mover import MoveWindow
+from winrustler.fader import FadeWindow
+from winrustler.winapi import get_window_title
+
+
+def rustle_description(rustle):
+    title = get_window_title(rustle.hwnd)
+    if isinstance(rustle, MoveWindow):
+        return "Moved {title} to {rustle.x} x {rustle.y}.".format(**locals())
+    elif isinstance(rustle, FadeWindow):
+        return "Set {title} opacity to {rustle.opacity}.".format(**locals())
+    else:
+        return "Did something, not sure what."

          
A => winrustler/ui/widgets/rustlerwindow.py +110 -0
@@ 0,0 1,110 @@ 
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt
+from PyQt5.QtWidgets import (
+        QDialog,
+        QDialogButtonBox,
+        QDialogButtonBox,
+        QHBoxLayout,
+        QPushButton,
+        QTabWidget,
+        QVBoxLayout,
+)
+
+from winrustler.ui import icon
+from winrustler.ui.debug import show_exceptions
+from winrustler.ui.widgets.select import WindowSelect
+from winrustler.ui.widgets.rustle import MoveControls, FadeControls
+from winrustler.ui.widgets.match import MatchDialog
+from winrustler.ui.state import save_window_state, restore_window_state
+
+
+class RustlerWindow(QDialog):
+
+    rustle = pyqtSignal(object)
+
+    def __init__(self, winset, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.setAttribute(Qt.WA_DeleteOnClose)
+        self.setWindowTitle("WinRustler")
+
+        self._select = WindowSelect(self)
+        self._match_select = QPushButton(icon('1f984.png'), "", self, shortcut="Alt+m")
+        self._match_select.clicked.connect(self._show_match)
+
+        self._move = MoveControls(self)
+        self._fade = FadeControls(self)
+
+        self._function_tab = QTabWidget(self)
+        self._function_tab.addTab(self._move, icon('1f4d0.png'), "M&ove")
+        self._function_tab.addTab(self._fade, icon('1f47b.png'), "&Fade")
+
+        self._bb = QDialogButtonBox(self)
+        self._bb.accepted.connect(self.accept)
+        self._bb.rejected.connect(self.reject)
+        self._bb.clicked.connect(self._bb_on_clicked)
+
+        self._apply = self._bb.addButton(QDialogButtonBox.Apply)
+        self._apply_and_close = self._bb.addButton('&Apply && Close', QDialogButtonBox.AcceptRole)
+        self._close = self._bb.addButton(QDialogButtonBox.Close)
+
+        self._layout = QVBoxLayout(self)
+        self._select_layout = QHBoxLayout()
+        self._select_layout.addWidget(self._select, stretch=1)
+        self._select_layout.addWidget(self._match_select)
+        self._layout.addLayout(self._select_layout)
+        self._layout.addWidget(self._function_tab)
+        self._layout.addWidget(self._bb)
+        self._layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
+
+        self._select.updated.connect(self._refresh_engagement)
+        self._move.updated.connect(self._refresh_engagement)
+        self._refresh_engagement()
+
+        self.winset = winset
+        self.winset.tell_and_connect(self.sync_windows)
+
+    @pyqtSlot(object, object)
+    @show_exceptions
+    def sync_windows(self, *args, **kwargs):
+        """
+        This needs to exist so that it can be a slot and Qt disconnects it
+        properly, otherwise PyQt start holding reference to things and fucks
+        everything up.
+        """
+        self._select.sync_windows(*args, **kwargs)
+
+    def _refresh_engagement(self):
+        is_acceptable = self.request() is not None
+        self._apply.setEnabled(is_acceptable)
+        self._apply_and_close.setEnabled(is_acceptable)
+
+    def _bb_on_clicked(self, button):
+        if button == self._apply:
+            self.rustle.emit(self.request())
+        elif button == self._apply_and_close:
+            self.rustle.emit(self.request())
+            self.accept()
+        elif button == self._close:
+            self.reject()
+        else:
+            raise NotImplementedError()
+
+    def _show_match(self):
+        m = MatchDialog(self.winset, parent=self)
+        #m = ComposeMatch(self.winset)
+        #app.tell_windows_and_connect(m.sync_windows)
+        print(m.exec_())
+
+    def request(self):
+        hwnd = self._select.hwnd
+        tab = self._function_tab.currentWidget() 
+        if tab is not None:
+            hwnd = self._select.hwnd()
+            return tab.window_request(hwnd)
+
+    def showEvent(self, event):
+        restore_window_state(self)
+        return super().showEvent(event)
+
+    def hideEvent(self, event):
+        save_window_state(self)
+        return super().hideEvent(event)

          
A => winrustler/ui/widgets/tray.py +94 -0
@@ 0,0 1,94 @@ 
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QObject
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import (
+        QAction,
+        QDialog,
+        QMainWindow,
+        QMenu,
+        QMessageBox,
+        QSizePolicy,
+        QSystemTrayIcon,
+        qApp,
+)
+
+from winrustler import __version__
+from winrustler.winapi import get_window_title
+from winrustler.mover import MoveWindow
+from winrustler.fader import FadeWindow
+from winrustler.ui import icon
+from winrustler.ui.debug import show_exceptions
+from winrustler.ui.rustle import rustle_description
+from winrustler.ui.winapi import get_window_icon
+from winrustler.ui.widgets.rustlerwindow import RustlerWindow
+
+ABOUT_TEXT = """\
+<h1>WinRustler</h1>
+
+<p>Version: <b>{__version__}</b></p>
+
+<p><a href='https://bitbucket.org/sqwishy/winrustler'>bitbucket.org/sqwishy/winrustler</a></p>
+
+<p>Project built with
+<a href='https://www.python.org/'>Python 3</a>,
+<a href='https://www.riverbankcomputing.com/software/pyqt/intro'>Qt 5</a>,
+<a href='https://www.qt.io/'>PyQt5</a>, and
+<a href='https://www.emojione.com/emoji/v2'>EmojiOne v2</a>.</p>
+""".format(**locals())
+
+
+class RustlerTray(QSystemTrayIcon):
+
+    rustle = pyqtSignal(object)
+
+    def __init__(self, winset, history_feature, *args, **kwargs):
+        super().__init__(winset, *args, **kwargs)
+        self.rustle_icon = icon('1f412.png')
+        self.about_icon = icon('1f49f.png')
+        self.alligator_icon = icon('1f40a.png')
+        self.exit_icon = QIcon()
+
+        self.menu = QMenu(parent=None)
+        self.rustle_act = self.menu.addAction(self.rustle_icon, '&Rustle...', self.show_window)
+        self.history_act = self.menu.addAction(self.alligator_icon, '&History')
+        self.about_act = self.menu.addAction(self.about_icon, '&About...', self._about)
+        self.exit_act = self.menu.addAction(self.exit_icon, '&Exit', self._exit)
+
+        self.history_feature = history_feature
+        self.history_act.setMenu(self.history_feature.menu)
+
+        self.setContextMenu(self.menu)
+        self.activated.connect(self._icon_clicky)
+
+        self.winset = winset
+        self.window = None
+        self.setIcon(self.rustle_icon)
+
+    def _icon_clicky(self, reason):
+        if reason == QSystemTrayIcon.Trigger:  # Left-click
+            self.show_window()
+
+    def show_window(self):
+        if self.window is None:
+            self.window = RustlerWindow(self.winset)
+            self.window.rustle.connect(self.rustle)
+            self.window.destroyed.connect(self._window_destroyed)
+        self.window.show()
+        self.window.raise_()
+        self.window.activateWindow()
+
+    @pyqtSlot(object)
+    @show_exceptions
+    def show_rustle_message(self, req):
+        icon = get_window_icon(req.hwnd)
+        msg = rustle_description(req)
+        self.showMessage("I did something.", msg, icon)
+
+    def _window_destroyed(self, ptr):
+        self.window = None
+
+    def _about(self):
+        QMessageBox.about(None, "About WinRustler", ABOUT_TEXT)
+
+    def _exit(self):
+        qApp.quit()
+

          
M winrustler/winapi.py +19 -10
@@ 5,25 5,34 @@ from ctypes import wintypes
 from winrustler.winconsts import *
 
 user32 = ctypes.windll.user32
+psapi = ctypes.windll.psapi
+
+try:
+    GetWindowLong = user32.GetWindowLongPtrW
+except AttributeError:
+    GetWindowLong = user32.GetWindowLongW
 
     
 def get_window_title(hwnd):
     # TODO GetWindowTextLength
     buf = ctypes.create_unicode_buffer(255)
+    # Can't really raise on zero because the window text might have no length
     user32.GetWindowTextW(hwnd, buf, 254)
     return buf.value
 
 
-import contextlib
-@contextlib.contextmanager
-def exception_diaper():
-    try:
-        yield
-    except (SystemExit, KeyboardInterrupt):
-        raise
-    except:
-        logger.exception("Unhandled exception", fn)
-        raise
+def get_window_application(hwnd):
+    hinstance = GetWindowLong(hwnd, GWLP_HINSTANCE)
+    if 0 == hinstance:
+        raise ctypes.WinError()
+    return hinstance
+
+
+def get_memes(hproc):
+    buf = ctypes.create_unicode_buffer(1024)
+    if 0 == psapi.GetModuleFileNameExW(..., hproc, buf, 1023):
+        raise ctypes.WinError()
+    return buf.value
 
 
 class WindowDiscovery(object):

          
M winrustler/winconsts.py +7 -1
@@ 1,7 1,13 @@ 
 GW_OWNER = 4
 
+# https://msdn.microsoft.com/en-us/library/ms633585%28VS.85%29.aspx
+GWL_EXSTYLE = -20
+GWLP_HINSTANCE = -6
+GWLP_HWNDPARENT = -8
+GWLP_ID = -12
 GWL_STYLE = -16
-GWL_EXSTYLE = -20
+GWLP_USERDATA = -21
+GWLP_WNDPROC = -4
 
 GCL_HICON = -14
 GCL_HICONSM = -34