st-ten-1/src/lib/oskb/oskb.py

662 lines
27 KiB
Python
Raw Normal View History

import os, sys, re, json, subprocess
from functools import partial
import pkg_resources
from PyQt5.QtCore import QTimer, QRect, QSysInfo, QEvent, QSize, Qt
from PyQt5.QtWidgets import (
QWidget,
QPushButton,
QMainWindow,
QGridLayout,
QHBoxLayout,
QSizePolicy,
QLayout,
QStackedLayout,
QLabel,
)
RELEASED = 0
PRESSED = 1
COLUMN_MARGIN = 0.1
# key detection timings in milliseconds
LONGPRESS_TIMEOUT = 350
DOUBLECLICK_TIMEOUT = 200
# The keyboard file format has its own version numbering
KEYBOARDFILE_VERSION = 1
class Keyboard(QWidget):
def __init__(self):
super().__init__()
self._modifiers = {}
self._flashmodifiers = True
# This is all for the key-detection state-machine
self._longpresswait = False
self._longtimer = QTimer()
self._stopsinglepress = False
self._doublebutton = None
self._doubletimer = QTimer()
self._doubletimer.setSingleShot(True)
self._doubletimer.timeout.connect(self._doubleTimeout)
self._viewindex = None
self._kbdname = None
self._viewuntil = None
self._thenview = None
self._kbds = {}
# Create the special 'chooser' keyboard that shows all the loaded keyboards
self._kbds["_chooser"] = {
"views": {"default": {"columns": [{"rows": []}]}},
}
# Create the special 'minimized' keyboard that shows one small button
self._kbds["_minimized"] = {
"style": "QWidget {background: transparent;}",
"views": {
"default": {
"columns": [
{"rows": [{"keys": [{"caption": "", "single": {"keyboard": {"name": "back"}}}]}]}
],
}
},
}
self._view = None
self._viewname = "default"
self._kbd = None
self._sendkeys = None
self._sendmapchanges = None
self._sendscreenstate = None
self._buttonhandler = self._oskbButtonHandler
self._minimizerlocation = QRect(0, 0, 70, 70)
self._kbdstack = QStackedLayout(self)
self._stylesheet = pkg_resources.resource_string("oskb", "default.css").decode("utf-8")
#
# Reimplemented Qt methods
#
# Make sure show events also calculate proper sizes and first initialise if that hasn't happened yet.
def showEvent(self, event):
self.updateKeyboard()
QWidget.showEvent(self, event)
# Recalculate the fontsize and mrgaing and change the stylesheets when resizing
def resizeEvent(self, event):
QWidget.resizeEvent(self, event)
if self._view and self.isVisible():
self.updateKeyboard()
# We just store the stylesheet, and then only do the super().setStyleSheet() when we've
# recalculated values in updateKeyboard()
def setStyleSheet(self, stylesheet):
self._stylesheet = stylesheet
#
# Our own public
#
# specify callback that receives keymap information when user switches keyboards using _chooser
def sendMapChanges(self, function):
if callable(function):
self._sendmapchanges = function
return True
return False
def sendScreenState(self, function):
if callable(function):
self._sendscreenstate = function
return True
return False
def sendKeys(self, function):
if callable(function):
self._sendkeys = function
return True
return False
def setButtonHandler(self, handler=None):
if not handler:
handler = self._oskbButtonHandler
self._buttonhandler = handler
def setMinimizer(self, mx, my, mw, mh):
self._minimizerlocation = QRect(mx, my, mw, mh)
def setFlashModifiers(self, mode):
self._flashmodifiers = mode
def readKeyboard(self, kbdfile):
kbd = None
if os.access(kbdfile, os.R_OK):
with open(kbdfile, "r", encoding="utf-8") as f:
kbd = json.load(f)
elif kbdfile == os.path.basename(kbdfile) and pkg_resources.resource_exists(
"oskb", "keyboards/" + kbdfile
):
kbd = json.loads(pkg_resources.resource_string("oskb", "keyboards/" + kbdfile))
if not kbd:
raise FileNotFoundError("Could not find " + kbdfile)
if kbd.get("format") != "oskb keyboard":
raise RuntimeError("Not an oskb keyboard file")
if kbd.get("formatversion") > KEYBOARDFILE_VERSION:
raise RuntimeError("oskb keyboard file for newer oskb version. You must upgrade.")
kbdname = os.path.basename(kbdfile)
self._kbds[kbdname] = kbd
self._updateChooser()
self.initKeyboards()
return os.path.basename(kbdfile)
def getView(self):
return self._viewname
def getViews(self):
return self._kbd["views"].keys()
def _updateChooser(self):
if not self._kbds.get("_chooser"):
return
therows = self._kbds["_chooser"]["views"]["default"]["columns"][0]["rows"]
therows.clear()
for kbdname, kbd in self._kbds.items():
if kbdname.startswith("_"):
continue
therows.append(
{"keys": [{"caption": kbd.get("description"), "single": {"keyboard": {"name": kbdname}},}]}
)
def setKeyboard(self, kbdname=None):
if self._sendscreenstate:
self._sendscreenstate(kbdname != "_minimized")
newgeometry = None
if kbdname == "_minimized":
newgeometry = self._minimizerlocation
else:
if kbdname == "back":
kbdname = self._previouskeyboard
if self._previousgeometry != self.geometry():
newgeometry = self._previousgeometry
for n, k in self._kbds.items():
if kbdname and kbdname != n:
continue
if not kbdname and n.startswith("_"):
continue
self._kbdname = n
self._kbd = k
# print("setKeybaord picked ", n)
self._releaseModifiers()
if self._sendmapchanges and k.get("keymap"):
self._sendmapchanges(k.get("keymap"))
if newgeometry:
self.hide()
if kbdname != "_minimized":
self._previouskeyboard = n
self._previousgeometry = self.geometry()
self._kbdstack.setCurrentIndex(k.get("_stackindex", 0))
if self._kbd["views"].get(self._viewname):
self.setView(self._viewname, newgeometry)
else:
self.setView("default", newgeometry)
return True
return False
def setView(self, viewname, newgeometry=None):
# print ("setView", viewname)
if self._kbd["views"].get(viewname):
self._view = self._kbd["views"][viewname]
self._viewname = viewname
self._kbd["_QWidget"].layout().setCurrentIndex(self._view["_stackindex"])
if newgeometry:
self.setGeometry(newgeometry)
self.show()
else:
self.updateKeyboard()
return True
return False
def getRawKbds(self):
return self._kbds
#
# initKeyboards sets up a QStackedLayout holding QWidgets for each keyboard, which in turn have a
# QStackedlayout that holds a QWidget for each view within that keyboard. That has a QGridLayout with
# QHboxLayouts in it that hold the individual key QPushButton widgets. It also sets the captions and
# button actions for each key and figures out how many standard key widths and row vis there are in
# all the views, which is used by updateKeyboard() to dynamically figure out how big the fonts, margins
# and rounded corners need to be.
#
def initKeyboards(self):
# Helper to return placeholder "empty row" widget
def _makeEmptyRow(row):
er = QPushButton(self)
er.pressed.connect(partial(self._buttonhandler, er, PRESSED))
er.released.connect(partial(self._buttonhandler, er, RELEASED))
er.setMinimumSize(1, 1)
er.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
er.setProperty("class", "emptyrow")
row["_QWidget"] = er
row["type"] = "emptyrow"
er.data = row
return er
# Helper to return a QLayout to go in place of the QPushButton that contains it plus
# any extra labels stacked on top
def _makeCaptionLayout(k):
extracaptions = k.data.get("extracaptions", None)
if not extracaptions:
return False
# ecl = extra captions layout
ecl = QStackedLayout()
ecl.setStackingMode(QStackedLayout.StackAll)
ecl.addWidget(k)
for cssclass, txt in extracaptions.items():
ql = QLabel(txt)
ql.setProperty("class", cssclass)
ql.setAttribute(Qt.WA_TransparentForMouseEvents)
ecl.addWidget(ql)
return ecl
def _maxRowsInView(view):
maxrows = 0
for column in view.get("columns", []):
maxrows = max(len(column.get("rows")), maxrows)
return maxrows
# This stores the width and height in standard key widths for each view.
def _storeWidthsAndHeights(view):
total_height = 0
# Heights are only stored in first column
column = view["columns"][0]
for ri, row in enumerate(column.get("rows", [])):
total_height += row.get("height", 1)
total_width = 0
for ci, column in enumerate(view.get("columns", [])):
largest_width = 0
for ri, row in reversed(list(enumerate(column.get("rows", [])))):
if len(row.get("keys", [])):
totalweight = 0
for keydata in row.get("keys", []):
w = keydata.get("width", 1)
totalweight += w
# Not counting frst row if there are widths already (reversed order)
if totalweight > largest_width and (ri != 0 or totalweight == 0):
largest_width = totalweight
column["_widthInUnits"] = largest_width
total_width += largest_width
view["_widthInUnits"] = max(total_width, 1)
view["_heightInUnits"] = max(total_height, 1)
# Start of initKeyboards() itself
if self._kbdstack.itemAt(0):
self._clearLayout(self._kbdstack)
ki = 0
for kbdname, kbd in self._kbds.items():
viewstack = QStackedLayout()
vi = 0
for viewname, view in kbd.get("views", {}).items():
_storeWidthsAndHeights(view)
grid = QGridLayout()
grid.setSpacing(0)
grid.setContentsMargins(0, 0, 0, 0)
for ci, column in enumerate(view.get("columns", [])):
for ri in range(_maxRowsInView(view)):
if ri < len(column["rows"]):
row = column["rows"][ri]
else:
row = {"keys": []}
column["rows"].append(row)
keys = row.get("keys", [])
kl = QHBoxLayout()
kl.setContentsMargins(0, 0, 0, 0)
kl.setSpacing(0)
for keydata in keys:
stretch = keydata.get("width", 1) * 10
type = keydata.get("type", "key")
k = QPushButton(self)
k.setMinimumSize(1, 1)
keydata["_QWidget"] = k
keydata["_selected"] = False
k.data = keydata
k.pressed.connect(partial(self._buttonhandler, k, PRESSED))
k.released.connect(partial(self._buttonhandler, k, RELEASED))
k.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
k.setMinimumSize(1, 1)
if type == "key":
k.setText(keydata.get("caption", ""))
# Multiple captions? Create a QStackedWidget overlays them all
ecl = _makeCaptionLayout(k)
if ecl:
kl.addLayout(ecl, stretch)
else:
kl.addWidget(k, int(stretch))
else:
kl.addWidget(k, int(stretch))
if not len(keys):
er = _makeEmptyRow(row)
kl.addWidget(er)
else:
row["_QWidget"] = None
grid.addLayout(kl, ri, ci * 2)
if ci == 0:
grid.setRowStretch(ri, int(row.get("height", 1) * 10))
grid.setColumnStretch(ci * 2, int(column.get("_widthInUnits", 1) * 10))
if ci > 0:
spacercolumn = QHBoxLayout()
spacercolumn.addWidget(QWidget(None))
grid.setColumnStretch((ci * 2) - 1, int(COLUMN_MARGIN * 10))
grid.addLayout(spacercolumn, 0, (ci * 2) - 1)
# Create with self as parent, then reparent to prevent startup flicker
view["_QWidget"] = QWidget(self)
view["_QWidget"].setLayout(grid)
viewstack.addWidget(view["_QWidget"])
view["_stackindex"] = vi
vi += 1
kbd["_QWidget"] = QWidget(self)
kbd["_stackindex"] = ki
ki += 1
kbd["_QWidget"].setLayout(viewstack)
self._kbdstack.addWidget(kbd["_QWidget"])
self.setKeyboard(self._kbdname)
# Qt keeps coming up with minimum sizes that are way too wide
# Some sane number will have to go in at some point, I guess
self.setMaximumSize(16777215, 16777215)
self.setMinimumSize(1, 1)
def updateKeyboard(self):
# Helper function to dynamically recalculate some sizes in stylesheets
def fixStyle(stylesheet, fontsize, margin, radius):
if stylesheet == "":
return ""
# Replace the main calculated values
stylesheet = stylesheet.replace("_OSKB_FONTSIZE_", str(fontsize))
stylesheet = stylesheet.replace("_OSKB_MARGIN_", str(margin))
stylesheet = stylesheet.replace("_OSKB_RADIUS_", str(radius))
# And then all the percentages based thereon (Qt5 doesn't do percentages in fontsizes)
r = re.compile(r"font-size\s*:\s*(\d+)\%")
i = r.finditer(stylesheet)
for m in i:
stylesheet = stylesheet.replace(
m.group(0), "font-size: " + str(int((fontsize / 100) * int(m.group(1)))) + "px",
)
return stylesheet
if not self._view:
return False
# Calculate the font and margin sizes
kw = self.width() / self._view["_widthInUnits"]
kh = self.height() / self._view["_heightInUnits"]
fontsize = min(max(int(min(kw / 1.5, kh / 2)), 5), 50)
margin = int(fontsize / 15)
radius = margin * 3
# Dynamically change the default and keyboard stylesheets
all_sheets = self._stylesheet + "\n\n" + self._kbd.get("style", "")
super().setStyleSheet(fixStyle(all_sheets, fontsize, margin, radius))
# Then adjust the stylesheets and class properties of all keys
for ci, column in enumerate(self._view.get("columns", [])):
for ri, row in enumerate(column.get("rows", [])):
rowwidget = row.get("_QWidget")
if rowwidget:
if row.get("_selected", False):
rowwidget.setProperty("class", "emptyrow selected")
else:
rowwidget.setProperty("class", "emptyrow")
# It needs .setStyleSheet(""), not .repaint() to show the changes
rowwidget.setStyleSheet("")
else:
for keydata in row.get("keys", []):
k = keydata.get("_QWidget")
type = keydata.get("type", "key")
classes = [type]
classes.append(keydata.get("class", ""))
if keydata.get("single") and keydata["single"].get("modifier"):
modname = keydata["single"]["modifier"].get("name", "")
moddata = self._modifiers.get(modname, {})
modstate = moddata.get("state")
if modstate == 1:
classes.append("held")
elif modstate == 2:
classes.append("locked")
else:
classes.append("modifier")
if keydata.get("_selected", False):
classes.append("selected")
classes.append("view_" + self._viewname)
classes.append("row" + str(ri + 1))
classes.append("col" + str(ci + 1))
k.setProperty("class", " ".join(classes).strip())
keystyle = keydata.get("style", "")
k.setStyleSheet(fixStyle(keystyle, fontsize, margin, radius))
#
# The part here is the low-level button handling. It takes care of calling _doAction() with PRESSED and
# RELEASED with pointers to either the "single", "double" or "long" sub-dictionaries for that button,
# handling all the nitty-gritty. Bit involved.., Maybe only touch when wide awake and concentrated.
#
def _oskbButtonHandler(self, button, direction):
sng = button.data.get("single")
dbl = button.data.get("double")
lng = button.data.get("long")
if direction == PRESSED:
if self._doublebutton and self._doublebutton != button:
# Another key was pressed within the doubleclick timeout, so we must
# first process the previous key that was held back
self._doAction(self._doublebutton.data.get("single"), PRESSED)
self._doAction(self._doublebutton.data.get("single"), RELEASED)
self._doublebutton = None
self._doubletimer.stop()
self._stopsinglepress = False
if lng or dbl:
if lng:
self._longtimer = QTimer()
self._longtimer.setSingleShot(True)
self._longtimer.timeout.connect(partial(self._longPress, lng))
self._longtimer.start(LONGPRESS_TIMEOUT)
if dbl:
self._stopsinglepress = True
if self._doubletimer.isActive():
self._doubletimer.stop()
self._doAction(dbl, PRESSED)
self._doAction(dbl, RELEASED)
self._doublebutton = None
else:
self._doublebutton = button
self._doubletimer.start(DOUBLECLICK_TIMEOUT)
else:
self._doAction(sng, PRESSED)
else:
if not self._stopsinglepress:
if self._longtimer.isActive():
self._longtimer.stop()
self._doAction(sng, PRESSED)
self._doAction(sng, RELEASED)
else:
self._doAction(sng, RELEASED)
self._stopsinglepress = False
self._longtimer.stop()
def _longPress(self, lng):
self._stopsinglepress = True
self._doAction(lng, PRESSED)
self._doAction(lng, RELEASED)
def _doubleTimeout(self):
if not self._stopsinglepress:
actiondict = self._doublebutton.data.get("single")
self._doAction(actiondict, PRESSED)
self._doAction(actiondict, RELEASED)
self._doublebutton = None
#
# Higher level button handling: parses the actions from the action dictionary
#
def _doAction(self, actiondict, direction):
if not actiondict:
return
for cmd, argdict in actiondict.items():
if not argdict:
continue
if cmd == "send":
keycode = argdict.get("keycode", "")
keycodeplus = keycode
keyname = argdict.get("name", "")
printable = argdict.get("printable", True)
for modname, mod in self._modifiers.items():
if mod.get("state") > 0:
keyname = modname + " " + keyname
modkeycode = mod.get("keycode")
keycodeplus = modkeycode + "+" + keycode
if not mod.get("printable"):
printable = False
if direction == PRESSED and self._flashmodifiers:
self._injectKeys(modkeycode, PRESSED)
self._injectKeys(keycode, direction)
if direction == RELEASED:
self._releaseModifiers()
if self._viewuntil and re.fullmatch(self._viewuntil, keyname):
self.setView(self._thenview)
self.viewuntil, self._thenview = None, None
if cmd == "view" and direction == RELEASED:
viewname = argdict.get("name", "default")
self._viewuntil = argdict.get("until")
self._thenview = argdict.get("thenview")
self.setView(viewname)
addclass = "oneview" if self._viewuntil else "view"
self.setProperty("class", self._view.get("class", "") + addclass)
self.updateKeyboard()
if cmd == "modifier" and direction == RELEASED:
keycode = argdict.get("keycode", "")
modifier = argdict.get("name", "")
printable = argdict.get("printable", True)
modaction = argdict.get("action", "toggle")
m = self._modifiers.get(modifier)
if modaction == "toggle":
if not m or m["state"] == 0:
self._modifiers[modifier] = {
"state": 1,
"keycode": keycode,
"printable": printable,
}
if not self._flashmodifiers:
self._injectKeys(keycode, PRESSED)
else:
self._modifiers[modifier] = {
"state": 0,
"keycode": keycode,
"printable": printable,
}
if not self._flashmodifiers:
self._injectKeys(keycode, RELEASED)
if modaction == "lock":
if not m:
self._modifiers[modifier] = {}
s = self._modifiers[modifier].get("state", 0)
self._modifiers[modifier] = {
"state": 0 if s == 2 else 2,
"keycode": keycode,
"printable": printable,
}
if not self._flashmodifiers:
self._injectKeys(keycode, PRESSED if s == 0 else RELEASED)
self.updateKeyboard()
if cmd == "keyboard" and direction == RELEASED:
kbdname = argdict.get("name", "")
self.setKeyboard(kbdname)
# This is where the strings with keycodes to be pressed or released get turned into actual keypress
# events. There's two levels here: "42+2;57" (in the US layout) means we're first pressing and then
# releasing shift 2 (an exclamation point) and then a space.
def _injectKeys(self, keystr, direction):
keylist = keystr.split(";")
# If PRESSED, press and release all the ;-separated keycodes, releasing all but the last
if direction == PRESSED:
for keycodes in keylist:
keycodelist = keycodes.split("+")
for keycode in keycodelist:
self._sendKey(int(keycode), PRESSED)
if keycodes != keylist[-1]:
self._sendKey(int(keycode), RELEASED)
# If RELEASED, only need to release the last (set of) keys
if direction == RELEASED:
keycodelist = keylist[-1].split("+")
for keycode in reversed(keycodelist):
self._sendKey(int(keycode), RELEASED)
def _sendKey(self, keycode, keyevent):
if self._sendkeys:
self._sendkeys(keycode, keyevent)
def _releaseModifiers(self):
if self._view:
donestuff = False
for modinfo in self._modifiers.values():
if modinfo["state"] == 1:
donestuff = True
if not self._flashmodifiers:
self._injectKeys(modinfo["keycode"], RELEASED)
modinfo["state"] = 0
if self._flashmodifiers:
self._injectKeys(modinfo["keycode"], RELEASED)
if donestuff:
self.updateKeyboard()
# Helper
def _clearLayout(self, layout):
if layout != None:
while layout.count():
child = layout.takeAt(0)
if child.widget() is not None:
child.widget().deleteLater()
elif child.layout() is not None:
self._clearLayout(child.layout())
# oskbCopy() copies an oskb data structure (a dict with sub-dicts and sub-lists). If you specify two
# variables it will move from one to the other without breaking the reference. If you specify just one,
# it will return a new copy.
def oskbCopy(f, t=None):
if t == None:
t = {}
t.clear()
if type(f) == dict:
for fk, fv in f.items():
if fv != {} and fv != "" and not fk.startswith("_"):
if type(fv) == list or type(fv) == dict:
t[fk] = oskbCopy(fv)
else:
t[fk] = fv
elif type(f) == list:
t = []
for fi, fv in enumerate(f):
t.append(oskbCopy(fv))
return t
if __name__ == "__main__":
main()