aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/content/controllers/KeymapController.ts128
-rw-r--r--src/content/usecases/KeymapUseCase.ts70
-rw-r--r--test/content/usecases/KeymapUseCase.test.ts204
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;
+ });
});
});