Inside geoWrite – 9: Keyboard Handling

In the series about the internals of the geoWrite WYSIWYG text editor for the C64, this article discusses how the app consolidates keyboard input to keep up with fast typists.

Article Series

  1. The Overlay System
  2. Screen Recovery
  3. Font Management
  4. Zero Page
  5. Copy Protection
  6. Localization
  7. File Format and Pagination
  8. Copy & Paste
  9. Keyboard Handling ← this article

GEOS Keyboard Basics

GEOS does not use the keyboard driver in the C64’s ROM, but has its own implementation that uses ASCII-based 8 bit key codes: Codes $20 through $7F are the regular ASCII printable characters. All control keys (e.g. cursor keys) as well as non-ASCII keys (like “£”) are mapped into the control code space $00 through $1F:

Code Key Comment
$00 no key
$01 F1
$02 F2
$03 F3
$04 F4
$05 F5
$06 F6
$07 NO SCROLL C128 only
$08 CRSR← C64: SHIFT+CRSR→
$09 TAB C64: Ctrl+I
$0A LF C128 only
$0B ENTER C128 only
$0C unused
$0D unused
$0E F7
$0F F8
$10 CRSR↑ C64: SHIFT+CRSR↓
$11 CRSR↓
$12 HOME
$13 SHIFT+HOME “CLR”
$14
$15 UPARROW
$16 STOP
$17 SHIFT+STOP “RUN”
$18 £
$19 HELP C128 only
$1A ALT C128 only
$1B ESC C128 only
$1C SHIFT+DEL “INST”
$1D DEL
$1E CRSR→
$1F used internally

All this defines the codes $00-$7F, which accounts for seven bits. The uppermost bit (value of $80) indicates whether the “Commodore” modifier key has been pressed with the key. The Commodore key is used in GEOS for keyboard shortcuts, much like the Command key on the Macintosh.

Keyboard Buffer

The keyboard driver runs in the interrupt context, which is triggered 60 times per second by a timer. It scans the keyboard and puts new key codes at the end of a 16 byte queue: This way, no keys will be lost if the application can’t get to handling incoming keys immediately – as long as it’s not more than 16 keys behind.

The application can get the next key from the queue by calling GetNextChar. If there is a key in the queue, it will be removed from the queue and returned. Otherwise, a value of 0 will be returned.

All this is pretty much what’s going on in any operating system – including the C64’s original ROM in BASIC mode.

Callback

GEOS is an event-driven environment: The system’s “main loop” controls all execution and calls back the app based on events, like mouse clicks, key presses and timers.

The idiomatic way to handle the keyboard on GEOS is therefore to register for keyboard callbacks. On every main loop iteration, the system will check whether there is at least one key in the queue. If this is the case, it will dequeue the oldest key, store it in the system variable keyData and call the function pointer keyVector, which the app can set. If the vector is 0 (the default), no callback will happen and the key code will be discarded.

The app can then read the key code from keyData, handle it and return. If there are more keys in the queue, the main loop will call the vector again, with the next key code, in the next iteration.

Alternatively, the callback function can read more keys from the queue by repeatedly calling GetNextChar.

geoWrite

The core function of geoWrite is text input, so it needs to make sure it is responsive, and no key presses get lost.

And that’s tricky: On a 1 MHz CPU, redrawing several lines of proportional fonts can take seconds, so it is quite common that the app falls behind the user’s typing. So if the user types a character that leads to a few lines being redrawn, and the user types three characters in the meantime, these three characters may cause three redraws, in which time the user can type another nine characters. The app would fall more and more behind, and eventually drop characters.

To avoid this, the app has to catch up to the user’s typing. The good news is that once the app is behind on keys, it has several key codes to work with, so there are some optimizations that can be applied:

Consolidating Character Inserts

When inserting a single character, the text data after the cursor is moved up by one byte and the new character is added. Then, the screen is updated: The part of the current line to the right of the cursor is redrawn, and if the last word of the line overflows to the next line, that one has to be redrawn as well and so on.

The following animation shows this in action:

Instead of doing this work for every character in the keyboard buffer separately, a lot of work can be saved by doing it for all characters in one go:

  • Only move the buffer up once, by the number of characters in the buffer.
  • Copy the characters into the buffer.
  • Redraw only once.

This is basically the same as what happens when pasting text from a text scrap.

You can see the effect of this strategy on the animation above. The “a” key was held down on the keyboard, generating a fast stream of “a” key codes.

  • The first character is inserted, which triggers a redraw of just the line.
  • In the meantime, two more characters came in, which are added in one go. This triggers the redraw of the whole paragraph, in this case, since the word “Commodore” had to be moved to the next line.
  • This redraw was so expensive that seven more characters came in in the meantime. The line gets redrawn, and it does not overflow this time.
  • Because this redraw was cheap, only two more characters came in. Again, it causes a redraw of only the current line.

We see in practice that whenever more text needs to be redrawn, more keypresses are buffered, but the system catches up quickly.

All printable characters as well as line break and TAB characters can be combined into a single string to be inserted. All other keys have to be special cased.

DEL Characters

If the user types a character, and then hits the DEL key, the document will effectively be unchanged, but the text in memory was first moved up by one byte, then moved down by one byte again, and the screen was updated twice.

So if the keyboard buffer contains a DEL char, geoWrites removes the preceding character from the buffer. The two characters cancel each other out and no more work has to be done.

If a DEL is encountered while the buffer is empty, no character can be removed, but it’s not a character that can be inserted either. geoWrite then increments the count of DEL keys it has seen in the keyboard buffer that remained. There are some examples after the next paragraph.

Control Keys and Keyboard Shortcuts

Non-printable key codes like cursor keys and keyboard shortcuts are also special. If the queue contains “abc”, followed by C=T (Commodore Key + T; paste text) and “def”, geoWrite has to insert “abc”, paste the text in the text scrap, and then insert “def”. It can not combine the two text strings.

This is why whenever a control key or shortcut is encountered, geoWrite stops processing the queue. The DEL characters, the string so far and the control key will be evaluated, and the remainder of the keyboard queue will be handled once the keyVector is called the next time.

Examples

Here are some examples of contents of the keyboard queue, the number of DEL characters at the beginning, the string to be inserted, the control key that was detected, and the contents of the keyboard queue after this processing:

Kbd Queue Before # DEL Insert String Control Key Kbd Queue After
abc 0 abc
abc<DEL> 0 ab
abc<DEL>d 0 abd
abc<DEL><DEL>DEL> 0
abc<DEL><DEL>DEL><DEL> 1
<DEL>abc 1 abc
<DEL><DEL>abc 2 abc
a<DEL><DEL>bc 1 bc
a<C=T>bc 0 a C=T bc
a<DEL><DEL>bc<C=T><DEL>de 1 bc C=T <DEL>de

The examples show that DEL keys that follow a character will effectively remove that key, but if the number of DEL key codes exceeds the number of characters before it, the extra DEL keys will be counted. And if there is a control key or keyboard shortcut, processing of the keyboard queue stops at this point.

Code

This is the code that processes the keyboard queue and returns the number of characters to delete, the string to insert, and the detected control key:

processKbdQueue:  
        lda     #0  
        sta     delCount                ; no excess DEL keys so far  
        sta     kbdStringCnt            ; kbd string empty  
        sta     curControlKey           ; no control key

        lda     keyData                 ; first character, as passed into keyVector

@again: bmi     @ctrl                   ; "C=" shortcut, so stop processing

        cmp     #KEY_DELETE             ; DEL key?  
        beq     @del                    ; yes  
        cmp     #KEY_INSERT             ; SHIFT+DEL?  
        beq     @del                    ; yes (same as DEL in GEOS)

        cmp     #CR                     ; return key?  
        beq     @nctrl                  ; yes, does not count as a control key  
        cmp     #TAB                    ; TAB key?  
        beq     @nctrl                  ; yes, does not count as a control key

        cmp     #$20                    ; below $20, i.e. non-printable  
        bcc     @ctrl                   ; yes, it's a control key, stop processing

@nctrl: ldx     kbdStringCnt  
        sta     kbdString,x             ; add to kbd string  
        inc     kbdStringCnt  
@next:  jsr     GetNextChar             ; are there more characters in the kbd queue?  
        tax  
        beq     @rts                    ; no, return  
        bne     @again                  ; yes, repeat

@del:   ldx     kbdStringCnt            ; are there characters in the kbd string?  
        beq     @excss                  ; no, no characters to delete

        dec     kbdStringCnt            ; remove previous character  
        bra     @next                   ; and continue

@excss: inc     delCount                ; count excess delete characters  
        bne     @next                   ; and continue

@ctrl:  sta     curControlKey           ; save control key  
@rts:   rts

geoWrite’s keyVector handler calls this, and then applies the three outputs (DELs, string, control key) to the document:

  1. First, if there are excess DELs, it deletes characters at the current cursor position according to the number of DELs. It just moves the remainder of the buffer down by the number of DELs.
  2. Then, if there is a string, it inserts it in one go by moving the buffer up by the length of the string, and copying the string into the text.
  3. If there were DELs or a string, the screen is updated.
  4. If there is a control key, it gets evaluated.

There are two special cases: If there are more DELs than characters on the current page, the extra DELs have to be handled differently. And if there are DELs and a string, there is an optimization performed in the first two steps: There is no need to move the buffer twice. For example

  • if there is one DEL and two characters in the string, the buffer has to be moved up by one byte, and the insertion position is one byte before the cursor.
  • if there are two DELs and one character in the string, the buffer has to be moved down by one byte, and the insertion position is two bytes before the cursor.
  • if there are two DELs and two characters in the string, the buffer does not have to be moved at all, and the insertion position is two bytes before the cursor.

Conclusion

When designing software, a slow CPU can be made up for if there is a lot of memory, e.g. by caching data to avoid calculating it. And too little memory can be made up for by a fast CPU. GEOS and geoWrite had neither. They were written for an 8-bit CPU with 64 KB of RAM.

This scenario makes all design decisions interdependent:

  • The memory constraint requires all code to be optimized for size, which makes it slower, and requires geoWrite to be split into 9 parts that are loaded from the slow disk drive.
  • The font renderer could have been much faster, had it had the space for fastpath code and caches.
  • Slow font rendering requires geoWrite to include lots of extra logic to be able to keep up with fast typists.
  • The extra code increases the memory pressure, requiring other code to be written more densely or pushed into different records.

The GEOS engineers seem to have found a reasonable tradeoff. Yes, geoWrite is slow on an unexpanded C64, but it is a useful and very powerful tool for a 64 KB computer.

5 thoughts on “Inside geoWrite – 9: Keyboard Handling”

  1. Thanks for this article series!

    Why did they do beq @rts, bne @again like this:

    beq @rts ; no, return
    bne @again ; yes, repeat
    …..
    @rts: rts

    instead of just do a bne @again and a rts? That would save one byte and make the code slightly faster. Am I missing something?

  2. Man, as always shoot so an extremely good job in documenting c64 stuff, really love the information. Thanks for all your effort.

    Will there be more chapters? I especially would love to see how geoWRITE 128 worked differently, and how the extra memory and the vdc chip were used.

  3. @MiaM

    > beq @rts ; no, return
    > bne @again ; yes, repeat

    I think you will find the code is making the assumption that the buffer with one character is the more popular and typical path, over looping over another character in the buffer. It is therefore “faster” to pick the more popular path.

    • @Miroslav

      I don’t think that would be optimal, since the “common” path would still need to jump to the “rts,” instead of returning directly.

      Wouldn’t it be more efficient, like @MiaM said, to loop on BNE, then RTS directly? The cost of the test-fall-through should be cheaper than the cost of the test-branch, no?

      I admit I am not familiar with 6502 assembly, but in my pet platform (CP-1610), this would be the case.

      Still, impressive what Berkeley Softworks managed to do with such low resources. I recall back in the 1980s my best friend had GEOS and we marveled at how close it imitated his father’s Macintosh.

      dZ.

Leave a Reply to MiaM Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.