class Tex0WidgetGroup(QWidget): def __init__(self, parent, tex0s=None, max_rows=0, max_columns=4, brres=None): super().__init__(parent) main_layout = QVBoxLayout(self) self.stack = QStackedLayout(self) self.stack_widget = QWidget(self) self.map_box = QComboBox() self.map_box.activated.connect(self.on_map_change) self.__init_context_menu() main_layout.addWidget(self.map_box) self.stack_widget.setLayout(self.stack) main_layout.addWidget(self.stack_widget) self.subscriber = parent if tex0s is not None: self.set_tex0s(tex0s) if brres is None: self.brres = tex0s[0].parent if brres: self.brres = brres self.setLayout(main_layout) def __init_context_menu(self): self.setContextMenuPolicy(Qt.ActionsContextMenu) create_action = QAction('&Add Map', self) create_action.setToolTip('Add new map') create_action.triggered.connect(self.create_map) self.addAction(create_action) replace_action = QAction('&Replace', self) replace_action.setToolTip('Replace map') replace_action.triggered.connect(self.replace_map) self.addAction(replace_action) export_action = QAction('&Export', self) export_action.setToolTip('Export as png') export_action.triggered.connect(self.export) self.addAction(export_action) remove_action = QAction('&Delete', self) remove_action.setToolTip('Remove the map') remove_action.triggered.connect(self.remove) self.addAction(remove_action) def export(self): self.stack.currentWidget().export() def remove(self): self.remove_map_widget(self.stack.currentWidget()) def replace_map(self): self.stack.currentWidget().replace_map() def get_tex0(self, index): return self.stack.itemAt(index).tex0 def on_map_replace(self, tex): if self.subscriber is not None: self.subscriber.on_map_replace(tex, self.stack.currentIndex()) def on_map_change(self, index): self.stack.setCurrentIndex(index) if self.subscriber is not None: self.subscriber.on_map_change(self.stack.currentWidget().tex0, index) def reset(self): for i in reversed(range(self.stack.count())): widget = self.stack.itemAt(i).widget() widget.del_widget() self.map_box.removeItem(i) def set_brres(self, brres): self.brres = brres def set_tex0s(self, tex0s): self.reset() for x in tex0s: self.add_tex0(x) def add_tex0(self, x): widget = MapWidget(self, x) self.add_map_widget(widget) def add_map_widget(self, map_widget): self.stack.addWidget(map_widget) self.map_box.addItem(map_widget.name) def remove_map_widget(self, map_widget): tex0 = map_widget.tex0 index = self.stack.currentIndex() if self.subscriber is not None: self.subscriber.on_map_remove(tex0, index) def create_map(self): self.importer = MapImporter(self, self.brres) def on_import(self, tex0): index = self.stack.count() if self.subscriber: self.subscriber.on_map_add(tex0, index) self.importer = None
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, stretch) else: kl.addWidget(k, 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, row.get("height", 1) * 10) grid.setColumnStretch(ci * 2, column.get("_widthInUnits", 1) * 10) if ci > 0: spacercolumn = QHBoxLayout() spacercolumn.addWidget(QWidget(None)) grid.setColumnStretch((ci * 2) - 1, 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())