refactoring and history saving
M winrustler/fader.py +3 -0
@@ 43,3 43,6 @@ class FadeWindow(object):
                     LWA_COLORKEY|LWA_ALPHA,
                     ):
                 raise ctypes.WinError()
+
+    def summarized(self):
+        return "Set opacity to {}/255 ({}%)".format(self.opacity, (100 * self.opacity)//255)

          
M winrustler/mover.py +4 -0
@@ 54,3 54,7 @@ class MoveWindow(object):
         else:
             x, y = self.x, self.y
         user32.SetWindowPos(self.hwnd, 0, x, y, 0, 0, SWP_NOSIZE)
+
+    def summarized(self):
+        what = "" if self.move_viewport else "frame "
+        return "Move {}to {}, {}".format(what, self.x, self.y)

          
M winrustler/ui/__main__.py +12 -2
@@ 1,9 1,10 @@ 
 import sys
 import logging
 
-from PyQt5.QtWidgets import qApp
+from PyQt5.QtWidgets import qApp, QMenu
 
 from winrustler.ui.app import WinRustlerApp
+from winrustler.ui.history import HistoryFeature
 from winrustler.ui.widgets.tray import RustlerTray
 
 

          
@@ 33,10 34,19 @@ def 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, app.history_feature)
+
+    history_menu = QMenu(parent=None)
+    history_feature = HistoryFeature(app.winset, history_menu)
+    history_feature.load()
+    history_feature.rustle.connect(app.attempt_rustle)
+
+    tray = RustlerTray(app.winset, history_feature)
     tray.show()
     tray.rustle.connect(app.do_rustling)
+
     app.rustled.connect(tray.show_rustle_message)
+    app.rustled.connect(history_feature.update_from_rustle)
+
     app.setWindowIcon(tray.icon())
 
     if args.show:

          
M winrustler/ui/app.py +19 -24
@@ 1,17 1,18 @@ 
 import logging
 
+import attr
+
 from PyQt5.QtCore import (
         QObject,
         pyqtSignal,
         pyqtSlot,
         QTimer,
 )
-from PyQt5.QtWidgets import QApplication, QMenu
+from PyQt5.QtWidgets import QApplication
 
-from winrustler.winapi import WindowDiscovery
+from winrustler.winapi import WindowDiscovery, query_one
 from winrustler.ui.winapi import WinHooker
-from winrustler.ui.history import HistoryFeature
-
+from winrustler.ui.debug import show_exceptions
 
 logger = logging.getLogger(__name__)
 

          
@@ 24,26 25,22 @@ class WindowSet(QObject):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.data = set()
+        self.hwnds = set()
 
     def tell_and_connect(self, slot):
-        slot(self.data, ())
+        slot(self.hwnds, ())
         self.discovered.connect(slot)
 
     def sync(self, adds, removes):
-        self.data.update(adds)
-        self.data.difference_update(removes)
+        self.hwnds.update(adds)
+        self.hwnds.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)

          
@@ 58,21 55,19 @@ class WinRustlerApp(QApplication):
         # 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)
 
+    @pyqtSlot(object)
+    @show_exceptions
     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)
+        rustle.run()
+        self.rustled.emit(rustle)
 
+    @pyqtSlot(object, object)
+    @show_exceptions
+    def attempt_rustle(self, window_title, rustle):
+        hwnd = query_one(self.winset.hwnds, window_title)
+        rustle = attr.evolve(rustle, hwnd=hwnd)
+        self.do_rustling(rustle)

          
M winrustler/ui/history.py +63 -9
@@ 3,11 3,12 @@ import logging
 import pytest
 import attr
 
-from PyQt5.QtCore import QObject, pyqtSlot
+from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal
 from PyQt5.QtWidgets import QMenu, QAction
 
 from winrustler.winapi import get_window_title
 from winrustler.ui.debug import show_exceptions
+from winrustler.ui.state import program_settings, Serialization
 from winrustler.ui.winapi import get_window_icon
 
 logger = logging.getLogger(__name__)

          
@@ 30,8 31,25 @@ class PastRustle(object):
                 and self.window_title == other.window_title
                 #and self.process_path == other.process_path
 
+    def save(self):
+        return attr.asdict(self)
+
+    @classmethod
+    def restore(cls, data):
+        #data['rustle'] = 
+        return cls(**data)
+
+
+from winrustler.fader import FadeWindow
+from winrustler.mover import MoveWindow
+serialization = Serialization()
+serialization.know(FadeWindow, 'fade')
+serialization.know(MoveWindow, 'move')
+serialization.know(PastRustle, 'past')
+
 
 class HistoryFeature(QObject):
+    rustle = pyqtSignal(object, object)
 
     def __init__(self, winset, menu, *args, **kwargs):
         super().__init__(menu, *args, **kwargs)

          
@@ 46,8 64,29 @@ class HistoryFeature(QObject):
     @pyqtSlot(object, object)
     @show_exceptions
     def _update_engagement(self, new, lost):
-        #new
-        pass
+        for past in self.data:
+            pass
+
+    def save(self):
+        settings = program_settings()
+        logger.debug("Saving history.")
+        settings.setValue("history", serialization.savable(self.data))
+
+    def load(self):
+        settings = program_settings()
+        logger.debug("Loading history.")
+        for past in serialization.restored(settings.value("history", [])):
+            self.extend_history(past)
+
+    @pyqtSlot()
+    @show_exceptions
+    def clear_and_save(self):
+        self.clear()
+        self.save()
+
+    def clear(self):
+        while self.data:
+            self.menu.remove.removeAction(self.actions.pop(self.data.pop()))
 
     @pyqtSlot(object)
     @show_exceptions

          
@@ 57,6 96,7 @@ class HistoryFeature(QObject):
         rustle = attr.evolve(rustle, hwnd=None)
         past = PastRustle(window_icon=icon, window_title=title, rustle=rustle)
         self.extend_history(past)
+        self.save()
 
     def extend_history(self, past):
         """ Inserts at the "beginning" of the menu, should be the most recent.

          
@@ 77,25 117,25 @@ class HistoryFeature(QObject):
                 self.menu.insertAction(before, act)
         else:
             self.data.insert(0, past)
-            act = QAction(past.window_icon, past.window_title, parent=self.menu)
+            text = "{} - {}".format(past.window_title, past.rustle.summarized())
+            act = QAction(past.window_icon, text, parent=self.menu)
             act.triggered.connect(self._activate_past)
+            act.setData(past)
             self.menu.insertAction(before, act)
             self.actions[past] = act
 
             while len(self.data) > HISTORY_LENGTH:
-                extranious = self.actions.remove(self.data.pop())
+                extranious = self.actions.pop(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)
+        past = act.data()
+        self.rustle.emit(past.window_title, past.rustle)
 
 
 @pytest.fixture

          
@@ 104,6 144,20 @@ def app():
     return QApplication([])
 
 
+def test_save(app):
+    from winrustler.mover import MoveWindow
+    from winrustler.ui import icon
+    window_icon = icon('1f412.png')
+    rustle = MoveWindow(hwnd=None, x=1, y=2)
+    past = PastRustle(window_icon=window_icon, window_title="foobar", rustle=rustle)
+    settings = program_settings("-test")
+    settings.setValue("past", serialization.savable(past))
+
+    loaded_past = serialization.restored(settings.value("past"))
+    # The icons won't be equal, comparing them is hard...
+    assert past.rustle == loaded_past.rustle
+
+
 def test(app):
     from winrustler.mover import MoveWindow
     from winrustler.ui.app import WindowSet

          
M winrustler/ui/state.py +62 -4
@@ 7,20 7,78 @@ from winrustler.ui.debug import log_exce
 logger = logging.getLogger(__name__)
 
 
+def program_settings(suffix=""):
+    return QSettings("WinRustler Corp.", "WinRustler" + suffix)
+
+
+import attr
+from attr.exceptions import NotAnAttrsClassError
+
+IDENT_KEY = "__typename__"
+
+@attr.s()
+class Serialization():
+    _cls_idents = attr.ib(default=attr.Factory(dict))
+    _ident_clss = attr.ib(default=attr.Factory(dict))
+
+    def know(self, cls, ident):
+        self._cls_idents[cls] = ident
+        self._ident_clss[ident] = cls
+
+    def savable(self, obj):
+        if isinstance(obj, list):
+            return list(map(self.savable, obj))
+        elif isinstance(obj, tuple):
+            return tuple(map(self.savable, obj))
+        elif isinstance(obj, object):
+            try:
+                data = attr.asdict(obj, recurse=False)
+            except NotAnAttrsClassError:
+                return obj
+            else:
+                for cls, ident in self._cls_idents.items():
+                    if type(obj) == cls:
+                        for key, value in data.items():
+                            data[key] = self.savable(value)
+                        data[IDENT_KEY] = ident
+                        return data
+                else:
+                    raise NotImplementedError("Not sure what to do with %s?" % type(obj))
+        else:
+            return obj  # Assume savable?
+
+    def restored(self, data):
+        if isinstance(data, list):
+            return list(map(self.restored, data))
+        elif isinstance(data, tuple):
+            return tuple(map(self.restored, data))
+        elif isinstance(data, dict):
+            for key, value in data.items():
+                data[key] = self.restored(value)
+            if IDENT_KEY in data:
+                ctor = self._ident_clss[data.pop(IDENT_KEY)]
+                data = ctor(**data)
+                return data
+            else:
+                return data
+        else:
+            return data
+
+
 @log_exceptions
-def save_window_state(window):
+def save_window_geometry(window):
     classname = type(window).__name__
     assert classname
-    settings = QSettings("WinRustler Corp.", "WinRustler")
+    settings = program_settings()
     logger.debug("Saving %r", classname + "/geometry")
     settings.setValue(classname + "/geometry", window.saveGeometry())
 
 
 @log_exceptions
-def restore_window_state(window):
+def restore_window_geometry(window):
     classname = type(window).__name__
     assert classname
-    settings = QSettings("WinRustler Corp.", "WinRustler")
+    settings = program_settings()
     logger.debug("Loading %r", classname + "/geometry")
     geometry = settings.value(classname + "/geometry")
     if geometry is not None:

          
M winrustler/ui/widgets/match.py +3 -3
@@ 8,7 8,7 @@ from PyQt5.QtGui import QStandardItemMod
 
 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
+from winrustler.ui.state import save_window_geometry, restore_window_geometry
 
 logger = logging.getLogger(__name__)
 

          
@@ 97,10 97,10 @@ class MatchDialog(QDialog):
         self._layout.addWidget(self._bb)
 
     def showEvent(self, event):
-        restore_window_state(self)
+        restore_window_geometry(self)
         return super().showEvent(event)
 
     def hideEvent(self, event):
-        save_window_state(self)
+        save_window_geometry(self)
         return super().hideEvent(event)
 

          
M winrustler/ui/widgets/rustlerwindow.py +3 -3
@@ 14,7 14,7 @@ from winrustler.ui.debug import show_exc
 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
+from winrustler.ui.state import save_window_geometry, restore_window_geometry
 
 
 class RustlerWindow(QDialog):

          
@@ 102,9 102,9 @@ class RustlerWindow(QDialog):
             return tab.window_request(hwnd)
 
     def showEvent(self, event):
-        restore_window_state(self)
+        restore_window_geometry(self)
         return super().showEvent(event)
 
     def hideEvent(self, event):
-        save_window_state(self)
+        save_window_geometry(self)
         return super().hideEvent(event)

          
M winrustler/winapi.py +40 -0
@@ 1,4 1,6 @@ 
 import ctypes
+import enum
+import itertools
 import re
 from ctypes import wintypes
 

          
@@ 12,6 14,17 @@ try:
 except AttributeError:
     GetWindowLong = user32.GetWindowLongW
 
+
+class Q(enum.Enum):
+    CASE_INSENSITIVE = 0x0
+    CASE_SENSITIVE = 0x1
+
+    EXACT = 0x0
+    #SUBSTRING = 0x10
+    RE = 0x20
+
+    DEFAULT = CASE_INSENSITIVE | EXACT
+
     
 def get_window_title(hwnd):
     # TODO GetWindowTextLength

          
@@ 114,6 127,33 @@ def search(hwnds, pat):
     return r[0]
 
 
+def query(hwnds, match, flags=Q.DEFAULT):
+    if flags != Q.DEFAULT:
+        raise NotImplementedError
+    for hwnd in hwnds:
+        if get_window_title(hwnd) == match:
+            yield hwnd
+
+
+class NoResults(ValueError):
+    pass
+
+
+class TooManyResults(ValueError):
+    pass
+
+
+def query_one(*args, **kwargs):
+    res = list(query(*args, **kwargs))
+    if len(res) == 1:
+        return res[0]
+    if len(res) == 0:
+        raise NoResults(res)
+    if len(res) == 2:
+        raise TooManyResults(res)
+    raise NotImplementedError(len(res))
+
+
 #def test_WindowDiscovery(app):
 #    from PyQt5.QtWidgets import QComboBox
 #    cb = QComboBox()