aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2019-12-22 10:47:00 +0900
committerGitHub <noreply@github.com>2019-12-22 10:47:00 +0900
commitb2dcdedad729ff7087867da50e20578f9fc8fb29 (patch)
tree033ecffbd7db9b6db8000464a68d748fcae1dc3d
parent3c7230c3036e8bb2b2e9a752be9b0ef4a0a7349d (diff)
parent75f86907fc2699c0f0661d4780c38249a18f849b (diff)
Merge pull request #689 from ueokande/n-times-repeat-operations
Repeat commands n-times
-rw-r--r--docs/keymaps.md15
-rw-r--r--e2e/repeat_n_times.test.ts60
-rw-r--r--package-lock.json9
-rw-r--r--package.json2
-rw-r--r--src/background/controllers/OperationController.ts175
-rw-r--r--src/background/infrastructures/ContentMessageListener.ts9
-rw-r--r--src/content/client/OperationClient.ts6
-rw-r--r--src/content/controllers/KeymapController.ts125
-rw-r--r--src/content/domains/KeySequence.ts (renamed from src/shared/settings/KeySequence.ts)39
-rw-r--r--src/content/repositories/KeymapRepository.ts2
-rw-r--r--src/content/usecases/KeymapUseCase.ts68
-rw-r--r--src/shared/messages.ts1
-rw-r--r--src/shared/operations.ts26
-rw-r--r--src/shared/settings/Key.ts22
-rw-r--r--test/content/domains/KeySequence.test.ts166
-rw-r--r--test/content/usecases/KeymapUseCase.test.ts204
-rw-r--r--test/shared/settings/Key.test.ts33
-rw-r--r--test/shared/settings/KeySequence.test.ts72
18 files changed, 708 insertions, 326 deletions
diff --git a/docs/keymaps.md b/docs/keymaps.md
index 9ae0c98..504a093 100644
--- a/docs/keymaps.md
+++ b/docs/keymaps.md
@@ -4,8 +4,11 @@ title: Keymaps
# Keymaps
-Keymaps are configurable in the add-on's preferences by navigating to `about:addons` and selecting "Extensions".
-The default mappings are as follows:
+The following descriptions are the default keymaps.
+You can configure keymaps in the add-on's preferences by navigating to `about:addons` and selecting "Extensions".
+
+In the following descriptions, <kbd>Ctrl</kbd>+<kbd>x</kbd> means "press <kbd>x</kbd> with <kbd>Ctrl</kbd>", and <kbd>g</kbd><kbd>x</kbd> means "press <kbd>g</kbd>, then press <kbd>x</kbd>".
+Some commands may be preceded by a decimal number, such as <kbd>3</kbd><kbd>d</kbd> deletes three tabs.
## Scrolling
@@ -13,10 +16,10 @@ The default mappings are as follows:
- <kbd>j</kbd>: scroll down
- <kbd>h</kbd>: scroll left
- <kbd>l</kbd>: scroll right
-- <kbd>Ctrl</kbd>+<kbd>U</kbd>: scroll up half a page
-- <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll down half a page
-- <kbd>Ctrl</kbd>+<kbd>B</kbd>: scroll up a page
-- <kbd>Ctrl</kbd>+<kbd>F</kbd>: scroll down a page
+- <kbd>Ctrl</kbd>+<kbd>u</kbd>: scroll up half a page
+- <kbd>Ctrl</kbd>+<kbd>d</kbd>: scroll down half a page
+- <kbd>Ctrl</kbd>+<kbd>b</kbd>: scroll up a page
+- <kbd>Ctrl</kbd>+<kbd>f</kbd>: scroll down a page
- <kbd>g</kbd><kbd>g</kbd>: scroll to the top of a page
- <kbd>G</kbd>: scroll to the bottom of a page
- <kbd>0</kbd>: scroll to the leftmost part of a page
diff --git a/e2e/repeat_n_times.test.ts b/e2e/repeat_n_times.test.ts
new file mode 100644
index 0000000..d28f3c9
--- /dev/null
+++ b/e2e/repeat_n_times.test.ts
@@ -0,0 +1,60 @@
+import * as path from 'path';
+import * as assert from 'assert';
+
+import TestServer from './lib/TestServer';
+import eventually from './eventually';
+import { Builder, Lanthan } from 'lanthan';
+import { WebDriver } from 'selenium-webdriver';
+import Page from './lib/Page';
+
+describe("tab test", () => {
+ let server = new TestServer().receiveContent('/',
+ `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`,
+ );
+ let lanthan: Lanthan;
+ let webdriver: WebDriver;
+ let browser: any;
+
+ before(async() => {
+ lanthan = await Builder
+ .forBrowser('firefox')
+ .spyAddon(path.join(__dirname, '..'))
+ .build();
+ webdriver = lanthan.getWebDriver();
+ browser = lanthan.getWebExtBrowser();
+ await server.start();
+
+ browser = browser;
+ });
+
+ after(async() => {
+ await server.stop();
+ if (lanthan) {
+ await lanthan.quit();
+ }
+ });
+
+ it('repeats scroll 3-times', async () => {
+ let page = await Page.navigateTo(webdriver, server.url());
+ await page.sendKeys('3', 'j');
+
+ let scrollY = await page.getScrollY();
+ assert.strictEqual(scrollY, 64 * 3);
+ });
+
+ it('repeats tab deletion 3-times', async () => {
+ let win = await browser.windows.create({ url: server.url('/#0') });
+ for (let i = 1; i < 5; ++i) {
+ await browser.tabs.create({ url: server.url('/#' + i), windowId: win.id });
+ await webdriver.navigate().to(server.url('/#' + i));
+ }
+
+ let page = await Page.navigateTo(webdriver, server.url());
+ await page.sendKeys('3', 'd');
+
+ await eventually(async() => {
+ let current = await browser.tabs.query({ windowId: win.id });
+ assert.strictEqual(current.length, 2);
+ });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index b844719..22fa09b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -248,6 +248,15 @@
"redux": "^4.0.0"
}
},
+ "@types/react-test-renderer": {
+ "version": "16.9.0",
+ "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.0.tgz",
+ "integrity": "sha512-bN5EyjtuTY35xX7N5j0KP1vg5MpUXHpFTX6tGsqkNOthjNvet4VQOYRxFh+NT5cDSJrATmAFK9NLeYZ4mp/o0Q==",
+ "dev": true,
+ "requires": {
+ "@types/react": "*"
+ }
+ },
"@types/redux-promise": {
"version": "0.5.28",
"resolved": "https://registry.npmjs.org/@types/redux-promise/-/redux-promise-0.5.28.tgz",
diff --git a/package.json b/package.json
index 381bb7d..0725795 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build": "NODE_ENV=production webpack --mode production --progress --display-error-details --devtool inline-source-map",
"package": "npm run build && script/package",
"lint": "eslint --ext .ts,.tsx src",
+ "lint:fix": "eslint --ext .ts,.tsx src --fix",
"type-checks": "tsc --noEmit",
"test": "karma start",
"test:e2e": "mocha --timeout 10000 --retries 10 --require ts-node/register --extension ts e2e"
@@ -31,6 +32,7 @@
"@types/react": "^16.9.2",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.2",
+ "@types/react-test-renderer": "^16.9.0",
"@types/redux-promise": "^0.5.28",
"@types/selenium-webdriver": "^4.0.2",
"@types/sinon": "^7.0.13",
diff --git a/src/background/controllers/OperationController.ts b/src/background/controllers/OperationController.ts
index 7a10ad6..2f5d4a6 100644
--- a/src/background/controllers/OperationController.ts
+++ b/src/background/controllers/OperationController.ts
@@ -21,95 +21,108 @@ export default class OperationController {
) {
}
- async exec(op: operations.Operation): Promise<any> {
- await this.doOperation(op);
+ async exec(repeat: number, op: operations.Operation): Promise<any> {
+ await this.doOperation(repeat, op);
if (this.repeatUseCase.isRepeatable(op)) {
this.repeatUseCase.storeLastOperation(op);
}
}
// eslint-disable-next-line complexity, max-lines-per-function
- doOperation(operation: operations.Operation): Promise<any> {
- switch (operation.type) {
- case operations.TAB_CLOSE:
- return this.tabUseCase.close(false, operation.select === 'left');
- case operations.TAB_CLOSE_RIGHT:
- return this.tabUseCase.closeRight();
- case operations.TAB_CLOSE_FORCE:
- return this.tabUseCase.close(true);
- case operations.TAB_REOPEN:
- return this.tabUseCase.reopen();
- case operations.TAB_PREV:
- return this.tabSelectUseCase.selectPrev(1);
- case operations.TAB_NEXT:
- return this.tabSelectUseCase.selectNext(1);
- case operations.TAB_FIRST:
- return this.tabSelectUseCase.selectFirst();
- case operations.TAB_LAST:
- return this.tabSelectUseCase.selectLast();
- case operations.TAB_PREV_SEL:
- return this.tabSelectUseCase.selectPrevSelected();
- case operations.TAB_RELOAD:
- return this.tabUseCase.reload(operation.cache);
- case operations.TAB_PIN:
- return this.tabUseCase.setPinned(true);
- case operations.TAB_UNPIN:
- return this.tabUseCase.setPinned(false);
- case operations.TAB_TOGGLE_PINNED:
- return this.tabUseCase.togglePinned();
- case operations.TAB_DUPLICATE:
- return this.tabUseCase.duplicate();
- case operations.PAGE_SOURCE:
- return this.tabUseCase.openPageSource();
- case operations.PAGE_HOME:
- return this.tabUseCase.openHome(operation.newTab);
- case operations.ZOOM_IN:
- return this.zoomUseCase.zoomIn();
- case operations.ZOOM_OUT:
- return this.zoomUseCase.zoomOut();
- case operations.ZOOM_NEUTRAL:
- return this.zoomUseCase.zoomNutoral();
- case operations.COMMAND_SHOW:
- return this.consoleUseCase.showCommand();
- case operations.COMMAND_SHOW_OPEN:
- return this.consoleUseCase.showOpenCommand(operation.alter);
- case operations.COMMAND_SHOW_TABOPEN:
- return this.consoleUseCase.showTabopenCommand(operation.alter);
- case operations.COMMAND_SHOW_WINOPEN:
- return this.consoleUseCase.showWinopenCommand(operation.alter);
- case operations.COMMAND_SHOW_BUFFER:
- return this.consoleUseCase.showBufferCommand();
- case operations.COMMAND_SHOW_ADDBOOKMARK:
- return this.consoleUseCase.showAddbookmarkCommand(operation.alter);
- case operations.FIND_START:
- return this.findUseCase.findStart();
- case operations.CANCEL:
- return this.consoleUseCase.hideConsole();
- case operations.NAVIGATE_HISTORY_PREV:
- return this.navigateUseCase.openHistoryPrev();
- case operations.NAVIGATE_HISTORY_NEXT:
- return this.navigateUseCase.openHistoryNext();
- case operations.NAVIGATE_LINK_PREV:
- return this.navigateUseCase.openLinkPrev();
- case operations.NAVIGATE_LINK_NEXT:
- return this.navigateUseCase.openLinkNext();
- case operations.NAVIGATE_PARENT:
- return this.navigateUseCase.openParent();
- case operations.NAVIGATE_ROOT:
- return this.navigateUseCase.openRoot();
- case operations.REPEAT_LAST:
- {
- let last = this.repeatUseCase.getLastOperation();
- if (typeof last !== 'undefined') {
- return this.doOperation(last);
+ async doOperation(
+ repeat: number,
+ operation: operations.Operation,
+ ): Promise<any> {
+ // eslint-disable-next-line complexity, max-lines-per-function
+ const opFunc = (() => {
+ switch (operation.type) {
+ case operations.TAB_CLOSE:
+ return () => this.tabUseCase.close(false, operation.select === 'left');
+ case operations.TAB_CLOSE_RIGHT:
+ return () => this.tabUseCase.closeRight();
+ case operations.TAB_CLOSE_FORCE:
+ return () => this.tabUseCase.close(true);
+ case operations.TAB_REOPEN:
+ return () => this.tabUseCase.reopen();
+ case operations.TAB_PREV:
+ return () => this.tabSelectUseCase.selectPrev(1);
+ case operations.TAB_NEXT:
+ return () => this.tabSelectUseCase.selectNext(1);
+ case operations.TAB_FIRST:
+ return () => this.tabSelectUseCase.selectFirst();
+ case operations.TAB_LAST:
+ return () => this.tabSelectUseCase.selectLast();
+ case operations.TAB_PREV_SEL:
+ return () => this.tabSelectUseCase.selectPrevSelected();
+ case operations.TAB_RELOAD:
+ return () => this.tabUseCase.reload(operation.cache);
+ case operations.TAB_PIN:
+ return () => this.tabUseCase.setPinned(true);
+ case operations.TAB_UNPIN:
+ return () => this.tabUseCase.setPinned(false);
+ case operations.TAB_TOGGLE_PINNED:
+ return () => this.tabUseCase.togglePinned();
+ case operations.TAB_DUPLICATE:
+ return () => this.tabUseCase.duplicate();
+ case operations.PAGE_SOURCE:
+ return () => this.tabUseCase.openPageSource();
+ case operations.PAGE_HOME:
+ return () => this.tabUseCase.openHome(operation.newTab);
+ case operations.ZOOM_IN:
+ return () => this.zoomUseCase.zoomIn();
+ case operations.ZOOM_OUT:
+ return () => this.zoomUseCase.zoomOut();
+ case operations.ZOOM_NEUTRAL:
+ return () => this.zoomUseCase.zoomNutoral();
+ case operations.COMMAND_SHOW:
+ return () => this.consoleUseCase.showCommand();
+ case operations.COMMAND_SHOW_OPEN:
+ return () => this.consoleUseCase.showOpenCommand(operation.alter);
+ case operations.COMMAND_SHOW_TABOPEN:
+ return () => this.consoleUseCase.showTabopenCommand(operation.alter);
+ case operations.COMMAND_SHOW_WINOPEN:
+ return () => this.consoleUseCase.showWinopenCommand(operation.alter);
+ case operations.COMMAND_SHOW_BUFFER:
+ return () => this.consoleUseCase.showBufferCommand();
+ case operations.COMMAND_SHOW_ADDBOOKMARK:
+ return () => this.consoleUseCase.showAddbookmarkCommand(
+ operation.alter);
+ case operations.FIND_START:
+ return () => this.findUseCase.findStart();
+ case operations.CANCEL:
+ return () => this.consoleUseCase.hideConsole();
+ case operations.NAVIGATE_HISTORY_PREV:
+ return () => this.navigateUseCase.openHistoryPrev();
+ case operations.NAVIGATE_HISTORY_NEXT:
+ return () => this.navigateUseCase.openHistoryNext();
+ case operations.NAVIGATE_LINK_PREV:
+ return () => this.navigateUseCase.openLinkPrev();
+ case operations.NAVIGATE_LINK_NEXT:
+ return () => this.navigateUseCase.openLinkNext();
+ case operations.NAVIGATE_PARENT:
+ return () => this.navigateUseCase.openParent();
+ case operations.NAVIGATE_ROOT:
+ return () => this.navigateUseCase.openRoot();
+ case operations.REPEAT_LAST:
+ return () => {
+ let last = this.repeatUseCase.getLastOperation();
+ if (typeof last !== 'undefined') {
+ return this.doOperation(1, last);
+ }
+ return Promise.resolve();
+ };
+ case operations.INTERNAL_OPEN_URL:
+ return () => this.tabUseCase.openURL(
+ operation.url, operation.newTab, operation.newWindow);
+ default:
+ throw new Error('unknown operation: ' + operation.type);
}
- return Promise.resolve();
- }
- case operations.INTERNAL_OPEN_URL:
- return this.tabUseCase.openURL(
- operation.url, operation.newTab, operation.newWindow);
+ })();
+
+ for (let i = 0; i < repeat; ++i) {
+ // eslint-disable-next-line no-await-in-loop
+ await opFunc();
}
- throw new Error('unknown operation: ' + operation.type);
}
}
diff --git a/src/background/infrastructures/ContentMessageListener.ts b/src/background/infrastructures/ContentMessageListener.ts
index f80d686..f20340b 100644
--- a/src/background/infrastructures/ContentMessageListener.ts
+++ b/src/background/infrastructures/ContentMessageListener.ts
@@ -1,5 +1,6 @@
import { injectable } from 'tsyringe';
import * as messages from '../../shared/messages';
+import * as operations from '../../shared/operations';
import CompletionGroup from '../domains/CompletionGroup';
import CommandController from '../controllers/CommandController';
import SettingController from '../controllers/SettingController';
@@ -19,7 +20,7 @@ export default class ContentMessageListener {
private findController: FindController,
private addonEnabledController: AddonEnabledController,
private linkController: LinkController,
- private backgroundOperationController: OperationController,
+ private operationController: OperationController,
private markController: MarkController,
) {
this.consolePorts = {};
@@ -79,7 +80,7 @@ export default class ContentMessageListener {
senderTab.id as number,
message.background);
case messages.BACKGROUND_OPERATION:
- return this.onBackgroundOperation(message.operation);
+ return this.onBackgroundOperation(message.repeat, message.operation);
case messages.MARK_SET_GLOBAL:
return this.onMarkSetGlobal(message.key, message.x, message.y);
case messages.MARK_JUMP_GLOBAL:
@@ -126,8 +127,8 @@ export default class ContentMessageListener {
return this.linkController.openToTab(url, openerId);
}
- onBackgroundOperation(operation: any): Promise<any> {
- return this.backgroundOperationController.exec(operation);
+ onBackgroundOperation(count: number, op: operations.Operation): Promise<any> {
+ return this.operationController.exec(count, op);
}
onMarkSetGlobal(key: string, x: number, y: number): Promise<any> {
diff --git a/src/content/client/OperationClient.ts b/src/content/client/OperationClient.ts
index 5dbe555..9c72c75 100644
--- a/src/content/client/OperationClient.ts
+++ b/src/content/client/OperationClient.ts
@@ -2,7 +2,7 @@ import * as operations from '../../shared/operations';
import * as messages from '../../shared/messages';
export default interface OperationClient {
- execBackgroundOp(op: operations.Operation): Promise<void>;
+ execBackgroundOp(repeat: number, op: operations.Operation): Promise<void>;
internalOpenUrl(
url: string, newTab?: boolean, background?: boolean,
@@ -10,9 +10,10 @@ export default interface OperationClient {
}
export class OperationClientImpl implements OperationClient {
- execBackgroundOp(op: operations.Operation): Promise<void> {
+ execBackgroundOp(repeat: number, op: operations.Operation): Promise<void> {
return browser.runtime.sendMessage({
type: messages.BACKGROUND_OPERATION,
+ repeat,
operation: op,
});
}
@@ -22,6 +23,7 @@ export class OperationClientImpl implements OperationClient {
): Promise<void> {
return browser.runtime.sendMessage({
type: messages.BACKGROUND_OPERATION,
+ repeat: 1,
operation: {
type: operations.INTERNAL_OPEN_URL,
url,
diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts
index 6157a71..452e3d4 100644
--- a/src/content/controllers/KeymapController.ts
+++ b/src/content/controllers/KeymapController.ts
@@ -23,7 +23,7 @@ export default class KeymapController {
private markKeyUseCase: MarkKeyyUseCase,
@inject('OperationClient')
- private backgroundClient: OperationClient,
+ private operationClient: OperationClient,
@inject('FollowMasterClient')
private followMasterClient: FollowMasterClient,
@@ -32,71 +32,70 @@ 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 nextOp = this.keymapUseCase.nextOps(key);
+ if (nextOp === null) {
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);
+ if (!operations.isNRepeatable(nextOp.op.type)) {
+ nextOp.repeat = 1;
+ }
+
+ const doFunc = ((op: operations.Operation) => {
+ switch (op.type) {
+ case operations.ADDON_ENABLE:
+ return () => this.addonEnabledUseCase.enable();
+ case operations.ADDON_DISABLE:
+ return () => this.addonEnabledUseCase.disable();
+ case operations.ADDON_TOGGLE_ENABLED:
+ return () => this.addonEnabledUseCase.toggle();
+ case operations.FIND_NEXT:
+ return () => this.findSlaveUseCase.findNext();
+ case operations.FIND_PREV:
+ return () => this.findSlaveUseCase.findPrev();
+ case operations.SCROLL_VERTICALLY:
+ return () => this.scrollUseCase.scrollVertically(op.count);
+ case operations.SCROLL_HORIZONALLY:
+ return () => this.scrollUseCase.scrollHorizonally(op.count);
+ case operations.SCROLL_PAGES:
+ return () => this.scrollUseCase.scrollPages(op.count);
+ case operations.SCROLL_TOP:
+ return () => this.scrollUseCase.scrollToTop();
+ case operations.SCROLL_BOTTOM:
+ return () => this.scrollUseCase.scrollToBottom();
+ case operations.SCROLL_HOME:
+ return () => this.scrollUseCase.scrollToHome();
+ case operations.SCROLL_END:
+ return () => this.scrollUseCase.scrollToEnd();
+ case operations.FOLLOW_START:
+ return () => this.followMasterClient.startFollow(
+ op.newTab, op.background);
+ case operations.MARK_SET_PREFIX:
+ return () => this.markKeyUseCase.enableSetMode();
+ case operations.MARK_JUMP_PREFIX:
+ return () => this.markKeyUseCase.enableJumpMode();
+ case operations.FOCUS_INPUT:
+ return () => this.focusUseCase.focusFirstInput();
+ case operations.URLS_YANK:
+ return () => this.clipbaordUseCase.yankCurrentURL();
+ case operations.URLS_PASTE:
+ return () => this.clipbaordUseCase.openOrSearch(
+ op.newTab ? op.newTab : false,
+ );
+ default:
+ return null;
+ }
+ })(nextOp.op);
+
+ if (doFunc === null) {
+ // 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.
+ this.operationClient.execBackgroundOp(nextOp.repeat, nextOp.op);
+ } else {
+ for (let i = 0; i < nextOp.repeat; ++i) {
+ doFunc();
+ }
}
return true;
}
diff --git a/src/shared/settings/KeySequence.ts b/src/content/domains/KeySequence.ts
index abae61a..4534b60 100644
--- a/src/shared/settings/KeySequence.ts
+++ b/src/content/domains/KeySequence.ts
@@ -1,4 +1,4 @@
-import Key from './Key';
+import Key from '../../shared/settings/Key';
export default class KeySequence {
constructor(
@@ -26,6 +26,43 @@ export default class KeySequence {
return true;
}
+ isDigitOnly(): boolean {
+ return this.keys.every(key => key.isDigit());
+ }
+
+ repeatCount(): number {
+ let nonDigitAt = this.keys.findIndex(key => !key.isDigit());
+ if (this.keys.length === 0 || nonDigitAt === 0) {
+ return 1;
+ }
+ if (nonDigitAt === -1) {
+ nonDigitAt = this.keys.length;
+ }
+ let digits = this.keys.slice(0, nonDigitAt)
+ .map(key => key.key)
+ .join('');
+ return Number(digits);
+ }
+
+ trimNumericPrefix(): KeySequence {
+ let nonDigitAt = this.keys.findIndex(key => !key.isDigit());
+ if (nonDigitAt === -1) {
+ nonDigitAt = this.keys.length;
+ }
+ return new KeySequence(this.keys.slice(nonDigitAt));
+ }
+
+ splitNumericPrefix(): [KeySequence, KeySequence] {
+ let nonDigitIndex = this.keys.findIndex(key => !key.isDigit());
+ if (nonDigitIndex === -1) {
+ return [this, new KeySequence([])];
+ }
+ return [
+ new KeySequence(this.keys.slice(0, nonDigitIndex)),
+ new KeySequence(this.keys.slice(nonDigitIndex)),
+ ];
+ }
+
static fromMapKeys(keys: string): KeySequence {
const fromMapKeysRecursive = (
remaining: string, mappedKeys: Key[],
diff --git a/src/content/repositories/KeymapRepository.ts b/src/content/repositories/KeymapRepository.ts
index 3391229..2944723 100644
--- a/src/content/repositories/KeymapRepository.ts
+++ b/src/content/repositories/KeymapRepository.ts
@@ -1,5 +1,5 @@
import Key from '../../shared/settings/Key';
-import KeySequence from '../../shared/settings/KeySequence';
+import KeySequence from '../domains/KeySequence';
export default interface KeymapRepository {
enqueueKey(key: Key): KeySequence;
diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts
index 67d667d..a2e7cc3 100644
--- a/src/content/usecases/KeymapUseCase.ts
+++ b/src/content/usecases/KeymapUseCase.ts
@@ -5,16 +5,19 @@ import AddonEnabledRepository from '../repositories/AddonEnabledRepository';
import * as operations from '../../shared/operations';
import Keymaps from '../../shared/settings/Keymaps';
import Key from '../../shared/settings/Key';
-import KeySequence from '../../shared/settings/KeySequence';
+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): { repeat: number, op: operations.Operation } | null {
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;
}
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 { repeat: 1, op: 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 { repeat: sequence.repeatCount(), op: baseMatched[0][1] };
+ } else if (matched.length >= 1 || baseMatched.length >= 1) {
+ // keys are matched with an operation's prefix
return null;
}
- // 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 null;
}
- 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/src/shared/messages.ts b/src/shared/messages.ts
index 36a23d8..7f8bd5b 100644
--- a/src/shared/messages.ts
+++ b/src/shared/messages.ts
@@ -49,6 +49,7 @@ export const NAVIGATE_LINK_PREV = 'navigate.link.prev';
export interface BackgroundOperationMessage {
type: typeof BACKGROUND_OPERATION;
+ repeat: number;
operation: operations.Operation;
}
diff --git a/src/shared/operations.ts b/src/shared/operations.ts
index 1ce5256..67c5ca8 100644
--- a/src/shared/operations.ts
+++ b/src/shared/operations.ts
@@ -508,3 +508,29 @@ export const valueOf = (o: any): Operation => {
}
throw new TypeError('Unknown operation type: ' + o.type);
};
+
+export const isNRepeatable = (type: string): boolean => {
+ switch (type) {
+ case SCROLL_VERTICALLY:
+ case SCROLL_HORIZONALLY:
+ case SCROLL_PAGES:
+ case NAVIGATE_HISTORY_PREV:
+ case NAVIGATE_HISTORY_NEXT:
+ case NAVIGATE_PARENT:
+ case TAB_CLOSE:
+ case TAB_CLOSE_FORCE:
+ case TAB_CLOSE_RIGHT:
+ case TAB_REOPEN:
+ case TAB_PREV:
+ case TAB_NEXT:
+ case TAB_DUPLICATE:
+ case ZOOM_IN:
+ case ZOOM_OUT:
+ case URLS_PASTE:
+ case FIND_NEXT:
+ case FIND_PREV:
+ case REPEAT_LAST:
+ return true;
+ }
+ return false;
+};
diff --git a/src/shared/settings/Key.ts b/src/shared/settings/Key.ts
index b11eeb2..3a3eb3b 100644
--- a/src/shared/settings/Key.ts
+++ b/src/shared/settings/Key.ts
@@ -1,3 +1,5 @@
+const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
+
export default class Key {
public readonly key: string;
@@ -9,12 +11,18 @@ export default class Key {
public readonly meta: boolean;
- constructor({ key, shift, ctrl, alt, meta }: {
+ constructor({
+ key,
+ shift = false,
+ ctrl = false,
+ alt = false,
+ meta = false,
+ }: {
key: string;
- shift: boolean;
- ctrl: boolean;
- alt: boolean;
- meta: boolean;
+ shift?: boolean;
+ ctrl?: boolean;
+ alt?: boolean;
+ meta?: boolean;
}) {
this.key = key;
this.shift = shift;
@@ -51,6 +59,10 @@ export default class Key {
});
}
+ isDigit(): boolean {
+ return digits.includes(this.key);
+ }
+
equals(key: Key) {
return this.key === key.key &&
this.ctrl === key.ctrl &&
diff --git a/test/content/domains/KeySequence.test.ts b/test/content/domains/KeySequence.test.ts
new file mode 100644
index 0000000..bc16189
--- /dev/null
+++ b/test/content/domains/KeySequence.test.ts
@@ -0,0 +1,166 @@
+import KeySequence from '../../../src/content/domains/KeySequence';
+import { expect } from 'chai'
+import Key from "../../../src/shared/settings/Key";
+
+describe("KeySequence", () => {
+ describe('#push', () => {
+ it('append a key to the sequence', () => {
+ let seq = new KeySequence([]);
+ seq.push(Key.fromMapKey('g'));
+ seq.push(Key.fromMapKey('<S-U>'));
+
+ expect(seq.keys[0].key).to.equal('g');
+ expect(seq.keys[1].key).to.equal('U');
+ expect(seq.keys[1].shift).to.be.true;
+ })
+ });
+
+ describe('#startsWith', () => {
+ it('returns true if the key sequence starts with param', () => {
+ let seq = new KeySequence([
+ Key.fromMapKey('g'),
+ Key.fromMapKey('<S-U>'),
+ ]);
+
+ expect(seq.startsWith(new KeySequence([
+ ]))).to.be.true;
+ expect(seq.startsWith(new KeySequence([
+ Key.fromMapKey('g'),
+ ]))).to.be.true;
+ expect(seq.startsWith(new KeySequence([
+ Key.fromMapKey('g'), Key.fromMapKey('<S-U>'),
+ ]))).to.be.true;
+ expect(seq.startsWith(new KeySequence([
+ Key.fromMapKey('g'), Key.fromMapKey('<S-U>'), Key.fromMapKey('x'),
+ ]))).to.be.false;
+ expect(seq.startsWith(new KeySequence([
+ Key.fromMapKey('h'),
+ ]))).to.be.false;
+ });
+
+ it('returns true if the empty sequence starts with an empty sequence', () => {
+ let seq = new KeySequence([]);
+
+ expect(seq.startsWith(new KeySequence([]))).to.be.true;
+ expect(seq.startsWith(new KeySequence([
+ Key.fromMapKey('h'),
+ ]))).to.be.false;
+ })
+ });
+
+ describe('#isDigitOnly', () => {
+ it('returns true the keys are only digits', () => {
+ expect(new KeySequence([
+ new Key({ key: '4' }),
+ new Key({ key: '0' }),
+ ]).isDigitOnly()).to.be.true;
+ expect(new KeySequence([
+ new Key({ key: '4' }),
+ new Key({ key: '0' }),
+ new Key({ key: 'z' }),
+ ]).isDigitOnly()).to.be.false;
+ })
+ });
+
+ describe('#repeatCount', () => {
+ it('returns repeat count with a numeric prefix', () => {
+ let seq = new KeySequence([
+ new Key({ key: '1' }), new Key({ key: '0' }) ,
+ new Key({ key: 'g' }), new Key({ key: 'g' }) ,
+ ]);
+ expect(seq.repeatCount()).to.equal(10);
+
+ seq = new KeySequence([
+ new Key({ key: '0' }), new Key({ key: '5' }) ,
+ new Key({ key: 'g' }), new Key({ key: 'g' }) ,
+ ]);
+ expect(seq.repeatCount()).to.equal(5);
+ });
+
+ it('returns 1 if no numeric prefix', () => {
+ let seq = new KeySequence([
+ new Key({ key: 'g' }), new Key({ key: 'g' }) ,
+ ]);
+ expect(seq.repeatCount()).to.equal(1);
+
+ seq = new KeySequence([]);
+ expect(seq.repeatCount()).to.equal(1);
+ });
+
+ it('returns whole keys if digits only sequence', () => {
+ let seq = new KeySequence([
+ new Key({ key: '1' }), new Key({ key: '0' }) ,
+ ]);
+ expect(seq.repeatCount()).to.equal(10);
+
+ seq = new KeySequence([
+ new Key({ key: '0' }), new Key({ key: '5' }) ,
+ ]);
+ expect(seq.repeatCount()).to.equal(5);
+ });
+ });
+
+ describe('#trimNumericPrefix', () => {
+ it('removes numeric prefix', () => {
+ let seq = new KeySequence([
+ new Key({ key: '1' }), new Key({ key: '0' }) ,
+ new Key({ key: 'g' }), new Key({ key: 'g' }) , new Key({ key: '3' }) ,
+ ]).trimNumericPrefix();
+ expect(seq.keys.map(key => key.key)).to.deep.equal(['g', 'g', '3']);
+ });
+
+ it('returns empty if keys contains only digis', () => {
+ let seq = new KeySequence([
+ new Key({ key: '1' }), new Key({ key: '0' }) ,
+ ]).trimNumericPrefix();
+ expect(seq.trimNumericPrefix().keys).to.be.empty;
+ });
+
+ it('returns itself if no numeric prefix', () => {
+ let seq = new KeySequence([
+ new Key({ key: 'g' }), new Key({ key: 'g' }) , new Key({ key: '3' }) ,
+ ]).trimNumericPrefix();
+
+ expect(seq.keys.map(key => key.key)).to.deep.equal(['g', 'g', '3']);
+ });
+ });
+
+ describe('#splitNumericPrefix', () => {
+ it('splits numeric prefix', () => {
+ expect(KeySequence.fromMapKeys('10gg').splitNumericPrefix()).to.deep.equal([
+ KeySequence.fromMapKeys('10'),
+ KeySequence.fromMapKeys('gg'),
+ ]);
+ expect(KeySequence.fromMapKeys('10').splitNumericPrefix()).to.deep.equal([
+ KeySequence.fromMapKeys('10'),
+ new KeySequence([]),
+ ]);
+ expect(KeySequence.fromMapKeys('gg').splitNumericPrefix()).to.deep.equal([
+ new KeySequence([]),
+ KeySequence.fromMapKeys('gg'),
+ ]);
+ });
+ });
+
+ describe('#fromMapKeys', () => {
+ it('returns mapped keys for Shift+Esc', () => {
+ let keys = KeySequence.fromMapKeys('<S-Esc>').keys;
+ expect(keys).to.have.lengthOf(1);
+ expect(keys[0].key).to.equal('Esc');
+ expect(keys[0].shift).to.be.true;
+ });
+
+ it('returns mapped keys for a<C-B><A-C>d<M-e>', () => {
+ let keys = KeySequence.fromMapKeys('a<C-B><A-C>d<M-e>').keys;
+ expect(keys).to.have.lengthOf(5);
+ expect(keys[0].key).to.equal('a');
+ expect(keys[1].ctrl).to.be.true;
+ expect(keys[1].key).to.equal('b');
+ expect(keys[2].alt).to.be.true;
+ expect(keys[2].key).to.equal('c');
+ expect(keys[3].key).to.equal('d');
+ expect(keys[4].meta).to.be.true;
+ expect(keys[4].key).to.equal('e');
+ });
+ })
+});
diff --git a/test/content/usecases/KeymapUseCase.test.ts b/test/content/usecases/KeymapUseCase.test.ts
index 5f2feba..598d5a3 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({ repeat: 1, op: {type: 'scroll.vertically', count: -1}});
+ expect(sut.nextOps(Key.fromMapKey('j'))).to.deep.equal({ repeat: 1, op: {type: 'scroll.vertically', count: 1}});
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal({ repeat: 1, op: {type: 'scroll.top'}});
+ expect(sut.nextOps(Key.fromMapKey('z'))).to.be.null;
+ });
+
+ it('repeats n-times by numeric prefix and multiple key operations', () => {
+ expect(sut.nextOps(Key.fromMapKey('1'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('0'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal({ repeat: 10, op: {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.null;
+ expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal({ repeat: 1, op: { type: 'scroll.bottom'}});
+ });
+
+ it('returns an operation matched the operation with digit keymaps', () => {
+ expect(sut.nextOps(Key.fromMapKey('2'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('0'))).to.be.deep.equal({ repeat: 1, op: { type: 'scroll.top'}});
+ });
+
+ it('returns operations repeated by numeric prefix', () => {
+ expect(sut.nextOps(Key.fromMapKey('2'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal({ repeat: 2, op: { type: 'scroll.bottom'}});
+ });
+
+ it('does not matches with digit operation with numeric prefix', () => {
+ expect(sut.nextOps(Key.fromMapKey('3'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('2'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('0'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('5'))).to.be.deep.equal({ repeat: 320, op: { 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.null;
+ expect(sut.nextOps(Key.fromMapKey('x'))).to.be.null; // clear
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal({repeat: 1, op: {type: "scroll.top"}});
+ });
+
+ it('clears input keys and the prefix with no-matched operations', () => {
+ expect(sut.nextOps(Key.fromMapKey('1'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('0'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('x'))).to.be.null; // clear
+ expect(sut.nextOps(Key.fromMapKey('1'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('0'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal({ repeat: 10, op: {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.null;
+ expect(sut.nextOps(Key.fromMapKey('a'))).to.deep.equal({ repeat: 1, op: {type: 'addon.enable'}});
+ expect(sut.nextOps(Key.fromMapKey('b'))).to.deep.equal({ repeat: 1, op: {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({ repeat: 1, op: {type: 'scroll.vertically', count: -1}});
+ expect(sut.nextOps(Key.fromMapKey('j'))).to.deep.equal({ repeat: 1, op: {type: 'scroll.vertically', count: 1}});
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.be.null;
+ expect(sut.nextOps(Key.fromMapKey('G'))).to.deep.equal({ repeat: 1, op: {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.null;
+ expect(sut.nextOps(Key.fromMapKey('g'))).to.deep.equal({ repeat: 1, op: {type: 'scroll.top'}});
+ expect(sut.nextOps(Key.fromMapKey('G'))).to.be.null;
+ });
});
});
diff --git a/test/shared/settings/Key.test.ts b/test/shared/settings/Key.test.ts
index 8222d5a..91a47f8 100644
--- a/test/shared/settings/Key.test.ts
+++ b/test/shared/settings/Key.test.ts
@@ -76,17 +76,30 @@ describe("Key", () => {
});
});
+ describe('idDigit', () => {
+ it('returns true if the key is a digit', () => {
+ expect(new Key({ key: '0' }).isDigit()).to.be.true;
+ expect(new Key({ key: '9' }).isDigit()).to.be.true;
+ expect(new Key({ key: '9', shift: true }).isDigit()).to.be.true;
+
+ expect(new Key({ key: 'a' }).isDigit()).to.be.false;
+ expect(new Key({ key: '0' }).isDigit()).to.be.false;
+ })
+ });
+
describe('equals', () => {
- expect(new Key({
- key: 'x', shift: false, ctrl: true, alt: false, meta: false,
- }).equals(new Key({
- key: 'x', shift: false, ctrl: true, alt: false, meta: false,
- }))).to.be.true;
+ it('returns true if the keys are equivalent', () => {
+ expect(new Key({
+ key: 'x', shift: false, ctrl: true, alt: false, meta: false,
+ }).equals(new Key({
+ key: 'x', shift: false, ctrl: true, alt: false, meta: false,
+ }))).to.be.true;
- expect(new Key({
- key: 'x', shift: false, ctrl: false, alt: false, meta: false,
- }).equals(new Key({
- key: 'X', shift: true, ctrl: false, alt: false, meta: false,
- }))).to.be.false;
+ expect(new Key({
+ key: 'x', shift: false, ctrl: false, alt: false, meta: false,
+ }).equals(new Key({
+ key: 'X', shift: true, ctrl: false, alt: false, meta: false,
+ }))).to.be.false;
+ })
});
});
diff --git a/test/shared/settings/KeySequence.test.ts b/test/shared/settings/KeySequence.test.ts
deleted file mode 100644
index 361cbd1..0000000
--- a/test/shared/settings/KeySequence.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import KeySequence from '../../../src/shared/settings/KeySequence';
-import { expect } from 'chai'
-import Key from "../../../src/shared/settings/Key";
-
-describe("KeySequence", () => {
- describe('#push', () => {
- it('append a key to the sequence', () => {
- let seq = new KeySequence([]);
- seq.push(Key.fromMapKey('g'));
- seq.push(Key.fromMapKey('<S-U>'));
-
- expect(seq.keys[0].key).to.equal('g');
- expect(seq.keys[1].key).to.equal('U');
- expect(seq.keys[1].shift).to.be.true;
- })
- });
-
- describe('#startsWith', () => {
- it('returns true if the key sequence starts with param', () => {
- let seq = new KeySequence([
- Key.fromMapKey('g'),
- Key.fromMapKey('<S-U>'),
- ]);
-
- expect(seq.startsWith(new KeySequence([
- ]))).to.be.true;
- expect(seq.startsWith(new KeySequence([
- Key.fromMapKey('g'),
- ]))).to.be.true;
- expect(seq.startsWith(new KeySequence([
- Key.fromMapKey('g'), Key.fromMapKey('<S-U>'),
- ]))).to.be.true;
- expect(seq.startsWith(new KeySequence([
- Key.fromMapKey('g'), Key.fromMapKey('<S-U>'), Key.fromMapKey('x'),
- ]))).to.be.false;
- expect(seq.startsWith(new KeySequence([
- Key.fromMapKey('h'),
- ]))).to.be.false;
- });
-
- it('returns true if the empty sequence starts with an empty sequence', () => {
- let seq = new KeySequence([]);
-
- expect(seq.startsWith(new KeySequence([]))).to.be.true;
- expect(seq.startsWith(new KeySequence([
- Key.fromMapKey('h'),
- ]))).to.be.false;
- })
- });
-
- describe('#fromMapKeys', () => {
- it('returns mapped keys for Shift+Esc', () => {
- let keys = KeySequence.fromMapKeys('<S-Esc>').keys;
- expect(keys).to.have.lengthOf(1);
- expect(keys[0].key).to.equal('Esc');
- expect(keys[0].shift).to.be.true;
- });
-
- it('returns mapped keys for a<C-B><A-C>d<M-e>', () => {
- let keys = KeySequence.fromMapKeys('a<C-B><A-C>d<M-e>').keys;
- expect(keys).to.have.lengthOf(5);
- expect(keys[0].key).to.equal('a');
- expect(keys[1].ctrl).to.be.true;
- expect(keys[1].key).to.equal('b');
- expect(keys[2].alt).to.be.true;
- expect(keys[2].key).to.equal('c');
- expect(keys[3].key).to.equal('d');
- expect(keys[4].meta).to.be.true;
- expect(keys[4].key).to.equal('e');
- });
- })
-});