M .hgignore +2 -0
@@ 1,3 1,5 @@
__pycache__
egg-info
.cache
+build
+dist
R README => +0 -0
A => README.rst +5 -0
@@ 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.
M setup.py +6 -5
@@ 5,20 5,21 @@ def read(fname):
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',
],
},
)
A => winrustler/__init__.py +1 -0
@@ 0,0 1,1 @@
+__version__ = '0.9.0' # Keep in sync with ../setup.py
M winrustler/__main__.py +1 -1
@@ 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
A winrustler/ui/__init__.py +10 -0
@@ 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))
M winrustler/ui/__main__.py +15 -333
@@ 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 = """\
-<h1>WinRustler</h1>
-
-<p>Version: <b>{VERSION}</b></p>
-
-<p><a href='https://bitbucket.org/sqwishy/winrustler'>bitbucket.org/sqwishy/winrustler</a></p>
-
-<p>Project built with
-<a href='https://www.python.org/'>Python 3</a>,
-<a href='https://www.riverbankcomputing.com/software/pyqt/intro'>Qt 5</a>,
-<a href='https://www.qt.io/'>PyQt5</a>, and
-<a href='https://www.emojione.com/emoji/v2'>EmojiOne v2</a>.</p>
-""".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 @@ 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.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 @@ if __name__ == "__main__":
finally:
tray.hide()
print("hey, we exited cleanly!")
+
+
+if __name__ == "__main__":
+ main()
A => winrustler/ui/app.py +78 -0
@@ 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)
+
M winrustler/ui/debug.py +21 -0
@@ 1,5 1,8 @@
import functools
import logging
+import traceback
+
+from PyQt5.QtWidgets import QMessageBox
logger = logging.getLogger(__name__)
@@ 14,3 17,21 @@ def log_exceptions(fn):
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
A => winrustler/ui/history.py +141 -0
@@ 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]
A => winrustler/ui/rustle.py +13 -0
@@ 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."
A => winrustler/ui/widgets/rustlerwindow.py +110 -0
@@ 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)
A => winrustler/ui/widgets/tray.py +94 -0
@@ 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 = """\
+<h1>WinRustler</h1>
+
+<p>Version: <b>{__version__}</b></p>
+
+<p><a href='https://bitbucket.org/sqwishy/winrustler'>bitbucket.org/sqwishy/winrustler</a></p>
+
+<p>Project built with
+<a href='https://www.python.org/'>Python 3</a>,
+<a href='https://www.riverbankcomputing.com/software/pyqt/intro'>Qt 5</a>,
+<a href='https://www.qt.io/'>PyQt5</a>, and
+<a href='https://www.emojione.com/emoji/v2'>EmojiOne v2</a>.</p>
+""".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()
+
M winrustler/winapi.py +19 -10
@@ 5,25 5,34 @@ from ctypes import wintypes
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):
M winrustler/winconsts.py +7 -1
@@ 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