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()