From 626c982331031fc34b58381d981b202a732d1bd8 Mon Sep 17 00:00:00 2001 From: Mehdi GHESH Date: Tue, 21 Jan 2020 22:18:12 +0100 Subject: [PATCH] Enhance the mecanism that removes whitespaces on newline insertion When the cursor reaches a line that contains only whitespaces, this line is stored and if an edition happens elsewhere, the stored lines are checked and cleaned if they still contains only whitespaces. This tries to copy SublimeText's feature --- lib/whitespace.js | 215 +++++++++++++++++++++++++++++++++++----- package.json | 5 + spec/whitespace-spec.js | 184 +++++++++++++++++++++++++++++----- 3 files changed, 355 insertions(+), 49 deletions(-) diff --git a/lib/whitespace.js b/lib/whitespace.js index bfbe9ce..360d3ec 100644 --- a/lib/whitespace.js +++ b/lib/whitespace.js @@ -6,6 +6,28 @@ module.exports = class Whitespace { constructor () { this.watchedEditors = new WeakSet() this.subscriptions = new CompositeDisposable() + /* This map is used to control cursors in multiple tab + The key is the TextEditor.id field, as it seems to be unique and stable + over buffer renaming + The value is an array of row positions. + When inserting text (whatever the text inserted), each row is checked + and cleaned. The set is cleared after each insertion + When moving the cursor, the old row position is stored + */ + this.mapOfOldCursorPositions = new Map() + /* This variable holds the status of the update process + When we are removing whitespaces lines, we don't want to process + onDidChange events generated by these removals + */ + this.currentlyRemovingWhitespacesLines = false + /* This variable is used to tell if during the last change we updated the + indentation or not. This is used to manage the history + */ + this.hasUpdatedIndentation = false + /* This variable is true when the text inserted is blank only */ + this.insertedTextIsBlank = false + + this.groupLastEventWasIndentUpdate = false this.subscriptions.add(atom.workspace.observeTextEditors(editor => { return this.handleEvents(editor) @@ -69,12 +91,93 @@ module.exports = class Whitespace { return this.subscriptions.dispose() } + // Retrieve the right array for the current TextEditor + getCurrentEditorArray () { + let id = atom.workspace.getActiveTextEditor().id + let mapOld = this.mapOfOldCursorPositions + if (!mapOld.has(id)) { + mapOld.set(id, []) + } + return mapOld.get(id) + } + + // Check that the row in the given buffer is eligible for being stored + // A row is considered eligible if it's not empty and if only blank filled + isBufferRowEligible (buffer, row) { + return buffer.lineLengthForRow(row) && buffer.isRowBlank(row) + } + + // Store a row in the current array, if it doesn't already exist (set-like storage) + appendRowCurrentBuffer (row) { + let curArray = this.getCurrentEditorArray() + if (curArray.indexOf(row) === -1) { + curArray.push(row) + } + } + + // This function actually cleans the rows that are only filled with blanks + // As it cleans the rows, it also clears the array storing the rows + updateAndClearCurrentBufferEmptyRows (buffer) { + // Flag set to true to tell other functions where the change event comes from + this.currentlyRemovingWhitespacesLines = true + // Group the changes into one history entity, so that several newlines can be + // undo/redo as one history event + this.hasUpdatedIndentation = 0 + buffer.transact(() => { + let curArray = this.getCurrentEditorArray() + while (curArray.length) { + let v = curArray.pop() + if (this.isBufferRowEligible(buffer, v)) { + atom.workspace.getActiveTextEditor().setIndentationForBufferRow(v, 0) + this.hasUpdatedIndentation = 1 + } + } + if (!this.hasUpdatedIndentation) { + buffer.abortTransaction() + } + }) + + // Unset the flag + this.currentlyRemovingWhitespacesLines = false + } + + // This function updates the rows saved when lines are added + // from : row where the insertion happened + // altered : number or lines added/removed + // If a saved-row does not exist anymore after a deletion occurs, this function + // will remove it + updateSavedRows (from, altered) { + let curArray = this.getCurrentEditorArray() + let i = 0 + while (i < curArray.length) { + if (curArray[i] > from) { + curArray[i] += altered + } + if (curArray[i] < 0) { + curArray.splice(i, 1) + } else { + ++i + } + } + } + + // Get the state of the ensureNoBlankLinesLeft configuration state + isNoBlankLinesLeftActivated (editor) { + return atom.config.get('whitespace.ensureNoBlankLinesLeft', { + scope: editor.getRootScopeDescriptor() + }) + } + handleEvents (editor) { - if (this.watchedEditors.has(editor)) return + if (this.watchedEditors.has(editor)) { + return + } + + let subArray = [] let buffer = editor.getBuffer() - let bufferSavedSubscription = buffer.onWillSave(() => { + subArray.push(buffer.onWillSave(() => { return buffer.transact(() => { let scopeDescriptor = editor.getRootScopeDescriptor() @@ -88,43 +191,105 @@ module.exports = class Whitespace { return this.ensureSingleTrailingNewline(editor) } }) - }) + })) + + subArray.push(editor.onDidDestroy(event => { + // Get rid of informations corresponding to the recently closed editor + this.mapOfOldCursorPositions.delete(editor.id) + })) - let editorTextInsertedSubscription = editor.onDidInsertText(function (event) { - if (event.text !== '\n') { + subArray.push(buffer.onDidChange(event => { + if (!this.isNoBlankLinesLeftActivated(editor)) { return } - if (!buffer.isRowBlank(event.range.start.row)) { + // If this event happened during our update, discard it + if (this.currentlyRemovingWhitespacesLines) { return } - let scopeDescriptor = editor.getRootScopeDescriptor() + // Update the rows depending on the changes reported (insertion/deletion) + let altered = [] + + event.changes.forEach(val => { + const row = val.newRange.start.row + // row : the row where the change occured + // alt : final number of lines added/removed + altered.push({ + row: row, + alt: (val.newRange.end.row - row) - (val.oldRange.end.row - val.oldRange.start.row) + }) + }) - if (atom.config.get('whitespace.removeTrailingWhitespace', { - scope: scopeDescriptor - })) { - if (!atom.config.get('whitespace.ignoreWhitespaceOnlyLines', { - scope: scopeDescriptor - })) { - return editor.setIndentationForBufferRow(event.range.start.row, 0) + // Keep the array sorted, so that we loop from the lowest row index + altered.sort((a, b) => { + if (a.row < b.row) { + return -1 + } + return a.row > b.row + }) + + altered.forEach((val) => this.updateSavedRows(val.row, val.alt)) + + this.updateAndClearCurrentBufferEmptyRows(buffer) + + if (this.hasUpdatedIndentation) { + if (this.groupLastEventWasIndentUpdate) { + buffer.groupLastChanges() } + buffer.groupLastChanges() + } + this.groupLastEventWasIndentUpdate = this.hasUpdatedIndentation && this.insertedTextIsBlank + })) + + subArray.push(editor.onWillInsertText(event => { + if (!this.isNoBlankLinesLeftActivated(editor)) { + return + } + + // based on TextBuffer.rowIsBlank source : test if string is blank. + this.insertedTextIsBlank = /\s/.test(event.text) + /* if the text about to be inserted is blank only and don't contains newline, do not try to save the rows + Maybe we could implement a TextBuffer.stringIsBlank... + */ + if (this.insertedTextIsBlank && /[^\n]/.test(event.text)) { + return + } + editor.getCursorBufferPositions().forEach(pos => { + this.appendRowCurrentBuffer(pos.row) + }) + })) + + subArray.push(editor.onDidChangeCursorPosition(event => { + if (!this.isNoBlankLinesLeftActivated(editor)) { + return } - }) - let editorDestroyedSubscription = editor.onDidDestroy(() => { - bufferSavedSubscription.dispose() - editorTextInsertedSubscription.dispose() - editorDestroyedSubscription.dispose() - this.subscriptions.remove(bufferSavedSubscription) - this.subscriptions.remove(editorTextInsertedSubscription) - this.subscriptions.remove(editorDestroyedSubscription) + // If the text changed, discard (onDidChange will be called) + if (event.textChanged) { + return + } + // If we moved on the current line, discard + if (event.oldBufferPosition.row === event.newBufferPosition.row) { + return + } + + this.hasUpdatedIndentation = 0 + this.appendRowCurrentBuffer(event.oldBufferPosition.row) + })) + + editor.onDidDestroy(() => { + subArray.forEach((sub) => { + sub.dispose() + this.subscriptions.remove(sub) + }) this.watchedEditors.delete(editor) }) - this.subscriptions.add(bufferSavedSubscription) - this.subscriptions.add(editorTextInsertedSubscription) - this.subscriptions.add(editorDestroyedSubscription) + subArray.forEach((sub) => { + this.subscriptions.add(sub) + }) + this.watchedEditors.add(editor) } diff --git a/package.json b/package.json index 075209d..c41dba8 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "type": "boolean", "default": true, "description": "If the buffer doesn't end with a newline character when it's saved, then append one. If it ends with more than one newline, remove all but one. To disable/enable for a certain language, use [syntax-scoped properties](https://github.com/atom/whitespace#readme) in your `config.cson`." + }, + "ensureNoBlankLinesLeft": { + "type": "boolean", + "default": true, + "description": "When a cursor leaves a line with blanks only, remove the blanks. To disable/enable for a certain language, use [syntax-scoped properties](https://github.com/atom/whitespace#readme) in your `config.cson`." } } } diff --git a/spec/whitespace-spec.js b/spec/whitespace-spec.js index 7020376..97bcd31 100644 --- a/spec/whitespace-spec.js +++ b/spec/whitespace-spec.js @@ -57,30 +57,6 @@ describe('Whitespace', () => { await editor.save() expect(editor.getText()).toBe('foo\r\nbar\r\n\r\nbaz\r\n') }) - - it('clears blank lines when the editor inserts a newline', () => { - // Need autoIndent to be true - editor.update({autoIndent: true}) - - // Create an indent level and insert a newline - editor.setIndentationForBufferRow(0, 1) - editor.insertText('\n') - expect(editor.getText()).toBe('\n ') - - // Undo the newline insert and redo it - editor.undo() - expect(editor.getText()).toBe(' ') - editor.redo() - expect(editor.getText()).toBe('\n ') - - // Test for multiple cursors, possibly without blank lines - editor.insertText('foo') - editor.insertText('\n') - editor.setCursorBufferPosition([1, 5]) // Cursor after 'foo' - editor.addCursorAtBufferPosition([2, 2]) // Cursor on the next line (blank) - editor.insertText('\n') - expect(editor.getText()).toBe('\n foo\n \n\n ') - }) }) describe("when 'whitespace.removeTrailingWhitespace' is false", () => { @@ -528,4 +504,164 @@ describe('Whitespace', () => { expect(editor.getSoftTabs()).toBe(true) }) }) + + describe("when 'whitespace.ensureNoBlankLinesLeft' is true", () => { + beforeEach(() => { + atom.config.set('whitespace.ensureNoBlankLinesLeft', true) + // Need autoIndent to be true + editor.update({ autoIndent: true }) + }) + + it('clears blank lines when the editor inserts a newline', () => { + // Create an indent level and insert a newline + editor.setIndentationForBufferRow(0, 1) + editor.insertText('\n') + expect(editor.getText()).toBe('\n ') + + // Undo the newline insert and redo it + editor.undo() + expect(editor.getText()).toBe(' ') + editor.redo() + expect(editor.getText()).toBe('\n ') + + // Test for multiple cursors, possibly without blank lines + editor.insertText('foo') + editor.insertText('\n') + editor.setCursorBufferPosition([1, 5]) // Cursor after 'foo' + editor.addCursorAtBufferPosition([2, 2]) // Cursor on the next line (blank) + editor.insertText('\n') + expect(editor.getText()).toBe('\n foo\n \n\n ') + }) + + // ------------------------------------------------------------------------- + // One cursor, starting at begining of the line + it('keeps empty line empty when leaving it', () => { + editor.setText('') + editor.insertText('\n') + expect(editor.getText()).toBe('\n') + + editor.undo() + expect(editor.getText()).toBe('') + editor.redo() + expect(editor.getText()).toBe('\n') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n') + }) + + // ------------------------------------------------------------------------- + // One cursor, starting at two spaces and a character + it('removes trailing blanks where the cursor was', () => { + editor.setText(' l') + editor.insertText('\n') + expect(editor.getText()).toBe(' l\n ') + editor.insertText('\n') + editor.insertText('\n') + expect(editor.getText()).toBe(' l\n\n\n ') + + editor.undo() + expect(editor.getText()).toBe(' l\n ') + editor.redo() + expect(editor.getText()).toBe(' l\n\n\n ') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1 l\n\n\n') + }) + + // ------------------------------------------------------------------------- + // One cursor, starting at two spaces + it('removes trailing blanks where the cursor was', () => { + editor.setText(' ') + editor.insertText('\n') + expect(editor.getText()).toBe('\n ') + + editor.undo() + expect(editor.getText()).toBe(' ') + editor.redo() + expect(editor.getText()).toBe('\n ') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n') + }) + + // ------------------------------------------------------------------------- + // Two cursors, starting at begining of lines + it('keeps empty line empty when leaving it', () => { + editor.setText('\n') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 0]) + editor.insertText('\n') + expect(editor.getText()).toBe('\n\n\n') + + editor.undo() + expect(editor.getText()).toBe('\n') + editor.redo() + expect(editor.getText()).toBe('\n\n\n') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n\n\n') + }) + + // ------------------------------------------------------------------------- + // Two cursors, first with spaces, second on begining of the line + it('removes trailing blanks where the cursors were', () => { + editor.setText('\n') + editor.setCursorBufferPosition([0, 0]) + editor.insertText(' ') + editor.addCursorAtBufferPosition([1, 0]) + editor.insertText('\n') + expect(editor.getText()).toBe('\n \n\n') + + editor.undo() + expect(editor.getText()).toBe(' \n') + editor.redo() + expect(editor.getText()).toBe('\n \n\n') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n\n\n') + }) + + // ------------------------------------------------------------------------- + // Two cursors, first on begining of the line, second with spaces + it('removes trailing blanks where the cursors were', () => { + editor.setText('\n ') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 2]) + editor.insertText('\n') + expect(editor.getText()).toBe('\n\n\n ') + + editor.undo() + expect(editor.getText()).toBe('\n ') + editor.redo() + expect(editor.getText()).toBe('\n\n\n ') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n\n\n') + }) + + // ------------------------------------------------------------------------- + // Two cursors, all with spaces + it('removes trailing blanks where the cursors were', () => { + editor.setText(' \n ') + editor.setCursorBufferPosition([0, 2]) + editor.addCursorAtBufferPosition([1, 2]) + editor.insertText('\n') + expect(editor.getText()).toBe('\n \n\n ') + + editor.undo() + expect(editor.getText()).toBe(' \n ') + editor.redo() + expect(editor.getText()).toBe('\n \n\n ') + + editor.setCursorBufferPosition([0, 0]) + editor.insertText('1') + expect(editor.getText()).toBe('1\n\n\n') + }) + }) })