From b8214fd378506fe7b4debc76e45433b9612d0676 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Sat, 24 Jan 2026 02:30:13 +1000 Subject: [PATCH] Trackball Pointer Mode & driver rewrite (#453) --- Data/system/cursor.png | Bin 0 -> 546 bytes Data/system_sources/cursor.svg | 44 ++ Devices/lilygo-tdeck/Source/Init.cpp | 19 - .../Source/Trackball/Trackball.cpp | 467 ++++++++++++++---- .../lilygo-tdeck/Source/Trackball/Trackball.h | 45 +- .../Source/devices/KeyboardBacklight.cpp | 19 +- .../Source/devices/TrackballDevice.cpp | 47 +- Tactility/Include/Tactility/Assets.h | 1 + .../Tactility/settings/KeyboardSettings.h | 9 +- .../Tactility/settings/TrackballSettings.h | 27 + Tactility/Source/Tactility.cpp | 6 + .../Source/app/keyboard/KeyboardSettings.cpp | 63 +-- .../app/trackball/TrackballSettings.cpp | 219 ++++++++ .../Source/settings/KeyboardSettings.cpp | 7 +- .../Source/settings/TrackballSettings.cpp | 86 ++++ 15 files changed, 850 insertions(+), 209 deletions(-) create mode 100644 Data/system/cursor.png create mode 100644 Data/system_sources/cursor.svg create mode 100644 Tactility/Include/Tactility/settings/TrackballSettings.h create mode 100644 Tactility/Source/app/trackball/TrackballSettings.cpp create mode 100644 Tactility/Source/settings/TrackballSettings.cpp diff --git a/Data/system/cursor.png b/Data/system/cursor.png new file mode 100644 index 0000000000000000000000000000000000000000..7f35bc39fa00d9b00ed3b1946f09888edde2b14c GIT binary patch literal 546 zcmV+-0^R+IP)mt|^wS>@2rT8ykfIyHr~YT56YK*=iw7Aqdvn zrV!F7g0gj30$QfoG2_Q=f}JE+y|}oM`3}G(082!0>H!h? z{eE9styYxJ=L4nGBN1H#I6Dcz%y;c}dndm&nM|bFY;Ku(V~qKhNF=TR%C~4PN&jnG(x3RsI|Taa2_AVhf1aLbM_2+K4`z+5=sT2Tkj_$!hrtb=c!hVvo*-R|U+5oUm z1*C~+IUbKiM5JD?huLiQ(~GNoSRkTX$8o~taw$rwEr7TG1psit%p2ErMQeTk>Sy{_ k(YEayW6T49^Va~s0I58c_p1yfmjD0&07*qoM6N<$g0&&?f&c&j literal 0 HcmV?d00001 diff --git a/Data/system_sources/cursor.svg b/Data/system_sources/cursor.svg new file mode 100644 index 00000000..f8f79e1c --- /dev/null +++ b/Data/system_sources/cursor.svg @@ -0,0 +1,44 @@ + + + + + + diff --git a/Devices/lilygo-tdeck/Source/Init.cpp b/Devices/lilygo-tdeck/Source/Init.cpp index 8b88c405..5646dac6 100644 --- a/Devices/lilygo-tdeck/Source/Init.cpp +++ b/Devices/lilygo-tdeck/Source/Init.cpp @@ -7,10 +7,6 @@ #include #include #include -#include -#include - -#include static const auto LOGGER = tt::Logger("T-Deck"); @@ -89,21 +85,6 @@ bool initBoot() { LOGGER.error("{} start failed", trackball->getName()); } } - - // Backlight doesn't seem to turn on until toggled on and off from keyboard settings... - // Or let the display and backlight sleep then wake it up. - // Then it works fine...until reboot, then you need to toggle again. - // The current keyboard firmware sets backlight duty to 0 on boot. - // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/firmware/T-Keyboard_Keyboard_ESP32C3_250620.bin - // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/examples/Keyboard_ESP32C3/Keyboard_ESP32C3.ino#L25 - // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/examples/Keyboard_ESP32C3/Keyboard_ESP32C3.ino#L217 - auto kbSettings = tt::settings::keyboard::loadOrGetDefault(); - bool result = keyboardbacklight::setBrightness(kbSettings.backlightEnabled ? kbSettings.backlightBrightness : 0); - if (!result) { - LOGGER.warn("Failed to set keyboard backlight brightness"); - } - - trackball::setEnabled(kbSettings.trackballEnabled); }); return true; diff --git a/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp b/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp index c59c1916..b2bd6a06 100644 --- a/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp +++ b/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp @@ -1,6 +1,8 @@ #include "Trackball.h" +#include #include +#include static const auto LOGGER = tt::Logger("Trackball"); @@ -8,19 +10,292 @@ namespace trackball { static TrackballConfig g_config; static lv_indev_t* g_indev = nullptr; -static bool g_initialized = false; -static bool g_enabled = true; +static std::atomic g_initialized{false}; +static std::atomic g_enabled{true}; +static std::atomic g_mode{Mode::Encoder}; -// Track last GPIO states for edge detection -static bool g_lastState[5] = {false, false, false, false, false}; +// Interrupt-driven position tracking (atomic for ISR safety) +static std::atomic g_cursorX{160}; +static std::atomic g_cursorY{120}; +static std::atomic g_buttonPressed{false}; -static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) { - if (!g_initialized || !g_enabled) { - data->state = LV_INDEV_STATE_RELEASED; - data->enc_diff = 0; +// Encoder mode: accumulated diff since last read +static std::atomic g_encoderDiff{0}; + +// Sensitivity cached for ISR access (atomic for thread safety) +static std::atomic g_encoderSensitivity{1}; // Steps per tick for encoder +static std::atomic g_pointerSensitivity{10}; // Pixels per tick for pointer + +// Cursor object for pointer mode +static lv_obj_t* g_cursor = nullptr; + +// Screen dimensions (T-Deck: 320x240) +static constexpr int32_t SCREEN_WIDTH = 320; +static constexpr int32_t SCREEN_HEIGHT = 240; + +static constexpr int32_t CURSOR_SIZE = 16; + +// ISR handler for trackball directions +static void IRAM_ATTR trackball_isr_handler(void* arg) { + // Skip accumulating movement when disabled + if (!g_enabled.load(std::memory_order_relaxed)) { return; } - + + gpio_num_t pin = static_cast(reinterpret_cast(arg)); + + if (g_mode.load(std::memory_order_relaxed) == Mode::Pointer) { + // Pointer mode: update absolute position using atomic fetch_add/sub + // Clamping is done in read_cb to avoid race conditions + int32_t step = g_pointerSensitivity.load(std::memory_order_relaxed); + if (pin == g_config.pinRight) { + g_cursorX.fetch_add(step, std::memory_order_relaxed); + } else if (pin == g_config.pinLeft) { + g_cursorX.fetch_sub(step, std::memory_order_relaxed); + } else if (pin == g_config.pinUp) { + g_cursorY.fetch_sub(step, std::memory_order_relaxed); + } else if (pin == g_config.pinDown) { + g_cursorY.fetch_add(step, std::memory_order_relaxed); + } + } else { + // Encoder mode: accumulate diff + int32_t step = g_encoderSensitivity.load(std::memory_order_relaxed); + if (pin == g_config.pinRight || pin == g_config.pinDown) { + g_encoderDiff.fetch_add(step, std::memory_order_relaxed); + } else if (pin == g_config.pinLeft || pin == g_config.pinUp) { + g_encoderDiff.fetch_sub(step, std::memory_order_relaxed); + } + } +} + +// ISR handler for button (any edge) +static void IRAM_ATTR button_isr_handler(void* arg) { + // Read current button state (active low) + bool pressed = gpio_get_level(g_config.pinClick) == 0; + g_buttonPressed.store(pressed, std::memory_order_relaxed); +} + +// Helper to clamp value to range +static inline int32_t clamp(int32_t val, int32_t minVal, int32_t maxVal) { + if (val < minVal) return minVal; + if (val > maxVal) return maxVal; + return val; +} + +static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) { + Mode currentMode = g_mode.load(std::memory_order_relaxed); + + if (!g_initialized.load(std::memory_order_relaxed) || !g_enabled.load(std::memory_order_relaxed)) { + data->state = LV_INDEV_STATE_RELEASED; + if (currentMode == Mode::Encoder) { + data->enc_diff = 0; + } else { + // Clamp cursor position to screen bounds + int32_t x = clamp(g_cursorX.load(std::memory_order_relaxed), 0, SCREEN_WIDTH - CURSOR_SIZE - 1); + int32_t y = clamp(g_cursorY.load(std::memory_order_relaxed), 0, SCREEN_HEIGHT - CURSOR_SIZE - 1); + g_cursorX.store(x, std::memory_order_relaxed); + g_cursorY.store(y, std::memory_order_relaxed); + data->point.x = static_cast(x); + data->point.y = static_cast(y); + } + return; + } + + if (currentMode == Mode::Encoder) { + // Read and reset accumulated encoder diff + int32_t diff = g_encoderDiff.exchange(0); + data->enc_diff = static_cast(clamp(diff, INT16_MIN, INT16_MAX)); + + if (diff != 0) { + lv_disp_trig_activity(nullptr); + } + } else { + // Pointer mode: read and clamp cursor position + int32_t x = clamp(g_cursorX.load(std::memory_order_relaxed), 0, SCREEN_WIDTH - CURSOR_SIZE - 1); + int32_t y = clamp(g_cursorY.load(std::memory_order_relaxed), 0, SCREEN_HEIGHT - CURSOR_SIZE - 1); + + // Store clamped values back to prevent unbounded growth + g_cursorX.store(x, std::memory_order_relaxed); + g_cursorY.store(y, std::memory_order_relaxed); + + data->point.x = static_cast(x); + data->point.y = static_cast(y); + } + + // Button state (same for both modes) + bool pressed = g_buttonPressed.load(std::memory_order_relaxed); + data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; + + if (pressed) { + lv_disp_trig_activity(nullptr); + } +} + +lv_indev_t* init(const TrackballConfig& config) { + if (g_initialized.load(std::memory_order_relaxed)) { + LOGGER.warn("Already initialized"); + return g_indev; + } + + g_config = config; + + // Set default sensitivities if not specified + if (g_config.encoderSensitivity == 0) { + g_config.encoderSensitivity = 1; + } + if (g_config.pointerSensitivity == 0) { + g_config.pointerSensitivity = 10; + } + g_encoderSensitivity.store(g_config.encoderSensitivity, std::memory_order_relaxed); + g_pointerSensitivity.store(g_config.pointerSensitivity, std::memory_order_relaxed); + + // Initialize cursor position to center + g_cursorX.store(SCREEN_WIDTH / 2, std::memory_order_relaxed); + g_cursorY.store(SCREEN_HEIGHT / 2, std::memory_order_relaxed); + g_encoderDiff.store(0, std::memory_order_relaxed); + g_buttonPressed.store(false, std::memory_order_relaxed); + + // Configure direction pins as interrupt inputs (falling edge) + const gpio_num_t dirPins[4] = { + config.pinRight, + config.pinUp, + config.pinLeft, + config.pinDown + }; + + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_NEGEDGE; // Falling edge (active low) + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + + // Install GPIO ISR service (if not already installed) + static bool isr_service_installed = false; + if (!isr_service_installed) { + esp_err_t err = gpio_install_isr_service(ESP_INTR_FLAG_IRAM); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + // ESP_ERR_INVALID_STATE means already installed, which is fine + isr_service_installed = true; + } else { + LOGGER.error("Failed to install GPIO ISR service: {}", esp_err_to_name(err)); + return nullptr; + } + } + + // Track added handlers for cleanup on failure + int handlersAdded = 0; + + // Configure and attach ISR for direction pins + for (int i = 0; i < 4; i++) { + io_conf.pin_bit_mask = (1ULL << dirPins[i]); + esp_err_t err = gpio_config(&io_conf); + if (err != ESP_OK) { + LOGGER.error("Failed to configure GPIO {}: {}", static_cast(dirPins[i]), esp_err_to_name(err)); + // Cleanup previously added handlers + for (int j = 0; j < handlersAdded; j++) { + gpio_isr_handler_remove(dirPins[j]); + } + return nullptr; + } + + err = gpio_isr_handler_add(dirPins[i], trackball_isr_handler, reinterpret_cast(static_cast(dirPins[i]))); + if (err != ESP_OK) { + LOGGER.error("Failed to add ISR for GPIO {}: {}", static_cast(dirPins[i]), esp_err_to_name(err)); + // Cleanup previously added handlers + for (int j = 0; j < handlersAdded; j++) { + gpio_isr_handler_remove(dirPins[j]); + } + return nullptr; + } + handlersAdded++; + } + + // Configure button pin (any edge for press/release detection) + io_conf.intr_type = GPIO_INTR_ANYEDGE; + io_conf.pin_bit_mask = (1ULL << config.pinClick); + esp_err_t err = gpio_config(&io_conf); + if (err != ESP_OK) { + LOGGER.error("Failed to configure button GPIO {}: {}", static_cast(config.pinClick), esp_err_to_name(err)); + // Cleanup direction handlers + for (int i = 0; i < 4; i++) { + gpio_isr_handler_remove(dirPins[i]); + } + return nullptr; + } + + err = gpio_isr_handler_add(config.pinClick, button_isr_handler, nullptr); + if (err != ESP_OK) { + LOGGER.error("Failed to add button ISR: {}", esp_err_to_name(err)); + // Cleanup direction handlers + for (int i = 0; i < 4; i++) { + gpio_isr_handler_remove(dirPins[i]); + } + return nullptr; + } + + // Read initial button state + g_buttonPressed.store(gpio_get_level(config.pinClick) == 0); + + // Register as LVGL encoder input device for group navigation (default mode) + g_indev = lv_indev_create(); + if (g_indev == nullptr) { + LOGGER.error("Failed to register LVGL input device"); + // Cleanup ISR handlers on failure + const gpio_num_t pins[5] = { + config.pinRight, config.pinUp, config.pinLeft, + config.pinDown, config.pinClick + }; + for (int i = 0; i < 5; i++) { + gpio_intr_disable(pins[i]); + gpio_isr_handler_remove(pins[i]); + } + return nullptr; + } + + lv_indev_set_type(g_indev, LV_INDEV_TYPE_ENCODER); + lv_indev_set_read_cb(g_indev, read_cb); + g_initialized.store(true, std::memory_order_relaxed); + LOGGER.info("Initialized with interrupts (R:{} U:{} L:{} D:{} Click:{})", + static_cast(config.pinRight), + static_cast(config.pinUp), + static_cast(config.pinLeft), + static_cast(config.pinDown), + static_cast(config.pinClick)); + + return g_indev; +} + +// Create cursor for pointer mode +static void createCursor() { + if (g_cursor != nullptr || g_indev == nullptr) return; + + g_cursor = lv_image_create(lv_layer_sys()); + if (g_cursor != nullptr) { + lv_obj_remove_flag(g_cursor, LV_OBJ_FLAG_CLICKABLE); + + // Set cursor image + lv_image_set_src(g_cursor, TT_ASSETS_UI_CURSOR); + lv_indev_set_cursor(g_indev, g_cursor); + LOGGER.debug("Cursor created"); + } +} + +// Destroy cursor when switching back to encoder mode +static void destroyCursor() { + if (g_cursor == nullptr) return; + + // Delete the cursor object - this automatically detaches it from the indev + lv_obj_delete(g_cursor); + g_cursor = nullptr; + LOGGER.debug("Cursor destroyed"); +} + +void deinit() { + if (!g_initialized.load(std::memory_order_relaxed)) return; + + destroyCursor(); + + // Disable interrupts and remove ISR handlers const gpio_num_t pins[5] = { g_config.pinRight, g_config.pinUp, @@ -28,122 +303,96 @@ static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) { g_config.pinDown, g_config.pinClick }; - - // Read GPIO states and detect changes (active low with pull-up) - bool currentStates[5]; - for (int i = 0; i < 5; i++) { - currentStates[i] = gpio_get_level(pins[i]) == 0; - } - - // Process directional inputs as encoder steps - // Right/Down = positive diff (next item), Left/Up = negative diff (prev item) - int16_t diff = 0; - - // Right pressed (rising edge) - if (currentStates[0] && !g_lastState[0]) { - diff += g_config.movementStep; - } - // Up pressed (rising edge) - if (currentStates[1] && !g_lastState[1]) { - diff -= g_config.movementStep; - } - // Left pressed (rising edge) - if (currentStates[2] && !g_lastState[2]) { - diff -= g_config.movementStep; - } - // Down pressed (rising edge) - if (currentStates[3] && !g_lastState[3]) { - diff += g_config.movementStep; - } - - // Update last states - for (int i = 0; i < 5; i++) { - g_lastState[i] = currentStates[i]; - } - - // Update encoder diff and button state - data->enc_diff = diff; - data->state = currentStates[4] ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; - - // Trigger activity for wake-on-trackball - if (diff != 0 || currentStates[4]) { - lv_disp_trig_activity(nullptr); - } -} -lv_indev_t* init(const TrackballConfig& config) { - if (g_initialized) { - LOGGER.warn("Already initialized"); - return g_indev; - } - - g_config = config; - - // Set default movement step if not specified - if (g_config.movementStep == 0) { - g_config.movementStep = 10; - } - - // Configure all GPIO pins as inputs with pull-ups (active low) - const gpio_num_t pins[5] = { - config.pinRight, - config.pinUp, - config.pinLeft, - config.pinDown, - config.pinClick - }; - - gpio_config_t io_conf = {}; - io_conf.intr_type = GPIO_INTR_DISABLE; - io_conf.mode = GPIO_MODE_INPUT; - io_conf.pull_up_en = GPIO_PULLUP_ENABLE; - io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; - for (int i = 0; i < 5; i++) { - io_conf.pin_bit_mask = (1ULL << pins[i]); - gpio_config(&io_conf); - g_lastState[i] = gpio_get_level(pins[i]) == 0; - } - - // Register as LVGL encoder input device for group navigation - g_indev = lv_indev_create(); - lv_indev_set_type(g_indev, LV_INDEV_TYPE_ENCODER); - lv_indev_set_read_cb(g_indev, read_cb); - - if (g_indev != nullptr) { - g_initialized = true; - LOGGER.info("Initialized as encoder (R:{} U:{} L:{} D:{} Click:{})", - static_cast(config.pinRight), - static_cast(config.pinUp), - static_cast(config.pinLeft), - static_cast(config.pinDown), - static_cast(config.pinClick)); - } else { - LOGGER.error("Failed to register LVGL input device"); + gpio_intr_disable(pins[i]); + gpio_isr_handler_remove(pins[i]); } - return g_indev; -} - -void deinit() { if (g_indev) { lv_indev_delete(g_indev); g_indev = nullptr; } - g_initialized = false; + + g_initialized.store(false, std::memory_order_relaxed); + g_mode.store(Mode::Encoder, std::memory_order_relaxed); + g_enabled.store(true, std::memory_order_relaxed); LOGGER.info("Deinitialized"); } -void setMovementStep(uint8_t step) { - if (step > 0) { - g_config.movementStep = step; - LOGGER.debug("Movement step set to {}", step); +void setEncoderSensitivity(uint8_t sensitivity) { + if (sensitivity > 0) { + // Only update the atomic - ISR reads from atomic, not g_config + g_encoderSensitivity.store(sensitivity, std::memory_order_relaxed); + LOGGER.debug("Encoder sensitivity set to {}", sensitivity); + } +} + +void setPointerSensitivity(uint8_t sensitivity) { + if (sensitivity > 0) { + // Only update the atomic - ISR reads from atomic, not g_config + g_pointerSensitivity.store(sensitivity, std::memory_order_relaxed); + LOGGER.debug("Pointer sensitivity set to {}", sensitivity); } } void setEnabled(bool enabled) { - g_enabled = enabled; + g_enabled.store(enabled, std::memory_order_relaxed); + + if (!enabled) { + // Clear accumulated state to prevent jumps on re-enable + g_encoderDiff.store(0, std::memory_order_relaxed); + } + + // Hide/show cursor based on enabled state when in pointer mode + // Note: Must be called from LVGL thread (main thread) for thread safety + lv_obj_t* cursor = g_cursor; // Local copy to avoid race with setMode + if (cursor != nullptr) { + if (enabled) { + lv_obj_clear_flag(cursor, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(cursor, LV_OBJ_FLAG_HIDDEN); + } + } + LOGGER.info("{}", enabled ? "Enabled" : "Disabled"); } +void setMode(Mode mode) { + // Note: Must be called from LVGL thread (main thread) for thread safety + if (!g_initialized.load(std::memory_order_relaxed) || g_indev == nullptr) { + LOGGER.warn("Cannot set mode - not initialized"); + return; + } + + if (g_mode.load(std::memory_order_relaxed) == mode) { + return; + } + + g_mode.store(mode, std::memory_order_relaxed); + + if (mode == Mode::Pointer) { + // Switch to pointer mode + lv_indev_set_type(g_indev, LV_INDEV_TYPE_POINTER); + createCursor(); + if (!g_enabled.load(std::memory_order_relaxed) && g_cursor != nullptr) { + lv_obj_add_flag(g_cursor, LV_OBJ_FLAG_HIDDEN); + } + // Reset cursor to center when switching modes + g_cursorX.store(SCREEN_WIDTH / 2, std::memory_order_relaxed); + g_cursorY.store(SCREEN_HEIGHT / 2, std::memory_order_relaxed); + LOGGER.info("Switched to Pointer mode"); + } else { + // Switch to encoder mode + destroyCursor(); + lv_indev_set_type(g_indev, LV_INDEV_TYPE_ENCODER); + g_encoderDiff.store(0, std::memory_order_relaxed); // Reset encoder diff + LOGGER.info("Switched to Encoder mode"); + } +} + +Mode getMode() { + return g_mode.load(std::memory_order_relaxed); +} + } diff --git a/Devices/lilygo-tdeck/Source/Trackball/Trackball.h b/Devices/lilygo-tdeck/Source/Trackball/Trackball.h index acfe9c29..4f67c9f8 100644 --- a/Devices/lilygo-tdeck/Source/Trackball/Trackball.h +++ b/Devices/lilygo-tdeck/Source/Trackball/Trackball.h @@ -5,16 +5,25 @@ namespace trackball { +/** + * @brief Trackball operating mode + */ +enum class Mode { + Encoder, // Navigation via enc_diff (scroll wheel behavior) + Pointer // Mouse cursor via point.x/y +}; + /** * @brief Trackball configuration structure */ struct TrackballConfig { - gpio_num_t pinRight; // Right direction GPIO - gpio_num_t pinUp; // Up direction GPIO - gpio_num_t pinLeft; // Left direction GPIO - gpio_num_t pinDown; // Down direction GPIO - gpio_num_t pinClick; // Click/select button GPIO - uint8_t movementStep; // Pixels to move per trackball event (default: 10) + gpio_num_t pinRight; // Right direction GPIO + gpio_num_t pinUp; // Up direction GPIO + gpio_num_t pinLeft; // Left direction GPIO + gpio_num_t pinDown; // Down direction GPIO + gpio_num_t pinClick; // Click/select button GPIO + uint8_t encoderSensitivity = 1; // Encoder mode: steps per tick + uint8_t pointerSensitivity = 10; // Pointer mode: pixels per tick }; /** @@ -30,10 +39,16 @@ lv_indev_t* init(const TrackballConfig& config); void deinit(); /** - * @brief Set movement step size - * @param step Encoder steps per trackball event + * @brief Set encoder mode sensitivity + * @param sensitivity Steps per trackball tick (1-10, default: 1) */ -void setMovementStep(uint8_t step); +void setEncoderSensitivity(uint8_t sensitivity); + +/** + * @brief Set pointer mode sensitivity + * @param sensitivity Pixels per trackball tick (1-10, default: 10) + */ +void setPointerSensitivity(uint8_t sensitivity); /** * @brief Enable or disable trackball input processing @@ -41,4 +56,16 @@ void setMovementStep(uint8_t step); */ void setEnabled(bool enabled); +/** + * @brief Set trackball operating mode + * @param mode Encoder or Pointer mode + */ +void setMode(Mode mode); + +/** + * @brief Get current trackball operating mode + * @return Current mode + */ +Mode getMode(); + } diff --git a/Devices/lilygo-tdeck/Source/devices/KeyboardBacklight.cpp b/Devices/lilygo-tdeck/Source/devices/KeyboardBacklight.cpp index a9137779..41ef5b67 100644 --- a/Devices/lilygo-tdeck/Source/devices/KeyboardBacklight.cpp +++ b/Devices/lilygo-tdeck/Source/devices/KeyboardBacklight.cpp @@ -1,16 +1,31 @@ #include "KeyboardBacklight.h" #include // Driver #include +#include // TODO: Add Mutex and consider refactoring into a class bool KeyboardBacklightDevice::start() { if (initialized) { return true; } - + // T-Deck uses I2C_NUM_0 for internal peripherals initialized = keyboardbacklight::init(I2C_NUM_0); - return initialized; + if (!initialized) { + return false; + } + + // Backlight doesn't seem to turn on until toggled on and off from keyboard settings... + // Or let the display and backlight sleep then wake it up. + // Then it works fine...until reboot, then you need to toggle again. + // The current keyboard firmware sets backlight duty to 0 on boot. + // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/firmware/T-Keyboard_Keyboard_ESP32C3_250620.bin + // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/examples/Keyboard_ESP32C3/Keyboard_ESP32C3.ino#L25 + // https://github.com/Xinyuan-LilyGO/T-Deck/blob/master/examples/Keyboard_ESP32C3/Keyboard_ESP32C3.ino#L217 + auto kbSettings = tt::settings::keyboard::loadOrGetDefault(); + keyboardbacklight::setBrightness(kbSettings.backlightEnabled ? kbSettings.backlightBrightness : 0); + + return true; } bool KeyboardBacklightDevice::stop() { diff --git a/Devices/lilygo-tdeck/Source/devices/TrackballDevice.cpp b/Devices/lilygo-tdeck/Source/devices/TrackballDevice.cpp index 16b65f26..9b24f10f 100644 --- a/Devices/lilygo-tdeck/Source/devices/TrackballDevice.cpp +++ b/Devices/lilygo-tdeck/Source/devices/TrackballDevice.cpp @@ -1,28 +1,49 @@ #include "TrackballDevice.h" #include // Driver +#include +#include +#include + +static const auto LOGGER = tt::Logger("TrackballDevice"); bool TrackballDevice::start() { if (initialized) { return true; } - + // T-Deck trackball GPIO configuration from LilyGo reference trackball::TrackballConfig config = { - .pinRight = GPIO_NUM_2, // BOARD_TBOX_G02 - .pinUp = GPIO_NUM_3, // BOARD_TBOX_G01 - .pinLeft = GPIO_NUM_1, // BOARD_TBOX_G04 - .pinDown = GPIO_NUM_15, // BOARD_TBOX_G03 - .pinClick = GPIO_NUM_0, // BOARD_BOOT_PIN - .movementStep = 1 // pixels per movement + .pinRight = GPIO_NUM_2, // BOARD_TBOX_G02 + .pinUp = GPIO_NUM_3, // BOARD_TBOX_G01 + .pinLeft = GPIO_NUM_1, // BOARD_TBOX_G04 + .pinDown = GPIO_NUM_15, // BOARD_TBOX_G03 + .pinClick = GPIO_NUM_0, // BOARD_BOOT_PIN + .encoderSensitivity = 1, // 1 step per tick for menu navigation + .pointerSensitivity = 10 // 10 pixels per tick for cursor movement }; - + indev = trackball::init(config); - if (indev != nullptr) { - initialized = true; - return true; + if (indev == nullptr) { + return false; } - - return false; + + initialized = true; + + // Apply persisted trackball settings (requires LVGL lock for cursor manipulation) + auto tbSettings = tt::settings::trackball::loadOrGetDefault(); + if (tt::lvgl::lock(100)) { + trackball::setMode(tbSettings.trackballMode == tt::settings::trackball::TrackballMode::Pointer + ? trackball::Mode::Pointer + : trackball::Mode::Encoder); + trackball::setEncoderSensitivity(tbSettings.encoderSensitivity); + trackball::setPointerSensitivity(tbSettings.pointerSensitivity); + trackball::setEnabled(tbSettings.trackballEnabled); + tt::lvgl::unlock(); + } else { + LOGGER.warn("Failed to acquire LVGL lock for trackball settings"); + } + + return true; } bool TrackballDevice::stop() { diff --git a/Tactility/Include/Tactility/Assets.h b/Tactility/Include/Tactility/Assets.h index b3efa797..6e7a865f 100644 --- a/Tactility/Include/Tactility/Assets.h +++ b/Tactility/Include/Tactility/Assets.h @@ -10,6 +10,7 @@ // UI #define TT_ASSETS_UI_SPINNER TT_ASSET("spinner.png") +#define TT_ASSETS_UI_CURSOR TT_ASSET("cursor.png") // App icons #define TT_ASSETS_APP_ICON_FALLBACK TT_ASSET("app_icon_fallback_dark_mode.png") diff --git a/Tactility/Include/Tactility/settings/KeyboardSettings.h b/Tactility/Include/Tactility/settings/KeyboardSettings.h index c65dbafd..baab26d8 100644 --- a/Tactility/Include/Tactility/settings/KeyboardSettings.h +++ b/Tactility/Include/Tactility/settings/KeyboardSettings.h @@ -5,11 +5,10 @@ namespace tt::settings::keyboard { struct KeyboardSettings { - bool backlightEnabled; - uint8_t backlightBrightness; // 0-255 - bool trackballEnabled; - bool backlightTimeoutEnabled; - uint32_t backlightTimeoutMs; // Timeout in milliseconds + bool backlightEnabled = true; + uint8_t backlightBrightness = 127; // 0-255 + bool backlightTimeoutEnabled = true; + uint32_t backlightTimeoutMs = 60000; // Timeout in milliseconds }; bool load(KeyboardSettings& settings); diff --git a/Tactility/Include/Tactility/settings/TrackballSettings.h b/Tactility/Include/Tactility/settings/TrackballSettings.h new file mode 100644 index 00000000..ff769a50 --- /dev/null +++ b/Tactility/Include/Tactility/settings/TrackballSettings.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace tt::settings::trackball { + +enum class TrackballMode : uint8_t { + Encoder = 0, // Scroll wheel navigation (default) + Pointer = 1 // Mouse cursor mode +}; + +struct TrackballSettings { + bool trackballEnabled = false; + TrackballMode trackballMode = TrackballMode::Encoder; + uint8_t encoderSensitivity = 1; // Steps per tick (1-10) + uint8_t pointerSensitivity = 10; // Pixels per tick (1-10) +}; + +bool load(TrackballSettings& settings); + +TrackballSettings loadOrGetDefault(); + +TrackballSettings getDefault(); + +bool save(const TrackballSettings& settings); + +} diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index e6b0ed4a..6f30c6eb 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -103,6 +103,9 @@ namespace app { namespace systeminfo { extern const AppManifest manifest; } namespace timedatesettings { extern const AppManifest manifest; } namespace timezone { extern const AppManifest manifest; } +#if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK) + namespace trackballsettings { extern const AppManifest manifest; } +#endif namespace usbsettings { extern const AppManifest manifest; } namespace wifiapsettings { extern const AppManifest manifest; } namespace wificonnect { extern const AppManifest manifest; } @@ -146,6 +149,9 @@ static void registerInternalApps() { addAppManifest(app::systeminfo::manifest); addAppManifest(app::timedatesettings::manifest); addAppManifest(app::timezone::manifest); +#if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK) + addAppManifest(app::trackballsettings::manifest); +#endif addAppManifest(app::wifiapsettings::manifest); addAppManifest(app::wificonnect::manifest); addAppManifest(app::wifimanage::manifest); diff --git a/Tactility/Source/app/keyboard/KeyboardSettings.cpp b/Tactility/Source/app/keyboard/KeyboardSettings.cpp index 075d1842..89bc1f9a 100644 --- a/Tactility/Source/app/keyboard/KeyboardSettings.cpp +++ b/Tactility/Source/app/keyboard/KeyboardSettings.cpp @@ -13,14 +13,21 @@ namespace keyboardbacklight { bool setBrightness(uint8_t brightness); } -namespace trackball { - void setEnabled(bool enabled); -} - namespace tt::app::keyboardsettings { constexpr auto* TAG = "KeyboardSettings"; +// Shared timeout values: 15s, 30s, 1m, 2m, 5m, Never (0) +static constexpr uint32_t TIMEOUT_VALUES_MS[] = {15000, 30000, 60000, 120000, 300000, 0}; +static constexpr size_t TIMEOUT_DEFAULT_IDX = 2; // 1 minute + +static uint32_t timeoutMsToIndex(uint32_t ms) { + for (size_t i = 0; i < sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]); ++i) { + if (TIMEOUT_VALUES_MS[i] == ms) return static_cast(i); + } + return TIMEOUT_DEFAULT_IDX; +} + static void applyKeyboardBacklight(bool enabled, uint8_t brightness) { keyboardbacklight::setBrightness(enabled ? brightness : 0); } @@ -30,7 +37,6 @@ class KeyboardSettingsApp final : public App { settings::keyboard::KeyboardSettings kbSettings; bool updated = false; lv_obj_t* switchBacklight = nullptr; - lv_obj_t* switchTrackball = nullptr; lv_obj_t* sliderBrightness = nullptr; lv_obj_t* switchTimeoutEnable = nullptr; lv_obj_t* timeoutDropdown = nullptr; @@ -57,14 +63,6 @@ class KeyboardSettingsApp final : public App { } } - static void onTrackballSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchTrackball, LV_STATE_CHECKED); - app->kbSettings.trackballEnabled = enabled; - app->updated = true; - trackball::setEnabled(enabled); - } - static void onTimeoutEnableSwitch(lv_event_t* e) { auto* app = static_cast(lv_event_get_user_data(e)); bool enabled = lv_obj_has_state(app->switchTimeoutEnable, LV_STATE_CHECKED); @@ -83,17 +81,16 @@ class KeyboardSettingsApp final : public App { auto* app = static_cast(lv_event_get_user_data(event)); auto* dropdown = static_cast(lv_event_get_target(event)); uint32_t idx = lv_dropdown_get_selected(dropdown); - // Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never - static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0}; - if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) { - app->kbSettings.backlightTimeoutMs = values_ms[idx]; - app->updated = true; + if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) { + app->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx]; + app->updated = true; } } public: void onShow(AppContext& app, lv_obj_t* parent) override { kbSettings = settings::keyboard::loadOrGetDefault(); + updated = false; lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); @@ -136,20 +133,6 @@ public: if (!kbSettings.backlightEnabled) lv_obj_add_state(sliderBrightness, LV_STATE_DISABLED); lv_obj_add_event_cb(sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this); - // Trackball toggle - auto* tb_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(tb_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(tb_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(tb_wrapper, 0, LV_STATE_DEFAULT); - - auto* tb_label = lv_label_create(tb_wrapper); - lv_label_set_text(tb_label, "Trackball"); - lv_obj_align(tb_label, LV_ALIGN_LEFT_MID, 0, 0); - switchTrackball = lv_switch_create(tb_wrapper); - if (kbSettings.trackballEnabled) lv_obj_add_state(switchTrackball, LV_STATE_CHECKED); - lv_obj_align(switchTrackball, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, this); - // Backlight timeout enable auto* to_enable_wrapper = lv_obj_create(main_wrapper); lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); @@ -181,20 +164,7 @@ public: lv_obj_set_style_border_width(timeoutDropdown, 1, LV_PART_MAIN); lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this); // Initialize dropdown selection from settings - uint32_t ms = kbSettings.backlightTimeoutMs; - uint32_t idx = 2; // default 1 minute - if (ms == 15000) idx = 0; - else if (ms == 30000) - idx = 1; - else if (ms == 60000) - idx = 2; - else if (ms == 120000) - idx = 3; - else if (ms == 300000) - idx = 4; - else if (ms == 0) - idx = 5; - lv_dropdown_set_selected(timeoutDropdown, idx); + lv_dropdown_set_selected(timeoutDropdown, timeoutMsToIndex(kbSettings.backlightTimeoutMs)); if (!kbSettings.backlightTimeoutEnabled) { lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED); } @@ -204,6 +174,7 @@ public: if (updated) { const auto copy = kbSettings; getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); }); + updated = false; } } }; diff --git a/Tactility/Source/app/trackball/TrackballSettings.cpp b/Tactility/Source/app/trackball/TrackballSettings.cpp new file mode 100644 index 00000000..332060e7 --- /dev/null +++ b/Tactility/Source/app/trackball/TrackballSettings.cpp @@ -0,0 +1,219 @@ +#ifdef ESP_PLATFORM + +#include + +#include +#include +#include + +#include + +// Forward declare driver functions +namespace trackball { + void setEnabled(bool enabled); + enum class Mode { Encoder, Pointer }; + void setMode(Mode mode); + void setEncoderSensitivity(uint8_t sensitivity); + void setPointerSensitivity(uint8_t sensitivity); +} + +namespace tt::app::trackballsettings { + +constexpr auto* TAG = "TrackballSettings"; + +static trackball::Mode toDriverMode(settings::trackball::TrackballMode mode) { + switch (mode) { + case settings::trackball::TrackballMode::Encoder: return trackball::Mode::Encoder; + case settings::trackball::TrackballMode::Pointer: return trackball::Mode::Pointer; + } + return trackball::Mode::Encoder; // default +} + +// Convert settings enum to dropdown index (dropdown order: Encoder=0, Pointer=1) +static uint32_t modeToDropdownIndex(settings::trackball::TrackballMode mode) { + switch (mode) { + case settings::trackball::TrackballMode::Encoder: return 0; + case settings::trackball::TrackballMode::Pointer: return 1; + } + return 0; // default to Encoder +} + +class TrackballSettingsApp final : public App { + + settings::trackball::TrackballSettings tbSettings; + bool updated = false; + lv_obj_t* switchTrackball = nullptr; + lv_obj_t* trackballModeDropdown = nullptr; + lv_obj_t* encoderSensitivitySlider = nullptr; + lv_obj_t* pointerSensitivitySlider = nullptr; + + static void onTrackballSwitch(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(app->switchTrackball, LV_STATE_CHECKED); + app->tbSettings.trackballEnabled = enabled; + app->updated = true; + trackball::setEnabled(enabled); + + // Enable/disable controls based on trackball state + if (enabled) { + if (app->trackballModeDropdown) lv_obj_clear_state(app->trackballModeDropdown, LV_STATE_DISABLED); + if (app->encoderSensitivitySlider) lv_obj_clear_state(app->encoderSensitivitySlider, LV_STATE_DISABLED); + if (app->pointerSensitivitySlider) lv_obj_clear_state(app->pointerSensitivitySlider, LV_STATE_DISABLED); + } else { + if (app->trackballModeDropdown) lv_obj_add_state(app->trackballModeDropdown, LV_STATE_DISABLED); + if (app->encoderSensitivitySlider) lv_obj_add_state(app->encoderSensitivitySlider, LV_STATE_DISABLED); + if (app->pointerSensitivitySlider) lv_obj_add_state(app->pointerSensitivitySlider, LV_STATE_DISABLED); + } + } + + static void onTrackballModeChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + uint32_t selected = lv_dropdown_get_selected(app->trackballModeDropdown); + + // Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1) + settings::trackball::TrackballMode mode; + switch (selected) { + case 0: mode = settings::trackball::TrackballMode::Encoder; break; + case 1: mode = settings::trackball::TrackballMode::Pointer; break; + default: return; // Invalid selection, ignore + } + + app->tbSettings.trackballMode = mode; + app->updated = true; + + // Apply mode change immediately + trackball::setMode(toDriverMode(mode)); + } + + static void onEncoderSensitivityChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + int32_t value = lv_slider_get_value(app->encoderSensitivitySlider); + app->tbSettings.encoderSensitivity = static_cast(value); + app->updated = true; + + // Apply immediately + trackball::setEncoderSensitivity(static_cast(value)); + } + + static void onPointerSensitivityChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + int32_t value = lv_slider_get_value(app->pointerSensitivitySlider); + app->tbSettings.pointerSensitivity = static_cast(value); + app->updated = true; + + // Apply immediately + trackball::setPointerSensitivity(static_cast(value)); + } + +public: + void onShow(AppContext& app, lv_obj_t* parent) override { + tbSettings = settings::trackball::loadOrGetDefault(); + auto ui_scale = hal::getConfiguration()->uiScale; + updated = false; + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); + + switchTrackball = lvgl::toolbar_add_switch_action(toolbar); + lv_obj_add_event_cb(switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, this); + if (tbSettings.trackballEnabled) lv_obj_add_state(switchTrackball, LV_STATE_CHECKED); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // Trackball mode dropdown + auto* tb_mode_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT); + + auto* tb_mode_label = lv_label_create(tb_mode_wrapper); + lv_label_set_text(tb_mode_label, "Mode"); + lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0); + + trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper); + lv_dropdown_set_options(trackballModeDropdown, "Encoder\nPointer"); + lv_obj_align(trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_border_color(trackballModeDropdown, lv_color_hex(0xFAFAFA), LV_PART_MAIN); + lv_obj_set_style_border_width(trackballModeDropdown, 1, LV_PART_MAIN); + lv_dropdown_set_selected(trackballModeDropdown, modeToDropdownIndex(tbSettings.trackballMode)); + lv_obj_add_event_cb(trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, this); + + // Disable dropdown if trackball is disabled + if (!tbSettings.trackballEnabled) { + lv_obj_add_state(trackballModeDropdown, LV_STATE_DISABLED); + } + + // Encoder sensitivity slider + auto* enc_sens_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT); + if (ui_scale != hal::UiScale::Smallest) { + lv_obj_set_style_pad_ver(enc_sens_wrapper, 4, LV_STATE_DEFAULT); + } + + auto* enc_sens_label = lv_label_create(enc_sens_wrapper); + lv_label_set_text(enc_sens_label, "Encoder Speed"); + lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0); + + encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper); + lv_slider_set_range(encoderSensitivitySlider, 1, 10); + lv_slider_set_value(encoderSensitivitySlider, tbSettings.encoderSensitivity, LV_ANIM_OFF); + lv_obj_set_width(encoderSensitivitySlider, LV_PCT(50)); + lv_obj_align(encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, this); + + if (!tbSettings.trackballEnabled) { + lv_obj_add_state(encoderSensitivitySlider, LV_STATE_DISABLED); + } + + // Pointer sensitivity slider + auto* ptr_sens_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); + if (ui_scale != hal::UiScale::Smallest) { + lv_obj_set_style_pad_ver(ptr_sens_wrapper, 4, LV_STATE_DEFAULT); + } + + auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper); + lv_label_set_text(ptr_sens_label, "Pointer Speed"); + lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0); + + pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper); + lv_slider_set_range(pointerSensitivitySlider, 1, 10); + lv_slider_set_value(pointerSensitivitySlider, tbSettings.pointerSensitivity, LV_ANIM_OFF); + lv_obj_set_width(pointerSensitivitySlider, LV_PCT(50)); + lv_obj_align(pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, this); + + if (!tbSettings.trackballEnabled) { + lv_obj_add_state(pointerSensitivitySlider, LV_STATE_DISABLED); + } + } + + void onHide(TT_UNUSED AppContext& app) override { + if (updated) { + const auto copy = tbSettings; + getMainDispatcher().dispatch([copy]{ settings::trackball::save(copy); }); + updated = false; + } + } +}; + +extern const AppManifest manifest = { + .appId = "TrackballSettings", + .appName = "Trackball", + .appIcon = TT_ASSETS_APP_ICON_SETTINGS, + .appCategory = Category::Settings, + .createApp = create +}; + +} + +#endif diff --git a/Tactility/Source/settings/KeyboardSettings.cpp b/Tactility/Source/settings/KeyboardSettings.cpp index 2f7a2697..af2811a6 100644 --- a/Tactility/Source/settings/KeyboardSettings.cpp +++ b/Tactility/Source/settings/KeyboardSettings.cpp @@ -9,7 +9,6 @@ namespace tt::settings::keyboard { constexpr auto* SETTINGS_FILE = "/data/settings/keyboard.properties"; constexpr auto* KEY_BACKLIGHT_ENABLED = "backlightEnabled"; constexpr auto* KEY_BACKLIGHT_BRIGHTNESS = "backlightBrightness"; -constexpr auto* KEY_TRACKBALL_ENABLED = "trackballEnabled"; constexpr auto* KEY_BACKLIGHT_TIMEOUT_ENABLED = "backlightTimeoutEnabled"; constexpr auto* KEY_BACKLIGHT_TIMEOUT_MS = "backlightTimeoutMs"; @@ -21,15 +20,13 @@ bool load(KeyboardSettings& settings) { auto bl_enabled = map.find(KEY_BACKLIGHT_ENABLED); auto bl_brightness = map.find(KEY_BACKLIGHT_BRIGHTNESS); - auto tb_enabled = map.find(KEY_TRACKBALL_ENABLED); auto bl_timeout_enabled = map.find(KEY_BACKLIGHT_TIMEOUT_ENABLED); auto bl_timeout_ms = map.find(KEY_BACKLIGHT_TIMEOUT_MS); settings.backlightEnabled = (bl_enabled != map.end()) ? (bl_enabled->second == "1" || bl_enabled->second == "true" || bl_enabled->second == "True") : true; settings.backlightBrightness = (bl_brightness != map.end()) ? static_cast(std::stoi(bl_brightness->second)) : 127; - settings.trackballEnabled = (tb_enabled != map.end()) ? (tb_enabled->second == "1" || tb_enabled->second == "true" || tb_enabled->second == "True") : true; settings.backlightTimeoutEnabled = (bl_timeout_enabled != map.end()) ? (bl_timeout_enabled->second == "1" || bl_timeout_enabled->second == "true" || bl_timeout_enabled->second == "True") : true; - settings.backlightTimeoutMs = (bl_timeout_ms != map.end()) ? static_cast(std::stoul(bl_timeout_ms->second)) : 30000; // Default 30 seconds + settings.backlightTimeoutMs = (bl_timeout_ms != map.end()) ? static_cast(std::stoul(bl_timeout_ms->second)) : 60000; // Default 60 seconds return true; } @@ -38,7 +35,6 @@ KeyboardSettings getDefault() { return KeyboardSettings{ .backlightEnabled = true, .backlightBrightness = 127, - .trackballEnabled = true, .backlightTimeoutEnabled = true, .backlightTimeoutMs = 60000 // 60 seconds default }; @@ -56,7 +52,6 @@ bool save(const KeyboardSettings& settings) { std::map map; map[KEY_BACKLIGHT_ENABLED] = settings.backlightEnabled ? "1" : "0"; map[KEY_BACKLIGHT_BRIGHTNESS] = std::to_string(settings.backlightBrightness); - map[KEY_TRACKBALL_ENABLED] = settings.trackballEnabled ? "1" : "0"; map[KEY_BACKLIGHT_TIMEOUT_ENABLED] = settings.backlightTimeoutEnabled ? "1" : "0"; map[KEY_BACKLIGHT_TIMEOUT_MS] = std::to_string(settings.backlightTimeoutMs); return file::savePropertiesFile(SETTINGS_FILE, map); diff --git a/Tactility/Source/settings/TrackballSettings.cpp b/Tactility/Source/settings/TrackballSettings.cpp new file mode 100644 index 00000000..7478b948 --- /dev/null +++ b/Tactility/Source/settings/TrackballSettings.cpp @@ -0,0 +1,86 @@ +#include +#include + +#include +#include +#include + +namespace tt::settings::trackball { + +constexpr auto* SETTINGS_FILE = "/data/settings/trackball.properties"; +constexpr auto* KEY_TRACKBALL_ENABLED = "trackballEnabled"; +constexpr auto* KEY_TRACKBALL_MODE = "trackballMode"; +constexpr auto* KEY_ENCODER_SENSITIVITY = "encoderSensitivity"; +constexpr auto* KEY_POINTER_SENSITIVITY = "pointerSensitivity"; + +constexpr uint8_t MIN_ENCODER_SENSITIVITY = 1; +constexpr uint8_t MAX_ENCODER_SENSITIVITY = 10; +constexpr uint8_t MIN_POINTER_SENSITIVITY = 1; +constexpr uint8_t MAX_POINTER_SENSITIVITY = 10; + +bool load(TrackballSettings& settings) { + std::map map; + if (!file::loadPropertiesFile(SETTINGS_FILE, map)) { + return false; + } + + auto tb_enabled = map.find(KEY_TRACKBALL_ENABLED); + auto tb_mode = map.find(KEY_TRACKBALL_MODE); + auto enc_sens = map.find(KEY_ENCODER_SENSITIVITY); + auto ptr_sens = map.find(KEY_POINTER_SENSITIVITY); + + // Safe integer parsing without exceptions + auto safeParseUint8 = [](const std::string& str, uint8_t defaultVal) -> uint8_t { + if (str.empty()) return defaultVal; + unsigned int val = 0; + for (char c : str) { + if (c < '0' || c > '9') return defaultVal; + if (val > 25) return defaultVal; // Early exit: val*10+9 would exceed 255 + val = val * 10 + (c - '0'); + if (val > 255) return defaultVal; + } + return static_cast(val); + }; + + auto isTrueValue = [](const std::string& s) { + return s == "1" || s == "true" || s == "True" || s == "TRUE"; + }; + settings.trackballEnabled = (tb_enabled != map.end()) ? isTrueValue(tb_enabled->second) : true; + settings.trackballMode = (tb_mode != map.end() && tb_mode->second == "1") ? TrackballMode::Pointer : TrackballMode::Encoder; + settings.encoderSensitivity = (enc_sens != map.end()) ? safeParseUint8(enc_sens->second, MIN_ENCODER_SENSITIVITY) : MIN_ENCODER_SENSITIVITY; + settings.pointerSensitivity = (ptr_sens != map.end()) ? safeParseUint8(ptr_sens->second, MAX_POINTER_SENSITIVITY) : MAX_POINTER_SENSITIVITY; + + // Clamp values to valid ranges + settings.encoderSensitivity = std::clamp(settings.encoderSensitivity, MIN_ENCODER_SENSITIVITY, MAX_ENCODER_SENSITIVITY); + settings.pointerSensitivity = std::clamp(settings.pointerSensitivity, MIN_POINTER_SENSITIVITY, MAX_POINTER_SENSITIVITY); + + return true; +} + +TrackballSettings getDefault() { + return TrackballSettings{ + .trackballEnabled = true, + .trackballMode = TrackballMode::Encoder, + .encoderSensitivity = MIN_ENCODER_SENSITIVITY, + .pointerSensitivity = MAX_POINTER_SENSITIVITY + }; +} + +TrackballSettings loadOrGetDefault() { + TrackballSettings s; + if (!load(s)) { + s = getDefault(); + } + return s; +} + +bool save(const TrackballSettings& settings) { + std::map map; + map[KEY_TRACKBALL_ENABLED] = settings.trackballEnabled ? "1" : "0"; + map[KEY_TRACKBALL_MODE] = (settings.trackballMode == TrackballMode::Pointer) ? "1" : "0"; + map[KEY_ENCODER_SENSITIVITY] = std::to_string(std::clamp(settings.encoderSensitivity, MIN_ENCODER_SENSITIVITY, MAX_ENCODER_SENSITIVITY)); + map[KEY_POINTER_SENSITIVITY] = std::to_string(std::clamp(settings.pointerSensitivity, MIN_POINTER_SENSITIVITY, MAX_POINTER_SENSITIVITY)); + return file::savePropertiesFile(SETTINGS_FILE, map); +} + +}