From 8cb9c4e46ddb95aee6c490a30823401d2452c569 Mon Sep 17 00:00:00 2001 From: tshino Date: Sun, 16 Jan 2022 12:56:26 +0900 Subject: [PATCH 1/5] Add end_of_file_detector.js --- src/end_of_file_detector.js | 42 +++++++++++ test/suite/end_of_file_detector.test.js | 94 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/end_of_file_detector.js create mode 100644 test/suite/end_of_file_detector.test.js diff --git a/src/end_of_file_detector.js b/src/end_of_file_detector.js new file mode 100644 index 00000000..fc837dc5 --- /dev/null +++ b/src/end_of_file_detector.js @@ -0,0 +1,42 @@ +'use strict'; + +const endOfFileDetectorUtil = (function() { + const getCursorPosition = function(textEditor) { + return textEditor.selections[textEditor.selections.length - 1].active; + }; + const calculateDistanceBelow = function(textEditor) { + if (!textEditor) { + return [0, 0]; + } + const lineCount = textEditor.document.lineCount; + const currentLine = getCursorPosition(textEditor).line; + const lineLength = textEditor.document.lineAt(currentLine).text.length; + const currentChar = getCursorPosition(textEditor).character; + return [ + Math.max(0, lineCount - 1 - currentLine), + Math.max(0, lineLength - currentChar) + ]; + }; + const compareDistance = function(a, b) { + if (a[0] < b[0]) { + return -1; + } else if (a[0] > b[0]) { + return 1; + } + if (a[1] < b[1]) { + return -1; + } else if (a[1] > b[1]) { + return 1; + } + return 0; + }; + return { + getCursorPosition, + calculateDistanceBelow, + compareDistance + }; +})(); + +module.exports = { + endOfFileDetectorUtil +}; diff --git a/test/suite/end_of_file_detector.test.js b/test/suite/end_of_file_detector.test.js new file mode 100644 index 00000000..40869af0 --- /dev/null +++ b/test/suite/end_of_file_detector.test.js @@ -0,0 +1,94 @@ +'use strict'; +const assert = require('assert'); +const vscode = require('vscode'); +const { endOfFileDetectorUtil } = require('../../src/end_of_file_detector.js'); + +describe('endOfFileDetectorUtil', () => { + describe('getCursorPosition', () => { + const getCursorPosition = endOfFileDetectorUtil.getCursorPosition; + it('should return the cursor position', () => { + const Input = { selections: [ new vscode.Selection(3, 5, 3, 5) ] }; + const Expected = new vscode.Position(3, 5); + assert.strictEqual(getCursorPosition(Input).isEqual(Expected), true); + }); + it('should return the active position of current selection range (1)', () => { + const Input = { selections: [ new vscode.Selection(3, 5, 6, 8) ] }; + const Expected = new vscode.Position(6, 8); + assert.strictEqual(getCursorPosition(Input).isEqual(Expected), true); + }); + it('should return the active position of current selection range (2)', () => { + const Input = { selections: [ new vscode.Selection(6, 8, 3, 5) ] }; + const Expected = new vscode.Position(3, 5); + assert.strictEqual(getCursorPosition(Input).isEqual(Expected), true); + }); + it('should return the position of the last cursor in multi-cursor (1)', () => { + const Input = { selections: [ + new vscode.Selection(1, 4, 1, 4), + new vscode.Selection(2, 4, 2, 4), + new vscode.Selection(3, 4, 3, 4) + ] }; + const Expected = new vscode.Position(3, 4); + assert.strictEqual(getCursorPosition(Input).isEqual(Expected), true); + }); + it('should return the position of the last cursor in multi-cursor (2)', () => { + const Input = { selections: [ + new vscode.Selection(3, 4, 3, 4), + new vscode.Selection(2, 4, 2, 4), + new vscode.Selection(1, 4, 1, 4) + ] }; + const Expected = new vscode.Position(1, 4); + assert.strictEqual(getCursorPosition(Input).isEqual(Expected), true); + }); + }); + describe('calculateDistanceBelow', () => { + const calculateDistanceBelow = endOfFileDetectorUtil.calculateDistanceBelow; + it('should return [0, 0] if the editor is null', () => { + assert.deepStrictEqual(calculateDistanceBelow(null), [0, 0]); + }); + it('should return the number of lines below the cursor and the number of characters right of the cursor', () => { + const editorMock = { + document: { + lineCount: 10, + lineAt: () => ({ text: ' '.repeat(15) }) + }, + selections: [ + new vscode.Selection(5, 3, 5, 3) + ] + }; + assert.deepStrictEqual(calculateDistanceBelow(editorMock), [4, 12]); + }); + it('should return [0, 0] if the cursor is at the end of the document', () => { + const editorMock = { + document: { + lineCount: 10, + lineAt: () => ({ text: ' '.repeat(15) }) + }, + selections: [ + new vscode.Selection(9, 15, 9, 15) + ] + }; + assert.deepStrictEqual(calculateDistanceBelow(editorMock), [0, 0]); + }); + }); + describe('compareDistance', () => { + const compareDistance = endOfFileDetectorUtil.compareDistance; + it('should return 0 if two arguments are the same distance', () => { + assert.strictEqual(compareDistance([3, 4], [3, 4]), 0); + assert.strictEqual(compareDistance([0, 1], [0, 1]), 0); + assert.strictEqual(compareDistance([10, 0], [10, 0]), 0); + assert.strictEqual(compareDistance([8, 5], [8, 5]), 0); + }); + it('should return a positive integer if first one is greater', () => { + assert.strictEqual(compareDistance([4, 1], [3, 1]) > 0, true); + assert.strictEqual(compareDistance([4, 5], [3, 7]) > 0, true); + assert.strictEqual(compareDistance([4, 7], [3, 5]) > 0, true); + assert.strictEqual(compareDistance([5, 8], [5, 4]) > 0, true); + }); + it('should return a negative integer if first one is smaller', () => { + assert.strictEqual(compareDistance([3, 1], [4, 1]) < 0, true); + assert.strictEqual(compareDistance([3, 7], [4, 5]) < 0, true); + assert.strictEqual(compareDistance([3, 5], [4, 7]) < 0, true); + assert.strictEqual(compareDistance([5, 4], [5, 8]) < 0, true); + }); + }); +}); From 6a21db735e13d383791b8eed228803cb7bb1994a Mon Sep 17 00:00:00 2001 From: tshino Date: Sun, 16 Jan 2022 13:01:14 +0900 Subject: [PATCH 2/5] Add EndOfFileDetector class --- src/end_of_file_detector.js | 41 +++++++++++++ test/suite/end_of_file_detector.test.js | 78 ++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/end_of_file_detector.js b/src/end_of_file_detector.js index fc837dc5..0095862d 100644 --- a/src/end_of_file_detector.js +++ b/src/end_of_file_detector.js @@ -37,6 +37,47 @@ const endOfFileDetectorUtil = (function() { }; })(); +const EndOfFileDetector = function(textEditor) { + let lastDistanceBelow = endOfFileDetectorUtil.calculateDistanceBelow(textEditor); + + // whether distanceBelow[0] is predicted to decline or not + let belowLinesDeclines = null; + + const reachedEndOfFile = function() { + const distanceBelow = endOfFileDetectorUtil.calculateDistanceBelow(textEditor); + const compBelow = endOfFileDetectorUtil.compareDistance(distanceBelow, lastDistanceBelow); + if (distanceBelow[0] === 0 && distanceBelow[1] === 0) { + // it reached the end of the document + return true; + } + if (compBelow >= 0) { + // distance to the bottom of the document should always decline, otherwise we stop + return true; + } + if (belowLinesDeclines === null) { + belowLinesDeclines = distanceBelow[0] < lastDistanceBelow[0]; + } else if (belowLinesDeclines) { + if (distanceBelow[0] >= lastDistanceBelow[0]) { + // rest lines below the cursor should decline consistently, otherwise, we stop + return true; + } + if (distanceBelow[0] === 0) { + // it reached the last line of the document + return true; + } + } + lastDistanceBelow = distanceBelow; + return false; + }; + + return { + reachedEndOfFile + }; +}; + module.exports = { + EndOfFileDetector, + + // testing purpose only endOfFileDetectorUtil }; diff --git a/test/suite/end_of_file_detector.test.js b/test/suite/end_of_file_detector.test.js index 40869af0..d88971ce 100644 --- a/test/suite/end_of_file_detector.test.js +++ b/test/suite/end_of_file_detector.test.js @@ -1,7 +1,8 @@ 'use strict'; const assert = require('assert'); const vscode = require('vscode'); -const { endOfFileDetectorUtil } = require('../../src/end_of_file_detector.js'); +const { TestUtil } = require('./test_util.js'); +const { endOfFileDetectorUtil, EndOfFileDetector } = require('../../src/end_of_file_detector.js'); describe('endOfFileDetectorUtil', () => { describe('getCursorPosition', () => { @@ -92,3 +93,78 @@ describe('endOfFileDetectorUtil', () => { }); }); }); + +describe('EndOfFileDetector', () => { + let textEditor; + const setSelections = function(array) { + textEditor.selections = TestUtil.arrayToSelections(array); + }; + before(async () => { + vscode.window.showInformationMessage('Started test for EndOfFileDetector.'); + textEditor = await TestUtil.setupTextEditor({ content: '' }); + }); + + describe('reachedEndOfFile', () => { + beforeEach(async () => { + await TestUtil.resetDocument(textEditor, ( + '0. zero\n' + + '1. one\n' + + '2. two\n' + + '3. three\n' + + '4. four\n' + + '5. five' + )); + }); + it('should return true immediately if the cursor does not move at all', async () => { + setSelections([[2, 3]]); + const detector = EndOfFileDetector(textEditor); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true immediately if the cursor moves up', async () => { + setSelections([[2, 3]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[1, 3]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true immediately if the cursor moves to the left', async () => { + setSelections([[2, 3]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[2, 2]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true if it reaches the end of the document', async () => { + setSelections([[5, 5]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[5, 6]]); + assert.strictEqual(detector.reachedEndOfFile(), false); + setSelections([[5, 7]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true if it reaches the last line of the document', async () => { + setSelections([[3, 0]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[4, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), false); + setSelections([[5, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true if the rest lines below the cursor stops to decline', async () => { + setSelections([[1, 0]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[2, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), false); + setSelections([[3, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), false); + setSelections([[3, 8]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + it('should return true if it reaches the last line of the document', async () => { + setSelections([[3, 0]]); + const detector = EndOfFileDetector(textEditor); + setSelections([[4, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), false); + setSelections([[5, 0]]); + assert.strictEqual(detector.reachedEndOfFile(), true); + }); + }); +}); From 282be218e662386844d89fdb34f5c62853ae257d Mon Sep 17 00:00:00 2001 From: tshino Date: Sun, 16 Jan 2022 15:34:08 +0900 Subject: [PATCH 3/5] Add repeatPlaybackTillEndOfFile command --- package.json | 6 ++ src/extension.js | 1 + src/keyboard_macro.js | 24 ++++++- test/suite/playback_repeat.test.js | 105 +++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 test/suite/playback_repeat.test.js diff --git a/package.json b/package.json index 267385aa..4713432c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "onCommand:kb-macro.playback", "onCommand:kb-macro.abortPlayback", "onCommand:kb-macro.repeatPlayback", + "onCommand:kb-macro.repeatPlaybackTillEndOfFile", "onCommand:kb-macro.wrap" ], "main": "./src/extension.js", @@ -106,6 +107,11 @@ "command": "kb-macro.repeatPlayback", "title": "Repeat Playback", "category": "Keyboard Macro" + }, + { + "command": "kb-macro.repeatPlaybackTillEndOfFile", + "title": "Repeat Playback Till End of File", + "category": "Keyboard Macro" } ], "keybindings": [ diff --git a/src/extension.js b/src/extension.js index 7961200c..a11e3f36 100644 --- a/src/extension.js +++ b/src/extension.js @@ -34,6 +34,7 @@ function activate(context) { registerCommand('playback', keyboardMacro.playback); registerCommand('abortPlayback', keyboardMacro.abortPlayback); registerCommand('repeatPlayback', keyboardMacro.repeatPlayback); + registerCommand('repeatPlaybackTillEndOfFile', keyboardMacro.repeatPlaybackTillEndOfFile); registerCommand('wrap', keyboardMacro.wrap); keyboardMacro.registerInternalCommand('internal:performType', internalCommands.performType); diff --git a/src/keyboard_macro.js b/src/keyboard_macro.js index 2d68846e..c598d06a 100644 --- a/src/keyboard_macro.js +++ b/src/keyboard_macro.js @@ -1,6 +1,7 @@ 'use strict'; const vscode = require('vscode'); const { CommandSequence } = require('./command_sequence.js'); +const { EndOfFileDetector } = require('./end_of_file_detector.js'); const reentrantGuard = require('./reentrant_guard.js'); const KeyboardMacro = function({ awaitController }) { @@ -113,7 +114,7 @@ const KeyboardMacro = function({ awaitController }) { return ok; }; - const playbackImpl = async function(args) { + const playbackImpl = async function(args, { tillEndOfFile = false } = {}) { if (recording) { return; } @@ -123,14 +124,26 @@ const KeyboardMacro = function({ awaitController }) { args = (args && typeof(args) === 'object') ? args : {}; const repeat = typeof(args.repeat) === 'number' ? args.repeat : 1; const commands = sequence.get(); + let endOfFileDetector; + if (tillEndOfFile) { + endOfFileDetector = EndOfFileDetector(vscode.window.activeTextEditor); + } let ok = true; - for (let k = 0; k < repeat && ok && !shouldAbortPlayback; k++) { + for (let k = 0; k < repeat || tillEndOfFile; k++) { for (const spec of commands) { ok = await invokeCommandSync(spec, 'playback'); if (!ok || shouldAbortPlayback) { break; } } + if (!ok || shouldAbortPlayback) { + break; + } + if (tillEndOfFile) { + if (endOfFileDetector.reachedEndOfFile()) { + break; + } + } } } finally { const reason = shouldAbortPlayback ? @@ -169,6 +182,12 @@ const KeyboardMacro = function({ awaitController }) { } }); + const repeatPlaybackTillEndOfFile = reentrantGuard.makeGuardedCommand(async function() { + const args = {}; + const option = { tillEndOfFile: true }; + await playbackImpl(args, option); + }); + const makeCommandSpec = function(args) { if (!args || !args.command) { return null; @@ -230,6 +249,7 @@ const KeyboardMacro = function({ awaitController }) { abortPlayback, validatePositiveIntegerInput, repeatPlayback, + repeatPlaybackTillEndOfFile, wrap, // testing purpose only diff --git a/test/suite/playback_repeat.test.js b/test/suite/playback_repeat.test.js new file mode 100644 index 00000000..7d33bd98 --- /dev/null +++ b/test/suite/playback_repeat.test.js @@ -0,0 +1,105 @@ +'use strict'; +const assert = require('assert'); +const vscode = require('vscode'); +const util = require('../../src/util.js'); +const { TestUtil } = require('./test_util.js'); +const { CommandsToTest } = require('./commands_to_test.js'); +const { keyboardMacro, awaitController } = require('../../src/extension.js'); + +describe('Recording and Playback with Repeat', () => { + let textEditor; + const Cmd = CommandsToTest; + const Type = text => ({ command: 'internal:performType', args: { text } }); + + const setSelections = async function(array) { + await awaitController.waitFor('selection', 1).catch(() => {}); + const newSelections = TestUtil.arrayToSelections(array); + if (!util.isEqualSelections(textEditor.selections, newSelections)) { + const timeout = 1000; + textEditor.selections = newSelections; + await awaitController.waitFor('selection', timeout).catch( + () => { console.log('Warning: timeout in setSelections!'); } + ); + } + }; + const getSelections = function() { + return TestUtil.selectionsToArray(textEditor.selections); + }; + const record = async function(sequence) { + keyboardMacro.startRecording(); + for (let i = 0; i < sequence.length; i++) { + await keyboardMacro.wrap(sequence[i]); + } + keyboardMacro.finishRecording(); + }; + + before(async () => { + vscode.window.showInformationMessage('Started test for Recording and Playback with Repeat.'); + textEditor = await TestUtil.setupTextEditor({ content: '' }); + }); + + describe('repeatPlaybackTillEndOfFile', () => { + beforeEach(async () => { + await TestUtil.resetDocument(textEditor, ( + 'zero\n' + + 'one\n' + + 'two\n' + + 'three\n' + + 'four\n' + + 'five' + )); + }); + it('should join all lines with a comma', async () => { + const seq = [ Cmd.CursorEnd, Type(', '), Cmd.DeleteRight ]; + await setSelections([[0, 0]]); + await record(seq); + assert.strictEqual(textEditor.document.lineAt(0).text, 'zero, one'); + + await keyboardMacro.repeatPlaybackTillEndOfFile(); + + assert.strictEqual(textEditor.document.lineAt(0).text, 'zero, one, two, three, four, five'); + assert.deepStrictEqual(getSelections(), [[0, 29]]); // at 'f' of 'five' + }); + it('should join every two lines with a space', async () => { + const seq = [ Cmd.CursorEnd, Cmd.DeleteRight, Type(' '), Cmd.CursorEnd, Cmd.CursorRight ]; + await setSelections([[0, 0]]); + await record(seq); + assert.strictEqual(textEditor.document.lineAt(0).text, 'zero one'); + + await keyboardMacro.repeatPlaybackTillEndOfFile(); + + assert.strictEqual(textEditor.document.lineAt(1).text, 'two three'); + assert.strictEqual(textEditor.document.lineAt(2).text, 'four five'); + assert.deepStrictEqual(getSelections(), [[2, 9]]); // the end of the last line + }); + it('should quote all lines except the last line', async () => { + const seq = [ Type('"'), Cmd.CursorEnd, Type('"'), Cmd.CursorRight ]; + await setSelections([[0, 0]]); + await record(seq); + assert.strictEqual(textEditor.document.lineAt(0).text, '"zero"'); + + await keyboardMacro.repeatPlaybackTillEndOfFile(); + + // This may not be the expected result for the user, + // but for now, it is considered the best for safety. + assert.strictEqual(textEditor.document.lineAt(4).text, '"four"'); + assert.strictEqual(textEditor.document.lineAt(5).text, 'five'); + assert.deepStrictEqual(getSelections(), [[5, 0]]); + }); + it('should insert a blank line below every line except the last line', async () => { + const seq = [ Cmd.CursorDown, Cmd.Enter ]; + await setSelections([[0, 0]]); + + await record(seq); + assert.strictEqual(textEditor.document.lineAt(1).text, ''); + + await keyboardMacro.repeatPlaybackTillEndOfFile(); + + assert.strictEqual(textEditor.document.lineAt(8).text, 'four'); + assert.strictEqual(textEditor.document.lineAt(9).text, ''); + assert.strictEqual(textEditor.document.lineAt(10).text, 'five'); + assert.strictEqual(textEditor.document.lineCount, 11); + assert.deepStrictEqual(getSelections(), [[10, 0]]); // the beginning of the last line + }); + }); +}); From 11d5721abd17579d3da73ca02d33d1fca8043014 Mon Sep 17 00:00:00 2001 From: tshino Date: Sun, 16 Jan 2022 16:14:03 +0900 Subject: [PATCH 4/5] Remove redundant test --- test/suite/end_of_file_detector.test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/suite/end_of_file_detector.test.js b/test/suite/end_of_file_detector.test.js index d88971ce..fe587837 100644 --- a/test/suite/end_of_file_detector.test.js +++ b/test/suite/end_of_file_detector.test.js @@ -158,13 +158,5 @@ describe('EndOfFileDetector', () => { setSelections([[3, 8]]); assert.strictEqual(detector.reachedEndOfFile(), true); }); - it('should return true if it reaches the last line of the document', async () => { - setSelections([[3, 0]]); - const detector = EndOfFileDetector(textEditor); - setSelections([[4, 0]]); - assert.strictEqual(detector.reachedEndOfFile(), false); - setSelections([[5, 0]]); - assert.strictEqual(detector.reachedEndOfFile(), true); - }); }); }); From b6246fe346038eae228191242c1afcd0500d8439 Mon Sep 17 00:00:00 2001 From: tshino Date: Sun, 16 Jan 2022 16:19:49 +0900 Subject: [PATCH 5/5] Update CHANGELOG and README --- CHANGELOG.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d30edc..76008944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to the Keyboard Macro Bata extension will be documented in t ### [Unreleased] - Feature + - Added a new `Keyboard Macro: Repeat Playback Till End of File` command. [#34](https://github.com/tshino/vscode-kb-macro/issues/34) - Made the `kb-macro.wrap` command queueable to reduce input misses during recording. [#32](https://github.com/tshino/vscode-kb-macro/pull/32) - Documentation - Added 'Tips' section to the README. diff --git a/README.md b/README.md index ca860498..08027814 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ This is the default keybinding set for recording/playback of this extension. Cop | `Playback` | `kb-macro.playback` | Perform playback of the last recorded sequence | | `Abort Playback` | `kb-macro.abortPlayback` | Abort currently-running playback | | `Repeat Playback` | `kb-macro.repeatPlayback` | Perform playback specified number of times | +| `Repeat Playback Till End of File` | `kb-macro.repeatPlaybackTillEndOfFile` | Repeat playback until the cursor reaches the end of the file | The `kb-macro.repeatPlayback` command shows an input box to specify the number of times.