refactoring and unicorn button
11 files changed, 372 insertions(+), 295 deletions(-)

M winrustler/ui/__main__.py
A => winrustler/ui/debug.py
R winrustler/ui/models.py => 
A => winrustler/ui/res/1f40a.png
A => winrustler/ui/res/1f984.png
A => winrustler/ui/state.py
R winrustler/ui/widgets.py => 
A => winrustler/ui/widgets/match.py
A => winrustler/ui/widgets/rustle.py
A => winrustler/ui/widgets/select.py
A => winrustler/ui/widgets/sync.py
M winrustler/ui/__main__.py +65 -69
@@ 4,16 4,15 @@ 
 import sys
 import os.path
 import logging
-import functools
 
 import attr
 
 from PyQt5.QtCore import (
         QObject,
         pyqtSignal,
+        pyqtSlot,
         Qt,
         QTimer,
-        QSettings,
 )
 from PyQt5.QtGui import (
         QIcon,

          
@@ 33,6 32,7 @@ from PyQt5.QtWidgets import (
 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 = """\

          
@@ 57,20 57,8 @@ def icon(filename):
     return QIcon(os.path.join(RES_DIR, filename))
 
 
-def log_exceptions(fn):
-    @functools.wraps(fn)
-    def inner(*args, **kwargs):
-        try:
-            fn(*args, **kwargs)
-        except (SystemExit, KeyboardInterrupt):
-            raise
-        except:
-            logger.exception("Unhandled exception in %r", fn)
-    return inner
-
-
 @attr.s(frozen=True)
-class Suggestion(object):
+class History(object):
     search = attr.ib()
     rustle = attr.ib()
 

          
@@ 79,20 67,18 @@ class RustlerWindow(QDialog):
 
     rustle = pyqtSignal(object)
 
-    def __init__(self, app, *args, **kwargs):
+    def __init__(self, winset, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self.setAttribute(Qt.WA_DeleteOnClose)
         self.setWindowTitle("WinRustler")
 
-        from .widgets import WindowSelect, MoveControls, FadeControls#, MatchSelect
+        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)
-        #menu = QMenu(self._match_select)
-        #menu.addAction("hi")
-        #self._match_select.setMenu(menu)
-
-        #self._match = WindowMatch(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)

          
@@ 111,50 97,45 @@ class RustlerWindow(QDialog):
         self._bb = QDialogButtonBox(self)
         self._bb.accepted.connect(self.accept)
         self._bb.rejected.connect(self.reject)
-        self._bb.clicked.connect(self._on_clicked)
+        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 QFormLayout
-        #self._layout = QFormLayout(self)
-        #self._layout.addRow(self._description)
-        ##self._layout.addRow("&Window", self._window_select)
-        ##self._layout.addRow("&Left", self._x)
-        ##self._layout.addRow("&Top", self._y)
-        #self._layout.addRow(self._move_viewport)
-        #self._layout.addRow(self._bb)
-        #self._layout.setSizeConstraint(QFormLayout.SetFixedSize)
         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.addWidget(self._window_tab)
+        self._select_layout.addWidget(self._match_select)
         self._layout.addLayout(self._select_layout)
-        #self._layout.addWidget(self._select)
-        #self._layout.addWidget(self._match_select)
         self._layout.addWidget(self._function_tab)
         self._layout.addWidget(self._bb)
         self._layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
-        #self.setLayout(self._layout)
 
         self._select.updated.connect(self._refresh_engagement)
-        #self._match.updated.connect(self._refresh_engagement)
         self._move.updated.connect(self._refresh_engagement)
         self._refresh_engagement()
 
-    def sync_windows(self, *args):
-        self._select.sync_windows(*args)
+        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 _on_clicked(self, button):
+    def _bb_on_clicked(self, button):
         from PyQt5.QtWidgets import QDialogButtonBox
         if button == self._apply:
             self.rustle.emit(self.request())

          
@@ 166,6 147,14 @@ class RustlerWindow(QDialog):
         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() 

          
@@ 174,24 163,20 @@ class RustlerWindow(QDialog):
             return tab.window_request(hwnd)
 
     def showEvent(self, event):
-        settings = QSettings("WinRustler Corp.", "WinRustler")
-        geometry = settings.value("RustlerWindow/geometry")
-        if geometry is not None:
-            self.restoreGeometry(geometry)
+        restore_window_state(self)
         return super().showEvent(event)
 
-    def closeEvent(self, event):
-        settings = QSettings("WinRustler Corp.", "WinRustler")
-        settings.setValue("RustlerWindow/geometry", self.saveGeometry())
-        return super().closeEvent(event)
+    def hideEvent(self, event):
+        save_window_state(self)
+        return super().hideEvent(event)
 
 
 class RustlerTray(QSystemTrayIcon):
 
     rustle = pyqtSignal(object)
 
-    def __init__(self, app, *args, **kwargs):
-        super().__init__(app, *args, **kwargs)
+    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()

          
@@ 206,7 191,7 @@ class RustlerTray(QSystemTrayIcon):
         self.activated.connect(self._icon_clicky)
 
         #self.suggest = []
-        self.app = app
+        self.winset = winset
         self.window = None
         self.setIcon(self.rustle_icon)
 

          
@@ 242,11 227,9 @@ class RustlerTray(QSystemTrayIcon):
 
     def show_window(self):
         if self.window is None:
-            self.window = RustlerWindow(self.app)
-            self.window.setAttribute(Qt.WA_DeleteOnClose)
+            self.window = RustlerWindow(self.winset)
             self.window.rustle.connect(self.rustle)
             self.window.destroyed.connect(self._window_destroyed)
-            self.app.tell_windows_and_connect(self.window.sync_windows)
         self.window.show()
         self.window.raise_()
         self.window.activateWindow()

          
@@ 291,6 274,26 @@ class SuggestiveRustles(QObject):
         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)

          
@@ 298,11 301,12 @@ class WinRustlerApp(QApplication):
     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)
+    #windows_discovered = pyqtSignal(set, set)
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.windows = WindowDiscovery(self.windows_discovered.emit)
+        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

          
@@ 310,14 314,10 @@ class WinRustlerApp(QApplication):
         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.windows.refresh)
+        self.discovery_timer.timeout.connect(self.windisc.refresh)
 
         self.suggesty = SuggestiveRustles(self)
 
-    def tell_windows_and_connect(self, slot):
-        slot(app.windows.discovered, set())
-        self.windows_discovered.connect(slot)
-
     def event(self, e):
         # This is here just so we can go into the interpreter in case SIGINT.
         return super().event(e)

          
@@ 354,7 354,7 @@ 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)
+    tray = RustlerTray(app.winset)
     tray.show()
     tray.rustle.connect(app.do_rustling)
     app.rustled.connect(tray.show_rustle_message)

          
@@ 362,10 362,6 @@ if __name__ == "__main__":
     app.setWindowIcon(tray.icon())
 
     if args.show:
-        from .widgets import ComposeMatch
-        #m = ComposeMatch()
-        #m.show()
-        #app.tell_windows_and_connect(m.sync_windows)
         tray.show_window()
 
     print("Lets go!")

          
A => winrustler/ui/debug.py +16 -0
@@ 0,0 1,16 @@ 
+import functools
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def log_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)
+    return inner

          
R winrustler/ui/models.py =>  +0 -0

        
A => winrustler/ui/res/1f40a.png +0 -0

        
A => winrustler/ui/res/1f984.png +0 -0

        
A => winrustler/ui/state.py +27 -0
@@ 0,0 1,27 @@ 
+import logging
+
+from PyQt5.QtCore import QSettings
+
+from winrustler.ui.debug import log_exceptions
+
+logger = logging.getLogger(__name__)
+
+
+@log_exceptions
+def save_window_state(window):
+    classname = type(window).__name__
+    assert classname
+    settings = QSettings("WinRustler Corp.", "WinRustler")
+    logger.debug("Saving %r", classname + "/geometry")
+    settings.setValue(classname + "/geometry", window.saveGeometry())
+
+
+@log_exceptions
+def restore_window_state(window):
+    classname = type(window).__name__
+    assert classname
+    settings = QSettings("WinRustler Corp.", "WinRustler")
+    logger.debug("Loading %r", classname + "/geometry")
+    geometry = settings.value(classname + "/geometry")
+    if geometry is not None:
+        window.restoreGeometry(geometry)

          
R winrustler/ui/widgets.py =>  +0 -226
@@ 1,226 0,0 @@ 
-import logging
-import textwrap
-
-from PyQt5.QtCore import (
-        QObject,
-        pyqtSignal,
-        Qt,
-)
-from PyQt5.QtGui import (
-        QPixmap,
-        QIcon,
-)
-from PyQt5.QtWidgets import (
-        qApp,
-        QApplication,
-        QWidget,
-        QSystemTrayIcon,
-        QMenu,
-        QDialog,
-        QMainWindow,
-        QMessageBox,
-        QAction,
-        QSizePolicy,
-        QVBoxLayout,
-        QGridLayout,
-        QFormLayout,
-)
-
-from PyQt5.QtWinExtras import (
-        QtWin,
-)
-
-from winrustler.winapi import get_window_title, test_window_match
-from winrustler.ui.winapi import get_window_icon
-
-logger = logging.getLogger(__name__)
-
-class SyncToStandardItemModel(object):
-
-    def __init__(self, model, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.model = model
-        self.added_items = {}
-
-    def __call__(self, adds, removes):
-        from PyQt5.QtGui import QStandardItem
-        logger.debug("Resyncing %s, adds=%r, removes=%r", self.model, adds, removes)
-        for hwnd in removes:
-            item = self.added_items.pop(hwnd)
-            row = self.model.indexFromItem(item).row()
-            assert [item] == self.model.takeRow(row)
-        for hwnd in adds:
-            # FIXME CURRENT WINDOW SYNCRONIZIATION STATE MEMES
-            item = QStandardItem(get_window_icon(hwnd), get_window_title(hwnd))
-            item.setData(hwnd, Qt.UserRole)
-            self.model.appendRow(item)
-            self.added_items[hwnd] = item
-
-
-class WindowSelect(QWidget):
-    """
-    Input:
-        Call sync_windows() (SyncToStandardItemModel.__call__()) to tell it about the
-        hwnds that are going on.
-
-    Output:
-        The hwnd() function for the currently selected hwnd.
-        Emits updated() when this changes.
-    """
-
-    updated = pyqtSignal()
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        from PyQt5.QtWidgets import QComboBox
-        from PyQt5.QtGui import QStandardItemModel
-        from PyQt5.QtCore import QSortFilterProxyModel
-        self._select = QComboBox(self)
-        self._proxy = QSortFilterProxyModel(self._select)
-        self._model = QStandardItemModel(0, 1, self._proxy)
-        self._proxy.setSourceModel(self._model)
-        self._select.setModel(self._proxy)
-        self._select.currentIndexChanged.connect(self.updated)
-
-        self._layout = QVBoxLayout(self)
-        self._layout.setContentsMargins(0, 0, 0, 0)
-        self._layout.addWidget(self._select)
-        self.setLayout(self._layout)
-
-        self.sync_windows = SyncToStandardItemModel(self._model)
-
-    def hwnd(self):
-        return self._select.currentData()
-
-
-#class MatchSelect(QAction):
-#
-#    def __init__
-#    pass
-
-
-class ComposeMatch(QWidget):
-    """
-    Input:
-        Call sync_windows() (SyncToStandardItemModel.__call__()) to tell it about the
-        hwnds that are going on.
-
-    Output:
-        The filter property will be a the user filter as a string.
-        Emits updated() when this changes.
-    """
-
-    updated = pyqtSignal()
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        from PyQt5.QtWidgets import QComboBox, QListView, QLineEdit, QLabel
-        from PyQt5.QtGui import QStandardItemModel
-        from PyQt5.QtCore import QSortFilterProxyModel
-
-        help_text = textwrap.dedent("""\
-                <p>Filter on the window title. The filter is a case insensitive
-                <a href='https://docs.python.org/3/library/re.html'>regular
-                expression</a>.</p>
-                """)
-        self._help = QLabel(help_text, self, openExternalLinks=True)
-        self._filter = QLineEdit(self, placeholderText="Filter...")
-        self._list = QListView(self)
-
-        self._proxy = QSortFilterProxyModel(self._list)
-        self._model = QStandardItemModel(0, 1, self._proxy)
-        self._proxy.setSourceModel(self._model)
-        self._list.setModel(self._proxy)
-
-        self._filter.textEdited.connect(self._refilter)
-        self._filter.textEdited.connect(self.updated)
-
-        self._layout = QVBoxLayout(self)
-        self._layout.setContentsMargins(0, 0, 0, 0)
-        self._layout.addWidget(self._help)
-        self._layout.addWidget(self._filter)
-        self._layout.addWidget(self._list)
-        self.setLayout(self._layout)
-
-        self.sync_windows = SyncToStandardItemModel(self._model)
-
-    def _refilter(self, pattern):
-        items = [self._model.item(row) for row in range(self._model.rowCount())]
-        #hwnds = [item.data(role=Qt.UserRole) for item in items]
-        for item in items:
-            title, test = test_window_match(item.data(role=Qt.UserRole), pattern)
-            item.setText(title)  # In case it changed??
-            item.setEnabled(test)
-
-    @property
-    def filter(self):
-        return self._filter.text()
-
-
-from winrustler.mover import MoveWindow
-
-class MoveControls(QWidget):
-
-    updated = pyqtSignal()
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        from PyQt5.QtWidgets import QSpinBox, QCheckBox, QLabel
-        self._description = QLabel("<p>Move a window by setting the the top-left \
-                of the window some number of pixels away from the top-left of \
-                the desktop.</p>", self)
-        self._x = QSpinBox(self, value=0, minimum=-2**16, maximum=2**16)
-        self._y = QSpinBox(self, value=0, minimum=-2**16, maximum=2**16)
-        self._move_viewport = QCheckBox("Set position of &internal viewport rather than the window frame", self)
-        self._move_viewport.setCheckState(Qt.Checked)
-
-        from PyQt5.QtWidgets import QFormLayout
-        self._layout = QFormLayout(self)
-        self._layout.addRow(self._description)
-        self._layout.addRow("&Left", self._x)
-        self._layout.addRow("&Top", self._y)
-        self._layout.addRow(self._move_viewport)
-        self.setLayout(self._layout)
-
-    def window_request(self, hwnd):
-        return MoveWindow(hwnd, self._x.value(), self._y.value(),
-                self._move_viewport.checkState() == Qt.Checked)
-
-
-from winrustler.fader import FadeWindow
-
-class FadeControls(QWidget):
-
-    DESCRIPTION_TEMPLATE = "<p>Set a window's opacity to {opacity}. At zero, it \
-    is transparent; at 255 it is opaque.</p>"
-
-    updated = pyqtSignal()
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        from PyQt5.QtWidgets import QSlider, QLabel, QHBoxLayout
-        self._description = QLabel(parent=self)
-        self._opacity = QSlider(Qt.Horizontal, parent=self, minimum=0, maximum=255)#, tickInterval=127, tickPosition=QSlider.TicksBelow)
-        # This can't be a kwarg, probably because PyQt applies those in no
-        # particular order and this should happen after setting the maximum.
-        self._opacity.setValue(255)
-
-        from PyQt5.QtWidgets import QFormLayout
-        self._layout = QFormLayout(self)
-        self._layout.addRow(self._description)
-        self._layout.addRow("&Opacity", self._opacity)
-        self.setLayout(self._layout)
-
-        self._opacity.valueChanged.connect(self.updated)
-        self.updated.connect(self._refresh_engagement)
-        self._refresh_engagement()
-    
-    def _refresh_engagement(self):
-        self._description.setText(self.DESCRIPTION_TEMPLATE.format(opacity=self._opacity.value()))
-
-    def window_request(self, hwnd):
-        return FadeWindow(hwnd, self._opacity.value())

          
A => winrustler/ui/widgets/match.py +106 -0
@@ 0,0 1,106 @@ 
+import logging
+import textwrap
+
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QSortFilterProxyModel
+from PyQt5.QtWidgets import QWidget, QComboBox, QListView, QLineEdit, QLabel
+from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
+from PyQt5.QtGui import QStandardItemModel
+
+from winrustler.winapi import test_window_match
+from winrustler.ui.widgets.sync import SyncToStandardItemModel
+from winrustler.ui.state import save_window_state, restore_window_state
+
+logger = logging.getLogger(__name__)
+
+
+class ComposeMatch(QWidget):
+    """
+    Input:
+        Call sync_windows() (SyncToStandardItemModel.__call__()) to tell it about the
+        hwnds that are going on.
+
+    Output:
+        The filter property will be a the user filter as a string.
+        Emits updated() when this changes.
+    """
+
+    updated = pyqtSignal()
+
+    def __init__(self, winset, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.winset = winset
+
+        help_text = textwrap.dedent("""\
+                <p>Filter on the window title. The filter is a case insensitive
+                <a href='https://docs.python.org/3/library/re.html'>regular
+                expression</a>.</p>
+                """)
+        self._help = QLabel(help_text, self, openExternalLinks=True)
+        self._filter = QLineEdit(self, placeholderText="Filter...")
+        self._list = QListView(self)
+
+        self._proxy = QSortFilterProxyModel(self._list)
+        self._model = QStandardItemModel(0, 1, self._proxy)
+        self._proxy.setSourceModel(self._model)
+        self._list.setModel(self._proxy)
+
+        self._filter.textEdited.connect(self._refilter)
+        self._filter.textEdited.connect(self.updated)
+
+        self._layout = QVBoxLayout(self)
+        self._layout.setContentsMargins(0, 0, 0, 0)
+        self._layout.addWidget(self._help)
+        self._layout.addWidget(self._filter)
+        self._layout.addWidget(self._list)
+        self.setLayout(self._layout)
+
+        self._sync = SyncToStandardItemModel(self._model)
+        self.winset.tell_and_connect(self.sync_windows)
+
+    @pyqtSlot(object, object)
+    def sync_windows(self, *args, **kwargs):  # Because slots and reference counting
+        self._sync(*args, **kwargs)
+
+    def _refilter(self, pattern):
+        items = [self._model.item(row) for row in range(self._model.rowCount())]
+        #hwnds = [item.data(role=Qt.UserRole) for item in items]
+        for item in items:
+            title, test = test_window_match(item.data(role=Qt.UserRole), pattern)
+            item.setText(title)  # In case it changed??
+            item.setEnabled(test)
+
+    @property
+    def filter(self):
+        return self._filter.text()
+
+
+class MatchDialog(QDialog):
+
+    def __init__(self, winset, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.setAttribute(Qt.WA_DeleteOnClose)
+        self.setWindowTitle("Window Match")
+
+        self._match = ComposeMatch(winset, parent=self)
+
+        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._layout.addWidget(self._match)
+        self._layout.addWidget(self._bb)
+
+    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/rustle.py +74 -0
@@ 0,0 1,74 @@ 
+import logging
+
+from PyQt5.QtCore import pyqtSignal, Qt
+from PyQt5.QtWidgets import (
+        QCheckBox,
+        QFormLayout,
+        QFormLayout,
+        QLabel,
+        QSlider,
+        QSpinBox,
+        QWidget,
+)
+
+from winrustler.mover import MoveWindow
+from winrustler.fader import FadeWindow
+
+
+class MoveControls(QWidget):
+
+    updated = pyqtSignal()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self._description = QLabel("<p>Move a window by setting the the top-left \
+                of the window some number of pixels away from the top-left of \
+                the desktop.</p>", self)
+        self._x = QSpinBox(self, value=0, minimum=-2**16, maximum=2**16)
+        self._y = QSpinBox(self, value=0, minimum=-2**16, maximum=2**16)
+        self._move_viewport = QCheckBox("Set position of &internal viewport rather than the window frame", self)
+        self._move_viewport.setCheckState(Qt.Checked)
+
+        self._layout = QFormLayout(self)
+        self._layout.addRow(self._description)
+        self._layout.addRow("&Left", self._x)
+        self._layout.addRow("&Top", self._y)
+        self._layout.addRow(self._move_viewport)
+        self.setLayout(self._layout)
+
+    def window_request(self, hwnd):
+        return MoveWindow(hwnd, self._x.value(), self._y.value(),
+                self._move_viewport.checkState() == Qt.Checked)
+
+
+class FadeControls(QWidget):
+
+    DESCRIPTION_TEMPLATE = "<p>Set a window's opacity to {opacity}. At zero, it \
+    is transparent; at 255 it is opaque.</p>"
+
+    updated = pyqtSignal()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self._description = QLabel(parent=self)
+        self._opacity = QSlider(Qt.Horizontal, parent=self, minimum=0, maximum=255)#, tickInterval=127, tickPosition=QSlider.TicksBelow)
+        # This can't be a kwarg, probably because PyQt applies those in no
+        # particular order and this should happen after setting the maximum.
+        self._opacity.setValue(255)
+
+        self._layout = QFormLayout(self)
+        self._layout.addRow(self._description)
+        self._layout.addRow("&Opacity", self._opacity)
+        self.setLayout(self._layout)
+
+        self._opacity.valueChanged.connect(self.updated)
+        self.updated.connect(self._refresh_engagement)
+        self._refresh_engagement()
+    
+    def _refresh_engagement(self):
+        self._description.setText(self.DESCRIPTION_TEMPLATE.format(opacity=self._opacity.value()))
+
+    def window_request(self, hwnd):
+        return FadeWindow(hwnd, self._opacity.value())

          
A => winrustler/ui/widgets/select.py +48 -0
@@ 0,0 1,48 @@ 
+import logging
+
+from PyQt5.QtCore import (
+        QObject,
+        pyqtSignal,
+        Qt,
+        QSortFilterProxyModel,
+)
+from PyQt5.QtWidgets import QWidget, QComboBox, QVBoxLayout
+from PyQt5.QtGui import QStandardItemModel
+
+from winrustler.ui.widgets.sync import SyncToStandardItemModel
+
+logger = logging.getLogger(__name__)
+
+
+class WindowSelect(QWidget):
+    """
+    Input:
+        Call sync_windows() (SyncToStandardItemModel.__call__()) to tell it about the
+        hwnds that are going on.
+
+    Output:
+        The hwnd() function for the currently selected hwnd.
+        Emits updated() when this changes.
+    """
+
+    updated = pyqtSignal()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self._select = QComboBox(self)
+        self._proxy = QSortFilterProxyModel(self._select)
+        self._model = QStandardItemModel(0, 1, self._proxy)
+        self._proxy.setSourceModel(self._model)
+        self._select.setModel(self._proxy)
+        self._select.currentIndexChanged.connect(self.updated)
+
+        self._layout = QVBoxLayout(self)
+        self._layout.setContentsMargins(0, 0, 0, 0)
+        self._layout.addWidget(self._select)
+        self.setLayout(self._layout)
+
+        self.sync_windows = SyncToStandardItemModel(self._model)
+
+    def hwnd(self):
+        return self._select.currentData()

          
A => winrustler/ui/widgets/sync.py +36 -0
@@ 0,0 1,36 @@ 
+import logging
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtCore import pyqtSlot
+
+from winrustler.winapi import get_window_title
+from winrustler.ui.winapi import get_window_icon
+
+logger = logging.getLogger(__name__)
+
+
+class SyncToStandardItemModel(object):
+
+    def __init__(self, model, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.model = model
+        self.added_items = {}
+
+    def __call__(self, adds, removes):
+        from PyQt5.QtGui import QStandardItem
+        logger.debug("Resyncing %s, adds=%r, removes=%r", self.model, adds, removes)
+        try:
+            for hwnd in removes:
+                item = self.added_items.pop(hwnd)
+                row = self.model.indexFromItem(item).row()
+                assert [item] == self.model.takeRow(row)
+            for hwnd in adds:
+                # FIXME CURRENT WINDOW SYNCRONIZIATION STATE MEMES
+                item = QStandardItem(get_window_icon(hwnd), get_window_title(hwnd))
+                item.setData(hwnd, Qt.UserRole)
+                self.model.appendRow(item)
+                self.added_items[hwnd] = item
+        except (SystemExit, KeyboardInterrupt):
+            raise
+        except:
+            logger.exception("Uh oh...")