Ploopy Classic Trackball

ploopy trackball qmk

Ploopy Classic Trackball
Ploopy Classic with Custom Paint Job, BTU Mod and Custom firmware

Note: Updated with a bugfix where the two tap dance events could interfere with each other, this post has been revised with a fix along with the updated firmware to address this.

In this post I'm going to walk through what I did to customize the firmware running on the ploopy classic trackball. I've been using a trackball for at least decade starting with the Logitech series of thumb operated trackballs moving through to finger operated trackballs. The Ploopy classic trackball is an open-source design and fairly easy to modify the firmware if you're familiar with QMK.

I like the standard buttons, left, right and middle click with a scroll wheel as is with buttons 4 and 5 allow for clicking back and forward. The scroll wheel on the ploopy is average and not notched and requires thumb operation. It's not my favorite part of the device. To counter this you might be familiar with drag scroll, which allows you to hold a button and use the ball to scroll vertically and horizontally rather than the scroll wheel. Once you get the hang of it becomes second nature and for me becomes a must have.

On Linux you can setup drag scroll via software, but ideally it would be baked into the device so it works on windows and mac too. In addition to that, I wanted to toggle through the DPI settings (make the pointer more or less sensitive) using firmware, which avoids having to configure something in software which is specific to each computer you're connected to. Furthermore, it's useful to have a precision mode when gaming or doing something which requires a high degree of accuracy in CAD.

With QMK you can use "tap dance", which allows you to customize the behavior when a button is held or double tapped which works around using a single button for each use. I wanted the firmware to do the following with just five buttons:

  • Use the buttons like a normal mouse or Microsoft Trackball Explorer.
  • Hold button 5 and tap right click, to cycle through DPI.
  • Hold button 4 for momentary drag scroll to quickly scroll through something.
  • Hold button 5 and tap left click, to toggle drag scroll where you want to scroll through a long thread or wide spreadsheet without having to hold down a button.
  • Double click button 4 to enter precision mode, double clicking again to exit.
  • Hold button 4 and double click button 5 to swap right click and back buttons, this is sometimes useful for gaming, I find right click challenging using my thumb for gaming.

First, we need to enable tap dance in QMK for the ploopy, add the following to your rules.mk

TAP_DANCE_ENABLE = yes
MOUSEKEY_ENABLE = yes

Next, create directory under keymaps with a file named config.h

trackball/keymaps/custom/config.h

#pragma once

// Define DPIs 
#define PLOOPY_DPI_OPTIONS { 400, 600, 800, 1000, 1200, 1600, 2400 }
#define PLOOPY_DPI_DEFAULT 1
#define PLOOPY_DRAGSCROLL_FIXED 1
#define PLOOPY_DRAGSCROLL_INVERT 1
#define PLOOPY_DRAGSCROLL_DPI 100  // Fixed-DPI Drag Scroll

PLOOPY_DPI_OPTIONS defines the DPI modes we want to cycle through.

PLOOPY_DPI_DEFAULT Sets the default DPI mode.

PLOOPY_DRAGSCROLL_FIXED 1 Sets the dragscroll to fixed DPI for when we're using the trackball like a scroll wheel.

PLOOPY_DRAGSCROLL_INVERT  1 Inverts the drag scroll direction so it works like the scroll wheel on a mouse, rolling the finger towards the palm scrolls up and vice versa.

PLOOPY_DRAGSCROLL_DPI 100 Sets the DPI to 100, you can't go lower than this, if you do the DPI seems to default to a very high number making it difficult to control. I found 100 was still too high for me, more on that later.

We need to define keymap.c this goes into the directory you just created trackball/keymaps/custom/keymap.c

#include QMK_KEYBOARD_H

// Tap Dance keycodes
enum td_keycodes {
    MSE_BTN5_LAYR_1,  // Our example key: Forward mouse button when held switch to layer 1,  forward mouse button when tapped.
    MSE_BTN4_DRAG     // Hold mse btn4 toggles dragscroll
};

// Define a state for when we're holding down button 4
// this enters precison mode but also allows us to switch to another layer
bool btn4_held = false;
bool precision_mode = false;

// Define a type containing as many tapdance states as you need
// Define a type containing as many tapdance states as you need
typedef enum {
    TD_NONE,
    TD_UNKNOWN,
    TD_SINGLE_TAP,
    TD_SINGLE_HOLD,
    TD_DOUBLE_SINGLE_TAP,
    TD_DOUBLE_HOLD,
    TD_DOUBLE_TAP
} td_state_t;

// Create a global instance of the tapdance state type
static td_state_t td_state; 

// Declare your tapdance functions:

// Function to determine the current tapdance state
td_state_t cur_dance(qk_tap_dance_state_t *state);

// `finished` and `reset` functions for each tapdance keycode
void mseBtn4_finished(qk_tap_dance_state_t *state, void *user_data);
void mseBtn4_reset(qk_tap_dance_state_t *state, void *user_data);
void mseBtn5_finished(qk_tap_dance_state_t *state, void *user_data);
void mseBtn5_reset(qk_tap_dance_state_t *state, void *user_data);
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
    [0] = LAYOUT( /* Base */
        KC_BTN1, KC_BTN3, KC_BTN2,
          TD(MSE_BTN4_DRAG), TD(MSE_BTN5_LAYR_1) 
    ),
    [1] = LAYOUT(
        DRAG_SCROLL, _______, DPI_CONFIG,
          _______, _______
    ),
    [2] = LAYOUT(
        KC_BTN1, KC_BTN3, TD(MSE_BTN4_DRAG),
          KC_BTN2, TD(MSE_BTN5_LAYR_1)
    )
};


// Determine the tapdance state to return
td_state_t cur_dance(qk_tap_dance_state_t *state) {
    if (state->count == 1) {
        // Interrupted means some other button was pressed in the tapping term
        if (state->interrupted || !state->pressed) {
            xprintf("TD_SINGLE_TAP\n");
            return TD_SINGLE_TAP;
        } else {
            xprintf("TD_SINGLE_HOLD\n");
            return TD_SINGLE_HOLD;
        }
    }

    if (state->count == 2) {
        if (state->interrupted) {
            xprintf("TD_DOUBLE_SINGLE_TAP\n");
            return TD_DOUBLE_SINGLE_TAP;
        } else if (state->pressed) {
            xprintf("TD_DOUBLE_HOLD\n");
            return TD_DOUBLE_HOLD;
        } else {
            xprintf("TD_DOUBLE_TAP\n");
            return TD_DOUBLE_TAP;
        }

    } else {
        xprintf("TD_UNKNOWN\n");
        return TD_UNKNOWN; // Any number higher than the maximum state value you return above
    }
}
void mseBtn4_finished(qk_tap_dance_state_t *state, void *user_data) {
    td_state = cur_dance(state);
    switch (td_state) {
        case TD_SINGLE_HOLD:
            xprintf("Hold for button 4 finished\n");
            is_drag_scroll = true;
            btn4_held = true;
        break;
        case TD_DOUBLE_TAP:
            if (!precision_mode) {
                pmw33xx_set_cpi(0, (dpi_array[keyboard_config.dpi_config] / 2) );
                precision_mode = true;
            } else {
                pmw33xx_set_cpi(0, dpi_array[keyboard_config.dpi_config] );
                precision_mode = false;
            }
        break;
        default:
        break;
    }
}

void mseBtn4_reset(qk_tap_dance_state_t *state, void *user_data) {
    switch (td_state) {
        case TD_SINGLE_TAP:
            xprintf("reset button 4 sending tap code\n");
            tap_code16(KC_BTN4);
        break;
        case TD_SINGLE_HOLD:
            xprintf("Hold for button 4 reset\n");
            is_drag_scroll = false;
            btn4_held = false;
        break;
        default:
        break;
    }
}

// Handle the possible states for each tapdance keycode you define:
void mseBtn5_finished(qk_tap_dance_state_t *state, void *user_data) {
    td_state = cur_dance(state);
    switch (td_state) {
        case TD_SINGLE_HOLD:
            xprintf("Turning on layer 1 for button 5\n");
            layer_on(1);
        break;
        default:
        break;
    }
}

void mseBtn5_reset(qk_tap_dance_state_t *state, void *user_data) {
    switch (td_state) {
        case TD_SINGLE_TAP:
            tap_code16(KC_BTN5);
        break;
        case TD_SINGLE_HOLD:
            layer_off(1);
        break;
        case TD_DOUBLE_TAP:
            xprintf(" button 5 double tapped\n");
            if (btn4_held) {
                // If button 4 is held we're in drag scroll, so come out of that mode
                is_drag_scroll = false;
                uprintf(" button 4 is held\n");
                if (layer_state_is(0))  {
                    uprintf(" layer state was 0 switching to 2\n");
                    layer_on(2);
                }
                else {
                    uprintf(" layer state was 2 switching to 0\n");
                    layer_clear();
                }
            }
        break;
        default:
        break;
    }
}

// Define `ACTION_TAP_DANCE_FN_ADVANCED()` for each tapdance keycode, passing in `finished` and `reset` functions
qk_tap_dance_action_t tap_dance_actions[] = {
    [MSE_BTN5_LAYR_1]  = ACTION_TAP_DANCE_FN_ADVANCED(NULL, mseBtn5_finished, mseBtn5_reset),
    [MSE_BTN4_DRAG]   = ACTION_TAP_DANCE_FN_ADVANCED(NULL, mseBtn4_finished, mseBtn4_reset)
};

First, we define three layers, the default layer provides our standard mouse button support, buttons 4 and buttons 5 use TD (Tap Dance) macros to customize their behavior when they are held or double tapped, but when clicked work as back and forward buttons.

The second layer is enabled when button 5 is held, much like holding a shift key, when button 5 is held button 1 toggles drag scroll when clicked, and right click (button 2) cycles through the DPI settings.

The third layer flips button 4 with button 2 (right click).

The rest of file is mostly the logic to enable tap dance to understand our intentions as we hold or double click the buttons 4 and 5.

Taking a closer look at the following function:

void mseBtn4_finished(qk_tap_dance_state_t *state, void *user_data) {
    td_state = cur_dance(state);
    switch (td_state) {
        case TD_SINGLE_HOLD:
            xprintf("Hold for button 4 finished\n");
            is_drag_scroll = true;
            btn4_held = true;
        break;
        case TD_DOUBLE_TAP:
            if (!precision_mode) {
                pmw33xx_set_cpi(0, (dpi_array[keyboard_config.dpi_config] / 2) );
                precision_mode = true;
            } else {
                pmw33xx_set_cpi(0, dpi_array[keyboard_config.dpi_config] );
                precision_mode = false;
            }
        break;
        default:
        break;
    }
}

We handle the single tap scenario in the reset function for tap dance, this addressed an earlier bug where two tap dance events are triggered leaving the button pressed down.

When holding button 4, we've exposed is_drag_scroll from the trackball.c file to tap dance so we momentarily enable it here.

When double tapping button 4 we toggle on a precision_mode dividing the DPI setting by 2, if we're already in precision mode we exit it.

The following method handles exiting tap dance, here we send the button 4 tap event or exit drag scroll if we were holding it.

void mseBtn4_reset(qk_tap_dance_state_t *state, void *user_data) {
    switch (td_state) {
        case TD_SINGLE_TAP:
            xprintf("reset button 4 sending tap code\n");
            tap_code16(KC_BTN4);
        break;
        case TD_SINGLE_HOLD:
            xprintf("Hold for button 4 reset\n");
            is_drag_scroll = false;
            btn4_held = false;
        break;
        default:
        break;
    }
}

To expose is_drag_scroll we need to add a definition to trackball.h

extern bool              is_drag_scroll;

As previously mentioned, the fixed DPI setting of 100 was still too sensitive for me. Searching around this code was taken from another reddit user u/Yuler, which I adapted for the classic:

In trackball/trackball.c add the following lines just under the trackball state definitions to hold accumulator variables.

#define PLOOPY_DRAGSCROLL_DENOMINATOR 10;
static int _dragscroll_accumulator_x = 0;
static int _dragscroll_accumulator_y = 0;

Then replace the following method with what's below, which will make drag scroll less sensitive.

report_mouse_t pointing_device_task_kb(report_mouse_t mouse_report) {
    process_wheel();

    if (is_drag_scroll) {
#ifdef PLOOPY_DRAGSCROLL_H_INVERT
        // Invert horizontal scroll direction
        _dragscroll_accumulator_x += -mouse_report.x;
#else
        _dragscroll_accumulator_x += mouse_report.x;
#endif
#ifdef PLOOPY_DRAGSCROLL_INVERT
        // Invert vertical scroll direction
        _dragscroll_accumulator_y += -mouse_report.y;
#else
        _dragscroll_accumulator_y += mouse_report.y;
#endif
        int div_x = _dragscroll_accumulator_x / PLOOPY_DRAGSCROLL_DENOMINATOR;
        int div_y = _dragscroll_accumulator_y / PLOOPY_DRAGSCROLL_DENOMINATOR;

        if (div_x != 0) {
            mouse_report.h += div_x;
            _dragscroll_accumulator_x -= div_x * PLOOPY_DRAGSCROLL_DENOMINATOR;
        }

        if (div_y != 0) {
            mouse_report.v += div_y;
            _dragscroll_accumulator_y -= div_y * PLOOPY_DRAGSCROLL_DENOMINATOR;
        }
        mouse_report.x = 0;
        mouse_report.y = 0;
    }

    return pointing_device_task_user(mouse_report);
}

After making the changes it's a case of compiling the source code, I do this with the following command:

qmk compile -kb ploopyco/trackball -km custom

Once compiled you can flash the hex file to your ploopy, to do that unplug the ploopy and with the QMK toolbox installed and running, hold down button 4 (that's the one just right of ball) and plug it in. Then flash the new firmware.

In case this is all too much, I've included the firmware hex, use at your own risk. Ensure that your ploopy classic is compatible before flashing, I have rev 1.005