# HG changeset patch # User sqwishy # Date 1515231278 28800 # Sat Jan 06 01:34:38 2018 -0800 # Node ID f3c0176da6b7cf2ef27a691c5c0ea9119670970a # Parent 0c1450ebcaee379d44528e8f0e5761ab9018bd62 Moved classes into their own special modules diff --git a/.hgignore b/.hgignore --- a/.hgignore +++ b/.hgignore @@ -1,3 +1,5 @@ __pycache__ egg-info .cache +build +dist diff --git a/README b/README deleted file mode 100644 diff --git a/README.rst b/README.rst new file mode 100644 --- /dev/null +++ b/README.rst @@ -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. diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -5,20 +5,21 @@ 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', ], }, ) diff --git a/winrustler/__init__.py b/winrustler/__init__.py new file mode 100644 --- /dev/null +++ b/winrustler/__init__.py @@ -0,0 +1,1 @@ +__version__ = '0.9.0' # Keep in sync with ../setup.py diff --git a/winrustler/__main__.py b/winrustler/__main__.py --- a/winrustler/__main__.py +++ b/winrustler/__main__.py @@ -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 diff --git a/winrustler/ui/__init__.py b/winrustler/ui/__init__.py --- a/winrustler/ui/__init__.py +++ b/winrustler/ui/__init__.py @@ -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)) diff --git a/winrustler/ui/__main__.py b/winrustler/ui/__main__.py --- a/winrustler/ui/__main__.py +++ b/winrustler/ui/__main__.py @@ -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 = """\ -

WinRustler

- -

Version: {VERSION}

- -

bitbucket.org/sqwishy/winrustler

- -

Project built with -Python 3, -Qt 5, -PyQt5, and -EmojiOne v2.

-""".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 @@ 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 @@ finally: tray.hide() print("hey, we exited cleanly!") + + +if __name__ == "__main__": + main() diff --git a/winrustler/ui/app.py b/winrustler/ui/app.py new file mode 100644 --- /dev/null +++ b/winrustler/ui/app.py @@ -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) + diff --git a/winrustler/ui/debug.py b/winrustler/ui/debug.py --- a/winrustler/ui/debug.py +++ b/winrustler/ui/debug.py @@ -1,5 +1,8 @@ import functools import logging +import traceback + +from PyQt5.QtWidgets import QMessageBox logger = logging.getLogger(__name__) @@ -14,3 +17,21 @@ 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 diff --git a/winrustler/ui/history.py b/winrustler/ui/history.py new file mode 100644 --- /dev/null +++ b/winrustler/ui/history.py @@ -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] diff --git a/winrustler/ui/rustle.py b/winrustler/ui/rustle.py new file mode 100644 --- /dev/null +++ b/winrustler/ui/rustle.py @@ -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." diff --git a/winrustler/ui/widgets/rustlerwindow.py b/winrustler/ui/widgets/rustlerwindow.py new file mode 100644 --- /dev/null +++ b/winrustler/ui/widgets/rustlerwindow.py @@ -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) diff --git a/winrustler/ui/widgets/tray.py b/winrustler/ui/widgets/tray.py new file mode 100644 --- /dev/null +++ b/winrustler/ui/widgets/tray.py @@ -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 = """\ +

WinRustler

+ +

Version: {__version__}

+ +

bitbucket.org/sqwishy/winrustler

+ +

Project built with +Python 3, +Qt 5, +PyQt5, and +EmojiOne v2.

+""".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() + diff --git a/winrustler/winapi.py b/winrustler/winapi.py --- a/winrustler/winapi.py +++ b/winrustler/winapi.py @@ -5,25 +5,34 @@ 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): diff --git a/winrustler/winconsts.py b/winrustler/winconsts.py --- a/winrustler/winconsts.py +++ b/winrustler/winconsts.py @@ -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