# SPDX-License-Identifier: GPL-2.0-or-later from PyQt5.QtCore import Qt, QSettings, QStandardPaths from PyQt5.QtWidgets import QWidget, QComboBox, QToolButton, QHBoxLayout, QVBoxLayout, QMainWindow, QAction, qApp, \ QFileDialog, QDialog, QTabWidget, QActionGroup, QMessageBox, QLabel import json import os import sys from urllib.request import urlopen from editor_container import EditorContainer from firmware_flasher import FirmwareFlasher from keyboard_comm import ProtocolError from keymap_editor import KeymapEditor from keymaps import KEYMAPS from layout_editor import LayoutEditor from macro_recorder import MacroRecorder from qmk_settings import QmkSettings from rgb_configurator import RGBConfigurator from tabbed_keycodes import TabbedKeycodes from tap_dance import TapDance from unlocker import Unlocker from util import tr, find_vial_devices, EXAMPLE_KEYBOARDS, KeycodeDisplay from vial_device import VialKeyboard from matrix_test import MatrixTest import themes class MainWindow(QMainWindow): def __init__(self, appctx): super().__init__() self.appctx = appctx self.settings = QSettings("Vial", "Vial") themes.set_theme(self.get_theme()) self.current_device = None self.devices = [] # create empty VIA definitions. Easier than setting it to none and handling a bunch of exceptions self.via_stack_json = {"definitions": {}} self.sideload_json = None self.sideload_vid = self.sideload_pid = -1 self.combobox_devices = QComboBox() self.combobox_devices.currentIndexChanged.connect(self.on_device_selected) self.btn_refresh_devices = QToolButton() self.btn_refresh_devices.setToolButtonStyle(Qt.ToolButtonTextOnly) self.btn_refresh_devices.setText(tr("MainWindow", "Refresh")) self.btn_refresh_devices.clicked.connect(self.on_click_refresh) layout_combobox = QHBoxLayout() layout_combobox.addWidget(self.combobox_devices) layout_combobox.addWidget(self.btn_refresh_devices) self.layout_editor = LayoutEditor() self.keymap_editor = KeymapEditor(self.layout_editor) self.firmware_flasher = FirmwareFlasher(self) self.macro_recorder = MacroRecorder() self.tap_dance = TapDance() self.qmk_settings = QmkSettings(self.appctx) self.matrix_tester = MatrixTest(self.layout_editor) self.rgb_configurator = RGBConfigurator() self.editors = [(self.keymap_editor, "Keymap"), (self.layout_editor, "Layout"), (self.macro_recorder, "Macros"), (self.rgb_configurator, "Lighting"), (self.tap_dance, "Tap Dance"), (self.qmk_settings, "QMK Settings"), (self.matrix_tester, "Matrix tester"), (self.firmware_flasher, "Firmware updater")] Unlocker.global_layout_editor = self.layout_editor self.current_tab = None self.tabs = QTabWidget() self.tabs.currentChanged.connect(self.on_tab_changed) self.refresh_tabs() no_devices = 'No devices detected. Connect a Vial-compatible device and press "Refresh"
' \ 'or select "File" → "Download VIA definitions" in order to enable support for VIA keyboards.' if sys.platform.startswith("linux"): no_devices += '

On Linux you need to set up a custom udev rule for keyboards to be detected. ' \ 'Follow the instructions linked below:
' \ 'https://get.vial.today/getting-started/linux-udev.html' self.lbl_no_devices = QLabel(tr("MainWindow", no_devices)) self.lbl_no_devices.setTextFormat(Qt.RichText) self.lbl_no_devices.setAlignment(Qt.AlignCenter) layout = QVBoxLayout() layout.addLayout(layout_combobox) layout.addWidget(self.tabs) layout.addWidget(self.lbl_no_devices) layout.setAlignment(self.lbl_no_devices, Qt.AlignHCenter) self.tray_keycodes = TabbedKeycodes() self.tray_keycodes.make_tray() layout.addWidget(self.tray_keycodes) self.tray_keycodes.hide() w = QWidget() w.setLayout(layout) self.setCentralWidget(w) self.init_menu() # cache for via definition files self.cache_path = QStandardPaths.writableLocation(QStandardPaths.CacheLocation) if not os.path.exists(self.cache_path): os.makedirs(self.cache_path) # check if the via defitions already exist if os.path.isfile(os.path.join(self.cache_path, "via_keyboards.json")): with open(os.path.join(self.cache_path, "via_keyboards.json")) as vf: self.via_stack_json = json.load(vf) vf.close() # make sure initial state is valid self.on_click_refresh() def init_menu(self): layout_load_act = QAction(tr("MenuFile", "Load saved layout..."), self) layout_load_act.setShortcut("Ctrl+O") layout_load_act.triggered.connect(self.on_layout_load) layout_save_act = QAction(tr("MenuFile", "Save current layout..."), self) layout_save_act.setShortcut("Ctrl+S") layout_save_act.triggered.connect(self.on_layout_save) sideload_json_act = QAction(tr("MenuFile", "Sideload VIA JSON..."), self) sideload_json_act.triggered.connect(self.on_sideload_json) download_via_stack_act = QAction(tr("MenuFile", "Download VIA definitions"), self) download_via_stack_act.triggered.connect(self.load_via_stack_json) load_dummy_act = QAction(tr("MenuFile", "Load dummy JSON..."), self) load_dummy_act.triggered.connect(self.on_load_dummy) exit_act = QAction(tr("MenuFile", "Exit"), self) exit_act.setShortcut("Ctrl+Q") exit_act.triggered.connect(qApp.exit) file_menu = self.menuBar().addMenu(tr("Menu", "File")) file_menu.addAction(layout_load_act) file_menu.addAction(layout_save_act) file_menu.addSeparator() file_menu.addAction(sideload_json_act) file_menu.addAction(download_via_stack_act) file_menu.addAction(load_dummy_act) file_menu.addSeparator() file_menu.addAction(exit_act) keyboard_unlock_act = QAction(tr("MenuSecurity", "Unlock"), self) keyboard_unlock_act.triggered.connect(self.unlock_keyboard) keyboard_lock_act = QAction(tr("MenuSecurity", "Lock"), self) keyboard_lock_act.triggered.connect(self.lock_keyboard) keyboard_reset_act = QAction(tr("MenuSecurity", "Reboot to bootloader"), self) keyboard_reset_act.triggered.connect(self.reboot_to_bootloader) keyboard_layout_menu = self.menuBar().addMenu(tr("Menu", "Keyboard layout")) keymap_group = QActionGroup(self) selected_keymap = self.settings.value("keymap") for idx, keymap in enumerate(KEYMAPS): act = QAction(tr("KeyboardLayout", keymap[0]), self) act.triggered.connect(lambda checked, x=idx: self.change_keyboard_layout(x)) act.setCheckable(True) if selected_keymap == keymap[0]: self.change_keyboard_layout(idx) act.setChecked(True) keymap_group.addAction(act) keyboard_layout_menu.addAction(act) # check "QWERTY" if nothing else is selected if keymap_group.checkedAction() is None: keymap_group.actions()[0].setChecked(True) self.security_menu = self.menuBar().addMenu(tr("Menu", "Security")) self.security_menu.addAction(keyboard_unlock_act) self.security_menu.addAction(keyboard_lock_act) self.security_menu.addSeparator() self.security_menu.addAction(keyboard_reset_act) self.theme_menu = self.menuBar().addMenu(tr("Menu", "Theme")) theme_group = QActionGroup(self) selected_theme = self.get_theme() for name, _ in [("System", None)] + themes.themes: act = QAction(tr("MenuTheme", name), self) act.triggered.connect(lambda x,name=name: self.set_theme(name)) act.setCheckable(True) act.setChecked(selected_theme == name) theme_group.addAction(act) self.theme_menu.addAction(act) # check "System" if nothing else is selected if theme_group.checkedAction() is None: theme_group.actions()[0].setChecked(True) def on_layout_load(self): dialog = QFileDialog() dialog.setDefaultSuffix("vil") dialog.setAcceptMode(QFileDialog.AcceptOpen) dialog.setNameFilters(["Vial layout (*.vil)"]) if dialog.exec_() == QDialog.Accepted: with open(dialog.selectedFiles()[0], "rb") as inf: data = inf.read() self.keymap_editor.restore_layout(data) self.rebuild() def on_layout_save(self): dialog = QFileDialog() dialog.setDefaultSuffix("vil") dialog.setAcceptMode(QFileDialog.AcceptSave) dialog.setNameFilters(["Vial layout (*.vil)"]) if dialog.exec_() == QDialog.Accepted: with open(dialog.selectedFiles()[0], "wb") as outf: outf.write(self.keymap_editor.save_layout()) def on_click_refresh(self): self.combobox_devices.clear() self.devices = find_vial_devices(self.via_stack_json, self.sideload_vid, self.sideload_pid) for dev in self.devices: self.combobox_devices.addItem(dev.title()) if self.devices: self.lbl_no_devices.hide() self.tabs.show() else: self.lbl_no_devices.show() self.tabs.hide() def on_device_selected(self): if self.current_device is not None: self.current_device.close() self.current_device = None idx = self.combobox_devices.currentIndex() if idx >= 0: self.current_device = self.devices[idx] if self.current_device is not None: try: if self.current_device.sideload: self.current_device.open(self.sideload_json) elif self.current_device.via_stack: self.current_device.open(self.via_stack_json["definitions"][self.current_device.via_id]) else: self.current_device.open(None) except ProtocolError: QMessageBox.warning(self, "", "Unsupported protocol version!\n" "Please download latest Vial from https://get.vial.today/") if isinstance(self.current_device, VialKeyboard) \ and self.current_device.keyboard.keyboard_id in EXAMPLE_KEYBOARDS: QMessageBox.warning(self, "", "An example keyboard UID was detected.\n" "Please change your keyboard UID to be unique before you ship!") self.rebuild() self.refresh_tabs() def rebuild(self): # don't show "Security" menu for bootloader mode, as the bootloader is inherently insecure self.security_menu.menuAction().setVisible(isinstance(self.current_device, VialKeyboard)) # if unlock process was interrupted, we must finish it first if isinstance(self.current_device, VialKeyboard) and self.current_device.keyboard.get_unlock_in_progress(): Unlocker.unlock(self.current_device.keyboard) self.current_device.keyboard.reload() for e in [self.layout_editor, self.keymap_editor, self.firmware_flasher, self.macro_recorder, self.tap_dance, self.qmk_settings, self.matrix_tester, self.rgb_configurator]: e.rebuild(self.current_device) def refresh_tabs(self): self.tabs.clear() for container, lbl in self.editors: if not container.valid(): continue c = EditorContainer(container) self.tabs.addTab(c, tr("MainWindow", lbl)) def load_via_stack_json(self): data = urlopen("https://github.com/vial-kb/via-keymap-precompiled/raw/main/via_keyboard_stack.json") self.via_stack_json = json.load(data) # write to cache with open(os.path.join(self.cache_path, "via_keyboards.json"), "w") as cf: cf.write(json.dumps(self.via_stack_json, indent=2)) cf.close() def on_sideload_json(self): dialog = QFileDialog() dialog.setDefaultSuffix("json") dialog.setAcceptMode(QFileDialog.AcceptOpen) dialog.setNameFilters(["VIA layout JSON (*.json)"]) if dialog.exec_() == QDialog.Accepted: with open(dialog.selectedFiles()[0], "rb") as inf: data = inf.read() self.sideload_json = json.loads(data) self.sideload_vid = int(self.sideload_json["vendorId"], 16) self.sideload_pid = int(self.sideload_json["productId"], 16) self.on_click_refresh() def on_load_dummy(self): dialog = QFileDialog() dialog.setDefaultSuffix("json") dialog.setAcceptMode(QFileDialog.AcceptOpen) dialog.setNameFilters(["VIA layout JSON (*.json)"]) if dialog.exec_() == QDialog.Accepted: with open(dialog.selectedFiles()[0], "rb") as inf: data = inf.read() self.sideload_json = json.loads(data) self.sideload_vid = self.sideload_pid = 0 self.on_click_refresh() def lock_ui(self): self.tabs.setEnabled(False) self.combobox_devices.setEnabled(False) self.btn_refresh_devices.setEnabled(False) def unlock_ui(self): self.tabs.setEnabled(True) self.combobox_devices.setEnabled(True) self.btn_refresh_devices.setEnabled(True) def unlock_keyboard(self): if isinstance(self.current_device, VialKeyboard): Unlocker.unlock(self.current_device.keyboard) def lock_keyboard(self): if isinstance(self.current_device, VialKeyboard): self.current_device.keyboard.lock() def reboot_to_bootloader(self): if isinstance(self.current_device, VialKeyboard): Unlocker.unlock(self.current_device.keyboard) self.current_device.keyboard.reset() def change_keyboard_layout(self, index): self.settings.setValue("keymap", KEYMAPS[index][0]) KeycodeDisplay.set_keymap_override(KEYMAPS[index][1]) def get_theme(self): return self.settings.value("theme", "Dark") def set_theme(self, theme): themes.set_theme(theme) self.settings.setValue("theme", theme) msg = QMessageBox() msg.setText(tr("MainWindow", "In order to fully apply the theme you should restart the application.")) msg.exec_() def on_tab_changed(self, index): TabbedKeycodes.close_tray() old_tab = self.current_tab new_tab = None if index >= 0: new_tab = self.tabs.widget(index) if old_tab is not None: old_tab.editor.deactivate() if new_tab is not None: new_tab.editor.activate() self.current_tab = new_tab