# HG changeset patch # User sqwishy # Date 1515280218 28800 # Sat Jan 06 15:10:18 2018 -0800 # Node ID 40f446d619baf62f5528e69210ced2b507b4cfe8 # Parent 290be9616d5ac815d61a68b05d8c2c0ec8a81005 refactoring and history saving diff --git a/winrustler/fader.py b/winrustler/fader.py --- a/winrustler/fader.py +++ b/winrustler/fader.py @@ -43,3 +43,6 @@ LWA_COLORKEY|LWA_ALPHA, ): raise ctypes.WinError() + + def summarized(self): + return "Set opacity to {}/255 ({}%)".format(self.opacity, (100 * self.opacity)//255) diff --git a/winrustler/mover.py b/winrustler/mover.py --- a/winrustler/mover.py +++ b/winrustler/mover.py @@ -54,3 +54,7 @@ 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) diff --git a/winrustler/ui/__main__.py b/winrustler/ui/__main__.py --- a/winrustler/ui/__main__.py +++ b/winrustler/ui/__main__.py @@ -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 @@ 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: diff --git a/winrustler/ui/app.py b/winrustler/ui/app.py --- a/winrustler/ui/app.py +++ b/winrustler/ui/app.py @@ -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 @@ 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 @@ # 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) diff --git a/winrustler/ui/history.py b/winrustler/ui/history.py --- a/winrustler/ui/history.py +++ b/winrustler/ui/history.py @@ -3,11 +3,12 @@ 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 @@ 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 @@ @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 @@ 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 @@ 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 @@ 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 diff --git a/winrustler/ui/state.py b/winrustler/ui/state.py --- a/winrustler/ui/state.py +++ b/winrustler/ui/state.py @@ -7,20 +7,78 @@ 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: diff --git a/winrustler/ui/widgets/match.py b/winrustler/ui/widgets/match.py --- a/winrustler/ui/widgets/match.py +++ b/winrustler/ui/widgets/match.py @@ -8,7 +8,7 @@ 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 @@ 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) diff --git a/winrustler/ui/widgets/rustlerwindow.py b/winrustler/ui/widgets/rustlerwindow.py --- a/winrustler/ui/widgets/rustlerwindow.py +++ b/winrustler/ui/widgets/rustlerwindow.py @@ -14,7 +14,7 @@ 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 @@ 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) diff --git a/winrustler/winapi.py b/winrustler/winapi.py --- a/winrustler/winapi.py +++ b/winrustler/winapi.py @@ -1,4 +1,6 @@ import ctypes +import enum +import itertools import re from ctypes import wintypes @@ -12,6 +14,17 @@ 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 @@ 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()