An example of capturing keystrokes


#1

I am looking for some sample code that demonstrates capturing keystrokes in an atom package. I would like to be able to get just the changes (inserts/deletes) after every keystroke or cut and paste along with the line numbers and column numbers where they happened.

Is this possible in an atom package? Are there any examples that someone can point me to?


#2

Use TextEditor::onDidStopChanging.


#3

How do you get the inserts/deletes that were made in the latest change? I also need to know where those changes were in the file.


#4

I’m pretty sure that is all available in the event. Have you looked it up in the API?

There is another event that happens immediately after each keystroke but that should be avoided in general for performance.


#5

I am looking at the api docs here for onDidStopChanging() but I don’t see any info about the event that gets passed to the function.

I added an event to the function and played around with the debugger, and I can see the event object, but I am wondering where the documentation is on the event is?


#6

If it isn’t in the docs for onDidStopChanging then it is apparently not documented. Or I could be wrong about it returning the info. I know I’ve done this before but I can’t find the partially implemented project from two years ago which used this info. I’ll keep looking. Surely someone else here has watched the keys before.

Another alternative is to look at the core code for onDidStopChanging.


#7

TextEditor.onDidChange and TextEditor.onDidStopChanging both call through to TextBuffer.onDidChange and TextBuffer.onDidStopChanging. It looks like the latest iteration of onDidStopChanging returns a list of change objects:


#8

UPDATE: I added a new post below that describes a different (and better documented) approach to this problem!

My apologies for this long post but I could not find the documentation I needed so I have tried to reverse engineer what is happening in the Atom text editor when text is entered. I am brand new to Atom package development and coffeescript so if anyone notices something that is wrong let me know and I will update this post. I have done my best to understand what is going on but I may be wrong- please don’t judge me :slight_smile:

First, my example code and then I decompose it in detail below:

    #observe all existing and new text editors
    @subscriptions.add atom.workspace.observeTextEditors (editor) =>

        #add an event handler to the editor to get updates about text changes
        @subscriptions.add editor.onDidStopChanging (changeEvents) =>

            #changeEvents is an array of objects that holds changes from different cursors
            console.log "Number of cursors: #{changeEvents.length}"

            #for each of the cursors
            for cursor in changeEvents

                #each cursor's 'newText' holds any new text that was added or an empty string if this is a delete
                #if this is an insert
                if cursor.newText.length > 0

                    #cursor.start.row is the row where the insert began
                    #cursor.start.column is the column where the insert began

                    #all changes are made to the cursors in order. This will affect starting row/column values.
                    #the second cursor's start row/column will be affected by the first cursor's insert/delete, etc.

                    #get the starting position of the insert
                    startingRow = cursor.start.row
                    startingColumn = cursor.start.column

                    #newExtent.row is the number of rows that have been added for this insert (0 if the insert all happened on the same line)
                    #newExtent.column is the number of columns moved to the right (or characters added) for this insert if the insert happened on the same line (newExtent.row is 0)
                    #newExtent.column is the ending column number after the insert if this was a multiline insert (newExtent.row is > 0)

                    #get the number of rows moved and add to the starting row for the ending row number
                    endingRow = cursor.start.row + cursor.newExtent.row

                    #if the insert happened all on a single line
                    if cursor.newExtent.row is 0

                        #add the starting column to the number of characters added to get where the insert ends
                        endingColumn = cursor.start.column + cursor.newExtent.column

                    else #multiline insert

                        #this is the column where the insert ends on the new line
                        endingColumn = cursor.newExtent.column

                    #the cursor's 'oldExtent' tells us if any text was selected and is being replace in this insert
                    if cursor.oldExtent.row > 0 or cursor.oldExtent.column > 0

                        #some text is being removed and replaced with the new text
                        replacedText = true

                        #the starting position of the text being removed
                        removedTextStartRow = cursor.start.row
                        removedTextStartColumn = cursor.start.column

                        #the end row is the sum of the start row and the number of additional rows selected
                        removedTextEndRow = cursor.start.row + cursor.oldExtent.row

                        #if the selected text was on a single line
                        if cursor.oldExtent.row is 0

                            #the end column is the start column plus the number of characters selected
                            removedTextEndColumn = cursor.start.column + cursor.oldExtent.column

                        else #multiple lines selected 

                            #in a multiline selected text the end column is the old extent column Number
                            removedTextEndColumn = cursor.oldExtent.column

                    #if there was some text replaced, log it
                    if replacedText
                        console.log "Replacing some text from row: #{removedTextStartRow} col: #{removedTextStartColumn} to row: #{removedTextEndRow} col: #{removedTextEndColumn}"

                    #log what was inserted
                    console.log "Inserted #{cursor.newText.length} characters (#{cursor.newText}) starting at position row: #{startingRow} col: #{startingColumn} and ending at row: #{endingRow} col: #{endingColumn}"

                else #nothing in the cursor's 'newText', it is a delete

                    #get the starting position of the delete
                    startingRow = cursor.start.row
                    startingColumn = cursor.start.column

                    #get the number of rows removed and add to the starting row for the ending row number
                    endingRow = cursor.start.row + cursor.oldExtent.row

                    #if the delete happened all on a single line
                    if cursor.oldExtent.row is 0

                        #add the starting column to the number of characters deleted to get where the delete ends
                        endingColumn = cursor.start.column + cursor.oldExtent.column

                    else #multiline delete

                        #this is the column where the delete ends on the new line
                        endingColumn = cursor.oldExtent.column

                    #the cursor's newExtent is not used in deletes

                    #log what was deleted
                    console.log "Deleted text starting at position row: #{startingRow} col: #{startingColumn} and ending at row: #{endingRow} col: #{endingColumn}"

            #print the whole object
            console.log cursor for cursor in changeEvents

This line:

atom.workspace.observeTextEditors (editor) =>

registers an event handler for all open editors and any new editors that are created in the future.

Side note: if you want this handler to work from the time Atom starts, you have to remove any activation command declarations from your package.json. If you are using the ‘package generator’ that comes with Atom it will add some to the file that you should remove. This took me a while to figure out. My code would only run after I ran the predefined command from the generated code.

This line:

editor.onDidStopChanging (changeEvents) =>

registers an event handler for whenever the underlying Text Buffer has changed. A Text Buffer is a container of text that one or more Text Editors may display. I believe the Text Editor just passes the anonymous function to the underlying Text Buffer’s onDidStopChanging method.

The callback function is not called for every single keystroke. Instead, Atom waits for 300ms of inactivity before collecting all of the changes and sending them to the function. This is done to improve performance.

The function gets passed an array that I am calling changeEvents. This array holds the changes that occurred in the editor since the last bit of inactivity. I could not find much good info on what was in here so I attempted to work it out.


Here are the things that can happen in an editor:

  • a single character is inserted
  • a single character is deleted
  • a group of characters is inserted (from a paste, for example)
  • a group of characters is deleted (from a cut, for example)
  • a group of selected text is removed and replaced with some new text (a delete followed by an insert)
  • all of the above with more than one cursor

My goal was to find out where each of these things happened in an editor.


To find out how many active cursors there are you can ask for the array’s length:

console.log "Number of cursors: #{changeEvents.length}"

and then iterate over them:

for cursor in changeEvents

Here, the ‘cursor’ object really represents a change at a cursor.

To find out if the change is an insert or a delete you can look at the cursor’s newText member. With inserts, there will be some text in here. On deletes, there will not:

if cursor.newText.length > 0
    #this is an insert
else
    #this is a delete

Each cursor change object, whether it is an insert or a delete, has these properties:

  • start
  • newExtent
  • oldExtent

Each one of these objects has a row and column property. Here is what I believe each of them mean:

Insert
An insert’s start.row: this is the row number of the first character of the insert

An insert’s start.column: this is the column number of the first character of the insert

(There is no length property for an insert change object but you can ask for the newText’s length to find out how many characters were inserted)


An insert’s newExtent.row: this is the number of additional rows that have been added as a result of the insert. If all of the changes happened on a single line then this value will be 0. If an insert spanned 2 lines this value would be 1 (one more line in addition to the first).

An insert’s newExtent.column: this property can mean one of two things.
If the insert occurred all on one line then this is the number of new characters that have been added on the one line.
If the insert spanned more than one line then this is the position of the last inserted character on the last inserted line.


oldExtent is only used in an insert when there is some text that was selected when the insert happened. The selected code is removed and then the new text is added. oldExtent describes where the selected code was.

An insert’s oldExtent.row: this holds the number of selected lines in addition to the first one. If only 1 line is selected this will be 0. If 2 lines were selected this value would be 1. If 7 lines of code were selected this would be 6.

An insert’s oldExtent.column: this property can mean one of two things.
If the selected text is all on one line then this holds the number of characters that were selected on the one line.
If the selected text spans more than one line this holds the column number of the last selected item on the last selected line.

The start object’s row and column will always hold the start of the selected text. The oldExtent is used to find out where the selected text ends.


Delete
A delete’s start.row: this is the row number of the first character deleted

A delete’s start.column: this is the column number of the first character deleted


A delete’s newExtent.row: not used in deletes.

A delete’s newExtent.column: not used in deletes.


A delete’s oldExtent.row: this holds the number of additional lines (other than the first) that were deleted. 0 means all the deleted characters were on the same line. A value of 1 means that the delete spanned 2 lines. A value of 6 means that the delete spanned 7 lines.

A delete’s oldExtent.column:this property can mean one of two things.
If the delete occurred all on one line then this is the number of characters that were deleted on the one line.
If the delete spanned more than one line then this is the position of the last deleted character on the last line.


#9

After a little more digging I found an alternate (and better documented) solution for getting the changes I was looking for:

#observe all existing and new text editors
@subscriptions.add atom.workspace.observeTextEditors (editor) =>

    @subscriptions.add editor.getBuffer().onDidChange (changeEvent) =>

        #if there was some new text inserted it will be stored in the event's newText member
        if changeEvent.newText.length > 0

            #record where the new text is being inserted
            startingRow = changeEvent.newRange.start.row
            startingColumn = changeEvent.newRange.start.column

            endingRow = changeEvent.newRange.end.row
            endingColumn = changeEvent.newRange.end.column

            #if there is some text being replaced by this new text
            if changeEvent.oldText.length > 0
                replacedText = true

                #get the position of where the old code is
                removedTextStartRow = changeEvent.oldRange.start.row
                removedTextStartColumn = changeEvent.oldRange.start.column

                removedTextEndRow = changeEvent.oldRange.end.row
                removedTextEndColumn = changeEvent.oldRange.end.column

            #if there was some text replaced, log it
            if replacedText
                console.log "Replacing #{changeEvent.oldText.length} characters (#{changeEvent.oldText}) from row: #{removedTextStartRow} col: #{removedTextStartColumn} to row: #{removedTextEndRow} col: #{removedTextEndColumn}"

            #log what was inserted
            console.log "Inserted #{changeEvent.newText.length} characters (#{changeEvent.newText}) starting at position row: #{startingRow} col: #{startingColumn} and ending at row: #{endingRow} col: #{endingColumn}"

        else #if newText is empty then this is a delete

            #record where the deleted text is
            startingRow = changeEvent.oldRange.start.row
            startingColumn = changeEvent.oldRange.start.column

            endingRow = changeEvent.oldRange.end.row
            endingColumn = changeEvent.oldRange.end.column

            #log what was deleted
            console.log "Deleted #{changeEvent.oldText.length} characters (#{changeEvent.oldText}) starting at position row: #{startingRow} col: #{startingColumn} and ending at row: #{endingRow} col: #{endingColumn}"

        console.log changeEvent

The big change is to use the TextBuffer’s onDidChange() rather than the TextEditor’s onDidStopChanging(). onDidChange is called before any edits are made and gives you access to any selected code that will be replaced.

Rather than dealing with cursor’s explicitly (like I did in the first solution) onDidChange is called separately, and in order, for each cursor.

One downside is that changes are not buffered until 300ms of inactivity. If that is important (you are going to do a lot of work) then you should probably go with the first solution I mentioned.


#10

I think you have it backwards. It is onDidStopChanging that buffers and waits for inactivity.