from {{ format_stamp(sess["start"]) }} {{ "to %s" % format_stamp(sess["end"]) if sess["end"] else "" }}
-
-%end # for sess
-%if sessions:
-
-
-%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:
-
-
+
+
{{ conf.Title }}
@@ -45,6 +46,10 @@ Template arguments:
+%if session or days:
+
+%end # if session or days
+
%if session:
%if get("input"):
@@ -61,7 +66,7 @@ Template arguments:
%end # if defined("session")
%if days:
-
+
%prevperiod, nextperiod = None, None
%if period and len(period) < 8:
% prevperiod = next((x["day"][:7] for x in days[::-1] if x["day"][:7] < period), None)
@@ -73,8 +78,11 @@ Template arguments:
% end # if dayidx is not None
% prevperiod = prevperiod or None if period else days[-1]["day"]
%end # if period and len(period) < 8
+
%if prevperiod:
/" % INPUTURL, table=table, period=prevperiod, **URLARGS) }}">< {{ prevperiod }}
+ %else:
+
%end # if prevperiod
%end # if days
+%if session or days:
+
+%end # if session or days
+