diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2019-12-22 10:47:00 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-12-22 10:47:00 +0900 |
commit | b2dcdedad729ff7087867da50e20578f9fc8fb29 (patch) | |
tree | 033ecffbd7db9b6db8000464a68d748fcae1dc3d /src/content | |
parent | 3c7230c3036e8bb2b2e9a752be9b0ef4a0a7349d (diff) | |
parent | 75f86907fc2699c0f0661d4780c38249a18f849b (diff) |
Merge pull request #689 from ueokande/n-times-repeat-operations
Repeat commands n-times
Diffstat (limited to 'src/content')
-rw-r--r-- | src/content/client/OperationClient.ts | 6 | ||||
-rw-r--r-- | src/content/controllers/KeymapController.ts | 125 | ||||
-rw-r--r-- | src/content/domains/KeySequence.ts | 91 | ||||
-rw-r--r-- | src/content/repositories/KeymapRepository.ts | 2 | ||||
-rw-r--r-- | src/content/usecases/KeymapUseCase.ts | 68 |
5 files changed, 194 insertions, 98 deletions
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/content/domains/KeySequence.ts b/src/content/domains/KeySequence.ts new file mode 100644 index 0000000..4534b60 --- /dev/null +++ b/src/content/domains/KeySequence.ts @@ -0,0 +1,91 @@ +import Key from '../../shared/settings/Key'; + +export default class KeySequence { + constructor( + public readonly keys: Key[], + ) { + } + + push(key: Key): number { + return this.keys.push(key); + } + + length(): number { + return this.keys.length; + } + + startsWith(o: KeySequence): boolean { + if (this.keys.length < o.keys.length) { + return false; + } + for (let i = 0; i < o.keys.length; ++i) { + if (!this.keys[i].equals(o.keys[i])) { + return false; + } + } + 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[], + ): Key[] => { + if (remaining.length === 0) { + return mappedKeys; + } + + let nextPos = 1; + if (remaining.startsWith('<')) { + let ltPos = remaining.indexOf('>'); + if (ltPos > 0) { + nextPos = ltPos + 1; + } + } + + return fromMapKeysRecursive( + remaining.slice(nextPos), + mappedKeys.concat([Key.fromMapKey(remaining.slice(0, nextPos))]) + ); + }; + + let data = fromMapKeysRecursive(keys, []); + return new KeySequence(data); + } +} 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 { |