diff --git a/common/web/types/src/consts/virtual-key-constants.ts b/common/web/types/src/consts/virtual-key-constants.ts index 7b083241bdc..0ce728b2f9f 100644 --- a/common/web/types/src/consts/virtual-key-constants.ts +++ b/common/web/types/src/consts/virtual-key-constants.ts @@ -127,6 +127,10 @@ export const USVirtualKeyCodes = { 'k_?C1':193, K_oDF:0xDF, K_ODF:0xDF, + + // Key codes > 50000 are special virtual key codes that are only used + // in touch layouts and should probably not be used elsewhere. + // See https://github.com/keymanapp/keyman/pull/15343#discussion_r2674949933 K_LOPT:50001, K_ROPT:50002, K_NUMERALS:50003, diff --git a/web/src/engine/src/core-processor/coreKeyboardProcessor.ts b/web/src/engine/src/core-processor/coreKeyboardProcessor.ts index 625a7255a71..529f35d87f4 100644 --- a/web/src/engine/src/core-processor/coreKeyboardProcessor.ts +++ b/web/src/engine/src/core-processor/coreKeyboardProcessor.ts @@ -9,9 +9,11 @@ import { DeviceSpec, EventMap, Keyboard, KeyboardMinimalInterface, KeyboardProcessor, KeyEvent, KMXKeyboard, SyntheticTextStore, MutableSystemStore, TextStore, ProcessorAction, StateKeyMap, - Deadkey + Deadkey, + Codes } from "keyman/engine/keyboard"; import { KM_CORE_EVENT_FLAG } from '../core-adapter/KM_Core.js'; +import { ModifierKeyConstants } from '@keymanapp/common-types'; export class CoreKeyboardInterface implements KeyboardMinimalInterface { public activeKeyboard: Keyboard; @@ -150,6 +152,12 @@ export class CoreKeyboardProcessor extends EventEmitter implements Key this._layerStore.set(value); } + private getLayerId(modifier: number): string { + // TODO-web-core: implement + // return Layouts.getLayerId(modifier); + return 'default'; // TODO-web-core: put into LayerNames enum + } + /** * Retrieve context including deadkeys from TextStore and apply to Core's context * @@ -302,6 +310,7 @@ export class CoreKeyboardProcessor extends EventEmitter implements Key return null; } + // TODO-web-core: this could be shared with JsKeyboardProcessor /** * Determines if the given key event is a modifier key press. * Returns true if the event corresponds to a modifier key, otherwise false. @@ -313,10 +322,113 @@ export class CoreKeyboardProcessor extends EventEmitter implements Key * @returns {boolean} True if the event is a modifier key press, false otherwise. */ public doModifierPress(keyEvent: KeyEvent, textStore: TextStore, isKeyDown: boolean): boolean { - // TODO-web-core: Implement this method (#15287) + if(!this.activeKeyboard) { + return false; + } + + if(keyEvent.isModifier) { + this.activeKeyboard.notify(keyEvent.Lcode, textStore, isKeyDown); + // For eventual integration - we bypass an OSK update for physical keystrokes when in touch mode. + if(!keyEvent.device.touchable) { + return this._UpdateVKShift(keyEvent); // I2187 + } else { + return true; + } + } + + if(keyEvent.LmodifierChange) { + this.activeKeyboard.notify(0, textStore, true); + if(!keyEvent.device.touchable) { + this._UpdateVKShift(keyEvent); + } + } + + // No modifier keypresses detected. return false; } + + // TODO-web-core: this could be shared with JsKeyboardProcessor + /** + * Updates the virtual keyboard shift state based on the provided key event. + * Handles modifier key simulation, state key updates, and layer selection for the OSK. + * + * @param {KeyEvent} e - The key event used to update the shift state. + * + * @returns {boolean} True if the update was processed, otherwise true if no active keyboard. + */ + private _UpdateVKShift(e: KeyEvent): boolean { + let keyShiftState=0; + + const lockNames = ['CAPS', 'NUM_LOCK', 'SCROLL_LOCK'] as const; + const lockKeys = ['K_CAPS', 'K_NUMLOCK', 'K_SCROLL'] as const; + const lockModifiers = [ModifierKeyConstants.CAPITALFLAG, ModifierKeyConstants.NUMLOCKFLAG, ModifierKeyConstants.SCROLLFLAG] as const; + + if(!this.activeKeyboard) { + return true; + } + + if(e) { + // read shift states from event + keyShiftState = e.Lmodifiers; + + // Are we simulating AltGr? If it's a simulation and not real, time to un-simulate for the OSK. + if(this.activeKeyboard.isChiral && this.activeKeyboard.emulatesAltGr && + (this.modStateFlags & Codes.modifierBitmasks['ALT_GR_SIM']) == Codes.modifierBitmasks['ALT_GR_SIM']) { + keyShiftState |= Codes.modifierBitmasks['ALT_GR_SIM']; + keyShiftState &= ~ModifierKeyConstants.RALTFLAG; + } + + // Set stateKeys where corresponding value is passed in e.Lstates + let stateMutation = false; + for(let i=0; i < lockNames.length; i++) { + if((e.Lstates & Codes.stateBitmasks[lockNames[i]]) != 0) { + this.stateKeys[lockKeys[i]] = ((e.Lstates & lockModifiers[i]) != 0); + stateMutation = true; + } + } + + if(stateMutation) { + this.emit('statekeychange', this.stateKeys); + } + } + + this.updateStates(); + + if (this.activeKeyboard.isMnemonic && this.stateKeys['K_CAPS'] && (!e || !e.isModifier)) { + // Modifier keypresses don't trigger mnemonic manipulation of modifier state. + // Only an output key does; active use of Caps will also flip the SHIFT flag. + // Mnemonic keystrokes manipulate the SHIFT property based on CAPS state. + // We need to unflip them when tracking the OSK layer. + keyShiftState ^= ModifierKeyConstants.K_SHIFTFLAG; + } + + this.layerId = this.getLayerId(keyShiftState); + return true; + } + + // TODO-web-core: this could be shared with JsKeyboardProcessor + private updateStates(): void { + const lockKeys = ['K_CAPS', 'K_NUMLOCK', 'K_SCROLL'] as const; + const lockModifiers = [ModifierKeyConstants.CAPITALFLAG, ModifierKeyConstants.NUMLOCKFLAG, ModifierKeyConstants.SCROLLFLAG] as const; + const noLockModifers = [ModifierKeyConstants.NOTCAPITALFLAG, ModifierKeyConstants.NOTNUMLOCKFLAG, ModifierKeyConstants.NOTSCROLLFLAG] as const; + + for (let i = 0; i < lockKeys.length; i++) { + const key = lockKeys[i]; + const flag = this.stateKeys[key]; + + // Ensures that the current mod-state info properly matches the currently-simulated + // state key states. + if (flag) { + this.modStateFlags |= lockModifiers[i]; + this.modStateFlags &= ~noLockModifers[i]; + } else { + this.modStateFlags &= ~lockModifiers[i]; + this.modStateFlags |= noLockModifers[i]; + } + } + } + /** * Resets the keyboard context, optionally using the provided text store. * Clears or reinitializes the context for subsequent keyboard processing. diff --git a/web/src/engine/src/keyboard/keyEvent.ts b/web/src/engine/src/keyboard/keyEvent.ts index 363212bb04f..66b3fe060d2 100644 --- a/web/src/engine/src/keyboard/keyEvent.ts +++ b/web/src/engine/src/keyboard/keyEvent.ts @@ -135,12 +135,12 @@ export class KeyEvent implements KeyEventSpec { get isModifier(): boolean { switch(this.Lcode) { - case 16: //"K_SHIFT":16,"K_CONTROL":17,"K_ALT":18 - case 17: - case 18: - case 20: //"K_CAPS":20, "K_NUMLOCK":144,"K_SCROLL":145 - case 144: - case 145: + case Codes.keyCodes.K_SHIFT: + case Codes.keyCodes.K_CONTROL: + case Codes.keyCodes.K_ALT: + case Codes.keyCodes.K_CAPS: + case Codes.keyCodes.K_NUMLOCK: + case Codes.keyCodes.K_SCROLL: return true; default: return false; diff --git a/web/src/test/auto/headless/engine/core-processor/coreKeyboardProcessor.tests.ts b/web/src/test/auto/headless/engine/core-processor/coreKeyboardProcessor.tests.ts index f01cba232e0..46c3659d42b 100644 --- a/web/src/test/auto/headless/engine/core-processor/coreKeyboardProcessor.tests.ts +++ b/web/src/test/auto/headless/engine/core-processor/coreKeyboardProcessor.tests.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import sinon from 'sinon'; import { KM_Core, km_core_context, km_core_keyboard, km_core_state, KM_CORE_CT, KM_CORE_STATUS, km_core_context_items } from 'keyman/engine/core-adapter'; import { coreurl, loadKeyboardBlob } from '../loadKeyboardHelper.js'; -import { Codes, Deadkey, KeyEvent, KMXKeyboard, SyntheticTextStore } from 'keyman/engine/keyboard'; +import { Codes, Deadkey, DeviceSpec, KeyEvent, KMXKeyboard, SyntheticTextStore } from 'keyman/engine/keyboard'; import { CoreKeyboardProcessor } from 'keyman/engine/core-processor'; describe('CoreKeyboardProcessor', function () { @@ -532,4 +532,77 @@ describe('CoreKeyboardProcessor', function () { }); } }); + + describe('doModifierPress', function () { + // const touchable = true; + const nonTouchable = false; + + beforeEach(async function () { + coreProcessor = new CoreKeyboardProcessor(); + await coreProcessor.init(coreurl); + state = createState('/common/test/resources/keyboards/test_8568_deadkeys.kmx'); + context = KM_Core.instance.state_context(state); + sandbox = sinon.createSandbox(); + const coreKeyboard = loadKeyboard('/common/test/resources/keyboards/test_8568_deadkeys.kmx'); + coreProcessor.activeKeyboard = new KMXKeyboard(coreKeyboard); + }); + + afterEach(() => { + sandbox.restore(); + sandbox = null; + }) + + for (const key of [ + { code: Codes.keyCodes.K_SHIFT, name: 'Shift' }, + { code: Codes.keyCodes.K_CONTROL, name: 'Control' }, + { code: Codes.keyCodes.K_ALT, name: 'Alt' }, + { code: Codes.keyCodes.K_CAPS, name: 'CapsLock' }, + { code: Codes.keyCodes.K_NUMLOCK, name: 'NumLock' }, + { code: Codes.keyCodes.K_SCROLL, name: 'ScrollLock' }, + ]) { + it(`recognizes ${key.name} as modifier`, function () { + // Setup + const keyEvent = new KeyEvent({ + Lcode: key.code, + Lmodifiers: 0, + Lstates: Codes.modifierCodes.NO_CAPS | Codes.modifierCodes.NO_NUM_LOCK | Codes.modifierCodes.NO_SCROLL_LOCK, + LisVirtualKey: true, + device: new DeviceSpec('chrome', 'desktop', 'windows', nonTouchable), + kName: key.name + }); + keyEvent.source = { type: 'keydown' }; + + // Execute + const result = coreProcessor.doModifierPress(keyEvent, new SyntheticTextStore(), true); + + // Verify + assert.isTrue(result); + }); + } + + for (const key of [ + { modifiers: 0, name: 'a' }, + { modifiers: Codes.modifierCodes.SHIFT, name: 'A' } + ]) { + it(`recognizes ${key.name} not as modifier`, function () { + // Setup + const keyEvent = new KeyEvent({ + Lcode: Codes.keyCodes.K_A, + Lmodifiers: key.modifiers, + Lstates: Codes.modifierCodes.NO_CAPS | Codes.modifierCodes.NO_NUM_LOCK | Codes.modifierCodes.NO_SCROLL_LOCK, + LisVirtualKey: true, + device: new DeviceSpec('chrome', 'desktop', 'windows', nonTouchable), + kName: 'K_A' + }); + keyEvent.source = { type: 'keydown' }; + + // Execute + const result = coreProcessor.doModifierPress(keyEvent, new SyntheticTextStore(), true); + + // Verify + assert.isFalse(result); + }); + } + + }); });