Skip to content

Commit 3506554

Browse files
committed
- added better Linux support to keyboard listener;
- added more statistics to mouse page; - reworked to use subprocessing when running from source (works better with pip installs).
1 parent 0837096 commit 3506554

File tree

12 files changed

+254
-190
lines changed

12 files changed

+254
-190
lines changed

README.md

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
InputScope
22
==========
33

4-
Mouse and keyboard input heatmap visualizer, and input statistics.
4+
Mouse and keyboard input heatmap visualizer and statistics.
55

66
Three components:
77
* main - wxPython desktop tray program, runs listener and webui
8-
* listener - logs mouse and keyboard input
9-
* webui - web frontend for statistics and heatmaps
8+
* listener - logs mouse and keyboard input, can run individually
9+
* webui - web frontend for statistics and heatmaps, can run individually
1010

1111
Listener and web-UI components can be run separately, or launched from main.
1212

13-
Data is kept in an SQLite database.
13+
Data is kept in an SQLite database, under inputscope/var.
1414

1515
[![Mouse heatmap](https://raw.github.com/suurjaak/InputScope/media/img/th_mouse.png)](https://raw.github.com/suurjaak/InputScope/media/img/mouse.png)
1616
[![Keyboard heatmap](https://raw.github.com/suurjaak/InputScope/media/img/th_keyboard.png)](https://raw.github.com/suurjaak/InputScope/media/img/keyboard.png)
@@ -51,9 +51,9 @@ Icon from Paomedia small-n-flat iconset,
5151
released under Creative Commons (Attribution 3.0 Unported),
5252
https://www.iconfinder.com/icons/285642/monitor_icon.
5353

54-
Keyboard image modified from Wikipedia File:ISO keyboard (105) QWERTY UK.svg,
54+
Keyboard image modified from Wikipedia `File:ISO keyboard (105) QWERTY UK.svg`,
5555
released under the GNU Free Documentation License,
56-
http://en.wikipedia.org/wiki/File:ISO_keyboard_(105)_QWERTY_UK.svg
56+
http://en.wikipedia.org/wiki/File:ISO_keyboard_(105)_QWERTY_UK.svg.
5757

5858

5959
License

inputscope/conf.py

+11-10
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
2121
@author Erki Suurjaak
2222
@created 26.03.2015
23-
@modified 29.04.2015
23+
@modified 06.05.2015
2424
------------------------------------------------------------------------------
2525
"""
2626
try: import ConfigParser as configparser # Py2
@@ -37,7 +37,7 @@
3737
"""Program title, version number and version date."""
3838
Title = "InputScope"
3939
Version = "1.0"
40-
VersionDate = "29.04.2015"
40+
VersionDate = "06.05.2015"
4141

4242
"""TCP port of the web user interface."""
4343
WebHost = "localhost"
@@ -109,8 +109,8 @@
109109
"I": (270, 84),
110110
"O": (300, 84),
111111
"P": (330, 84),
112-
"Oem_3": (370, 84),
113-
"Oem_4": (400, 84),
112+
"Oem_3": (360, 84),
113+
"Oem_4": (390, 84),
114114
"Enter": (426, 96),
115115

116116
"CapsLock": (25, 111),
@@ -198,7 +198,9 @@
198198
"""Whether web server is quiet or echoes access log."""
199199
WebQuiet = True
200200

201-
if getattr(sys, "frozen", False): # Running as a pyinstaller executable
201+
"""Whether running as a pyinstaller executable."""
202+
Frozen = getattr(sys, "frozen", False)
203+
if Frozen:
202204
ExecutablePath = ShortcutIconPath = os.path.abspath(sys.executable)
203205
ApplicationPath = os.path.dirname(ExecutablePath)
204206
RootPath = os.path.join(os.environ.get("_MEIPASS2", getattr(sys, "_MEIPASS", "")))
@@ -254,14 +256,14 @@ def parse_value(raw):
254256

255257
def save(filename=ConfigPath):
256258
"""Saves this module's changed attributes to INI configuration."""
257-
global DefaultValues
259+
default_values = defaults()
258260
parser = configparser.RawConfigParser()
259261
parser.optionxform = str # Force case-sensitivity on names
260262
try:
261263
save_types = basestring, int, float, tuple, list, dict, type(None)
262264
for k, v in sorted(globals().items()):
263265
if not isinstance(v, save_types) or k.startswith("_") \
264-
or DefaultValues.get(k, parser) == v: continue # for k, v
266+
or default_values.get(k, parser) == v: continue # for k, v
265267
try: parser.set("DEFAULT", k, json.dumps(v))
266268
except Exception: pass
267269
if parser.defaults():
@@ -276,14 +278,13 @@ def save(filename=ConfigPath):
276278
logging.warn("Error writing config to %s.", filename, exc_info=True)
277279

278280

279-
def register_defaults(values={}):
281+
def defaults(values={}):
280282
"""Returns a once-assembled dict of this module's storable attributes."""
281283
if values: return values
282284
save_types = basestring, int, float, tuple, list, dict, type(None)
283285
for k, v in globals().items():
284286
if isinstance(v, save_types) and not k.startswith("_"): values[k] = v
285-
values["DefaultValues"] = values
286287
return values
287288

288289

289-
DefaultValues = register_defaults() # Store initial values to compare on saving
290+
defaults() # Store initial values to compare on saving

inputscope/db.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,12 @@
1414
1515
@author Erki Suurjaak
1616
@created 05.03.2014
17-
@modified 22.04.2015
17+
@modified 03.05.2015
1818
"""
1919
import os
2020
import re
2121
import sqlite3
2222

23-
DBPATH = None # Default database path
24-
INIT_STATEMENTS = [] # Statements to run in default database at startup
25-
2623

2724
def fetch(table, cols="*", where=(), group="", order=(), limit=(), **kwargs):
2825
"""Convenience wrapper for database SELECT and fetch all."""
@@ -68,7 +65,8 @@ def execute(sql, args=None):
6865

6966
def get_cursor():
7067
"""Returns a cursor to the default database."""
71-
return make_cursor(DBPATH, INIT_STATEMENTS)
68+
config = get_config()
69+
return make_cursor(config["path"], config["statements"])
7270

7371

7472
def make_cursor(path, init_statements=(), _cursorcache={}):
@@ -131,7 +129,10 @@ def makeSQL(action, table, cols="*", where=(), group="", order=(), limit=(), val
131129
return sql, args
132130

133131

134-
def init(path, statements=None):
135-
global DBPATH, INIT_STATEMENTS
136-
DBPATH, INIT_STATEMENTS = path, statements
137-
get_cursor()
132+
def get_config(config={}): return config
133+
134+
135+
def init(path, init_statements=None):
136+
config = get_config()
137+
config["path"], config["statements"] = path, init_statements
138+
make_cursor(config["path"], config["statements"])

inputscope/listener.py

+84-39
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
55
@author Erki Suurjaak
66
@created 06.04.2015
7-
@modified 29.04.2015
7+
@modified 06.05.2015
88
"""
99
from __future__ import print_function
1010
import datetime
11-
import multiprocessing
1211
import Queue
1312
import sys
1413
import threading
@@ -21,22 +20,22 @@
2120
DEBUG = False
2221

2322

24-
class Listener(object):
23+
class Listener(threading.Thread):
2524
"""Runs mouse and keyboard listeners, and handles incoming commands."""
2625

27-
def __init__(self, inqueue, outqueue):
26+
def __init__(self, inqueue, outqueue=None):
27+
threading.Thread.__init__(self)
2828
self.inqueue = inqueue
29-
self.outqueue = outqueue
3029
self.running = False
3130
self.mouse_handler = None
3231
self.key_handler = None
33-
self.data_handler = DataHandler(self.outqueue.put)
32+
self.data_handler = DataHandler(getattr(outqueue, "put", lambda x: x))
3433

3534
def run(self):
3635
self.running = True
3736
while self.running:
3837
command = self.inqueue.get()
39-
if "exit" == command or command is None:
38+
if "exit" == command:
4039
self.stop()
4140
elif "mouse_start" == command:
4241
if not self.mouse_handler:
@@ -110,17 +109,17 @@ class MouseHandler(pymouse.PyMouseEvent):
110109

111110
def __init__(self, output):
112111
pymouse.PyMouseEvent.__init__(self)
113-
self.output = output
112+
self._output = output
114113
self.start()
115114

116115
def click(self, x, y, button, press):
117-
if press: self.output(type="clicks", x=x, y=y, button=button)
116+
if press: self._output(type="clicks", x=x, y=y, button=button)
118117

119118
def move(self, x, y):
120-
self.output(type="moves", x=x, y=y)
119+
self._output(type="moves", x=x, y=y)
121120

122121
def scroll(self, x, y, wheel):
123-
self.output(type="scrolls", x=x, y=y, wheel=wheel)
122+
self._output(type="scrolls", x=x, y=y, wheel=wheel)
124123

125124

126125

@@ -129,38 +128,48 @@ class KeyHandler(pykeyboard.PyKeyboardEvent):
129128
CONTROLCODES = {"\x00": "Nul", "\x01": "Start-Of-Header", "\x02": "Start-Of-Text", "\x03": "Break", "\x04": "End-Of-Transmission", "\x05": "Enquiry", "\x06": "Ack", "\x07": "Bell", "\x08": "Backspace", "\x09": "Tab", "\x0a": "Linefeed", "\x0b": "Vertical-Tab", "\x0c": "Form-Fe", "\x0d": "Enter", "\x0e": "Shift-In", "\x0f": "Shift-Out", "\x10": "Data-Link-Escape", "\x11": "Devicecontrol1", "\x12": "Devicecontrol2", "\x13": "Devicecontrol3", "\x14": "Devicecontrol4", "\x15": "Nak", "\x16": "Syn", "\x17": "End-Of-Transmission-Block", "\x18": "Break", "\x19": "End-Of-Medium", "\x1a": "Substitute", "\x1b": "Escape", "\x1c": "File-Separator", "\x1d": "Group-Separator", "\x1e": "Record-Separator", "\x1f": "Unit-Separator", "\x20": "Space", "\x7f": "Del", "\xa0": "Non-Breaking Space"}
130129
NUMPAD_SPECIALS = [("Insert", False), ("Delete", False), ("Home", False), ("End", False), ("PageUp", False), ("PageDown", False), ("Up", False), ("Down", False), ("Left", False), ("Right", False), ("Clear", False), ("Enter", True)]
131130
MODIFIERNAMES = {"Lcontrol": "Ctrl", "Rcontrol": "Ctrl", "Lshift": "Shift", "Rshift": "Shift", "Alt": "Alt", "AltGr": "Alt", "Lwin": "Win", "Rwin": "Win"}
132-
RENAMES = {"Prior": "PageUp", "Next": "PageDown", "Lmenu": "Alt", "Rmenu": "AltGr", "Apps": "Menu", "Return": "Enter", "Back": "Backspace", "Capital": "CapsLock", "Numlock": "NumLock", "Snapshot": "PrintScreen", "Scroll": "ScrollLock", "Decimal": "Numpad-Decimal", "Divide": "Numpad-Divide", "Subtract": "Numpad-Subtract", "Multiply": "Numpad-Multiply", "Add": "Numpad-Add", "Cancel": "Break"}
131+
RENAMES = {"Prior": "PageUp", "Next": "PageDown", "Lmenu": "Alt", "Rmenu": "AltGr", "Apps": "Menu", "Return": "Enter", "Back": "Backspace", "Capital": "CapsLock", "Numlock": "NumLock", "Snapshot": "PrintScreen", "Scroll": "ScrollLock", "Decimal": "Numpad-Decimal", "Divide": "Numpad-Divide", "Subtract": "Numpad-Subtract", "Multiply": "Numpad-Multiply", "Add": "Numpad-Add", "Cancel": "Break", "Control_L": "Lcontrol", "Control_R": "Rcontrol", "Alt_L": "Alt", "Shift_L": "Lshift", "Shift_R": "Rshift", "Super_L": "Lwin", "Super_R": "Rwin", "BackSpace": "Backspace", "L1": "F11", "L2": "F12", "Page_Up": "PageUp", "Print": "PrintScreen", "Scroll_Lock": "ScrollLock", "Caps_Lock": "CapsLock", "Num_Lock": "NumLock", "Begin": "Clear", "Super": "Win", "Mode_switch": "AltGr"}
133132
KEYS_DOWN = (0x0100, 0x0104) # [WM_KEYDOWN, WM_SYSKEYDOWN]
134133
KEYS_UP = (0x0101, 0x0105) # [WM_KEYUP, WM_SYSKEYUP]
135134
ALT_GRS = (36, 64, 91, 92, 93, 123, 124, 125, 128, 163, 208, 222, 240, 254) # $@[\]{|}€£ŠŽšž
135+
OEM_KEYS = {34: "Oem_3", 35: "Oem_5", 47: "Oem_1", 48: "Oem_2", 51: "Oem_5", 59: "Oem_Comma", 60: "Oem_Period", 61: "Oem_6", 94: "Oem_102"}
136136

137137

138138

139139
def __init__(self, output):
140140
pykeyboard.PyKeyboardEvent.__init__(self)
141-
self.output = output
141+
self._output = output
142142
NAMES = {"win32": "handler", "linux2": "tap", "darwin": "keypress"}
143-
HANDLERS = {"win32": self.handle_windows, "linux2": self.handle_linux,
144-
"darwin": self.handle_mac}
143+
HANDLERS = {"win32": self._handle_windows, "linux2": self._handle_linux,
144+
"darwin": self._handle_mac}
145145
setattr(self, NAMES[sys.platform], HANDLERS[sys.platform])
146-
self.modifiers = dict((x, False) for x in self.MODIFIERNAMES.values())
147-
self.realmodifiers = dict((x, False) for x in self.MODIFIERNAMES)
146+
self._modifiers = dict((x, False) for x in self.MODIFIERNAMES.values())
147+
self._realmodifiers = dict((x, False) for x in self.MODIFIERNAMES)
148148
self.start()
149149

150150

151-
def keyname(self, key):
152-
key = self.CONTROLCODES.get(key, key)
153-
key = self.RENAMES.get(key, key)
151+
def _keyname(self, key, keycode=None):
152+
if keycode in self.OEM_KEYS:
153+
key = self.OEM_KEYS[keycode]
154+
elif key.startswith("KP_"): # Linux numpad
155+
if 4 == len(key):
156+
key = key.replace("KP_", "Numpad")
157+
else:
158+
key = key.replace("KP_", "")
159+
key = "Numpad-" + self.RENAMES.get(key, key).replace("Numpad-", "")
160+
else:
161+
key = self.CONTROLCODES.get(key, key)
162+
key = self.RENAMES.get(key, key)
154163
return key.upper() if 1 == len(key) else key
155164

156165

157-
def handle_windows(self, event):
166+
def _handle_windows(self, event):
158167
"""Windows key event handler."""
159-
vkey = self.keyname(event.GetKey())
168+
vkey = self._keyname(event.GetKey())
160169
if event.Message in self.KEYS_UP + self.KEYS_DOWN:
161170
if vkey in self.MODIFIERNAMES:
162-
self.realmodifiers[vkey] = event.Message in self.KEYS_DOWN
163-
self.modifiers[self.MODIFIERNAMES[vkey]] = self.realmodifiers[vkey]
171+
self._realmodifiers[vkey] = event.Message in self.KEYS_DOWN
172+
self._modifiers[self.MODIFIERNAMES[vkey]] = self._realmodifiers[vkey]
164173
if event.Message not in self.KEYS_DOWN:
165174
return True
166175

@@ -171,22 +180,22 @@ def handle_windows(self, event):
171180
key = vkey
172181
else:
173182
is_altgr = event.Ascii in self.ALT_GRS
174-
key = self.keyname(unichr(event.Ascii))
183+
key = self._keyname(unichr(event.Ascii))
175184

176185
if DEBUG: print("Adding key %s (real %s)" % (key.encode("utf-8"), vkey.encode("utf-8")))
177-
self.output(type="keys", key=key, realkey=vkey)
186+
self._output(type="keys", key=key, realkey=vkey)
178187

179188
if vkey not in self.MODIFIERNAMES and not is_altgr:
180189
modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"]
181190
if self.modifiers[k])
182191
if modifier and modifier != "Shift": # Shift-X is not a combo
183-
if self.modifiers["Ctrl"] and event.Ascii:
184-
key = self.keyname(unichr(event.KeyID))
185-
realmodifier = "-".join(k for k, v in self.realmodifiers.items() if v)
192+
if self._modifiers["Ctrl"] and event.Ascii:
193+
key = self._keyname(unichr(event.KeyID))
194+
realmodifier = "-".join(k for k, v in self._realmodifiers.items() if v)
186195
realkey = "%s-%s" % (realmodifier, key)
187196
key = "%s-%s" % (modifier, key)
188197
if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8")))
189-
self.output(type="combos", key=key, realkey=realkey)
198+
self._output(type="combos", key=key, realkey=realkey)
190199

191200
if DEBUG:
192201
print("CHARACTER: %r" % key)
@@ -203,29 +212,65 @@ def handle_windows(self, event):
203212
return True
204213

205214

206-
def handle_mac(self, keycode):
215+
def _handle_mac(self, keycode):
207216
"""Mac key event handler"""
208-
key = self.keyname(unichr(keycode))
209-
self.output(type="keys", key=key, realkey=key)
217+
key = self._keyname(unichr(keycode))
218+
self._output(type="keys", key=key, realkey=key)
210219

211-
def handle_linux(self, keycode, character, press):
220+
def _handle_linux(self, keycode, character, press):
212221
"""Linux key event handler."""
213-
key = self.keyname(character)
214-
if press: self.output(type="keys", key=key, realkey=key)
222+
if character is None: return
223+
key = self._keyname(character, keycode)
224+
if key in self.MODIFIERNAMES:
225+
self._modifiers[self.MODIFIERNAMES[key]] = press
226+
self._realmodifiers[key] = press
227+
if press:
228+
self._output(type="keys", key=key, realkey=key)
229+
if press and key not in self.MODIFIERNAMES:
230+
modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"]
231+
if self._modifiers[k])
232+
if modifier and modifier != "Shift": # Shift-X is not a combo
233+
realmodifier = "-".join(k for k, v in self._realmodifiers.items() if v)
234+
realkey = "%s-%s" % (realmodifier, key)
235+
key = "%s-%s" % (modifier, key)
236+
if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8")))
237+
self._output(type="combos", key=key, realkey=realkey)
238+
215239

216240
def escape(self, event):
217241
"""Override PyKeyboardEvent.escape to not quit on Escape."""
218242
return False
219243

220244

245+
246+
class LineQueue(threading.Thread):
247+
"""Reads lines from a file-like object and pushes to self.queue."""
248+
def __init__(self, input):
249+
threading.Thread.__init__(self)
250+
self.daemon = True
251+
self.input, self.queue = input, Queue.Queue()
252+
self.start()
253+
254+
def run(self):
255+
for line in iter(self.input.readline, ""):
256+
self.queue.put(line.strip())
257+
258+
259+
def start(inqueue, outqueue=None):
260+
"""Starts the listener with incoming and outgoing queues."""
261+
conf.init(), db.init(conf.DbPath)
262+
Listener(inqueue, outqueue).run()
263+
264+
221265
def main():
222266
"""Entry point for stand-alone execution."""
223267
conf.init(), db.init(conf.DbPath)
224-
inqueue = multiprocessing.Queue()
225-
outqueue = type("PrintQueue", (), {"put": lambda self, x: print(x)})()
268+
inqueue = LineQueue(sys.stdin).queue
269+
outqueue = type("", (), {"put": lambda self, x: print("\r%s" % x, end=" ")})()
270+
if "--quiet" in sys.argv: outqueue = None
226271
if conf.MouseEnabled: inqueue.put("mouse_start")
227272
if conf.KeyboardEnabled: inqueue.put("keyboard_start")
228-
Listener(inqueue, outqueue).run()
273+
start(inqueue, outqueue)
229274

230275

231276
if "__main__" == __name__:

0 commit comments

Comments
 (0)