diff options
-rw-r--r-- | src/content/controllers/KeymapController.ts | 128 | ||||
-rw-r--r-- | src/content/usecases/KeymapUseCase.ts | 70 | ||||
-rw-r--r-- | test/content/usecases/KeymapUseCase.test.ts | 204 |
3 files changed, 258 insertions, 144 deletions
diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts index 6157a71..cf59ae5 100644 --- a/src/content/controllers/KeymapController.ts +++ b/src/content/controllers/KeymapController.ts @@ -32,71 +32,75 @@ export default class KeymapController { // eslint-disable-next-line complexity, max-lines-per-function press(key: Key): boolean { - let op = this.keymapUseCase.nextOp(key); - if (op === null) { + let ops = this.keymapUseCase.nextOps(key); + if (ops.length === 0) { return false; } - // do not await due to return a boolean immediately - switch (op.type) { - case operations.ADDON_ENABLE: - this.addonEnabledUseCase.enable(); - break; - case operations.ADDON_DISABLE: - this.addonEnabledUseCase.disable(); - break; - case operations.ADDON_TOGGLE_ENABLED: - this.addonEnabledUseCase.toggle(); - break; - case operations.FIND_NEXT: - this.findSlaveUseCase.findNext(); - break; - case operations.FIND_PREV: - this.findSlaveUseCase.findPrev(); - break; - case operations.SCROLL_VERTICALLY: - this.scrollUseCase.scrollVertically(op.count); - break; - case operations.SCROLL_HORIZONALLY: - this.scrollUseCase.scrollHorizonally(op.count); - break; - case operations.SCROLL_PAGES: - this.scrollUseCase.scrollPages(op.count); - break; - case operations.SCROLL_TOP: - this.scrollUseCase.scrollToTop(); - break; - case operations.SCROLL_BOTTOM: - this.scrollUseCase.scrollToBottom(); - break; - case operations.SCROLL_HOME: - this.scrollUseCase.scrollToHome(); - break; - case operations.SCROLL_END: - this.scrollUseCase.scrollToEnd(); - break; - case operations.FOLLOW_START: - this.followMasterClient.startFollow(op.newTab, op.background); - break; - case operations.MARK_SET_PREFIX: - this.markKeyUseCase.enableSetMode(); - break; - case operations.MARK_JUMP_PREFIX: - this.markKeyUseCase.enableJumpMode(); - break; - case operations.FOCUS_INPUT: - this.focusUseCase.focusFirstInput(); - break; - case operations.URLS_YANK: - this.clipbaordUseCase.yankCurrentURL(); - break; - case operations.URLS_PASTE: - this.clipbaordUseCase.openOrSearch( - op.newTab ? op.newTab : false, - ); - break; - default: - this.backgroundClient.execBackgroundOp(op); + // Do not await asynchronous methods to return a boolean immidiately. The + // caller requires the synchronous response from the callback to identify + // to continue of abandon the event propagations. + for (let op of ops) { + switch (op.type) { + case operations.ADDON_ENABLE: + this.addonEnabledUseCase.enable(); + break; + case operations.ADDON_DISABLE: + this.addonEnabledUseCase.disable(); + break; + case operations.ADDON_TOGGLE_ENABLED: + this.addonEnabledUseCase.toggle(); + break; + case operations.FIND_NEXT: + this.findSlaveUseCase.findNext(); + break; + case operations.FIND_PREV: + this.findSlaveUseCase.findPrev(); + break; + case operations.SCROLL_VERTICALLY: + this.scrollUseCase.scrollVertically(op.count); + break; + case operations.SCROLL_HORIZONALLY: + this.scrollUseCase.scrollHorizonally(op.count); + break; + case operations.SCROLL_PAGES: + this.scrollUseCase.scrollPages(op.count); + break; + case operations.SCROLL_TOP: + this.scrollUseCase.scrollToTop(); + break; + case operations.SCROLL_BOTTOM: + this.scrollUseCase.scrollToBottom(); + break; + case operations.SCROLL_HOME: + this.scrollUseCase.scrollToHome(); + break; + case operations.SCROLL_END: + this.scrollUseCase.scrollToEnd(); + break; + case operations.FOLLOW_START: + this.followMasterClient.startFollow(op.newTab, op.background); + break; + case operations.MARK_SET_PREFIX: + this.markKeyUseCase.enableSetMode(); + break; + case operations.MARK_JUMP_PREFIX: + this.markKeyUseCase.enableJumpMode(); + break; + case operations.FOCUS_INPUT: + this.focusUseCase.focusFirstInput(); + break; + case operations.URLS_YANK: + this.clipbaordUseCase.yankCurrentURL(); + break; + case operations.URLS_PASTE: + this.clipbaordUseCase.openOrSearch( + op.newTab ? op.newTab : false, + ); + break; + default: + this.backgroundClient.execBackgroundOp(op); + } } return true; } diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts index 1707f6f..f7de334 100644 --- a/src/content/usecases/KeymapUseCase.ts +++ b/src/content/usecases/KeymapUseCase.ts @@ -8,13 +8,16 @@ import Key from '../../shared/settings/Key'; import KeySequence from '../domains/KeySequence'; import AddressRepository from '../repositories/AddressRepository'; -type KeymapEntityMap = Map<KeySequence, operations.Operation>; - const reservedKeymaps = Keymaps.fromJSON({ '<Esc>': { type: operations.CANCEL }, '<C-[>': { type: operations.CANCEL }, }); +const enableAddonOps = [ + operations.ADDON_ENABLE, + operations.ADDON_TOGGLE_ENABLED, +]; + @injectable() export default class KeymapUseCase { constructor( @@ -32,53 +35,54 @@ export default class KeymapUseCase { ) { } - nextOp(key: Key): operations.Operation | null { + // eslint-disable-next-line max-statements + nextOps(key: Key): operations.Operation[] { let sequence = this.repository.enqueueKey(key); - if (sequence.length() === 1 && this.blacklistKey(key)) { + let baseSequence = sequence.trimNumericPrefix(); + if (baseSequence.length() === 1 && this.blacklistKey(key)) { // ignore if the input starts with black list keys this.repository.clear(); - return null; + return []; } let keymaps = this.keymapEntityMap(); - let matched = Array.from(keymaps.keys()).filter( - (mapping: KeySequence) => { - return mapping.startsWith(sequence); - }); - if (!this.addonEnabledRepository.get()) { - // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if - // the addon disabled - matched = matched.filter((keymap) => { - let type = (keymaps.get(keymap) as operations.Operation).type; - return type === operations.ADDON_ENABLE || - type === operations.ADDON_TOGGLE_ENABLED; - }); - } - if (matched.length === 0) { - // No operations to match with inputs + let matched = keymaps.filter(([seq]) => seq.startsWith(sequence)); + let baseMatched = keymaps.filter(([seq]) => seq.startsWith(baseSequence)); + + if (matched.length === 1 && + sequence.length() === matched[0][0].length()) { + // keys are matched with an operation this.repository.clear(); - return null; - } else if (matched.length > 1 || - matched.length === 1 && sequence.length() < matched[0].length()) { - // More than one operations are matched - return null; + return [matched[0][1]]; + } else if ( + baseMatched.length === 1 && + baseSequence.length() === baseMatched[0][0].length()) { + // keys are matched with an operation with a numeric prefix + this.repository.clear(); + return Array(sequence.repeatCount()).fill(baseMatched[0][1]); + } else if (matched.length >= 1 || baseMatched.length >= 1) { + // keys are matched with an operation's prefix + return []; } - // Exactly one operation is matched - let operation = keymaps.get(matched[0]) as operations.Operation; - this.repository.clear(); - return operation; - } - clear(): void { + // matched with no operations this.repository.clear(); + return []; } - private keymapEntityMap(): KeymapEntityMap { + private keymapEntityMap(): [KeySequence, operations.Operation][] { let keymaps = this.settingRepository.get().keymaps.combine(reservedKeymaps); let entries = keymaps.entries().map( ([keys, op]) => [KeySequence.fromMapKeys(keys), op] ) as [KeySequence, operations.Operation][]; - return new Map<KeySequence, operations.Operation>(entries); + if (!this.addonEnabledRepository.get()) { + // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if + // the addon disabled + entries = entries.filter( + ([_seq, { type }]) => enableAddonOps.includes(type) + ); + } + return entries; } private blacklistKey(key: Key): boolean { diff --git a/test/content/usecases/KeymapUseCase.test.ts b/test/content/usecases/KeymapUseCase.test.ts index 5f2feba..9fced9c 100644 --- a/test/content/usecases/KeymapUseCase.test.ts +++ b/test/content/usecases/KeymapUseCase.test.ts @@ -1,3 +1,4 @@ +import "reflect-metadata"; import KeymapUseCase from '../../../src/content/usecases/KeymapUseCase'; import {expect} from 'chai'; import SettingRepository from "../../../src/content/repositories/SettingRepository"; @@ -50,7 +51,7 @@ class MockAddressRepository implements AddressRepository { describe('KeymapUseCase', () => { - it('returns matched operation', () => { + context('with no-digis keymaps', () => { let settings = Settings.fromJSON({ keymaps: { k: {type: 'scroll.vertically', count: -1}, @@ -58,21 +59,117 @@ describe('KeymapUseCase', () => { gg: {type: 'scroll.top'}, }, }); - let sut = new KeymapUseCase( - new KeymapRepositoryImpl(), - new MockSettingRepository(settings), - new MockAddonEnabledRepository(true), - new MockAddressRepository(new URL('https://example.com')), - ); - - expect(sut.nextOp(Key.fromMapKey('k'))).to.deep.equal({type: 'scroll.vertically', count: -1}); - expect(sut.nextOp(Key.fromMapKey('j'))).to.deep.equal({type: 'scroll.vertically', count: 1}); - expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null; - expect(sut.nextOp(Key.fromMapKey('g'))).to.deep.equal({type: 'scroll.top'}); - expect(sut.nextOp(Key.fromMapKey('z'))).to.be.null; + + let sut: KeymapUseCase; + + before(() => { + sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(true), + new MockAddressRepository(new URL('https://example.com')), + ); + }); + + it('returns matched operation', () => { + expect(sut.nextOps(Key.fromMapKey('k'))).to.deep.equal([{type: 'scroll.vertically', count: -1}]); + expect(sut.nextOps(Key.fromMapKey('j'))).to.deep.equal([{type: 'scroll.vertically', count: 1}]); + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal([{type: 'scroll.top'}]); + expect(sut.nextOps(Key.fromMapKey('z'))).to.be.empty; + }); + + it('repeats n-times by numeric prefix and multiple key operations', () => { + expect(sut.nextOps(Key.fromMapKey('1'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('0'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal(Array(10).fill({type: "scroll.top"})); + }); + }); + + context('when keymaps containing numeric mappings', () => { + let settings = Settings.fromJSON({ + keymaps: { + 20: {type: "scroll.top"}, + g5: {type: 'scroll.bottom'}, + }, + }); + + let sut: KeymapUseCase; + + before(() => { + sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(true), + new MockAddressRepository(new URL('https://example.com')), + ); + }); + + it('returns the matched operation ends with digit', () => { + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal([{ type: 'scroll.bottom'}]); + }); + + it('returns an operation matched the operation with digit keymaps', () => { + expect(sut.nextOps(Key.fromMapKey('2'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('0'))).to.be.deep.equal([{ type: 'scroll.top'}]); + }); + + it('returns operations repeated by numeric prefix', () => { + expect(sut.nextOps(Key.fromMapKey('2'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal(Array(2).fill({ type: 'scroll.bottom'})); + }); + + it('does not matches with digit operation with numeric prefix', () => { + expect(sut.nextOps(Key.fromMapKey('3'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('2'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('0'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal(Array(320).fill({ type: 'scroll.bottom'})); + }); }); - it('returns only ADDON_ENABLE and ADDON_TOGGLE_ENABLED operation', () => { + context('when the keys are mismatched with the operations', () => { + let settings = Settings.fromJSON({ + keymaps: { + gg: {type: "scroll.top"}, + G: {type: "scroll.bottom"}, + }, + }); + + let sut: KeymapUseCase; + + before(() => { + sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(true), + new MockAddressRepository(new URL('https://example.com')), + ); + }); + + it('clears input keys with no-matched operations', () => { + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('x'))).to.be.empty; // clear + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal([{type: "scroll.top"}]); + }); + + it('clears input keys and the prefix with no-matched operations', () => { + expect(sut.nextOps(Key.fromMapKey('1'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('0'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('x'))).to.be.empty; // clear + expect(sut.nextOps(Key.fromMapKey('1'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('0'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal(Array(10).fill({type: "scroll.top"})); + }); + }); + + context('when the site matches to the blacklist', () => { let settings = Settings.fromJSON({ keymaps: { k: {type: 'scroll.vertically', count: -1}, @@ -80,25 +177,32 @@ describe('KeymapUseCase', () => { b: {type: 'addon.toggle.enabled'}, }, }); - let sut = new KeymapUseCase( - new KeymapRepositoryImpl(), - new MockSettingRepository(settings), - new MockAddonEnabledRepository(false), - new MockAddressRepository(new URL('https://example.com')), - ); - expect(sut.nextOp(Key.fromMapKey('k'))).to.be.null; - expect(sut.nextOp(Key.fromMapKey('a'))).to.deep.equal({type: 'addon.enable'}); - expect(sut.nextOp(Key.fromMapKey('b'))).to.deep.equal({type: 'addon.toggle.enabled'}); + let sut: KeymapUseCase; + + before(() => { + sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(false), + new MockAddressRepository(new URL('https://example.com')), + ); + }); + + it('returns only ADDON_ENABLE and ADDON_TOGGLE_ENABLED operation', () => { + expect(sut.nextOps(Key.fromMapKey('k'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('a'))).to.deep.equal([{type: 'addon.enable'}]); + expect(sut.nextOps(Key.fromMapKey('b'))).to.deep.equal([{type: 'addon.toggle.enabled'}]); + }); }); - it('blocks keys in the partial blacklist', () => { + context('when the site matches to the partial blacklist', () => { let settings = Settings.fromJSON({ keymaps: { k: {type: 'scroll.vertically', count: -1}, j: {type: 'scroll.vertically', count: 1}, - gg: {"type": "scroll.top"}, - G: {"type": "scroll.bottom"}, + gg: {type: "scroll.top"}, + G: {type: "scroll.bottom"}, }, blacklist: [ { url: "example.com", keys: ['g'] }, @@ -106,28 +210,30 @@ describe('KeymapUseCase', () => { ], }); - let sut = new KeymapUseCase( - new KeymapRepositoryImpl(), - new MockSettingRepository(settings), - new MockAddonEnabledRepository(true), - new MockAddressRepository(new URL('https://example.com')), - ); - - expect(sut.nextOp(Key.fromMapKey('k'))).to.deep.equal({type: 'scroll.vertically', count: -1}); - expect(sut.nextOp(Key.fromMapKey('j'))).to.deep.equal({type: 'scroll.vertically', count: 1}); - expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null; - expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null; - expect(sut.nextOp(Key.fromMapKey('G'))).to.deep.equal({type: 'scroll.bottom'}); - - sut = new KeymapUseCase( - new KeymapRepositoryImpl(), - new MockSettingRepository(settings), - new MockAddonEnabledRepository(true), - new MockAddressRepository(new URL('https://example.org')), - ); - - expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null; - expect(sut.nextOp(Key.fromMapKey('g'))).to.deep.equal({type: 'scroll.top'}); - expect(sut.nextOp(Key.fromMapKey('G'))).to.be.null; + it('blocks keys in the partial blacklist', () => { + let sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(true), + new MockAddressRepository(new URL('https://example.com')), + ); + + expect(sut.nextOps(Key.fromMapKey('k'))).to.deep.equal([{type: 'scroll.vertically', count: -1}]); + expect(sut.nextOps(Key.fromMapKey('j'))).to.deep.equal([{type: 'scroll.vertically', count: 1}]); + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('G'))).to.deep.equal([{type: 'scroll.bottom'}]); + + sut = new KeymapUseCase( + new KeymapRepositoryImpl(), + new MockSettingRepository(settings), + new MockAddonEnabledRepository(true), + new MockAddressRepository(new URL('https://example.org')), + ); + + expect(sut.nextOps(Key.fromMapKey('g'))).to.be.empty; + expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal([{type: 'scroll.top'}]); + expect(sut.nextOps(Key.fromMapKey('G'))).to.be.empty; + }); }); }); |