diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..648f251 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build +/InputScope.egg-info +__pycache__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a5bb5..f941834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ CHANGELOG ========= +1.6, 2022-07-13 +--------------- +- take display index into account when discarding close mouse move events +- add missing Numpad-Delete key (issue #14) +- add more OEM keys (issue #14) +- add support for user-configured keys +- add support for larger fonts in statistics +- add unique count to keyboard pages (issue #21) +- add program version to database info box in statistics (issue #21) +- register key being held down as one keypress (issue #15) +- fix potential error on unplugging monitor (issue #12) +- fix registering Ctrl-NUM combos (issue #14) +- fix error on closing program with Ctrl-C in Linux (issue #16) +- fix running listener from main application in Linux (issue #17) +- fix tray icon menu error in Linux (issue #18) +- fix running application with pythonw.exe in Py3 W7 +- always save basic config directives to config file +- rearrange source code in src-layout + + 1.5, 2022-01-22 --------------- - add named sessions functionality diff --git a/MANIFEST.in b/MANIFEST.in index 0e529ef..a2d0d4c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -recursive-include inputscope *.py *.tpl *.db *.ini *.css *.ico *.js *.svg -include LICENSE.md README.md requirements.txt +recursive-include src *.py *.tpl *.db *.ini *.css *.ico *.js *.svg +include CHANGELOG.md LICENSE.md README.md requirements.txt diff --git a/README.md b/README.md index 0ad456b..d878bf4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,15 @@ Keypresses are logged as physical keys, ignoring Unicode mappings. Note: keyboard logging can interfere with remote control desktop, UI automation scripts, and sticky keys. +Non-standard keys can be added in configuration file, as: +```javascript +CustomKeys = {numeric key code: "text label for key"} +``` +e.g. +```javascript +CustomKeys = {21: "IME Han/Yeong", 25: "IME Hanja"} +``` + Data is kept in an SQLite database. The local web page is viewable at http://localhost:8099/, @@ -49,7 +58,7 @@ Three components in source code form: Listener and web-UI components can be run separately. -In source code form, data and configuration is kept under inputscope/var. +In source code form, data and configuration is kept under `inputscope/var`. The pip installation will add commands `inputscope`, `inputscope-listener` and `inputscope-webui` to path. @@ -61,8 +70,12 @@ Dependencies * Python 2.7 or Python 3.5+ * bottle * pynput +* pywin32 (optional, for toggling "Start with Windows") * wxPython (optional) +If wxPython is not available, InputScope will not have its tray program, +and will not recognize multi-monitor setups in mouse statistics. + Attribution ----------- diff --git a/inputscope.spec b/inputscope.spec index 04cf09e..42d9b72 100644 --- a/inputscope.spec +++ b/inputscope.spec @@ -7,7 +7,7 @@ Pyinstaller-provided names and variables: Analysis, EXE, PYZ, SPEC, TOC. @author Erki Suurjaak @created 13.04.2015 -@modified 22.01.2022 +@modified 28.02.2022 """ import os import struct @@ -18,10 +18,10 @@ DO_DEBUGVER = False DO_64BIT = (struct.calcsize("P") * 8 == 64) BUILDPATH = os.path.dirname(os.path.abspath(SPEC)) -APPPATH = os.path.join(BUILDPATH, NAME) ROOTPATH = BUILDPATH +APPPATH = os.path.join(ROOTPATH, "src") os.chdir(ROOTPATH) -sys.path.append(ROOTPATH) +sys.path.insert(0, APPPATH) from inputscope import conf @@ -64,7 +64,7 @@ a = Analysis( [entrypoint], excludes=MODULE_EXCLUDES, hiddenimports=MODULE_INCLUDES ) a.datas -= [(n, None, "DATA") for n in DATA_EXCLUDES] # entry=(name, path, typecode) -a.datas += [(os.path.join(*x), os.path.join(APPPATH, *x), "DATA") +a.datas += [(os.path.join(*x), os.path.join(APPPATH, NAME, *x), "DATA") for x in APP_INCLUDES] a.binaries -= [(n, None, None) for n in BINARY_EXCLUDES] a.pure = TOC([(n, p, c) for (n, p, c) in a.pure if not any( @@ -83,7 +83,7 @@ exe = EXE( strip=False, # EXE and all shared libraries run through cygwin's strip, tends to render Win32 DLLs unusable upx=True, # Using Ultimate Packer for eXecutables console=DO_DEBUGVER, # Use the Windows subsystem executable instead of the console one - icon=os.path.join(APPPATH, "static", "icon.ico"), + icon=os.path.join(APPPATH, NAME, "static", "icon.ico"), ) try: os.remove(entrypoint) diff --git a/inputscope/static/site.css b/inputscope/static/site.css deleted file mode 100644 index f17d832..0000000 --- a/inputscope/static/site.css +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Site style file. - * - * @author Erki Suurjaak - * @created 07.04.2015 - * @modified 21.10.2021 - */ -* { - font-family: Tahoma; -} -#header, #footer, #header *, #footer * { - font-size: 0.85em; -} -#header { - width: 700px; - height: 40px; - background: #1abc9c; - border-radius: 4px 4px 0px 0px; - margin-left: auto; - margin-right: auto; -} -#content { - position: relative; - width: 678px; - min-height: 200px; - border: 1px solid #1abc9c; - margin-left: auto; - margin-right: auto; - padding: 10px; -} -#dbinfo { - display: none; -} -#footer { - position: relative; - width: 700px; - color: white; - background: #1abc9c; - border-radius: 0px 0px 4px 4px; - text-align: center; - font-size: 0.8em; - margin-left: auto; - margin-right: auto; -} -#footer div { - padding: 7px; -} -#header a { - color: white; - text-decoration: none; -} -#footer a { - position: absolute; - color: white; - font-size: 1em; -} -#footer a:first-of-type { - left: 10px; -} -#footer a:last-of-type { - right: 10px; -} -#content a { - text-decoration: none; -} -body.index #content > div { - display: inline-block; -} -body.index #content > div > table { - width: 100%; -} -#header a:hover, #content a:hover { - text-decoration: underline; -} -#headerlinks { - float: left; - position: relative; -} -#session { - color: white; - font-size: 1em; - left: 250px; - position: absolute; - top: 4px; -} -#session * { - font-size: 1em; -} -#session div { - font-size: 0.9em; - max-width: 100px; - overflow-x: clip; - text-overflow: ellipsis; - white-space: nowrap; -} -#indexlink { - font-size: 2em; - padding: 10px; - position: relative; - top: 6px; -} -#inputlinks { - float: right; - font-size: 1em; - padding: 3px; - position: relative; - top: 5px; -} -#inputlinks a { - display: block; -} -#replaysection { - float: right; - margin-top: 20px; - position: relative; -} -#replaysection #limit { - font-size: 0.8em; - opacity: 0.5; - color: gray; -} -#daysection { - float: right; - font-size: 1em; - padding: 10px; - min-width: 260px; - display: table; - margin: 0 auto; - text-align: center; - position: relative; -} -#daysection a { - position: absolute; - top: 2px; - padding: 10px; -} -#daysection a:first-child { - left: 0px; -} -#daysection a:last-child { - right: 0px; -} -#daysection select { - text-align: center; -} -#tablelinks { - position: absolute; - top: 4px; - left: 4px; - font-weight: bold; - font-size: 0.8em; -} -#tablelinks a { - color: #1abc9c; - margin: 2px; - text-decoration: none; -} -#tablelinks span { - margin: 2px; -} -#tablelinks span.inactive { - color: gray; - font-weight: normal; -} -.heatmap.mouse { - margin-left: auto; - margin-right: auto; - border: 1px solid #1abc9c; -} -#status { - font-size: 0.8em; - color: gray; - margin-bottom: 10px; - position: relative; -} -#status #replay_stop { - color: gray; - float: right; - display: none; -} -#status.playing #replay_stop { - display: inline; -} -#progressbar { - width: 0px; - height: 5px; - background: lightgray; - display: block; - position: absolute; - bottom: -5px; -} -#button_replay { - width: 70px; -} -#tables { - clear: both; - overflow: auto; -} -#stats.keyboard, -#stats.sessions { - clear: both; - float: right; - margin-right: 10px; -} -h3 { - display: inline-block; -} -tbody { - display: table; - border: 1px dashed #1abc9c; - margin-bottom: 20px; - margin-top: 20px; - padding: 5px; - width: 100%; -} -td { - vertical-align: top; - padding-right: 10px; - font-size: 0.9em; -} -th { - text-align: left; - text-transform: uppercase; - font-size: 0.8em; - min-width: 80px; -} -input[type="range"] { - vertical-align: middle; - width: 120px; -} -span.range { - position: relative; -} -label.check_label { - font-size: 0.7em; - float: right; - margin-left: 5px; -} -label.check_label input { - position: relative; - top: 3px; -} -label.range_label { - font-size: 0.7em; - color: lightgray; - text-align: center; - position: absolute; - top: -18px; - width: 100%; -} -a.toggle { - position: absolute; - right: 0px; -} -a.toggle:hover { - text-decoration: none !important; -} -table.input_index td.periods { - position: relative; - padding-right: 20px; -} -table.input_index td.periods .count { - display: none; -} -table.input_index td.periods.collapsed .count { - display: block; - white-space: nowrap; -} -table.input_index td.periods.collapsed .periods { - display: none; -} -table.input_index td.periods .periods a.day { - margin-left: 10px; -} -table.input_index td.periods .periods span { - float: right; -} -#stats.sessions tr:first-child { - font-weight: bold; -} -#stats.sessions tr:not(:first-child) td:last-child { - text-align: right; -} -#stats.sessions tr:first-child td:nth-child(2), -table.sessions:not(#stats) td:first-child { - max-width: 200px; - overflow-x: hidden; - text-overflow: ellipsis; -} -#overlay { - display: none; - align-items: center; - bottom: 0; - justify-content: center; - left: 0; - position: fixed; - right: 0; - top: 0; - z-index: 10000; -} -#overlay.visible { - display: flex; -} -#overlay #overshadow { - background: black; - bottom: 0; - height: 100%; - left: 0; - opacity: 0.5; - position: fixed; - right: 0; - top: 0; - width: 100%; -} -#overlay #overcontent { - background: white; - border-radius: 5px; - display: flex; - flex-direction: column; - justify-content: space-between; - opacity: 1; - padding: 10px; - padding-right: 20px; - position: relative; - z-index: 10001; -} -#overlay #overcontent > * { - align-self: center; -} -#overlay #overcontent td:first-child { - font-weight: bold; -} diff --git a/inputscope/views/index.tpl b/inputscope/views/index.tpl deleted file mode 100644 index a5995fa..0000000 --- a/inputscope/views/index.tpl +++ /dev/null @@ -1,53 +0,0 @@ -%""" -Index page. - -Template arguments: - stats data statistics as {input: {count, first, last}} - sessions sessions statistics, as [{name, start, end, ..category counts}] - -@author Erki Suurjaak -@created 07.04.2015 -@modified 18.10.2021 -%""" -%import datetime -%from inputscope import conf -%from inputscope.util import format_stamp -%WEBROOT = get_url("/") -%page = "index" -%rebase("base.tpl", **locals()) - -
- -%for input, data in stats.items(): - - -% if data["count"]: - - -% else: - -% end # if data["count"] - -%end # for input, data -
{{ input }}
Total:", input=input) }}#{{ data["count"] }}">{{ "{:,}".format(data["count"]) }}from {{ data["first"] }} to {{ data["last"] }}0
- -%if sessions: - - -%end # if sessions -%for sess in sessions: - - -% if sess["count"]: - -% else: - -% end # if sess["count"] - - -%end # for sess -%if sessions: - -
sessions
{{ sess["name"] }}:", session=sess["id"]) }}#{{ sess["count"] }}">{{ "{:,}".format(sess["count"]) }}0from {{ format_stamp(sess["start"]) }} {{ "to %s" % format_stamp(sess["end"]) if sess["end"] else "" }}
-%end # if sessions -
diff --git a/requirements.txt b/requirements.txt index cfc2b9e..039f6fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ bottle pynput +# pywin32 # Used for toggling "Start with Windows" wxPython>=4.0 diff --git a/setup.py b/setup.py index 54ffdeb..8615cba 100644 --- a/setup.py +++ b/setup.py @@ -4,33 +4,58 @@ @author Erki Suurjaak @created 29.04.2015 -@modified 21.10.2021 +@modified 28.02.2022 ------------------------------------------------------------------------------ """ +import os +import re +import sys import setuptools +ROOTPATH = os.path.abspath(os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(ROOTPATH, "src")) + from inputscope import conf + +PACKAGE = conf.Title.lower() + + +def readfile(path): + """Returns contents of path, relative to current file.""" + root = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(root, path)) as f: return f.read() + +def get_description(): + """Returns package description from README.""" + LINK_RGX = r"\[([^\]]+)\]\(([^\)]+)\)" # 1: content in [], 2: content in () + linkify = lambda s: "#" + re.sub(r"[^\w -]", "", s).lower().replace(" ", "-") + # Unwrap local links like [Page link](#page-link) and [LICENSE.md](LICENSE.md) + repl = lambda m: m.group(1 if m.group(2) in (m.group(1), linkify(m.group(1))) else 0) + return re.sub(LINK_RGX, repl, readfile("README.md")) + + setuptools.setup( - name=conf.Title, - version=conf.Version, - description="Mouse and keyboard input heatmap visualizer and statistics", - url="https://github.com/suurjaak/InputScope", - - author="Erki Suurjaak", - author_email="erki@lap.ee", - license="MIT", - platforms=["any"], - keywords="mouse keyboard logging heatmap", - - install_requires=["bottle", "pynput", "wxPython>=4.0"], - entry_points={"gui_scripts": ["inputscope = inputscope.main:main"], - "console_scripts": ["inputscope-listener = inputscope.listener:main", - "inputscope-webui = inputscope.webui:main"]}, - - packages=setuptools.find_packages(), - include_package_data=True, # Use MANIFEST.in for data files - classifiers=[ + name = conf.Title, + version = conf.Version, + description = "Mouse and keyboard input heatmap visualizer and statistics", + url = "https://github.com/suurjaak/InputScope", + + author = "Erki Suurjaak", + author_email = "erki@lap.ee", + license = "MIT", + platforms = ["any"], + keywords = "mouse keyboard logging heatmap", + + install_requires = ["bottle", "pynput", "wxPython>=4.0"], + entry_points = {"gui_scripts": ["{0} = {0}.main:main".format(PACKAGE)], + "console_scripts": ["{0}-listener = {0}.listener:main".format(PACKAGE), + "{0}-webui = {0}.webui:main".format(PACKAGE)]}, + + package_dir = {"": "src"}, + packages = [PACKAGE], + include_package_data = True, # Use MANIFEST.in for data files + classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Operating System :: Microsoft :: Windows", @@ -45,13 +70,6 @@ "Programming Language :: Python :: 3", ], - long_description_content_type="text/markdown", - long_description= -"""Mouse and keyboard input heatmap visualizer and statistics. - -Runs a tray program that logs mouse and keyboard input events to a local database, -and provides a local web page for viewing statistics and heatmaps by day or month. - -Data is kept in an SQLite database. -""", + long_description_content_type = "text/markdown", + long_description = get_description(), ) diff --git a/inputscope/__init__.py b/src/inputscope/__init__.py similarity index 100% rename from inputscope/__init__.py rename to src/inputscope/__init__.py diff --git a/inputscope/__main__.py b/src/inputscope/__main__.py similarity index 100% rename from inputscope/__main__.py rename to src/inputscope/__main__.py diff --git a/inputscope/conf.py b/src/inputscope/conf.py similarity index 80% rename from inputscope/conf.py rename to src/inputscope/conf.py index acf2444..d2f6358 100644 --- a/inputscope/conf.py +++ b/src/inputscope/conf.py @@ -20,9 +20,10 @@ @author Erki Suurjaak @created 26.03.2015 -@modified 22.01.2022 +@modified 16.07.2022 ------------------------------------------------------------------------------ """ +import ast try: import ConfigParser as configparser # Py2 except ImportError: import configparser # Py3 import datetime @@ -35,8 +36,8 @@ """Program title, version number and version date.""" Title = "InputScope" -Version = "1.5" -VersionDate = "22.01.2022" +Version = "1.6.dev21" +VersionDate = "16.07.2022" """TCP port of the web user interface.""" WebHost = "localhost" @@ -82,19 +83,25 @@ "combos" : "KeyboardCombosEnabled", } +"""Extra configured keys, as {virtual keycode: "key name"}.""" +CustomKeys = {} + """Maximum keypress interval to count as one typing session, in seconds.""" KeyboardSessionMaxDelta = 3 -"""Maximum interval between linear move events for event reduction, in seconds.""" +"""Maximum interval between same key/combo presses for event reduction, in seconds (0 disables).""" +KeyboardJoinInterval = 0.05 + +"""Maximum interval between linear move events for event reduction, in seconds (0 disables).""" MouseMoveJoinInterval = 0.5 """Fuzz radius for linear move events for event reduction, in heatmap pixels.""" MouseMoveJoinRadius = 5 -"""Maximum interval between scroll events for event reduction, in seconds.""" +"""Maximum interval between scroll events for event reduction, in seconds (0 disables).""" MouseScrollJoinInterval = 0.5 -"""Interval between writings events to database, in seconds.""" +"""Interval between writing events to database, in seconds.""" EventsWriteInterval = 5 """Interval between checking and saving screen size, in seconds.""" @@ -106,6 +113,9 @@ """Maximum number of events to replay on statistics page.""" MaxEventsForReplay = 100 * 1000 +"""Maximum number of events to queue for database insertion, excess is discarded.""" +MaxEventsForQueue = 1000 + """Maximum number of sessions listed in tray menu.""" MaxSessionsInMenu = 20 @@ -319,22 +329,33 @@ "ALTER TABLE scrolls ADD COLUMN dx INTEGER DEFAULT 0"]], ] +"""List of attribute names that are always saved to ConfigFile.""" +FileDirectives = ["CustomKeys", "DefaultScreenSize", "EventsWriteInterval", "MaxEventsForStats", + "MaxEventsForReplay", "KeyboardEnabled", "KeyboardKeysEnabled", "KeyboardCombosEnabled", + "KeyboardJoinInterval", "MouseEnabled", "MouseMovesEnabled", "MouseClicksEnabled", + "MouseScrollsEnabled", "MouseHeatmapSize", "MouseMoveJoinInterval", "MouseMoveJoinRadius", + "MouseScrollJoinInterval", "PixelLength", "ScreenSizeInterval", "WebPort", +] try: text_types = (str, unicode) # Py2 except Exception: text_types = (str, ) # Py3 -def init(filename=ConfigPath): - """Loads INI configuration into this module's attributes.""" +def init(filename=ConfigPath, create=True): + """Loads INI configuration into this module's attributes; creates file if not present.""" section, parts = "DEFAULT", filename.rsplit(":", 1) if len(parts) > 1 and os.path.isfile(parts[0]): filename, section = parts - if not os.path.isfile(filename): return + if not os.path.isfile(filename): + if create: save() + return vardict, parser = globals(), configparser.RawConfigParser() parser.optionxform = str # Force case-sensitivity on names try: def parse_value(raw): try: return json.loads(raw) # Try to interpret as JSON - except ValueError: return raw # JSON failed, fall back to raw + except ValueError: + try: return ast.literal_eval(raw) # JSON failed, fall back to eval + except (SyntaxError, ValueError): raw # Fall back to raw with open(filename, "r") as f: txt = f.read() try: txt = txt.decode() @@ -344,6 +365,7 @@ def parse_value(raw): for k, v in parser.items(section): vardict[k] = parse_value(v) except Exception: logging.warn("Error reading config from %s.", filename, exc_info=True) + validate() def save(filename=ConfigPath): @@ -354,24 +376,36 @@ def save(filename=ConfigPath): try: save_types = text_types + (int, float, tuple, list, dict, type(None)) for k, v in sorted(globals().items()): - if not isinstance(v, save_types) or k.startswith("_") \ - or k not in default_values or default_values[k] == v \ - or isinstance(default_values[k], tuple) and isinstance(v, list) \ - and default_values[k] == tuple(v): continue # for k, v + if not isinstance(v, save_types) or k.startswith("_") or k not in default_values \ + or k not in FileDirectives and default_values[k] == v: continue # for k, v try: parser.set("DEFAULT", k, json.dumps(v)) except Exception: pass - if parser.defaults(): - with open(filename, "w") as f: - f.write("# %s %s configuration written on %s.\n" % (Title, Version, - datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) - parser.write(f) - else: # Nothing to write: delete configuration file - try: os.unlink(filename) - except Exception: pass + with open(filename, "w") as f: + f.write("# %s %s configuration written on %s.\n" % (Title, Version, + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) + parser.write(f) except Exception: logging.warn("Error writing config to %s.", filename, exc_info=True) +def validate(): + """Validates configuration values, discarding invalids.""" + global CustomKeys + default_values = defaults() + for k, v in sorted(globals().items()): + v0 = default_values.get(k) + if isinstance(v, list) and isinstance(v0, tuple): + globals()[k] = tuple(v) + try: + keys, _ = CustomKeys.copy(), CustomKeys.clear() + for k, v in keys.items(): + try: CustomKeys[int(k)] = v + except Exception: + try: CustomKeys[int(k, 16)] = v + except Exception: pass + except Exception: CustomKeys = defaults()["CustomKeys"] + + def defaults(values={}): """Returns a once-assembled dict of this module's storable attributes.""" if values: return values diff --git a/inputscope/db.py b/src/inputscope/db.py similarity index 100% rename from inputscope/db.py rename to src/inputscope/db.py diff --git a/inputscope/listener.py b/src/inputscope/listener.py similarity index 84% rename from inputscope/listener.py rename to src/inputscope/listener.py index 76d7ce4..27f26c0 100644 --- a/inputscope/listener.py +++ b/src/inputscope/listener.py @@ -21,11 +21,11 @@ @author Erki Suurjaak @created 06.04.2015 -@modified 18.10.2021 +@modified 09.07.2022 """ from __future__ import print_function +from collections import defaultdict import datetime -import math try: import Queue as queue # Py2 except ImportError: import queue # Py3 import sys @@ -175,10 +175,9 @@ class DataHandler(threading.Thread): def __init__(self, output): threading.Thread.__init__(self) - self.counts = {} # {type: count} + self.counts = defaultdict(int) # {type: count} self.output = output self.inqueue = queue.Queue() - self.lasts = {"moves": None} self.screen_sizes = [[0, 0] + list(conf.DefaultScreenSize)] self.running = False self.start() @@ -193,7 +192,8 @@ def get_display(pt): for i, size in enumerate(self.screen_sizes): # Point falls exactly into display if size[0] <= pt[0] <= size[0] + size[2] \ - and size[1] <= pt[1] <= size[1] + size[3]: return i, size + and size[1] <= pt[1] <= size[1] + size[3]: + return i, size if pt[0] >= self.screen_sizes[-1][0] + self.screen_sizes[-1][2] \ or pt[1] >= self.screen_sizes[-1][1] + self.screen_sizes[-1][3]: # Point is beyond the last display @@ -201,7 +201,8 @@ def get_display(pt): for i, size in enumerate(self.screen_sizes): # One coordinate falls into display, other is off screen if size[0] <= pt[0] <= size[0] + size[2] \ - or size[1] <= pt[1] <= size[1] + size[3]: return i, size + or size[1] <= pt[1] <= size[1] + size[3]: + return i, size return 0, self.screen_sizes[0] # Fall back to first display def rescale(pt): @@ -220,7 +221,8 @@ def one_line(pt1, pt2, pt3): def sign(v): return -1 if v < 0 else 1 if v > 0 else 0 - + stamps0, stamps1 = defaultdict(float), defaultdict(float) # {category: stamp} + lasts = {"moves": None, "keys": None, "combos": None} # {event category: [values]} while self.running: data, items = self.inqueue.get(), [] while data: @@ -229,42 +231,54 @@ def sign(v): return -1 if v < 0 else 1 if v > 0 else 0 except queue.Empty: data = None if not items or not self.running: continue # while self.running - move0, move1, scroll0 = None, None, None + move0, move1, scroll0 = None, None, None # For merging events in this iteration for data in items: category = data.pop("type") if category in conf.InputEvents["mouse"]: data["display"], _ = get_display([data["x"], data["y"]]) - if category in self.lasts: # Skip event if same position as last - pos = rescale([data["x"], data["y"]]) - if self.lasts[category] == pos: continue # for data - self.lasts[category] = pos - - if "moves" == category: # Reduce move events - if move0 and move1 and move1["stamp"] - move0["stamp"] < conf.MouseMoveJoinInterval \ - and data["stamp"] - move1["stamp"] < conf.MouseMoveJoinInterval \ - and move0["display"] == move1["display"] == data["display"]: - if one_line(*[(v["x"], v["y"]) for v in (move0, move1, data)]): - move1.update(data) - continue # for data + stamps0.update(stamps1) + stamps1[category] = data["stamp"] + + if category in lasts: + if category in conf.InputEvents["mouse"]: # Skip if same downscaled pos as last + INTERVAL = conf.MouseMoveJoinInterval + newlast = [data["display"]] + rescale([data["x"], data["y"]]) + else: # Skip if key appears held down (same value repeating rapidly) + INTERVAL = conf.KeyboardJoinInterval + newlast = [data["key"], data["realkey"]] + if INTERVAL and lasts[category] == newlast \ + and data["stamp"] - stamps0[category] < INTERVAL: + if "moves" == category: move0, move1 = move1, data + continue # for data + lasts[category] = newlast + + if "moves" == category: # Reduce mouse move events + if conf.MouseMoveJoinInterval and move0 and move1 \ + and move1["stamp"] - move0["stamp"] < conf.MouseMoveJoinInterval \ + and data["stamp"] - move1["stamp"] < conf.MouseMoveJoinInterval \ + and move0["display"] == move1["display"] == data["display"] \ + and one_line(*[(v["x"], v["y"]) for v in (move0, move1, data)]): + move1.update(data) + continue # for data move0, move1 = move1, data - elif "scrolls" == category: # Reduce scroll events - if scroll0 and scroll0["display"] == data["display"] \ + elif "scrolls" == category: # Reduce mouse scroll events + if scroll0 and conf.MouseScrollJoinInterval \ + and scroll0["display"] == data["display"] \ and sign(scroll0["dx"]) == sign(data["dx"]) \ and sign(scroll0["dy"]) == sign(data["dy"]) \ - and data["stamp"] - scroll0["stamp"] < conf.MouseScrollJoinInterval: - for k in ("dx", "dy"): scroll0[k] += data[k] - for k in ("stamp", "x", "y"): scroll0[k] = data[k] + and data["stamp"] - stamps0[category] < conf.MouseScrollJoinInterval: + for k in ("dx", "dy"): scroll0[k] += data[k] + for k in ( "x", "y"): scroll0[k] = data[k] continue # for data scroll0 = data - if category not in self.counts: self.counts[category] = 0 self.counts[category] += 1 dbqueue.append((category, data)) try: while dbqueue: db.insert(*dbqueue.pop(0)) - except StandardError as e: print(e) - self.output(self.counts) + except Exception as e: print(e) + self.output(dict(self.counts)) if conf.EventsWriteInterval > 0: time.sleep(conf.EventsWriteInterval) def stop(self): @@ -276,7 +290,7 @@ def handle(self, **kwargs): category = kwargs.get("type") if not getattr(conf, conf.InputFlags.get(category), False): return kwargs.update(day=datetime.date.today(), stamp=time.time()) - self.inqueue.put(kwargs) + if self.inqueue.qsize() < conf.MaxEventsForQueue: self.inqueue.put(kwargs) @@ -347,15 +361,29 @@ class KeyHandler(object): 105: "Numpad9", 12: "Numpad-Clear", # Numpad5 without NumLock - 111: "Numpad-Divide", 106: "Numpad-Multiply", - 109: "Numpad-Subtract", 107: "Numpad-Add", + 109: "Numpad-Subtract", + 110: "Numpad-Delete", + 111: "Numpad-Divide", + + 21: "IME Hangul/Kana", + 23: "IME Junja", + 24: "IME final", + 25: "IME Hanja/Kanji", 172: "Web/Home", # Extra top keys + 173: "Volume Mute", + 174: "Volume Down", + 175: "Volume Up", + 176: "Media Next", + 177: "Media Prev", + 178: "Media Stop", + 179: "Media Play/Pause", 180: "Email", - 181: "Media", - 183: "Calculator", + 181: "Media Select", + 182: "Application 1", + 183: "Application 2", } OTHER_VK_NAMES = { # Not Windows 65027: "AltGr", @@ -372,7 +400,7 @@ class KeyHandler(object): def __init__(self, output): - self.KEYNAMES = {k: v for k, v in self.PYNPUT_NAMES.items()} # pynput.Key.xyz.name: label + self.KEYNAMES = self.PYNPUT_NAMES.copy() # pynput.Key.xyz.name: label for key in pynput.keyboard.Key: if key.name not in self.KEYNAMES: self.KEYNAMES[key.name] = self.nicename(key.name) @@ -435,7 +463,8 @@ def extract(self, key): name = realname = self.RENAMES.get(name, name) if vk and (name, self._is_extended) in self.NUMPAD_SPECIALS: name = realname = "Numpad-" + name - elif ord("A") <= vk <= ord("Z"): # Common A..Z keys, whatever the chars + elif ord("A") <= vk <= ord("Z") or ord("0") <= vk <= ord("9"): + # Common A..Z 0..9 keys, whatever the chars name, realname = char.upper() if char else chr(vk), chr(vk) if not name and "win32" != sys.platform: @@ -459,6 +488,9 @@ def extract(self, key): if self._modifiers["Ctrl"]: name = realname else: name = self.CONTROLCODES[name] + if vk in conf.CustomKeys: + name = realname = conf.CustomKeys[vk] + return name, realname @@ -491,8 +523,9 @@ def start(inqueue, outqueue=None): try: db.execute("PRAGMA journal_mode = WAL") except Exception: pass - try: Listener(inqueue, outqueue).run() - except KeyboardInterrupt: pass + listener = Listener(inqueue, outqueue) + try: listener.run() + except KeyboardInterrupt: listener.stop() def main(): diff --git a/inputscope/main.py b/src/inputscope/main.py similarity index 92% rename from inputscope/main.py rename to src/inputscope/main.py index 17ff3cf..55893f7 100644 --- a/inputscope/main.py +++ b/src/inputscope/main.py @@ -5,7 +5,7 @@ @author Erki Suurjaak @created 05.05.2015 -@modified 21.10.2021 +@modified 07.07.2022 """ import calendar import datetime @@ -20,9 +20,9 @@ import threading import time import webbrowser +win32com = wx = tk = None try: import win32com.client # For creating startup shortcut except ImportError: pass -wx = tk = None try: import wx, wx.adv, wx.py.shell except ImportError: try: import Tkinter as tk # For getting screen size if wx unavailable @@ -109,7 +109,10 @@ def session_action(self, action, session=None, arg=None): def stop(self, exit=False): self.running = False - if self.listener: self.listenerqueue.put("exit"), self.listener.terminate() + if self.listenerqueue: + try: self.listenerqueue.put("exit") + except Exception: pass + if self.listener: self.listener.terminate() if self.webui: self.webui.terminate() if exit: sys.exit() @@ -136,9 +139,18 @@ def run(self): pkg = os.path.basename(conf.ApplicationPath) root = os.path.dirname(conf.ApplicationPath) args = lambda *x: [sys.executable, "-m", "%s.%s" % (pkg, x[0])] + list(x[1:]) - self.listener = subprocess.Popen(args("listener", "--quiet"), cwd=root, shell=True, - stdin=subprocess.PIPE, universal_newlines=True) - self.webui = subprocess.Popen(args("webui", "--quiet"), cwd=root) + lprocargs = dict(cwd=root, stdin=subprocess.PIPE, universal_newlines=True) + wprocargs = dict(cwd=root) + if "win32" == sys.platform: + lprocargs.update(shell=True) + if "pythonw.exe" == os.path.basename(sys.executable).lower(): + # Workaround for Py3 bug in W7: pythonw sets sys.stdout and .stderr to None + lprocargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if hasattr(subprocess, "DEVNULL"): # Python 3.3+ + wprocargs.update({k: subprocess.DEVNULL + for k in ("stdin", "stdout", "stderr")}) + self.listener = subprocess.Popen(args("listener", "--quiet"), **lprocargs) + self.webui = subprocess.Popen(args("webui", "--quiet"), **wprocargs) self.listenerqueue = QueueLine(self.listener.stdin) if conf.MouseEnabled: self.listenerqueue.put("start mouse") @@ -232,7 +244,6 @@ def OnOpenMenu(self, event): activename = format_session(activesession, quote=True, stamp=False) if activesession else None item_session_start = makeitem(menu, "&Start session ..") item_session_stop = makeitem(menu, "Stop session%s" % (' ' + activename if activename else "")) - item_session_stop.Enable(bool(activename)) for session in self.model.sessions[:conf.MaxSessionsInMenu]: sessmenu = wx.Menu() item_session_open = makeitem(sessmenu, "Open statistics") @@ -309,6 +320,7 @@ def OnOpenMenu(self, event): item_keys .Check(conf.KeyboardEnabled and conf.KeyboardKeysEnabled) item_combos .Check(conf.KeyboardEnabled and conf.KeyboardCombosEnabled) item_console .Check(self.frame_console.Shown) + item_session_stop.Enable(bool(activename)) menu.Bind(wx.EVT_MENU, self.OnOpenUI, item_ui) menu.Bind(wx.EVT_MENU, self.OnVacuum, item_vacuum) @@ -362,12 +374,14 @@ def OnClearHistory(self, period, category, event=None): def OnLogResolution(self, event=None): if not self: return def make_size(geometry, w, h): + """Returns [scaled x, scaled y, w, h].""" scale = geometry.Width / float(w) return [int(geometry.X * scale), int(geometry.Y * scale), w, h] sizes = [make_size(d.Geometry, m.Width, m.Height) for i in range(wx.Display.GetCount()) - for d in [wx.Display(i)] for m in [d.GetCurrentMode()]] - self.model.log_resolution(sizes) + for d in [wx.Display(i)] for m in [d.GetCurrentMode()] + if m.Width and m.Height and all(d.Geometry.Size)] + if sizes: self.model.log_resolution(sizes) def OnOpenUI(self, event): webbrowser.open(conf.WebUrl) @@ -454,7 +468,7 @@ class StartupService(object): def can_start(self): """Whether startup can be set on this system at all.""" - return ("win32" == sys.platform) + return win32com and ("win32" == sys.platform) def is_started(self): """Whether the program has been added to startup.""" @@ -470,7 +484,7 @@ def start(self): def stop(self): """Stops the program from running at system startup.""" try: os.unlink(self.get_shortcut_path()) - except StandardError: pass + except Exception: pass def get_shortcut_path(self): path = "~\\Start Menu\\Programs\\Startup\\%s.lnk" % conf.Title diff --git a/inputscope/static/heatmap.min.js b/src/inputscope/static/heatmap.min.js similarity index 100% rename from inputscope/static/heatmap.min.js rename to src/inputscope/static/heatmap.min.js diff --git a/inputscope/static/icon.ico b/src/inputscope/static/icon.ico similarity index 100% rename from inputscope/static/icon.ico rename to src/inputscope/static/icon.ico diff --git a/inputscope/static/keyboard.svg b/src/inputscope/static/keyboard.svg similarity index 100% rename from inputscope/static/keyboard.svg rename to src/inputscope/static/keyboard.svg diff --git a/src/inputscope/static/site.css b/src/inputscope/static/site.css new file mode 100644 index 0000000..cd527c0 --- /dev/null +++ b/src/inputscope/static/site.css @@ -0,0 +1,380 @@ +/** + * Site style file. + * + * @author Erki Suurjaak + * @created 07.04.2015 + * @modified 16.07.2022 + */ +:root { + font-size: 6.25%; /** Root font size to 1px @ default font size 16px */ +} +body { + background-color: white; + color: black; + font-size: 16rem; /** 1rem == 1px @ default font size */ + overflow-y: scroll; +} +* { + font-family: Tahoma, Arial, sans serif; +} +#header > *, #footer > * { + font-size: 0.72em; +} +#header { + padding: 6px 10rem; + width: calc(700px - 2 * 10rem); + min-height: calc(40rem - 2 * 6px); + background: #1abc9c; + border-radius: 4px 4px 0 0; + margin-left: auto; + margin-right: auto; +} +#content { + position: relative; + width: calc(700px - 2 * (10rem + 1px)); + min-height: 200rem; + border: 1px solid #1abc9c; + margin-left: auto; + margin-right: auto; + padding: 10rem; + overflow-x: hidden; +} +#dbinfo { + display: none; +} +#footer { + padding: 0 10rem; + width: calc(700px - 2 * 10rem); + color: white; + background: #1abc9c; + border-radius: 0 0 4px 4px; + text-align: center; + font-size: 0.95em; + margin-left: auto; + margin-right: auto; +} +#footer > * { + padding: 7rem 0; +} +#footer a { + color: white; +} +#content a { + text-decoration: none; +} +#header a { + color: white; + text-decoration: none; +} +#header a:hover, #content a:hover { + text-decoration: underline; +} +#headerlinks { + display: flex; + flex-wrap: wrap; + column-gap: 12rem; + align-items: center; +} +#session div { + font-size: 0.9em; + max-width: 100rem; + overflow-x: clip; + text-overflow: ellipsis; + white-space: nowrap; +} +#indexlink { + font-size: 2em; +} +#inputlinks a { + display: block; + font-size: 0.9em; +} +.flex-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; +} +.flex-row > * { + margin-left: auto; + margin-right: auto; +} +.flex-row > :first-child { + margin-left: 0; +} +.flex-row > :last-child { + margin-right: 0; +} +#heading { + margin-top: 10rem; + row-gap: 8rem; +} +#replaysection { + margin-left: auto; +} +#replaysection #limit { + font-size: 0.8em; + opacity: 0.5; + color: gray; +} +#daysection a { + display: inline-block; + padding: 10px; +} +#daysection a:empty { + min-width: 60rem; + min-height: 1.2em; +} +#daysection select { + font-size: 1em; + text-align: center; +} +#tablelinks { + font-weight: bold; + font-size: 0.8em; + margin: -6rem 0 -1rem -6rem; +} +#tablelinks a { + color: #1abc9c; + margin: 2rem; + text-decoration: none; +} +#tablelinks span { + margin: 2rem; +} +#tablelinks span.inactive { + color: gray; + font-weight: normal; +} +.heatmap.keyboard { + margin-left: calc(-10rem + 10px - 1px); +} +.heatmap.mouse { + border: 1px solid #1abc9c; +} +#status { + font-size: 0.8em; + color: gray; + margin-bottom: 10rem; + margin-top: 18rem; + position: relative; +} +#status #replay_stop { + color: gray; + float: right; + display: none; +} +#status.playing #replay_stop { + display: inline; +} +#progressbar { + width: 0px; + height: 5rem; + background: lightgray; + display: block; + position: absolute; + bottom: -5rem; +} +#tables { + clear: both; + overflow: auto; + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; + justify-content: flex-end; +} +#tables > table:last-child { + margin-right: auto; +} +a { + color: blue; +} +a:visited { + color: blue; +} +h3 { + display: inline; +} +tbody { + display: table; + border: 1px dashed #1abc9c; + margin-bottom: 20px; + margin-top: 20px; + padding: 5rem; +} +input[type="button"] { + font-size: 0.85em; + width: fit-content; +} +#button_replay { + min-width: 70rem; +} +input[type="range"] { + vertical-align: middle; + width: 120px; +} +span.range { + display: inline-block; + position: relative; +} +label.check_label { + font-size: 0.7em; + float: right; + margin-left: 5rem; +} +label.check_label input { + font-size: inherit; + position: relative; + top: 2rem; + width: 1.2em; + height: 1.2em; +} +label.range_label { + font-size: 0.7em; + color: lightgray; + text-align: center; + position: absolute; + top: -18px; + width: 100%; +} +a.toggle { + position: absolute; + right: 0px; +} +a.toggle:hover { + text-decoration: none !important; +} +table.totals td.periods { + position: relative; + padding-right: 20px; +} +table.totals td.periods .periods > div { + column-gap: 10rem; +} +table.totals td.periods .count { + display: none; +} +table.totals td.periods.collapsed .count { + display: block; + white-space: nowrap; +} +table.totals td.periods.collapsed .periods { + display: none; +} +table.totals td.periods .periods a.day { + margin-left: 10px; +} +table.totals td.periods .periods span { + margin-left: auto; +} +#stats.sessions tr:first-child { + font-weight: bold; +} +#stats.sessions tr:not(:first-child) td:last-child { + text-align: right; +} +#stats.sessions tr:first-child td:nth-child(2) { + max-width: 200px; + overflow-x: hidden; + text-overflow: ellipsis; +} +body.session.index #header, +body.session.input #header { + justify-content: start; +} +body.session.index #header > *, +body.session.input #header > * { + margin: 0; +} +body.session.index #header > #headerlinks, +body.session.input #header > #headerlinks { + margin-right: 60rem; +} +body.session.input td.periods a.day { + margin-left: 0; +} +#overlay { + display: none; + align-items: center; + bottom: 0; + justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 10000; +} +#overlay.visible { + display: flex; +} +#overlay #overshadow { + background: black; + bottom: 0; + height: 100%; + left: 0; + opacity: 0.5; + position: fixed; + right: 0; + top: 0; + width: 100%; +} +#overlay #overcontent { + background: white; + border-radius: 5px; + opacity: 1; + padding: 10px; + z-index: 10001; + max-width: calc(100% - 2 * 10px); + max-height: calc(100% - 2 * 10px); + overflow: auto; +} +#overlay #overcontent > * { + display: block; + margin: 0 auto; +} +#overlay #overcontent td { + word-break: break-word; +} +#overlay #overcontent td:first-child { + font-weight: bold; +} +th { + font-weight: bold; + text-align: left; + text-transform: uppercase; + vertical-align: top; + font-size: 0.8em; +} +td { + vertical-align: top; + font-size: 0.9em; +} +#counts th:first-child, +#counts td:first-child, +#stats td:first-child, +table.totals th:not(:last-child), +table.totals td:not(:last-child) { + padding-right: 10px; +} +#counts th:last-child, +#counts td:last-child { + text-align: right; +} +#counts td, +#stats td { + word-break: break-word; +} +table.totals th:first-child, +#counts th:first-child { + min-width: 80rem; +} +body.index table .flex-row { + column-gap: 10px; + justify-content: unset; +} +body.index table .flex-row > * { + margin: 0; +} +body.index table .flex-row > :first-child { + min-width: 80rem; +} diff --git a/inputscope/util.py b/src/inputscope/util.py similarity index 97% rename from inputscope/util.py rename to src/inputscope/util.py index a6842b9..94e6870 100644 --- a/inputscope/util.py +++ b/src/inputscope/util.py @@ -4,7 +4,7 @@ @author Erki Suurjaak @created 17.10.2021 -@modified 18.10.2021 +@modified 16.07.2022 """ import datetime import errno @@ -50,10 +50,11 @@ def format_stamp(stamp, fmt="%Y-%m-%d %H:%M"): def format_timedelta(timedelta): - """Formats the timedelta as "3d 40h 23min 23.1sec".""" + """Formats the timedelta as "3d 40h 23min 23sec".""" dd, rem = divmod(timedelta_seconds(timedelta), 24*3600) hh, rem = divmod(rem, 3600) mm, ss = divmod(rem, 60) + if mm or hh or dd: ss = int(ss) items = [] for c, n in (dd, "d"), (hh, "h"), (mm, "min"), (ss, "sec"): f = "%d" % c if "sec" != n else str(round(c, 2)).rstrip("0").rstrip(".") diff --git a/inputscope/var/inputscope.db b/src/inputscope/var/inputscope.db similarity index 100% rename from inputscope/var/inputscope.db rename to src/inputscope/var/inputscope.db diff --git a/inputscope/var/inputscope.ini b/src/inputscope/var/inputscope.ini similarity index 91% rename from inputscope/var/inputscope.ini rename to src/inputscope/var/inputscope.ini index 0f74497..d808e96 100644 --- a/inputscope/var/inputscope.ini +++ b/src/inputscope/var/inputscope.ini @@ -1,5 +1,6 @@ -# InputScope 1.3 configuration. +# InputScope configuration. [DEFAULT] +CustomKeys = {} DefaultScreenSize = [1920, 1080] EventsWriteInterval = 5 MaxEventsForStats = 1000000 diff --git a/inputscope/views/base.tpl b/src/inputscope/views/base.tpl similarity index 85% rename from inputscope/views/base.tpl rename to src/inputscope/views/base.tpl index c4c3328..71cffe2 100644 --- a/inputscope/views/base.tpl +++ b/src/inputscope/views/base.tpl @@ -14,7 +14,7 @@ Template arguments: @author Erki Suurjaak @created 07.04.2015 -@modified 17.10.2021 +@modified 16.07.2022 %""" %from inputscope.util import format_session %WEBROOT = get_url("/") @@ -23,6 +23,7 @@ Template arguments: %if session: % INPUTURL, URLARGS = "/sessions/" + INPUTURL, dict(URLARGS, session=session["id"]) %end # if session +%bodycls = " ".join(set(filter(bool, get("page", "").split() + ["session" if session else ""]))) @@ -33,8 +34,8 @@ Template arguments: - - -